…, thumbnails (#410)
* feat(file_storage): public files with anonymous serving, list search/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
* fix(file_storage): harden thumbnails (format allowlist, size/pixel caps, 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
* fix(file_storage): hold the decode slot until the worker thread finishes; 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
* refactor(optimize): dedupe 404/headers helpers, drop dead code and one-use wrappers in file_storage
Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4
* fix: address code review findings (round 1, pass 1)
Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4
* fix: address code review findings (round 1, pass 1b)
Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4
* fix(file_storage): no session cookie on public responses, strict PATCH bool, 16-bit grayscale thumbnails (QA round 1)
Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4
* fix(file_storage): log orphaned thumbnail variant deletes (review round 1, pass 3)
Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4
* test: reconcile file_storage/branding tests with #401 and #404 on main
Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4
* fix(migrations): chain file_storage public column after #406's timestamp revision
Both branched from c7f2d9a41e83; repoint down_revision so main keeps a single
mainline head.
Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4
* feat(file_storage): own rate-limit bucket for anonymous public file GETs
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 #346
Fixes #349
#346: JSON for unhandled 500 / NotFoundError
On current main the three handlers (validation,
NotFoundError, unhandled) already share the_wants_jsonnegotiation (/api/*or non-HTML Accept gets JSON;X-Inertiaand browser navigations keep the Inertia page). What was still wrong:ServerErrorMiddleware, which wraps outsideCorrelationIdMiddleware, so the 500 response (JSON or page) never carriedX-Correlation-ID- the one handle the issue says clients have.unhandled_exception_handlernow sets it fromrequest.state.correlation_id.NotFoundErrorpaths end to end; added.#349: no session cookie for anonymous API calls
build_i18n_blockstores__i18n_locale/__i18n_audiencein the session so a later Inertia XHR can tell "locale/audience unchanged" and skip re-sending the message catalog (messages: None). It wrote them unconditionally, so every anonymous response (API, health, ...) got aSet-Cookie, which makes it uncacheable by shared caches.Fix: only persist when it can pay off, i.e. when the session already has data (a cookie was presented), or the request is an Inertia request, or it accepts
text/html(a page navigation). A cookie-less bare API/health call writes nothing, so noSet-Cookie. Chosen over a path-prefix rule because it needs no knowledge of route layout and keeps real browser sessions (which sendAccept: text/htmlon first load) behaving exactly as before. Reading the plain-dict session does not mark it accessed here, and noVary: Cookieappears (asserted in tests).One existing test seeded the session with a
*/*GET and expected persistence; it now sendsAccept: text/htmllike a real page load.Tests
framework/hosting/tests/test_hosting_error_json_and_session_cookie.py: JSON 500 on/api(with correlation id), HTML 500 on a page, JSON/HTMLNotFoundError, anonymous/apiand/healthhave noset-cookie/Vary: Cookie, anonymous page load still sets the session.https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4