Repository navigation
feat(file_storage): public files, anonymous serving, list search/sort, thumbnails - #410
Conversation
…sort, thumbnails Fixes #353, fixes #352. - StoredFile.public (default false; migration uses sa.false()), set on upload (public=true) or PATCH /files/{id}; StoredFileOut gains public/public_url. - Anonymous GET /public/{id}[/{filename}] and /public/{id}/thumbnail, exempted via register_public_routes (GET only). Only public, non-deleted rows resolve; other cases are one 404. Cross-tenant lookup by id uses all_tenants(). ETag/304, Cache-Control public, nosniff, sandbox CSP; active content (HTML, SVG, JS) forced to attachment and streamed. - SecurityHeadersMiddleware keeps a CSP the response already set. - GET /files: q, content_type (exact or "image/" prefix), sort. - GET /files/{id}/thumbnail?w=: Pillow WebP, width snapped to a whitelist, pixel-budget guard, variants cached in the storage backend and dropped with the file. - Browse screen: Public badge and make public/private action. Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4
…ps, concurrency) and add tenant/permission tests Pillow only opens JPEG/PNG/WEBP/GIF, sniffed format must match the declared type, DecompressionBombWarning is an error, source bytes are capped while reading, decodes are bounded by a semaphore, first frame only, metadata not carried over. Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4
…hes; stop mutating Pillow's global pixel cap Decode runs as a shielded single-flight task per variant, so a cancelled request neither frees its slot early nor lets retries multiply decodes. The semaphore is per event loop. The explicit header-only pixel budget replaces the racy process-global MAX_IMAGE_PIXELS toggle. Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
Deploying simple-module-python with
|
| Latest commit: |
66dd945
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://f6bc7d72.simple-module-python.pages.dev |
| Branch Preview URL: | https://feat-352-353-file-storage-pu.simple-module-python.pages.dev |
|
Merge-order note: this PR's migration |
…e-use wrappers in file_storage Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4
…H bool, 16-bit grayscale thumbnails (QA round 1) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4
…nd 1, pass 3) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4
/ship results (optimize, review, QA, local CI)Pushed 5 commits (adbcbf9..da03d56). Report: https://claude.ai/artifact/KGrv9YGs1YZhQ73NkDunWk Optimize: net -38 lines (shared not_found/headers helpers, dead code, one-use wrappers, width snapped once). Code review (3 passes, security-heavy): fixed uniform 404 when bytes are missing, atomic thumbnail writes, CSP kept only when it carries QA (browser + HTTP agents, app on API 8100 / Vite 5250, SQLite): uploaded PNG/JPG/PDF/HTML/SVG, toggled public/private in Browse, logged-out context 200 when public and identical 404 when private/unknown/deleted/soft-deleted, HTML/SVG/XML/JS always attachment + nosniff + sandbox CSP (no inline render, no dialogs), LIKE wildcards literal, search/content_type/sort, thumbnails at several widths (snapped to 5 sizes), non-image 404, 40000x40000 PNG refused (422, server responsive), 30 concurrent cold thumbnails all 200. Bugs found and fixed: anonymous public responses set a 14-day session cookie next to Local CI: Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 |
# Conflicts: # modules/file_storage/file_storage/service.py
…amp revision Both branched from c7f2d9a41e83; repoint down_revision so main keeps a single mainline head. Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4
With #408's shared limiter on main, public file reads shared the 120/minute anonymous default; a page embedding many images would throttle itself. Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4
Fixes #353
Fixes #352
Design
Public files (#353).
StoredFile.public(default false; migratione5a8c1f27b94usessa.false(), Postgres-portable). Set viapublic=trueon upload orPATCH /files/{id}(file_storage.upload, tenant-scoped like every other authenticated route).StoredFileOutgainspublicandpublic_url. AnonymousGET /api/file-storage/public/{id}[/{filename}]and/public/{id}/thumbnailare exempted viaregister_public_routes(GET-only regex). ETag/304,Cache-Control: public, S3 backends redirect to the presigned URL (cached for at most half its TTL).Search/filter/sort/thumbnails (#352).
GET /filestakesq(case-insensitive substring, LIKE wildcards escaped),content_type(exact orimage/prefix) andsort(created_at,-created_atdefault,name,-name,size,-size; invalid is 422; ties break on id).GET /files/{id}/thumbnail?w=(and the public equivalent) returns WebP, aspect preserved, never enlarged, width clamped to 32-1024 and snapped to64/128/256/512/1024. Variants are cached in the storage backend beside the original ({key}.w{width}.webp): durable, shared across workers, bounded to 5 per file, tenant-prefixed, deleted with the file. Browse screen gets a Public badge and a make public/private action (i18n'd). Docs indocs/modules/file_storage.md.Security
public=True, non-deleted rows; unknown, private and deleted are one identical 404 (no existence/metadata leak).all_tenants()is used only on that single by-UUID lookup, which also requirespublic=True(publishing is the owner's explicit cross-tenant choice). PATCH, authenticated thumbnail and download go through the tenant-filteredget(), so another tenant's id is a 404; thumbnail/download needfile_storage.download, PATCH needsfile_storage.upload. Anonymous callers cannot reach/files/*.nosniffandContent-Security-Policy: default-src 'none'; sandbox. HTML/XHTML/SVG/XML/JS are forced toContent-Disposition: attachmentand always streamed (never redirected).SecurityHeadersMiddlewarenow keeps a CSP a response already set instead of overwriting it (framework change; the app-wide CSP stays the default).Image.open(formats=[JPEG, PNG, WEBP, GIF])only; the sniffed format must match the declared content type (a mislabelled SVG/PSD/BMP is a 422); SVG and every other type is a 404. First frame only; output is re-encoded from pixels, so EXIF/XMP/ICC are dropped.MAX_IMAGE_PIXELS; source capped at 20 MB while streaming (no unbounded buffer); width snapped to a 5-value whitelist; decodes bounded by a per-event-loop semaphore (2). Decode runs as a shielded single-flight task per variant, so a client that disconnects cancels only its wait: the slot is held until the worker thread returns and retries join the in-flight decode instead of multiplying them.Verification
make lintgreen;make test-pyrun to completion (3601 passed; the only failures were twotest_module_css_buildtests, which failed because the suite's own pnpm step clobberednode_modulesin the worktree and pass afternpm install);make test-jsfile_storage vitest (44 passed),make ci-check-untranslatedOK; migration applies on SQLite andalembic checkshows no file_storage drift. Not run: Postgres suite, S3 redirect against a real bucket (covered by unit logic only).https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4