package handler import ( "context" "encoding/json" "errors" "fmt" "net/http" "strings" "time" "github.com/flowy-live/llink/internal/utils/flog" "cloud.google.com/go/firestore" "github.com/livekit/protocol/webhook" "github.com/flowy-live/llink/internal/auth" "github.com/flowy-live/llink/internal/billing" "github.com/flowy-live/llink/internal/depot" "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/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" ) type Handler struct { authSvc auth.AuthService humanSvc human.Service networkSvc network.Service particleSvc particle.Service depotSvc depot.Service waitlistSvc waitlist.Service billingSvc billing.Service pushTokenSvc pushnotify.Service livekitClient livekit.Client firestoreClient *firestore.Client } func NewHandler( authSvc auth.AuthService, humanSvc human.Service, networkSvc network.Service, particleSvc particle.Service, depotSvc depot.Service, waitlistSvc waitlist.Service, billingSvc billing.Service, pushTokenSvc pushnotify.Service, livekitClient livekit.Client, firestoreClient *firestore.Client, ) *Handler { return &Handler{ authSvc: authSvc, humanSvc: humanSvc, networkSvc: networkSvc, particleSvc: particleSvc, depotSvc: depotSvc, waitlistSvc: waitlistSvc, billingSvc: billingSvc, pushTokenSvc: pushTokenSvc, livekitClient: livekitClient, firestoreClient: firestoreClient, } } // Response DTOs type Human struct { Id string `json:"id"` Email string `json:"email"` EmailPrefix string `json:"email_prefix"` EmailNotificationsEnabled bool `json:"email_notifications_enabled"` AvatarObjectID *string `json:"avatar_object_id"` CreatedAt time.Time `json:"created_at"` } type Network struct { Id string `json:"id"` Name string `json:"name"` AdminHuman Human `json:"admin_human"` Humans []Human `json:"humans"` CreatedAt time.Time `json:"created_at"` } // Auth Request/Response DTOs type RequestSignInCodeRequest struct { Email string `json:"email"` } type SignInRequest struct { Email string `json:"email"` Code string `json:"code"` } type SignInResponse struct { Human Human `json:"human"` Token string `json:"token"` } type FirebaseTokenResponse struct { Token string `json:"token"` } // Network Request DTOs type CreateNetworkRequest struct { Name string `json:"name"` } type AddMembersToNetworkRequest struct { EmailAddresses []string `json:"email_addresses"` } type MembersRequest struct { Emails []string `json:"emails"` } type Invitation struct { NetworkId string `json:"network_id"` NetworkName string `json:"network_name"` Email string `json:"email"` CreatedAt time.Time `json:"created_at"` } type AcceptInvitationRequest struct { NetworkId string `json:"network_id"` } type RevokeInvitationRequest struct { Email string `json:"email"` } // LiveKit DTOs type GetLivekitTokenRequest struct { NetworkId string `json:"network_id"` StreamId string `json:"stream_id"` } type GetLivekitTokenResponse struct { Token string `json:"token"` ServerUrl string `json:"server_url"` } // Depot DTOs type PrepareUploadRequest struct { NetworkId string `json:"network_id"` Name string `json:"name"` ContentType string `json:"content_type"` ContentLength int64 `json:"content_length"` } type PrepareUploadResponse struct { ObjectID string `json:"object_id"` UploadURL string `json:"upload_url"` UploadHeaders map[string]string `json:"upload_headers"` } type DepotObject struct { ID string `json:"id"` Name string `json:"name"` ContentType string `json:"content_type"` ContentLength int64 `json:"content_length"` ContainsContent bool `json:"contains_content"` DownloadURL string `json:"download_url,omitempty"` CreatedAt time.Time `json:"created_at"` } // ============================================================================ // Auth Handlers // ============================================================================ // RequestSignInCode auto-creates the human if missing, then emails a one-time code. func (h *Handler) RequestSignInCode(w http.ResponseWriter, r *http.Request) { var req RequestSignInCodeRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request body", http.StatusBadRequest) return } if req.Email == "" { http.Error(w, "email is required", http.StatusBadRequest) return } _, err := h.humanSvc.GetOrCreateByEmail(r.Context(), req.Email) if err != nil { flog.Error("failed to get or create human", "error", err, "email", req.Email) http.Error(w, "internal server error", http.StatusInternalServerError) return } if err := h.authSvc.RequestSignInCode(r.Context(), req.Email); err != nil { flog.Error("failed to request sign-in code", "error", err, "email", req.Email) http.Error(w, "internal server error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) SignIn(w http.ResponseWriter, r *http.Request) { var req SignInRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request body", http.StatusBadRequest) return } if req.Email == "" || req.Code == "" { http.Error(w, "email and code are required", http.StatusBadRequest) return } // humanId is captured into the session so later requests don't re-resolve email → id. hum, err := h.humanSvc.GetByEmail(r.Context(), req.Email) if err != nil { if errors.Is(err, human.ErrNotFound) { http.Error(w, "human not found", http.StatusNotFound) return } flog.Error("failed to get human for sign-in", "error", err, "email", req.Email) http.Error(w, "internal server error", http.StatusInternalServerError) return } token, err := h.authSvc.VerifySignInCode(r.Context(), req.Email, req.Code, hum.ID) if err != nil { if errors.Is(err, auth.ErrInvalidCode) { http.Error(w, "invalid code", http.StatusUnauthorized) return } flog.Error("failed to verify sign-in code", "error", err, "email", req.Email) http.Error(w, "internal server error", http.StatusInternalServerError) return } resp := SignInResponse{ Human: humanToDTO(hum), Token: token, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } // FirebaseToken mints a custom token so the client can signInWithCustomToken // and have request.auth.uid populated in Firestore security rules. func (h *Handler) FirebaseToken(w http.ResponseWriter, r *http.Request) { humanId, ok := middleware.HumanIdFromContext(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } token, err := h.authSvc.MintFirebaseCustomToken(r.Context(), humanId) if err != nil { flog.Error("failed to mint Firebase custom token", "error", err, "humanId", humanId) http.Error(w, "internal server error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(FirebaseTokenResponse{Token: token}) } func (h *Handler) SignOut(w http.ResponseWriter, r *http.Request) { token := extractBearerToken(r) if token == "" { http.Error(w, "unauthorized", http.StatusUnauthorized) return } if err := h.authSvc.SignOut(r.Context(), token); err != nil { flog.Error("failed to sign out", "error", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) GetCurrentHuman(w http.ResponseWriter, r *http.Request) { email, ok := middleware.EmailFromContext(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } hum, err := h.humanSvc.GetByEmail(r.Context(), email) if err != nil { if errors.Is(err, human.ErrNotFound) { http.Error(w, "human not found", http.StatusNotFound) return } flog.Error("failed to get current human", "error", err, "email", email) http.Error(w, "internal server error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") dto := humanToDTO(hum) json.NewEncoder(w).Encode(dto) } type UpdateSettingsRequest struct { EmailNotificationsEnabled *bool `json:"email_notifications_enabled"` } func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) { humanId, ok := middleware.HumanIdFromContext(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } var req UpdateSettingsRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request body", http.StatusBadRequest) return } if req.EmailNotificationsEnabled != nil { if err := h.humanSvc.UpdateEmailNotificationsEnabled(r.Context(), humanId, *req.EmailNotificationsEnabled); err != nil { flog.Error("failed to update email notifications setting", "error", err, "humanId", humanId) http.Error(w, "internal server error", http.StatusInternalServerError) return } } w.WriteHeader(http.StatusNoContent) } func (h *Handler) DeleteAvatar(w http.ResponseWriter, r *http.Request) { humanId, ok := middleware.HumanIdFromContext(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } human, err := h.humanSvc.GetByID(r.Context(), humanId) if err != nil { flog.Error("failed to get human by id", "error", err, "humanId", humanId) http.Error(w, "internal server error", http.StatusInternalServerError) return } err = h.humanSvc.DeleteAvatar(r.Context(), humanId) if err != nil { flog.Error("failed to delete avatar from human", "error", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if human.AvatarObjectID != nil { err = h.depotSvc.Delete(r.Context(), utils.OptionalString(human.AvatarObjectID)) if err != nil { flog.Error("failed to delete object", "error", err, "objectID", human.AvatarObjectID) } } w.WriteHeader(http.StatusNoContent) return } func (h *Handler) UpdateAvatar(w http.ResponseWriter, r *http.Request) { humanId, ok := middleware.HumanIdFromContext(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } // 5MB limit = 5 * 1024 * 1024 bytes const maxBodySize = 5 << 20 r.Body = http.MaxBytesReader(w, r.Body, maxBodySize) object, err := h.depotSvc.CreateFromReader(r.Context(), depot.CreateFromReaderInput{ Prefix: "avatars", Name: fmt.Sprintf("%s-avatar", humanId), ContentType: r.Header.Get("Content-Type"), }, r.Body) if err != nil { var maxErr *http.MaxBytesError if errors.As(err, &maxErr) { http.Error(w, "avatar file too large", http.StatusRequestEntityTooLarge) return } flog.Error("failed to upload avatar with depo", "error", err, "humanId", humanId) http.Error(w, "internal server error", http.StatusInternalServerError) return } err = h.humanSvc.UpdateAvatar(r.Context(), humanId, object.ID) if err != nil { flog.Error("failed to update human avatar", "error", err, "humanId", humanId) http.Error(w, "internal server error", http.StatusInternalServerError) // best effort err = h.depotSvc.Delete(r.Context(), object.ID) if err != nil { flog.Error("best-effort delete of object failed", "error", err) } return } w.WriteHeader(http.StatusNoContent) } // ============================================================================ // Network Handlers // ============================================================================ func (h *Handler) CreateNetwork(w http.ResponseWriter, r *http.Request) { humanId, ok := middleware.HumanIdFromContext(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } var req CreateNetworkRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request body", http.StatusBadRequest) return } net, err := h.networkSvc.Create(r.Context(), req.Name, humanId) if err != nil { if errors.Is(err, network.ErrInvalidName) { http.Error(w, "name cannot be empty", http.StatusBadRequest) return } flog.Error("failed to create network", "error", err, "humanId", humanId, "name", req.Name) http.Error(w, "internal server error", http.StatusInternalServerError) return } resp, err := h.networkToDTO(r.Context(), net) if err != nil { flog.Error("failed to convert network to DTO", "error", err, "network_id", net.ID) http.Error(w, "internal server error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(resp) } func (h *Handler) ListNetworks(w http.ResponseWriter, r *http.Request) { humanId, ok := middleware.HumanIdFromContext(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } networks, err := h.networkSvc.ListForHuman(r.Context(), humanId) if err != nil { flog.Error("failed to list networks", "error", err, "humanId", humanId) http.Error(w, "internal server error", http.StatusInternalServerError) return } resp := make([]Network, 0, len(networks)) for _, net := range networks { dto, err := h.networkToDTO(r.Context(), net) if err != nil { flog.Warn("failed to convert network to DTO in list", "error", err, "network_id", net.ID) continue } resp = append(resp, dto) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } func (h *Handler) GetNetwork(w http.ResponseWriter, r *http.Request) { humanId, ok := middleware.HumanIdFromContext(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } networkID := r.PathValue("id") if networkID == "" { http.Error(w, "network id is required", http.StatusBadRequest) return } isMember, err := h.networkSvc.IsMember(r.Context(), networkID, humanId) if err != nil { flog.Error("failed to check network membership", "error", err, "network_id", networkID, "humanId", humanId) http.Error(w, "internal server error", http.StatusInternalServerError) return } if !isMember { http.Error(w, "access denied", http.StatusForbidden) return } net, err := h.networkSvc.GetByID(r.Context(), networkID) if err != nil { if errors.Is(err, network.ErrNotFound) { http.Error(w, "network not found", http.StatusNotFound) return } flog.Error("failed to get network", "error", err, "network_id", networkID) http.Error(w, "internal server error", http.StatusInternalServerError) return } resp, err := h.networkToDTO(r.Context(), net) if err != nil { flog.Error("failed to convert network to DTO", "error", err, "network_id", networkID) http.Error(w, "internal server error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } // AddMembersToNetwork routes registered users into membership and emails an // invitation to the rest. func (h *Handler) AddMembersToNetwork(w http.ResponseWriter, r *http.Request) { humanId, ok := middleware.HumanIdFromContext(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } networkID := r.PathValue("id") if networkID == "" { http.Error(w, "network id is required", http.StatusBadRequest) return } isMember, err := h.networkSvc.IsMember(r.Context(), networkID, humanId) if err != nil { flog.Error("failed to check network membership", "error", err, "network_id", networkID, "humanId", humanId) http.Error(w, "internal server error", http.StatusInternalServerError) return } if !isMember { http.Error(w, "access denied", http.StatusForbidden) return } var req AddMembersToNetworkRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request body", http.StatusBadRequest) return } if len(req.EmailAddresses) == 0 { http.Error(w, "email addresses are required", http.StatusBadRequest) return } var memberHumanIds []string var inviteEmails []string for _, email := range req.EmailAddresses { normalized, err := utils.NormalizeEmail(email) if err != nil { http.Error(w, "invalid email: "+email, http.StatusBadRequest) return } hum, err := h.humanSvc.GetByEmail(r.Context(), normalized) if err != nil { if errors.Is(err, human.ErrNotFound) { inviteEmails = append(inviteEmails, normalized) continue } flog.Error("failed to look up human by email", "error", err, "email", normalized) http.Error(w, "internal server error", http.StatusInternalServerError) return } memberHumanIds = append(memberHumanIds, hum.ID) } if len(memberHumanIds) > 0 { if err := h.networkSvc.AddMembers(r.Context(), networkID, memberHumanIds); err != nil { flog.Error("failed to add members to network", "error", err, "network_id", networkID) http.Error(w, "internal server error", http.StatusInternalServerError) return } } if len(inviteEmails) > 0 { if err := h.networkSvc.InviteByEmail(r.Context(), networkID, inviteEmails); err != nil { flog.Error("failed to invite members to network", "error", err, "network_id", networkID) http.Error(w, "internal server error", http.StatusInternalServerError) return } } net, err := h.networkSvc.GetByID(r.Context(), networkID) if err != nil { flog.Error("failed to get network after adding members", "error", err, "network_id", networkID) http.Error(w, "internal server error", http.StatusInternalServerError) return } resp, err := h.networkToDTO(r.Context(), net) if err != nil { flog.Error("failed to convert network to DTO", "error", err, "network_id", networkID) http.Error(w, "internal server error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } // RemoveMemberFromNetwork is admin-only. Admins cannot remove themselves // (would orphan networks.admin_human_id); removing a non-member is a no-op (204). func (h *Handler) RemoveMemberFromNetwork(w http.ResponseWriter, r *http.Request) { net, _, ok := h.loadNetworkForAdmin(w, r) if !ok { return } memberHumanId := r.PathValue("humanId") if memberHumanId == "" { http.Error(w, "member humanId is required", http.StatusBadRequest) return } if memberHumanId == net.AdminHumanId { http.Error(w, "admin cannot remove themselves", http.StatusConflict) return } if err := h.networkSvc.RemoveMember(r.Context(), net.ID, memberHumanId); err != nil { flog.Error("failed to remove member from network", "error", err, "network_id", net.ID, "memberHumanId", memberHumanId) http.Error(w, "internal server error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) ListInvitationsForNetwork(w http.ResponseWriter, r *http.Request) { humanId, ok := middleware.HumanIdFromContext(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } networkID := r.PathValue("id") if networkID == "" { http.Error(w, "network id is required", http.StatusBadRequest) return } isMember, err := h.networkSvc.IsMember(r.Context(), networkID, humanId) if err != nil { flog.Error("failed to check network membership", "error", err, "network_id", networkID, "humanId", humanId) http.Error(w, "internal server error", http.StatusInternalServerError) return } if !isMember { http.Error(w, "access denied", http.StatusForbidden) return } invitations, err := h.networkSvc.ListInvitationsForNetwork(r.Context(), networkID) if err != nil { flog.Error("failed to list invitations", "error", err, "network_id", networkID) http.Error(w, "internal server error", http.StatusInternalServerError) return } resp := make([]Invitation, 0, len(invitations)) for _, inv := range invitations { resp = append(resp, Invitation{ NetworkId: inv.NetworkID, NetworkName: inv.NetworkName, Email: inv.Email, CreatedAt: inv.CreatedAt, }) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } func (h *Handler) ListMyInvitations(w http.ResponseWriter, r *http.Request) { email, ok := middleware.EmailFromContext(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } invitations, err := h.networkSvc.ListInvitationsForEmail(r.Context(), email) if err != nil { flog.Error("failed to list invitations for email", "error", err, "email", email) http.Error(w, "internal server error", http.StatusInternalServerError) return } resp := make([]Invitation, 0, len(invitations)) for _, inv := range invitations { resp = append(resp, Invitation{ NetworkId: inv.NetworkID, NetworkName: inv.NetworkName, Email: inv.Email, CreatedAt: inv.CreatedAt, }) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } func (h *Handler) AcceptInvitation(w http.ResponseWriter, r *http.Request) { email, ok := middleware.EmailFromContext(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } humanId, ok := middleware.HumanIdFromContext(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } var req AcceptInvitationRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request body", http.StatusBadRequest) return } if req.NetworkId == "" { http.Error(w, "network_id is required", http.StatusBadRequest) return } if err := h.networkSvc.AcceptInvitation(r.Context(), req.NetworkId, email, humanId); err != nil { flog.Error("failed to accept invitation", "error", err, "network_id", req.NetworkId, "email", email) http.Error(w, "internal server error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) RevokeInvitation(w http.ResponseWriter, r *http.Request) { humanId, ok := middleware.HumanIdFromContext(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } networkID := r.PathValue("id") if networkID == "" { http.Error(w, "network id is required", http.StatusBadRequest) return } isMember, err := h.networkSvc.IsMember(r.Context(), networkID, humanId) if err != nil { flog.Error("failed to check network membership", "error", err, "network_id", networkID, "humanId", humanId) http.Error(w, "internal server error", http.StatusInternalServerError) return } if !isMember { http.Error(w, "access denied", http.StatusForbidden) return } var req RevokeInvitationRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request body", http.StatusBadRequest) return } if req.Email == "" { http.Error(w, "email is required", http.StatusBadRequest) return } if err := h.networkSvc.RevokeInvitation(r.Context(), networkID, req.Email); err != nil { flog.Error("failed to revoke invitation", "error", err, "network_id", networkID, "email", req.Email) http.Error(w, "internal server error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // GetObjectDownloadUrl returns a fresh signed URL for media/file particles. func (h *Handler) GetObjectDownloadUrl(w http.ResponseWriter, r *http.Request) { _, ok := middleware.EmailFromContext(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } // TODO: integrate firebase to fetch particle, and verify visibility for this particle's media objectID := r.PathValue("id") downloadURL, err := h.depotSvc.GetDownloadURL(r.Context(), objectID) if err != nil { flog.Error("failed to get download URL", "error", err, "object_id", objectID) http.Error(w, "internal server error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"url": downloadURL}) } // ============================================================================ // Depot Handlers // ============================================================================ // PrepareUpload returns a signed URL for direct upload to GCS. func (h *Handler) PrepareUpload(w http.ResponseWriter, r *http.Request) { humanId, ok := middleware.HumanIdFromContext(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } var req PrepareUploadRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request body", http.StatusBadRequest) return } if req.NetworkId == "" { http.Error(w, "network_id is required", http.StatusBadRequest) return } isMember, err := h.networkSvc.IsMember(r.Context(), req.NetworkId, humanId) if err != nil { flog.Error("failed to check network membership", "error", err, "network_id", req.NetworkId, "humanId", humanId) http.Error(w, "internal server error", http.StatusInternalServerError) return } if !isMember { http.Error(w, "access denied", http.StatusForbidden) return } input := depot.PrepareUploadInput{ Prefix: req.NetworkId, Name: req.Name, ContentType: req.ContentType, ContentLength: req.ContentLength, } result, err := h.depotSvc.PrepareUpload(r.Context(), input) if err != nil { if errors.Is(err, depot.ErrInvalidInput) { http.Error(w, err.Error(), http.StatusBadRequest) return } flog.Error("failed to prepare upload", "error", err, "network_id", req.NetworkId, "name", req.Name) http.Error(w, "internal server error", http.StatusInternalServerError) return } resp := PrepareUploadResponse{ ObjectID: result.ObjectID, UploadURL: result.UploadURL, UploadHeaders: result.UploadHeaders, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } func (h *Handler) ConfirmUpload(w http.ResponseWriter, r *http.Request) { _, ok := middleware.EmailFromContext(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } objectID := r.PathValue("id") if objectID == "" { http.Error(w, "object id is required", http.StatusBadRequest) return } obj, err := h.depotSvc.ConfirmUpload(r.Context(), objectID) if err != nil { if errors.Is(err, depot.ErrNotFound) { http.Error(w, "object not found", http.StatusNotFound) return } if errors.Is(err, depot.ErrInvalidInput) { http.Error(w, err.Error(), http.StatusBadRequest) return } flog.Error("failed to confirm upload", "error", err, "object_id", objectID) http.Error(w, "internal server error", http.StatusInternalServerError) return } resp := DepotObject{ ID: obj.ID, Name: obj.Name, ContentType: obj.ContentType, ContentLength: obj.ContentLength, ContainsContent: obj.ContainsContent, DownloadURL: "", CreatedAt: obj.CreatedAt, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } // ============================================================================ // Waitlist DTOs // ============================================================================ type AddToWaitlistRequest struct { Email string `json:"email"` Metadata map[string]string `json:"metadata"` } type WaitlistEntryResponse struct { Id int `json:"id"` Email string `json:"email"` Metadata map[string]string `json:"metadata"` CreatedAt time.Time `json:"created_at"` InvitedAt *time.Time `json:"invited_at,omitempty"` } type InviteWaitlistEntrantRequest struct { Email string `json:"email"` } // ============================================================================ // Waitlist Handlers // ============================================================================ // AddToWaitlist is public — no auth required. func (h *Handler) AddToWaitlist(w http.ResponseWriter, r *http.Request) { var req AddToWaitlistRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request body", http.StatusBadRequest) return } if req.Email == "" { http.Error(w, "email is required", http.StatusBadRequest) return } err := h.waitlistSvc.AddToWaitlist(r.Context(), req.Email, req.Metadata) if err != nil { if errors.Is(err, waitlist.AlreadyInWaitlistError) { http.Error(w, "already in the waitlist", http.StatusConflict) return } flog.Error("failed to add to waitlist", "error", err, "email", req.Email) http.Error(w, "internal server error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusCreated) } // GetWaitlist is admin-only. func (h *Handler) GetWaitlist(w http.ResponseWriter, r *http.Request) { if !middleware.IsAdminFromContext(r.Context()) { http.Error(w, "forbidden", http.StatusForbidden) return } filterParam := r.URL.Query().Get("filter") filter := waitlist.GetWaitlistFilterAll switch filterParam { case "invited": filter = waitlist.GetWaitlistFilterInvitedOnly case "uninvited": filter = waitlist.GetWaitlistFilterUninvitedOnly } entries, err := h.waitlistSvc.GetWaitlist(r.Context(), filter) if err != nil { flog.Error("failed to get waitlist", "error", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } resp := make([]WaitlistEntryResponse, 0, len(entries)) for _, e := range entries { resp = append(resp, waitlistEntryToDTO(e)) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } // GetWaitlistEntry is admin-only. func (h *Handler) GetWaitlistEntry(w http.ResponseWriter, r *http.Request) { if !middleware.IsAdminFromContext(r.Context()) { http.Error(w, "forbidden", http.StatusForbidden) return } email := r.PathValue("email") if email == "" { http.Error(w, "email is required", http.StatusBadRequest) return } entry, err := h.waitlistSvc.GetWaitlistEntryByEmail(r.Context(), email) if err != nil { if errors.Is(err, waitlist.EntryNotFoundError) { http.Error(w, "entry not found", http.StatusNotFound) return } flog.Error("failed to get waitlist entry", "error", err, "email", email) http.Error(w, "internal server error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(waitlistEntryToDTO(entry)) } // InviteWaitlistEntrant is admin-only. func (h *Handler) InviteWaitlistEntrant(w http.ResponseWriter, r *http.Request) { if !middleware.IsAdminFromContext(r.Context()) { http.Error(w, "forbidden", http.StatusForbidden) return } var req InviteWaitlistEntrantRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request body", http.StatusBadRequest) return } if req.Email == "" { http.Error(w, "email is required", http.StatusBadRequest) return } if err := h.waitlistSvc.MarkWaitlistEntryInvited(r.Context(), req.Email); err != nil { if errors.Is(err, waitlist.EntryNotFoundError) { http.Error(w, "entry not found", http.StatusNotFound) return } flog.Error("failed to invite waitlist entrant", "error", err, "email", req.Email) http.Error(w, "internal server error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } func waitlistEntryToDTO(e *waitlist.WaitlistEntry) WaitlistEntryResponse { return WaitlistEntryResponse{ Id: e.Id, Email: e.Email, Metadata: e.Metadata, CreatedAt: e.CreatedAt, InvitedAt: e.InvitedAt, } } // ============================================================================ // Helper Functions // ============================================================================ func humanToDTO(h *human.Human) Human { return Human{ Id: h.ID, Email: h.Email, EmailPrefix: h.EmailPrefix, EmailNotificationsEnabled: h.EmailNotificationsEnabled, AvatarObjectID: h.AvatarObjectID, CreatedAt: h.CreatedAt, } } func (h *Handler) networkToDTO(ctx context.Context, n *network.Network) (Network, error) { adminHuman, err := h.humanSvc.GetByID(ctx, n.AdminHumanId) if err != nil { return Network{}, err } humans := make([]Human, 0, len(n.MemberHumanIds)) for _, memberHumanId := range n.MemberHumanIds { hum, err := h.humanSvc.GetByID(ctx, memberHumanId) if err != nil { flog.Warn("failed to look up network member", "humanId", memberHumanId, "error", err) continue } humans = append(humans, humanToDTO(hum)) } return Network{ Id: n.ID, Name: n.Name, AdminHuman: humanToDTO(adminHuman), Humans: humans, CreatedAt: n.CreatedAt, }, nil } // ============================================================================ // LiveKit Handlers // ============================================================================ func (h *Handler) GetLivekitToken(w http.ResponseWriter, r *http.Request) { humanId, ok := middleware.HumanIdFromContext(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } humanEmail, ok := middleware.EmailFromContext(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } var req GetLivekitTokenRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request body", http.StatusBadRequest) return } if req.NetworkId == "" { http.Error(w, "network_id is required", http.StatusBadRequest) return } if req.StreamId == "" { http.Error(w, "stream_id is required", http.StatusBadRequest) return } // Encode both IDs in the room name so the webhook handler can resolve them. roomName := req.NetworkId + "/" + req.StreamId token, err := h.livekitClient.GetJoinToken(roomName, humanId, humanEmail) if err != nil { flog.Error("failed to generate livekit token", "error", err, "humanId", humanId, "roomName", roomName) http.Error(w, "failed to generate token", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(GetLivekitTokenResponse{Token: token, ServerUrl: h.livekitClient.ServerUrl()}) } // HandleLivekitWebhook verifies the webhook signature (not user auth) and // reconciles huddle_active_participants on the stream particle in Firestore. func (h *Handler) HandleLivekitWebhook(w http.ResponseWriter, r *http.Request) { event, err := webhook.ReceiveWebhookEvent(r, h.livekitClient.KeyProvider()) if err != nil { flog.Error("failed to verify livekit webhook", "error", err) http.Error(w, "unauthorized", http.StatusUnauthorized) return } eventType := event.GetEvent() flog.Info("received livekit webhook", "event", eventType, "room", event.GetRoom().GetName()) switch eventType { case "participant_joined", "participant_left", "room_finished": // fall through default: w.WriteHeader(http.StatusOK) return } roomName := event.GetRoom().GetName() parts := strings.SplitN(roomName, "/", 2) if len(parts) != 2 { flog.Error("invalid room name format", "room", roomName) http.Error(w, "invalid room name", http.StatusBadRequest) return } networkId, streamId := parts[0], parts[1] docPath := fmt.Sprintf("networks/%s/children/%s", networkId, streamId) docRef := h.firestoreClient.Doc(docPath) ctx := r.Context() var participantIds []string if eventType == "room_finished" { participantIds = []string{} } else { // Authoritative list avoids drift from missed/out-of-order webhooks. participants, err := h.livekitClient.ListParticipants(ctx, roomName) if err != nil { flog.Error("failed to list participants", "error", err, "room", roomName) // 200 to suppress LiveKit retries. w.WriteHeader(http.StatusOK) return } participantIds = make([]string, 0, len(participants)) for _, p := range participants { participantIds = append(participantIds, p.Identity) } } _, err = docRef.Update(ctx, []firestore.Update{ {Path: "huddle_active_participants", Value: participantIds}, }) if err != nil { flog.Error("failed to update huddle participants in firestore", "error", err, "path", docPath) } w.WriteHeader(http.StatusOK) } func extractBearerToken(r *http.Request) string { authHeader := r.Header.Get("Authorization") if authHeader == "" { return "" } const prefix = "Bearer " if len(authHeader) > len(prefix) && authHeader[:len(prefix)] == prefix { return authHeader[len(prefix):] } return "" }