package main import ( "context" "fmt" "net/http" "os" "strings" "github.com/flowy-live/llink/internal/utils/flog" "cloud.google.com/go/firestore" "cloud.google.com/go/storage" firebase "firebase.google.com/go/v4" "github.com/redis/go-redis/v9" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" pbaero "github.com/flowy-live/llink/genproto/aero" "github.com/flowy-live/llink/internal" "github.com/flowy-live/llink/internal/auth" "github.com/flowy-live/llink/internal/billing" "github.com/flowy-live/llink/internal/db" "github.com/flowy-live/llink/internal/depot" "github.com/flowy-live/llink/internal/handler" "github.com/flowy-live/llink/internal/human" "github.com/flowy-live/llink/internal/human/pushnotify" "github.com/flowy-live/llink/internal/livekit" "github.com/flowy-live/llink/internal/livestore" "github.com/flowy-live/llink/internal/middleware" "github.com/flowy-live/llink/internal/network" "github.com/flowy-live/llink/internal/particle" "github.com/flowy-live/llink/internal/utils" "github.com/flowy-live/llink/internal/waitlist" ) func redisForAuth() *redis.Client { return internal.ConnectAndTestRedis(db.RedisDBAuth) } func main() { port := utils.MustGetEnv("PORT") gcsBucket := utils.MustGetEnv("GCS_BUCKET") db.Init() defer db.Cleanup() redisClient := redisForAuth() ctx := context.Background() storageClient, err := storage.NewClient(ctx) if err != nil { flog.Error("failed to create GCS client", "error", err) os.Exit(1) } defer storageClient.Close() aeroAddr := utils.MustGetEnv("AERO_ADDR") if aeroAddr == "" { flog.Error("must provide AERO_ADDR") os.Exit(1) } aeroServer, err := grpc.NewClient(aeroAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { flog.Error("connection to aero server invalid", "error", err) os.Exit(1) } defer aeroServer.Close() aeroSvc := pbaero.NewPrimaryClient(aeroServer) gcpProject := utils.MustGetEnv("GCP_PROJECT") fbApp, err := firebase.NewApp(ctx, &firebase.Config{ProjectID: gcpProject}) if err != nil { flog.Error("failed to init Firebase Admin app", "error", err) os.Exit(1) } fbAuth, err := fbApp.Auth(ctx) if err != nil { flog.Error("failed to create Firebase auth client", "error", err) os.Exit(1) } authSvc := auth.NewAuthService(redisClient, aeroSvc, fbAuth) humanSvc := human.NewService(db.Pool()) billingSvc, err := billing.NewService(ctx, db.Pool(), billing.Config{ SecretKey: utils.MustGetEnv("STRIPE_SECRET_KEY"), WebhookSecret: utils.MustGetEnv("STRIPE_WEBHOOK_SECRET"), PriceMonthlyID: utils.MustGetEnv("STRIPE_PRICE_PRO_MONTHLY"), PriceAnnualID: utils.MustGetEnv("STRIPE_PRICE_PRO_ANNUAL"), SuccessURL: utils.MustGetEnv("BILLING_SUCCESS_URL"), CancelURL: utils.MustGetEnv("BILLING_CANCEL_URL"), }) if err != nil { flog.Error("failed to initialize billing service", "error", err) os.Exit(1) } firestoreClient, err := firestore.NewClient(ctx, gcpProject) if err != nil { flog.Error("failed to create Firestore client", "error", err) os.Exit(1) } defer firestoreClient.Close() networkSvc := network.NewService(db.Pool(), aeroSvc, billingSvc, livestore.NewMembershipPublisher(firestoreClient), humanSvc) particleSvc := particle.NewService(db.Pool(), networkSvc) depotSvc := depot.NewService(db.Pool(), storageClient, depot.Config{ GoogleServiceAccountEmail: utils.MustGetEnv("GOOGLE_SERVICE_ACCOUNT_EMAIL"), BucketName: gcsBucket, }) waitlistSvc := waitlist.NewService(db.Pool(), aeroSvc) pushTokenSvc := pushnotify.NewService(db.Pool()) livekitClient := livekit.NewClient() h := handler.NewHandler(authSvc, humanSvc, networkSvc, particleSvc, depotSvc, waitlistSvc, billingSvc, pushTokenSvc, livekitClient, firestoreClient) withAuth := func(hf http.HandlerFunc) http.Handler { return middleware.Auth(authSvc)(http.HandlerFunc(hf)) } mux := http.NewServeMux() // ========================================================================== // Public routes (no auth) // ========================================================================== mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) mux.HandleFunc("POST /auth/request-code", h.RequestSignInCode) mux.HandleFunc("POST /auth/sign-in", h.SignIn) mux.HandleFunc("POST /waitlist", h.AddToWaitlist) mux.HandleFunc("POST /livekit/webhook", h.HandleLivekitWebhook) mux.HandleFunc("POST /webhooks/stripe", h.HandleStripeWebhook) // ========================================================================== // Protected routes (auth required) // ========================================================================== // Auth mux.Handle("POST /auth/sign-out", withAuth(h.SignOut)) mux.Handle("GET /auth/me", withAuth(h.GetCurrentHuman)) mux.Handle("POST /auth/firebase-token", withAuth(h.FirebaseToken)) // Settings mux.Handle("PATCH /humans/me/settings", withAuth(h.UpdateSettings)) mux.Handle("PUT /humans/me/avatar", withAuth(h.UpdateAvatar)) mux.Handle("DELETE /humans/me/avatar", withAuth(h.DeleteAvatar)) // Get avatar download url, given objectId mux.Handle("GET /humans/avatar/{id}", withAuth(h.GetObjectDownloadUrl)) // Push notification tokens (per-device) mux.Handle("POST /humans/me/push-tokens", withAuth(h.RegisterPushToken)) mux.Handle("DELETE /humans/me/push-tokens", withAuth(h.UnregisterPushToken)) // Networks mux.Handle("POST /networks", withAuth(h.CreateNetwork)) mux.Handle("GET /networks", withAuth(h.ListNetworks)) mux.Handle("GET /networks/{id}", withAuth(h.GetNetwork)) mux.Handle("POST /networks/{id}/members", withAuth(h.AddMembersToNetwork)) mux.Handle("DELETE /networks/{id}/members/{humanId}", withAuth(h.RemoveMemberFromNetwork)) // Billing (network admin only; admin check happens inside each handler) mux.Handle("GET /networks/{id}/billing", withAuth(h.GetNetworkBilling)) mux.Handle("POST /networks/{id}/billing/checkout-session", withAuth(h.CreateCheckoutSession)) mux.Handle("POST /networks/{id}/billing/portal-session", withAuth(h.CreatePortalSession)) // Freemium usage (any network member) mux.Handle("GET /networks/{id}/usage", withAuth(h.GetNetworkUsage)) // Network Invitations mux.Handle("GET /networks/{id}/invitations", withAuth(h.ListInvitationsForNetwork)) mux.Handle("DELETE /networks/{id}/invitations", withAuth(h.RevokeInvitation)) mux.Handle("GET /invitations", withAuth(h.ListMyInvitations)) mux.Handle("POST /invitations/accept", withAuth(h.AcceptInvitation)) // Particles mux.Handle("GET /particles/{id}/download", withAuth(h.GetObjectDownloadUrl)) // Link metadata mux.Handle("GET /metadata", withAuth(h.GetLinkMetadata)) // Depot mux.Handle("POST /depot/upload", withAuth(h.PrepareUpload)) mux.Handle("POST /depot/objects/{id}/confirm", withAuth(h.ConfirmUpload)) // LiveKit mux.Handle("POST /livekit/token", withAuth(h.GetLivekitToken)) // Waitlist (admin-only) mux.Handle("GET /waitlist", withAuth(h.GetWaitlist)) mux.Handle("GET /waitlist/{email}", withAuth(h.GetWaitlistEntry)) mux.Handle("POST /waitlist/invite", withAuth(h.InviteWaitlistEntrant)) // CORS_ALLOWED_ORIGINS is a comma-separated whitelist for the web client. // Empty / unset = allow all var allowedOrigins []string if raw := os.Getenv("CORS_ALLOWED_ORIGINS"); raw != "" { for _, o := range strings.Split(raw, ",") { if o = strings.TrimSpace(o); o != "" { allowedOrigins = append(allowedOrigins, o) } } } muxWithCors := middleware.CORS(allowedOrigins)(mux) addr := fmt.Sprintf("0.0.0.0:%s", port) flog.Info("running server", "addr", addr) if err := http.ListenAndServe(addr, muxWithCors); err != nil { flog.Error("server failed", "error", err) os.Exit(1) } }