Uploading on behalf of users
Your backend creates the upload tickets, so every rule of yours runs before a byte moves, and your app never needs a File Service token. This guide covers the choices the quickstart made for you and what to do when something goes wrong. It is for backend developers using ccs-files-client.
files:manageChoose what the ticket says
Section titled “Choose what the ticket says”| Field | Choose |
|---|---|
owner_id |
The user the file belongs to. The owner holds the application’s owner actions on it and sees it in their listings. Without it, the file belongs to acting_user_id, or else to no user |
acting_user_id |
The user you act for, recorded on the audit row |
folder_id |
Where the file lives; the root by default. A folder per user, per team or per record keeps permissions simple: see Folders and files |
visibility |
application when every member may see it (a social feed); private when only the owner and the entries decide; public for a stable link (if the application allows it) |
external_ref |
Your record, such as post:123 |
policy |
A named upload policy of the application, for example avatar with images only and a small size cap |
on_name_conflict |
rename when names repeat (phone photos); the default fail answers name_conflict |
notify_user_ids |
Who besides the owner gets file.ready |
title, description, tags, category, department, custom, status |
Metadata, all optional and changeable later |
content_sha256 |
The hex SHA-256 of the file, when the app can compute it: the store then rejects a corrupted body |
Before you offer an upload at all, read the application’s rules once with files.settings.public() (GET P/settings/public): allowed types, the largest file, the named policies, and single_put_max_bytes. Your app can then refuse a wrong file without a round trip.
Handle refusals
Section titled “Handle refusals”A ticket is refused before any byte is sent. Map the codes to messages your app shows:
import { FilesApiError, FilesForbiddenError, FilesNotFoundError, FilesPayloadTooLargeError, FilesRateLimitedError, FilesUnprocessableError,} from '@central-code-station/ccs-files-client';import { files } from './files-client';
// Turn a refused ticket into an answer your app can show before any byte is sent.export async function ticketOrReason(input: { name: string; size: number; mime_type: string; owner_id: string }) { try { return { ticket: await files.uploads.create(input) }; } catch (error) { if (error instanceof FilesPayloadTooLargeError) { // file_too_large, or quota_exceeded with the numbers in the body return { reason: error.code, quota: error.code === 'quota_exceeded' ? error.body : undefined }; } if (error instanceof FilesUnprocessableError) return { reason: error.code }; // type_not_allowed, ... if (error instanceof FilesForbiddenError) return { reason: error.code }; // tenant_suspended, missing_action, ... if (error instanceof FilesNotFoundError) return { reason: error.code }; // folder_not_found if (error instanceof FilesRateLimitedError) return { reason: 'rate_limited', retryAfterSeconds: error.retryAfterSeconds }; if (error instanceof FilesApiError) console.error(error.status, error.code, error.body?.message); throw error; }}| Code | Tell the user |
|---|---|
type_not_allowed |
This kind of file is not accepted here |
file_too_large |
The file is larger than the limit (read it from the public settings) |
quota_exceeded |
The storage is full; the body has quota_bytes, used_bytes, reserved_bytes |
tenant_suspended |
Uploads are paused for this organisation |
name_conflict |
A file of that name exists (or send on_name_conflict: "rename") |
rate_limited |
Wait retryAfterSeconds |
Retries and resume
Section titled “Retries and resume”The app sends the bytes, so it owns the retries:
- A failed PUT (network error or 5xx): retry the same URL with a back-off. The URL stays valid for the ticket’s lifetime (a single PUT) or 1 hour (a part).
- An expired part URL (403 from the store): ask your API for fresh URLs; your API calls
files.uploads.parts(uploadId, { from, count })(POST P/uploads/:upload_id/parts). - The app restarted mid-upload: ask your API for the ticket’s state; your API calls
files.uploads.status(uploadId)(GET P/uploads/:upload_id), which lists the parts the store already has. Send only the missing ones, then complete. - The user cancelled: tell your API, which calls
files.uploads.abort(uploadId)(DELETE P/uploads/:upload_id) so the reserved quota is released at once.
Your API’s completion route must accept a retry too: if the app sends “finished” twice, the second completion answers upload_not_open. Read the file with files.files.get(fileId) then, and link it if it is not linked yet.
The whole upload from your backend
Section titled “The whole upload from your backend”When the bytes are already on your server (an import job, a generated invoice, a file another service sent you), files.upload() runs the ticket, the PUTs and the completion in one call:
import { FilesConflictError, FilesUploadError } from '@central-code-station/ccs-files-client';import { files } from './files-client';
// A file your backend already has (an import job, a generated report): the whole protocol in one call.export async function importAvatar(userId: string, path: string, signal: AbortSignal) { try { const file = await files.upload( { path }, // or a Buffer, Uint8Array, Blob, or a Readable with input.size { mime_type: 'image/png', owner_id: userId, acting_user_id: userId, external_ref: `avatar:${userId}`, on_name_conflict: 'rename' }, { concurrency: 4, onProgress: ({ loaded, total }) => console.log(`${Math.round((100 * loaded) / total)} %`), signal }, ); // Optional: wait until processing finished (thumbnails made, metadata read). return await files.files.waitUntilReady(file.file_id, { timeoutMs: 60_000, pollMs: 1_000 }); } catch (error) { if (error instanceof FilesUploadError) console.error(`part ${error.partNumber} could not be sent; the ticket was aborted`); if (error instanceof FilesConflictError) console.error(`processing refused the file: ${error.code}`); // file_rejected, ... throw error; }}- The source is a
Buffer, aUint8Array, aBlob, a{ path }or aReadable(withsizein the input).namedefaults to the file name of a path,mime_typeto a Blob’s type. - Large files go up in parts, 4 at a time by default (
concurrency), each retried up to 5 times; onlyconcurrencyparts are held in memory. - When it gives up, it aborts the ticket and throws
FilesUploadErrorwithpartNumber. A refused ticket throws its API error before any byte is sent.signalcancels and aborts the ticket. onProgress({ loaded, total, parts })fires after the ticket and after every finished part.
files.files.waitUntilReady(fileId) polls the file until processing is done and rejects when it was refused or failed. Use it only where you must wait; most pages can show the original until the rendition exists.
Related
Section titled “Related”- Upload protocol reference: every field and rule.
- What to store in your database.
- Errors.