Files
llink/go/internal/handler/handler.go
T
Arjun Patel 4d1ad717ad support huddles (#106)
* add token endpoint for livekit

* fix: invalid type passed to hook

* fix: inject livekit env variables for orion

* fix: show controls indicator above stream # shortcut

* return livekit server url from api

* simple huddle implementation with streams

* set human name in livekit room context

* simplify deployment tooling

* join huddle with audio automatically

* feat: show when there is an active huddle

This introduces a webhook which listens to events from livekit and
updates our firestore stream particle. It keeps the client simple,
reacting to changes to firestore docs.

* use headphones icon for huddles

* fix: screenshare not working in electron

The default VideoConference component from livekit doesn't support
screenshare in electron. This attempts to compose our own layout with
livekit components ourselves and introduces our own flow for
screenshare.
2026-04-01 10:32:15 -07:00

1187 lines
36 KiB
Go

package handler
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"cloud.google.com/go/firestore"
"github.com/flowy-live/llink/internal/auth"
"github.com/flowy-live/llink/internal/depot"
"github.com/flowy-live/llink/internal/human"
"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"
"github.com/livekit/protocol/webhook"
)
type Handler struct {
authSvc auth.AuthService
humanSvc human.Service
networkSvc network.Service
particleSvc particle.Service
depotSvc depot.Service
waitlistSvc waitlist.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, livekitClient livekit.Client, firestoreClient *firestore.Client) *Handler {
return &Handler{
authSvc: authSvc,
humanSvc: humanSvc,
networkSvc: networkSvc,
particleSvc: particleSvc,
depotSvc: depotSvc,
waitlistSvc: waitlistSvc,
livekitClient: livekitClient,
firestoreClient: firestoreClient,
}
}
// Response DTOs
type Human struct {
Id string `json:"id"`
Email string `json:"email"`
EmailPrefix string `json:"email_prefix"`
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"`
MessageRetentionHours int `json:"message_retention_hours"`
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"`
}
// Network Request DTOs
type CreateNetworkRequest struct {
Name string `json:"name"`
}
type AddMembersToNetworkRequest struct {
EmailAddresses []string `json:"email_addresses"`
}
type SetOpenStreamCapacityRequest struct {
Capacity int `json:"capacity"`
}
type SetMessageRetentionHoursRequest struct {
Hours int `json:"hours"`
}
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 creates a human account if not already existent and sends a sign-in 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
}
// Auto-create human if doesn't exist
_, err := h.humanSvc.GetOrCreateByEmail(r.Context(), req.Email)
if err != nil {
slog.Error("failed to get or create human", "error", err, "email", req.Email)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
// Request sign-in code
if err := h.authSvc.RequestSignInCode(r.Context(), req.Email); err != nil {
slog.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)
}
// SignIn verifies the code and returns a session token
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
}
// Look up human first so we can store humanId in the session
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
}
slog.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
}
slog.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)
}
// SignOut deletes the session from the token in headers
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 {
slog.Error("failed to sign out", "error", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// GetCurrentHuman returns the authenticated human
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
}
slog.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)
}
// ============================================================================
// Network Handlers
// ============================================================================
// CreateNetwork creates a new network
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
}
slog.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 {
slog.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)
}
// ListNetworks retrieves networks for the authenticated human
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 {
slog.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 {
slog.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)
}
// GetNetwork retrieves a specific network
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 {
slog.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
}
slog.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 {
slog.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 adds members to a network. Registered users are added as members,
// unregistered users receive email invitations.
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 {
slog.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
}
// Resolve emails: registered users become members, unregistered get invitations
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
}
slog.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 {
slog.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 {
slog.Error("failed to invite members to network", "error", err, "network_id", networkID)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
}
// Return updated network
net, err := h.networkSvc.GetByID(r.Context(), networkID)
if err != nil {
slog.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 {
slog.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 removes a member from a network
func (h *Handler) RemoveMemberFromNetwork(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")
memberHumanId := r.PathValue("humanId")
if networkID == "" || memberHumanId == "" {
http.Error(w, "network id and member humanId are required", http.StatusBadRequest)
return
}
isMember, err := h.networkSvc.IsMember(r.Context(), networkID, humanId)
if err != nil {
slog.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
}
if err := h.networkSvc.RemoveMember(r.Context(), networkID, memberHumanId); err != nil {
slog.Error("failed to remove member from network", "error", err, "network_id", networkID, "memberHumanId", memberHumanId)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ListInvitationsForNetwork returns pending invitations for a network
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 {
slog.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 {
slog.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)
}
// ListMyInvitations returns pending invitations for the authenticated user
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 {
slog.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)
}
// AcceptInvitation accepts a pending network invitation for the authenticated user
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 {
slog.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)
}
// RevokeInvitation revokes a pending invitation from a network
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 {
slog.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 {
slog.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)
}
// SetMessageRetentionHours updates the message retention window for a network (admin-only)
func (h *Handler) SetMessageRetentionHours(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
}
// Fetch network to verify admin
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
}
slog.Error("failed to get network", "error", err, "network_id", networkID)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if net.AdminHumanId != humanId {
http.Error(w, "only the network admin can change this setting", http.StatusForbidden)
return
}
var req SetMessageRetentionHoursRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if err := h.networkSvc.SetMessageRetentionHours(r.Context(), networkID, req.Hours); err != nil {
if errors.Is(err, network.ErrInvalidRetentionHours) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
slog.Error("failed to set message retention hours", "error", err, "network_id", networkID)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
// Return updated network
updatedNet, err := h.networkSvc.GetByID(r.Context(), networkID)
if err != nil {
slog.Error("failed to get network after update", "error", err, "network_id", networkID)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
resp, err := h.networkToDTO(r.Context(), updatedNet)
if err != nil {
slog.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)
}
// DownloadParticleMedia returns a fresh signed download URL for media/file particles
func (h *Handler) DownloadParticleMedia(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 {
slog.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 prepares an upload and 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 {
slog.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
}
slog.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)
}
// ConfirmUpload confirms that an upload has been completed
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
}
slog.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 adds an email to the waitlist (public, no auth)
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
}
slog.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 returns all waitlist entries (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 {
slog.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 returns a single waitlist entry by email (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
}
slog.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 marks a waitlist entry as invited (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
}
slog.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,
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 {
slog.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,
MessageRetentionHours: n.MessageRetentionHours,
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
}
// Compose room name encoding both network and stream IDs for webhook resolution
roomName := req.NetworkId + "/" + req.StreamId
token, err := h.livekitClient.GetJoinToken(roomName, humanId, humanEmail)
if err != nil {
slog.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 processes LiveKit webhook events for huddle presence.
// It verifies the webhook signature (not user auth), then updates the stream
// particle's huddle_active_participants field in Firestore.
func (h *Handler) HandleLivekitWebhook(w http.ResponseWriter, r *http.Request) {
event, err := webhook.ReceiveWebhookEvent(r, h.livekitClient.KeyProvider())
if err != nil {
slog.Error("failed to verify livekit webhook", "error", err)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
eventType := event.GetEvent()
slog.Info("received livekit webhook", "event", eventType, "room", event.GetRoom().GetName())
switch eventType {
case "participant_joined", "participant_left", "room_finished":
// Handle these events
default:
w.WriteHeader(http.StatusOK)
return
}
// Parse room name to extract networkId and streamId
roomName := event.GetRoom().GetName()
parts := strings.SplitN(roomName, "/", 2)
if len(parts) != 2 {
slog.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" {
// Room is done — clear the participants
participantIds = []string{}
} else {
// Use ListParticipants for authoritative state (avoids drift from missed webhooks)
participants, err := h.livekitClient.ListParticipants(ctx, roomName)
if err != nil {
slog.Error("failed to list participants", "error", err, "room", roomName)
// Return 200 so LiveKit doesn't retry
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 {
slog.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 ""
}