Files API
REST endpoints for uploading, downloading, listing, and deleting files using presigned R2 URLs.
#Files API
EmuView stores files on Cloudflare R2 using a presigned URL flow. Files upload directly from the client to R2 — they never pass through the Worker, keeping memory usage low and upload speeds fast.
All file endpoints use the /api/v1/files base path.
#Upload flow
File uploads follow a three-step process:
- Request a token — get a presigned upload URL and file ID
- Upload to R2 — PUT the file body directly to the presigned URL
- Confirm the upload — tell EmuView the upload is complete
#Step 1: Request upload token
POST /api/v1/files/upload-token
Permission required: system/files:create
#Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
filename |
string |
Yes | Original filename |
mime_type |
string |
Yes | MIME type (e.g., image/jpeg, application/pdf) |
size_bytes |
integer |
Yes | File size in bytes |
collection_name |
string |
No | Collection to associate the file with (default: __system) |
visibility |
string |
No | public (default) or private. See Ownership and visibility |
#Request
#HTTP
POST /api/v1/files/upload-token
Content-Type: application/json
Authorization: Bearer sk-your-api-key
{
"filename": "product-photo.jpg",
"mime_type": "image/jpeg",
"size_bytes": 2048576,
"collection_name": "products"
}
#SDK
import { EmuView } from '@emuview/sdk';
const sdk = new EmuView({
url: 'https://your-api.example.com',
auth: { apiKey: 'sk-your-api-key' }
});
// The SDK wraps all three steps into a single call:
const fileInput = document.querySelector('input[type=file]');
const file = fileInput.files[0];
const { fileId } = await sdk.files.upload(file, {
filename: file.name,
mimeType: file.type,
collection: 'products'
});
#Response
200 OK
{
"id": "file_a1b2c3d4e5f6a7b8",
"uploadUrl": "https://r2.example.com/presigned-put-url?X-Amz-Expires=120&...",
"r2Key": "projects/proj_abc/collections/products/file_a1b2c3d4e5f6a7b8/product-photo.jpg"
}
#Errors
| Status | Code | Description |
|---|---|---|
400 |
limit_exceeded |
File size exceeds project storage quota (default 5 GB) |
400 |
invalid_request |
Missing filename, MIME type, or size |
403 |
forbidden |
User lacks write permission on the target collection |
#Step 2: Upload file to R2
Upload the file body directly to the presigned URL returned in step 1. This request goes to R2, not through EmuView.
PUT https://r2.example.com/presigned-put-url?X-Amz-Expires=120&...
Content-Type: image/jpeg
<binary file data>
The presigned URL expires after 120 seconds. Start the upload immediately after requesting the token.
#Step 3: Confirm upload
POST /api/v1/files/confirm-upload/:id
Verify the file exists in R2 and mark the database record as complete. For JPEG and PNG images, thumbnail generation starts asynchronously.
Permission required: system/files:create
#Request
POST /api/v1/files/confirm-upload/file_a1b2c3d4e5f6a7b8
Authorization: Bearer sk-your-api-key
#Response
200 OK
{ "success": true, "message": "Upload confirmed." }
The SDK's upload() method handles all three steps automatically. You only need to call the individual endpoints when building a custom upload flow.
#Download file
GET /api/v1/files/download-token/:id
Request a presigned download URL for a file. The URL expires after 5 minutes.
Permission required: system/files:read
#Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
string |
File ID |
#Query parameters
| Parameter | Type | Description |
|---|---|---|
size |
string |
Named size variant (e.g., thumbnail). Falls back to the original if the variant doesn't exist |
#Request
#HTTP
GET /api/v1/files/download-token/file_a1b2c3d4e5f6a7b8
Authorization: Bearer sk-your-api-key
#SDK
const downloadUrl = await sdk.files.getDownloadUrl('file_a1b2c3d4e5f6a7b8');
// downloadUrl → "https://r2.example.com/presigned-get-url?..."
// Request a thumbnail variant
const thumbUrl = await sdk.files.getDownloadUrl('file_a1b2c3d4e5f6a7b8', {
size: 'thumbnail'
});
#Response
200 OK
{
"id": "file_a1b2c3d4e5f6a7b8",
"downloadUrl": "https://r2.example.com/presigned-get-url?X-Amz-Expires=300&..."
}
#Errors
| Status | Code | Description |
|---|---|---|
404 |
not_found |
File does not exist or has not been confirmed |
#List files
GET /api/v1/files
Returns all files with status = 'complete' for the current project, ordered by created_at descending.
Permission required: system/files:read
#Request
#HTTP
GET /api/v1/files
Authorization: Bearer sk-your-api-key
#SDK
const files = await sdk.files.list();
#Response
200 OK
[
{
"id": "file_a1b2c3d4e5f6a7b8",
"projectId": "proj_abc",
"collectionName": "products",
"r2Key": "projects/proj_abc/collections/products/file_a1b2c3d4e5f6a7b8/product-photo.jpg",
"filename": "product-photo.jpg",
"mimeType": "image/jpeg",
"sizeBytes": 2048576,
"uploadedBy": "usr_01HXK5M9ABCDEF",
"createdAt": 1718900000
},
{
"id": "file_b2c3d4e5f6a7b8c9",
"projectId": "proj_abc",
"collectionName": "__system",
"r2Key": "projects/proj_abc/collections/__system/file_b2c3d4e5f6a7b8c9/report.pdf",
"filename": "report.pdf",
"mimeType": "application/pdf",
"sizeBytes": 1024000,
"uploadedBy": "usr_01HXK5M9ABCDEF",
"createdAt": 1718895000
}
]
#Replace a file's bytes
POST /api/v1/files/:id/replace
Rewrite the content of an existing file, keeping its id. Multipart form data with
one file field, same as the proxy upload — and the same ~95MB ceiling, because
the bytes pass through the Worker.
Use this instead of upload-new → repoint → delete-old: the id does not change, so nothing that references the file has to be found and updated. Three things happen as part of the call, not as follow-ups:
- the old size variants are removed, so a thumbnail of the previous content can never be served beside the new bytes;
- the stored size, content type and SHA-256 follow the new bytes;
- the file's edge-cache tag is purged, so a public file stops serving the old content immediately rather than for the rest of its TTL.
Permission required: system/files:update, and the grant's item_filter
must reach the row — the seeded editor grant scopes that to files they uploaded.
A private file you cannot read answers 404, as it does everywhere else.
#Request
#HTTP
POST /api/v1/files/file_a1b2c3d4e5f6a7b8/replace
Authorization: Bearer sk-your-api-key
Content-Type: multipart/form-data; boundary=----X
------X
Content-Disposition: form-data; name="file"; filename="beach-v2.jpg"
Content-Type: image/jpeg
<binary>
------X--
#Response
200 OK — the updated file record, under data.
#Errors
| Status | Code | Description |
|---|---|---|
400 |
invalid_request |
Empty replacement, or the upload never completed |
400 |
limit_exceeded |
Over the role upload limit or the project quota |
401 |
unauthorized |
Anonymous caller |
403 |
forbidden |
No system/files:update, or outside its item filter |
404 |
not_found |
File does not exist, or is private and not yours |
503 |
service_unavailable |
Old variants could not be removed; nothing changed |
#Delete file
DELETE /api/v1/files/:id
Delete the original file, all generated size variants (thumbnails), and the database metadata record.
Permission required: system/files:delete
#Request
#HTTP
DELETE /api/v1/files/file_a1b2c3d4e5f6a7b8
Authorization: Bearer sk-your-api-key
#SDK
await sdk.files.delete('file_a1b2c3d4e5f6a7b8');
#Response
200 OK
{ "success": true, "message": "File and all variations successfully deleted." }
#Errors
| Status | Code | Description |
|---|---|---|
404 |
not_found |
File does not exist |
#File object schema
Each file stored in EmuView has the following metadata:
| Field | Type | Description |
|---|---|---|
id |
string |
Unique file identifier |
projectId |
string |
Project the file belongs to |
collectionName |
string |
Associated collection (or __system for unattached files) |
r2Key |
string |
Full R2 object path |
filename |
string |
Original filename |
mimeType |
string |
MIME type |
sizeBytes |
integer |
File size in bytes |
uploadedBy |
string|null |
User ID of the uploader (set server-side; null on legacy rows or when the user was deleted) |
createdAt |
integer |
Unix timestamp |
visibility |
string |
public or private — controls who may read the file |
#Ownership and visibility
Every file records its owner (uploadedBy) server-side from the authenticated
principal at upload time — it is never client-asserted. A visibility flag,
chosen by the uploader when requesting the upload token (or as a visibility
form field on the proxy upload), controls read access:
public(default, and the behaviour of all files uploaded before this feature): any project user withsystem/files:readcan list, download, and read the file's metadata.private: only the uploader can read it. Other users receive404(not403) so file IDs don't leak existence, and the file is omitted from theirGET /fileslistings. Roles withadmin_accessandsuper_adminbypass the gate.
Independently of visibility, three write paths are owner-only:
POST /files/confirm-upload/:id— only the user who requested the upload token can confirm it (403otherwise, including for admins: confirm is a completion handshake, not an administrative operation).POST /files/:id/variant— only the uploader can attach client-generated preview variants.- Role
item_filterscoping (e.g. the seeded editor policy{"uploaded_by": {"_eq": "$CURRENT_USER"}}) is enforced on update/delete, so editors can only delete their own files.
#Get file metadata
GET /api/v1/files/:id
Returns a single file's metadata record (see schema above), including
uploadedBy and visibility. Use this in your app's backend to layer your own
ownership checks on top of the gateway's enforcement (defence-in-depth):
const meta = await sdk.files.get('file_a1b2c3d4e5f6a7b8');
if (meta.uploaded_by !== currentUser.id) {
// refuse to serve this file from your own /api/v1/files/[id] proxy
}
Errors: 404 not_found when the file doesn't exist or is private and owned
by someone else; 403 forbidden when the caller lacks system/files:read.
#Storage limits
| Limit | Default |
|---|---|
| Project storage quota | 5 GB |
| Max upload size | 100 MB per file |
| Upload URL expiry | 120 seconds |
| Download URL expiry | 300 seconds (5 minutes) |
#Thumbnail generation
For JPEG and PNG uploads, EmuView generates thumbnails asynchronously using WebAssembly-based image resizing. Request a thumbnail by passing ?size=thumbnail to the download endpoint.
Three strategies are available (configured per project):
| Strategy | Behaviour |
|---|---|
| Store on upload | Generates thumbnails immediately when the upload is confirmed |
| Store on request | First download request triggers generation; subsequent requests serve from R2 |
| Edge cache only | Transforms on every request; CDN caches the result |
#Regenerating variants
POST /api/v1/files/:id/regenerate re-runs variant generation for one file — the way to pick up a changed size configuration, or to replace legacy variants.
For a whole library, POST /api/v1/files/regenerate sweeps files a page at a time. This is what a format migration needs: files that already have variants are not missing anything, so nothing regenerates them on its own.
| Field | Type | Default | Description |
|---|---|---|---|
cursor |
string | null | null |
nextCursor from the previous page. Omit to start. |
limit |
number | 10 |
Files this page may sweep. Capped at 50. |
containerId |
string | null | null |
Confine the sweep to one file container. |
The response reports every file it touched, and carries the cursor for the next page:
{
"limit": 10,
"cursor": null,
"processed": 3,
"regenerated": 2,
"skipped": 1,
"failed": 0,
"outOfScope": 0,
"results": [
{ "id": "file_abc", "filename": "beach.jpg", "status": "regenerated" },
{
"id": "file_def",
"filename": "notes.txt",
"status": "skipped",
"reason": "unsupported-source-type"
}
],
"nextCursor": "file_def",
"hasMore": true,
"total": 220947
}
Call it again with cursor set to nextCursor until hasMore is false. total is counted on the first call only — a resumed page returns null rather than re-counting the library.
A file that cannot be processed comes back as skipped with a reason, and one whose storage or codec failed comes back as failed with its error; neither ends the page. Requires system/files:update, and a grant's item_filter applies — files outside it are counted in outOfScope and left untouched.