Backend

Files

Private uploads with progress, type sniffing, image cleaning, quotas, downloads and direct S3 uploads.

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.

Mock backendPick any small image or PDF

What every upload goes through

  • Size and quota. At most MAX_SIZE per file and USER_QUOTA per 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, .log or .csv extension becomes text/plain or text/csv. Then it must be in ALLOWED_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 and ANIMATION_MAX_PIXELS decoded pixels.
  • Safe names. Paths, control and reserved characters are removed from the name; storage keys are random.
  • Safe downloads. Content-Disposition: attachment with an RFC 5987 filename*, nosniff and a private cache. ?inline=1 is honored only for images and PDF.

Endpoints

MethodPathPurpose
GET/filesMy files. Page list, search on name, filters content_type and purpose, ordering by created_at, size, name.
POST/filesMultipart: 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>/downloadThe 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>/completeAfter the direct upload: checks the size, sniffs the bytes, cleans images and marks the file ready.
A file
{
  "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

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

ts
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);
settings.py
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

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"]:

KeyDefaultMeaning
STORAGEGridFSStorageDotted path of a Django storage, e.g. storages.backends.s3.S3Storage.
MAX_SIZE26214400Bytes per file (25 MB).
ALLOWED_TYPESimages, PDF, text, CSV, zip, docx, xlsxContent types accepted after sniffing.
USER_QUOTA524288000Bytes per user (500 MB), None for unlimited.
IMAGE_MAX_DIMENSION2400Longest side of stored images, in pixels.
ANIMATION_MAX_PIXELS100000000Frames times width times height allowed for animated images.
AVATAR_MAX_SIZE2097152Avatar upload limit (2 MB).
AVATAR_DIMENSION512Avatars are cropped to this square.
PRESIGNED_UPLOADSFalseEnable presign and complete. Needs the S3 storage.

Error codes

CodeStatusWhen
file_too_large413Over MAX_SIZE; the body has max_size.
file_type_not_allowed415The sniffed type is not in ALLOWED_TYPES.
quota_exceeded413Over USER_QUOTA; the body has quota and used.
invalid_image400Looks like an image but cannot be decoded.
presign_not_supported400Presigned uploads are off or the storage is not S3.
upload_not_found400complete was called but the object is not in the bucket.