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 |
|
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 toDEFAULT_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 |
|
|
|
chunk |
|
|
|
status |
|
|
|
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 |
|---|---|---|
|
|
init, status, complete |
|
|
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 |
|---|---|
|
|
|
|
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.