Liking cljdoc? Tell your friends :D

Files

Synthigy stores files: uploads, downloads, versions and folders, under the same access control as your data. The bytes go through the engine at /files; each file is also an ordinary File record you can query and relate to your own entities. The files module is part of every released bundle and starts with the engine.

Where the bytes live

By default files are kept on the engine's disk, in <home>/files/. For an S3 bucket (AWS, SeaweedFS, MinIO or any S3-compatible store):

SYNTHIGY_FILES_STORAGE=s3
SYNTHIGY_FILES_S3_BUCKET=synthigy-files
SYNTHIGY_FILES_S3_REGION=eu-central-1

A self-hosted store also needs its endpoint, and usually path-style addressing:

SYNTHIGY_FILES_S3_ENDPOINT=http://seaweedfs:8333
SYNTHIGY_FILES_S3_PATH_STYLE=true
SYNTHIGY_FILES_S3_ACCESS_KEY_ID=...
SYNTHIGY_FILES_S3_SECRET_ACCESS_KEY=...

Without keys the engine uses AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY, then the EC2 instance role. Give the engine a bucket of its own: it deletes objects it no longer needs (below), and it decides that from the object names.

More than one engine behind a load balancer needs S3: local storage keeps each engine's files on its own disk.

The portal's setup page asks for this when it creates an instance, with a Test that writes, reads, lists and deletes one object in the bucket.

To change the storage later, use Move files in the portal's File storage panel. It copies every file and every kept version to the new store while the engine keeps serving, pauses uploads (they answer 503 FILES_MIGRATING) only while the last few are copied, then switches. Downloads keep working throughout, and the old store keeps its copies. Editing the environment variables by hand moves nothing: files stored before the change stop downloading until you switch back.

Every version of a file is a separate object, never overwritten. Back up the database and the store together; one without the other holds rows without bytes, or bytes nobody can find.

Size

The Enso and Jetty servers stream an upload to disk and set no limit. The http-kit server holds each request body in memory, so it caps one request at SYNTHIGY_HTTPKIT_MAX_BODY bytes, 25 MB by default, and refuses a bigger one with a plain-text 413 before the files module sees it. A reverse proxy has a limit of its own (nginx client_max_body_size, see Reverse proxy); set it to the largest file you accept.

Access

Three roles come with the module:

RoleFor
File AdminEvery file and folder, whatever the folder access lists say
File UserUploads and manages files where a folder's access lists, or authorship, allow
File ReaderDownloads what is shared with it; a folder's write list never gives it write

Folders carry three access lists, read, write and delete, each naming users and groups. A new folder starts with its parent's lists plus its creator on all three; later changes to the parent do not flow down. Whoever may write a folder may share it through /files/folders. File Admin and File User can read the lists on /data as well. Folders at the top, inside ROOT, are created by File Admin.

Files without a folder are allowed, for a file that belongs to a task or an invoice rather than a place. Their uploader and File Admin can reach them.

Your own datasets may use the File entity: relate it to your entities and add row-rule guards on File in the modeling tool, for example the invoice's owner is me. Guards add up (Access control), so yours gives access on top of the uploader, the folders and File Admin.

The facts of a file (size, hash, status, uploader) are set by the engine, and a File record is created only by an upload. Files and folders are read on /data like any record, and your records can link to them there, but they are created, changed and deleted only through /files: a write, delete, purge or slice of them on /data is refused with ENGINE_OWNED, for every role. The storage key is never readable.

Names

Names are unique inside a folder and compared exactly: Report.pdf and report.pdf are two files. A taken name is FILE_NAME_TAKEN. Files without a folder are never compared. A name is refused (INVALID_NAME) when it is empty, longer than 255 characters, holds /, \ or control characters, or is . or ...

Versions and deletion

Replacing a file keeps the old bytes as a version. The last ten are kept, and any of them can be downloaded or restored; restoring copies no bytes. A deleted file's bytes stay for 30 days. Every six hours the engine deletes what nothing needs any more: versions past the tenth, files deleted more than 30 days ago, and uploads whose record was never written. Nothing younger than an hour is touched.

Versions are read from audit history, so the files module needs a durable audit store and does not start on the in-memory one. While audit is unavailable, uploads, replacements, deletions and version calls answer AUDIT_UNAVAILABLE (503); downloads keep working.

HTTP

Every call needs a bearer token and runs as its identity, except links and downloads of public files (Serving files to browsers). Errors are {"error": {"code", "message"}}. JSON answers are snake_case; ?key_format=camel, kebab or pascal on any call cases their keys like /data does.

A folder is named by its xid or by its path: / is ROOT, and /projects/acme and /projects/acme/ name the same folder. A path works everywhere a folder xid does, in folder and parent parameters and bodies, and as ?path= in place of /<xid> in the folder URLs below (GET /files/folders?path=/projects/acme/, GET /files/folders/access?path=...). A path that names no folder is FOLDER_NOT_FOUND, never ROOT.

A file is named by its xid or by its path, /projects/acme/report.pdf: as ?path= in place of /<xid> on GET, HEAD, PUT, PATCH and DELETE /files and on /files/info. Files without a folder are not under / and have no path: list them with folderless=true, or without folder among all you can see.

RequestDoes
POST /files?name=<name>&folder=<xid\|path>Upload the body as a new file; folder is optional. 201 with the file
GET /files?folder=&folderless=&name=&q=&order=&limit=&offset=Files you can see, newest first. folderless=true keeps only files in no folder you can see (not with folder); name matches one name exactly; q matches names holding its words in order, ignoring case (a space stands for anything between; % and _ are plain characters); order is name or oldest, limit up to 1000 (default 100), offset for the next page
GET /files/<xid>/infoOne file
GET /files/search?q=&folder=&limit=&offset=Folders and files whose paths hold the words of q in order, ignoring case (site logo finds /site/img/logo.png), shortest path first: {"results": [...], "truncated"}. Each result is a folder or a file with type; a file's path is its full path, null when it is in no folder or in one you cannot see (then it matches by its name). folder searches below one folder; limit up to 1000 (default 100), offset for the next page
GET, HEAD /files/<xid>The bytes
GET, HEAD /files/path/<folder path>/<name>The bytes, by a readable link (below)
PUT /files/<xid>A new version from the body; If-Match: "<hash>" refuses it when the file changed since (FILE_CHANGED, 412)
PUT /files?path=/<folder path>/<name>The body becomes that file: created (201), or a new version when it is there (200, If-Match as above). The folder must exist: FOLDER_NOT_FOUND names the first one missing in details.path
PATCH /files/<xid>{"name", "folder"}: rename and/or move; "folder": null takes it out of every folder. A taken name is FILE_NAME_TAKEN
DELETE /files/<xid>Delete the file. 204
POST /files/zip{"files": [xid\|path...], "folders": [xid\|path...]}: one zip of those files and everything inside those folders, streamed (Zip)
GET /files/<xid>/versionsKept versions, newest first: id, at, by, hash, size, content_type, current
GET /files/<xid>/versions/<id>The bytes of one version
POST /files/<xid>/versions/<id>/restoreMake a version current again
GET /files/folders?parent=<xid\|path>Folders inside parent (default ROOT): xid, name, path, parent, writable, public, cache and can_link as they apply, and the folder's own values under settings
POST /files/folders{"name", "parent"}; a new folder. 201
PUT /files/folders?path=/a/b/{"public", "cache", "can_link"}, all optional: the folder, with every missing folder above it, then its settings. When a setting is refused nothing is created
GET, PATCH, DELETE /files/folders/<xid>One folder; PATCH {"name", "parent"} renames or moves it ("parent": null or "/" to the top), paths below follow; only an empty folder is deleted
POST /files/<xid>/links{"expires", "max_downloads", "inline"}: a link that downloads the file without signing in (Links). 201 with its url, shown only now
GET /files/<xid>/linksThe file's live links you can see: your own, or all of them for File Admin
DELETE /files/links/<link xid>End a link at once. 204
GET, HEAD /files/link/<token>The bytes, for whoever holds the link
GET /files/folders/<xid>/access{"read"\|"write"\|"delete": {"users": [...], "groups": [...]}}, each {xid, name} (a user's display name, never the login); File Admin and File User, not File Reader
POST /files/folders/<xid>/share, /unshare{"access": "read"\|"write"\|"delete", "users": [xid...], "groups": [xid...]}

Send the bytes as the body with their own Content-Type, or none and the engine guesses from the name. A form-encoded body is refused (UNSUPPORTED_MEDIA_TYPE): it would be parsed as a form, not stored.

TOKEN=$(synthigy token)
curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/pdf' \
     --data-binary @report.pdf 'https://synthigy.example.com/files?name=report.pdf'
{
  "xid": "rkpYAgQpiwaSnUhP7z5hU1",
  "name": "report.pdf",
  "size": 48213,
  "hash": "906ed25f…",
  "status": "READY",
  "content_type": "application/pdf",
  "uploaded_at": "2026-10-29T13:15:07Z",
  "uploaded_by": {"xid": "…", "name": "jane"},
  "folder": null,
  "public": false
}

hash is the SHA-256 of the bytes and the download's ETag. public says whether the file downloads without a token. Downloads answer Range (206) and If-None-Match (304), and are sent as attachments; ?disposition=inline shows images, audio, video, PDF and plain text in the browser instead. A plain <img src> cannot carry a token: give it a public file's link (below), or download into a Blob and use URL.createObjectURL.

Listing a folder through /data

For sorting, paging with a total, or filtering by name inside one folder, read File on /data like any record, under the same access lists. One request with the page and the totals:

{"operations": [
  {"op": "search", "entity": "file",
   "args": {"_order_by": [["size", "desc"], ["xid", "asc"]], "_limit": 200, "_offset": 0},
   "selections": {"name": null, "size": null, "content_type": null, "hash": null,
                  "uploaded_at": null, "uploaded_by": {"display_name": null},
                  "folder": [{"args": {"_join": "inner", "_where": {"xid": {"_eq": "<folder>"}}},
                              "selections": {"xid": null}}]}},
  {"op": "sql-template",
   "template": "SELECT count(*) AS files, coalesce(sum({file.size}), 0) AS bytes FROM {file->folder.xid} WHERE {file->folder.xid} = ?folder:string",
   "params": {"folder": "<folder>"}}]}
  • End every sort with ["xid", "asc"], so pages never repeat or skip a file.
  • Inside the folder by name: "_where": {"name": {"_ilike": "%annual%report%"}} on the search and AND {file.name} ILIKE ?q:string in the template, with the same pattern. Put \ before a %, _ or \ the user typed.
  • Files in no folder you can see: "_join": "none" on folder, and WHERE {file->folder.xid} IS NULL in the template.
  • uploaded_by {display_name} needs no access to User.

Zip

POST /files/zip streams one application/zip of the current versions of the files you name and of every file inside the folders you name, read as you:

  • A folder's files keep their paths below the folder's parent, so /site/img/ gives img/logo.png and img/icons/a.svg. A file you name directly is stored under its name.
  • Two entries with the same name are numbered as uploads are (a (2).txt).
  • A file named twice, or both by itself and inside a named folder, is stored once.
  • What you named but cannot read, does not exist, or is not READY is listed in a _skipped.txt entry rather than failing the zip. A folder you cannot see is skipped whole.
  • One folder alone names the zip after it (img.zip); anything else is files-<date>.zip.

The total size and the number of files are checked before a byte is sent: over SYNTHIGY_FILES_ZIP_MAX_BYTES (2 GB) or SYNTHIGY_FILES_ZIP_MAX_ENTRIES (10 000) the answer is ZIP_TOO_LARGE (413) with the counts in details. Images, video, audio, already-compressed formats and application/octet-stream go in uncompressed. On the http-kit server a zip is assembled in memory first; Enso and Jetty stream it.

curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
     -d '{"folders": ["/site/img/"]}' -o img.zip https://synthigy.example.com/files/zip

The JavaScript SDK wraps all of this as client.files. In the console, Tools → Files does it by hand: folders, uploads, versions and restore, and each folder's Public, Cache and access lists.

Serving files to browsers

Images, styles and documents that a web page shows its visitors. Two Folder attributes decide how they are served. Set them with PUT /files/folders?path=:

AttributeMeans
Publictrue: the folder's files download without signing in; false: they don't, also inside a public folder
CacheSeconds browsers and CDNs may keep the folder's files; without one they check every time

Each folder has its own value or none, as on a filesystem: without one, it takes the value of the nearest folder above that has one. Clearing a value (null) makes the folder inherit again.

Whoever may write a folder may set them, within the attribute permissions of the model: File User may set Cache but not Public, so making files public is up to File Admin unless you change that attribute's permissions in the data modeling tool. Files without a folder are never public.

Links. /files/<xid> and /files/path/<folder path>/<name> (each part percent-encoded) both download a file. The first survives a rename or a move; the second reads well and is the same in every environment. Both always serve the current bytes. Without a token, only downloads of files in a public folder answer, with Access-Control-Allow-Origin: *; everything else is 401, the same for a file that does not exist. A browser opening such a link gets a "Sign-in required" page instead of the JSON error, or the default login-page bundle's files.html when it has one (LOGIN_PAGES.md). ?disposition=inline opens images, video, audio, PDF and plain text in the browser instead of downloading them.

Cache-Control follows the folder's Cache: public, max-age=<Cache> without a token, private, max-age=<Cache> with one. With no value it is no-cache: browsers ask each time and get 304 while the file is unchanged. With a CDN in front of the engine, public files come from its cache for Cache seconds; a replaced file shows its new bytes once that time is up.

A request that carries a token follows the access lists, also for a public file, and a token that fails is refused (401) even for a public file.

A browser signed in to the engine (the console or a sign-in page on the same address) opens file links with its session cookie, under the same access lists, when it opens the link itself: typed, bookmarked, from a page on the engine's address, or a link clicked on another site. A page on another site cannot use the cookie to embed or fetch a file. The cookie works for the two download links only; every other /files request needs a token.

Links

A link hands one file to someone without an account, or to another service, for a while: https://<engine>/files/link/<token>. It works from any engine on the same database, and ends everywhere at once when it expires, runs out of downloads or is deleted.

  • Where. Only in folders where Can Link is true, on the folder itself or the nearest folder above with a value; off everywhere until a File Admin switches it on. Files in no folder are never shared by link. false below a true turns it off again, also for links already made.
  • Who. File User and File Admin, on files they can read (the create grant on File Link). File Reader never.
  • Every download checks the link and the person who made it: if they can no longer read the file (taken off the folder, deactivated), the link stops. A bad, expired, used-up and deleted link all answer the same 404 (LINK_NOT_FOUND), or a "This link no longer works" page in a browser.
  • Lifetime. expires in seconds, 3600 by default, at most 7 days.
  • Limited links. With max_downloads, every GET is one whole download: Range is ignored (Accept-Ranges: none) so the file cannot be fetched in pieces past the limit; HEAD is free. Without a limit, ranges work and only a request from the first byte counts in downloads.
  • Caching. private, never public: a CDN that ignores the token in the path must not hand the file to others. A limited link sends no-store; otherwise max-age is the folder's Cache, never past the link's expiry. Referrer-Policy: no-referrer keeps the link out of other sites' logs.
  • Only the token's SHA-256 is stored, so the database cannot rebuild a link: the url is shown once, when it is made. Links are read on /data as File Link records and, like files, changed only through /files.
  • The engine deletes ended links every six hours, and a file's links with the file.
curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
     -d '{"expires": 86400, "max_downloads": 3}' \
     https://synthigy.example.com/files/rkpYAgQpiwaSnUhP7z5hU1/links

Errors

CodeStatusWhen
FILE_NOT_FOUND404No such file, or you cannot see it
FOLDER_NOT_FOUND404No such folder, or you cannot see it
FORBIDDEN403Your roles or the folder's access lists refuse it
FILE_NAME_TAKEN409The folder already has a file of that name
FOLDER_NAME_TAKEN409The parent already has a folder of that name
FOLDER_NOT_EMPTY409Deleting a folder that holds files or folders
FOLDER_CYCLE409Moving a folder inside itself
FILE_NOT_READY409The file is still being processed, or failed
FILE_CHANGED412If-Match no longer matches
NAME_REQUIRED400An upload without ?name=
INVALID_NAME400See Names
INVALID_ACCESS400access is not read, write or delete
UNSUPPORTED_MEDIA_TYPE415A form-encoded body
RANGE_NOT_SATISFIABLE416A range past the end of the file
ZIP_TOO_LARGE413A zip over SYNTHIGY_FILES_ZIP_MAX_BYTES or SYNTHIGY_FILES_ZIP_MAX_ENTRIES
VERSION_EXPIRED410A version, or bytes, no longer kept
AUDIT_UNAVAILABLE503A write or version call while audit history is unavailable
FILES_MIGRATING503An upload while files move to a new store; retry in a moment, downloads keep working
INVALID_PATH400Not a file path (/folder/name) or not a folder path
INVALID_SETTINGS400public or can_link is not true or false, or cache is not whole seconds
ATTRIBUTE_FORBIDDEN403Your roles may not set that folder setting
LINKS_DISABLED403The file's folder does not allow links, or the file is in no folder
LINK_NOT_FOUND404A link that expired, was used up or deleted, or never existed
INVALID_EXPIRES400expires is not whole seconds from 1 to 604800

Can you improve this documentation?Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
←Move to previous article
→Move to next article
Ctrl+/Jump to the search field
× close