package handler import ( "context" "encoding/json" "errors" "log/slog" "net/http" "strings" "time" "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/middleware" "github.com/flowy-live/llink/internal/network" "github.com/flowy-live/llink/internal/particle" "github.com/flowy-live/llink/internal/utils" ) type Handler struct { authSvc auth.AuthService humanSvc human.Service networkSvc network.Service particleSvc particle.Service depotSvc depot.Service } func NewHandler(authSvc auth.AuthService, humanSvc human.Service, networkSvc network.Service, particleSvc particle.Service, depotSvc depot.Service) *Handler { return &Handler{ authSvc: authSvc, humanSvc: humanSvc, networkSvc: networkSvc, particleSvc: particleSvc, depotSvc: depotSvc, } } // Response DTOs type Human struct { // Id will be nil if this human is not registered 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"` OpenStreamCount int `json:"open_stream_count"` OpenStreamCapacity int `json:"open_stream_capacity"` CreatedAt time.Time `json:"created_at"` } type StreamParticle struct { Id string `json:"id"` Type string `json:"type"` Data json.RawMessage `json:"data"` CreatedByEmail string `json:"created_by_email"` Seen bool `json:"seen"` Acks []*AckInfo `json:"acks"` UpdatedAt time.Time `json:"updated_at"` CreatedAt time.Time `json:"created_at"` } type Particle struct { Id string `json:"id"` Type string `json:"type"` Data json.RawMessage `json:"data"` CreatedByEmail string `json:"created_by_email"` Visibility string `json:"visibility"` Members []string `json:"members,omitempty"` StreamStatus *string `json:"stream_status,omitempty"` Seen bool `json:"seen,omitempty"` Acks []*AckInfo `json:"acks,omitempty"` UnseenCount *int `json:"unseen_count,omitempty"` UpdatedAt time.Time `json:"updated_at"` CreatedAt time.Time `json:"created_at"` } type Stream struct { Id string `json:"id"` Name string `json:"name"` Description string `json:"description"` Status StreamStatus `json:"status"` // The emails of the members in this stream Members []string `json:"members,omitempty"` Particles []*StreamParticle `json:"particles"` UnseenCount int `json:"unseen_count"` UpdatedAt time.Time `json:"updated_at"` CreatedAt time.Time `json:"created_at"` } type StreamStatus string const ( STREAM_STATUS_OPEN StreamStatus = "open" STREAM_STATUS_CLOSED StreamStatus = "closed" STREAM_STATUS_UNSPECIFIED StreamStatus = "unspecified" ) type NetworkWithStreams struct { Network Streams []*Stream `json:"streams"` } type StartData struct { Networks []*NetworkWithStreams `json:"networks"` } type AckInfo struct { Email string `json:"email"` AckedAt time.Time `json:"acked_at"` } type ParticleList struct { Particles []Particle `json:"particles"` HasMore bool `json:"has_more"` NextCursor *string `json:"next_cursor,omitempty"` PrevCursor *string `json:"prev_cursor,omitempty"` } // 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"` } // Particle Request DTOs type CreateStreamParticleRequest struct { Type string `json:"type"` Data json.RawMessage `json:"data"` } type CreateStreamRequest struct { Name string `json:"name"` Description string `json:"description"` Visibility string `json:"visibility"` Members []string `json:"members"` } type UpdateStreamRequest struct { Name *string `json:"name"` Description *string `json:"description"` } type UpdateParticleRequest struct { Data json.RawMessage `json:"data"` } type MembersRequest struct { Emails []string `json:"emails"` } type MarkSeenBatchRequest struct { ParticleIDs []string `json:"particle_ids"` } // 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 } token, err := h.authSvc.VerifySignInCode(r.Context(), req.Email, req.Code) 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 } // Get the human hum, err := h.humanSvc.GetByEmail(r.Context(), req.Email) if err != nil { slog.Error("failed to get human after sign-in", "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) } func (h *Handler) StartupData(w http.ResponseWriter, r *http.Request) { email, ok := middleware.EmailFromContext(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } networks, err := h.networkSvc.ListForEmail(r.Context(), email) if err != nil { http.Error(w, "unable to fetch networks", http.StatusInternalServerError) return } data := StartData{ Networks: make([]*NetworkWithStreams, 0, len(networks)), } streamFilter := particle.ListFilter{ Types: []particle.ParticleType{particle.TypeStream}, } for _, net := range networks { networkDTO, err := h.networkToDTO(r.Context(), net) if err != nil { slog.Error("unable to convert db network to dto", "error", err, "network_id", net.ID) continue } nws := &NetworkWithStreams{ Network: networkDTO, } // Fetch all top-level streams for this network streams, err := h.listAllParticles(r.Context(), net.ID, nil, email, streamFilter) if err != nil { slog.Error("unable to list streams for network", "error", err, "network_id", net.ID) data.Networks = append(data.Networks, nws) continue } // Collect stream IDs for unseen counts streamIDs := make([]string, len(streams)) for i, s := range streams { streamIDs[i] = s.ID } // Get unseen counts and members for all streams in this network var unseenCounts map[string]int var streamMembersMap map[string][]string if len(streamIDs) > 0 { unseenCounts, err = h.particleSvc.GetUnseenCounts(r.Context(), net.ID, streamIDs, email) if err != nil { slog.Warn("failed to get unseen counts for streams", "error", err, "network_id", net.ID) unseenCounts = make(map[string]int) } streamMembersMap, err = h.particleSvc.GetMembersMap(r.Context(), streamIDs) if err != nil { slog.Warn("failed to get members map for streams", "error", err, "network_id", net.ID) streamMembersMap = make(map[string][]string) } } nws.Streams = make([]*Stream, 0, len(streams)) for _, sp := range streams { // Parse stream metadata from particle data var streamData particle.StreamData if err := json.Unmarshal(sp.Data, &streamData); err != nil { slog.Warn("failed to parse stream data", "error", err, "particle_id", sp.ID) } status := parseStreamStatus(streamData.Status) stream := &Stream{ Id: sp.ID, Name: streamData.Name, Description: utils.OptionalString(streamData.Description), Status: status, Members: h.getStreamMembers(r.Context(), sp, streamMembersMap), UnseenCount: unseenCounts[sp.ID], UpdatedAt: sp.UpdatedAt, CreatedAt: sp.CreatedAt, } // Fetch child particles for this stream children, err := h.listAllParticles(r.Context(), net.ID, &sp.ID, email, particle.ListFilter{}) if err != nil { slog.Error("unable to list particles for stream", "error", err, "stream_id", sp.ID) nws.Streams = append(nws.Streams, stream) continue } // Collect child particle IDs for bulk enrichment childIDs := make([]string, len(children)) for i, p := range children { childIDs[i] = p.ID } seenMap, err := h.particleSvc.GetSeenMap(r.Context(), childIDs, email) if err != nil { slog.Warn("failed to get seen map", "error", err) seenMap = make(map[string]bool) } acksMap, err := h.particleSvc.GetAcksMap(r.Context(), childIDs) if err != nil { slog.Warn("failed to get acks map", "error", err) acksMap = make(map[string][]particle.AckInfo) } // Build enriched particle DTOs stream.Particles = make([]*StreamParticle, 0, len(children)) for _, p := range children { dto := h.streamParticleToDTO(r.Context(), p) if seen, ok := seenMap[p.ID]; ok { dto.Seen = seen } if acks, ok := acksMap[p.ID]; ok && len(acks) > 0 { dto.Acks = make([]*AckInfo, len(acks)) for i, a := range acks { dto.Acks[i] = &AckInfo{Email: a.Email, AckedAt: a.AckedAt} } } else { dto.Acks = []*AckInfo{} } stream.Particles = append(stream.Particles, &dto) } nws.Streams = append(nws.Streams, stream) } data.Networks = append(data.Networks, nws) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(data) } func (h *Handler) CreateStream(w http.ResponseWriter, r *http.Request) { email, ok := middleware.EmailFromContext(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } networkID := r.PathValue("network_id") if networkID == "" { http.Error(w, "network_id is required", http.StatusBadRequest) return } // Check network membership isMember, err := h.networkSvc.IsMember(r.Context(), networkID, email) if err != nil { slog.Error("failed to check network membership", "error", err, "network_id", networkID, "email", email) http.Error(w, "internal server error", http.StatusInternalServerError) return } if !isMember { http.Error(w, "access denied", http.StatusForbidden) return } var req CreateStreamRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request body", http.StatusBadRequest) return } if req.Name == "" { http.Error(w, "name is required", http.StatusBadRequest) return } streamData := &particle.StreamData{ Name: req.Name, Description: utils.CreateOptionalString(req.Description), Status: string(particle.StreamStatusOpen), } data, err := json.Marshal(streamData) if err != nil { slog.Error("failed to marshal stream data", "error", err, "stream_name", req.Name) http.Error(w, "internal server error", http.StatusInternalServerError) return } visibilityMode, err := particle.ParseVisibilityMode(req.Visibility) if err != nil { slog.Error("invalid visibility mode", "error", err, "visibility", req.Visibility) http.Error(w, "invalid visibility mode", http.StatusBadRequest) return } params := particle.CreateInput{ Type: particle.TypeStream, NetworkID: networkID, ParentID: nil, Data: data, Members: req.Members, Visibility: visibilityMode, } created, err := h.particleSvc.Create(r.Context(), params, email) if err != nil { if errors.Is(err, particle.ErrMembersRequired) { http.Error(w, err.Error(), http.StatusBadRequest) return } if errors.Is(err, particle.ErrCapacityExceeded) { http.Error(w, "stream capacity exceeded", http.StatusConflict) return } if errors.Is(err, particle.ErrInvalidData) { http.Error(w, err.Error(), http.StatusBadRequest) return } slog.Error("failed to create stream particle", "error", err, "stream_name", req.Name, "network_id", networkID) http.Error(w, "internal server error", http.StatusInternalServerError) return } // Get members for the created stream membersMap, _ := h.particleSvc.GetMembersMap(r.Context(), []string{created.ID}) var parsedData particle.StreamData _ = json.Unmarshal(created.Data, &parsedData) resp := Stream{ Id: created.ID, Name: parsedData.Name, Description: utils.OptionalString(parsedData.Description), Status: parseStreamStatus(parsedData.Status), Members: h.getStreamMembers(r.Context(), created, membersMap), Particles: []*StreamParticle{}, UnseenCount: 0, UpdatedAt: created.UpdatedAt, CreatedAt: created.CreatedAt, } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(resp) } // GetStream returns a stream with its child particles, members, and unseen count func (h *Handler) GetStream(w http.ResponseWriter, r *http.Request) { email, ok := middleware.EmailFromContext(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } streamID := r.PathValue("id") if streamID == "" { http.Error(w, "stream id is required", http.StatusBadRequest) return } // Get the stream particle and verify access sp, err := h.particleSvc.GetByID(r.Context(), streamID, email) if err != nil { if errors.Is(err, particle.ErrNotFound) { http.Error(w, "stream not found", http.StatusNotFound) return } if errors.Is(err, particle.ErrAccessDenied) { http.Error(w, "access denied", http.StatusForbidden) return } slog.Error("failed to get stream", "error", err, "stream_id", streamID, "email", email) http.Error(w, "internal server error", http.StatusInternalServerError) return } if sp.Type != particle.TypeStream { http.Error(w, "particle is not a stream", http.StatusBadRequest) return } // Parse stream data var streamData particle.StreamData if err := json.Unmarshal(sp.Data, &streamData); err != nil { slog.Warn("failed to parse stream data", "error", err, "stream_id", streamID) } // Get members membersMap, err := h.particleSvc.GetMembersMap(r.Context(), []string{streamID}) if err != nil { slog.Warn("failed to get members for stream", "error", err, "stream_id", streamID) membersMap = make(map[string][]string) } // Get unseen count unseenCounts, err := h.particleSvc.GetUnseenCounts(r.Context(), sp.NetworkID, []string{streamID}, email) if err != nil { slog.Warn("failed to get unseen counts", "error", err, "stream_id", streamID) unseenCounts = make(map[string]int) } stream := &Stream{ Id: sp.ID, Name: streamData.Name, Description: utils.OptionalString(streamData.Description), Status: parseStreamStatus(streamData.Status), Members: h.getStreamMembers(r.Context(), sp, membersMap), UnseenCount: unseenCounts[sp.ID], UpdatedAt: sp.UpdatedAt, CreatedAt: sp.CreatedAt, } // Fetch child particles children, err := h.listAllParticles(r.Context(), sp.NetworkID, &sp.ID, email, particle.ListFilter{}) if err != nil { slog.Error("unable to list particles for stream", "error", err, "stream_id", sp.ID) stream.Particles = []*StreamParticle{} w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(stream) return } // Collect child IDs for bulk enrichment childIDs := make([]string, len(children)) for i, p := range children { childIDs[i] = p.ID } seenMap, err := h.particleSvc.GetSeenMap(r.Context(), childIDs, email) if err != nil { slog.Warn("failed to get seen map", "error", err) seenMap = make(map[string]bool) } acksMap, err := h.particleSvc.GetAcksMap(r.Context(), childIDs) if err != nil { slog.Warn("failed to get acks map", "error", err) acksMap = make(map[string][]particle.AckInfo) } stream.Particles = make([]*StreamParticle, 0, len(children)) for _, p := range children { dto := h.streamParticleToDTO(r.Context(), p) if seen, ok := seenMap[p.ID]; ok { dto.Seen = seen } if acks, ok := acksMap[p.ID]; ok && len(acks) > 0 { dto.Acks = make([]*AckInfo, len(acks)) for i, a := range acks { dto.Acks[i] = &AckInfo{Email: a.Email, AckedAt: a.AckedAt} } } else { dto.Acks = []*AckInfo{} } stream.Particles = append(stream.Particles, &dto) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(stream) } // UpdateStream updates a stream's name and/or description (not status) func (h *Handler) UpdateStream(w http.ResponseWriter, r *http.Request) { email, ok := middleware.EmailFromContext(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } streamID := r.PathValue("id") if streamID == "" { http.Error(w, "stream id is required", http.StatusBadRequest) return } var req UpdateStreamRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request body", http.StatusBadRequest) return } // Get the existing stream to preserve status and merge fields sp, err := h.particleSvc.GetByID(r.Context(), streamID, email) if err != nil { if errors.Is(err, particle.ErrNotFound) { http.Error(w, "stream not found", http.StatusNotFound) return } if errors.Is(err, particle.ErrAccessDenied) { http.Error(w, "access denied", http.StatusForbidden) return } slog.Error("failed to get stream for update", "error", err, "stream_id", streamID, "email", email) http.Error(w, "internal server error", http.StatusInternalServerError) return } if sp.Type != particle.TypeStream { http.Error(w, "particle is not a stream", http.StatusBadRequest) return } // Parse existing data to preserve status var existing particle.StreamData if err := json.Unmarshal(sp.Data, &existing); err != nil { slog.Error("failed to parse existing stream data", "error", err, "stream_id", streamID) http.Error(w, "internal server error", http.StatusInternalServerError) return } // Merge: only update fields that were provided if req.Name != nil { if *req.Name == "" { http.Error(w, "name cannot be empty", http.StatusBadRequest) return } existing.Name = *req.Name } if req.Description != nil { existing.Description = utils.CreateOptionalString(*req.Description) } newData, err := json.Marshal(existing) if err != nil { slog.Error("failed to marshal updated stream data", "error", err, "stream_id", streamID) http.Error(w, "internal server error", http.StatusInternalServerError) return } updated, err := h.particleSvc.Update(r.Context(), streamID, newData, email) if err != nil { if errors.Is(err, particle.ErrNotFound) { http.Error(w, "stream not found", http.StatusNotFound) return } if errors.Is(err, particle.ErrAccessDenied) { http.Error(w, "access denied", http.StatusForbidden) return } if errors.Is(err, particle.ErrInvalidData) { http.Error(w, err.Error(), http.StatusBadRequest) return } slog.Error("failed to update stream", "error", err, "stream_id", streamID, "email", email) http.Error(w, "internal server error", http.StatusInternalServerError) return } // Build response var parsedData particle.StreamData _ = json.Unmarshal(updated.Data, &parsedData) membersMap, _ := h.particleSvc.GetMembersMap(r.Context(), []string{updated.ID}) unseenCounts, _ := h.particleSvc.GetUnseenCounts(r.Context(), updated.NetworkID, []string{updated.ID}, email) resp := Stream{ Id: updated.ID, Name: parsedData.Name, Description: utils.OptionalString(parsedData.Description), Status: parseStreamStatus(parsedData.Status), Members: h.getStreamMembers(r.Context(), updated, membersMap), Particles: []*StreamParticle{}, UnseenCount: unseenCounts[updated.ID], UpdatedAt: updated.UpdatedAt, CreatedAt: updated.CreatedAt, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } // CreateStreamParticle creates a particle inside a stream func (h *Handler) CreateStreamParticle(w http.ResponseWriter, r *http.Request) { email, ok := middleware.EmailFromContext(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } streamID := r.PathValue("id") if streamID == "" { http.Error(w, "stream id is required", http.StatusBadRequest) return } var req CreateStreamParticleRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request body", http.StatusBadRequest) return } // Get the stream to find NetworkID and verify it's a stream stream, err := h.particleSvc.GetByID(r.Context(), streamID, email) if err != nil { if errors.Is(err, particle.ErrNotFound) { http.Error(w, "stream not found", http.StatusNotFound) return } if errors.Is(err, particle.ErrAccessDenied) { http.Error(w, "access denied", http.StatusForbidden) return } slog.Error("failed to get stream for particle creation", "error", err, "stream_id", streamID, "email", email) http.Error(w, "internal server error", http.StatusInternalServerError) return } if stream.Type != particle.TypeStream { http.Error(w, "particle is not a stream", http.StatusBadRequest) return } // Parse and validate particle type particleType, err := particle.ParseParticleType(req.Type) if err != nil { http.Error(w, "invalid particle type", http.StatusBadRequest) return } // Reject streams and folders as children if particleType == particle.TypeStream || particleType == particle.TypeFolder { http.Error(w, "cannot create streams or folders inside a stream", http.StatusBadRequest) return } // For media/file types, validate object_id exists in depot if req.Type == "media" || req.Type == "file" { var data struct { ObjectID string `json:"object_id"` } if err := json.Unmarshal(req.Data, &data); err == nil && data.ObjectID != "" { exists, err := h.depotSvc.Exists(r.Context(), data.ObjectID) if err != nil { slog.Error("failed to check depot object existence", "error", err, "object_id", data.ObjectID) http.Error(w, "internal server error", http.StatusInternalServerError) return } if !exists { http.Error(w, "object_id does not exist in depot", http.StatusBadRequest) return } } } // Service will force inherited visibility and nil members input := particle.CreateInput{ Type: particleType, NetworkID: stream.NetworkID, ParentID: &streamID, Data: req.Data, } created, err := h.particleSvc.Create(r.Context(), input, email) if err != nil { if errors.Is(err, particle.ErrInvalidType) || errors.Is(err, particle.ErrInvalidData) || errors.Is(err, particle.ErrInvalidParent) { http.Error(w, err.Error(), http.StatusBadRequest) return } if errors.Is(err, particle.ErrAccessDenied) { http.Error(w, "access denied", http.StatusForbidden) return } slog.Error("failed to create stream particle", "error", err, "stream_id", streamID, "type", req.Type, "email", email) http.Error(w, "internal server error", http.StatusInternalServerError) return } resp := h.streamParticleToDTO(r.Context(), created) resp.Acks = []*AckInfo{} w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(resp) } // 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) { email, ok := middleware.EmailFromContext(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, email) 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, "email", email, "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) { email, ok := middleware.EmailFromContext(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } networks, err := h.networkSvc.ListForEmail(r.Context(), email) if err != nil { slog.Error("failed to list networks", "error", err, "email", email) 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) { email, ok := middleware.EmailFromContext(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 } // Check membership isMember, err := h.networkSvc.IsMember(r.Context(), networkID, email) if err != nil { slog.Error("failed to check network membership", "error", err, "network_id", networkID, "email", email) 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 func (h *Handler) AddMembersToNetwork(w http.ResponseWriter, r *http.Request) { email, ok := middleware.EmailFromContext(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 } // Check membership isMember, err := h.networkSvc.IsMember(r.Context(), networkID, email) if err != nil { slog.Error("failed to check network membership", "error", err, "network_id", networkID, "email", email) 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 } if err := h.networkSvc.AddMembers(r.Context(), networkID, req.EmailAddresses); err != nil { slog.Error("failed to add 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) { email, ok := middleware.EmailFromContext(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } networkID := r.PathValue("id") memberEmail := r.PathValue("email") if networkID == "" || memberEmail == "" { http.Error(w, "network id and member email are required", http.StatusBadRequest) return } // Check membership isMember, err := h.networkSvc.IsMember(r.Context(), networkID, email) if err != nil { slog.Error("failed to check network membership", "error", err, "network_id", networkID, "email", email) 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, memberEmail); err != nil { slog.Error("failed to remove member from network", "error", err, "network_id", networkID, "member_email", memberEmail) http.Error(w, "internal server error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // SetOpenStreamCapacity sets the open stream capacity for a network func (h *Handler) SetOpenStreamCapacity(w http.ResponseWriter, r *http.Request) { email, ok := middleware.EmailFromContext(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 } // Get network to check 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 for capacity update", "error", err, "network_id", networkID) http.Error(w, "internal server error", http.StatusInternalServerError) return } // Only admin can set capacity if net.AdminEmail != email { http.Error(w, "only admin can set capacity", http.StatusForbidden) return } var req SetOpenStreamCapacityRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request body", http.StatusBadRequest) return } if err := h.networkSvc.SetOpenStreamCapacity(r.Context(), networkID, req.Capacity); err != nil { if errors.Is(err, network.ErrNotFound) { http.Error(w, "network not found", http.StatusNotFound) return } slog.Error("failed to set open stream capacity", "error", err, "network_id", networkID, "capacity", req.Capacity) http.Error(w, "internal server error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // ============================================================================ // Particle Handlers // ============================================================================ // ListParticles returns particles in a network func (h *Handler) ListParticles(w http.ResponseWriter, r *http.Request) { email, ok := middleware.EmailFromContext(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } networkID := r.PathValue("network_id") if networkID == "" { http.Error(w, "network id is required", http.StatusBadRequest) return } // Check network membership isMember, err := h.networkSvc.IsMember(r.Context(), networkID, email) if err != nil { slog.Error("failed to check network membership", "error", err, "network_id", networkID, "email", email) http.Error(w, "internal server error", http.StatusInternalServerError) return } if !isMember { http.Error(w, "access denied", http.StatusForbidden) return } // Parse query parameters parentID := r.URL.Query().Get("parent_id") var parentIDPtr *string if parentID != "" { parentIDPtr = &parentID } // Parse cursor var cursor *particle.Cursor cursorStr := r.URL.Query().Get("cursor") direction := r.URL.Query().Get("direction") if cursorStr != "" { cursor = &particle.Cursor{ Position: cursorStr, Direction: direction, } if cursor.Direction == "" { cursor.Direction = "after" } } // Parse type filter filter := particle.ListFilter{} typeFilter := r.URL.Query()["type"] for _, t := range typeFilter { pt, err := particle.ParseParticleType(t) if err != nil { http.Error(w, "invalid particle type: "+t, http.StatusBadRequest) return } filter.Types = append(filter.Types, pt) } list, err := h.particleSvc.List(r.Context(), networkID, parentIDPtr, email, filter, cursor, 50) if err != nil { if errors.Is(err, particle.ErrNotFound) { http.Error(w, "parent not found", http.StatusNotFound) return } if errors.Is(err, particle.ErrAccessDenied) { http.Error(w, "access denied", http.StatusForbidden) return } slog.Error("failed to list particles", "error", err, "network_id", networkID, "email", email) http.Error(w, "internal server error", http.StatusInternalServerError) return } // Extract particle IDs for enrichment particleIDs := make([]string, len(list.Particles)) streamIDs := make([]string, 0) for i, p := range list.Particles { particleIDs[i] = p.ID if p.Type == particle.TypeStream { streamIDs = append(streamIDs, p.ID) } } // Get seen map for enrichment seenMap, err := h.particleSvc.GetSeenMap(r.Context(), particleIDs, email) if err != nil { slog.Warn("failed to get seen map", "error", err) seenMap = make(map[string]bool) } // Get acks map for enrichment acksMap, err := h.particleSvc.GetAcksMap(r.Context(), particleIDs) if err != nil { slog.Warn("failed to get acks map", "error", err) acksMap = make(map[string][]particle.AckInfo) } // Get members map for enrichment membersMap, err := h.particleSvc.GetMembersMap(r.Context(), particleIDs) if err != nil { slog.Warn("failed to get members map", "error", err) membersMap = make(map[string][]string) } // Get unseen counts for streams var unseenCounts map[string]int if len(streamIDs) > 0 { unseenCounts, err = h.particleSvc.GetUnseenCounts(r.Context(), networkID, streamIDs, email) if err != nil { slog.Warn("failed to get unseen counts", "error", err) unseenCounts = make(map[string]int) } } resp := ParticleList{ Particles: make([]Particle, 0, len(list.Particles)), HasMore: list.HasMore, } for _, p := range list.Particles { dto := h.particleToDTO(r.Context(), p) // Enrich with seen status seen, ok := seenMap[p.ID] if ok { dto.Seen = seen } // Enrich with acks if acks, ok := acksMap[p.ID]; ok && len(acks) > 0 { dto.Acks = make([]*AckInfo, len(acks)) for i, a := range acks { dto.Acks[i] = &AckInfo{Email: a.Email, AckedAt: a.AckedAt} } } // Enrich with members if members, ok := membersMap[p.ID]; ok && len(members) > 0 { dto.Members = members } // Enrich with unseen count for streams if p.Type == particle.TypeStream { count := unseenCounts[p.ID] dto.UnseenCount = &count } resp.Particles = append(resp.Particles, dto) } if list.NextCursor != nil { encoded := list.NextCursor.Position + ":" + list.NextCursor.Direction resp.NextCursor = &encoded } if list.PrevCursor != nil { encoded := list.PrevCursor.Position + ":" + list.PrevCursor.Direction resp.PrevCursor = &encoded } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } // GetParticle gets the details of a particle func (h *Handler) GetParticle(w http.ResponseWriter, r *http.Request) { email, ok := middleware.EmailFromContext(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } particleID := r.PathValue("id") if particleID == "" { http.Error(w, "particle id is required", http.StatusBadRequest) return } p, err := h.particleSvc.GetByID(r.Context(), particleID, email) if err != nil { if errors.Is(err, particle.ErrNotFound) { http.Error(w, "particle not found", http.StatusNotFound) return } if errors.Is(err, particle.ErrAccessDenied) { http.Error(w, "access denied", http.StatusForbidden) return } slog.Error("failed to get particle", "error", err, "particle_id", particleID, "email", email) http.Error(w, "internal server error", http.StatusInternalServerError) return } // Verify network membership isMember, err := h.networkSvc.IsMember(r.Context(), p.NetworkID, email) if err != nil { slog.Error("failed to check network membership", "error", err, "network_id", p.NetworkID, "email", email) http.Error(w, "internal server error", http.StatusInternalServerError) return } if !isMember { http.Error(w, "access denied", http.StatusForbidden) return } resp := h.particleToDTO(r.Context(), p) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } // UpdateParticle updates the data of a particle func (h *Handler) UpdateParticle(w http.ResponseWriter, r *http.Request) { email, ok := middleware.EmailFromContext(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } particleID := r.PathValue("id") if particleID == "" { http.Error(w, "particle id is required", http.StatusBadRequest) return } var req UpdateParticleRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request body", http.StatusBadRequest) return } updated, err := h.particleSvc.Update(r.Context(), particleID, req.Data, email) if err != nil { if errors.Is(err, particle.ErrNotFound) { http.Error(w, "particle not found", http.StatusNotFound) return } if errors.Is(err, particle.ErrAccessDenied) { http.Error(w, "access denied", http.StatusForbidden) return } if errors.Is(err, particle.ErrInvalidData) { http.Error(w, err.Error(), http.StatusBadRequest) return } slog.Error("failed to update particle", "error", err, "particle_id", particleID, "email", email) http.Error(w, "internal server error", http.StatusInternalServerError) return } resp := h.particleToDTO(r.Context(), updated) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } // DeleteParticle deletes a particle and cascades to depot if applicable func (h *Handler) DeleteParticle(w http.ResponseWriter, r *http.Request) { email, ok := middleware.EmailFromContext(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } particleID := r.PathValue("id") if particleID == "" { http.Error(w, "particle id is required", http.StatusBadRequest) return } // Get particle first to check for object_id (for cascade delete) p, err := h.particleSvc.GetByID(r.Context(), particleID, email) if err != nil { if errors.Is(err, particle.ErrNotFound) { http.Error(w, "particle not found", http.StatusNotFound) return } if errors.Is(err, particle.ErrAccessDenied) { http.Error(w, "access denied", http.StatusForbidden) return } slog.Error("failed to get particle for deletion", "error", err, "particle_id", particleID, "email", email) http.Error(w, "internal server error", http.StatusInternalServerError) return } // Extract object_id if media/file objectID := extractObjectID(p) // Delete particle if err := h.particleSvc.Delete(r.Context(), particleID, email); err != nil { if errors.Is(err, particle.ErrNotFound) { http.Error(w, "particle not found", http.StatusNotFound) return } if errors.Is(err, particle.ErrAccessDenied) { http.Error(w, "access denied", http.StatusForbidden) return } slog.Error("failed to delete particle", "error", err, "particle_id", particleID, "email", email) http.Error(w, "internal server error", http.StatusInternalServerError) return } // Cascade delete depot object if applicable if objectID != "" { if err := h.depotSvc.Delete(r.Context(), objectID); err != nil { slog.Warn("failed to cascade delete depot object", "error", err, "object_id", objectID, "particle_id", particleID) } } w.WriteHeader(http.StatusNoContent) } // OpenStream opens a stream particle func (h *Handler) OpenStream(w http.ResponseWriter, r *http.Request) { email, ok := middleware.EmailFromContext(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } particleID := r.PathValue("id") if particleID == "" { http.Error(w, "particle id is required", http.StatusBadRequest) return } if err := h.particleSvc.OpenStream(r.Context(), particleID, email); err != nil { if errors.Is(err, particle.ErrNotFound) { http.Error(w, "particle not found", http.StatusNotFound) return } if errors.Is(err, particle.ErrAccessDenied) { http.Error(w, "access denied", http.StatusForbidden) return } if errors.Is(err, particle.ErrNotAStream) { http.Error(w, "particle is not a stream", http.StatusBadRequest) return } if errors.Is(err, particle.ErrStreamAlreadyOpen) { http.Error(w, "stream is already open", http.StatusConflict) return } if errors.Is(err, particle.ErrCapacityExceeded) { http.Error(w, "stream capacity exceeded", http.StatusConflict) return } slog.Error("failed to open stream", "error", err, "particle_id", particleID, "email", email) http.Error(w, "internal server error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // CloseStream closes a stream particle func (h *Handler) CloseStream(w http.ResponseWriter, r *http.Request) { email, ok := middleware.EmailFromContext(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } particleID := r.PathValue("id") if particleID == "" { http.Error(w, "particle id is required", http.StatusBadRequest) return } if err := h.particleSvc.CloseStream(r.Context(), particleID, email); err != nil { if errors.Is(err, particle.ErrNotFound) { http.Error(w, "particle not found", http.StatusNotFound) return } if errors.Is(err, particle.ErrAccessDenied) { http.Error(w, "access denied", http.StatusForbidden) return } if errors.Is(err, particle.ErrNotAStream) { http.Error(w, "particle is not a stream", http.StatusBadRequest) return } if errors.Is(err, particle.ErrStreamAlreadyClosed) { http.Error(w, "stream is already closed", http.StatusConflict) return } slog.Error("failed to close stream", "error", err, "particle_id", particleID, "email", email) http.Error(w, "internal server error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // AddMembers adds members to a particle with custom visibility func (h *Handler) AddMembers(w http.ResponseWriter, r *http.Request) { email, ok := middleware.EmailFromContext(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } particleID := r.PathValue("id") if particleID == "" { http.Error(w, "particle id is required", http.StatusBadRequest) return } var req MembersRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request body", http.StatusBadRequest) return } if len(req.Emails) == 0 { http.Error(w, "emails are required", http.StatusBadRequest) return } if err := h.particleSvc.AddMembers(r.Context(), particleID, req.Emails, email); err != nil { if errors.Is(err, particle.ErrNotFound) { http.Error(w, "particle not found", http.StatusNotFound) return } if errors.Is(err, particle.ErrAccessDenied) { http.Error(w, "access denied", http.StatusForbidden) return } if errors.Is(err, particle.ErrNotAContainer) { http.Error(w, "only streams can have members", http.StatusBadRequest) return } slog.Error("failed to add members to particle", "error", err, "particle_id", particleID, "email", email) http.Error(w, "internal server error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // RemoveMembers removes members from a particle with custom visibility func (h *Handler) RemoveMembers(w http.ResponseWriter, r *http.Request) { email, ok := middleware.EmailFromContext(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } particleID := r.PathValue("id") if particleID == "" { http.Error(w, "particle id is required", http.StatusBadRequest) return } var req MembersRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request body", http.StatusBadRequest) return } if len(req.Emails) == 0 { http.Error(w, "emails are required", http.StatusBadRequest) return } if err := h.particleSvc.RemoveMembers(r.Context(), particleID, req.Emails, email); err != nil { if errors.Is(err, particle.ErrNotFound) { http.Error(w, "particle not found", http.StatusNotFound) return } if errors.Is(err, particle.ErrAccessDenied) { http.Error(w, "access denied", http.StatusForbidden) return } if errors.Is(err, particle.ErrNotAContainer) { http.Error(w, "only streams can have members", http.StatusBadRequest) return } slog.Error("failed to remove members from particle", "error", err, "particle_id", particleID, "email", email) http.Error(w, "internal server error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // MarkSeen marks a particle as seen by the requester func (h *Handler) MarkSeen(w http.ResponseWriter, r *http.Request) { email, ok := middleware.EmailFromContext(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } particleID := r.PathValue("id") if particleID == "" { http.Error(w, "particle id is required", http.StatusBadRequest) return } if err := h.particleSvc.MarkSeen(r.Context(), particleID, email); err != nil { if errors.Is(err, particle.ErrNotFound) { http.Error(w, "particle not found", http.StatusNotFound) return } if errors.Is(err, particle.ErrAccessDenied) { http.Error(w, "access denied", http.StatusForbidden) return } slog.Error("failed to mark particle as seen", "error", err, "particle_id", particleID, "email", email) http.Error(w, "internal server error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // MarkSeenBatch marks multiple particles as seen by the requester func (h *Handler) MarkSeenBatch(w http.ResponseWriter, r *http.Request) { email, ok := middleware.EmailFromContext(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } var req MarkSeenBatchRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request body", http.StatusBadRequest) return } if len(req.ParticleIDs) == 0 { http.Error(w, "particle_ids are required", http.StatusBadRequest) return } if err := h.particleSvc.MarkSeenBatch(r.Context(), req.ParticleIDs, email); err != nil { slog.Error("failed to mark particles as seen", "error", err, "email", email) http.Error(w, "internal server error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // AckParticle acknowledges a particle (public, permanent) func (h *Handler) AckParticle(w http.ResponseWriter, r *http.Request) { email, ok := middleware.EmailFromContext(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } particleID := r.PathValue("id") if particleID == "" { http.Error(w, "particle id is required", http.StatusBadRequest) return } if err := h.particleSvc.Ack(r.Context(), particleID, email); err != nil { if errors.Is(err, particle.ErrNotFound) { http.Error(w, "particle not found", http.StatusNotFound) return } if errors.Is(err, particle.ErrAccessDenied) { http.Error(w, "access denied", http.StatusForbidden) return } slog.Error("failed to ack particle", "error", err, "particle_id", particleID, "email", email) http.Error(w, "internal server error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // DownloadParticle redirects to a fresh signed download URL for media/file particles func (h *Handler) DownloadParticle(w http.ResponseWriter, r *http.Request) { email, ok := middleware.EmailFromContext(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } particleID := r.PathValue("id") if particleID == "" { http.Error(w, "particle id is required", http.StatusBadRequest) return } p, err := h.particleSvc.GetByID(r.Context(), particleID, email) if err != nil { if errors.Is(err, particle.ErrNotFound) { http.Error(w, "particle not found", http.StatusNotFound) return } if errors.Is(err, particle.ErrAccessDenied) { http.Error(w, "access denied", http.StatusForbidden) return } slog.Error("failed to get particle for download", "error", err, "particle_id", particleID, "email", email) http.Error(w, "internal server error", http.StatusInternalServerError) return } objectID := extractObjectID(p) if objectID == "" { http.Error(w, "particle has no downloadable content", http.StatusBadRequest) return } downloadURL, err := h.depotSvc.GetDownloadURL(r.Context(), objectID) if err != nil { slog.Error("failed to get download URL", "error", err, "object_id", objectID, "particle_id", particleID) http.Error(w, "internal server error", http.StatusInternalServerError) return } http.Redirect(w, r, downloadURL, http.StatusFound) } // ============================================================================ // 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) { email, ok := middleware.EmailFromContext(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 } // Check network membership isMember, err := h.networkSvc.IsMember(r.Context(), req.NetworkId, email) if err != nil { slog.Error("failed to check network membership", "error", err, "network_id", req.NetworkId, "email", email) 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, CreatedAt: obj.CreatedAt, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } // ============================================================================ // Helper Functions // ============================================================================ const listAllBatchSize = 100 // listAllParticles fetches all particles matching the query by paginating in batches. // This keeps the particle service pagination contract intact. func (h *Handler) listAllParticles(ctx context.Context, networkID string, parentID *string, email string, filter particle.ListFilter) ([]*particle.Particle, error) { var all []*particle.Particle var cursor *particle.Cursor for { page, err := h.particleSvc.List(ctx, networkID, parentID, email, filter, cursor, listAllBatchSize) if err != nil { return nil, err } all = append(all, page.Particles...) if !page.HasMore || page.NextCursor == nil { break } cursor = page.NextCursor } return all, nil } // getStreamMembers returns the effective members for a stream. // For custom visibility, returns particle_members. For network_all, returns all network members. func (h *Handler) getStreamMembers(ctx context.Context, sp *particle.Particle, membersMap map[string][]string) []string { if sp.Visibility == particle.VisibilityCustom { return membersMap[sp.ID] } // network_all — return all network members net, err := h.networkSvc.GetByID(ctx, sp.NetworkID) if err != nil { slog.Warn("failed to get network for stream members", "error", err, "network_id", sp.NetworkID) return nil } return net.MemberEmails } func humanToDTO(h *human.Human) Human { return Human{ Id: utils.CreateOptionalString(h.ID), Email: h.Email, EmailPrefix: h.EmailPrefix, CreatedAt: &h.CreatedAt, } } func emailPrefix(email string) string { return strings.Split(email, "@")[0] } func (h *Handler) networkToDTO(ctx context.Context, n *network.Network) (Network, error) { adminHuman, err := h.humanSvc.GetByEmail(ctx, n.AdminEmail) if err != nil { return Network{}, err } // Get member humans humans := make([]Human, 0, len(n.MemberEmails)) for _, email := range n.MemberEmails { hum, err := h.humanSvc.GetByEmail(ctx, email) if err != nil { if err == human.ErrNotFound { humans = append(humans, Human{ Id: nil, CreatedAt: nil, Email: email, EmailPrefix: emailPrefix(email), }) } continue } else { humans = append(humans, humanToDTO(hum)) } } return Network{ Id: n.ID, Name: n.Name, AdminHuman: humanToDTO(adminHuman), Humans: humans, OpenStreamCount: n.OpenStreamCount, OpenStreamCapacity: n.OpenStreamCapacity, CreatedAt: n.CreatedAt, }, nil } func (h *Handler) particleToDTO(ctx context.Context, p *particle.Particle) Particle { dto := Particle{ Id: p.ID, Type: string(p.Type), CreatedByEmail: p.CreatedByEmail, Data: p.Data, UpdatedAt: p.UpdatedAt, CreatedAt: p.CreatedAt, Visibility: string(p.Visibility), Seen: false, Acks: []*AckInfo{}, } return dto } func (h *Handler) streamParticleToDTO(ctx context.Context, p *particle.Particle) StreamParticle { dto := StreamParticle{ Id: p.ID, Type: string(p.Type), CreatedByEmail: p.CreatedByEmail, Data: p.Data, UpdatedAt: p.UpdatedAt, CreatedAt: p.CreatedAt, } return dto } func parseStreamStatus(s string) StreamStatus { switch s { case "open": return STREAM_STATUS_OPEN case "closed": return STREAM_STATUS_CLOSED default: return STREAM_STATUS_UNSPECIFIED } } func extractObjectID(p *particle.Particle) string { if p.Type != particle.TypeMedia && p.Type != particle.TypeFile { return "" } var data struct { ObjectID string `json:"object_id"` } if err := json.Unmarshal(p.Data, &data); err == nil { return data.ObjectID } return "" } 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 "" }