ffac812273
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1233 lines
36 KiB
Go
1233 lines
36 KiB
Go
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// best-effort, gracefully continue
|
|
human, err := h.humanSvc.GetByID(r.Context(), humanId)
|
|
if err != nil {
|
|
flog.Error("failed to get human by id", "error", err, "humanId", humanId)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
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)
|
|
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 ""
|
|
}
|