terp-cap-files 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,47 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+ .venv/
9
+ .venv-*/
10
+ venv/
11
+ .pytest_cache/
12
+ .mypy_cache/
13
+ .ruff_cache/
14
+ .coverage
15
+ htmlcov/
16
+
17
+ # uv
18
+ uv.lock
19
+
20
+ # Node
21
+ node_modules/
22
+ .pnpm-store/
23
+ *.tsbuildinfo
24
+
25
+ # Playwright (conformance e2e) artifacts
26
+ test-results/
27
+ playwright-report/
28
+ blob-report/
29
+ playwright/.cache/
30
+ .last-run.json
31
+
32
+ # Local frontend template render checks
33
+ apps/example/_frontend_tpl_check/
34
+
35
+ # Editor / OS
36
+ .DS_Store
37
+ .idea/
38
+ *.local
39
+
40
+ # Local environment overrides — never commit (a real .env may hold SECRET_KEY).
41
+ # The tracked template is `.env.example`.
42
+ .env
43
+ .env.*
44
+ !.env.example
45
+ !.env.example.jinja
46
+ # Rendered app-declared variables (environment.schema.json) — may hold secrets.
47
+ .app.env
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: terp-cap-files
3
+ Version: 0.1.0
4
+ Summary: Terp files capability — owner-scoped file objects with metadata in the platform database and bytes behind a pluggable storage backend.
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.13
7
+ Requires-Dist: python-multipart>=0.0.30
8
+ Requires-Dist: terp-core==0.1.0
@@ -0,0 +1,3 @@
1
+ {
2
+ "arch-allow-routes-declare-response-model": 1
3
+ }
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "terp-cap-files"
7
+ version = "0.1.0"
8
+ description = "Terp files capability — owner-scoped file objects with metadata in the platform database and bytes behind a pluggable storage backend."
9
+ requires-python = ">=3.13"
10
+ license = "Apache-2.0"
11
+ dependencies = [
12
+ "terp-core==0.1.0",
13
+ # FastAPI parses the multipart upload body through this (UploadFile); the byte
14
+ # stream never touches app code — it flows metadata->DB, bytes->StorageBackend.
15
+ "python-multipart>=0.0.30",
16
+ ]
17
+
18
+ # Self-registering: the kernel discovers this ModuleSpec via the entry point, mounting the
19
+ # admin files router at `/api/v1/files` without any composition-root edit.
20
+ [project.entry-points."terp.capabilities"]
21
+ files = "terp.capabilities.files:module"
22
+
23
+ # Owns the `file_object` metadata table, so it ships an independent, linear Alembic
24
+ # history (its own `alembic_version_files` table). `terp migrate` discovers this via
25
+ # the `terp.migrations` group (ADR 0027).
26
+ [project.entry-points."terp.migrations"]
27
+ files = "terp.capabilities.files"
28
+
29
+ # PEP 420 namespace package: this distribution owns only `terp.capabilities.files`.
30
+ [tool.hatch.build.targets.wheel]
31
+ sources = ["src"]
32
+ only-include = ["src/terp/capabilities/files"]
@@ -0,0 +1,101 @@
1
+ """terp.capabilities.files — owner-scoped file objects on a pluggable storage backend.
2
+
3
+ The last of the planned foundation capabilities (design §3.1, §6): an app stores and
4
+ retrieves **file objects** through a maintained, secure-by-default surface, with all
5
+ *metadata* owned in the platform database and the *bytes* behind a tiny storage port.
6
+
7
+ * :class:`File` (``BaseTable`` + ``OwnedMixin``) is the metadata row — name, type, size,
8
+ digest, the server-generated ``storage_key`` addressing the bytes, and the
9
+ ``storage_profile`` naming the backend that holds them. Both are server-side-only
10
+ material: no Read DTO ever serializes them.
11
+ * :class:`StorageBackend` is the pluggable byte-store seam (``put`` / ``open`` /
12
+ ``delete``, streamed through readable binary objects — ADR 0066) behind a
13
+ **named-profile registry** (ADR 0057): the ``"default"`` profile
14
+ is the shipped :class:`LocalFilesystemStorage` reference adapter, and a deployment
15
+ installs any provider — another root, S3, Azure Blob, a NAS mount — under one or many
16
+ profiles (one per container / module use) with one :func:`register_storage_backend`
17
+ line each; resolution (:func:`resolve_storage_backend`) is fail-closed
18
+ (:class:`UnknownStorageProfileError`). A service or call selects a store by profile
19
+ name; a client never does.
20
+ * :func:`FileRef` declares a model column that references a stored file, and
21
+ :meth:`FileService.load_for` is the serve-through delegation read: a module serves a
22
+ file through its **own**, already-authorized row, fail-closed on any undeclared
23
+ reference (:class:`UndeclaredFileReferenceError`; build-time twin: the
24
+ ``no_raw_file_references`` rule).
25
+ * The discovered, admin-only router at ``/api/v1/files`` uploads, downloads, lists
26
+ (``Page[T]``), renames, and deletes; ``OwnedMixin`` makes edit / delete owner-gated
27
+ centrally in ``BaseService`` with zero module code.
28
+
29
+ It depends only on ``terp-core`` (plus the multipart parser FastAPI needs for uploads) —
30
+ never a sibling capability or a storage engine SDK.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from terp.capabilities.files.models import File
36
+ from terp.capabilities.files.references import (
37
+ FileRef,
38
+ UndeclaredFileReferenceError,
39
+ is_file_reference,
40
+ )
41
+ from terp.capabilities.files.router import (
42
+ MAX_UPLOAD_BYTES,
43
+ active_upload_limit,
44
+ configure_upload_limit,
45
+ module,
46
+ reset_upload_limit,
47
+ router,
48
+ )
49
+ from terp.capabilities.files.schemas import FileCreate, FileRead, FileUpdate
50
+ from terp.capabilities.files.service import (
51
+ ContentTypeMismatchError,
52
+ FileService,
53
+ UnsupportedContentTypeError,
54
+ active_allowed_content_types,
55
+ configure_allowed_content_types,
56
+ reset_allowed_content_types,
57
+ )
58
+ from terp.capabilities.files.storage import (
59
+ DEFAULT_STORAGE_PROFILE,
60
+ FileStorageError,
61
+ LocalFilesystemStorage,
62
+ StorageBackend,
63
+ UnknownStorageProfileError,
64
+ active_storage_backend,
65
+ register_storage_backend,
66
+ reset_storage_backend,
67
+ resolve_storage_backend,
68
+ set_storage_backend,
69
+ )
70
+
71
+ __all__ = [
72
+ "DEFAULT_STORAGE_PROFILE",
73
+ "MAX_UPLOAD_BYTES",
74
+ "ContentTypeMismatchError",
75
+ "File",
76
+ "FileCreate",
77
+ "FileRead",
78
+ "FileRef",
79
+ "FileService",
80
+ "FileStorageError",
81
+ "FileUpdate",
82
+ "LocalFilesystemStorage",
83
+ "StorageBackend",
84
+ "UndeclaredFileReferenceError",
85
+ "UnknownStorageProfileError",
86
+ "UnsupportedContentTypeError",
87
+ "active_allowed_content_types",
88
+ "active_storage_backend",
89
+ "active_upload_limit",
90
+ "configure_allowed_content_types",
91
+ "configure_upload_limit",
92
+ "is_file_reference",
93
+ "module",
94
+ "register_storage_backend",
95
+ "reset_allowed_content_types",
96
+ "reset_storage_backend",
97
+ "reset_upload_limit",
98
+ "resolve_storage_backend",
99
+ "router",
100
+ "set_storage_backend",
101
+ ]
@@ -0,0 +1,54 @@
1
+ """create file table
2
+
3
+ Revision ID: ceffeb4b0fc2
4
+ Revises:
5
+ Create Date: 2026-07-02 11:20:59.258321
6
+
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Sequence
11
+
12
+ from alembic import op
13
+ import sqlalchemy as sa
14
+ import sqlmodel
15
+
16
+
17
+ # revision identifiers, used by Alembic.
18
+ revision: str = 'ceffeb4b0fc2'
19
+ down_revision: str | None = None
20
+ branch_labels: str | Sequence[str] | None = None
21
+ depends_on: str | Sequence[str] | None = None
22
+
23
+
24
+ def upgrade() -> None:
25
+ # ### commands auto generated by Alembic - please adjust! ###
26
+ op.create_table('file_object',
27
+ sa.Column('owner_id', sa.Uuid(), nullable=True),
28
+ sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
29
+ sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
30
+ sa.Column('id', sa.Uuid(), nullable=False),
31
+ sa.Column('version', sa.Integer(), nullable=False),
32
+ sa.Column('filename', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
33
+ sa.Column('content_type', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
34
+ sa.Column('size', sa.Integer(), nullable=False),
35
+ sa.Column('sha256', sqlmodel.sql.sqltypes.AutoString(length=64), nullable=False),
36
+ sa.Column('storage_key', sqlmodel.sql.sqltypes.AutoString(length=512), nullable=False),
37
+ sa.PrimaryKeyConstraint('id', name=op.f('pk_file_object')),
38
+ sa.UniqueConstraint('storage_key', name=op.f('uq_file_object_storage_key'))
39
+ )
40
+ with op.batch_alter_table('file_object', schema=None) as batch_op:
41
+ batch_op.create_index(batch_op.f('ix_file_object_filename'), ['filename'], unique=False)
42
+ batch_op.create_index(batch_op.f('ix_file_object_owner_id'), ['owner_id'], unique=False)
43
+
44
+ # ### end Alembic commands ###
45
+
46
+
47
+ def downgrade() -> None:
48
+ # ### commands auto generated by Alembic - please adjust! ###
49
+ with op.batch_alter_table('file_object', schema=None) as batch_op:
50
+ batch_op.drop_index(batch_op.f('ix_file_object_owner_id'))
51
+ batch_op.drop_index(batch_op.f('ix_file_object_filename'))
52
+
53
+ op.drop_table('file_object')
54
+ # ### end Alembic commands ###
@@ -0,0 +1,40 @@
1
+ """add file storage profile
2
+
3
+ Revision ID: e3a9c47d51b8
4
+ Revises: ceffeb4b0fc2
5
+ Create Date: 2026-07-02 12:05:00.000000
6
+
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Sequence
11
+
12
+ from alembic import op
13
+ import sqlalchemy as sa
14
+ import sqlmodel
15
+
16
+
17
+ # revision identifiers, used by Alembic.
18
+ revision: str = 'e3a9c47d51b8'
19
+ down_revision: str | None = 'ceffeb4b0fc2'
20
+ branch_labels: str | Sequence[str] | None = None
21
+ depends_on: str | Sequence[str] | None = None
22
+
23
+
24
+ def upgrade() -> None:
25
+ # Every pre-existing row was stored through the original single (default) backend,
26
+ # so the backfill value is exactly the store that holds its bytes.
27
+ with op.batch_alter_table('file_object', schema=None) as batch_op:
28
+ batch_op.add_column(
29
+ sa.Column(
30
+ 'storage_profile',
31
+ sqlmodel.sql.sqltypes.AutoString(length=64),
32
+ nullable=False,
33
+ server_default='default',
34
+ )
35
+ )
36
+
37
+
38
+ def downgrade() -> None:
39
+ with op.batch_alter_table('file_object', schema=None) as batch_op:
40
+ batch_op.drop_column('storage_profile')
@@ -0,0 +1,66 @@
1
+ """The ``file_object`` metadata table: an owner-scoped file record.
2
+
3
+ ``File`` is a normal owner-scoped resource (``BaseTable`` + ``OwnedMixin``): its creator
4
+ owns it and only the owner may edit / delete it — the per-row write gate (ADR 0029),
5
+ enforced centrally by ``BaseService`` with zero module code (never a hand-rolled
6
+ ``owner_id`` check — ``terp guide ownership``). Only the *metadata* lives here; the bytes
7
+ live behind the pluggable :class:`~terp.capabilities.files.StorageBackend`, addressed by
8
+ the server-generated ``storage_key``.
9
+
10
+ Mutability is decided per field:
11
+
12
+ * ``filename`` — **mutable** (a rename is a metadata edit; the bytes are untouched), via
13
+ the OCC-bearing ``update`` path.
14
+ * ``content_type`` / ``size`` / ``sha256`` / ``storage_key`` / ``storage_profile`` —
15
+ **append-only**: set once from the uploaded bytes at create time and never patched
16
+ (``FileUpdate`` carries no field for them; replacing content is a new upload).
17
+ ``storage_key`` and ``storage_profile`` in particular are server-side-only material
18
+ (the raw storage address and the named backend holding it): both are generated /
19
+ selected by the service, never accepted from a client, and never serialized out of the
20
+ API boundary (no ``*Read`` DTO carries them — enforced by a runtime test alongside the
21
+ ``schemas_exclude_sensitive_fields`` posture). ``storage_profile`` being append-only is
22
+ what keeps a row pointing at the store that actually holds its bytes: re-homing a blob
23
+ is an explicit migration, never a patch.
24
+
25
+ Every caller-influenceable ``str`` column caps its length so a hostile or oversized value
26
+ can never break the INSERT.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from typing import Final
32
+
33
+ from sqlmodel import Field
34
+
35
+ from terp.core import BaseTable, OwnedMixin
36
+
37
+ from terp.capabilities.files.storage import STORAGE_PROFILE_MAX
38
+
39
+ # Hard caps so a hostile / oversized value can never break the INSERT.
40
+ FILENAME_MAX: Final[int] = 255
41
+ CONTENT_TYPE_MAX: Final[int] = 255
42
+ _SHA256_MAX: Final[int] = 64
43
+ _STORAGE_KEY_MAX: Final[int] = 512
44
+
45
+
46
+ class File(BaseTable, OwnedMixin, table=True):
47
+ """One stored file's metadata: name, type, size, digest, and its storage address.
48
+
49
+ ``id`` / ``created_at`` / ``updated_at`` / ``version`` are inherited from ``BaseTable``;
50
+ ``owner_id`` from ``OwnedMixin`` (stamped from the request actor on create, then enforced
51
+ as the per-row write gate). ``storage_key`` addresses the bytes inside the storage
52
+ backend registered under ``storage_profile`` (ADR 0057); neither is **ever**
53
+ serialized in a Read DTO.
54
+ """
55
+
56
+ __tablename__ = "file_object"
57
+
58
+ filename: str = Field(max_length=FILENAME_MAX, index=True)
59
+ content_type: str = Field(max_length=CONTENT_TYPE_MAX)
60
+ size: int = Field(ge=0)
61
+ sha256: str = Field(max_length=_SHA256_MAX)
62
+ storage_key: str = Field(max_length=_STORAGE_KEY_MAX, unique=True)
63
+ storage_profile: str = Field(max_length=STORAGE_PROFILE_MAX)
64
+
65
+
66
+ __all__ = ["CONTENT_TYPE_MAX", "FILENAME_MAX", "File"]
@@ -0,0 +1,82 @@
1
+ """Declared file references: ``FileRef`` + the fail-closed delegation check (ADR 0057).
2
+
3
+ A module that stores a pointer to a :class:`~terp.capabilities.files.File` (an invoice's
4
+ attachment, a report's export) must **declare** the reference — a bare
5
+ ``file_id: uuid.UUID`` column carries no authorization semantics, and implicit "can see
6
+ the record ⇒ can see the file" propagation is exactly how object-level (BOLA) leaks
7
+ happen. The posture is **default-deny + explicit delegation**:
8
+
9
+ * :func:`FileRef` declares the column: it returns a normal indexed ``uuid`` field whose
10
+ ``FieldInfo`` carries a machine-readable marker, making the reference greppable and
11
+ verifiable at both build time (the ``no_raw_file_references`` rule flags a bare
12
+ ``*file_id`` table column) and runtime (:func:`is_file_reference`).
13
+ * Delegated access is **serve-through**: the referencing module loads its *own* row
14
+ through its *own* service (so that row's policy + row scope + per-row write gate
15
+ already decided visibility), then serves the bytes with
16
+ :meth:`~terp.capabilities.files.FileService.load_for` — which fail-closes on any
17
+ column not declared with :func:`FileRef` (:class:`UndeclaredFileReferenceError`).
18
+ Access to the file thus provably follows the referencing record's access, the raw
19
+ ``/api/v1/files`` surface stays ADMIN-only, and delegation widens to exactly one
20
+ already-authorized row — never by an implicit, registry-wide grant.
21
+
22
+ The kernel's predicate seams (scope / object-authz) compose with **AND** semantics — they
23
+ can only ever *narrow* access. That is deliberate, and it is why delegation is a
24
+ serve-through helper rather than a registered predicate: a predicate cannot (and must
25
+ never) silently *widen* who may reach a file.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from typing import Any
31
+
32
+ from sqlmodel import Field, SQLModel
33
+
34
+ from terp.core import AppError
35
+
36
+ # The machine-readable marker a FileRef-declared column carries in its FieldInfo.
37
+ _FILE_REF_MARKER = "terp_file_ref"
38
+
39
+
40
+ class UndeclaredFileReferenceError(AppError):
41
+ """500 — a serve-through read named a column not declared with ``FileRef``.
42
+
43
+ Fail-closed: delegated file access flows only through a **declared** reference, so a
44
+ typo'd column name or an undeclared bare ``uuid`` column refuses instead of serving
45
+ bytes. This is a module wiring error (declare the column with :func:`FileRef`),
46
+ never a caller mistake.
47
+ """
48
+
49
+ status_code = 500
50
+ code = "file_reference_undeclared"
51
+ default_message = "The record's file reference is not declared with FileRef."
52
+
53
+
54
+ def FileRef(*, index: bool = True) -> Any: # noqa: N802 - a Field-style factory, named like the trait it declares
55
+ """Declare a model column as a reference to a stored ``File``.
56
+
57
+ Use it instead of a bare ``uuid`` field::
58
+
59
+ class Invoice(BaseTable, table=True):
60
+ attachment_file_id: uuid.UUID | None = FileRef()
61
+
62
+ The column is a normal nullable, indexed ``uuid`` — no database FK is imposed (the
63
+ ``File`` row may live in another package's migration history) — but its ``FieldInfo``
64
+ carries the declaration marker :func:`is_file_reference` and
65
+ :meth:`~terp.capabilities.files.FileService.load_for` verify, and the
66
+ ``no_raw_file_references`` rule enforces at build time.
67
+ """
68
+ field = Field(default=None, index=index)
69
+ field.json_schema_extra = {_FILE_REF_MARKER: True}
70
+ return field
71
+
72
+
73
+ def is_file_reference(model: type[SQLModel], column: str) -> bool:
74
+ """Whether *model*.*column* is declared as a file reference (via :func:`FileRef`)."""
75
+ field = model.model_fields.get(column)
76
+ if field is None:
77
+ return False
78
+ extra = field.json_schema_extra
79
+ return isinstance(extra, dict) and bool(extra.get(_FILE_REF_MARKER))
80
+
81
+
82
+ __all__ = ["FileRef", "UndeclaredFileReferenceError", "is_file_reference"]
@@ -0,0 +1,221 @@
1
+ """Owner-scoped admin router for files: upload, download, list, rename, delete.
2
+
3
+ Self-registering (``module``): the kernel's entry-point discovery mounts it at
4
+ ``/api/v1/files`` with no composition-root edit. File storage is a privileged,
5
+ disk/backend-consuming capability, so the policy requires ``ADMIN``; ``File`` also
6
+ composes ``OwnedMixin``, so the **per-row** write gate (an admin may rename / delete only
7
+ their *own* file) is enforced centrally by ``BaseService`` — the routes carry no ownership
8
+ logic. The raw ``storage_key`` never leaves the boundary (``FileRead`` omits it); the
9
+ download route **streams** the bytes back through a ``StreamingResponse`` (read from the
10
+ backend in chunks, so a large file never lands in memory whole) with a sanitized
11
+ ``Content-Disposition`` (RFC 5987 encoding, so a hostile stored filename can never inject
12
+ response headers). The upload is **streamed** straight from the parsed part's spooled file
13
+ into the storage backend, hashing and size-capping the bytes in flight (ADR 0066): the
14
+ service refuses the instant the running total crosses ``MAX_UPLOAD_BYTES`` and compensates
15
+ the partial blob, so the write path never buffers the whole upload — and the kernel's
16
+ ``RequestSizeLimitMiddleware`` still bounds the raw request body up front.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import urllib.parse
22
+ import uuid
23
+ from collections.abc import Iterator
24
+ from typing import BinaryIO, Final
25
+
26
+ from fastapi import APIRouter, Request
27
+ from fastapi.responses import StreamingResponse
28
+ from starlette.datastructures import UploadFile
29
+
30
+ from terp.core import (
31
+ ADMIN,
32
+ ModuleSpec,
33
+ Page,
34
+ PaginationDep,
35
+ Policy,
36
+ SessionDep,
37
+ ValidationFailedError,
38
+ )
39
+
40
+ from terp.capabilities.files.models import CONTENT_TYPE_MAX, FILENAME_MAX
41
+ from terp.capabilities.files.schemas import FileRead, FileUpdate
42
+ from terp.capabilities.files.service import FileService
43
+
44
+ # The default cap on one upload's stored byte size (a DoS guard enforced mid-stream by the
45
+ # service, which compensates the partial blob on overrun — the request path never buffers the
46
+ # whole upload). Deliberately conservative; a deployment retunes it with one composition-root
47
+ # line (configure_upload_limit) paired with create_app(request_size_overrides={"files": ...})
48
+ # when the new ceiling exceeds the module's declared request allowance (ADR 0067).
49
+ MAX_UPLOAD_BYTES: Final[int] = 25 * 1024 * 1024
50
+
51
+ # Headroom the module's declared request-body allowance adds over the stored-bytes cap:
52
+ # a multipart body carries boundary lines + part headers around the file bytes, so the
53
+ # request cap must sit slightly above the stored cap or a maximum-size file would be
54
+ # refused at the socket before the streamed cap ever measured it.
55
+ _MULTIPART_HEADROOM_BYTES: Final[int] = 64 * 1024
56
+
57
+ # The active stored-bytes cap (module-level, composition-root-configured — the same seam
58
+ # shape as the storage registry). Never client data.
59
+ _upload_limit: int = MAX_UPLOAD_BYTES
60
+
61
+
62
+ def configure_upload_limit(max_bytes: int) -> None:
63
+ """Set the stored-bytes cap for uploads (a composition-root line, ADR 0067).
64
+
65
+ Validated eagerly (positive) so a mis-wired root fails at boot, not at the first
66
+ upload. A ceiling above the module's declared request allowance
67
+ (``MAX_UPLOAD_BYTES + 64 KiB``) must be paired with
68
+ ``create_app(request_size_overrides={"files": <ceiling + headroom>})`` — otherwise
69
+ the kernel's request-size middleware still refuses the larger body first
70
+ (fail-closed, never silently wider).
71
+ """
72
+ global _upload_limit
73
+ if max_bytes <= 0:
74
+ raise ValueError("the upload limit must be a positive byte count")
75
+ _upload_limit = max_bytes
76
+
77
+
78
+ def active_upload_limit() -> int:
79
+ """The stored-bytes cap uploads are currently held to."""
80
+ return _upload_limit
81
+
82
+
83
+ def reset_upload_limit() -> None:
84
+ """Restore the default cap (the test-isolation reset, like the storage registry's)."""
85
+ global _upload_limit
86
+ _upload_limit = MAX_UPLOAD_BYTES
87
+
88
+
89
+ # The chunk size the download route pulls from the backend stream (bounded memory per read).
90
+ _DOWNLOAD_CHUNK_BYTES: Final[int] = 64 * 1024
91
+
92
+ _DEFAULT_FILENAME: Final[str] = "upload"
93
+ _DEFAULT_CONTENT_TYPE: Final[str] = "application/octet-stream"
94
+
95
+
96
+ def _iter_blob(stream: BinaryIO) -> Iterator[bytes]:
97
+ """Yield a backend blob stream in fixed-size chunks, closing it when exhausted.
98
+
99
+ Feeds ``StreamingResponse`` so a download never materializes the whole file in memory;
100
+ the ``finally`` guarantees the backend handle is released even if the client disconnects
101
+ mid-stream.
102
+ """
103
+ try:
104
+ while chunk := stream.read(_DOWNLOAD_CHUNK_BYTES):
105
+ yield chunk
106
+ finally:
107
+ stream.close()
108
+
109
+
110
+ router = APIRouter(tags=["files"])
111
+ _service = FileService()
112
+
113
+
114
+ def _content_disposition(filename: str) -> str:
115
+ """A header-safe attachment disposition for *filename* (RFC 5987 / RFC 6266).
116
+
117
+ The stored filename is caller-supplied, so it is never emitted raw: the primary
118
+ ``filename*`` value is fully percent-encoded (no CR/LF/quote can survive), and the
119
+ ASCII ``filename`` fallback keeps only a conservative character set.
120
+ """
121
+ fallback = "".join(
122
+ ch if ch.isascii() and (ch.isalnum() or ch in "._-") else "_" for ch in filename
123
+ )
124
+ encoded = urllib.parse.quote(filename, safe="")
125
+ return f'attachment; filename="{fallback or _DEFAULT_FILENAME}"; filename*=UTF-8\'\'{encoded}'
126
+
127
+
128
+ @router.post("/", response_model=FileRead, status_code=201)
129
+ async def upload_file(request: Request, session: SessionDep) -> FileRead:
130
+ form = await request.form(max_files=1, max_fields=0, max_part_size=1024)
131
+ uploaded = form.get("file")
132
+ if not isinstance(uploaded, UploadFile):
133
+ raise ValidationFailedError("Upload a file part named 'file'.")
134
+ file = uploaded
135
+ filename = file.filename or _DEFAULT_FILENAME
136
+ if len(filename) > FILENAME_MAX:
137
+ raise ValidationFailedError(
138
+ f"The filename exceeds the {FILENAME_MAX}-character limit."
139
+ )
140
+ content_type = file.content_type or _DEFAULT_CONTENT_TYPE
141
+ if len(content_type) > CONTENT_TYPE_MAX:
142
+ raise ValidationFailedError(
143
+ f"The content type exceeds the {CONTENT_TYPE_MAX}-character limit."
144
+ )
145
+ # Stream the parsed part's spooled file straight into the backend: the service hashes
146
+ # and size-caps the bytes in flight (refusing + compensating past the active limit), so
147
+ # the write path never holds the whole upload in memory. A sync handler runs in the
148
+ # threadpool, so the blocking copy never stalls the event loop.
149
+ file.file.seek(0)
150
+ return FileRead.model_validate(
151
+ _service.store(
152
+ session,
153
+ filename=filename,
154
+ content_type=content_type,
155
+ source=file.file,
156
+ max_bytes=active_upload_limit(),
157
+ )
158
+ )
159
+
160
+
161
+ @router.get("/", response_model=Page[FileRead])
162
+ def list_files(session: SessionDep, pagination: PaginationDep) -> Page[FileRead]:
163
+ rows, total = _service.list(session, skip=pagination.skip, limit=pagination.limit)
164
+ return Page[FileRead].of(
165
+ [FileRead.model_validate(row) for row in rows], total, pagination
166
+ )
167
+
168
+
169
+ @router.get("/{file_id}", response_model=FileRead)
170
+ def get_file(file_id: uuid.UUID, session: SessionDep) -> FileRead:
171
+ return FileRead.model_validate(_service.get(session, file_id))
172
+
173
+
174
+ @router.get("/{file_id}/content") # arch-allow-routes-declare-response-model: binary download — the bytes stream out through a StreamingResponse (stored media type + sanitized attachment disposition), never a serialized ORM object
175
+ def download_file(file_id: uuid.UUID, session: SessionDep) -> StreamingResponse:
176
+ row, stream = _service.open_stream(session, file_id)
177
+ return StreamingResponse(
178
+ _iter_blob(stream),
179
+ media_type=row.content_type,
180
+ headers={
181
+ "Content-Disposition": _content_disposition(row.filename),
182
+ # The stored size is append-only and derived from the streamed bytes at create
183
+ # time, so it is authoritative: declaring it keeps download progress / resume
184
+ # working, and a blob truncated out-of-band surfaces as a protocol error
185
+ # instead of a silently short body.
186
+ "Content-Length": str(row.size),
187
+ },
188
+ )
189
+
190
+
191
+ @router.patch("/{file_id}", response_model=FileRead)
192
+ def update_file(
193
+ file_id: uuid.UUID, payload: FileUpdate, session: SessionDep
194
+ ) -> FileRead:
195
+ return FileRead.model_validate(_service.update(session, file_id, payload))
196
+
197
+
198
+ @router.delete("/{file_id}", status_code=204)
199
+ def delete_file(file_id: uuid.UUID, session: SessionDep) -> None:
200
+ _service.remove(session, file_id)
201
+
202
+
203
+ module = ModuleSpec(
204
+ name="files",
205
+ router=router,
206
+ policy=Policy(read=ADMIN, write=ADMIN),
207
+ # The declared request-body allowance for /api/v1/files (ADR 0067): the default
208
+ # stored-bytes cap plus multipart framing headroom, so a maximum-size upload fits
209
+ # through the kernel's request-size middleware without widening the global cap.
210
+ max_request_bytes=MAX_UPLOAD_BYTES + _MULTIPART_HEADROOM_BYTES,
211
+ )
212
+
213
+
214
+ __all__ = [
215
+ "MAX_UPLOAD_BYTES",
216
+ "active_upload_limit",
217
+ "configure_upload_limit",
218
+ "module",
219
+ "reset_upload_limit",
220
+ "router",
221
+ ]