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:

  1. Request a token — get a presigned upload URL and file ID
  2. Upload to R2 — PUT the file body directly to the presigned URL
  3. 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>
Important

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." }
Tip

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 with system/files:read can list, download, and read the file's metadata.
  • private: only the uploader can read it. Other users receive 404 (not 403) so file IDs don't leak existence, and the file is omitted from their GET /files listings. Roles with admin_access and super_admin bypass 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 (403 otherwise, 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_filter scoping (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.