Files
multica/server/internal/handler/invitation.go
Bohan Jiang 607209de7c fix(avatar): serve avatars through a signed endpoint on private buckets (MUL-5393) (#6088)
* fix(avatar): serve avatars through a signed endpoint on private buckets (MUL-5393)

Avatar uploads persisted the raw storage object URL into `avatar_url`. On a
deployment whose bucket is private and has no public CDN domain (S3 with Block
Public Access, R2, MinIO) that URL is a guaranteed 403 in the browser:
ATTACHMENT_DOWNLOAD_MODE only ever applied to the attachment download
endpoint, so every user / agent / squad / workspace avatar rendered broken
even though the upload itself succeeded.

Resolve at read time instead of at upload time. What is persisted stays the
durable object reference, so nothing with a TTL is ever written to the
database and avatars saved by an older build are fixed without a backfill.
What is served is `/api/avatars/<sig>/<key>`, a stable URL the server resolves
per request through the deployment's existing storage download policy
(presigned redirect, CloudFront-signed redirect, or proxied body).

The endpoint is unauthenticated and the HMAC signature is the credential: the
session cookie is SameSite=Strict, so an auth-gated URL cannot be a native
<img src> from Desktop, a mobile webview, or a split-origin self-hosted web
app. The signature covers the storage key and only image extensions resolve,
so an avatar_url pointed at a private document cannot launder it into a
publicly fetchable URL.

Deployments that already work are untouched: a public CDN domain without
per-request signing, and the local-disk backend whose /uploads/* route is
public, both keep returning the raw URL.

Fixes #6024

Co-authored-by: multica-agent <github@multica.ai>

* fix(avatar): only publish avatar-class objects through the signed endpoint (MUL-5393)

Review found that being able to name a storage object was treated as
permission to publish it. `ownedStorageKey` proved only that a URL came from
this deployment's storage, and every image-shaped key was then signed — while
the avatar update endpoints accepted any raw storage URL. A caller who had
seen a private image attachment's URL could submit it as their own avatar, and
the unauthenticated endpoint would keep re-signing it indefinitely. A user
avatar propagates to every workspace that user belongs to, so the leak crossed
workspace boundaries.

Add the missing authorization rule: an object is serveable as an avatar only
when it is avatar-class — a standalone image upload not attached to an issue,
comment, chat session, chat message, or task. The check resolves the backing
attachment row from the id UploadFile embeds in the object filename, so it
needs no lookup by URL and no new index.

It is enforced on both sides. The write side rejects such a value with 403
before anything is stored; the read side re-checks per request, which is what
makes the guarantee hold for rows written before this existed and revokes the
URL if an object is later bound to a comment or chat.

Scope is the `workspaces/` namespace — the only place that can hold content
belonging to someone other than whoever is setting the avatar, covering both
uploads and channel media ingest. Keys elsewhere (the per-user standalone
namespace, or objects an operator placed in the bucket) stay usable, which
keeps the documented "an explicit avatar_url is preserved" contract intact.

Uploader identity is deliberately not part of the rule: duplicating an agent
legitimately reuses the source agent's avatar object, which a different admin
may have uploaded. Publishing someone else's unbound image would require
knowing its URL, and unbound rows appear in no listing endpoint.

Also clamp the 302's cache lifetime to half the signed URL's own TTL (0 ->
no-store). ATTACHMENT_DOWNLOAD_URL_TTL takes any positive duration, so the
fixed 60s could outlive the target it pointed at on a short-TTL deployment.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-29 15:39:18 +08:00

553 lines
18 KiB
Go

package handler
import (
"encoding/json"
"log/slog"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/analytics"
"github.com/multica-ai/multica/server/internal/logger"
obsmetrics "github.com/multica-ai/multica/server/internal/metrics"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
// InvitationResponse is the JSON shape returned for a workspace invitation.
type InvitationResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
InviterID string `json:"inviter_id"`
InviteeEmail string `json:"invitee_email"`
InviteeUserID *string `json:"invitee_user_id"`
Role string `json:"role"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ExpiresAt string `json:"expires_at"`
// Enriched fields (present in list responses).
InviterName string `json:"inviter_name,omitempty"`
InviterEmail string `json:"inviter_email,omitempty"`
WorkspaceName string `json:"workspace_name,omitempty"`
}
func invitationToResponse(inv db.WorkspaceInvitation) InvitationResponse {
return InvitationResponse{
ID: uuidToString(inv.ID),
WorkspaceID: uuidToString(inv.WorkspaceID),
InviterID: uuidToString(inv.InviterID),
InviteeEmail: inv.InviteeEmail,
InviteeUserID: uuidToPtr(inv.InviteeUserID),
Role: inv.Role,
Status: inv.Status,
CreatedAt: timestampToString(inv.CreatedAt),
UpdatedAt: timestampToString(inv.UpdatedAt),
ExpiresAt: timestampToString(inv.ExpiresAt),
}
}
// ---------------------------------------------------------------------------
// CreateInvitation replaces the old "instant-add" CreateMember flow.
// POST /api/workspaces/{id}/members (same endpoint, new behaviour)
// ---------------------------------------------------------------------------
func (h *Handler) CreateInvitation(w http.ResponseWriter, r *http.Request) {
workspaceID := workspaceIDFromURL(r, "id")
requester, ok := h.workspaceMember(w, r, workspaceID)
if !ok {
return
}
var req CreateMemberRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
email := strings.ToLower(strings.TrimSpace(req.Email))
if email == "" {
writeError(w, http.StatusBadRequest, "email is required")
return
}
role, valid := normalizeMemberRole(req.Role)
if !valid {
writeError(w, http.StatusBadRequest, "invalid member role")
return
}
if role == "owner" {
writeError(w, http.StatusBadRequest, "cannot invite as owner")
return
}
// Check if the user is already a member.
existingUser, err := h.Queries.GetUserByEmail(r.Context(), email)
if err == nil {
_, memberErr := h.Queries.GetMemberByUserAndWorkspace(r.Context(), db.GetMemberByUserAndWorkspaceParams{
UserID: existingUser.ID,
WorkspaceID: requester.WorkspaceID,
})
if memberErr == nil {
writeError(w, http.StatusConflict, "user is already a member")
return
}
}
// Drop any past-due pending invitations to 'expired' first. The partial unique
// index idx_invitation_unique_pending only filters by status = 'pending', so a
// stale row would otherwise block CreateInvitation below — see issue #2055.
if err := h.Queries.ExpireStalePendingInvitations(r.Context(), db.ExpireStalePendingInvitationsParams{
WorkspaceID: requester.WorkspaceID,
InviteeEmail: email,
}); err != nil {
slog.Warn("expire stale invitations failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID, "email", email)...)
writeError(w, http.StatusInternalServerError, "failed to create invitation")
return
}
// Check if there is still a live pending invitation.
_, err = h.Queries.GetPendingInvitationByEmail(r.Context(), db.GetPendingInvitationByEmailParams{
WorkspaceID: requester.WorkspaceID,
InviteeEmail: email,
})
if err == nil {
writeError(w, http.StatusConflict, "invitation already pending for this email")
return
}
// Resolve invitee_user_id if the user already exists.
var inviteeUserID pgtype.UUID
if existingUser.ID.Valid {
inviteeUserID = existingUser.ID
}
inv, err := h.Queries.CreateInvitation(r.Context(), db.CreateInvitationParams{
WorkspaceID: requester.WorkspaceID,
InviterID: requester.UserID,
InviteeEmail: email,
InviteeUserID: inviteeUserID,
Role: role,
})
if err != nil {
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "invitation already pending for this email")
return
}
slog.Warn("create invitation failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID, "email", email)...)
writeError(w, http.StatusInternalServerError, "failed to create invitation")
return
}
slog.Info("invitation created", append(logger.RequestAttrs(r), "invitation_id", uuidToString(inv.ID), "workspace_id", workspaceID, "email", email, "role", role)...)
resp := invitationToResponse(inv)
// Notify the invitee in real time if they are a registered user.
userID := requestUserID(r)
eventPayload := map[string]any{"invitation": resp}
var workspaceName string
if ws, err := h.Queries.GetWorkspace(r.Context(), requester.WorkspaceID); err == nil {
workspaceName = ws.Name
eventPayload["workspace_name"] = ws.Name
}
h.publish(protocol.EventInvitationCreated, uuidToString(requester.WorkspaceID), "member", userID, eventPayload)
obsmetrics.RecordEvent(h.Analytics, h.Metrics, analytics.TeamInviteSent(
uuidToString(requester.UserID),
uuidToString(requester.WorkspaceID),
email,
"email",
))
// Send invitation email (fire-and-forget).
if h.EmailService != nil && workspaceName != "" {
inviterName := email // fallback
if inviter, err := h.Queries.GetUser(r.Context(), requester.UserID); err == nil {
inviterName = inviter.Name
}
invID := uuidToString(inv.ID)
go func() {
if err := h.EmailService.SendInvitationEmail(email, inviterName, workspaceName, invID); err != nil {
slog.Warn("failed to send invitation email", "email", email, "error", err)
}
}()
}
writeJSON(w, http.StatusCreated, resp)
}
// ---------------------------------------------------------------------------
// ListWorkspaceInvitations — pending invitations for a workspace (admin view).
// GET /api/workspaces/{id}/invitations
// ---------------------------------------------------------------------------
func (h *Handler) ListWorkspaceInvitations(w http.ResponseWriter, r *http.Request) {
workspaceID := workspaceIDFromURL(r, "id")
workspaceUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
if !ok {
return
}
rows, err := h.Queries.ListPendingInvitationsByWorkspace(r.Context(), workspaceUUID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list invitations")
return
}
resp := make([]InvitationResponse, len(rows))
for i, row := range rows {
resp[i] = InvitationResponse{
ID: uuidToString(row.ID),
WorkspaceID: uuidToString(row.WorkspaceID),
InviterID: uuidToString(row.InviterID),
InviteeEmail: row.InviteeEmail,
InviteeUserID: uuidToPtr(row.InviteeUserID),
Role: row.Role,
Status: row.Status,
CreatedAt: timestampToString(row.CreatedAt),
UpdatedAt: timestampToString(row.UpdatedAt),
ExpiresAt: timestampToString(row.ExpiresAt),
InviterName: row.InviterName,
InviterEmail: row.InviterEmail,
}
}
writeJSON(w, http.StatusOK, resp)
}
// ---------------------------------------------------------------------------
// RevokeInvitation — admin cancels a pending invitation.
// DELETE /api/workspaces/{id}/invitations/{invitationId}
// ---------------------------------------------------------------------------
func (h *Handler) RevokeInvitation(w http.ResponseWriter, r *http.Request) {
workspaceID := workspaceIDFromURL(r, "id")
invitationID := chi.URLParam(r, "invitationId")
workspaceUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
if !ok {
return
}
invitationUUID, ok := parseUUIDOrBadRequest(w, invitationID, "invitation id")
if !ok {
return
}
inv, err := h.Queries.GetInvitation(r.Context(), invitationUUID)
if err != nil || uuidToString(inv.WorkspaceID) != uuidToString(workspaceUUID) || inv.Status != "pending" {
writeError(w, http.StatusNotFound, "invitation not found")
return
}
if err := h.Queries.RevokeInvitation(r.Context(), inv.ID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to revoke invitation")
return
}
slog.Info("invitation revoked", "invitation_id", invitationID, "workspace_id", workspaceID)
userID := requestUserID(r)
h.publish(protocol.EventInvitationRevoked, uuidToString(workspaceUUID), "member", userID, map[string]any{
"invitation_id": uuidToString(inv.ID),
"invitee_email": inv.InviteeEmail,
"invitee_user_id": uuidToPtr(inv.InviteeUserID),
})
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------------------
// GetMyInvitation — get a single invitation by ID (for the invite accept page).
// GET /api/invitations/{id}
// ---------------------------------------------------------------------------
func (h *Handler) GetMyInvitation(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
invitationID := chi.URLParam(r, "id")
invitationUUID, ok := parseUUIDOrBadRequest(w, invitationID, "invitation id")
if !ok {
return
}
inv, err := h.Queries.GetInvitation(r.Context(), invitationUUID)
if err != nil {
writeError(w, http.StatusNotFound, "invitation not found")
return
}
// Verify the invitation belongs to the current user.
user, err := h.Queries.GetUser(r.Context(), parseUUID(userID))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load user")
return
}
if strings.ToLower(user.Email) != inv.InviteeEmail && uuidToString(inv.InviteeUserID) != userID {
writeError(w, http.StatusForbidden, "invitation does not belong to you")
return
}
resp := invitationToResponse(inv)
// Enrich with workspace name and inviter name.
if ws, err := h.Queries.GetWorkspace(r.Context(), inv.WorkspaceID); err == nil {
resp.WorkspaceName = ws.Name
}
if inviter, err := h.Queries.GetUser(r.Context(), inv.InviterID); err == nil {
resp.InviterName = inviter.Name
resp.InviterEmail = inviter.Email
}
writeJSON(w, http.StatusOK, resp)
}
// ---------------------------------------------------------------------------
// ListMyInvitations — current user's pending invitations across all workspaces.
// GET /api/invitations
// ---------------------------------------------------------------------------
func (h *Handler) ListMyInvitations(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
user, err := h.Queries.GetUser(r.Context(), parseUUID(userID))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load user")
return
}
rows, err := h.Queries.ListPendingInvitationsForUser(r.Context(), db.ListPendingInvitationsForUserParams{
InviteeUserID: user.ID,
InviteeEmail: user.Email,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list invitations")
return
}
resp := make([]InvitationResponse, len(rows))
for i, row := range rows {
resp[i] = InvitationResponse{
ID: uuidToString(row.ID),
WorkspaceID: uuidToString(row.WorkspaceID),
InviterID: uuidToString(row.InviterID),
InviteeEmail: row.InviteeEmail,
InviteeUserID: uuidToPtr(row.InviteeUserID),
Role: row.Role,
Status: row.Status,
CreatedAt: timestampToString(row.CreatedAt),
UpdatedAt: timestampToString(row.UpdatedAt),
ExpiresAt: timestampToString(row.ExpiresAt),
WorkspaceName: row.WorkspaceName,
InviterName: row.InviterName,
InviterEmail: row.InviterEmail,
}
}
writeJSON(w, http.StatusOK, resp)
}
// ---------------------------------------------------------------------------
// AcceptInvitation — user accepts a pending invitation.
// POST /api/invitations/{id}/accept
// ---------------------------------------------------------------------------
func (h *Handler) AcceptInvitation(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
invitationID := chi.URLParam(r, "id")
invitationUUID, ok := parseUUIDOrBadRequest(w, invitationID, "invitation id")
if !ok {
return
}
inv, err := h.Queries.GetInvitation(r.Context(), invitationUUID)
if err != nil {
writeError(w, http.StatusNotFound, "invitation not found")
return
}
// Verify the invitation belongs to the current user.
user, err := h.Queries.GetUser(r.Context(), parseUUID(userID))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load user")
return
}
if strings.ToLower(user.Email) != inv.InviteeEmail && uuidToString(inv.InviteeUserID) != userID {
writeError(w, http.StatusForbidden, "invitation does not belong to you")
return
}
if inv.Status != "pending" {
writeError(w, http.StatusBadRequest, "invitation is not pending")
return
}
// Check expiry.
if inv.ExpiresAt.Valid && inv.ExpiresAt.Time.Before(time.Now()) {
writeError(w, http.StatusGone, "invitation has expired")
return
}
// Use a transaction: mark accepted + create member atomically.
tx, err := h.TxStarter.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to accept invitation")
return
}
defer tx.Rollback(r.Context())
qtx := h.Queries.WithTx(tx)
accepted, err := qtx.AcceptInvitation(r.Context(), inv.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to accept invitation")
return
}
member, err := qtx.CreateMember(r.Context(), db.CreateMemberParams{
WorkspaceID: accepted.WorkspaceID,
UserID: user.ID,
Role: accepted.Role,
})
if err != nil {
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "you are already a member of this workspace")
return
}
writeError(w, http.StatusInternalServerError, "failed to create membership")
return
}
// Accepting an invite marks the invitee as onboarded. The web /
// desktop workspace layout has a hard onboarded_at gate; without
// this mark, an invitee landing on their first workspace would be
// redirected back to /onboarding to fill out a questionnaire for a
// workspace someone else already set up. Atomic with CreateMember so
// `member` and `onboarded_at` can never disagree. COALESCE in
// MarkUserOnboarded keeps the call idempotent for users joining
// additional workspaces after their first.
firstOnboardingCompletion := !user.OnboardedAt.Valid
onboardedUser, err := qtx.MarkUserOnboarded(r.Context(), user.ID)
if err != nil {
slog.Warn("accept invitation: mark user onboarded failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", uuidToString(accepted.WorkspaceID))...)
writeError(w, http.StatusInternalServerError, "failed to mark user onboarded")
return
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "failed to accept invitation")
return
}
slog.Info("invitation accepted", "invitation_id", invitationID, "user_id", userID, "workspace_id", uuidToString(accepted.WorkspaceID))
wsID := uuidToString(accepted.WorkspaceID)
memberResp := h.memberWithUserResponse(member, user)
// Broadcast member:added so existing clients update their member lists.
eventPayload := map[string]any{"member": memberResp}
if ws, err := h.Queries.GetWorkspace(r.Context(), accepted.WorkspaceID); err == nil {
eventPayload["workspace_name"] = ws.Name
}
h.publish(protocol.EventMemberAdded, wsID, "member", userID, eventPayload)
// Notify the workspace about the acceptance.
h.publish(protocol.EventInvitationAccepted, wsID, "member", userID, map[string]any{
"invitation_id": uuidToString(accepted.ID),
"member": memberResp,
})
h.notifyDaemonWorkspacesChanged(userID)
// days_since_invite rounds down to whole days so the funnel segments
// "accepted same day" cleanly from "accepted later". inv.CreatedAt is
// the invitation row's insertion time so this is safe to compute here.
var daysSinceInvite int64
if inv.CreatedAt.Valid {
daysSinceInvite = int64(time.Since(inv.CreatedAt.Time).Hours() / 24)
}
obsmetrics.RecordEvent(h.Analytics, h.Metrics, analytics.TeamInviteAccepted(
userID,
wsID,
daysSinceInvite,
))
if firstOnboardingCompletion {
onboardedAt := ""
if onboardedUser.OnboardedAt.Valid {
onboardedAt = onboardedUser.OnboardedAt.Time.UTC().Format("2006-01-02T15:04:05Z07:00")
}
obsmetrics.RecordEvent(h.Analytics, h.Metrics, analytics.OnboardingCompleted(
userID,
wsID,
analytics.OnboardingPathInviteAccept,
onboardedAt,
onboardedUser.CloudWaitlistEmail.Valid,
))
}
writeJSON(w, http.StatusOK, memberResp)
}
// ---------------------------------------------------------------------------
// DeclineInvitation — user declines a pending invitation.
// POST /api/invitations/{id}/decline
// ---------------------------------------------------------------------------
func (h *Handler) DeclineInvitation(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
invitationID := chi.URLParam(r, "id")
invitationUUID, ok := parseUUIDOrBadRequest(w, invitationID, "invitation id")
if !ok {
return
}
inv, err := h.Queries.GetInvitation(r.Context(), invitationUUID)
if err != nil {
writeError(w, http.StatusNotFound, "invitation not found")
return
}
// Verify the invitation belongs to the current user.
user, err := h.Queries.GetUser(r.Context(), parseUUID(userID))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load user")
return
}
if strings.ToLower(user.Email) != inv.InviteeEmail && uuidToString(inv.InviteeUserID) != userID {
writeError(w, http.StatusForbidden, "invitation does not belong to you")
return
}
if inv.Status != "pending" {
writeError(w, http.StatusBadRequest, "invitation is not pending")
return
}
declined, err := h.Queries.DeclineInvitation(r.Context(), inv.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to decline invitation")
return
}
slog.Info("invitation declined", "invitation_id", invitationID, "user_id", userID)
wsID := uuidToString(declined.WorkspaceID)
h.publish(protocol.EventInvitationDeclined, wsID, "member", userID, map[string]any{
"invitation_id": uuidToString(declined.ID),
"invitee_email": declined.InviteeEmail,
})
w.WriteHeader(http.StatusNoContent)
}