mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-05 17:40:11 +02:00
* 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>
373 lines
13 KiB
Go
373 lines
13 KiB
Go
package handler
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/mail"
|
|
"strings"
|
|
|
|
"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"
|
|
"github.com/multica-ai/multica/server/internal/middleware"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
// Upper bound on free-text fields. `cloudWaitlistReasonMaxLen` is a
|
|
// product cap ("we don't need an essay for a waitlist"); the body-size
|
|
// cap further down is defense in depth against arbitrary storage
|
|
// abuse via the JSON body.
|
|
const (
|
|
cloudWaitlistReasonMaxLen = 500
|
|
|
|
// PatchOnboarding body is a tiny JSON with at most a 3-question
|
|
// questionnaire. 16 KiB is ~10x the realistic ceiling — it's the
|
|
// minimum that keeps the door open for future fields without
|
|
// letting a malicious user stuff the JSONB column.
|
|
patchOnboardingBodyLimit = 16 * 1024
|
|
)
|
|
|
|
// completeOnboardingRequest carries the client's view of which exit the
|
|
// user took from the flow. Used purely as an analytics dimension — server
|
|
// state (onboarded_at) flips the same way regardless. Unknown / missing
|
|
// → OnboardingPathUnknown so legacy clients still complete cleanly, just
|
|
// without a funnel-ready label.
|
|
//
|
|
// `workspace_id` is retained for analytics enrichment; the v2 code path
|
|
// used it to seed an install-runtime issue inside the same transaction,
|
|
// but in v3 every workspace-content seeding lives in the frontend
|
|
// welcome hook (see packages/views/workspace/welcome-after-onboarding.tsx).
|
|
type completeOnboardingRequest struct {
|
|
CompletionPath string `json:"completion_path,omitempty"`
|
|
WorkspaceID string `json:"workspace_id,omitempty"`
|
|
}
|
|
|
|
var validCompletionPaths = map[string]struct{}{
|
|
analytics.OnboardingPathFull: {},
|
|
analytics.OnboardingPathRuntimeSkipped: {},
|
|
analytics.OnboardingPathCloudWaitlist: {},
|
|
analytics.OnboardingPathSkipExisting: {},
|
|
analytics.OnboardingPathInviteAccept: {},
|
|
}
|
|
|
|
// CompleteOnboarding marks the authenticated user as having completed
|
|
// onboarding. Idempotent: the underlying query uses COALESCE so the
|
|
// original timestamp is preserved if called more than once.
|
|
//
|
|
// Emits `onboarding_completed` exactly once — the first call that
|
|
// actually flips `onboarded_at` from NULL. Subsequent calls are still
|
|
// 200 OK (for client-side retries) but skip the event so the funnel
|
|
// counts honest first-completion.
|
|
//
|
|
// V3 has no in-handler seeding side effect: workspace content (Helper
|
|
// agent, starter issues, install-runtime guides) is created by the
|
|
// frontend welcome hook via the generic CreateAgent / CreateIssue
|
|
// endpoints. This handler does one thing: flip the field.
|
|
func (h *Handler) CompleteOnboarding(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Body is optional — an empty body is a legal legacy call.
|
|
var req completeOnboardingRequest
|
|
if r.ContentLength > 0 {
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err.Error() != "EOF" {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
}
|
|
|
|
// Validate workspace_id if supplied; we don't write with it, but a
|
|
// malformed value should fail fast rather than silently land in
|
|
// PostHog as a junk dimension.
|
|
if req.WorkspaceID != "" {
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, req.WorkspaceID, "workspace_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
req.WorkspaceID = uuidToString(wsUUID)
|
|
}
|
|
|
|
before, err := h.Queries.GetUser(r.Context(), parseUUID(userID))
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to complete onboarding")
|
|
return
|
|
}
|
|
firstCompletion := !before.OnboardedAt.Valid
|
|
|
|
user, err := h.Queries.MarkUserOnboarded(r.Context(), parseUUID(userID))
|
|
if err != nil {
|
|
slog.Warn("complete onboarding: mark user onboarded failed", append(logger.RequestAttrs(r), "error", err)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to complete onboarding")
|
|
return
|
|
}
|
|
|
|
if firstCompletion {
|
|
path := req.CompletionPath
|
|
if _, ok := validCompletionPaths[path]; !ok {
|
|
path = analytics.OnboardingPathUnknown
|
|
}
|
|
onboardedAt := ""
|
|
if user.OnboardedAt.Valid {
|
|
onboardedAt = user.OnboardedAt.Time.UTC().Format("2006-01-02T15:04:05Z07:00")
|
|
}
|
|
obsmetrics.RecordEvent(h.Analytics, h.Metrics, analytics.OnboardingCompleted(
|
|
userID,
|
|
req.WorkspaceID,
|
|
path,
|
|
onboardedAt,
|
|
user.CloudWaitlistEmail.Valid,
|
|
))
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, h.userToResponse(user))
|
|
}
|
|
|
|
type patchOnboardingRequest struct {
|
|
Questionnaire *json.RawMessage `json:"questionnaire,omitempty"`
|
|
}
|
|
|
|
// questionnaireAnswers mirrors the frontend's `QuestionnaireAnswers`
|
|
// shape. `use_case` is multi-select (Step 3 allows picking several);
|
|
// `source` is single-select (primary acquisition channel) but kept
|
|
// as `stringOrSlice` for back-compat with v2 multi-select rows — the
|
|
// client now always commits a one-element array. `role` stays
|
|
// single-select.
|
|
//
|
|
// stringOrSlice also tolerates pre-array rows that wrote a bare
|
|
// string into the JSONB column — `json.Unmarshal` would otherwise
|
|
// fail on type mismatch when reading those back.
|
|
type stringOrSlice []string
|
|
|
|
func (s *stringOrSlice) UnmarshalJSON(data []byte) error {
|
|
// Empty / null both decode to nil slice.
|
|
if len(data) == 0 || string(data) == "null" {
|
|
*s = nil
|
|
return nil
|
|
}
|
|
// Try array first (current shape).
|
|
var arr []string
|
|
if err := json.Unmarshal(data, &arr); err == nil {
|
|
*s = arr
|
|
return nil
|
|
}
|
|
// Fall back to single string (pre-array shape from before this
|
|
// column held a slice). Empty string means "unanswered" — keep nil.
|
|
var single string
|
|
if err := json.Unmarshal(data, &single); err != nil {
|
|
return err
|
|
}
|
|
if single == "" {
|
|
*s = nil
|
|
return nil
|
|
}
|
|
*s = []string{single}
|
|
return nil
|
|
}
|
|
|
|
type questionnaireAnswers struct {
|
|
Source stringOrSlice `json:"source"`
|
|
SourceOther string `json:"source_other"`
|
|
SourceSkipped bool `json:"source_skipped"`
|
|
Role string `json:"role"`
|
|
RoleOther string `json:"role_other"`
|
|
RoleSkipped bool `json:"role_skipped"`
|
|
UseCase stringOrSlice `json:"use_case"`
|
|
UseCaseOther string `json:"use_case_other"`
|
|
UseCaseSkipped bool `json:"use_case_skipped"`
|
|
Version int `json:"version"`
|
|
}
|
|
|
|
func (q questionnaireAnswers) sourceResolved() bool {
|
|
return len(q.Source) > 0 || q.SourceSkipped
|
|
}
|
|
func (q questionnaireAnswers) roleResolved() bool {
|
|
return q.Role != "" || q.RoleSkipped
|
|
}
|
|
func (q questionnaireAnswers) useCaseResolved() bool {
|
|
return len(q.UseCase) > 0 || q.UseCaseSkipped
|
|
}
|
|
|
|
// questionnaireSchemaVersion is the schema this handler understands.
|
|
// `complete()` and the funnel events are scoped to this version so a
|
|
// future v3 row can't be silently mis-counted against v2 semantics.
|
|
const questionnaireSchemaVersion = 2
|
|
|
|
// complete covers the IN-FLOW questionnaire only: role + use_case.
|
|
// Source moved out of the onboarding flow (MUL-5159) — it is collected
|
|
// later by the workspace backfill prompt, and its resolution is
|
|
// tracked by the separate `onboarding_source_submitted` emission in
|
|
// PatchOnboarding. Requiring source here would stall the funnel's
|
|
// "questionnaire submitted" step for days (or forever, for users who
|
|
// never see the backfill prompt).
|
|
func (q questionnaireAnswers) complete() bool {
|
|
if q.Version != questionnaireSchemaVersion {
|
|
return false
|
|
}
|
|
return q.roleResolved() && q.useCaseResolved()
|
|
}
|
|
|
|
// PatchOnboarding persists the user's questionnaire answers. The
|
|
// field is optional; an omitted questionnaire is preserved. Which
|
|
// step the user is on is deliberately not persisted — every
|
|
// onboarding entry starts at Welcome.
|
|
//
|
|
// Emits `onboarding_questionnaire_submitted` exactly once per user:
|
|
// the first PATCH that transitions role + use_case from "at least one
|
|
// slot empty" to "both resolved". Emits `onboarding_source_submitted`
|
|
// exactly once on the source slot's own unresolved → resolved
|
|
// transition, which normally happens later via the workspace backfill
|
|
// prompt. Revisions past those points don't re-emit — the funnel
|
|
// counts users, not edits.
|
|
func (h *Handler) PatchOnboarding(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
// Bound the body so the JSONB column can't be weaponized as bulk
|
|
// storage — otherwise every subsequent `/api/me` read would have
|
|
// to return the bloat.
|
|
r.Body = http.MaxBytesReader(w, r.Body, patchOnboardingBodyLimit)
|
|
var req patchOnboardingRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
// Read prior answers so we can detect the NULL/partial → complete
|
|
// transition after the update. An errored decode on the prior row
|
|
// is treated as "incomplete" — worst case we emit once more than
|
|
// we should, never twice for the same transition.
|
|
var before questionnaireAnswers
|
|
beforeRaw := []byte("{}")
|
|
if beforeUser, err := h.Queries.GetUser(r.Context(), parseUUID(userID)); err == nil {
|
|
beforeRaw = beforeUser.OnboardingQuestionnaire
|
|
_ = json.Unmarshal(beforeRaw, &before)
|
|
}
|
|
// firstTouch is true when the user has never written any
|
|
// onboarding state on the server before this PATCH. Used to fire
|
|
// onboarding_started exactly once per user from the server side.
|
|
firstTouch := len(beforeRaw) == 0 || string(beforeRaw) == "null" || string(beforeRaw) == "{}"
|
|
|
|
params := db.PatchUserOnboardingParams{ID: parseUUID(userID)}
|
|
if req.Questionnaire != nil {
|
|
params.Questionnaire = []byte(*req.Questionnaire)
|
|
}
|
|
user, err := h.Queries.PatchUserOnboarding(r.Context(), params)
|
|
if err != nil {
|
|
slog.Warn("patch onboarding failed", append(logger.RequestAttrs(r), "error", err)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to update onboarding")
|
|
return
|
|
}
|
|
|
|
// Server-side onboarding_started: fire on the first PATCH that
|
|
// actually carries a questionnaire payload. The frontend also
|
|
// emits its own onboarding_started on page open; the two together
|
|
// let Grafana cross-check the funnel against PostHog.
|
|
if firstTouch && req.Questionnaire != nil && len(*req.Questionnaire) > 0 && string(*req.Questionnaire) != "{}" {
|
|
platform, _, _ := middleware.ClientMetadataFromContext(r.Context())
|
|
obsmetrics.RecordEvent(h.Analytics, h.Metrics, analytics.OnboardingStarted(userID, platform))
|
|
}
|
|
|
|
var after questionnaireAnswers
|
|
_ = json.Unmarshal(user.OnboardingQuestionnaire, &after)
|
|
if after.complete() && !before.complete() {
|
|
obsmetrics.RecordEvent(h.Analytics, h.Metrics, analytics.OnboardingQuestionnaireSubmitted(
|
|
userID,
|
|
[]string(after.Source),
|
|
after.Role,
|
|
[]string(after.UseCase),
|
|
after.SourceSkipped,
|
|
after.RoleSkipped,
|
|
after.UseCaseSkipped,
|
|
after.SourceOther != "",
|
|
after.RoleOther != "",
|
|
after.UseCaseOther != "",
|
|
))
|
|
}
|
|
|
|
// Source resolves on its own timeline — typically days after the
|
|
// in-flow questionnaire, via the workspace backfill prompt (it can
|
|
// no longer resolve in-flow). Emit on the unresolved → resolved
|
|
// transition so the backfill prompt's answer/decline rate shows up
|
|
// in Grafana; the transition check keeps the emission
|
|
// once-per-user, mirroring the questionnaire event above.
|
|
if after.Version == questionnaireSchemaVersion &&
|
|
after.sourceResolved() && !before.sourceResolved() {
|
|
obsmetrics.RecordEvent(h.Analytics, h.Metrics, analytics.OnboardingSourceSubmitted(
|
|
userID,
|
|
[]string(after.Source),
|
|
after.SourceSkipped,
|
|
after.SourceOther != "",
|
|
))
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, h.userToResponse(user))
|
|
}
|
|
|
|
type joinCloudWaitlistRequest struct {
|
|
Email string `json:"email"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
// JoinCloudWaitlist records a user's interest in cloud runtimes.
|
|
// Pure side effect — does NOT complete onboarding. The user still
|
|
// has to pick a real Step 3 path (CLI with a detected runtime) or
|
|
// Skip to move on. Repeating the call overwrites email + reason.
|
|
func (h *Handler) JoinCloudWaitlist(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req joinCloudWaitlistRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
// RFC 5321 caps email at 254 chars; the column is VARCHAR(254) and
|
|
// the format check below rejects anything net/mail can't parse.
|
|
email := strings.ToLower(strings.TrimSpace(req.Email))
|
|
if email == "" {
|
|
writeError(w, http.StatusBadRequest, "email is required")
|
|
return
|
|
}
|
|
if len(email) > 254 {
|
|
writeError(w, http.StatusBadRequest, "email is too long")
|
|
return
|
|
}
|
|
if _, err := mail.ParseAddress(email); err != nil {
|
|
writeError(w, http.StatusBadRequest, "email is invalid")
|
|
return
|
|
}
|
|
|
|
reason := strings.TrimSpace(req.Reason)
|
|
if len(reason) > cloudWaitlistReasonMaxLen {
|
|
writeError(w, http.StatusBadRequest, "reason is too long")
|
|
return
|
|
}
|
|
|
|
reasonParam := pgtype.Text{}
|
|
if reason != "" {
|
|
reasonParam = pgtype.Text{String: reason, Valid: true}
|
|
}
|
|
|
|
user, err := h.Queries.JoinCloudWaitlist(r.Context(), db.JoinCloudWaitlistParams{
|
|
ID: parseUUID(userID),
|
|
CloudWaitlistEmail: pgtype.Text{String: email, Valid: true},
|
|
CloudWaitlistReason: reasonParam,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to join waitlist")
|
|
return
|
|
}
|
|
|
|
obsmetrics.RecordEvent(h.Analytics, h.Metrics, analytics.CloudWaitlistJoined(userID, reason != ""))
|
|
|
|
writeJSON(w, http.StatusOK, h.userToResponse(user))
|
|
}
|