Skip to content

Add arbitrary file uploads (agentdraft file) - #33

Merged
Hitesh-Sisara merged 8 commits into
mainfrom
feat/file-uploads
Aug 11, 2026
Merged

Hitesh-Sisara merged 8 commits into
mainfrom
feat/file-uploads

Conversation

@Hitesh-Sisara

Copy link
Copy Markdown
Contributor

Summary

Adds an arbitrary-file lane to agentdraft so agentdraft file <path> uploads any local
file (screenshot, recording, log, PDF, zip, build artifact) and returns a stable public URL
— our in-house replacement for third-party file hosts. Reuses the existing workers, R2
bucket, D1, auth, and CLI (Option A: shared origin).

Documents already worked via agentdraft upload (HTML/Markdown, validated + rendered).
This adds the binary lane alongside it, served as-is.

Security model (the core of this change)

Arbitrary files share the content origin with rendered documents, so a naively-served file
could execute as a page there. The gate that prevents it:

  • Attachment by default. Every file serves Content-Disposition: attachment UNLESS its
    MIME is on a strict inline allowlist (image/png|jpeg|gif|webp|avif, video/mp4|webm,
    audio/mpeg|mp4, text/plain, application/pdf).
  • text/html and image/svg+xml are never inline — the two script-carrying types.
  • X-Content-Type-Options: nosniff always, so a file lying about its type can't be
    reinterpreted as markup.
  • Serve response is default-src 'none'; sandbox, noindex, CORP: same-origin.
  • The allowlist is duplicated in both workers (they don't share a module) with a comment
    keeping them in sync, and is asserted by unit tests.

What's included

  • DB: 0005_files.sql — files table (unversioned; soft-delete + disable columns).
  • API worker: POST /api/files (streams the raw body straight to R2, never buffered;
    100 MB cap; idempotency-key dedupe), GET /api/files (list, read scope),
    POST /api/files/:id/disable (takedown, ownership in the query).
  • Content worker: GET /f/:id — attachment-default serve with the inline allowlist,
    nosniff, hard-immutable cache, 304 revalidation, and Range/206 for media seeking.
  • CLI: agentdraft file <path> with --content-type / --idempotency-key, streaming
    upload with no client timeout, 100 MB local pre-check.
  • Dashboard: a Files list page (name/type/size/date/link + disable) and a nav link.
  • Skill: agentdraft-file-upload replacing the external file-host skill.
  • Docs: api.md + cli.md updated.

What was tested (real prod, deployed workers)

PNG  → content-disposition: inline; content-type: image/png; nosniff        ✓
LOG  → inline; text/plain                                                    ✓
MP4  → inline; video/mp4; accept-ranges: bytes                              ✓
HTML → content-disposition: ATTACHMENT; nosniff   (must not serve inline)    ✓
SVG  → content-disposition: ATTACHMENT; nosniff   (must not serve inline)    ✓
Range GET bytes=0-99  → 206, content-range: bytes 0-99/2233, 100 bytes       ✓
Range GET bytes=-50   → 206, 50 bytes                                        ✓
Range GET malformed   → 416                                                  ✓
Integrity: served sha256 == source sha256                                    ✓
Idempotency: same key twice → same file_id, "no new upload"                  ✓
List (read scope): returns files with metadata + public_url                  ✓
Disable → serve immediately returns 451 (status gate runs before cache)      ✓

Unit tests: 78 passed (60 existing + 18 new for the inline-allowlist safety gate).
Worker + content-worker typecheck clean; dashboard svelte-check 0 errors; CLI go build

  • go vet clean.

Size cap rationale

100 MB = the Cloudflare Free-plan request-body limit (verified against
developers.cloudflare.com/workers/platform/limits), enforced at the edge before the Worker
runs, plus a Content-Length pre-check and a local CLI pre-check. Bytes stream to R2 so
Worker memory (128 MB) is never the constraint.

@Hitesh-Sisara
Hitesh-Sisara merged commit 1732da6 into main Aug 11, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant