Attachments
POST /charges/{id}/contest is the only writer of contest attachments; reads are byte-safe downloads. The implementation optimizes for correctness under the single atomic commit, not for scale.
Limits (from app/core/config.py)
MAX_ATTACHMENTS = 5
MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024 # per file
MAX_ATTACHMENT_TOTAL_BYTES = 25 * 1024 * 1024 # all files + metadata
ALLOWED_ATTACHMENT_MEDIA_TYPES = {
"image/jpeg": (".jpg",".jpeg"),
"image/png": (".png",),
"image/webp": (".webp",),
"video/mp4": (".mp4",),
"application/pdf": (".pdf",),
}
MAX_REQUEST_BYTES = MAX_ATTACHMENT_TOTAL_BYTES + 1*1024*1024 # middleware ceiling
Validation is streaming: MIME is checked first, size during streaming, total after. Unknown Content-Type ⇒ 422.
Staging lifecycle (app/services/attachment_service.py)
sequenceDiagram
participant Client
participant API as commands.py
participant Stager as AttachmentStager
participant Store as JsonStore
Client->>API: multipart (metadata + 0..5 files)
API->>Stager: stage(upload) per file
Stager->>Stager: write to runtime/uploads/.staging/<requestId>/att_<hex>
Stager->>Stager: sha256 + size + sanitize displayName
API->>API: contest_digest(metadata, staged digests)
API->>Store: commit(mutator with finalize_attachments)
Store->>Stager: finalize() — move staging -> runtime/uploads/<id>
alt commit ok
Store-->>API: CommitResult
else exception
API->>Stager: rollback(finalized) — delete newly moved files
end
- Stage —
AttachmentStager(settings, requestId)streams eachUploadFileto.staging/<requestId>/, hashes with SHA-256, enforces per-file limit, recordsStagedAttachment{sha256,sizeBytes,mediaType,sanitizedDisplayName, tmpPath}. - Digest —
contest_digest(metadata, attachments)canonicalizes to RFC 8785{"reason":…,"expectedStateEpoch":…,"expectedVersion":…,"attachments": [[sha256,sizeBytes,mediaType,sanitizedName], …]}in user order; multipart headers/boundaries never affect it so retries are truly idempotent. - Finalize — passed as a callable into
ChargeService.apply_charge_command; executed insideJsonStore.commitafter transition check succeeds;os.replacemakes it durable alongside the snapshot; returnslist[FinalizedAttachment]→ContestAttachmentJSON withdownloadUrl=/api/v1/attachments/<id>. - Rollback — if
commitraises (version/epoch/transition/validation), stagedfinalizedfiles are deleted — no orphan survives a failed mutation. Successful commit never deletes on failure.
AttachmentStager is a context manager: leaving _contest_form closes every spooled UploadFile even on malformed input.
Multipart parsing
_contest_form in commands.py enforces Content-Type: multipart/form-data, calls await request.form(max_files=6, max_fields=8), guarantees await form.close() on exit. _split_contest_parts extracts the required metadata part (must be application/json when UploadFile, else plain string), json.loads it, validates via ContestMetadata StrictModel, then collects attachments list. Excess files ⇒ 409 attachment.too_many with limit:5.
Download paths
GET /api/v1/attachments/{attachment_id}
-> 200 bytes + Content-Type + Content-Disposition, or 404 attachment.not_found
GET /api/v1/attachments/{attachment_id}/thumbnail
-> 200 when ContestAttachment.thumbnailUrl exists, else 404
Seed media at app/static/photos/att_*.png is served through the same path prefix; uploads at runtime/uploads/<32hex> are never enumerated — only referenced ids from charges/inspections/reports are resolvable.
GC & staging cleanup
app/main.py:lifespancallscleanup_staging(settings)on startup — wipes.staging/leftover from a crash.JsonStore.open()+ after each commit calls_referenced_attachment_ids(snapshot)and_remove_unreferenced_uploads(snapshot)— deletes uploads no longer referenced by any photo/attachment.- Tests use a temp
runtime_rootso no realruntime/is ever polluted.
Frontend parity
Flutter's attachment_bytes*.dart (_io vs _stub) and lib/core/database/attachment_bytes.dart implement platform-conditional byte reading + SHA-256. Dart validates same per-file/total limits before calling the HTTP adapter, and Outbox persists staged blobs via AttachmentBlobs Drift table until live sync drains.
Security notes
sanitizedDisplayNamestrips path separators and controls; original filename never reaches the filesystem.- SHA-256 is content-addressable;
id = att_<first 32 hex of sha256-derived id>is opaque — clients never parse it. - Not production-hardened: no virus scanning, no image re-encoding, no auth; bind to
127.0.0.1and keep it there.