Commit Graph

18 Commits

Author SHA1 Message Date
Bohan Jiang
f628e48775 refactor(server): error-returning ParseUUID to prevent silent data loss
* refactor(server): make ParseUUID error-returning to prevent silent data loss (MUL-1410)

util.ParseUUID previously swallowed errors and returned a zero pgtype.UUID
on invalid input. When this zero UUID reached a write query (DELETE/UPDATE),
the SQL matched zero rows and the handler returned 2xx success — producing
silent data corruption. #1661 (DeleteIssue with identifier-style ID) was the
visible symptom; PR #1680 patched that one site, this commit closes the
class of bug.

Changes:

- util.ParseUUID now returns (pgtype.UUID, error). Add util.MustParseUUID
  for trusted round-trips that should panic on invalid input.
- handler/handler.go: parseUUID wrapper now calls MustParseUUID — any
  unguarded user-input string reaching it surfaces as a recovered panic
  (chi middleware.Recoverer → 500) instead of silently corrupting data.
  Add parseUUIDOrBadRequest(w, s, fieldName) for handler entry points.
- Convert every Queries.Delete*/Update* call site reachable from raw user
  input (autopilot, comment, project, skill, skill_file, label, pin,
  attachment, feedback, issue assignee, daemon runtime, workspace) to
  validate UUIDs explicitly with parseUUIDOrBadRequest, returning 400 on
  invalid input. Where a resolved entity.ID is already in scope, write
  queries now use it directly instead of re-parsing the URL string.
- Update getWorkspaceMember + loadIssueForUser to handle invalid UUIDs
  gracefully (404/400 instead of panic).
- Update util/middleware/cmd-level callers (subscriber_listeners,
  notification_listeners, activity_listeners, scope_authorizer,
  middleware/workspace) to use the error-returning API.
- Add server/internal/util/pgx_test.go covering valid/invalid input and
  the MustParseUUID panic contract.
- Add TestDeleteIssueByIdentifier + TestDeleteIssueRejectsInvalidUUID
  regression tests in handler_test.go (the original #1661 bug + the
  invalid-input case).
- Document the handler UUID parsing convention in CLAUDE.md so the rule
  is enforceable in future PR review.

* fix(server): address GPT-Boy review of #1748

P1 fixes from PR #1748 review:

1. Migrate remaining request-boundary UUIDs to parseUUIDOrBadRequest so
   malformed input returns 400 instead of panic/500. Was missing on:
   - issue.go: workspace_id in CreateIssue/ChildIssueProgress/ListIssues/
     SearchIssues/BatchUpdateIssues/BatchDeleteIssues; project_id /
     parent_issue_id / lead_id / assignee_id / assignee_ids / creator_id
     filters; batch issue_ids and assignee/parent/project fields in
     BatchUpdateIssues (skip on bad input via util.ParseUUID, matching
     the existing per-row continue semantics).
   - project.go: project id + workspace_id in GetProject/UpdateProject/
     DeleteProject; lead_id in CreateProject/UpdateProject;
     workspace_id in ListProjects + SearchProjects.
   - handler.go: resolveActor now uses util.ParseUUID for X-Agent-ID /
     X-Task-ID headers; invalid UUID falls back to "member" (matches
     pre-existing semantics) instead of panicking.
   - issue.go: validateAssigneePair returns 400 on invalid workspace_id
     instead of panicking.

2. Fix issue:deleted WS event payloads to emit uuidToString(issue.ID)
   instead of the raw URL string. After an identifier-path delete
   ("MUL-7"), the previous payload would have leaked the identifier to
   subscribers, leaving stale entries in frontend caches that key by
   UUID. Updated DeleteIssue (issue.go:1341) and BatchDeleteIssues
   (issue.go:1641). The slog "issue deleted" log line also now records
   the resolved UUID so logs match the WS payload.

3. Extend TestDeleteIssueByIdentifier to subscribe to the bus and
   assert issue:deleted.payload.issue_id is the resolved UUID, not
   the identifier.

* fix(server): validate remaining reviewed UUID inputs

* fix(server): validate remaining handler UUID inputs

* fix(server): finish request boundary UUID audit

* fix(server): validate remaining request body UUIDs

* fix(server): validate runtime path UUIDs

* fix(server): validate remaining audit UUID inputs

---------

Co-authored-by: Eve <eve@multica.ai>
2026-04-28 14:50:28 +08:00
Naiyuan Qing
f0f3cb5c3a fix(server): resolve X-Workspace-Slug in middleware-less handlers (#1165)
Problem
-------
The v2 workspace URL refactor (#1141) switched the frontend from sending
X-Workspace-ID (UUID) to X-Workspace-Slug. The workspace middleware was
updated to accept the slug and translate it via GetWorkspaceBySlug.

But the handler package maintained a PARALLEL resolver
(`resolveWorkspaceID` in handler.go) used by endpoints that sit outside
the workspace middleware — and that resolver was never updated. It only
checked context / ?workspace_id / X-Workspace-ID, never the slug.

/api/upload-file is the one production route that hit the broken path:
it's user-scoped (not behind workspace middleware) because it also
serves avatar uploads (no workspace). Post-refactor requests from the
frontend arrived with only X-Workspace-Slug; the handler resolver
returned "", the code fell into the "no workspace context" branch, and
every file upload since v2 landed in S3 with no corresponding DB
attachment row — files orphaned, invisible to the UI.

Root cause is structural: two resolvers doing the same job, written
independently, diverged silently when one was updated.

Fix
---
Collapse to a single shared helper. middleware.ResolveWorkspaceIDFromRequest
is the new canonical resolver; both the middleware's internal
`resolveWorkspaceUUID` (for middleware gating) and the handler-side
`(h *Handler).resolveWorkspaceID` (promoted from a package function)
now delegate to it. Priority order matches what the middleware has had
since v2: context > X-Workspace-Slug header > ?workspace_slug query >
X-Workspace-ID header > ?workspace_id query.

Impact analysis
---------------
47 call sites of the old `resolveWorkspaceID(r)` are renamed to
`h.resolveWorkspaceID(r)`. 46 of them sit behind workspace middleware,
so they hit the context fast path and see zero behavior change. The
one caller that actually gains capability is UploadFile — which now
correctly recognizes slug requests and creates DB attachment rows.

Tests
-----
- New table-driven unit test for ResolveWorkspaceIDFromRequest covers
  all priority levels and the unknown-slug fallback.
- Regression tests for UploadFile: once with X-Workspace-Slug only
  (the broken path), once with X-Workspace-ID only (legacy CLI/daemon
  compat path). Both assert that a DB attachment row is created.
- Full Go test suite passes; typecheck + pnpm test unaffected.

Plan
----
See docs/plans/2026-04-16-unify-workspace-identity-resolver.md for the
full first-principles writeup.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 18:01:56 +08:00
devv-eve
7c7d7feed3 fix(storage): scope S3 upload keys by workspace (#936)
* fix(storage): scope S3 upload keys by workspace

Upload keys now use `workspaces/{workspace_id}/{uuid}.{ext}` instead of
flat `{uuid}.{ext}`, isolating file storage per workspace. Files uploaded
without workspace context (e.g. avatars) keep the flat key structure.

Refs: MUL-577

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(storage): scope user uploads under users/{user_id}/ prefix

Non-workspace uploads (avatars, profile images) now use
`users/{user_id}/{uuid}.{ext}` instead of flat `{uuid}.{ext}`,
matching the workspace-scoped pattern from the previous commit.

Refs: MUL-577

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(storage): fix LocalStorage for nested key paths

- Add MkdirAll before WriteFile to create intermediate directories
  for workspace/user-scoped keys
- Fix KeyFromURL to preserve full path after /uploads/ prefix instead
  of stripping to just the filename
- Update tests to match new behavior

Refs: MUL-577

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(upload): validate ownership before writing to storage

Move Storage.Upload after issue_id/comment_id ownership validation
to prevent orphaned files in S3 when validation fails. Previously,
the file was uploaded first and validation happened after, leaving
files in workspace-scoped S3 prefixes even on rejected requests.

Refs: MUL-577

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(upload): restore workspace membership check before upload

The membership check was accidentally removed during the upload
reordering refactor. Without it, any authenticated user could upload
files to any workspace by setting the X-Workspace-ID header.

Also restores the comment explaining the 200-on-DB-error behavior.

Refs: MUL-577

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 21:01:50 -07:00
devv-eve
8c0708bb5d fix(server): validate workspace membership for subscriptions and uploads (#935)
* fix(server): validate workspace membership for subscription targets and file uploads

Closes MED-1 (cross-workspace subscription injection) and MED-2 (file upload
missing workspace member validation) from the security audit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(server): add negative tests for cross-workspace subscription and upload

Address PR review feedback:
- Add tests verifying cross-workspace user_id is rejected with 403 on
  subscribe and unsubscribe
- Add test verifying upload with foreign workspace_id is rejected with 403
- Make isWorkspaceEntity explicitly enumerate "member"/"agent" and reject
  unknown user types

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 20:22:03 -07:00
Jiayuan Zhang
f4016fc721 fix(server): validate workspace ownership for attachment uploads and queries (#683)
Prevent cross-workspace attachment injection (CRIT-3) by verifying
issue_id/comment_id belong to the caller's workspace before creating
attachment records. Add workspace_id filter to ListAttachmentsByCommentIDs
query (MED-3) to prevent cross-workspace attachment data leakage.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 04:33:24 +08:00
Naiyuan Qing
8526f013da fix(upload): SVG content-type fallback and Content-Disposition for non-media files
- Add extension-based content-type override after http.DetectContentType()
  to fix SVG files getting text/xml instead of image/svg+xml
- Use Content-Disposition: attachment for non-media files so browsers
  download CSV/PDF instead of displaying inline

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:33:02 +08:00
yushen
4036d64996 fix(attachment): use UUIDv7 as S3 key and link attachments on issue/comment creation
- Use google/uuid NewV7() for attachment ID and S3 file key instead of
  random hex, so the S3 object name matches the attachment record ID
- Add LinkAttachmentsToIssue query to associate orphaned attachments
  with a newly created issue
- Pass attachment_ids in CreateIssue request so uploads during issue
  creation (before the issue exists) get linked after commit
- Collect and pass attachment IDs in comment-input and reply-input
  so comment creation properly links uploaded files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 07:55:17 +08:00
devv-eve
8eb1caa72b fix(agent): instruct agents to use download_url for attachments (#356)
* fix(agent): instruct agents to use download_url for attachments

Agents were not aware of the signed vs unsigned URL distinction in
attachments, causing failures when trying to read images. Added an
Attachments section to the generated CLAUDE.md/AGENTS.md template that
tells agents to always use `download_url`. Also increased signed URL
expiry from 5 to 30 minutes to better accommodate agent processing time.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(cli): add `multica attachment download` command

Adds a dedicated CLI command for downloading attachments by ID. The
command fetches attachment metadata from the API (which returns a fresh
signed URL), downloads the file, and saves it locally. This eliminates
the need for agents to understand signed vs unsigned URLs.

Changes:
- New `multica attachment download <id>` CLI command
- New `GET /api/attachments/{id}` backend endpoint
- `DownloadFile` helper on APIClient
- Updated CLAUDE.md template to document the command

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(cli): sanitize filename and add download size limit

- Use filepath.Base on attachment filename to prevent path traversal
- Add 100MB size limit to DownloadFile (matches upload limit)
- Include response body in download error messages for debugging

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 07:45:42 -07:00
LinYushen
ff616de82b fix(upload): remove file type allowlist to support all file types (#329)
* fix(upload): remove file type allowlist to support all file types

Removes the hardcoded MIME type allowlist from both frontend and backend
that was blocking uploads of file types like Word documents (.docx).
File size limit (10 MB) is still enforced. Content type detection is
preserved for metadata storage.

Closes MUL-123

* feat(upload): increase file size limit from 10 MB to 100 MB

Updates both frontend and backend to allow uploads up to 100 MB.
2026-04-02 13:55:50 +08:00
yushen
79cd2a3a5d fix(upload): link attachments to comments via client-side ID tracking
Instead of regex-parsing markdown content to find attachment URLs
(fragile), the frontend now tracks uploaded attachment IDs and sends
them with the comment creation request. The backend links them by ID.

Frontend: upload returns attachment ID, comment/reply inputs collect
IDs during editing session, pass as attachment_ids on submit.
Backend: CreateComment accepts attachment_ids, links by ID+issue scope.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:47:27 +08:00
yushen
acba0b8139 fix(upload): clean up S3 objects when attachments are deleted
- Add Delete/DeleteKeys/KeyFromURL methods to S3Storage
- DeleteAttachment handler now removes the S3 object after DB delete
- DeleteComment collects attachment URLs before CASCADE, then cleans S3
- DeleteIssue collects all attachment URLs (issue + comment level) before CASCADE, then cleans S3

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:34:47 +08:00
yushen
9e23fb76fc fix(upload): harden upload flow — sanitize filenames, refresh CF cookies, deduplicate handlers
- Sanitize Content-Disposition filenames to prevent header injection (strip control chars, quotes, semicolons)
- Add CloudFront cookie refresh middleware so cookies are re-issued when expired
- Log errors in groupAttachments instead of silently swallowing them
- Move useFileUpload hook to shared/hooks/ per project architecture conventions
- Add uploadWithToast helper to deduplicate try/catch/toast pattern across 3 components
- Refactor ApiClient.uploadFile to reuse auth headers, 401 handling, and error parsing
- Allow empty MIME types client-side (let server sniff and decide)
- Constrain Image extension max-width in rich-text-editor to prevent layout overflow

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:52:40 +08:00
yushen
f5353c6691 feat(upload): signed URLs for CLI + eager load attachments on comments
- Add CloudFrontSigner.SignedURL() for generating per-resource signed URLs
- Attachment responses include download_url (5-min signed URL for CLI)
- Eager load attachments on comments and timeline (same pattern as reactions)
- Add ListAttachmentsByCommentIDs query for batch loading
- Update Comment and TimelineEntry types with attachments field

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:42:10 +08:00
yushen
15f96468be feat(upload): add attachment table for tracking uploaded files
- Add attachment table with workspace/issue/comment associations
- Upload handler creates attachment record when workspace context exists
- Add GET /api/issues/{id}/attachments and DELETE /api/attachments/{id}
- Frontend passes issueId context during uploads for tracking
- Add Attachment type, listAttachments, deleteAttachment to API client

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:29:41 +08:00
yushen
978a5af5de fix(upload): remove unnecessary uploads/ key prefix
Single-purpose bucket with randomized hex keys doesn't benefit from
a prefix — no lifecycle policies or access controls scoped to it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:01:33 +08:00
yushen
c27b7bab5e fix(upload): sniff content type, sanitize filename, add key prefix
- Use http.DetectContentType() instead of trusting client-declared MIME type
- Sanitize quotes in filename for Content-Disposition header injection
- Add uploads/ prefix to S3 keys for better organization

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:58:52 +08:00
yushen
edf4c00c08 fix(upload): add file type/size validation, Content-Disposition header
- Add content type allowlist (images, PDF, text, video, audio, zip)
- Enforce 10 MB upload limit via http.MaxBytesReader
- Set Content-Disposition on S3 objects for proper download filenames
- Remove unused CloudFrontSigner.Domain() method

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:55:27 +08:00
yushen
29a80e057e feat(upload): add file upload API with S3 + CloudFront signed cookies
Add POST /api/upload-file endpoint that uploads files to S3 and returns
CDN URLs protected by CloudFront signed cookies (same pattern as Linear).

Infrastructure:
- Two private S3 buckets (static.multica.ai, static-staging.multica.ai)
- Two CloudFront distributions with OAC and Trusted Key Groups
- ACM wildcard cert in us-east-1, DNS records in Route 53
- RSA signing key stored in AWS Secrets Manager

Backend:
- S3 storage service with CloudFront CDN domain support
- CloudFront signed cookie generation (RSA-SHA1)
- Private key loaded from Secrets Manager (env var fallback for local dev)
- Cookies set on login (VerifyCode) with 72h expiry matching JWT
- Upload handler: multipart form → S3 → CloudFront URL response

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:41:17 +08:00