* 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.
1187 lines
36 KiB
Go
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 ""
|
|
}
|