diff --git a/docs/modules/file_storage.md b/docs/modules/file_storage.md
index ba9d176b..b09f86b6 100644
--- a/docs/modules/file_storage.md
+++ b/docs/modules/file_storage.md
@@ -17,11 +17,73 @@ Pluggable file storage with two shipped backends — local filesystem and S3-com
| Method + path | Body / response | Permission |
|---|---|---|
-| `POST /api/file-storage/upload` | `multipart` → `StoredFileOut` (201) | `file_storage.upload` |
-| `GET /api/file-storage/files` | `?page=&per_page=` → `StoredFileListOut` | `file_storage.download` |
+| `POST /api/file-storage/upload` | `multipart` (`file`, optional `public=true`) → `StoredFileOut` (201) | `file_storage.upload` |
+| `GET /api/file-storage/files` | `?page=&per_page=&q=&content_type=&sort=` → `StoredFileListOut` | `file_storage.download` |
| `GET /api/file-storage/files/{file_id}` | → `StoredFileOut` | `file_storage.download` |
+| `PATCH /api/file-storage/files/{file_id}` | `{"public": bool}` → `StoredFileOut` | `file_storage.upload` |
+| `GET /api/file-storage/files/{file_id}/thumbnail` | `?w=` → `image/webp` | `file_storage.download` |
| `GET /api/file-storage/files/{file_id}/download` | → 302 (S3) or stream (filesystem) | `file_storage.download` |
| `DELETE /api/file-storage/files/{file_id}` | → 204 | `file_storage.delete` |
+| `GET /api/file-storage/public/{file_id}[/{filename}]` | bytes (**anonymous**) | none, `public` files only |
+| `GET /api/file-storage/public/{file_id}/thumbnail` | `?w=` → `image/webp` (**anonymous**) | none, `public` files only |
+
+### Listing: search, filter, sort
+
+`GET /files` takes `q` (case-insensitive substring of the original filename;
+`%` and `_` match literally), `content_type` (an exact type, or a family
+ending in `/` such as `image/`) and `sort` — one of `created_at`, `-created_at`
+(default), `name`, `-name`, `size`, `-size` (anything else is a `422`). `name`
+sorts case-insensitively; ties break on `id` so pages never overlap. `total`
+reflects the filters.
+
+### Thumbnails
+
+`GET /files/{id}/thumbnail?w=` returns a Pillow-resized WebP, aspect ratio
+preserved, never enlarged. `w` is clamped to 32–1024 and **snapped up** to one
+of `64, 128, 256, 512, 1024` (default 256), so a file has at most five
+variants. Only `image/jpeg`, `png`, `webp` and `gif` (first frame) have
+thumbnails; everything else, including SVG, is `404`. An undecodable image is
+`422 file_storage.bad_image`, and an image over 25 megapixels, or a source over 20 MB, is refused before any
+decode (decompression-bomb guard; `DecompressionBombWarning` is an error). Only
+JPEG/PNG/WebP/GIF are ever opened (Pillow `formats=` allowlist), the sniffed
+format must match the declared type, animated images yield their first frame,
+metadata is not carried into the output, and at most two decodes run at once.
+
+Variants are cached **in the storage backend** next to the original, under
+`{key}.w{width}.webp`: they survive restarts, are shared by all workers, are
+bounded by the width whitelist, inherit the tenant key prefix, and are deleted
+with the file. A cache hit never re-reads the original. A concurrent first
+request may render twice; both write identical bytes.
+
+### Public files
+
+`StoredFile.public` (default `false`) opts a file into anonymous serving.
+Set it with `public=true` on upload or `PATCH /files/{id}`; both need
+`file_storage.upload`, so anyone who may add files may publish them. `StoredFileOut` carries
+`public` and, while public, `public_url` (`/api/file-storage/public/{id}/{filename}`),
+so consumers never build the URL themselves — an `
` on a public page
+can use it, or `.../public/{id}/thumbnail?w=256`.
+
+The public routes are exempt from `AuthMiddleware` through
+`register_public_routes` (GET only; uploads, PATCH and deletes stay gated).
+Serving rules:
+
+- Only `public=True`, non-deleted rows resolve; unknown, private and deleted
+ ids are the same `404`, so existence is not leaked.
+- Tenancy: an anonymous request binds no tenant, so the single lookup by
+ (unguessable) id runs under `all_tenants()` and requires `public=True`.
+ Making a file public is the owner's explicit choice to publish it
+ cross-tenant; nothing else is reachable this way.
+- `Cache-Control: public, max-age=3600`, a checksum `ETag` (`304` on
+ `If-None-Match`) and `X-Content-Type-Options: nosniff`.
+- Every public response carries `Content-Security-Policy: default-src 'none';
+ style-src 'unsafe-inline'; sandbox` (the security-headers middleware now
+ keeps a CSP the response already set). Active content — HTML, XHTML, SVG,
+ XML, JavaScript — is forced to `Content-Disposition: attachment` and is
+ always streamed, so stored XSS on the app origin is not possible. Other
+ types are `inline`.
+- Presigning backends (S3) answer with a `302` to the presigned URL, cached
+ for at most half the signature's TTL; filesystem backends stream.
### View
@@ -48,7 +110,7 @@ from file_storage.contracts import (
| Class | Purpose |
|---|---|
-| `StoredFileOut` | File metadata: `id`, `key`, `filename`, `content_type`, `size_bytes`, `backend`, `checksum_sha256`, `uploaded_by`, `created_at`. |
+| `StoredFileOut` | File metadata: `id`, `key`, `filename`, `content_type`, `size_bytes`, `backend`, `checksum_sha256`, `uploaded_by`, `created_at`, `public`, `public_url`. |
| `StoredFileListOut` | `items`, `total`, `page`, `per_page`. |
| `FileUploaded` (event) | `file_id`, `key`, `backend`, `size_bytes`, `uploaded_by`. Topic: `file_storage.file.uploaded`. |
| `FileDeleted` (event) | `file_id`, `key`. Topic: `file_storage.file.deleted`. |
@@ -69,6 +131,7 @@ from file_storage.contracts import (
| `size_bytes` | `int` | |
| `backend` | `str(32)` | `"filesystem"` or `"s3"` — recorded at upload time |
| `checksum_sha256` | `str(64)` | computed during stream-upload |
+| `public` | `bool` | default `false`; opt-in anonymous serving |
| `extra_metadata` | `dict` | per-backend extras |
| audit + soft-delete | from `AuditMixin` + `SoftDeleteMixin` | |
@@ -216,9 +279,9 @@ class MyModule(ModuleBase):
## Inertia pages
-- `FileStorage/Browse.tsx` — file list + upload dropzone; handles the upload progress + delete confirmation flow.
+- `FileStorage/Browse.tsx` — file list + upload dropzone; handles the upload progress + delete confirmation flow. Each row shows a "Public" badge and a make public / make private action.
- `FileStorage/components/UploadDropzone.tsx` — drag-drop upload child component.
## Locales
-Top-level keys in `file_storage/locales/en.json`: `browse`, `table`, `actions`, `delete_dialog`, `toasts`, `errors`. The `errors` namespace is keyed by error *code* (`not_found`, `too_large`, `bad_type`, `backend_error`) so the UI can render a deterministic message per `StorageError` subclass.
+Top-level keys in `file_storage/locales/en.json`: `browse`, `table`, `actions`, `delete_dialog`, `toasts`, `errors`. The `errors` namespace is keyed by error *code* (`not_found`, `too_large`, `bad_type`, `backend_error`, `bad_image`) so the UI can render a deterministic message per `StorageError` subclass.
diff --git a/framework/hosting/simple_module_hosting/middleware.py b/framework/hosting/simple_module_hosting/middleware.py
index ceb77e1e..f2d076b2 100644
--- a/framework/hosting/simple_module_hosting/middleware.py
+++ b/framework/hosting/simple_module_hosting/middleware.py
@@ -147,7 +147,13 @@ async def send_with_headers(message: Message) -> None:
headers[_HEADER_X_FRAME_OPTIONS] = _XFO_SAMEORIGIN
headers[_HEADER_X_XSS_PROTECTION] = _XXSS_DISABLED
headers[_HEADER_REFERRER_POLICY] = _REFERRER_STRICT_ORIGIN
- if self.csp:
+ # A response may only *tighten* the app-wide policy: one that
+ # carries its own ``sandbox`` directive (file_storage serving
+ # user-uploaded bytes) keeps it. Any other response-supplied
+ # policy is overwritten, so a module cannot weaken the default.
+ own = headers.get(_HEADER_CSP) or ""
+ keeps_own = "sandbox" in {d.strip().split(" ")[0].lower() for d in own.split(";")}
+ if self.csp and not keeps_own:
headers[_HEADER_CSP] = self.csp
if self.hsts:
headers[_HEADER_HSTS] = self.hsts
diff --git a/framework/hosting/tests/test_middleware_order.py b/framework/hosting/tests/test_middleware_order.py
index bc2635fe..e66eb60e 100644
--- a/framework/hosting/tests/test_middleware_order.py
+++ b/framework/hosting/tests/test_middleware_order.py
@@ -40,6 +40,11 @@
cannot hide SiteLock from an anonymous visitor, because acquiring a demo
session means POSTing to the demo endpoint, which SiteLock blocks first.
+CookielessPublicFilesMiddleware (``file_storage``) keeps the session cookie and
+``Vary: Cookie`` off anonymous public-file reads. Its place among the module
+middlewares does not matter: it clears the session's accessed/modified flags as
+the response starts, so reads by Auth or SiteLock further out are forgotten too.
+
Maintenance sits after InertiaLayoutData because its 503 page renders
through Inertia and needs the shared props (auth, menus, i18n) — placed any
further out it would render bare, with no layout and untranslated copy. It is
@@ -81,6 +86,7 @@
"SessionMiddleware",
"DemoReadOnlyMiddleware",
"SiteLockMiddleware",
+ "CookielessPublicFilesMiddleware",
"AuthMiddleware",
"TenantMiddleware",
"LocaleMiddleware",
@@ -101,6 +107,7 @@
"SessionMiddleware",
"DemoReadOnlyMiddleware",
"SiteLockMiddleware",
+ "CookielessPublicFilesMiddleware",
"AuthMiddleware",
"LocaleMiddleware",
"InertiaLayoutDataMiddleware",
diff --git a/host/migrations/versions/e5a8c1f27b94_file_storage_stored_file_public.py b/host/migrations/versions/e5a8c1f27b94_file_storage_stored_file_public.py
new file mode 100644
index 00000000..05196985
--- /dev/null
+++ b/host/migrations/versions/e5a8c1f27b94_file_storage_stored_file_public.py
@@ -0,0 +1,35 @@
+"""file_storage_stored_file: add ``public`` flag
+
+Opt-in anonymous serving (GH #353). Existing rows stay private.
+``server_default=sa.false()`` renders ``0`` on SQLite and ``false`` on
+Postgres, so the same migration runs on both.
+
+Revision ID: e5a8c1f27b94
+Revises: e5f2a8c1d7b3
+Create Date: 2026-10-05 10:00:00.000000
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "e5a8c1f27b94"
+down_revision: str | None = "e5f2a8c1d7b3"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+_TABLE = "file_storage_stored_file"
+
+
+def upgrade() -> None:
+ op.add_column(
+ _TABLE,
+ sa.Column("public", sa.Boolean(), nullable=False, server_default=sa.false()),
+ )
+
+
+def downgrade() -> None:
+ with op.batch_alter_table(_TABLE) as batch:
+ batch.drop_column("public")
diff --git a/modules/branding/tests/test_branding_reap_ordering.py b/modules/branding/tests/test_branding_reap_ordering.py
index 2d8468e0..40ab6e8a 100644
--- a/modules/branding/tests/test_branding_reap_ordering.py
+++ b/modules/branding/tests/test_branding_reap_ordering.py
@@ -80,7 +80,9 @@ async def delete(key):
monkeypatch.setattr(backend, "delete", delete)
await reaper.reap(app, file_id, tenant_id=a.tenant_id)
- assert events == ["commit", "delete"]
+ # The original and any cached thumbnail variants: every delete after the commit.
+ assert events[0] == "commit" and "delete" in events
+ assert set(events[1:]) == {"delete"}
assert (await _row(app, file_id)).is_deleted is True
diff --git a/modules/file_storage/file_storage/backends/filesystem.py b/modules/file_storage/file_storage/backends/filesystem.py
index 9b4d863d..b21c3665 100644
--- a/modules/file_storage/file_storage/backends/filesystem.py
+++ b/modules/file_storage/file_storage/backends/filesystem.py
@@ -2,6 +2,8 @@
from __future__ import annotations
+import contextlib
+import uuid
from collections.abc import AsyncIterator
from pathlib import Path
@@ -49,9 +51,19 @@ async def put(
) -> None:
path = self._resolve(key)
path.parent.mkdir(parents=True, exist_ok=True)
- async with aiofiles.open(path, "wb") as fh:
- async for chunk in stream:
- await fh.write(chunk)
+ # Write beside the target and rename into place: a concurrent ``get`` (a
+ # thumbnail cache hit mid-generation) must never see a half-written
+ # object, and a crash must not leave a truncated one behind.
+ tmp = path.with_name(f"{path.name}.{uuid.uuid4().hex}.tmp")
+ try:
+ async with aiofiles.open(tmp, "wb") as fh:
+ async for chunk in stream:
+ await fh.write(chunk)
+ tmp.replace(path)
+ except BaseException:
+ with contextlib.suppress(OSError):
+ tmp.unlink(missing_ok=True)
+ raise
async def get(self, key: str) -> AsyncIterator[bytes]:
path = self._resolve(key)
diff --git a/modules/file_storage/file_storage/constants.py b/modules/file_storage/file_storage/constants.py
index 7bd70659..2242d55e 100644
--- a/modules/file_storage/file_storage/constants.py
+++ b/modules/file_storage/file_storage/constants.py
@@ -34,6 +34,14 @@
PATH_FILES: Final = "/files"
PATH_FILE_BY_ID: Final = "/files/{file_id}"
PATH_FILE_DOWNLOAD: Final = "/files/{file_id}/download"
+PATH_FILE_THUMBNAIL: Final = "/files/{file_id}/thumbnail"
+# Anonymous serving of ``public`` files (#353). The thumbnail route is declared
+# before the named one, so a file literally called "thumbnail" is reachable
+# only through the id-only form.
+PUBLIC_SEGMENT: Final = "/public"
+PATH_PUBLIC: Final = "/public/{file_id}"
+PATH_PUBLIC_THUMBNAIL: Final = "/public/{file_id}/thumbnail"
+PATH_PUBLIC_NAMED: Final = "/public/{file_id}/{filename}"
# POST, not DELETE: a selection is a body, and DELETE with a body is refused
# or silently stripped by enough proxies that it cannot be relied on.
PATH_FILES_BULK_DELETE: Final = "/files/bulk-delete"
@@ -84,6 +92,7 @@ class ErrorCode:
BAD_TYPE: Final = "file_storage.bad_type"
NOT_FOUND: Final = "file_storage.not_found"
BACKEND_ERROR: Final = "file_storage.backend_error"
+ BAD_IMAGE: Final = "file_storage.bad_image"
class I18nKey:
@@ -93,6 +102,7 @@ class I18nKey:
ERR_TOO_LARGE: Final = "file_storage.errors.too_large"
ERR_BAD_TYPE: Final = "file_storage.errors.bad_type"
ERR_BACKEND: Final = "file_storage.errors.backend_error"
+ ERR_BAD_IMAGE: Final = "file_storage.errors.bad_image"
# ── Defaults ─────────────────────────────────────────────────────────
@@ -110,6 +120,43 @@ class I18nKey:
# uploads. The label is resolved server-side, so this is the only copy.
UNKNOWN_UPLOADER: Final = "—"
+# ── Public serving & thumbnails ──────────────────────────────────────
+PUBLIC_MAX_AGE_SECONDS: Final = 3600
+# Per-IP budget for anonymous public file GETs (its own bucket in the shared
+# rate limiter, #347), wider than the default because one page embeds many.
+PUBLIC_FILES_RATE: Final = "600/minute"
+# Types a browser would execute or render as a document on our origin. They are
+# served as attachments (and sandboxed) so a public upload cannot become stored
+# XSS on the app's origin.
+ACTIVE_CONTENT_TYPES: Final = frozenset(
+ {
+ "text/html",
+ "application/xhtml+xml",
+ "image/svg+xml",
+ "text/xml",
+ "application/xml",
+ "text/javascript",
+ "application/javascript",
+ "application/x-shockwave-flash",
+ }
+)
+PUBLIC_CSP: Final = "default-src 'none'; style-src 'unsafe-inline'; sandbox"
+
+THUMBNAIL_WIDTHS: Final = (64, 128, 256, 512, 1024)
+THUMBNAIL_DEFAULT_WIDTH: Final = 256
+THUMBNAIL_MIN_WIDTH: Final = 32
+THUMBNAIL_MAX_WIDTH: Final = 1024
+THUMBNAIL_CONTENT_TYPE: Final = "image/webp"
+# Decode budget: refuse an image whose pixel count would balloon memory
+# (decompression bomb) before Pillow ever decodes it.
+THUMBNAIL_MAX_PIXELS: Final = 25_000_000
+THUMBNAIL_MAX_SOURCE_BYTES: Final = 20 * 1024 * 1024
+THUMBNAIL_MAX_CONCURRENCY: Final = 2
+THUMBNAIL_MAX_AGE_SECONDS: Final = 86400
+
+# ── Listing ──────────────────────────────────────────────────────────
+DEFAULT_SORT: Final = "-created_at"
+
# ── Menu ─────────────────────────────────────────────────────────────
MENU_ICON: Final = "files"
MENU_ORDER: Final = 40
diff --git a/modules/file_storage/file_storage/contracts/schemas.py b/modules/file_storage/file_storage/contracts/schemas.py
index bd518b7e..24977fa4 100644
--- a/modules/file_storage/file_storage/contracts/schemas.py
+++ b/modules/file_storage/file_storage/contracts/schemas.py
@@ -5,7 +5,7 @@
import uuid
from datetime import datetime
-from pydantic import ConfigDict
+from pydantic import ConfigDict, StrictBool
from sqlmodel import Field, SQLModel
@@ -26,6 +26,20 @@ class StoredFileOut(SQLModel):
description="User id from AuditMixin.created_by — populated by the audit listener.",
)
created_at: datetime | None = None
+ public: bool = Field(default=False, description="Whether anonymous visitors may fetch it.")
+ public_url: str | None = Field(
+ default=None,
+ description="Anonymous URL, present only while the file is public.",
+ )
+
+
+class StoredFileUpdate(SQLModel):
+ """Body for PATCH /api/file-storage/files/{id}."""
+
+ # Strict: a JSON body says ``true``/``false``. Lax coercion would publish a
+ # file on ``"yes"``, ``"true"`` or ``1`` from a client bug. The upload form
+ # stays lax on purpose — multipart values are always strings.
+ public: StrictBool
class BulkDeleteRequest(SQLModel):
diff --git a/modules/file_storage/file_storage/cookieless.py b/modules/file_storage/file_storage/cookieless.py
new file mode 100644
index 00000000..5c59335e
--- /dev/null
+++ b/modules/file_storage/file_storage/cookieless.py
@@ -0,0 +1,68 @@
+"""Keep the session out of anonymous public-file responses (#353).
+
+Public files are served ``Cache-Control: public`` so a CDN or shared proxy can
+hold them. Two headers the session layer adds would undo that:
+
+* ``Set-Cookie: session=…`` — the layout middleware records the locale and
+ i18n audience it served in the session on *every* request, so an anonymous
+ fetch mints a fresh 14-day session cookie. A shared cache that stores the
+ response would hand that cookie to every later visitor.
+* ``Vary: Cookie`` — added whenever anything read the session, which splits
+ the cache into one entry per visitor for bytes that do not depend on who
+ asked (the handler never looks at the caller).
+
+Nothing on these routes needs to *write* the session, so this middleware tells
+the session layer that nothing touched it: when the response starts it clears
+the session's ``accessed``/``modified`` flags, and Starlette's
+``SessionMiddleware`` then emits neither header. Clearing the flags rather than
+swapping in a throwaway session keeps it correct whatever order the module
+middleware ends up in — an auth layer further out may already have read the
+real session on the way in. Writes made while serving are simply not persisted;
+a signed-in visitor's existing cookie is left exactly as it was.
+"""
+
+from __future__ import annotations
+
+import re
+
+from starlette.types import ASGIApp, Message, Receive, Scope, Send
+
+from file_storage import constants
+
+PUBLIC_PATH_PATTERN = (
+ rf"{re.escape(constants.ROUTE_PREFIX_API + constants.PUBLIC_SEGMENT)}/[^/]+(/[^/]+)?$"
+)
+"""The anonymous read routes: ``/public/{id}``, ``/{id}/{name}``, ``/{id}/thumbnail``."""
+
+_PUBLIC_PATH = re.compile(PUBLIC_PATH_PATTERN)
+_READ_METHODS = frozenset({"GET", "HEAD"})
+
+
+class CookielessPublicFilesMiddleware:
+ """Suppress session ``Set-Cookie`` / ``Vary: Cookie`` on public file reads."""
+
+ def __init__(self, app: ASGIApp) -> None:
+ self.app = app
+
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
+ if (
+ scope["type"] != "http"
+ or scope.get("method") not in _READ_METHODS
+ or _PUBLIC_PATH.match(scope.get("path", "")) is None
+ ):
+ await self.app(scope, receive, send)
+ return
+
+ async def send_untouched(message: Message) -> None:
+ if message["type"] == "http.response.start":
+ _forget_session_use(scope.get("session"))
+ await send(message)
+
+ await self.app(scope, receive, send_untouched)
+
+
+def _forget_session_use(session: object) -> None:
+ # Starlette's ``Session`` decides both headers from these two flags alone.
+ for flag in ("accessed", "modified"):
+ if hasattr(session, flag):
+ setattr(session, flag, False)
diff --git a/modules/file_storage/file_storage/endpoints/api.py b/modules/file_storage/file_storage/endpoints/api.py
index a4c864da..3fe3c824 100644
--- a/modules/file_storage/file_storage/endpoints/api.py
+++ b/modules/file_storage/file_storage/endpoints/api.py
@@ -3,20 +3,32 @@
from __future__ import annotations
import uuid
-
-from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
+from typing import Literal
+
+from fastapi import (
+ APIRouter,
+ Depends,
+ File,
+ Form,
+ HTTPException,
+ Query,
+ Response,
+ UploadFile,
+ status,
+)
from fastapi.responses import RedirectResponse, StreamingResponse
from simple_module_core.events import EventBus
from simple_module_hosting.i18n_deps import TranslatorDep
from simple_module_hosting.permissions import RequiresPermission
-from file_storage import constants
+from file_storage import constants, queries
from file_storage.contracts.events import FileDeleted, FileUploaded
from file_storage.contracts.schemas import (
BulkDeleteRequest,
BulkDeleteResult,
StoredFileListOut,
StoredFileOut,
+ StoredFileUpdate,
)
from file_storage.deps import get_event_bus, get_file_storage_service
from file_storage.format import format_bytes
@@ -28,6 +40,7 @@
StoredFileNotFoundError,
StreamDownload,
)
+from file_storage.serving import not_found, thumbnail_response
router = APIRouter()
@@ -41,11 +54,12 @@
async def upload_file(
t: TranslatorDep,
file: UploadFile = File(...),
+ public: bool = Form(default=False),
service: FileStorageService = Depends(get_file_storage_service),
bus: EventBus = Depends(get_event_bus),
) -> StoredFileOut:
try:
- out = await service.upload(file)
+ out = await service.upload(file, public=public)
except FileTooLargeError as exc:
# The limit belongs in the sentence: "too large" is not actionable to
# someone holding a 40 MB file, and every client that shows this
@@ -93,9 +107,21 @@ async def list_files(
# a 422 for out-of-range paging, while the Inertia views clamp instead.
page: int = Query(default=1, ge=1),
per_page: int = Query(default=20, ge=1, le=200),
+ q: str | None = Query(
+ default=None, description="Case-insensitive substring of the original filename."
+ ),
+ content_type: str | None = Query(
+ default=None,
+ description="Exact content type, or a family ending in '/' such as 'image/'.",
+ ),
+ sort: Literal["created_at", "-created_at", "name", "-name", "size", "-size"] = Query(
+ default=constants.DEFAULT_SORT
+ ),
service: FileStorageService = Depends(get_file_storage_service),
) -> StoredFileListOut:
- items, total = await service.list_files(page=page, per_page=per_page)
+ items, total = await service.list_files(
+ page=page, per_page=per_page, search=q, content_type=content_type, sort=sort
+ )
return StoredFileListOut(items=items, total=total, page=page, per_page=per_page)
@@ -112,25 +138,46 @@ async def get_file(
try:
row = await service.get(file_id)
except StoredFileNotFoundError as exc:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail={
- "code": constants.ErrorCode.NOT_FOUND,
- "message": t.t(constants.I18nKey.ERR_NOT_FOUND),
- },
- ) from exc
- return StoredFileOut.model_validate(
- {
- "id": row.id,
- "key": row.key,
- "filename": row.filename,
- "content_type": row.content_type,
- "size_bytes": row.size_bytes,
- "backend": row.backend,
- "checksum_sha256": row.checksum_sha256,
- "uploaded_by": row.created_by,
- "created_at": row.created_at,
- }
+ raise not_found(t) from exc
+ return StoredFileOut.model_validate(queries.to_out_dict(row))
+
+
+@router.patch(
+ constants.PATH_FILE_BY_ID,
+ response_model=StoredFileOut,
+ dependencies=[Depends(RequiresPermission(constants.Permission.UPLOAD))],
+)
+async def update_file(
+ file_id: uuid.UUID,
+ body: StoredFileUpdate,
+ t: TranslatorDep,
+ service: FileStorageService = Depends(get_file_storage_service),
+) -> StoredFileOut:
+ """Publish or unpublish a file (anyone allowed to upload may decide)."""
+ try:
+ row = await service.set_public(file_id, body.public)
+ except StoredFileNotFoundError as exc:
+ raise not_found(t) from exc
+ return StoredFileOut.model_validate(queries.to_out_dict(row))
+
+
+@router.get(
+ constants.PATH_FILE_THUMBNAIL,
+ response_model=None,
+ dependencies=[Depends(RequiresPermission(constants.Permission.DOWNLOAD))],
+)
+async def file_thumbnail(
+ file_id: uuid.UUID,
+ t: TranslatorDep,
+ w: int | None = Query(default=None, description="Width in px; clamped and snapped."),
+ service: FileStorageService = Depends(get_file_storage_service),
+) -> Response:
+ try:
+ row = await service.get(file_id)
+ except StoredFileNotFoundError as exc:
+ raise not_found(t) from exc
+ return await thumbnail_response(
+ service, row, w, t, cache_control=f"private, max-age={constants.THUMBNAIL_MAX_AGE_SECONDS}"
)
@@ -147,13 +194,7 @@ async def download_file(
try:
download = await service.download(file_id)
except StoredFileNotFoundError as exc:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail={
- "code": constants.ErrorCode.NOT_FOUND,
- "message": t.t(constants.I18nKey.ERR_NOT_FOUND),
- },
- ) from exc
+ raise not_found(t) from exc
if isinstance(download, RedirectDownload):
return RedirectResponse(url=download.url, status_code=status.HTTP_302_FOUND)
@@ -210,11 +251,5 @@ async def delete_file(
try:
row = await service.delete(file_id)
except StoredFileNotFoundError as exc:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail={
- "code": constants.ErrorCode.NOT_FOUND,
- "message": t.t(constants.I18nKey.ERR_NOT_FOUND),
- },
- ) from exc
+ raise not_found(t) from exc
await bus.publish(FileDeleted(file_id=row.id, key=row.key))
diff --git a/modules/file_storage/file_storage/endpoints/public.py b/modules/file_storage/file_storage/endpoints/public.py
new file mode 100644
index 00000000..9f853c73
--- /dev/null
+++ b/modules/file_storage/file_storage/endpoints/public.py
@@ -0,0 +1,60 @@
+"""Anonymous read routes for files marked ``public`` (#353).
+
+Exempted from ``AuthMiddleware`` by ``FileStorageModule.register_public_routes``
+(GET only). Every miss — unknown, private, soft-deleted — is the same 404, so
+the route cannot be used to learn which ids exist.
+"""
+
+from __future__ import annotations
+
+import uuid
+
+from fastapi import APIRouter, Depends, Query, Request, Response
+from simple_module_hosting.i18n_deps import TranslatorDep
+
+from file_storage import constants
+from file_storage.deps import get_file_storage_service
+from file_storage.service import FileStorageService, StoredFileNotFoundError
+from file_storage.serving import not_found, public_file_response, thumbnail_response
+
+router = APIRouter()
+
+
+async def _public_row(service: FileStorageService, file_id: uuid.UUID, t: TranslatorDep):
+ try:
+ return await service.get_public(file_id)
+ except StoredFileNotFoundError as exc:
+ raise not_found(t) from exc
+
+
+# Declared before the ``{filename}`` route so "thumbnail" is not read as a name.
+@router.get(constants.PATH_PUBLIC_THUMBNAIL, response_model=None)
+async def public_thumbnail(
+ file_id: uuid.UUID,
+ request: Request,
+ t: TranslatorDep,
+ w: int | None = Query(default=None, description="Width in px; clamped and snapped."),
+ service: FileStorageService = Depends(get_file_storage_service),
+) -> Response:
+ row = await _public_row(service, file_id, t)
+ return await thumbnail_response(
+ service,
+ row,
+ w,
+ t,
+ cache_control=f"public, max-age={constants.PUBLIC_MAX_AGE_SECONDS}",
+ request=request,
+ )
+
+
+@router.get(constants.PATH_PUBLIC, response_model=None)
+@router.get(constants.PATH_PUBLIC_NAMED, response_model=None)
+async def public_file(
+ file_id: uuid.UUID,
+ request: Request,
+ t: TranslatorDep,
+ filename: str | None = None, # cosmetic: lets the URL end in a readable name
+ service: FileStorageService = Depends(get_file_storage_service),
+) -> Response:
+ row = await _public_row(service, file_id, t)
+ return await public_file_response(service, row, request, t)
diff --git a/modules/file_storage/file_storage/locales/en.json b/modules/file_storage/file_storage/locales/en.json
index f716b3ab..d5f2e288 100644
--- a/modules/file_storage/file_storage/locales/en.json
+++ b/modules/file_storage/file_storage/locales/en.json
@@ -29,10 +29,13 @@
"when": "When",
"actions": "Actions",
"select_all": "Select every file on this page",
- "select_row": "Select {name}"
+ "select_row": "Select {name}",
+ "public": "Public"
},
"actions": {
- "download": "Download"
+ "download": "Download",
+ "make_public": "Make public",
+ "make_private": "Make private"
},
"delete_dialog": {
"title_one": "Delete “{name}”?",
@@ -48,6 +51,9 @@
"deleted_one": "“{name}” deleted",
"deleted_other": "{count} files deleted",
"delete_failed": "Failed to delete file",
+ "made_public": "“{name}” is now public",
+ "made_private": "“{name}” is now private",
+ "visibility_failed": "Could not change visibility",
"uploaded_count_one": "{count} file uploaded",
"uploaded_count_other": "{count} files uploaded",
"upload_failed_named": "“{name}” failed to upload"
@@ -56,7 +62,8 @@
"not_found": "File not found",
"too_large": "File exceeds the {max_size} limit for a single upload",
"bad_type": "This file type is not allowed",
- "backend_error": "Storage backend error"
+ "backend_error": "Storage backend error",
+ "bad_image": "This image could not be processed"
},
"filters": {
"search_placeholder": "Search filenames…",
diff --git a/modules/file_storage/file_storage/models.py b/modules/file_storage/file_storage/models.py
index b0a67961..630cb83f 100644
--- a/modules/file_storage/file_storage/models.py
+++ b/modules/file_storage/file_storage/models.py
@@ -39,6 +39,13 @@ class StoredFile(Base, AuditMixin, SoftDeleteMixin, MultiTenantMixin, table=True
size_bytes: int = Field()
backend: str = Field(max_length=32)
checksum_sha256: str = Field(max_length=64)
+ # Opt-in anonymous serving (#353): only ``public`` rows are reachable from
+ # ``GET {prefix}/public/{id}``. ``sa.false()`` renders as ``0`` on SQLite and
+ # ``false`` on Postgres, which a literal ``text("0")`` default would not.
+ public: bool = Field(
+ default=False,
+ sa_column=sa.Column(sa.Boolean(), nullable=False, server_default=sa.false()),
+ )
extra_metadata: dict = Field(
default_factory=dict,
sa_type=sa.JSON,
diff --git a/modules/file_storage/file_storage/module.py b/modules/file_storage/file_storage/module.py
index 2427a112..d714106e 100644
--- a/modules/file_storage/file_storage/module.py
+++ b/modules/file_storage/file_storage/module.py
@@ -14,6 +14,7 @@
from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection
from simple_module_core.module import ModuleBase, ModuleMeta
from simple_module_core.permissions import PermissionRegistry
+from simple_module_core.public_routes import PublicRouteRegistry
from simple_module_core.tenancy import TenantRole, tenant_role
from file_storage import constants
@@ -89,11 +90,33 @@ def register_settings(self, app: FastAPI) -> None:
def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None:
from file_storage.endpoints.api import router as api
+ from file_storage.endpoints.public import router as public
from file_storage.endpoints.views import router as views
api_router.include_router(api)
+ api_router.include_router(public)
view_router.include_router(views)
+ def register_public_routes(self, registry: PublicRouteRegistry) -> None:
+ """Let anyone GET a file its owner marked public (#353).
+
+ GET-only and anchored to ``/public/{id}[/{name}|/thumbnail]``, so
+ uploads, deletes and the authenticated download keep requiring a
+ session. The handler itself still refuses anything not ``public``.
+ """
+ from file_storage.constants import PUBLIC_FILES_RATE
+ from file_storage.cookieless import PUBLIC_PATH_PATTERN
+
+ # Its own, wider bucket: one public page can embed dozens of images and
+ # thumbnails, which would exhaust the shared anonymous default.
+ registry.add_regex(PUBLIC_PATH_PATTERN, methods={"GET"}, rate=PUBLIC_FILES_RATE)
+
+ def register_middleware(self, app: FastAPI) -> None:
+ """Serve public files without a session cookie or ``Vary: Cookie``."""
+ from file_storage.cookieless import CookielessPublicFilesMiddleware
+
+ app.add_middleware(CookielessPublicFilesMiddleware)
+
def register_audit_links(self, registry: AuditLinkRegistry) -> None:
"""Name file rows in the audit log, and tag them with their table.
diff --git a/modules/file_storage/file_storage/pages/Browse.tsx b/modules/file_storage/file_storage/pages/Browse.tsx
index 22b6a376..f1500728 100644
--- a/modules/file_storage/file_storage/pages/Browse.tsx
+++ b/modules/file_storage/file_storage/pages/Browse.tsx
@@ -19,7 +19,7 @@ import { UploadDropzone } from './components/UploadDropzone';
import { UploadsCard } from './components/UploadsCard';
import { PERMISSIONS, RELOAD_PROPS, ROUTES } from './constants';
import { describeTypes, formatBytes } from './format';
-import type { BrowseProps, FileFilters } from './types';
+import type { BrowseProps, FileFilters, StoredFile } from './types';
import { useUploadQueue } from './upload-queue';
function Browse() {
@@ -87,6 +87,30 @@ function Browse() {
}
}
+ async function handleTogglePublic(file: StoredFile) {
+ try {
+ const resp = await fetch(ROUTES.apiFile(file.id), {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ public: !file.public }),
+ });
+ if (!resp.ok) throw new Error('visibility change failed');
+ toast.success(
+ t(
+ file.public
+ ? keys.file_storage.toasts.made_private
+ : keys.file_storage.toasts.made_public,
+ {
+ name: file.filename,
+ },
+ ),
+ );
+ router.reload({ only: RELOAD_PROPS });
+ } catch {
+ toast.error(t(keys.file_storage.toasts.visibility_failed));
+ }
+ }
+
async function handleDelete() {
setDeleting(true);
try {
@@ -188,6 +212,8 @@ function Browse() {
files={files}
selectedIds={selectedIds}
canDelete={canDelete}
+ canPublish={canUpload}
+ onTogglePublic={handleTogglePublic}
onToggleRow={(id, on) =>
select(on ? [...selectedIds, id] : selectedIds.filter((x) => x !== id))
}
diff --git a/modules/file_storage/file_storage/pages/components/FileTable.tsx b/modules/file_storage/file_storage/pages/components/FileTable.tsx
index 8f4fe76c..7faec28e 100644
--- a/modules/file_storage/file_storage/pages/components/FileTable.tsx
+++ b/modules/file_storage/file_storage/pages/components/FileTable.tsx
@@ -1,5 +1,6 @@
import { keys, useT } from '@simple-module-py/i18n';
import { TableEmptyRow } from '@simple-module-py/ui/components/TableEmptyRow';
+import { Badge } from '@simple-module-py/ui/components/ui/badge';
import { Button } from '@simple-module-py/ui/components/ui/button';
import { Checkbox } from '@simple-module-py/ui/components/ui/checkbox';
import {
@@ -21,6 +22,9 @@ interface Props {
files: StoredFile[];
selectedIds: string[];
canDelete: boolean;
+ /** Whether the viewer may publish/unpublish (the upload permission). */
+ canPublish: boolean;
+ onTogglePublic: (file: StoredFile) => void;
onToggleRow: (id: string, selected: boolean) => void;
onToggleAll: (selected: boolean) => void;
/** Rendered in place of the rows when there is nothing to show. */
@@ -52,6 +56,8 @@ export function FileTable({
files,
selectedIds,
canDelete,
+ canPublish,
+ onTogglePublic,
onToggleRow,
onToggleAll,
empty,
@@ -108,7 +114,14 @@ export function FileTable({
/>
)}
- {file.filename}
+
+ {file.filename}
+ {file.public && (
+
+ {t(keys.file_storage.table.public)}
+
+ )}
+
{file.content_type}
@@ -130,6 +143,20 @@ export function FileTable({
>
{t(keys.file_storage.actions.download)}
+ {canPublish && (
+
+ )}
);
diff --git a/modules/file_storage/file_storage/pages/types.ts b/modules/file_storage/file_storage/pages/types.ts
index 22886ea5..e78d0088 100644
--- a/modules/file_storage/file_storage/pages/types.ts
+++ b/modules/file_storage/file_storage/pages/types.ts
@@ -10,6 +10,9 @@ export interface StoredFile {
/** ``uploaded_by`` resolved server-side to a name; "—" when nobody was recorded. */
uploaded_by_label: string;
created_at: string | null;
+ /** Anonymous visitors may fetch it at ``public_url``. */
+ public: boolean;
+ public_url: string | null;
}
export interface Pagination {
diff --git a/modules/file_storage/file_storage/queries.py b/modules/file_storage/file_storage/queries.py
index 4fd73ce2..2fbd680b 100644
--- a/modules/file_storage/file_storage/queries.py
+++ b/modules/file_storage/file_storage/queries.py
@@ -13,14 +13,28 @@
from __future__ import annotations
+from urllib.parse import quote
+
from simple_module_db import LIKE_ESCAPE_CHAR, like_contains_pattern, like_prefix_pattern
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
+from file_storage import constants
from file_storage.contracts.schemas import StoredFileOut
from file_storage.models import StoredFile
+def order_clauses(sort: str) -> list:
+ """ORDER BY for a ``sort`` token; ``id`` breaks ties so pages never overlap."""
+ field = sort.lstrip("-")
+ column = {
+ "created_at": StoredFile.created_at,
+ "name": func.lower(StoredFile.filename),
+ "size": StoredFile.size_bytes,
+ }.get(field, StoredFile.created_at)
+ return [column.desc() if sort.startswith("-") else column.asc(), StoredFile.id]
+
+
def filter_clauses(
*,
created_by: str | None,
@@ -91,13 +105,14 @@ async def page_of_files(
created_by: str | None = None,
search: str | None = None,
content_type: str | None = None,
+ sort: str = constants.DEFAULT_SORT,
) -> list[StoredFileOut]:
- """One page of rows, newest first, narrowed by the same filters as the count."""
+ """One page of rows, narrowed by the same filters as the count (newest first by default)."""
query = select(StoredFile)
for clause in filter_clauses(created_by=created_by, search=search, content_type=content_type):
query = query.where(clause)
result = await db.execute(
- query.order_by(StoredFile.created_at.desc()).offset((page - 1) * per_page).limit(per_page)
+ query.order_by(*order_clauses(sort)).offset((page - 1) * per_page).limit(per_page)
)
return [StoredFileOut.model_validate(to_out_dict(r)) for r in result.scalars().all()]
@@ -110,6 +125,7 @@ async def list_files(
created_by: str | None = None,
search: str | None = None,
content_type: str | None = None,
+ sort: str = constants.DEFAULT_SORT,
) -> tuple[list[StoredFileOut], int]:
"""Page plus total, for callers whose page number is already known good.
@@ -118,7 +134,7 @@ async def list_files(
"""
filters = {"created_by": created_by, "search": search, "content_type": content_type}
total = await count_files(db, **filters)
- items = await page_of_files(db, page=page, per_page=per_page, **filters)
+ items = await page_of_files(db, page=page, per_page=per_page, sort=sort, **filters)
return items, total
@@ -140,9 +156,19 @@ async def content_type_facets(db: AsyncSession, *, created_by: str | None = None
return [{"value": str(row[0]), "count": int(row[1])} for row in rows]
+def public_url_for(file_id: object, filename: str) -> str:
+ """The anonymous URL of a public file, with its name as a trailing segment."""
+ name = quote(filename, safe="")
+ base = f"{constants.ROUTE_PREFIX_API}{constants.PUBLIC_SEGMENT}/{file_id}"
+ # "thumbnail" as a trailing segment is the thumbnail route, not a filename.
+ return base if name.lower() == "thumbnail" else f"{base}/{name}"
+
+
def to_out_dict(row: StoredFile) -> dict:
"""Project ORM row → DTO dict, mapping ``created_by`` to ``uploaded_by``."""
return {
+ "public": row.public,
+ "public_url": public_url_for(row.id, row.filename) if row.public else None,
"id": row.id,
"key": row.key,
"filename": row.filename,
diff --git a/modules/file_storage/file_storage/reads.py b/modules/file_storage/file_storage/reads.py
index e9a9d1a4..c83b8b2a 100644
--- a/modules/file_storage/file_storage/reads.py
+++ b/modules/file_storage/file_storage/reads.py
@@ -17,7 +17,7 @@
from sqlalchemy.ext.asyncio import AsyncSession
-from file_storage import aggregates, queries
+from file_storage import aggregates, constants, queries
from file_storage.contracts.schemas import StoredFileOut
if TYPE_CHECKING:
@@ -38,6 +38,7 @@ async def list_files(
created_by: str | None = None,
search: str | None = None,
content_type: str | None = None,
+ sort: str = constants.DEFAULT_SORT,
) -> tuple[list[StoredFileOut], int]:
return await queries.list_files(
self.db,
@@ -46,6 +47,7 @@ async def list_files(
created_by=created_by,
search=search,
content_type=content_type,
+ sort=sort,
)
async def count_files(
diff --git a/modules/file_storage/file_storage/service.py b/modules/file_storage/file_storage/service.py
index c7b04724..31da1875 100644
--- a/modules/file_storage/file_storage/service.py
+++ b/modules/file_storage/file_storage/service.py
@@ -21,12 +21,13 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
-from file_storage import constants, queries
+from file_storage import constants, queries, thumbnails
from file_storage.contracts.schemas import StoredFileOut
from file_storage.contracts.service import StorageNotFoundError
from file_storage.models import StoredFile
from file_storage.reads import FileStorageReads
from file_storage.scope import PLATFORM_TENANT_ID, owning_tenant, platform_scope
+from file_storage.visibility import FileStoragePublic
if TYPE_CHECKING:
from file_storage.aggregates import AggregateCache
@@ -68,7 +69,7 @@ class RedirectDownload:
Download = StreamDownload | RedirectDownload
-class FileStorageService(FileStorageReads):
+class FileStorageService(FileStoragePublic, FileStorageReads):
"""Orchestrates validation, hashing, backend IO, and DB lifecycle."""
def __init__(
@@ -90,7 +91,9 @@ def __init__(
# ── Upload ───────────────────────────────────────────────────────
- async def upload(self, upload: UploadFile, *, platform: bool = False) -> StoredFileOut:
+ async def upload(
+ self, upload: UploadFile, *, platform: bool = False, public: bool = False
+ ) -> StoredFileOut:
"""Validate, stream-hash, persist to backend, and record metadata.
The row belongs to the bound tenant, or — with ``platform=True`` — to
@@ -141,6 +144,7 @@ async def _hashing_stream() -> AsyncIterator[bytes]:
size_bytes=size,
backend=self.backend.backend_id,
checksum_sha256=sha.hexdigest(),
+ public=public,
)
async with platform_scope(self.db, platform):
self.db.add(row)
@@ -231,7 +235,11 @@ async def delete_many(self, file_ids: Sequence[uuid.UUID]) -> list[StoredFile]:
# no row left pointing at them. A failure here is a janitor's
# problem, not the caller's.
try:
- await self.backend.delete(row.key)
+ try:
+ await self.backend.delete(row.key)
+ finally:
+ # Variants go even when the original's delete fails.
+ await thumbnails.delete_variants(self.backend, row.key)
except StorageNotFoundError:
# Acceptably absent — eg. a previous delete partially succeeded.
pass
@@ -266,10 +274,15 @@ async def delete(
return row
async def drop_object(self, row: StoredFile) -> None:
- """Delete ``row``'s backend object; an already-absent object is fine."""
- # Acceptably absent — eg. a previous delete partially succeeded.
- with contextlib.suppress(StorageNotFoundError):
- await self.backend.delete(row.key)
+ """Delete ``row``'s backend object and its thumbnails; absent ones are fine."""
+ try:
+ # Acceptably absent — eg. a previous delete partially succeeded.
+ with contextlib.suppress(StorageNotFoundError):
+ await self.backend.delete(row.key)
+ finally:
+ # A failed original delete still raises, but must not orphan the
+ # variants (``delete_variants`` itself never raises).
+ await thumbnails.delete_variants(self.backend, row.key)
def _generate_key(tenant_id: str, filename: str) -> str:
diff --git a/modules/file_storage/file_storage/serving.py b/modules/file_storage/file_storage/serving.py
new file mode 100644
index 00000000..6f4bae9a
--- /dev/null
+++ b/modules/file_storage/file_storage/serving.py
@@ -0,0 +1,133 @@
+"""HTTP responses for file bytes and thumbnails, shared by the authenticated and
+anonymous routes so both apply the same headers and the same safety rules."""
+
+from __future__ import annotations
+
+from urllib.parse import quote
+
+from fastapi import HTTPException, Request, Response, status
+from fastapi.responses import RedirectResponse, StreamingResponse
+
+from file_storage import constants, thumbnails
+from file_storage.contracts.service import StorageNotFoundError
+from file_storage.models import StoredFile
+from file_storage.service import FileStorageService
+
+
+def _etag(row: StoredFile, suffix: str = "") -> str:
+ return f'"{row.checksum_sha256}{suffix}"'
+
+
+def not_found(t) -> HTTPException:
+ """The uniform 404 body, so a miss never reveals why it missed."""
+ return HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail={
+ "code": constants.ErrorCode.NOT_FOUND,
+ "message": t.t(constants.I18nKey.ERR_NOT_FOUND),
+ },
+ )
+
+
+def _headers(etag: str, cache_control: str) -> dict[str, str]:
+ return {
+ "ETag": etag,
+ "Cache-Control": cache_control,
+ "X-Content-Type-Options": "nosniff",
+ "Content-Security-Policy": constants.PUBLIC_CSP,
+ }
+
+
+def _not_modified(request: Request, etag: str) -> bool:
+ sent = request.headers.get("if-none-match", "")
+ return etag in {part.strip().removeprefix("W/") for part in sent.split(",")}
+
+
+def is_active_content(content_type: str) -> bool:
+ return thumbnails.base_type(content_type) in constants.ACTIVE_CONTENT_TYPES
+
+
+def content_disposition(filename: str, *, attachment: bool) -> str:
+ ascii_name = "".join(
+ c for c in filename.encode("ascii", "ignore").decode() if c.isprintable() and c not in '"\\'
+ )
+ kind = "attachment" if attachment else "inline"
+ return f"{kind}; filename=\"{ascii_name or 'file'}\"; filename*=UTF-8''{quote(filename)}"
+
+
+async def thumbnail_response(
+ service: FileStorageService,
+ row: StoredFile,
+ width: int | None,
+ t,
+ *,
+ cache_control: str,
+ request: Request | None = None,
+) -> Response:
+ """Serve ``row``'s resized variant; 404 for non-images, 422 if undecodable."""
+ snapped = thumbnails.snap_width(width)
+ etag = _etag(row, f"-w{snapped}")
+ headers = _headers(etag, cache_control)
+ if (
+ request is not None
+ and _not_modified(request, etag)
+ and thumbnails.is_thumbnailable(row.content_type)
+ ):
+ return Response(status_code=status.HTTP_304_NOT_MODIFIED, headers=headers)
+ try:
+ data = await service.thumbnail(row, snapped)
+ except (thumbnails.NotAnImageError, StorageNotFoundError) as exc:
+ # A row whose object is gone is a miss, not a server error.
+ raise not_found(t) from exc
+ except thumbnails.UnreadableImageError as exc:
+ raise HTTPException(
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+ detail={
+ "code": constants.ErrorCode.BAD_IMAGE,
+ "message": t.t(constants.I18nKey.ERR_BAD_IMAGE),
+ },
+ ) from exc
+ return Response(content=data, media_type=constants.THUMBNAIL_CONTENT_TYPE, headers=headers)
+
+
+async def public_file_response(
+ service: FileStorageService, row: StoredFile, request: Request, t
+) -> Response:
+ """Serve a file already authorised as public.
+
+ Active content (HTML, SVG, scripts) is forced to download and sandboxed,
+ and is streamed even on presigning backends so these headers always apply;
+ everything else may redirect to the backend's own URL.
+ """
+ max_age = constants.PUBLIC_MAX_AGE_SECONDS
+ etag = _etag(row)
+ active = is_active_content(row.content_type)
+ headers = _headers(etag, f"public, max-age={max_age}")
+ if _not_modified(request, etag):
+ return Response(status_code=status.HTTP_304_NOT_MODIFIED, headers=headers)
+
+ if service.backend.supports_presigned_url and not active:
+ url = await service.backend.presigned_get_url(
+ row.key, service.settings.s3_presign_ttl_seconds
+ )
+ # A cached redirect must not outlive the signature it points at.
+ redirect_age = min(max_age, service.settings.s3_presign_ttl_seconds // 2)
+ return RedirectResponse(
+ url=url,
+ status_code=status.HTTP_302_FOUND,
+ headers={**headers, "Cache-Control": f"public, max-age={redirect_age}"},
+ )
+
+ try:
+ body = await service.backend.get(row.key)
+ except StorageNotFoundError as exc:
+ raise not_found(t) from exc
+ return StreamingResponse(
+ body,
+ media_type=row.content_type,
+ headers={
+ **headers,
+ "Content-Disposition": content_disposition(row.filename, attachment=active),
+ "Content-Length": str(row.size_bytes),
+ },
+ )
diff --git a/modules/file_storage/file_storage/thumbnails.py b/modules/file_storage/file_storage/thumbnails.py
new file mode 100644
index 00000000..05deb45d
--- /dev/null
+++ b/modules/file_storage/file_storage/thumbnails.py
@@ -0,0 +1,220 @@
+"""Resized image variants for the browse grid, pickers and public pages (#352).
+
+Variants are cached **in the storage backend**, beside the original, under a
+key derived from the original's (``{key}.w{width}.webp``). That is the simplest
+cache that is also robust: it survives restarts and is shared by every worker,
+it needs no eviction policy of its own because the width is snapped to a short
+whitelist (:data:`~file_storage.constants.THUMBNAIL_WIDTHS`, so at most five
+variants per file), it inherits the original's tenant prefix, and it is dropped
+with the original (:func:`delete_variants`). The cost is one extra object per
+size actually requested.
+
+Pillow work runs in a thread: decoding a large photo would otherwise stall the
+event loop for every other request.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import io
+import logging
+import struct
+import weakref
+from typing import TYPE_CHECKING
+
+from PIL import Image, ImageOps
+
+from file_storage import constants
+from file_storage.contracts.service import StorageNotFoundError
+
+if TYPE_CHECKING:
+ from file_storage.contracts.service import StorageBackend
+
+_logger = logging.getLogger(__name__)
+
+
+class NotAnImageError(Exception):
+ """The file's type has no thumbnail (not a raster image, or SVG)."""
+
+
+class UnreadableImageError(Exception):
+ """The bytes are not a decodable image, or are too large to decode safely."""
+
+
+def base_type(content_type: str) -> str:
+ """The media type without parameters, lowercased (``Image/PNG; x=y`` -> ``image/png``)."""
+ return content_type.split(";")[0].strip().lower()
+
+
+def is_thumbnailable(content_type: str) -> bool:
+ return base_type(content_type) in _FORMATS_BY_TYPE
+
+
+def snap_width(width: int | None) -> int:
+ """Clamp to the allowed range, then round up to the next whitelisted size."""
+ wanted = constants.THUMBNAIL_DEFAULT_WIDTH if width is None else width
+ wanted = max(constants.THUMBNAIL_MIN_WIDTH, min(wanted, constants.THUMBNAIL_MAX_WIDTH))
+ return next(w for w in constants.THUMBNAIL_WIDTHS if w >= wanted)
+
+
+def variant_key(key: str, width: int) -> str:
+ return f"{key}.w{width}.webp"
+
+
+_FORMATS_BY_TYPE = {
+ "image/jpeg": "JPEG",
+ "image/png": "PNG",
+ "image/webp": "WEBP",
+ "image/gif": "GIF",
+}
+# Pillow's decoders are a large native attack surface; only these four are ever
+# opened (``formats=``), never SVG/EPS/PSD/TIFF/PDF-style containers.
+_ALLOWED_FORMATS = tuple(_FORMATS_BY_TYPE.values())
+
+# At most this many decodes run at once, so a burst of cold-cache requests
+# cannot pin every CPU or hold N decoded canvases in memory together. One
+# semaphore per event loop (an asyncio primitive belongs to one loop).
+_SLOTS: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Semaphore] = (
+ weakref.WeakKeyDictionary()
+)
+# Single-flight: concurrent cold requests for one variant share one decode.
+_INFLIGHT: dict[tuple[int, str], asyncio.Task[bytes]] = {}
+
+
+def _slots() -> asyncio.Semaphore:
+ loop = asyncio.get_running_loop()
+ if loop not in _SLOTS:
+ _SLOTS[loop] = asyncio.Semaphore(constants.THUMBNAIL_MAX_CONCURRENCY)
+ return _SLOTS[loop]
+
+
+def render(data: bytes, width: int, content_type: str | None = None) -> bytes:
+ """Resize ``data`` to at most ``width`` px wide, keeping aspect; WebP out.
+
+ Never enlarges. The header is parsed lazily by ``Image.open``, so the pixel
+ budget is checked *before* any decode — a few KB of PNG can declare a
+ gigapixel canvas. The sniffed format must be on the allowlist *and* match
+ the declared content type (a ``.png`` that is really a PSD is refused).
+ Animated inputs yield their first frame; the output is re-encoded from
+ pixels only, so EXIF/XMP/ICC and other metadata are not carried over.
+ """
+ try:
+ with Image.open(io.BytesIO(data), formats=_ALLOWED_FORMATS) as img:
+ if content_type is not None and img.format != _FORMATS_BY_TYPE.get(
+ base_type(content_type)
+ ):
+ raise UnreadableImageError("content does not match the declared type")
+ # Header-only so far: refuse before any pixel is decoded. This is
+ # our own budget; the process-wide ``Image.MAX_IMAGE_PIXELS`` is
+ # left alone (it is shared with every other Pillow user and racy
+ # to mutate from worker threads).
+ if img.width * img.height > constants.THUMBNAIL_MAX_PIXELS:
+ raise UnreadableImageError("image exceeds the pixel budget")
+ img.seek(0) # first frame only
+ img.draft("RGB", (width * 2, width * 2)) # cheap JPEG downscale on decode
+ frame = _to_8bit(ImageOps.exif_transpose(img))
+ frame.thumbnail((width, width * 64), Image.Resampling.LANCZOS)
+ mode = "RGBA" if frame.mode in ("RGBA", "LA", "PA", "P") else "RGB"
+ out = io.BytesIO()
+ frame.convert(mode).save(out, format="WEBP", quality=80)
+ return out.getvalue()
+ except UnreadableImageError:
+ raise
+ except (
+ OSError,
+ ValueError,
+ EOFError,
+ SyntaxError, # Pillow raises this for some malformed PNG/ICO chunks
+ struct.error,
+ Image.DecompressionBombError,
+ ) as exc:
+ raise UnreadableImageError(str(exc)) from exc
+
+
+_WIDE_GRAY_MODES = frozenset({"I", "I;16", "I;16B", "I;16L", "I;16N"})
+
+
+def _to_8bit(frame: Image.Image) -> Image.Image:
+ """Map 16/32-bit grayscale (a 16-bit PNG opens as ``I;16``) onto 8-bit ``L``.
+
+ Pillow cannot ``reduce()`` these modes, which ``thumbnail`` uses for large
+ downscales, so a valid 16-bit PNG would render at some widths and fail at
+ others. Scaled by 1/256 rather than clipped, so a 16-bit image keeps its
+ tones instead of turning almost entirely white. Runs after the pixel-budget
+ check, so the wider intermediate is bounded like every other decode.
+ """
+ if frame.mode not in _WIDE_GRAY_MODES:
+ return frame
+ return frame.convert("I").point(lambda v: v * (1 / 256)).convert("L")
+
+
+async def _read_all(backend: StorageBackend, key: str, *, limit: int | None = None) -> bytes:
+ """Whole object, aborting once ``limit`` bytes are exceeded (no unbounded buffer)."""
+ chunks: list[bytes] = []
+ total = 0
+ async for chunk in await backend.get(key):
+ total += len(chunk)
+ if limit is not None and total > limit:
+ raise UnreadableImageError("source image is too large to thumbnail")
+ chunks.append(chunk)
+ return b"".join(chunks)
+
+
+async def get_or_create(
+ backend: StorageBackend, *, key: str, content_type: str, width: int
+) -> bytes:
+ """The variant's bytes, generating it on first use.
+
+ ``width`` must already be snapped (:func:`snap_width`) so the number of
+ cached variants stays bounded.
+ """
+ if not is_thumbnailable(content_type):
+ raise NotAnImageError(content_type)
+ cached_key = variant_key(key, width)
+ try:
+ return await _read_all(backend, cached_key)
+ except StorageNotFoundError:
+ pass
+
+ # The work runs in its own task and callers only *await* it through
+ # ``shield``: a client that disconnects cancels its wait, not the decode,
+ # so the semaphore slot is held until the worker thread has really
+ # finished and abort-and-retry loops cannot multiply concurrent decodes.
+ flight = (id(asyncio.get_running_loop()), cached_key)
+ task = _INFLIGHT.get(flight)
+ if task is None:
+ task = asyncio.ensure_future(_generate(backend, key, cached_key, content_type, width))
+ _INFLIGHT[flight] = task
+ task.add_done_callback(
+ lambda t: (_INFLIGHT.pop(flight, None), t.cancelled() or t.exception())
+ )
+ return await asyncio.shield(task)
+
+
+async def _generate(
+ backend: StorageBackend, key: str, cached_key: str, content_type: str, width: int
+) -> bytes:
+ async with _slots():
+ source = await _read_all(backend, key, limit=constants.THUMBNAIL_MAX_SOURCE_BYTES)
+ data = await asyncio.to_thread(render, source, width, content_type)
+
+ async def _once():
+ yield data
+
+ await backend.put(
+ cached_key, _once(), content_type=constants.THUMBNAIL_CONTENT_TYPE, size=len(data)
+ )
+ return data
+
+
+async def delete_variants(backend: StorageBackend, key: str) -> None:
+ """Drop every cached variant of ``key``; absent ones are fine."""
+ results = await asyncio.gather(
+ *(backend.delete(variant_key(key, width)) for width in constants.THUMBNAIL_WIDTHS),
+ return_exceptions=True,
+ )
+ for result in results:
+ # An absent variant is the common case; anything else is an orphan
+ # nobody would otherwise learn about.
+ if isinstance(result, Exception) and not isinstance(result, StorageNotFoundError):
+ _logger.warning("file_storage.variant_delete_failed key=%s error=%r", key, result)
diff --git a/modules/file_storage/file_storage/visibility.py b/modules/file_storage/file_storage/visibility.py
new file mode 100644
index 00000000..a91ece7f
--- /dev/null
+++ b/modules/file_storage/file_storage/visibility.py
@@ -0,0 +1,93 @@
+"""Public-file and thumbnail operations of :class:`~file_storage.service.FileStorageService`.
+
+Mixed into the service so ``service.py`` stays about upload/download/delete.
+"""
+
+from __future__ import annotations
+
+import uuid
+from typing import TYPE_CHECKING
+
+from simple_module_db import finalize_session
+from simple_module_db.listeners import SESSION_HAS_WRITES_KEY
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from file_storage import thumbnails
+from file_storage.models import StoredFile
+
+if TYPE_CHECKING:
+ from file_storage.contracts.service import StorageBackend
+ from file_storage.settings import FileStorageSettings
+
+
+class FileStoragePublic:
+ """Anonymous-serving lookups, the public flag, and thumbnails."""
+
+ db: AsyncSession
+ backend: StorageBackend
+ settings: FileStorageSettings
+
+ if TYPE_CHECKING:
+
+ async def get(self, file_id: uuid.UUID, *, platform: bool = False) -> StoredFile: ...
+
+ async def get_public(self, file_id: uuid.UUID) -> StoredFile:
+ """A ``public`` file by id, whichever tenant owns it; anything else misses.
+
+ An anonymous request has no tenant bound, so the tenant filter would
+ fail closed (or hide every row). The lookup is therefore explicitly
+ cross-tenant — safe because it is a single-row fetch by an unguessable
+ UUID, restricted to ``public=True``, and the soft-delete filter still
+ applies. A private, deleted or unknown id all raise the same error, so
+ the route cannot be used to probe for existence.
+ """
+ stmt = (
+ select(StoredFile)
+ .where(StoredFile.id == file_id, StoredFile.public.is_(True))
+ .execution_options(all_tenants=True)
+ )
+ row = (await self.db.execute(stmt)).scalar_one_or_none()
+ if row is None:
+ from file_storage.service import StoredFileNotFoundError # circular at import time
+
+ raise StoredFileNotFoundError(str(file_id))
+ return row
+
+ async def set_public(self, file_id: uuid.UUID, public: bool) -> StoredFile:
+ """Publish or unpublish one of the bound tenant's files."""
+ row = await self.get(file_id)
+ row.public = public
+ await self.db.flush()
+ await self.db.refresh(row)
+ return row
+
+ async def thumbnail(self, row: StoredFile, width: int) -> bytes:
+ """Cached variant of ``row`` at an already-snapped ``width``.
+
+ A cold variant means reading the original, waiting for a decode slot
+ and decoding — seconds, not milliseconds. The request's DB connection
+ is handed back first so a burst of cold (possibly anonymous) requests
+ cannot pin the whole pool on work that needs no database.
+ """
+ key, content_type = row.key, row.content_type
+ await self._release_connection(row)
+ return await thumbnails.get_or_create(
+ self.backend, key=key, content_type=content_type, width=width
+ )
+
+ async def _release_connection(self, row: StoredFile) -> None:
+ """End a read-only request transaction early, returning its connection.
+
+ ``row`` is detached first so its loaded attributes stay readable — the
+ rollback would otherwise expire it and the next access would try lazy
+ IO. A session holding writes is left alone: those commit (or roll back)
+ with the request as usual. ``finalize_session`` is re-armable, so any
+ later work in the request still opens a fresh transaction and commits.
+ """
+ db = self.db
+ if db.info.get(SESSION_HAS_WRITES_KEY) or db.new or db.dirty or db.deleted:
+ return
+ if row in db:
+ db.expunge(row)
+ await finalize_session(db)
diff --git a/modules/file_storage/pyproject.toml b/modules/file_storage/pyproject.toml
index fdfa4f40..9ed89c49 100644
--- a/modules/file_storage/pyproject.toml
+++ b/modules/file_storage/pyproject.toml
@@ -26,6 +26,7 @@ dependencies = [
"simple_module_hosting==0.0.35",
"simple_module_settings==0.0.35",
"aiofiles>=23",
+ "pillow>=10",
]
[project.optional-dependencies]
diff --git a/modules/file_storage/tests-js/FileTable.test.tsx b/modules/file_storage/tests-js/FileTable.test.tsx
index be190c40..358c246a 100644
--- a/modules/file_storage/tests-js/FileTable.test.tsx
+++ b/modules/file_storage/tests-js/FileTable.test.tsx
@@ -1,6 +1,6 @@
import '@testing-library/jest-dom/vitest';
import { configureI18n } from '@simple-module-py/i18n';
-import { render, screen } from '@testing-library/react';
+import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, test, vi } from 'vitest';
configureI18n({
@@ -15,6 +15,9 @@ configureI18n({
'file_storage.table.when': 'When',
'file_storage.table.actions': 'Actions',
'file_storage.actions.download': 'Download',
+ 'file_storage.actions.make_public': 'Make public',
+ 'file_storage.actions.make_private': 'Make private',
+ 'file_storage.table.public': 'Public',
},
});
@@ -32,12 +35,16 @@ const FILES: StoredFile[] = [
} as StoredFile,
];
+const onTogglePublic = vi.fn();
+
function renderTable(selectedIds: string[] = []) {
return render(
{
expect(screen.getByRole('checkbox', { name: 'Select every file on this page' })).toBeChecked();
});
});
+
+describe('FileTable visibility', () => {
+ test('a private file offers Make public and calls back with the row', () => {
+ renderTable();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Make public' }));
+
+ expect(onTogglePublic).toHaveBeenCalledWith(FILES[0]);
+ });
+});
diff --git a/modules/file_storage/tests/test_file_storage_delete_variants.py b/modules/file_storage/tests/test_file_storage_delete_variants.py
new file mode 100644
index 00000000..062f4a81
--- /dev/null
+++ b/modules/file_storage/tests/test_file_storage_delete_variants.py
@@ -0,0 +1,58 @@
+"""Thumbnail variants are dropped even when deleting the original fails."""
+
+from __future__ import annotations
+
+from io import BytesIO
+
+import pytest
+from fastapi import UploadFile
+from file_storage import constants, thumbnails
+from file_storage.backends.filesystem import FilesystemBackend
+from file_storage.service import FileStorageService
+from file_storage.settings import FileStorageSettings
+
+
+class BrokenOriginals(FilesystemBackend):
+ """Deleting an original fails; deleting a variant works."""
+
+ async def delete(self, key: str) -> None:
+ if not key.endswith(".webp"):
+ raise OSError("backend is on fire")
+ await super().delete(key)
+
+
+async def _service_with_variant(tmp_path, db_session):
+ settings = FileStorageSettings(
+ backend=constants.BackendId.FILESYSTEM, fs_root_path=str(tmp_path)
+ )
+ backend = BrokenOriginals(root=tmp_path)
+ svc = FileStorageService(db_session, backend, settings)
+ upload = UploadFile(
+ filename="p.png",
+ file=BytesIO(b"x"),
+ headers={"content-type": "image/png"}, # type: ignore[arg-type]
+ )
+ out = await svc.upload(upload)
+ variant = thumbnails.variant_key(out.key, 128)
+
+ async def _once():
+ yield b"v"
+
+ await backend.put(variant, _once(), content_type="image/webp", size=1)
+ assert await backend.exists(variant)
+ return svc, out, variant
+
+
+async def test_delete_drops_variants_when_original_delete_raises(tmp_path, db_session):
+ svc, out, variant = await _service_with_variant(tmp_path, db_session)
+ # The error still reaches the caller, unchanged.
+ with pytest.raises(OSError, match="on fire"):
+ await svc.delete(out.id)
+ assert not await svc.backend.exists(variant)
+
+
+async def test_bulk_delete_drops_variants_when_original_delete_raises(tmp_path, db_session):
+ svc, out, variant = await _service_with_variant(tmp_path, db_session)
+ removed = await svc.delete_many([out.id])
+ assert [r.id for r in removed] == [out.id]
+ assert not await svc.backend.exists(variant)
diff --git a/modules/file_storage/tests/test_file_storage_public.py b/modules/file_storage/tests/test_file_storage_public.py
new file mode 100644
index 00000000..fcaa57c3
--- /dev/null
+++ b/modules/file_storage/tests/test_file_storage_public.py
@@ -0,0 +1,145 @@
+"""Anonymous serving of ``public`` files (#353)."""
+
+from __future__ import annotations
+
+import uuid
+
+import httpx
+from file_storage import constants
+
+API = constants.ROUTE_PREFIX_API
+
+
+async def _upload(client, name="a.txt", data=b"hello", ctype="text/plain", **form):
+ resp = await client.post(
+ f"{API}{constants.PATH_UPLOAD}", files={"file": (name, data, ctype)}, data=form
+ )
+ assert resp.status_code == 201, resp.text
+ return resp.json()
+
+
+async def test_upload_private_by_default_has_no_public_url(authenticated_client):
+ body = await _upload(authenticated_client)
+ assert body["public"] is False
+ assert body["public_url"] is None
+
+
+async def test_upload_public_returns_url_and_anonymous_get_works(
+ authenticated_client, client: httpx.AsyncClient
+):
+ body = await _upload(authenticated_client, public="true")
+ assert body["public"] is True
+ assert body["public_url"] == f"{API}/public/{body['id']}/a.txt"
+
+ for url in (f"{API}/public/{body['id']}", body["public_url"]):
+ resp = await client.get(url)
+ assert resp.status_code == 200, resp.text
+ assert resp.content == b"hello"
+ assert resp.headers["cache-control"].startswith("public, max-age=")
+ assert resp.headers["x-content-type-options"] == "nosniff"
+ assert "sandbox" in resp.headers["content-security-policy"]
+ assert resp.headers["content-disposition"].startswith("inline")
+ etag = resp.headers["etag"]
+ again = await client.get(f"{API}/public/{body['id']}", headers={"If-None-Match": etag})
+ assert again.status_code == 304
+
+
+async def test_private_file_is_404_anonymously(authenticated_client, client):
+ body = await _upload(authenticated_client)
+ assert (await client.get(f"{API}/public/{body['id']}")).status_code == 404
+ assert (await client.get(f"{API}/public/{uuid.uuid4()}")).status_code == 404
+
+
+async def test_patch_toggles_public(authenticated_client, client):
+ body = await _upload(authenticated_client)
+ url = f"{API}/files/{body['id']}"
+ resp = await authenticated_client.patch(url, json={"public": True})
+ assert resp.status_code == 200
+ assert resp.json()["public_url"]
+ assert (await authenticated_client.get(url)).json()["public"] is True
+ assert (await client.get(f"{API}/public/{body['id']}")).status_code == 200
+
+ resp = await authenticated_client.patch(url, json={"public": False})
+ assert resp.json()["public"] is False
+ assert (await client.get(f"{API}/public/{body['id']}")).status_code == 404
+
+
+async def test_patch_requires_auth_and_known_id(authenticated_client, client):
+ assert (
+ await authenticated_client.patch(f"{API}/files/{uuid.uuid4()}", json={"public": True})
+ ).status_code == 404
+ body = await _upload(authenticated_client)
+ anon = await client.patch(f"{API}/files/{body['id']}", json={"public": True})
+ assert anon.status_code in (401, 302, 403)
+
+
+async def test_deleted_public_file_is_404(authenticated_client, client):
+ body = await _upload(authenticated_client, public="true")
+ assert (await client.get(f"{API}/public/{body['id']}")).status_code == 200
+ assert (await authenticated_client.delete(f"{API}/files/{body['id']}")).status_code == 204
+ assert (await client.get(f"{API}/public/{body['id']}")).status_code == 404
+
+
+async def test_active_content_is_attachment_and_sandboxed(authenticated_client, client):
+ body = await _upload(
+ authenticated_client,
+ name="x.svg",
+ data=b"