bf0147d542
* refactor: update api and client to reference humanIds * fix: prevent deletion of network member This may cause various side effects if there is data in other services which reference this member
824 lines
24 KiB
Go
824 lines
24 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"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 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"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// Auth Request/Response DTOs
|
|
|
|
type RequestSignInCodeRequest struct {
|
|
Email string `json:"email"`
|
|
}
|
|
|
|
type SignInRequest struct {
|
|
Email string `json:"email"`
|
|
Code string `json:"code"`
|
|
}
|
|
|
|
type SignInResponse struct {
|
|
Human Human `json:"human"`
|
|
Token string `json:"token"`
|
|
}
|
|
|
|
// Network Request DTOs
|
|
|
|
type CreateNetworkRequest struct {
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
type AddMembersToNetworkRequest struct {
|
|
EmailAddresses []string `json:"email_addresses"`
|
|
}
|
|
|
|
type SetOpenStreamCapacityRequest struct {
|
|
Capacity int `json:"capacity"`
|
|
}
|
|
|
|
type MembersRequest struct {
|
|
Emails []string `json:"emails"`
|
|
}
|
|
|
|
type Invitation struct {
|
|
NetworkId string `json:"network_id"`
|
|
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"`
|
|
}
|
|
|
|
// Depot DTOs
|
|
|
|
type PrepareUploadRequest struct {
|
|
NetworkId string `json:"network_id"`
|
|
Name string `json:"name"`
|
|
ContentType string `json:"content_type"`
|
|
ContentLength int64 `json:"content_length"`
|
|
}
|
|
|
|
type PrepareUploadResponse struct {
|
|
ObjectID string `json:"object_id"`
|
|
UploadURL string `json:"upload_url"`
|
|
UploadHeaders map[string]string `json:"upload_headers"`
|
|
}
|
|
|
|
type DepotObject struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
ContentType string `json:"content_type"`
|
|
ContentLength int64 `json:"content_length"`
|
|
ContainsContent bool `json:"contains_content"`
|
|
DownloadURL string `json:"download_url,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// ============================================================================
|
|
// Auth Handlers
|
|
// ============================================================================
|
|
|
|
// RequestSignInCode creates a human account if not already existent and sends a sign-in code
|
|
func (h *Handler) RequestSignInCode(w http.ResponseWriter, r *http.Request) {
|
|
var req RequestSignInCodeRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.Email == "" {
|
|
http.Error(w, "email is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Auto-create human if doesn't exist
|
|
_, err := h.humanSvc.GetOrCreateByEmail(r.Context(), req.Email)
|
|
if err != nil {
|
|
slog.Error("failed to get or create human", "error", err, "email", req.Email)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Request sign-in code
|
|
if err := h.authSvc.RequestSignInCode(r.Context(), req.Email); err != nil {
|
|
slog.Error("failed to request sign-in code", "error", err, "email", req.Email)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// SignIn verifies the code and returns a session token
|
|
func (h *Handler) SignIn(w http.ResponseWriter, r *http.Request) {
|
|
var req SignInRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.Email == "" || req.Code == "" {
|
|
http.Error(w, "email and code are required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Look up human first so we can store humanId in the session
|
|
hum, err := h.humanSvc.GetByEmail(r.Context(), req.Email)
|
|
if err != nil {
|
|
if errors.Is(err, human.ErrNotFound) {
|
|
http.Error(w, "human not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
slog.Error("failed to get human for sign-in", "error", err, "email", req.Email)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
token, err := h.authSvc.VerifySignInCode(r.Context(), req.Email, req.Code, hum.ID)
|
|
if err != nil {
|
|
if errors.Is(err, auth.ErrInvalidCode) {
|
|
http.Error(w, "invalid code", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
slog.Error("failed to verify sign-in code", "error", err, "email", req.Email)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
resp := SignInResponse{
|
|
Human: humanToDTO(hum),
|
|
Token: token,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
// SignOut deletes the session from the token in headers
|
|
func (h *Handler) SignOut(w http.ResponseWriter, r *http.Request) {
|
|
token := extractBearerToken(r)
|
|
if token == "" {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
if err := h.authSvc.SignOut(r.Context(), token); err != nil {
|
|
slog.Error("failed to sign out", "error", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// GetCurrentHuman returns the authenticated human
|
|
func (h *Handler) GetCurrentHuman(w http.ResponseWriter, r *http.Request) {
|
|
email, ok := middleware.EmailFromContext(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
hum, err := h.humanSvc.GetByEmail(r.Context(), email)
|
|
if err != nil {
|
|
if errors.Is(err, human.ErrNotFound) {
|
|
http.Error(w, "human not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
slog.Error("failed to get current human", "error", err, "email", email)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
dto := humanToDTO(hum)
|
|
json.NewEncoder(w).Encode(dto)
|
|
}
|
|
|
|
// ============================================================================
|
|
// Network Handlers
|
|
// ============================================================================
|
|
|
|
// CreateNetwork creates a new network
|
|
func (h *Handler) CreateNetwork(w http.ResponseWriter, r *http.Request) {
|
|
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
var req CreateNetworkRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
net, err := h.networkSvc.Create(r.Context(), req.Name, humanId)
|
|
if err != nil {
|
|
if errors.Is(err, network.ErrInvalidName) {
|
|
http.Error(w, "name cannot be empty", http.StatusBadRequest)
|
|
return
|
|
}
|
|
slog.Error("failed to create network", "error", err, "humanId", humanId, "name", req.Name)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
resp, err := h.networkToDTO(r.Context(), net)
|
|
if err != nil {
|
|
slog.Error("failed to convert network to DTO", "error", err, "network_id", net.ID)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusCreated)
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
// ListNetworks retrieves networks for the authenticated human
|
|
func (h *Handler) ListNetworks(w http.ResponseWriter, r *http.Request) {
|
|
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
networks, err := h.networkSvc.ListForHuman(r.Context(), humanId)
|
|
if err != nil {
|
|
slog.Error("failed to list networks", "error", err, "humanId", humanId)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
resp := make([]Network, 0, len(networks))
|
|
for _, net := range networks {
|
|
dto, err := h.networkToDTO(r.Context(), net)
|
|
if err != nil {
|
|
slog.Warn("failed to convert network to DTO in list", "error", err, "network_id", net.ID)
|
|
continue
|
|
}
|
|
resp = append(resp, dto)
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
// GetNetwork retrieves a specific network
|
|
func (h *Handler) GetNetwork(w http.ResponseWriter, r *http.Request) {
|
|
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
networkID := r.PathValue("id")
|
|
if networkID == "" {
|
|
http.Error(w, "network id is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
isMember, err := h.networkSvc.IsMember(r.Context(), networkID, humanId)
|
|
if err != nil {
|
|
slog.Error("failed to check network membership", "error", err, "network_id", networkID, "humanId", humanId)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !isMember {
|
|
http.Error(w, "access denied", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
net, err := h.networkSvc.GetByID(r.Context(), networkID)
|
|
if err != nil {
|
|
if errors.Is(err, network.ErrNotFound) {
|
|
http.Error(w, "network not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
slog.Error("failed to get network", "error", err, "network_id", networkID)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
resp, err := h.networkToDTO(r.Context(), net)
|
|
if err != nil {
|
|
slog.Error("failed to convert network to DTO", "error", err, "network_id", networkID)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
// AddMembersToNetwork adds members to a network. Registered users are added as members,
|
|
// unregistered users receive email invitations.
|
|
func (h *Handler) AddMembersToNetwork(w http.ResponseWriter, r *http.Request) {
|
|
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
networkID := r.PathValue("id")
|
|
if networkID == "" {
|
|
http.Error(w, "network id is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
isMember, err := h.networkSvc.IsMember(r.Context(), networkID, humanId)
|
|
if err != nil {
|
|
slog.Error("failed to check network membership", "error", err, "network_id", networkID, "humanId", humanId)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !isMember {
|
|
http.Error(w, "access denied", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
var req AddMembersToNetworkRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if len(req.EmailAddresses) == 0 {
|
|
http.Error(w, "email addresses are required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Resolve emails: registered users become members, unregistered get invitations
|
|
var memberHumanIds []string
|
|
var inviteEmails []string
|
|
for _, email := range req.EmailAddresses {
|
|
normalized, err := utils.NormalizeEmail(email)
|
|
if err != nil {
|
|
http.Error(w, "invalid email: "+email, http.StatusBadRequest)
|
|
return
|
|
}
|
|
hum, err := h.humanSvc.GetByEmail(r.Context(), normalized)
|
|
if err != nil {
|
|
if errors.Is(err, human.ErrNotFound) {
|
|
inviteEmails = append(inviteEmails, normalized)
|
|
continue
|
|
}
|
|
slog.Error("failed to look up human by email", "error", err, "email", normalized)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
memberHumanIds = append(memberHumanIds, hum.ID)
|
|
}
|
|
|
|
if len(memberHumanIds) > 0 {
|
|
if err := h.networkSvc.AddMembers(r.Context(), networkID, memberHumanIds); err != nil {
|
|
slog.Error("failed to add members to network", "error", err, "network_id", networkID)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
if len(inviteEmails) > 0 {
|
|
if err := h.networkSvc.InviteByEmail(r.Context(), networkID, inviteEmails); err != nil {
|
|
slog.Error("failed to invite members to network", "error", err, "network_id", networkID)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Return updated network
|
|
net, err := h.networkSvc.GetByID(r.Context(), networkID)
|
|
if err != nil {
|
|
slog.Error("failed to get network after adding members", "error", err, "network_id", networkID)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
resp, err := h.networkToDTO(r.Context(), net)
|
|
if err != nil {
|
|
slog.Error("failed to convert network to DTO", "error", err, "network_id", networkID)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
// RemoveMemberFromNetwork removes a member from a network
|
|
func (h *Handler) RemoveMemberFromNetwork(w http.ResponseWriter, r *http.Request) {
|
|
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
networkID := r.PathValue("id")
|
|
memberHumanId := r.PathValue("humanId")
|
|
if networkID == "" || memberHumanId == "" {
|
|
http.Error(w, "network id and member humanId are required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
isMember, err := h.networkSvc.IsMember(r.Context(), networkID, humanId)
|
|
if err != nil {
|
|
slog.Error("failed to check network membership", "error", err, "network_id", networkID, "humanId", humanId)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !isMember {
|
|
http.Error(w, "access denied", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
if err := h.networkSvc.RemoveMember(r.Context(), networkID, memberHumanId); err != nil {
|
|
slog.Error("failed to remove member from network", "error", err, "network_id", networkID, "memberHumanId", memberHumanId)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// ListInvitationsForNetwork returns pending invitations for a network
|
|
func (h *Handler) ListInvitationsForNetwork(w http.ResponseWriter, r *http.Request) {
|
|
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
networkID := r.PathValue("id")
|
|
if networkID == "" {
|
|
http.Error(w, "network id is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
isMember, err := h.networkSvc.IsMember(r.Context(), networkID, humanId)
|
|
if err != nil {
|
|
slog.Error("failed to check network membership", "error", err, "network_id", networkID, "humanId", humanId)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !isMember {
|
|
http.Error(w, "access denied", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
invitations, err := h.networkSvc.ListInvitationsForNetwork(r.Context(), networkID)
|
|
if err != nil {
|
|
slog.Error("failed to list invitations", "error", err, "network_id", networkID)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
resp := make([]Invitation, 0, len(invitations))
|
|
for _, inv := range invitations {
|
|
resp = append(resp, Invitation{
|
|
NetworkId: inv.NetworkID,
|
|
Email: inv.Email,
|
|
CreatedAt: inv.CreatedAt,
|
|
})
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
// ListMyInvitations returns pending invitations for the authenticated user
|
|
func (h *Handler) ListMyInvitations(w http.ResponseWriter, r *http.Request) {
|
|
email, ok := middleware.EmailFromContext(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
invitations, err := h.networkSvc.ListInvitationsForEmail(r.Context(), email)
|
|
if err != nil {
|
|
slog.Error("failed to list invitations for email", "error", err, "email", email)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
resp := make([]Invitation, 0, len(invitations))
|
|
for _, inv := range invitations {
|
|
resp = append(resp, Invitation{
|
|
NetworkId: inv.NetworkID,
|
|
Email: inv.Email,
|
|
CreatedAt: inv.CreatedAt,
|
|
})
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
// AcceptInvitation accepts a pending network invitation for the authenticated user
|
|
func (h *Handler) AcceptInvitation(w http.ResponseWriter, r *http.Request) {
|
|
email, ok := middleware.EmailFromContext(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
var req AcceptInvitationRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.NetworkId == "" {
|
|
http.Error(w, "network_id is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := h.networkSvc.AcceptInvitation(r.Context(), req.NetworkId, email, humanId); err != nil {
|
|
slog.Error("failed to accept invitation", "error", err, "network_id", req.NetworkId, "email", email)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// RevokeInvitation revokes a pending invitation from a network
|
|
func (h *Handler) RevokeInvitation(w http.ResponseWriter, r *http.Request) {
|
|
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
networkID := r.PathValue("id")
|
|
if networkID == "" {
|
|
http.Error(w, "network id is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
isMember, err := h.networkSvc.IsMember(r.Context(), networkID, humanId)
|
|
if err != nil {
|
|
slog.Error("failed to check network membership", "error", err, "network_id", networkID, "humanId", humanId)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !isMember {
|
|
http.Error(w, "access denied", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
var req RevokeInvitationRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.Email == "" {
|
|
http.Error(w, "email is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := h.networkSvc.RevokeInvitation(r.Context(), networkID, req.Email); err != nil {
|
|
slog.Error("failed to revoke invitation", "error", err, "network_id", networkID, "email", req.Email)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// DownloadParticleMedia returns a fresh signed download URL for media/file particles
|
|
func (h *Handler) DownloadParticleMedia(w http.ResponseWriter, r *http.Request) {
|
|
_, ok := middleware.EmailFromContext(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
// TODO: integrate firebase to fetch particle, and verify visibility for this particle's media
|
|
|
|
objectID := r.PathValue("id")
|
|
|
|
downloadURL, err := h.depotSvc.GetDownloadURL(r.Context(), objectID)
|
|
if err != nil {
|
|
slog.Error("failed to get download URL", "error", err, "object_id", objectID)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]string{"url": downloadURL})
|
|
}
|
|
|
|
// ============================================================================
|
|
// Depot Handlers
|
|
// ============================================================================
|
|
|
|
// PrepareUpload prepares an upload and returns a signed URL for direct upload to GCS
|
|
func (h *Handler) PrepareUpload(w http.ResponseWriter, r *http.Request) {
|
|
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
var req PrepareUploadRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.NetworkId == "" {
|
|
http.Error(w, "network_id is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
isMember, err := h.networkSvc.IsMember(r.Context(), req.NetworkId, humanId)
|
|
if err != nil {
|
|
slog.Error("failed to check network membership", "error", err, "network_id", req.NetworkId, "humanId", humanId)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !isMember {
|
|
http.Error(w, "access denied", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
input := depot.PrepareUploadInput{
|
|
Prefix: req.NetworkId,
|
|
Name: req.Name,
|
|
ContentType: req.ContentType,
|
|
ContentLength: req.ContentLength,
|
|
}
|
|
|
|
result, err := h.depotSvc.PrepareUpload(r.Context(), input)
|
|
if err != nil {
|
|
if errors.Is(err, depot.ErrInvalidInput) {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
slog.Error("failed to prepare upload", "error", err, "network_id", req.NetworkId, "name", req.Name)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
resp := PrepareUploadResponse{
|
|
ObjectID: result.ObjectID,
|
|
UploadURL: result.UploadURL,
|
|
UploadHeaders: result.UploadHeaders,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
// ConfirmUpload confirms that an upload has been completed
|
|
func (h *Handler) ConfirmUpload(w http.ResponseWriter, r *http.Request) {
|
|
_, ok := middleware.EmailFromContext(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
objectID := r.PathValue("id")
|
|
if objectID == "" {
|
|
http.Error(w, "object id is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
obj, err := h.depotSvc.ConfirmUpload(r.Context(), objectID)
|
|
if err != nil {
|
|
if errors.Is(err, depot.ErrNotFound) {
|
|
http.Error(w, "object not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if errors.Is(err, depot.ErrInvalidInput) {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
slog.Error("failed to confirm upload", "error", err, "object_id", objectID)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
resp := DepotObject{
|
|
ID: obj.ID,
|
|
Name: obj.Name,
|
|
ContentType: obj.ContentType,
|
|
ContentLength: obj.ContentLength,
|
|
ContainsContent: obj.ContainsContent,
|
|
DownloadURL: "",
|
|
CreatedAt: obj.CreatedAt,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
// ============================================================================
|
|
// Helper Functions
|
|
// ============================================================================
|
|
|
|
func humanToDTO(h *human.Human) Human {
|
|
return Human{
|
|
Id: h.ID,
|
|
Email: h.Email,
|
|
EmailPrefix: h.EmailPrefix,
|
|
CreatedAt: h.CreatedAt,
|
|
}
|
|
}
|
|
|
|
func (h *Handler) networkToDTO(ctx context.Context, n *network.Network) (Network, error) {
|
|
adminHuman, err := h.humanSvc.GetByID(ctx, n.AdminHumanId)
|
|
if err != nil {
|
|
return Network{}, err
|
|
}
|
|
|
|
humans := make([]Human, 0, len(n.MemberHumanIds))
|
|
for _, memberHumanId := range n.MemberHumanIds {
|
|
hum, err := h.humanSvc.GetByID(ctx, memberHumanId)
|
|
if err != nil {
|
|
slog.Warn("failed to look up network member", "humanId", memberHumanId, "error", err)
|
|
continue
|
|
}
|
|
humans = append(humans, humanToDTO(hum))
|
|
}
|
|
|
|
return Network{
|
|
Id: n.ID,
|
|
Name: n.Name,
|
|
AdminHuman: humanToDTO(adminHuman),
|
|
Humans: humans,
|
|
CreatedAt: n.CreatedAt,
|
|
}, nil
|
|
}
|
|
|
|
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 ""
|
|
}
|