Backend
Files
pleate_backend.files stores private uploads. Every user sees only their own files. By default the bytes go to MongoDB GridFS, so a development setup needs nothing else; production can switch to S3 or any compatible storage with one setting.
Upload with progress
useUpload posts each file as soon as it is picked, reports progress per file and supports retry and removal.
What every upload goes through
- Size and quota. At most
MAX_SIZEper file andUSER_QUOTAper user. - Type sniffing. The type is detected from the bytes with
filetype. The type the browser claims and the extension are ignored, except that UTF-8 text with a.txt,.md,.logor.csvextension becomestext/plainortext/csv. Then it must be inALLOWED_TYPES. - Image cleaning. Images are decoded and re-encoded with Pillow: EXIF and GPS metadata are dropped, the EXIF orientation is applied, and they are downscaled to
IMAGE_MAX_DIMENSION. Animations are limited to 300 frames andANIMATION_MAX_PIXELSdecoded pixels. - Safe names. Paths, control and reserved characters are removed from the name; storage keys are random.
- Safe downloads.
Content-Disposition: attachmentwith an RFC 5987filename*,nosniffand aprivatecache.?inline=1is honored only for images and PDF.
Endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | /files | My files. Page list, search on name, filters content_type and purpose, ordering by created_at, size, name. |
| POST | /files | Multipart: file (one per request, upload several in parallel) and optional purpose. Throttle scope upload. 201. |
| GET | /files/usage | {used, quota, count} in bytes. |
| GET | /files/<id> | One file. |
| PATCH | /files/<id> | {name} renames. |
| DELETE | /files/<id> | Deletes the record and the blob. 204. |
| GET | /files/<id>/download | The bytes. ?inline=1 for images and PDF. |
| POST | /files/presign | {name, size, content_type, purpose?} → {file_id, upload_url, fields, expires_in} for a direct S3 upload. |
| POST | /files/<id>/complete | After the direct upload: checks the size, sniffs the bytes, cleans images and marks the file ready. |
{
"id": "6720d0aa1b2c3d4e5f607182",
"name": "receipt-october.pdf",
"size": 182044,
"content_type": "application/pdf",
"checksum": "9f2c1e…",
"width": null,
"height": null,
"purpose": "attachment",
"status": "ready",
"created_at": "2026-10-26T10:12:00Z"
}From the client
const upload = useUpload({ purpose: "attachment", onUploaded: (file) => console.log(file.id) });
<FileUpload multiple {...upload.fileUploadProps} />
<Button disabled={upload.busy} onClick={() => save(upload.uploaded.map((f) => f.id))}>Save</Button>useUpload returns files, uploads (state per file), uploaded (the stored FileItems), busy, add, remove (which also deletes the stored file), retry and clear. Without the hook: client.files.upload(file, { purpose, onProgress, signal }). Progress uses XMLHttpRequest, since fetch cannot report upload progress.
Direct uploads to S3
Large files can skip Django. With the S3 storage and PRESIGNED_UPLOADS on, the browser asks for a presigned POST, sends the bytes straight to the bucket, then calls complete, which runs the same checks. Files stay pending (hidden from lists) until completed, and pending files older than a day are removed by the hourly cleanup task.
const { file_id, upload_url, fields } = await client.files.presign({
name: file.name, size: file.size, content_type: file.type,
});
const form = new FormData();
Object.entries(fields).forEach(([key, value]) => form.append(key, value));
form.append("file", file);
await fetch(upload_url, { method: "POST", body: form });
const stored = await client.files.complete(file_id);PLEATE["FILES"] = {
"STORAGE": "storages.backends.s3.S3Storage", # pip install "pleate-backend[s3]"
"PRESIGNED_UPLOADS": True,
}
AWS_STORAGE_BUCKET_NAME = "plate-uploads"
AWS_S3_REGION_NAME = "eu-south-1"In Python
from pleate_backend.files.services import save_upload, open_file
file = save_upload(request.user, request.FILES["file"], purpose="avatar") # runs every check
with open_file(file) as handle:
data = handle.read()File has owner, name, key, size, content_type, checksum (SHA-256 hex), width, height, purpose, status and created_at. Deleting a file, or its owner, deletes the blob. File metadata is part of the account export.
Settings
PLEATE["FILES"]:
| Key | Default | Meaning |
|---|---|---|
| STORAGE | GridFSStorage | Dotted path of a Django storage, e.g. storages.backends.s3.S3Storage. |
| MAX_SIZE | 26214400 | Bytes per file (25 MB). |
| ALLOWED_TYPES | images, PDF, text, CSV, zip, docx, xlsx | Content types accepted after sniffing. |
| USER_QUOTA | 524288000 | Bytes per user (500 MB), None for unlimited. |
| IMAGE_MAX_DIMENSION | 2400 | Longest side of stored images, in pixels. |
| ANIMATION_MAX_PIXELS | 100000000 | Frames times width times height allowed for animated images. |
| AVATAR_MAX_SIZE | 2097152 | Avatar upload limit (2 MB). |
| AVATAR_DIMENSION | 512 | Avatars are cropped to this square. |
| PRESIGNED_UPLOADS | False | Enable presign and complete. Needs the S3 storage. |
Error codes
| Code | Status | When |
|---|---|---|
| file_too_large | 413 | Over MAX_SIZE; the body has max_size. |
| file_type_not_allowed | 415 | The sniffed type is not in ALLOWED_TYPES. |
| quota_exceeded | 413 | Over USER_QUOTA; the body has quota and used. |
| invalid_image | 400 | Looks like an image but cannot be decoded. |
| presign_not_supported | 400 | Presigned uploads are off or the storage is not S3. |
| upload_not_found | 400 | complete was called but the object is not in the bucket. |