Community input file uploads

A community input form is submitted anonymously, so its attachment endpoints are mounted separately from the authenticated ones. There are two ways to attach a file, and which one a client uses depends on where the file is declared.

Attachment kind

Endpoint

The event’s own files list

POST /api/v2.0/community/{community_input_value}/activity/events/{id}/files/

An Attachment schema field on the event form

The chunked-upload endpoints below

Both paths enforce the same two per-tenant limits, configured in the tenant’s environment settings:

  • communityInputAllowedMimeTypes — MIME allowlist; patterns may end in /*. Falls back to DEFAULT_COMMUNITY_INPUT_ALLOWED_MIME_TYPES. Violations are 415.

  • communityInputMaxUploadBytes — maximum bytes per file. Falls back to 20 MiB. Violations are 413.

Chunked uploads

POST   /api/v2.0/community/{community_input_value}/usercontent/chunked-uploads/
GET    /api/v2.0/community/{community_input_value}/usercontent/chunked-uploads/{upload_id}/
PUT    /api/v2.0/community/{community_input_value}/usercontent/chunked-uploads/{upload_id}/chunks/{chunk_index}/
POST   /api/v2.0/community/{community_input_value}/usercontent/chunked-uploads/{upload_id}/complete/

These mirror the authenticated /api/v1.0/usercontent/chunked-uploads/… flow — init, then one PUT per chunk in order, then complete — and differ only in who the session belongs to and how it is guarded. See the OpenAPI schema for the request and response bodies of each call.

Every response here is wrapped in the standard DAS envelope — {"data": …, "status": {"code": …, "message": …}} — because these views use the default ExtendedJSONRenderer. Read payloads from data, not from the response root. One consequence worth knowing: the renderer rewrites a 204 to a 200 with data: null, so the chunk PUT answers 200 on success and never 204. The generated schema states both.

The community input is re-checked on every call

Every request in the flow resolves community_input_value again and answers 404 when no active community input has that value. An upload therefore stops the moment its community input is deleted or deactivated, even mid-flight.

Sessions belong to the community input, not to a user

The authenticated endpoints record the session’s owner as the requesting user’s primary key and reject any later call from a different user with 403. There is no user here — request.user.pk is None for an anonymous request, so reusing that rule would file every concurrent anonymous session under the same owner and let any submitter drive another’s upload.

The community mount instead records the owner as community:{community_input.pk}. A session opened under one community input is not addressable through another, nor through the authenticated endpoints, and vice versa: those calls get 403.

This is a per-form boundary, not a per-reporter one. Everyone filling the same form resolves to the same owner string, so a reporter who holds another’s upload id could drive that upload. Upload ids are uuid4 and are never published, and this mount authorises on the community input alone by design — there is no event yet to scope against — so the residual exposure is one form’s reporters to each other. Do not build anything on this ownership check that needs per-session isolation.

The chunk size is floored

Init raises a requested chunk_size to COMMUNITY_INPUT_MIN_CHUNK_BYTES (256 KiB) and, as on the authenticated mount, clamps it down to the server maximum. Split the file by the chunk_size in the init response, not by the one you asked for. The floor exists because the shared serializer accepts any chunk_size >= 1, which here would let a caller shrink chunk 0 below the 261-byte window filetype.guess reads and slip past the content check on the only chunk that can carry magic bytes — and would turn one capped upload into millions of whole-session cache rewrites.

Init validates from metadata; chunk 0 validates the bytes

Init receives only filename and size, so the MIME type is derived from the filename rather than sniffed from content, and the size cap is applied to the declared size. Filenames whose extension is outside the server-wide USERCONTENT_SETTINGS["allowed_extensions"] are rejected by the shared init serializer with 400 before either community limit is consulted.

The bytes are checked when they first arrive. Chunk 0 is sniffed with filetype.guess and compared against the type the filename declares — the same check CommunityInputEventFilesView runs on a whole-file upload — and a mismatch is 415. Only chunk 0 can be checked: magic numbers live in the first 261 bytes and no later chunk carries any, so chunks 1..n are written unexamined. A rejected session is not aborted; it is left to expire at its TTL, so a caller who guesses another session’s id cannot destroy it with one bad chunk.

Attribution

The FileContent / ImageFileContent that complete creates has no created_by, matching events and files submitted through a community input.

Attaching the finalized file to the event

Reference the id complete returned from the event form’s Attachment schema field, as an object inside event_details when the event is submitted:

{
  "event_type": "injured_animal",
  "event_details": {
    "photos": [{ "uploadId": "1b1e...b7" }]
  }
}

Do not post the id as usercontent_id to .../activity/events/{id}/files/. That body resolves the file with created_by=<requesting user>, which for an anonymous reporter is AnonymousUser, so it never matches and the request fails with 400. The usercontent_id route exists for the authenticated endpoints only.

Throttling

Each call is throttled on a scope backed by a per-tenant environment setting:

Call

Scope

Setting

Default

init, complete

community_input_file

communityInputFileThrottleRate

60/hour

chunk

community_input_chunk

communityInputChunkThrottleRate

1200/hour

status

community_input_read

communityInputReadThrottleRate

1200/hour

Chunks get their own scope because they are the only per-byte call: the number of chunk requests scales with file size, while init and complete are one each per upload. Sizing the two together makes the byte count decide how many files a reporter may send, which is not what the file limit is meant to express.

The defaults are sized from the worst case. The web client asks for 1 MiB chunks and the server takes min(client_chunk_size, max_chunk), so a file at the default communityInputMaxUploadBytes of 20 MiB costs:

1 init + 20 chunks + 1 complete = 22 requests
  → 2 against community_input_file
  → 20 against community_input_chunk

At the defaults, community_input_file allows 30 uploads/hour and community_input_chunk allows 60 max-size uploads/hour, so the file scope is the binding constraint — which is the intended one. A typical phone photo of 3–8 MiB costs 3–8 chunk requests.

Clearing a rate in a tenant’s environment settings opts that tenant out of the limit, except for communityInputChunkThrottleRate, which falls back to its 1200/hour dataclass default. It is the only per-byte scope, so an unset value there is the difference between “this tenant does not cap submissions” and “one anonymous caller may stream unbounded data into storage”. Clearing it logs a warning naming the tenant, once per process, so the divergence is discoverable rather than silently overriding the operator’s intent.

Per-form ceilings

The scopes above bucket by the caller’s apparent address, which on an anonymous mount is only as trustworthy as the ingress (see Known gaps). A second throttle, CommunityInputAggregateThrottle, buckets by the community input named in the URL path instead:

Setting

Default

Covers

communityInputFileAggregateThrottleRate

600/hour

init, status, complete

communityInputChunkAggregateThrottleRate

12000/hour

chunk PUTs

A caller cannot vary the path segment without addressing a different form, and a form that does not exist is a 404 that never reaches storage, so these hold however the caller presents itself. At the defaults one form is bounded at 300 uploads/hour, or roughly 6 GiB/hour against the 20 MiB cap.

Unlike the per-address scopes, an unset or empty value here falls back to the default rather than meaning “unlimited”. A ceiling an operator can switch off by clearing a field is not a ceiling; tune the per-address scopes instead.

The tradeoff is deliberate: the bucket is shared by everyone filling the same form, so a determined caller can exhaust one form’s budget and make that form refuse uploads until the window rolls. That is a denial of uploads on one form, recoverable and self-healing, accepted in exchange for closing an unbounded anonymous write into shared storage. Raise the rate for a form that legitimately sustains more than 300 uploads/hour.

Cleanup of abandoned uploads

Two different abandonment cases, with two different answers.

Abandoned mid-upload — init, some chunks, never completed — needs no cleanup and gets none. The Redis session expires at CHUNKED_UPLOAD_SESSION_TTL_SECONDS (24 hours), and an unfinalized resumable upload creates no storage object; the session URI itself lapses at GCS’s own ~7-day expiry.

Finalized but never attached — the reporter uploads a photo, then closes the tab without submitting the form — does. complete writes a permanent storage object and a FileContent / ImageFileContent row with created_by IS NULL, and deletes the upload session, so nothing afterwards identifies the file as having come from an anonymous form and no session-expiry sweep can reach it.

usercontent.tasks.reap_orphaned_community_uploads collects these nightly. It works from CommunityUploadReceipt, a row written only by this mount’s complete, and deletes a file only when both references are absent: no EventFile pointing at it, and no key naming it in any EventDetails attachment sidecar at data.metadata.attachments (which is what an Attachment schema field writes, and what this mount actually feeds). A file that is referenced has its receipt dropped instead, so it stops being re-examined.

Receipts exist rather than a plain created_by IS NULL filter because that condition is also true of any file whose uploader was later deleted — created_by is SET_NULL — and those are ordinary users’ files. Scoping to receipts means the reaper cannot reach them whatever happened to the account.

Setting

Default

COMMUNITY_UPLOAD_REAP_AFTER_DAYS

7

COMMUNITY_UPLOAD_REAP_BATCH_SIZE

500

The age gate has to comfortably exceed how long a reporter might sit on a half-finished form, since the file is unreferenced until the event is submitted.

When retuning these, keep communityInputChunkThrottleRate at or above communityInputFileThrottleRate ÷ 2 × (communityInputMaxUploadBytes ÷ chunk size), or the chunk budget silently becomes the real upload limit and submitters hit 429 part-way through a file rather than being turned away at init.

Known gaps

Accepted limitations of this mount, recorded so the next reader does not have to rediscover them.

Ownership scoping ends at complete

Session ownership is enforced for init, status, chunk and complete. Attachment schema field validation is deliberately ownership-agnostic: any well-formed UUID is accepted at write time (see activity.serializers.event_details.EventDetailsSerializer._validate_v2_attachment_fields). A finalized file id from any source can therefore be referenced from any community event. The consequence is misattribution — a file showing up under an event that did not upload it — not disclosure, since the id has to be known already and reading the file still goes through the usual download authorization.

Init answers whether an id already exists in this tenant

Supplying a client-chosen id that already exists in the tenant is answered 409 at init. Anonymous callers can therefore probe whether a given UUID is in use here. The value is low — the id must already be known, and a uuid4 is not guessable — so this is accepted rather than fixed, but it is now reachable without credentials, which it was not before.

A foreign tenant’s usercontent id fails late, leaving an orphan

Init’s collision pre-check is tenant-scoped, so a FileContent id belonging to another tenant passes it. The upload then runs to complete, where the insert raises IntegrityError and is answered 409, after the GCS object has been written — an orphaned object with no row pointing at it. This is accepted rather than fixed: a global, cross-tenant pre-check at init would answer “this id exists somewhere” and turn the endpoint into an anonymous cross-tenant existence oracle, which is worse than an occasional orphan.

Throttle keys rest on ingress X-Forwarded-For behaviour

Every scope here throttles anonymous callers by client IP, which DRF derives in BaseThrottle.get_ident(). With REST_FRAMEWORK["NUM_PROXIES"] unset — and it is unset repo-wide — DRF uses the whole X-Forwarded-For header as the identity. That is safe only if the ingress overwrites the header. If it appends to a client-supplied one instead, the caller controls a prefix of the key and can vary a bogus address per request to land on a fresh throttle bucket every time, making every limit above ineffective.

Infra must confirm which the ingress does. If it appends, set REST_FRAMEWORK["NUM_PROXIES"] to the number of proxies in front of the app so DRF counts back from the end of the list instead. Do not guess the value: too high collapses every caller onto the proxy’s own address and throttles the whole site as one client.

Until that is settled, the per-address scopes above are treated as tuning, not as a security boundary. The ceilings that hold regardless are the per-community-input ones described under Per-form ceilings; that is why they exist and why an empty tenant setting does not disable them.