Skip to main content

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-Type422.

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
  • StageAttachmentStager(settings, requestId) streams each UploadFile to .staging/<requestId>/, hashes with SHA-256, enforces per-file limit, records StagedAttachment{sha256,sizeBytes,mediaType,sanitizedDisplayName, tmpPath}.
  • Digestcontest_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 inside JsonStore.commit after transition check succeeds; os.replace makes it durable alongside the snapshot; returns list[FinalizedAttachment]ContestAttachment JSON with downloadUrl=/api/v1/attachments/<id>.
  • Rollback — if commit raises (version/epoch/transition/validation), staged finalized files 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:lifespan calls cleanup_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_root so no real runtime/ 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

  • sanitizedDisplayName strips 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.1 and keep it there.