# Backend HTTP API Base URL: `${FILEX_PUBLIC_URL}` (default `http://localhost:5212`). All endpoints under `/api/*` return JSON. All write endpoints expect `Content-Type: application/json` unless explicitly noted. **OpenAPI.** The routes under `/api/files` and `/api/ai` are described in [`backend/internal/api/openapi.json`](../backend/internal/api/openapi.json) (OpenAPI 3.1): every operation, its parameters, the request bodies and the answers of the main ones, the token verb or scope each needs. A test holds it to the router in both directions - no operation the server does not serve, no `/api/files` or `/api/ai` route left out - so it is safe to generate a client from. The admin API is not in it; this page is its reference, and the routes of the areas under [Admin: routes described on their own page](#admin-routes-described-on-their-own-page) are described on that area's page. - [Auth & sessions](#auth--sessions) - [Capabilities](#capabilities) - [File browsing](#file-browsing) - [Encryption policy](#encryption-policy) - [Vault (encryption level 3)](#vault-encryption-level-3) - [Drafts](#drafts) - [Uploads](#uploads) - [Archives](#archives) - [Sharing](#sharing) - [The public surface](#the-public-surface) - [Interface preferences](#interface-preferences) - [Thumbnails](#thumbnails) - [Versions](#versions) - [Comments](#comments) - [Realtime (WebSocket)](#realtime-websocket) - [Operations (long-running)](#operations-long-running) - [Admin: storages](#admin-storages) - [Admin: plugins](#admin-plugins) - [Admin: plugin requests](#admin-plugin-requests) - [Admin: encryption policy](#admin-encryption-policy) - [App plugins](#app-plugins) - [Admin: users](#admin-users) - [Admin: roles and permissions](#admin-roles-and-permissions) - [Admin: groups](#admin-groups) - [Admin: tenants](#admin-tenants) - [Admin: routes described on their own page](#admin-routes-described-on-their-own-page) - [Admin: quota](#admin-quota) - [Admin: external services](#admin-external-services) - [Admin: protection & antivirus](#admin-protection--antivirus) - [Admin: sign-in security](#admin-sign-in-security) - [Admin: webhooks](#admin-webhooks) - [Admin: sync runs](#admin-sync-runs) - [Admin: audit log](#admin-audit-log) ### Auth markers | Symbol | Meaning | |--------|---------| | ![public](https://img.shields.io/badge/-public-lightgrey) | No auth | | ![user](https://img.shields.io/badge/-user-blue) | Any authenticated user | | ![admin](https://img.shields.io/badge/-admin-red) | Admin role required - or, where the section says so, the delegated administration permission it names (`admin.users`, `admin.monitor`, `admin.audit`, …; [PERMISSIONS.md](PERMISSIONS.md#delegated-administration)). When an API token calls, the token must also carry the `admin` scope and no `root:` confinement (since v0.41.0): an administrator's token without it gets `403 token missing scope: admin` | | ![signed](https://img.shields.io/badge/-signed-yellow) | A session/token **or** a signed URL - see the route | | ![session](https://img.shields.io/badge/-admin%20session-darkred) | An administrator **signed in to the panel**: an API key is refused `403 session_required`, whatever its scopes ([Admin: plugin requests](#admin-plugin-requests), [Admin: encryption policy](#admin-encryption-policy)) | | ![watch key](https://img.shields.io/badge/-filex--watch%20key-orange) | A filex-watch key for the storage the path names, and no other credential ([FILEX-WATCH.md](FILEX-WATCH.md)) | Auth is provided either by a session cookie (`filex_session`) or a Bearer token (`Authorization: Bearer `). Both are accepted on the same routes. An API token (`X-Filex-Token: ` or `Authorization: Bearer `) is accepted there too, limited by its verbs - `read`, `write`, `delete` - on every route: see [RBAC.md → API tokens](RBAC.md#api-tokens-verbs-on-every-surface). Every caller is also held to the account's permissions ([PERMISSIONS.md](PERMISSIONS.md)); a refusal from that layer is `403 {"error": "permission_denied", "permission", "source", "message"}`. A refusal answers `{"error": "", "message": "", "params"?}`: `error` is a stable code to branch on, `message` the sentence to show, written by the server in the reader's language, and a failed queue operation carries `error_code` + `error_text` the same way. The codes, the language rules and what changed in 0.54: [API-ERRORS.md](API-ERRORS.md). A change (any method but `GET`, `HEAD`, `OPTIONS`, and a WebSocket upgrade) that a browser sends from another origin with only the session cookie is refused before any route runs: `403 {"error": "cross_origin_refused", "message"}`. A Bearer or `X-Filex-Token` header, and a client that is not a browser, are not affected; another origin is trusted only when it is in `FILEX_CORS_ALLOWED_ORIGINS` ([CONFIGURATION.md → Requests from other origins](CONFIGURATION.md#requests-from-other-origins)). --- ## Auth & sessions ### `POST /api/auth/login` ![public](https://img.shields.io/badge/-public-lightgrey) Password login. Every running password provider is asked in turn: the local accounts, and LDAP, Windows and Linux (PAM) when they are switched on (Admin → Identity providers). `email` is what the person typed - an e-mail address or a username, and with a Windows provider also `.\alex` or `CORP\alex`. **Request** ```json { "email": "admin@local", "password": "" } ``` **Response 200** ```json { "user": { "id": 1, "email": "admin@local", "username": "admin", "role": "admin" }, "token": "eyJhbGc..." } ``` The session cookie is set by the same response. The Bearer token is for SPA embeds that prefer header auth. **The bot check.** While [bot protection](BOT-PROTECTION.md) is on for `login` (or `demo_login`, a demo's documented account), the body carries the widget's answer, `captcha_token` - or, during an outage, the local solution `captcha_local` - and it is judged before the password is compared: **422** `captcha_required` / `captcha_failed` / `captcha_expired` / `captcha_fallback_inactive` (`captcha` names the reason; the attempt is not counted against the account), **409** `captcha_fallback` with a `challenge`. A token is spent by the attempt, whatever the password turns out to be. **The account's language.** An account that holds no language yet is given the one its sign-in arrived in: the request's `Accept-Language` (the web panel sends the language on its screen; a browser back from an identity provider sends its own list), when filex offers it - a shipped language or one a running language pack adds. The answer's `user.locale` already carries it. An account that has a language keeps it, and a language filex does not offer leaves the account without one (it reads the instance's, `FILEX_DEFAULT_LOCALE`, until somebody picks). The same happens at the realm handoff (`POST /api/auth/handoff`) and at the end of an OIDC sign-in; no client writes a language after a sign-in. **Status codes:** `200` ok · `401` invalid creds · `403` the account is disabled (`"disabled": true`), or its tenant may not sign in now - suspended, or maintenance mode (`"maintenance": true`) · `403 cross_origin_refused` the form was posted from another origin (sign-in forgery; a script that sends no `Origin`, `Sec-Fetch-Site` or `Referer` is not affected) · `429` too many failed attempts (see below) · `503` busy (see below). **Response 401** - the machine-readable `error` is unchanged; what a person needs is added beside it, worded in their language (`Accept-Language`): ```json { "error": "invalid credentials", "message": "Wrong credentials. 3 attempts left; the account is locked at failed attempt 5.", "remaining": 3, "limit": 5, "scope": "account" } ``` `scope` is `account` or `ip` - whichever counter has fewer tries left. The body is the same for an account that exists and one that does not (the counter is keyed by the identifier typed). On a public demo the shared account has no account counter, so its replies are always `scope: "ip"` - the address's tries - and never promise an account lock ([CONFIGURATION.md → Demo mode](CONFIGURATION.md#demo-mode)). A wrong second-factor code answers the same way with `"error": "invalid two-factor code", "totp_required": true`; a *missing* code does not count. See [Sign-in attempt limits](CONFIGURATION.md#sign-in-attempt-limits). **Response 429** - the attempt that trips the limit, and every attempt while the lock lasts (even with the right password), with a `Retry-After` header: ```json { "error": "too many attempts", "message": "Too many failed attempts: this account is locked. Try again in 1 minute.", "countdown": "Too many failed attempts: this account is locked. Try again in {wait}.", "locked": true, "scope": "account", "retry_after": 60 } ``` `countdown` (since 0.54) is the same sentence with `{wait}` left open: a sign-in form that counts the lock down replaces `{wait}` with its own clock (`4:05`) instead of keeping a copy of the words. **Response 503** `{"error": "busy", "message": "…"}` with `Retry-After: 3` - the sign-in provider is at its limit of simultaneous sign-ins (a Linux PAM provider's `max_concurrent`). It is not a judgement of the password and is not counted as a wrong attempt. **`realm`** (multi-tenant installs only; a single-tenant install ignores it) - the tenant realm the person typed in the form's Realm field ([MULTI-TENANCY.md → Realms](MULTI-TENANCY.md#realms-which-tenant-a-sign-in-is-for)): ```json { "email": "alex", "password": "…", "realm": "acme" } ``` Empty (or absent) is the tenant the request's host names, else the platform's own. The account lookup never leaves the tenant, and the account signed in to is checked against it once more. A realm nobody has, and a realm that is not the host's tenant, answer exactly like a wrong password (`401`, the same body, counted). The attempt limit counts `/`. **Response 200, handed to the tenant's address** - when the realm names a tenant that has a `host` of its own and the request came in on another host, no session is opened here (no cookie, no token): ```json { "handoff": { "origin": "https://files.acme.example", "code": "Zk…" } } ``` The client continues on the tenant's address, on the same sign-in page it started from (`/drive/login` or `/admin/login`, with the same query), with the code in the URL fragment (`/drive/login#handoff=`); that page redeems it with [`POST /api/auth/handoff`](#post-apiauthhandoff-). The code is bound to the one account that just signed in, lives 60 seconds, is spent by its first use and is honoured on the tenant's host only. ### `POST /api/auth/handoff` ![public](https://img.shields.io/badge/-public-lightgrey) The tenant's-address half of a sign-in typed on the platform's address (multi-tenant). **Body** `{"code": "…"}` - the code a `POST /api/auth/login` answered with. **Response 200** - as for a sign-in: `{"user", "token"}` and the session cookie, set on this host. **Response 401** `{"error": "invalid or expired handoff"}` - the code is unknown, spent, expired, issued for another purpose, presented on another host than the tenant's (it is spent all the same), or its account may no longer sign in or is no longer this host's tenant's. The same answer for every reason; the reason is in the audit row `auth.handoff_refused` (`reason`: `expired`, `wrong_host`, `wrong_purpose`, `wrong_tenant`, `actor_gone`, `subject_gone`, `actor_is_not_subject`), beside `auth.handoff_issued` and `auth.handoff_used` (`purpose`, `actor_id`, `subject_id`, `host`). The code is never logged or audited; an unknown or spent one is not audited at all. `404` on an install where the handoff does not exist (single-tenant). ### `POST /api/auth/logout` ![public](https://img.shields.io/badge/-public-lightgrey) Deletes the session and clears the cookie (with no session it only clears the cookie). **Body** (optional): `{"return_to": "/admin/login" | "/drive/login"}` - the sign-in page of the front door the person was using. Anything else means `/admin/login`; the address is never taken from the request. **Response 200**: `{"ok": true}`, plus `"logout_url"` for an SSO session whose identity provider can end sessions - its end-session URL with `id_token_hint`, `client_id` and `post_logout_redirect_uri` (`?signed_out=1`). The web app sends the browser there so the provider's session ends too (RP-initiated logout, [SSO.md](SSO.md#signing-out)). Never present with `FILEX_OIDC_LOGOUT=local`, for a password session, or for a session signed in through a provider that has since been replaced. ### `GET /api/auth/oidc/start` ![public](https://img.shields.io/badge/-public-lightgrey) Redirects (302) the browser to the configured OIDC issuer authorise URL. **Query**: `?next=/path/to/return/to` (optional) ### `GET /api/auth/oidc/callback` ![public](https://img.shields.io/badge/-public-lightgrey) OIDC redirect target. Validates `code`, exchanges for tokens, creates/updates the user, sets the session cookie, redirects to `next`. ### `GET /api/auth/me` ![user](https://img.shields.io/badge/-user-blue) **Response 200** ```json { "user": { "id": 5, "email": "ayse@example.com", "username": "ayse", "role": "user", "avatar_url": "data:image/jpeg;base64,…" }, "permissions": ["files.download", "files.create", "files.modify", "share.links", "…"], "permissions_in_folders": ["files.delete"], "permissions_by_folder": ["files.delete"], "permission_settings": { "share_link_max_days": 7 }, "two_factor_required": false, "realm": "acme" } ``` What the account may do ([PERMISSIONS.md](PERMISSIONS.md)), so a client can hide what would only answer `403`: `permissions` holds account-wide, `permissions_in_folders` only in some folders, and `permissions_by_folder` lists every permission whose answer differs from folder to folder - a client asks `POST /api/files/manager?action=allowed` about the selected paths before offering those. The explorer reads this itself when its host passes no `permissions` ([INTEGRATION.md](INTEGRATION.md)). The effective answer with the source of each permission is `GET /api/auth/me/permissions`. `realm` (multi-tenant installs, a tenant's account only) is the account's tenant realm - what it writes in front of its name where no address names its tenant (`acme/alex` over SFTP). Absent on a single-tenant install and for the platform's own accounts. `GET /api/auth/ssh-keys` answers the SFTP/FTPS `login` already written that way. ### `PATCH /api/auth/profile` ![user](https://img.shields.io/badge/-user-blue) Patches the caller's own `email`, `display_name`, `locale`, `timezone` and `avatar_url`. Absent fields are left alone. Needs the `account.edit` permission; an API token needs `write` too - as do `POST /api/auth/password` and `POST /api/auth/totp/enroll`, `…/verify` and `…/disable`: a token that may only read cannot change the account it belongs to (`403 token missing scope: write`). `POST /api/auth/password` (`{current_password, new_password}`) takes a new password of at least **8 characters** (counted as characters, not bytes). The same rule, in the same words, holds wherever an administrator sets one (`POST` and `PATCH /api/admin/users`): a shorter password is `400 {"error":"password_too_short","field":"password","message":…}`, the sentence in the reader's language naming the number. The rule lives in one place on the server (`internal/auth/drivers/local/password_rule.go`); until 0.54.0 only the person's own change asked it. `avatar_url` is the **profile picture**: a `data:image/…` URI (≤ 48 KB) or an `http(s)` / site-relative URL; an explicit `""` removes it. Anything else is a `400` rather than a silent drop - the person is looking at an upload they believe worked. The SPA's user-settings dialog downscales what you pick to 160px before encoding, so the cap is not something a user meets. The picture belongs to the **account**, which is what makes it appear everywhere: the explorer's collaboration strip draws it instead of initials for that user on every client of the account - browser session, desktop app, and any API key minted under it. Two deliberate exceptions, because the alternative is drawing the wrong face on somebody's row: - A token with a **username allow-list** is a shared proxy, not a person (its presence entry reads "work"), so no picture is attached. - When a trusted host proxy re-identifies a connection as a different end user via `X-Filex-Presence-Name`, only *that* person's picture may be drawn - supplied by the proxy as `X-Filex-Presence-Avatar` (same accepted shapes, same cap). Without it the row falls back to initials. "A trusted host proxy" is a token with a username allow-list: the `X-Filex-Presence-*` headers (`-Name`, `-Avatar`, `-Key`) are read from such a token only. A personal token (the desktop app, a CLI, a script) is its owner, and its headers are not read (since 0.54). The cap is small on purpose: the avatar rides inside every presence frame the collaboration socket broadcasts, so it is paid for again on each join, leave and focus change - unlike the branding logo, which is fetched once per page. ### `POST /api/auth/account/check` ![user](https://img.shields.io/badge/-user-blue) Would this e-mail address or username be accepted? Asked while a form is typed (the profile, an administrator adding a user) and answered by the same rules and in the same words a save refuses with. It writes nothing; an API token with `read` may ask. Since 0.54. ```json { "email": "a,b@x", "username": "9lives", "for": "self" } ``` Only the fields sent are checked. `for` is whose account the values are for: `self` (the default; the address and the name the account already holds are no change) or `new` (an account an administrator is about to create). **Response 200** - a field is present only when it would be refused, with the save's code and the sentence in the reader's language: ```json { "email": { "error": "email_invalid", "message": "“a,b@x” is not an email address. Write it as name@example.com." }, "username": { "error": "username_invalid", "message": "A username cannot start with a digit." } } ``` Whether an address or a name is **taken** (`email_taken`, `username_taken`) is said only where the save would say it: about your own account when you may edit it (`account.edit`), and to a full administrator about a new one. Anybody else is told about the format only. --- ## Capabilities ### `GET /api/capabilities` ![public](https://img.shields.io/badge/-public-lightgrey) Tells the frontend what features are available - used to hide buttons for disabled features. **Response 200** (abridged - the real body also carries the per-storage probe, build metadata and a set of flat aliases kept for older embeds) ```json { "version": "0.34.0", "upload": true, "move": true, "copy": true, "delete": true, "mkdir": true, "search": true, "versions": true, "ocr": false, "thumbs": { "enabled": true, "image": true, "video": true, "pdf": true, "office": false }, "antivirus": true, "antivirus_mode": "daemon", "external": { "onlyoffice": { "enabled": true, "url": "https://docs.example.com", "state": "ok" }, "drawio": { "enabled": false, "url": "", "state": "" } }, "onlyoffice_url": "https://docs.example.com", "drawio_url": "", "max_upload_size": 5368709120, "chunk_size": 8388608, "auth_drivers": ["local", "oidc"], "storage_drivers": ["ftp", "local", "s3", "sftp", "smb", "webdav"], "db_driver": "sqlite", "share_max_ttl_days": 7, "share_link_max_days": 3 } ``` Cached client-side for 1h. `share_max_ttl_days` is the longest life a new share link may be given on this install (0 = no ceiling; [PROTECTION.md](PROTECTION.md)). `share_link_max_days` is the CALLER's: that ceiling or the **Maximum share-link lifetime** of the permission rules binding them, whichever is shorter (0 = no ceiling) - what the share dialog derives its expiry choices from ([SHARING.md](SHARING.md)). #### Rules the server publishes Since 0.54, some answers are rules the clients used to keep copies of; the server publishes them and every client (the explorer, the admin panel, the desktop app, an embed) reads them instead (#211): - `release`, `commit`, `built` - the release (`v0.54.0`), the commit and the build time, apart. `version` keeps the one-line form (`v0.54.0 (, )`); nothing needs to parse it. `commit` and `built` are absent when the build did not stamp them. - `edit_kinds` - how each kind of file is edited, from `internal/editkind`: `office` (extensions the document server opens and edits), `text` (extensions the built-in editor opens and `POST /api/files/save-text` saves), `text_names` (whole names that are text, lower case: `makefile`, `.gitignore`), and `text_mime_prefixes` / `text_mimes` (a file whose name says nothing is text when its catalogued mime is). The two rules never claim one file: `.txt`, `.csv`, `.html` and `.xml` are text, and `.pdf`, `.xps` and `.epub`, which the document server only displays, are neither. - `limits` - the numbers an input is held to before it is sent, each enforced by the server again: `tag_max_runes` (64), `comment_max_runes` (5000), `e2e_request_reason_max_runes` (2000, the rest is cut), `app_state_max_bytes` (16384 - what one app's interface keeps for a person; `PUT /api/me/prefs` answers `413 APP_STATE_TOO_LARGE` above it) and `app_ui_save_chunk_bytes` (8 MiB). Characters are Unicode code points, not UTF-16 units. - `event_off` - for a signed-in caller, the notification events that cannot happen on this instance: `{ "": { "reason", "fixable", "text" } }`. `reason` is what it waits for (`antivirus`, `escrow`, `app_plugins`, `e2e_approval`), `fixable` whether THIS caller could switch that on (the instance's administrator for a service, the tenant's own administrator for the encryption policy) and `text` the sentence, in the reader's language. An event absent from the map can happen. The settings dialog greys a `fixable` one with the sentence and leaves the rest out; Admin → Webhooks prints the sentence beside the box. A rule a client applies while a person types or picks (a theme's key, the accent colour, a ZIP password, which app action a selection may run, which form field shows) cannot travel as a value: its cases are kept in `backend/internal/api/handlers/testdata/rule-mirrors.json`, which the Go tests and the client tests both check their side against. `realm` is present on a **multi-tenant** install only - the sign-in form's Realm field ([MULTI-TENANCY.md → Realms](MULTI-TENANCY.md#realms-which-tenant-a-sign-in-is-for)): `{"enabled": true, "locked_realm": null}` on the platform's address (the field is empty and free), `{"enabled": true, "locked_realm": "acme"}` on a tenant's own address (filled in and read-only). A single-tenant answer carries no `realm` key at all. ⚠⚠ **An anonymous caller is told _whether_ a capability is on, never _where_ it lives.** The endpoint is deliberately public - an embedder probes it before anybody logs in ([INTEGRATION.md](INTEGRATION.md)) - so for a request carrying no usable credential the `url` is dropped from every `external.` entry and the flat `onlyoffice_url` / `drawio_url` aliases come back empty. `enabled` and `state` are unchanged, which is what a feature probe actually asks. Signed-in callers see the payload above in full, because one consumer needs a real host in the browser: the draw.io iframe. OnlyOffice does not - the browser gets its document-server URL from the authenticated `POST /api/files/onlyoffice/config` (`documentServerUrl`, and `frame` - the page on `FILEX_ONLYOFFICE_FRAME_ORIGIN`, else on `FILEX_APP_UI_ORIGIN`, the editor runs in, when there is one) - so that host now travels only with a credential as well. Measured before this changed (2026-09-07, demo.filex.sh): an unauthenticated `GET /api/files/capabilities` answered 200 with `"url": "https://docs.example.com"` - the operator's internal document server, published by every install that configured one. `newdoc_types` is the other field worth naming, because it decides what a "New document" menu may offer: the document types **this build can create**, from a template registry compiled into the binary (`internal/newdoc`). Each row is `{ ext, group, mime, requires, ext_required }`, where `requires` names the external service the *editor* needs (`"onlyoffice"`, `"drawio"`, or absent for the built-in code and markdown editors), and `ext_required` says whether the file must carry the extension - `true` for office documents and diagrams, whose editors find them by it, `false` for text types, which a person may name anything (`LICENSE`, `test.conf`; #56). It is published as `false`, never omitted: its absence is how a client recognises a server from before #56. ```json "newdoc_types": [ { "ext": "docx", "group": "document", "mime": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "requires": "onlyoffice", "ext_required": true }, { "ext": "md", "group": "text", "mime": "text/markdown; charset=utf-8", "ext_required": false } ] ``` ⚠ It answers "can the SERVER make these bytes", not "can this deployment open them". A client crosses `requires` against the `external` block above and offers only what is satisfied, which is what stops an install with no document server from offering a `.docx` nobody there can then open - and what keeps the client from carrying a hardcoded extension list that rots the moment the registry grows a type. Published to anonymous callers too: it is a static property of the build, identical on every install of this version, and names no host. A signed-in person also gets the rows **running apps** add to the menu (`new_documents`, [APP-PLUGINS-API.md](APP-PLUGINS-API.md#new-documents)): `group` and `requires` are `"app"`, `key` is what the create call sends (`app::`), and `app` names the app, the view that opens the new file and the app's own label. They are not a property of the build, so an anonymous caller or an app token is never told them. A signed-in caller also gets `e2e_policy: { "available": true, "policy": "permitted" }` - the caller's own tenant's row of [who may encrypt](E2E-ENCRYPTION.md#who-may-encrypt): `available` is the platform operator's switch for the tenant, `policy` the tenant's choice (`off` · `admins` · `permitted` · `approval`). Not an anonymous caller's (a tenant's policy is not a property of the build), and absent on a server from before the policy, which the explorer reads as "offer encryption as before". The explorer's menus are not read off this row - the platform operator, looking at another tenant's storage, is answered by that tenant's policy - but asked per path (`POST /api/files/e2e/allowed`, [Encryption policy](#encryption-policy)); the row tells the notification settings and the Webhooks screen when the two request events can happen. The server decides every write. `e2e_vault` is always present: `true` only on a server that serves the [vault API](#vault-encryption-level-3) (`FILEX_E2E_VAULT`). A client offers the third encryption level only there. ⚠ `antivirus` means **configured**, not answering: the setting is on and either a scanner binary resolved or a clamd address is set. Reachability costs a network round trip and is probed on `GET /api/admin/protection`, where an operator is waiting for the answer. `antivirus_mode` is `binary` or `daemon` and is absent when `antivirus` is false - the two deployments produce the same green light and behave very differently when one of them breaks. --- ## File browsing ### `GET /api/files/manager` ![user](https://img.shields.io/badge/-user-blue) List the contents of a directory (`action=index`, the default). The same route reads a file (`action=preview`, `action=download`, both honour `Range`), lists only the subfolders (`action=subfolders`) and asks what changed (`action=changes`, below). **Query** | Param | Notes | |--------|-------| | `action` | `index` (default) \| `subfolders` \| `changes` \| `search` \| `preview` \| `download` | | `path` | `://`, e.g. `docs://reports/2026`; empty = the first storage's root | | `sort` | `index` / `subfolders`: `name` (default), `modified`, `size` or `type`; a leading `-` reverses it. Folders always come first. An unknown key answers `400 bad_sort`. | | `filter` | `search`: the query. `search` also takes the [narrowing parameters](SEARCH.md#narrowing-a-search) (`type`, `min_size`, `modified_after`, `under`, `owner`, `hidden`…). | **Response 200** ```json { "adapter": "docs", "storages": ["docs", "archive"], "dirname": "docs://reports", "read_only": false, "perm": "editor", "files": [ { "id": 4711, "path": "docs://reports/report.pdf", "basename": "report.pdf", "type": "file", "extension": "pdf", "size": 102400, "mime_type": "application/pdf", "last_modified": 1776852000000, "storage": "docs", "perm": "editor", "etag": "abc123", "thumb_url": "/api/files/thumb/4711?exp=…&sig=…" }, { "id": 4712, "path": "docs://reports/photos", "basename": "photos", "type": "dir", "size": 0, "storage": "docs" } ] } ``` `last_modified` is Unix milliseconds. `id` is the catalogue id (versions, tags and comments are addressed by it); a folder listed straight from the storage before a scan catalogued it carries rows without one. **One order.** The rows come back folders first, then by name (case and the four Latin i's folded, numbers as numbers: `Disk 2` before `Disk 10`), whichever path built the answer - the catalogue, the storage itself, or both merged - and `file_list` (MCP) answers in the same order (`internal/listorder`). Before 0.54 the catalogue path answered files first and the others folders first. **`starred`** is `true` on the rows the caller has starred (absent otherwise), and **`kind`** says what a row is (`folder`, `document`, `spreadsheet`, `presentation`, `pdf`, `image`, `video`, `audio`, `archive`, `code`, `text`, `other`) - the server's one rule, which the explorer's Type chip and a search's `type=` both use. Inside an end-to-end encrypted folder the answer also carries `e2e` and `e2e_root`, and an encrypted folder's own row `e2e: true`. A vault (encryption level 3, [E2E-VAULT-FORMAT.md](E2E-VAULT-FORMAT.md)) is marked as one: its row carries `e2e_vault: true`, and a listing of the vault folder or of anything in it carries `e2e_vault_root` (the vault folder's wire path) - what is listed there is the vault's layout on the storage (`v/`, packs, index files), which clients never show as folders. Every **file** row inside an encrypted folder says how it is encrypted - `encrypted: "folder"`, or `"vault"` inside a vault; a single encrypted file (`.fxe`) elsewhere says `"file"` - and so does each file row of the search answer, Recent, Starred, a tag view, Shared with me and `/api/files/stat`; a folder row and a plain file never carry it (one rule, `encryptedKind` in `internal/api/handlers/e2e_rows.go`). An app's interface is handed it as `FileInfo.encrypted`, and an app is refused such a file's bytes ([APP-PLUGINS-API.md](APP-PLUGINS-API.md#which-rows-say-encrypted)). **Status codes:** `200` ok · `403` forbidden · `404` path missing. ### `GET /api/files/pdf/{name}` ![user](https://img.shields.io/badge/-user-blue) A PDF's preview, on a path of its own (0.57.4, GitHub #124). It answers what `GET /api/files/manager?action=preview` answers for a PDF: the same storages, the token's root, the drafts and traversal guards, and `Range`. It exists for the built-in viewer. filex's own pages name the frames they may show of filex by path (`frame-src`, [CONFIGURATION.md](CONFIGURATION.md#security-headers-and-framing)), and Chrome checks a PDF in an `` against `frame-src`. The manager's preview URL is not on that list, so Chrome showed the viewer's fallback instead of the document. This path is on it, and it answers only a PDF's preview, so a page that may frame it can frame nothing else of the API. | Param | Notes | |-------|-------| | `{name}` | The file's name, for the browser's viewer only (its title and the name it saves under). | | `path` | The PDF, `://`. A path that does not end in `.pdf` answers `400`. | The verb is always preview: an `action` or `q` sent here is ignored, so the path cannot be turned into a download. A server that has it says `pdf_preview: true` in `GET /api/files/capabilities`; the explorer uses it for PDFs then (and from the start in the web app), and the manager's preview URL otherwise. **Status codes:** `200` ok · `206` a range · `400` not a PDF, or a bad path · `403` forbidden · `404` path missing. ### Names filex keeps for itself `.filex-trash`, `.versions`, `.thumbs`, the desktop app's `.filex-open` (at any depth), the drafts area `.filex-drafts` and the empty-folder marker `.keepdir` are filex's own (`backend/internal/syspath`). They are never listed or searched, and every write that names one - creating, uploading, renaming, moving, copying, extracting, saving, sharing, granting or restoring - answers: ```json HTTP 403 { "error": "\".filex-trash\" is reserved for filex's own use", "code": "RESERVED_NAME", "name": ".filex-trash" } ``` An archive member under one of them is skipped, the way a zip-slip entry is. The single exception is the desktop's open-with round trip: `newfolder` of `.filex-open` at the storage root, `upload` of `.filex-open/-` (and the document editor's save of that copy), and `delete` of it. A draft is the other door, and it opens for one person only: the file at `.filex-drafts///` can be read (`preview`, `download`, `info`) and saved into (`save-text`, the document server's callback, an app editor's save) by the account whose id is in its path - nobody else, an administrator included, gets anything but `404`/`403`. Everything else about a draft goes through [the drafts endpoints](#drafts). ### `GET /api/files/manager?action=changes` ![user](https://img.shields.io/badge/-user-blue) Has anything under a folder changed since the caller last asked? | Param | Notes | |---------|-------| | `path` | `://` (a file path works too) | | `since` | the `cursor` from the previous answer; empty = never asked | **Response 200** `{ "cursor": "", "changed": true }` Answered from an in-memory change log fed by the same event chain that refreshes open explorers and folder sizes - every write surface (explorer, WebDAV, S3, SFTP, FTPS, NFS, trash and version restores, the ops queue). Every doubt reads as `changed`: no `since`, a cursor from before a restart, one older than the log still holds (4,096 changes per storage), an event on the way to the folder that does not say what it touched. Changes the catalogue learns from a **storage scan** (bytes written straight into the backend) do not pass through the log, which is why sync clients keep a slower full walk as a safety net. `403` when the caller cannot see the folder; `400` on a `..` segment; `501` on servers without the log. ### `GET /api/files/manager?action=space` ![user](https://img.shields.io/badge/-user-blue) How much room is there at a path? What `filex mount` shows as the drive's size. | Param | Notes | |--------|-------| | `path` | `://`; empty = the storage list, the caller's storages added up | **Response 200** `{ "total": 4000787030016, "used": 1203982176256, "free": 2593416052736, "known": true }` (bytes) The same figures SFTP, FTPS, NFS and WebDAV give ([PROTOCOLS.md → Free space](PROTOCOLS.md#free-space)): the storage's own disk, share or server, capped by the caller's quota. `known: false` means nothing could be measured and there is no quota; the client picks its own stand-in. A storage the caller cannot see answers `404` like its listing. An older server answers `501`. ### Filenames in `Content-Disposition` Every endpoint that serves bytes (`action=download` / `preview`, share downloads, the share browser, the viewer) sends RFC 6266: ``` Content-Disposition: attachment; filename="T_rk_e adl_ dosya.txt"; filename*=UTF-8''T%C3%BCrk%C3%A7e%20adl%C4%B1%20dosya.txt ``` ⚠ The header is **always pure ASCII**. A raw non-ASCII byte in a header value is outside the specification, and while browsers cope, strict clients throw while parsing the response - Electron's `net.fetch` raises `Cannot convert argument to a ByteString …` from inside its response handler, which no caller's try/catch can catch. The ASCII `filename` is the fallback; `filename*` carries the real name and is what every current browser uses. ### `GET /api/files/read` ![user](https://img.shields.io/badge/-user-blue) Stream one file's bytes, addressed by its catalogue id or by storage and path: | Query | Notes | |-------|-------| | `id` | the node id (`entries[].id` of a listing) - preferred | | `storage` + `path` | the storage's numeric id and the path inside it, when there is no id | | `download=1` | `attachment` instead of `inline`; needs the `files.download` permission as well | Sends `Content-Type`, `Content-Length` and an RFC 6266 `Content-Disposition`. Needs viewer on the file. It does **not** honour `Range:`; for seeking in video and audio, or resuming a download, use `GET /api/files/manager?action=preview` or `action=download` with `path=://`, which honour it (`206`) on every storage that can read a range. A file in filex's own trash, version or thumbnail folders is the `404` a missing one gets. (A `GET /api/files/raw` this page once described was never served.) ### `POST /api/files/move` ![user](https://img.shields.io/badge/-user-blue) **Request** - sources and the destination FOLDER, both adapter-qualified. ⚠ `sourceDir` is **accepted and ignored** by the server. It decodes into the request struct and no handler reads it; the undo it was said to "stamp" is built entirely in the client. Root-confined callers do have it rewritten by the confine layer, so it is not free to lie in - but nothing depends on it either. Send it or don't. ```json { "source": ["alpha://a.txt", "alpha://klasor"], "target": "beta://hedef", "sourceDir": "alpha://" } ``` **Response 202** `{ "op": { "id": 12, "kind": "move", "storage_id": 1, "dest_storage_id": 2, … } }` - the work is queued; poll `GET /api/files/ops`. **One source under a new name.** With exactly one `source`, an optional `name` is the name it gets inside `target`: ```json { "source": ["alpha://a.txt"], "target": "beta://hedef", "name": "b.txt" } ``` The move (or copy) and the rename are one step of the queue, so nothing can stop between them. `name` is one path segment: not `.` or `..`, no `/`, `\` or NUL, at most 255 bytes. With more than one source, without `target`, or on `/api/files/delete` it is `400`. A `name` already taken in `target` is treated like any taken destination (below): the item lands as `name-copy` - except in a folder whose names are end-to-end encrypted, where the server cannot make up a name and the operation fails instead ([E2E-ENCRYPTION.md → Folder ids](E2E-ENCRYPTION.md#folder-ids): the explorer uses `name` to re-seal a name for the folder it moves into). **Nothing is moved on top of something.** When the destination folder already holds the name, the moved item lands beside it as `name-copy`, `name-copy-2`, … - within one storage exactly as between two. A move into the folder the item is already in changes nothing. (Before 0.41.0 a same-storage move replaced the file that held the name.) A **rename** onto a taken name is refused instead - see `POST /api/files/manager?action=rename` below. ### `POST /api/files/copy` ![user](https://img.shields.io/badge/-user-blue) Same shape, `name` included, same queued answer. **The two ends may live in different storages.** `dest_storage_id` on the queued op is the target's storage; when it differs from `storage_id`, the worker streams the bytes between the two drivers instead of asking one driver to rename - a whole tree, empty folders included, each file's mtime preserved where the target can hold one, and every file stat-checked on the far side before a move deletes anything. A cross-storage move removes the source outright (not to the trash); a name already taken becomes `name-copy`. A symlink the source cannot follow is left behind rather than read, a folder link back into the tree is walked once, and either ends the op **`partial`** with the skipped entries named in `error` - a **move** then keeps its source. Full behaviour: [Moving files between storages](STORAGE.md#moving-files-between-storages). **Refusals** are at submit time, not in the worker: `400` unknown target adapter · `403 { "code": "READ_ONLY" }` when the op would change a read-only storage - a copy or a move into it (with a `hint`), a move or a delete out of it; a copy out of it is fine · `403` no editor permission on the source, or on the target folder **in the destination's storage** · `400` mixed-adapter *sources* (one batch, one source storage). A caller confined to a folder (a `root:` token, or `X-Filex-Root`) is answered `403 {"error":"path outside confined root"}` for a `source`, a `target` or a `sourceDir` outside it, before anything else is asked: the same answer, byte for byte, whatever the body's `Content-Type` (JSON, `text/plain`, none) and whether the storage, the folder or the file exists or is read-only. Up to 0.51.0 a body not labelled JSON reached the checks above as written, and an unknown storage answered `400`, a read-only one `READ_ONLY` with its name. ⚠ Before v0.27.0 the destination's `://` prefix was dropped and the remaining relative path applied to the SOURCE storage, so a cross-storage paste answered `202` and wrote the file into the depo it was copied from. ### `POST /api/files/ops` ![user](https://img.shields.io/badge/-user-blue) The unified form behind the three per-verb endpoints: ```json { "kind": "copy", "storage_id": 1, "dest_storage_id": 2, "sources": ["a.txt"], "dest": "hedef/" } ``` `dest_storage_id` may be omitted or `0`, which means "the sources' storage". The paths are relative to their storage: a source or a `dest` with `://` in it, or with a `..` segment (`..\` too), is refused with `400 { "code": "BAD_PATH" }`. Up to v0.50.0 such a path was judged without its `://` prefix and carried out with it, so `"dest": "b/c://a/"` was checked as `a` and written to `b/c:/a/`. `kind` is `copy`, `move` or `delete`; any other kind is refused with `400 { "code": "BAD_KIND" }`. The queue runs more kinds than these (a rename, a restore, a purge, an upload commit, an app's action), but each of them is queued by the endpoint that judges it (`?action=rename&queued=1`, `POST /api/files/manager/restore?queued=1`, …), never through this one. The same refusals as the per-verb endpoints apply, `READ_ONLY` included (up to v0.46.0 this endpoint asked neither end of the op about the read-only flag, and the per-verb ones only the destination). A caller confined to a folder (a `root:` token, or `X-Filex-Root`) is held to it here as on every other door: a source or a destination outside the root - another storage included, or a `storage_id` that does not exist - answers `403 {"error":"path outside confined root"}`, before `BAD_PATH` and the read-only check, the same whatever the body's `Content-Type`. Up to v0.46.0 this endpoint was the one that did not: the confinement layer rewrites the path fields it knows (`source`, `target`, …), and this body names its paths `sources` and `dest` beside a bare `storage_id`. Up to 0.51.0 a body not labelled JSON was answered `400 BAD_PATH` for a path climbing out, and the root's refusal named the path. ⚠ Up to v0.46.0 this endpoint passed the client's `kind` straight to the queue (an upload commit or an app's action could be queued here unjudged), and once the queue also ran restores, `{"kind":"restore","sources":[""]}` would have restored any trash entry by id - another tenant's included - without the restore's own checks. ### `POST /api/files/manager?action=newfolder` ![user](https://img.shields.io/badge/-user-blue) ```json { "path": "alpha://reports", "name": "2026" } ``` Creates the folder `name` inside `path` and answers with the re-rendered listing of `path`, like every other action of this route. `name` is one segment: empty or containing `/` or `\` is `400 bad folder name`. Needs `files.create` (editor) in `path`; a token needs `write`. `403` on a read-only storage, `409` when a file already has that name, `501` when the storage cannot make folders. (A `POST /api/files/mkdir` this page once described was never served.) ### `POST /api/files/manager?action=rename` ![user](https://img.shields.io/badge/-user-blue) ```json { "path": "alpha://reports", "item": "alpha://reports/old.txt", "name": "new.txt" } ``` Renames one item inside its own folder and answers with the re-rendered listing. `name` is a leaf: empty, `/`, `\`, `.` and `..` are refused with `400`. **A rename never replaces what already has the name.** When a file or a folder already holds it - or the listing still shows a file there whose bytes have gone missing - the answer is `409 { "code": "NAME_TAKEN", "name": "new.txt" }` and nothing moves. When the backend cannot say whether the name is free, it is `503 { "code": "EXISTS_CHECK_FAILED" }`, never a rename on the chance. A rename that only changes the case (`a.txt` → `A.txt`) is allowed, also on a case-insensitive disk. Unlike a move, a rename is not given a `-copy` name: the person chose this one. ⚠ Before this, the rename replaced the file that had the name - not into the trash - and its catalogue row was dropped with its version history, shares and comments. A folder renamed onto another folder's name on an object store was merged into it. **Finished even if the client leaves.** Once a rename has passed its checks it runs to the end, whether or not anybody is still waiting for the answer. A closed tab, or a proxy that stops waiting (nginx after 60 s by default), no longer stops it half-way. The same holds for the synchronous `?action=move` and `?action=delete`, `POST /api/files/manager/restore`, `DELETE /api/admin/trash/{id}`, `POST /api/ai/move` and `/api/ai/delete` (with the MCP tools behind them), and WebDAV's `DELETE` and `MOVE` of a folder. A client that gave up lists the folder again to see the result. ⚠ Before this, the request's cancellation stopped the work between two objects. A folder on an object store, which is changed one object at a time, was left in two places with the catalogue still describing the old one, and a retry answered `409` because the half that had arrived held the name. **As a job: `?action=rename&queued=1`.** The same body and the same checks, so a refusal is still answered at once, as above. What they allow is queued instead of run: **202** `{ "op": { "kind": "rename", "sources": ["reports/old"], "dest": "reports/new", … } }`, followed with `GET /api/files/ops` like a move. The explorer asks for it for a folder, which on an object store is one request per object, when the server lists `rename` under `capabilities.queued`; a file is still renamed inside the request. The job never picks another name the way a move does: a name taken by the time it runs fails it (`something with that name already exists here`), and nothing is replaced. Once running it is not cancelled half-way; while it waits in the queue it can be. A server with no queue renames inside the request, as above. Queued renames, restores and purges run **one at a time on a lane of their own**, beside the worker that runs copies, moves, deletes and upload commits: a large folder renamed, restored or purged on an object store no longer holds every other queued operation of the instance behind it. A server that stops while one runs does not record it as `cancelled`: the entry in hand is finished (a rename that completes is `ok`), and a restore or purge of several entries stops between two of them and stays `running`; the next start requeues it and carries it on. A rename the process died in the middle of is carried on too: on an object store part of the folder is already at the new name, and that half is the rename's own, not a name taken by something else - the job finishes the move instead of failing with `something with that name already exists here` (which is what it did up to this release, leaving the folder in two places). Stopping waits for it at most ten seconds. Up to this release the job was recorded `cancelled` - terminal, so the entries it had not reached stayed in the trash - and stopping waited for the whole folder. ### `POST /api/files/delete` ![user](https://img.shields.io/badge/-user-blue) ```json { "source": ["alpha://a.txt", "alpha://klasor"] } ``` **Response 202** `{ "op": { "id": 13, "kind": "delete", … } }` - queued like a move; poll `GET /api/files/ops`. Every item goes to the trash. The items of one delete job are trashed several at a time (`FILEX_OPS_DELETE_WORKERS`, default 4 - [CONFIGURATION.md](CONFIGURATION.md)), and the job's `done`/`failed` counters are written about once a second while it runs. An item that lies inside another item of the same job is left to that one (it counts as done with it), so a folder goes to the trash whole. ### `GET /api/files/manager/shared-with-me` ![user](https://img.shields.io/badge/-user-blue) What other people have shared with the caller - the items they reach through a per-item grant rather than their own role. Newest grant first. | Param | Default | Meaning | |---|---|---| | `limit` | `100` | Page size, max 500. | | `offset` | `0` | Page offset. | | `sort` | `-when` | `-when` (newest grant first), `name`, `modified`, `size`; a leading `-` reverses. The whole set is ordered, then the page is cut. | ```json { "files": [ /* listing entries, same shape as /api/files/manager */ ], "storages": ["marketing"], "total": 2, "limit": 100, "offset": 0, "truncated": false, "sort": "-when" } ``` ### `GET /api/files/manager/recent` · `GET /api/files/manager/star/list` · `GET /api/files/manager/tagged` ![user](https://img.shields.io/badge/-user-blue) The person's own views: what they opened (Recent), what they starred, and what carries a tag (`?tag=`, `?kind=personal|team`). One page at a time, in the server's order, with how many there are in all: | Param | Default | Meaning | |---|---|---| | `limit` | Recent `20` (max 200), Starred `50` (max 500), a tag `500` (max 1000) | Page size. | | `offset` | `0` | Page offset. | | `sort` | Recent / Starred `-when` (the latest opening / star first), a tag `-modified` | `when` is the person's own time on the row; `name`, `modified`, `size`, `type` as on a listing. | ```json { "nodes": [ { "id": 4711, "name": "report.pdf", "storage": "docs", "opened_at": 1776852000000, "starred": true, "kind": "pdf" } ], "total": 812, "limit": 20, "offset": 0, "truncated": true, "sort": "-when" } ``` Each Recent row carries `opened_at`, each Starred row `starred_at` (Unix milliseconds), so a client keeps the server's order rather than re-sorting by the file's own date. Before 0.54 these lists had no offset, no total and no time: the explorer asked for the newest 50 or 200 and could not say there were more. ### `POST /api/files/search/match` ![user](https://img.shields.io/badge/-user-blue) Which of the given names answer the words, by the search's own name rule - what the explorer's "Filter in this folder" box asks. Body `{"q": "invoice 2026", "names": ["invoice_2026.pdf", "invoice-final.pdf"]}`, answer `{"matches": [0]}` (the indexes). At most 20,000 names; reads no storage. See [SEARCH.md](SEARCH.md#the-folder-filter-box). Each entry carries `perm` (the grant's level), `shared: true` and `shared_at`. A grant on a folder lists **the folder**, not its contents - the row is a `dir` whose `path` is adapter-qualified, so opening it navigates in the ordinary way. `storages` names the storages the caller reaches only through a grant: those are the "shared drives", and a storage-wide grant is reported there rather than as a row with an empty name. Results are filtered by tenant scope and by the caller's root confinement - `storages` too: a caller confined to a folder is told only the root's own storage, and only when one of its grants there reaches into the root or covers it. See [RBAC.md](RBAC.md). ### `GET /api/files/search?q=…` ![user](https://img.shields.io/badge/-user-blue) Bleve full-text + metadata search. Same response shape as `/api/files/manager` but with `path` echoing the matching entry's full path. | Param | Default | Meaning | |---|---|---| | `q` (or `query`) | - | Search text. May carry `tag:x` / `-tag:x` filters. | | `storage_id` | `0` (all) | Restrict to one storage. Also what enables the SQL LIKE fallback. | | `limit` | `50` | Max results. | | `scope` | `all` | `name` \| `content` \| `all`. | | `type`, `mime`, `modified_after`, `modified_before`, `min_size`, `max_size`, `under`, `not_under`, `owner`, `hidden` | - | The [narrowing](SEARCH.md#narrowing-a-search), applied before `limit` counts a row. | `POST /api/files/search` takes the same fields as a JSON body. Hits come back in a defined rank order - exact filename, prefix, name, path, fuzzy, then content-only - each with `score`, `kind` and (when the caller starred it) `starred`; the answer says `truncated` and `total`. Full reference: [SEARCH.md](SEARCH.md). ### `POST /api/files/save-text` ![user](https://img.shields.io/badge/-user-blue) Writes the body of the built-in text / code / Markdown editor. Takes a version snapshot of what it is about to replace, writes the bytes, updates the row and re-indexes the file. Which files it saves: those whose extension is a known text or code format (`.txt`, `.md`, `.conf`, `.json`, `Dockerfile`, `Makefile` …), and - since #56 - an existing file under any other name (`LICENSE`, `NOTICE`, `notes.custom`) whose catalogue row calls its bytes text (`text/*`, or JSON/XML/YAML). Anything else answers `415`: a binary file under an unknown name, and a path with no catalogue row that no known extension vouches for. ⚠ Creating and saving are **different events** and get **different scans**: | | event | antivirus | |---|---|---| | the path has no row yet | `file.uploaded` | scanned **immediately**, like an upload | | the path already holds a file | `file.updated` | **one** scan scheduled `antivirus.save_scan_window_minutes` out; further saves inside that window join it rather than rescheduling, and it reads the file as it stands *then* | So a burst of Ctrl+S costs exactly one scan, and the window cannot be pushed out indefinitely by somebody who keeps typing. The delay is a row in the operation queue, not a timer in the process, so it survives a restart. See [PROTECTION.md → Files written in the editor](PROTECTION.md#files-written-in-the-editor). --- ## Encryption policy Who may start encrypting ([E2E-ENCRYPTION.md → Who may encrypt](E2E-ENCRYPTION.md#who-may-encrypt)). A door that would create an encrypted folder's key file or a `.fxe` - or land an item on one of those names by a rename or a move, unless the item is a folder, a `.fxe` that stays a `.fxe` or a key file that stays its own folder's, or copy what is encrypted (a `.fxe`, a key file, a folder holding either) - and is refused answers `403 {"error":"e2e_not_allowed","reason":"tenant_disabled|policy_off|admins_only|permission|approval_required","message":…}` (`message` is the reason in the reader's language; a file request's is the sentence it gives any file the link does not take). An MCP tool answers an error result naming the reason. The agent surface (`/api/ai`, MCP, uploaders, upload tickets) refuses a key file's name before the rule, folder or file: `403 RESERVED_NAME`. A door that could not decide the rule - a lookup failed, the rule's or one it made to ask (the storage's row, the account a write is judged for) - answers its own server failure, never a refusal: `500 e2e_policy_undecided` with the server's sentence on the web app's routes, `500 {"error":"could not check the encryption policy"}` on `/api/ai`, an error result in those words over MCP, `503 storage_unavailable` for an upload ticket and a file request, and `500 save_failed` for an app's *save as*. The rule's own log line, one for each write it could not decide, names the storage, the person and the error, never the path; an app job that fails this way is also reported by the queue, which names the output. ### `POST /api/files/e2e/allowed` ![user](https://img.shields.io/badge/-user-blue) The explorer's question before it offers **Create encrypted folder…**, **Encrypt with E2EE…** or **Request encryption…**. Body `{ "items": [{ "path": "Docs://Reports", "kind": "new_folder" }, …] }` - at most 1000 (`400 {"error":"too many items"}`; a body that is not JSON is `400 {"error":"bad json"}`); answer `{ "encrypt": ["allowed" | "request" | "denied", …], "reasons": ["" | "tenant_disabled" | "policy_off" | "admins_only" | "permission" | "approval_required", …] }`, positional, in the order of the items. `kind` says which encryption: `folder` (the folder, encrypted where it is), `new_folder` (a new encrypted folder made in it), `file` (the file); without it, the kind of what is there. The answer counts exactly the approvals the create door would spend for that kind, so what the explorer offers is what the server accepts. A `kind` it does not know is `denied`. It changes nothing - an approval is looked for, never spent - so it needs only the `read` verb. It is asked for administrators too: the policy `off` and the tenant's ceiling stop them. `filex encrypt` asks it before the password. A path it cannot place - an unknown storage, one outside the caller's tenant, `..` - is `denied`, and so is a path outside the root of a root-confined caller (a token's `root:` scope, `X-Filex-Root`): nothing of the rule is heard there. ### `POST /api/files/e2e/requests` ![user](https://img.shields.io/badge/-user-blue) Asks to encrypt, under the `approval` policy. A session, or a token with the `write` verb. Body `{ "path": "Docs://Reports", "kind": "folder" | "new_folder" | "file", "reason": "…" }` - `folder` asks to encrypt that folder where it is, `new_folder` to make one new encrypted folder directly inside it, `file` to encrypt that file; the file's request is kept under the folder it goes into (a single file's approval is its folder's). An approval opens its kind there and nothing else. It is always filed `pending`, for the caller's own tenant and user: nothing in the body can name another. `201 { request, created: true }`, or `200 { request, created: false }` when the same request is already waiting. | Status | `error` | When | |---|---|---| | 400 | `not_requestable` | the answer for that path is not `request` - no approval is needed there, or none could help (the body also carries `answer` and `reason`) | | 400 | `kind_mismatch` | `kind` is not what is there: `folder` or `new_folder` for a folder, `file` for a file | | 400 | `reason_required` | an empty reason (a reason is cut at 2000 characters) | | 400 | `bad_request` | anything else that is wrong with the request, `message` saying what: a body that is not JSON, a path that names no storage or holds `..`, a `kind` that is neither, a file request that names no file, a path that names a key file or a `.fxe` | | 403 | `path outside confined root` | a root-confined caller asking outside its root | | 404 | `not_found` | an unknown storage, or one outside the caller's tenant | | 404 | `path_missing` | nothing is at the path: a request names a folder or a file that is there | | 429 | `too_many_pending` | the caller has 20 requests waiting already; one must be answered or lapse first | ### `GET /api/files/e2e/requests` ![user](https://img.shields.io/badge/-user-blue) The caller's own requests, every state, newest first (up to 200): `{ requests: [...] }`. A session, or a token with the `read` verb. A root-confined token sees the ones inside its root. A request, as these routes answer it: ```json { "id": 12, "path": "Docs://Reports", "storage": "Docs", "kind": "folder", "reason": "salary sheets", "status": "approved", "requester": "Ada", "requester_id": 7, "decider": "Grace", "decided_at": "2026-10-01T09:12:03Z", "decision_note": "", "expires_at": "2026-10-08T09:12:03Z", "used_at": null, "created_at": "2026-10-01T08:40:00Z", "tenant_id": 3, "decidable": false } ``` `status` is `pending` · `approved` · `rejected` · `expired` · `used`. `decidable` says whether the administrator reading the list may answer it: `false` on the platform operator's list for another tenant's request, and on a person's own list. `expires_at` is when a waiting request lapses, and once it is approved, when the approval does: 7 days from the request, and 7 days from the approval. `used_at` is when the approval was spent. --- ## Vault (encryption level 3) The **vault** ([E2E-VAULT-FORMAT.md](E2E-VAULT-FORMAT.md)) is one folder whose tree, names and sizes the server never sees: a key file, encrypted index files (`v/idx/G.fxi`) and packs of one fixed size (`v/p/XX/ID.fxp`). These routes are how a client writes one. **Off unless `FILEX_E2E_VAULT` is set** ([CONFIGURATION.md](CONFIGURATION.md#end-to-end-encryption-the-vault)): then every route below answers `404 VAULT_DISABLED` and `GET /api/files/capabilities` says `e2e_vault: false`. The server decrypts nothing; it orders the commits under a **write lock** it keeps in the database, checks what is in the clear (sizes, names, plaintext headers), and keeps every other door out of the folder. `path` is the vault folder's wire path (`Docs://Kasa`, never a storage's root). An error is `{"error": "", "message": "…", …}`, `message` in the reader's language. Times are RFC 3339 in UTC (`2026-10-06T19:00:00Z`); `vault_id` is the key file's id, base64url; `token` is opaque, and travels in the `X-Filex-Vault-Lock` header of every write. Reads of packs and index files use the ordinary download, ranges included (`GET /api/files/manager?action=download`). | Status | `error` | When | |---|---|---| | 400 | `NOT_A_VAULT` | the key file at `path` is not a vault's (or breaks the format's rules: `detail` says how) | | 400 | `VAULT_BAD_REQUEST` | a malformed request: `detail` says what | | 400 | `VAULT_BAD_OBJECT` | a key file, pack or index file that is not well formed: wrong size, wrong header | | 400 | `VAULT_KEEP` | a delete names one of the three newest generations | | 403 | `VAULT_PATH` | any **other** door's write strictly inside a vault folder (below) | | 404 | `VAULT_DISABLED` | vaults are off | | 409 | `VAULT_EXISTS` | `create` where something is: not a new or empty folder | | 409 | `VAULT_NESTED` | `create` inside an encrypted folder | | 409 | `VAULT_LOCKED` | somebody else holds the lock: `holder {name, client, label}`, `since`, `retry_after` (seconds) | | 409 | `VAULT_LOCK_LOST` | the token holds no lock: `reason` `expired` · `idle` · `broken` · `released` · `taken`, and `holder` for `taken` (who holds it now) and `broken` (who broke it) | | 409 | `VAULT_PACK_EXISTS` | a pack id that is already stored: packs are never replaced | | 409 | `VAULT_GENERATION` | an index that is not `latest + 1`: `latest` | | 409 | `VAULT_KEYFILE` | any **other** door changing the key file's `v`, `req` or `vault`, removing or moving it on its own, or making a folder a vault | | 503 | `VAULT_TIMEOUT` | an index write that took more than 60 seconds, abandoned | | 503 | `VAULT_BUSY` | the lock's record changed under every attempt to update it: try again | **The lock** (`internal/vaultlock`, table `vault_locks`): one session at a time, per tenant and vault id. It is held while its 60-second lease has not run out (renewed by the heartbeat every 15 seconds and by every vault write) and the session is not idle - a vault write or an `active` renewal within the person's idle time (1 to 10 minutes, 3 by default, `/prefs`). It is free again at once when it ends, unless an index write of its holder is still running; then when that write ends, or 60 seconds after it began. Its ending is audited (`vault.unlock`) by the call that finds it. **Other doors.** Inside a vault folder only these routes write: the explorer's verbs, the operations queue, uploads, the agent API and MCP, archives, apps, WebDAV, S3, SFTP, FTPS and NFS answer their own refusal (`403 VAULT_PATH` on the HTTP routes, `VAULT_PATH` in an MCP error, WebDAV `403`, S3 `AccessDenied`, SFTP permission denied, FTPS `550`, NFS `NFS3ERR_ACCES`), and the document server's save callback answers `{"error": 1}` and writes nothing. Every door asks through the router's one ACL resolver (`Deps.ACL`), the only one the vault finder is attached to (`AttachVaults`): a door that built its own with `acl.New` would see no vault (the ONLYOFFICE save did until 0.54). The key file is rewritten through the explorer's upload (a new password, a recovery reset, an escrow slot) only while its `v`, `req` and `vault` stay the same, and never removed, renamed or moved on its own (`409 VAULT_KEYFILE`); no upload makes a folder a vault. The vault folder itself is renamed, moved, deleted and copied like any encrypted folder. ### `POST /api/files/e2e/vault/create` ![user](https://img.shields.io/badge/-user-blue) Makes a new, empty vault. Body `{ "path", "marker", "index" }`: `marker` is the key file (a JSON object: `v` 3, `req` `["vault"]`, `fmk` `"wrapped"`, `vault` `{v: 1, id, pack: 22 | 24}`, and the password slot), `index` the base64 of generation 1's 65 536-byte index file. `path` must not exist or be an empty folder, outside every encrypted folder; `files.create` at its parent and the [encryption rule](#encryption-policy) there (a `new_folder` approval is spent under the `approval` policy, `403 e2e_not_allowed` otherwise). It writes the folder, the key file and the index, in that order, and removes what it wrote when a step fails. `201 { "generation": 1, "owner_id", "owner_name", "owner_self" }` (who owns the folder, as `state` says it); audited as `vault.create`. ### `GET /api/files/e2e/vault/state` ![user](https://img.shields.io/badge/-user-blue) `?path=` → `{ "vault_id", "pack_log2", "generation", "lock", "owner_id", "owner_name", "owner_self" }`: `generation` is the latest (0 when there is no index file), `lock` is `null` or `{ "holder": {name, client, label}, "since", "expires_at", "mine" }` - `mine` for the session whose token the request carries. The three `owner_*` keys say who owns the vault folder, as a listing row does (each omitted when there is nothing to say): every row inside the vault is drawn as the folder's, since the index records no author. `words` (0.56) are the sentences the browser shows about the vault on its own clock, said here in the reader's language (only the browser sees inside a vault and knows the moment; no screen composes them): `app_lock_soon` (a minute before the vault locks itself while an app holds unsaved changes in it), `app_lock_soon_keep` (the action that keeps it open), `app_lock_kept`. Needs the right to download from the folder. ### `GET /api/files/e2e/vault/list` ![user](https://img.shields.io/badge/-user-blue) `?path=&kind=index|pack&after=&limit=` → `{ "items", "next", "now" }`: `{generation, size, mtime}` per index file, `{id, size, mtime}` per pack, in name order, at most `limit` (1 000 by default, at most 10 000) after `after` - the hex name of the previous page's last item; `next` is that cursor for the next page, or `null`. `now` is the server's clock: retention is measured against it, never against the client's. ### `POST /api/files/e2e/vault/lock` ![user](https://img.shields.io/badge/-user-blue) Body `{ "path", "client": "web" | "desktop" | "cli" | "mount", "label" }` → `{ "token", "generation", "lease_seconds", "idle_seconds", "expires_at" }`, or `409 VAULT_LOCKED`. Needs `files.create`, `files.modify` and `files.delete` at the folder (a write session can do all three and the server cannot tell which). Audited as `vault.lock`; realtime `vault.lock {path, held, holder}`. ### `POST /api/files/e2e/vault/lock/renew` ![user](https://img.shields.io/badge/-user-blue) The heartbeat: body `{ "path", "active" }` + token → `{ "expires_at", "idle_until" }`, or `409 VAULT_LOCK_LOST`. ### `POST /api/files/e2e/vault/lock/release` ![user](https://img.shields.io/badge/-user-blue) Body `{ "path", "reason" }` + token → `204`, whether or not the token still held the lock. `reason` `locked_idle` says the client locked the vault itself after 15 minutes of nothing; the `vault.unlock` row records it. ### `POST /api/files/e2e/vault/lock/break` ![user](https://img.shields.io/badge/-user-blue) Ends somebody else's lock: the vault folder's owner or an administrator of its tenant (`403 permission_denied` otherwise). Body `{ "path" }` → `204`. The holder's next call gets `VAULT_LOCK_LOST` `broken`, with who broke it. Audited as `vault.lock_break`. ### `PUT /api/files/e2e/vault/pack` ![user](https://img.shields.io/badge/-user-blue) `?path=&id=` (32 lower-case hex digits), body: the pack + token → `201`. Exactly 2^`pack` bytes with the header of that id and size; created once, never replaced. Takes a body of up to 16 MiB whatever the limit for other requests; counted against the quota like any file. ### `PUT /api/files/e2e/vault/index` ![user](https://img.shields.io/badge/-user-blue) `?path=&generation=`, body: the index file + token → `201 { "generation" }`. `generation` must be `latest + 1`; the header that of that generation; the size a Padmé size from 65 536 bytes to 64 MiB. Written to `v/idx/.tmp-` and renamed into place (one PUT on S3), abandoned after 60 seconds and never moved into place afterwards; `v/idx/.tmp-*` files older than an hour are removed on the way. Realtime `vault.generation {path, generation}`. ### `POST /api/files/e2e/vault/delete` ![user](https://img.shields.io/badge/-user-blue) The lock holder's garbage collection: body `{ "path", "packs": ["<32 hex>", …], "indexes": [3, …] }` + token → `{ "deleted": { "packs": 3, "indexes": 1 } }` (how many). At most 1 000 names; never one of the three newest generations; for good - no trash, no version - and a missing one counts as deleted. Needs the `delete` verb on a token. ### `GET /api/files/e2e/vault/prefs` ![user](https://img.shields.io/badge/-user-blue) The caller's idle time as a vault writer: `{ "idle_minutes" }`, 3 until set. ### `PUT /api/files/e2e/vault/prefs` ![user](https://img.shields.io/badge/-user-blue) Body `{ "idle_minutes": 1..10 }` → `{ "idle_minutes" }`. Kept for the person, not per browser; read when a lock is taken. --- ## Drafts A new document is a **draft** until its first save (#71): a real file in the person's own `.filex-drafts///` folder of the storage it was made for, with a row that remembers where it is meant to go. Nothing is created in that folder until the draft is saved. The user guide is [ONLYOFFICE.md → Drafts](ONLYOFFICE.md#drafts-nothing-is-in-the-folder-until-you-save). Every route answers for the **caller's own** drafts; another person's key is `404`. They need a signed-in person: an app token, and a caller confined to a root (an embed's shared proxy token), get `403 {"code":"DRAFTS_UNAVAILABLE"}` and create documents with `newfile` as before. `GET /api/files/capabilities` carries `drafts: { limit }` exactly when the caller may use them. A draft, as these routes answer it: ```json { "key": "3f9c0a1b2c3d4e5f", "name": "minutes.txt", "path": "docs://.filex-drafts/7/3f9c0a1b2c3d4e5f/minutes.txt", "storage": "docs", "target_dir": "docs://Reports", "target": "docs://Reports/minutes.txt", "type": "txt", "size": 11, "mime": "text/plain; charset=utf-8", "created_at": "2026-09-27T09:12:03Z", "modified_at": "2026-09-27T09:14:40Z" } ``` `path` is the file an editor opens and saves into; `type` is the `newdoc_types` key it was made as. ### `POST /api/files/drafts` ![user](https://img.shields.io/badge/-user-blue) Makes a draft: the same body as `newfile` - `{ path, name, type, exact_name? }` where `path` is the folder it is for - and the same bytes (an office type gets its template). `201 { path, name, ext, size, mime, draft }`, shaped like `newfile`'s answer so a client opens `path` the same way. The caller needs ≥ editor on the folder, and the name and type rules are `newfile`'s. At the limit (`drafts.limit`, [PROTECTION.md → Drafts](PROTECTION.md#drafts)) it is `409 { "code": "DRAFT_LIMIT", "limit": 50, "count": 50 }` and nothing is created. ### `GET /api/files/drafts` ![user](https://img.shields.io/badge/-user-blue) `{ drafts: [draft…], count, limit }` - the caller's live drafts on every enabled storage, the most recently made first. ### `GET /api/files/drafts/count` ![user](https://img.shields.io/badge/-user-blue) `{ count, limit }` - what the navigation panel's badge reads. ### `GET /api/files/drafts/{key}` ![user](https://img.shields.io/badge/-user-blue) One draft. An editor that was handed only a path asks this to learn where the draft is meant to go. ### `POST /api/files/drafts/{key}/save` ![user](https://img.shields.io/badge/-user-blue) Moves the draft to its folder: body `{}` for its own name, or `{ "as": "" }` for another. `200 { ok, path, name, target_dir }`; the file keeps its catalogue row (an editor still open on it goes on saving into the saved document) and from then on is an ordinary file - versioned, listed, announced. - The name is taken: `409 { "code": "TARGET_TAKEN", "name", "suggested", "target_dir" }`, where `suggested` is the first free `name (2).ext`, `name (3).ext` … Nothing moves. The client asks, and sends `{ "as": suggested }` if the person agrees. ⚠ A save never replaces a file. - The folder is gone: `409 { "code": "FOLDER_GONE" }`; the draft stays. - The server cannot tell whether the name is free: `503 { "code": "EXISTS_CHECK_FAILED" }` - refused rather than risk a replace. - ≥ editor on the folder is checked **now**, not when the draft was made: `403` if the caller lost it meanwhile; a read-only storage is `403` too. ### `DELETE /api/files/drafts/{key}` ![user](https://img.shields.io/badge/-user-blue) Discards the draft into the trash: `200 { ok: true, trashed: true }`. It is then in the caller's own trash listing with `draft: true` and its name as its path, and restoring it puts it back in Drafts ([TRASH-VERSIONING.md → Discarded drafts](TRASH-VERSIONING.md#discarded-drafts)). On a storage that cannot keep deleted bytes it is deleted outright (`trashed: false`). --- ## Uploads Smaller files can use `POST /api/files/manager?action=upload` (single-shot `multipart/form-data`); anything larger, and anything that should survive a dropped connection, is the staged upload, `POST /api/files/upload/begin` ([UPLOADS.md](UPLOADS.md)). Both ask the account's quota and the per-file upload limit before anything is stored, and every file's name ([File names](FILE-NAMES.md#the-names-filex-refuses)) before the first byte of the batch is written. The single-shot form takes, beside each `file[]` part, an optional `name[]` field with the file's exact name, in the same order: a browser writes `"`, CR and LF in a part's filename as `%22`, `%0D`, `%0A`, and filex never guesses which `%22` was meant. A program that is not a browser - a screenshot tool, a phone shortcut, a script - uploads with an **uploader**'s key: `POST /api/uploads` (multipart `file`, `Authorization: Bearer `), answered with the file's link and a deletion link (`GET` / `POST /x/{token}`, credential-free; `GET` never deletes). The key is looked up by that one route and no other. The person's uploaders are `GET` · `POST /api/auth/uploaders`, `PATCH` · `DELETE …/{id}` and `POST …/{id}/key`; the deprecated `POST /api/sharex/upload` (an API key with `write`, until the next major release) answers as it always did. Bodies, answers and errors: [UPLOADERS.md](UPLOADERS.md). The presigned S3 multipart upload (`POST /api/files/upload/init`, `/finalize` and `/abort`) was **removed in 0.54.0**: no filex client spoke it any more. A caller still sending it is refused (the route is gone); move it to `POST /api/files/upload/begin`. --- ## Archives Archives are listed, extracted and created on the server, in every format [ARCHIVES.md](ARCHIVES.md#providers-and-formats) lists: ZIP, 7z, the TAR family (TAR, TAR.GZ, TAR.BZ2, TAR.XZ), bare gzip, bzip2 and xz, and RAR when the server's 7-Zip reads it. filex reads plain ZIP, TAR, TAR.GZ, TAR.BZ2, gzip and bzip2 itself; 7z, xz (TAR.XZ included), a password-protected ZIP and RAR need 7-Zip on the server, and so does creating anything but a plain ZIP. A password works for ZIP and 7z. Paths are `://` (a bare path, with `storage_id`, is the older form). Packing a selected item takes its bytes, so every door that packs asks what a download asks: `files.download` on the item (viewer and up) and never one of filex's own names. `POST /api/files/archive/create` and the AI surface's server-side zip (`POST /api/ai/zip`, the MCP `file_zip` tool) ask it through one function (`internal/api/handlers/pack_source_rule.go`); the AI zip asked only "may the caller see it" until v0.50. The AI zip also keeps the end-to-end encryption boundary: no encrypted file is packed into a zip outside its folder (`409 E2E_BOUNDARY`, [MCP.md](MCP.md#encrypted-folders-and-fxe)). **Limits.** **Settings → Archives** holds the live policy: whether 7-Zip is used, the formats allowed for creation and the default one, the most members an archive may hold, the most bytes it may expand to, and a timeout ([ARCHIVES.md](ARCHIVES.md#process-configuration)). Listing, extracting and creating are all held to it; over a limit answers `413`, naming the limit. **Errors** carry a `code`: `401 PASSWORD_REQUIRED` (the archive is encrypted and no `password` was sent: ask for one and send the same request again with it), `401 BAD_PASSWORD`, `400 UNSUPPORTED_FORMAT`, `400 PASSWORD_CHARSET` (a ZIP password takes ASCII only), `413 ARCHIVE_LIMIT_EXCEEDED`, `503 PROVIDER_UNAVAILABLE` (7-Zip is off or missing), `503 SNAPSHOT_FAILED` (a version of a file the write would replace could not be kept), `502 ARCHIVE_PROVIDER_FAILED`. Downloading a selection as one archive is a different pair of requests (`POST /api/files/archive/download`, then a credential-free GET): [API.md → Downloading a selection is two requests](API.md#downloading-a-selection-is-two-requests). ### `POST /api/files/archive/list` ![user](https://img.shields.io/badge/-user-blue) **Request** ```json { "path": "main://bundles/archive.7z", "password": "optional" } ``` **Response 200** ```json { "entries": [ { "name": "a.txt", "size": 100, "mtime": "2026-04-22T10:00:00Z", "is_dir": false }, { "name": "sub/", "size": 0, "mtime": "2026-04-22T10:00:00Z", "is_dir": true } ] } ``` Needs viewer on the archive. A member read through 7-Zip may also say `encrypted`, `is_link` or `special` (an archive holding a link or a special file is refused when extracted). ### `POST /api/files/archive/extract` ![user](https://img.shields.io/badge/-user-blue) ```json { "path": "main://bundles/archive.zip", "dest": "main://extracted", "members": ["sub/a.txt"], "password": "optional" } ``` `dest` defaults to the archive's own folder and must be on the **same storage** (`400` otherwise). `members` extracts just those entries; omit it for the whole archive. Needs viewer on the archive and `files.create` (editor) on `dest`. An archive the caller may not download is not extracted where its members could be downloaded (`403 permission_denied`, `files.download`); a member that would be downloadable where it lands, from an archive that is not, is skipped and counted in the result's `withheld` ([RBAC.md](RBAC.md#every-file-in-a-folder-answers-the-download-rule)). **Response 202** - the work is queued, with progress and Cancel in the operations center: ```json { "op": { "id": 3, "kind": "archive-extract", "storage_id": 1, "sources": ["bundles/archive.zip"], "dest": "/extracted", "total": 12, "done": 0, "status": "pending" } } ``` Follow it with `GET /api/files/ops/{id}` ([Operations](#operations-long-running)). A password is checked while the request is still open, so a missing or wrong one answers `401` here rather than failing the queued job. A member whose name is already taken in `dest` **replaces** that file (the replaced bytes are kept as a version first, as for any overwrite); a member that would land on a folder of its name, or a folder on a file, is skipped. There is no `overwrite` flag. An archive holding a member that would land outside `dest` (zip-slip), a link or a special file is refused whole, before anything is written. ### `POST /api/files/archive/create` ![user](https://img.shields.io/badge/-user-blue) ```json { "dest": "main://out/bundle.7z", "sources": ["main://reports", "main://notes/a.txt"], "format": "7z", "password": "optional", "encrypt_filenames": true, "compression": 7, "solid": true, "dictionary_size_mb": 64 } ``` Writes a new archive from files and folders already in filex (from any storage the caller can read). Only `dest` and `sources` are required: | Field | Notes | |---|---| | `format` | `zip`, `7z`, `tar`, `tar.gz`, `tar.bz2`, `tar.xz`; omitted, it is read from `dest`'s extension, then the policy's default. RAR and the bare compressors are extraction-only (`400 UNSUPPORTED_FORMAT`), and so is a format the policy does not allow | | `password` | ZIP (ASCII only) and 7z | | `encrypt_filenames` | 7z only: the member names are encrypted too, so even the listing asks for the password (a ZIP cannot hide its names; there it is ignored) | | `compression` | `0`-`9` | | `solid`, `dictionary_size_mb` | 7z only; the dictionary is `1`, `2`, `4`, `8`, `16`, `32`, `64`, `128` or `256` | A field that does not fit the format answers `400 INVALID_ARCHIVE_OPTIONS`. Needs `files.create` (editor) at `dest` and `files.download` on every source. **An occupied `dest` is refused** before any work is done, `409 TARGET_EXISTS` - never replaced. `409` also when the selection holds no readable file or two members would get the same name. **Response 202** `{ "op": { "id": 4, "kind": "archive-create", … } }`, followed like an extraction. ### `POST /api/files/archive/add` ![user](https://img.shields.io/badge/-user-blue) ```json { "path": "main://bundle.zip", "files": [ { "source": "main://a.txt", "name": "a.txt" }, { "source": "main://sub/report.pdf", "name": "docs/report.pdf" } ] } ``` The older, **ZIP-only** update: `path` is the zip to write, created when it is not there; `files[].source` is what to read (on the zip's own storage) and `files[].name` is where it lands inside the zip. A member of that name already in the zip is replaced. It runs in the request and answers **`200 {"path", "size"}`** - no queued job. Needs `files.create` for a new zip, `files.modify` for an existing one. For a new archive in any other format, use `archive/create`. ⚠ The `{paths, dest, compression}` body this page once showed was never the contract: anything without `path` and `files` is `400 missing path or files`. --- ## Sharing PIN-protected, time-limited, optionally download-capped public links. ### `POST /api/files/share` ![user](https://img.shields.io/badge/-user-blue) Needs **editor** on the item and the `share.links` permission (a file request, `kind: "drop"`: `share.upload_links`); an API token needs `write`. The same rule answers `POST /api/ai/share` and the MCP `file_share` tool. A link then answers only while its creator still holds that right on the item - [SHARING.md → A link follows its creator](SHARING.md#a-link-follows-its-creator). An end-to-end encrypted folder, and anything inside it, is never linked: `409 {"code":"E2E_ENCRYPTED"}` for a download link and a file request alike (a visitor would get ciphertext, and a file request would store their uploads in the folder unencrypted). A single encrypted file (`.fxe`) is linked as it is ([E2E-ENCRYPTION.md](E2E-ENCRYPTION.md#feature-trade-offs)). **Request** ```json { "path": "storage1://report.pdf", "expires_in": 604800, "max_downloads": 10, "pin": "1234" } ``` `password: true` instead of `pin` generates an 8-digit PIN. `expires_in` is seconds on the server's clock (what the share dialog sends); `expires_at` (RFC 3339) wins when both are given. **The PIN rule.** A PIN is **4 to 12 characters** - one rule, in `share.Service.Create`, for every door that makes a link (this one, `POST /api/ai/share`, MCP `file_share`, a permission rule's generated PIN, an app's `share_create`). Anything else is **400** `{"error":"pin_length","pin_min":4,"pin_max":12,"message":"A link's PIN must be 4 to 12 characters long."}` (the sentence in the reader's language). **Response 200** - the link under `share`, and the same fields flat for older callers: ```json { "share": { "id": 42, "uuid": "Xy3kPq", "token": "Xy3kPq", "url": "https://files.example.com/s/Xy3kPq", "kind": "download", "filename": "report.pdf", "has_pin": true, "expires_at": "2026-05-05T12:00:00Z", "max_downloads": 10, "download_command": { "curl": "curl -fSL -o 'report.pdf' 'https://files.example.com/s/Xy3kPq?pin=1234'", "powershell": "Invoke-WebRequest -UseBasicParsing -Uri 'https://files.example.com/s/Xy3kPq?pin=1234' -OutFile 'report.pdf'" } }, "id": 42, "url": "https://files.example.com/s/Xy3kPq", "token": "Xy3kPq", "expires_at": "2026-05-05T12:00:00Z", "expiry_clamped": false, "max_downloads": 10, "download_command": { "curl": "…", "powershell": "…" } } ``` `download_command` is the one line that fetches the link's file from a terminal, **written by the server**: `-L` for an S3 install's redirect, `?zip=wait` and `.zip` for a folder, the PIN as `?pin=` (only on this, the creator's answer). `null` for a file request. `POST /api/ai/share` and MCP `file_share` answer it too. `expires_at` is what was **stored**, not what was asked: every new link is capped at the admin's maximum link life (`share.max_ttl_days`, default 7 days - [PROTECTION.md](PROTECTION.md)). A request with no expiry gets one, a longer request is shortened, and `expiry_clamped: true` marks either case. The ceiling itself is public in `GET /api/capabilities` as `share_max_ttl_days` so a client can offer only expiries the server will keep. ### `GET /api/files/share` ![user](https://img.shields.io/badge/-user-blue) List shares the caller owns. **Response 200** ```json { "shares": [ { "id": 42, "path": "/storage1/report.pdf", "token": "Xy3kPq", "expires_at": "...", "max_downloads": 10, "downloads": 3, "created_at": "..." } ] } ``` ### `DELETE /api/files/share/:id` ![user](https://img.shields.io/badge/-user-blue) Revokes a share: the caller's own link (any link for an administrator), inside the caller's tenant and token root - `404` outside them, the answer an unknown id gets; `403` for somebody else's link. `POST /api/ai/unshare` and the MCP `file_unshare` tool ask the same rule (they asked only the last part until v0.50). ### `GET /api/shares` ![user](https://img.shields.io/badge/-user-blue) The links the **caller** created - the list behind **My shares**, for any signed-in person, administrator or not. `?limit=&offset=`, and `?active=true` for live links only. Answers `{"items": [...], "total", "page", "page_size"}` with the same rows and tenant filter as `GET /api/admin/shares`, minus the creator's address (on this list it is always the caller), and each row's `url` built server-side exactly as the share dialog's is - never from the browser's address. A `root:`-confined token sees only the links inside its folder. Every row's `share.state` says where the link stands, as the server judges it (`model.Share.StateAt`): `active`, `expired`, `exhausted` (its download, visit or upload cap is used up) or `revoked`. A screen shows it; it does not work it out from `expires_at` on the browser's clock. `GET /api/admin/shares` carries the same field. ### `GET /api/shares/{id}/pin` ![user](https://img.shields.io/badge/-user-blue) One link's PIN, for the person who created it or an administrator - **403** for anybody else, and for an `app` token (`handlers.RequirePersonalCaller`: a PIN is a credential, and an app token has no person behind it). An allowed caller always gets **200**, carrying the PIN or one word saying why there is none: - `{"pin": "83459512"}` - `{"pin": null, "reason": "no_pin"}` - the link has no PIN; - `{"pin": null, "reason": "not_recoverable"}` - minted before migration 00049, or while the instance had no key, so only the bcrypt hash was kept; - `{"pin": null, "reason": "no_secret_key"}` - the instance has no `FILEX_SECRET_KEY` to open the sealed copy with. The PIN is sealed with AES-256-GCM beside the bcrypt hash; the gate still checks only the hash. Every call writes an audit row, `share.pin_revealed`, whatever the answer. A link in another tenant, or outside a confined token's folder, is the **404** an unknown id gets. ### `GET /s/:token` ![public](https://img.shields.io/badge/-public-lightgrey) The link a stranger opens. **What it answers depends on who is asking** - see [The public surface](#the-public-surface) for the rule and the escape hatches. In short: a browser asking for HTML gets the SPA shell; anything else (curl, wget, a download manager, the PIN form's own POST) gets the bytes or the server-rendered no-JS page, exactly as before. `POST /s/:token` is what the no-JS PIN form submits to (`pin` as a form field). **A link behind a PIN, from a script.** There is no separate verify or download route (a `/api/share/:token/verify` and `/api/share/:token/download` this page once described were never served). Send the PIN with the request for the bytes: `GET /s/:token` with an `X-Filex-Pin: 1234` header (or `?pin=1234`). A right PIN streams the file and sets the link's unlock cookie, so the next request needs no PIN; a wrong one answers the PIN form again, and five wrong answers lock the link for ten minutes. A download is counted before the first byte leaves, and a link that has used up its `max_downloads` answers `404`, as an expired one does. The JSON surface the shell itself uses is [`POST /api/public/s/{token}/pin`](#post-apipublicstokenpin-) below. --- ## The bot check [BOT-PROTECTION.md](BOT-PROTECTION.md) is the whole story; the anonymous doors are these two, both `no-store` and limited per address. ### `GET /api/captcha/config` ![public](https://img.shields.io/badge/-public-lightgrey) What a form draws: `{"provider": "turnstile", "site_key": "0x4AAA…", "script": "https://challenges.cloudflare.com/turnstile/v0/api.js", "fallback": "local", "forms": ["login", "demo_login", "share_password", "file_request"], "misconfigured": false, "fallback_active": false, "local_field": "captcha_local", "token_field": "captcha_token"}`. Off: `provider: "none"`, `site_key` and `script` null, `forms` empty. Never the secret. ### `POST /api/captcha/local/challenge` ![public](https://img.shields.io/badge/-public-lightgrey) Asked by a page whose widget script did not load. **200** `{"captcha": "fallback", "challenge": {algorithm, salt, hash, signature, maxnumber}}` when the server cannot reach the provider either (or its keys cannot work) - the outage flag is raised; **409** `{"captcha": "provider"}` when it can (an ad blocker is not an outage); **404** when the check is off. ### `GET /api/admin/captcha` ![admin](https://img.shields.io/badge/-admin-red) Admin → Bot protection, the platform operator's (`supertenant_only` for a tenant's administrator): `stored` (the panel's own values, `has_secret` in place of the secret), `effective` (what is in force), `sources` (`db`, `env` or `default` per field), `misconfigured`, `secret_key_configured`, `fallback_active`, `fallback_total`, `all_forms`, `providers`, `bounds`. ### `PATCH /api/admin/captcha` ![admin](https://img.shields.io/badge/-admin-red) `{provider?, site_key?, secret?, clear_secret?, forms?, clear_forms?, fallback?, verify_timeout_ms?}`; an empty string clears a field, so the environment applies to it again. The secret is sealed and never answered; the audit row names the fields changed, never a value. **400** `captcha_bad_value` / `captcha_out_of_bounds`, **409** `captcha_no_secret_key` (no `FILEX_SECRET_KEY` to seal it with). ### `POST /api/admin/captcha/test` ![admin](https://img.shields.io/badge/-admin-red) `{provider?, site_key?, secret?}` - the page's values, saved or not - → `{ok, reachable, secret_ok, codes, misconfigured, cause, message}`: the provider is asked with a token it refuses, so `secret_ok` says whether it took the secret. The stored secret goes only to the provider it was saved for. ## The public surface Everything a stranger reaches through a filex link - a download share, a file request, an app plugin's page - is **one surface**: one shell, one PIN gate, one expiry story, one set of branding. The JSON below is what that shell is built from; the server-rendered pages behind it are the no-JS fallback. ⚠ Every answer under `/api/public/*` is `Cache-Control: no-store` and `X-Robots-Tag: noindex, nofollow`, except `/branding` and `/ui-locales/{code}`, which are the same for every visitor and revalidate (`public, no-cache` with an ETag, below). Nothing here is authenticated, and nothing here names the creator, the storage or the file's path. ⚠ The same default holds for the whole API: every `/api` answer is `Cache-Control: no-store` unless its handler sets a policy of its own (`api.APINoStore`, PR #41 - a CDN rule that cached everything once served one administrator's `/api/auth/me` to every visitor). The four answers that say who the instance is - `/api/public/branding`, `/api/branding`, `/api/appearance` and `/api/public/ui-locales/{code}` - are the only `public` ones, and a test walks the route table to keep it that way. ⚠ **The PIN gate.** Five wrong PINs shut the gate for ten minutes, counted on the share row - so it survives a restart and holds across two instances behind one address. The *right* PIN during a lock is refused too: a lock the correct answer lifts is no lock at all. Answering the PIN mints an HttpOnly cookie (`fxp_`, an HMAC over the token's hash and an expiry, 12 h) that carries no PIN and opens only that one link. Before v3 this lock existed only on app-plugin pages; a PIN on a `/s/` link could be walked through at the speed of HTTP. ### `GET /api/public/s/{token}/branding`, `GET /api/public/d/{token}/branding` ![public](https://img.shields.io/badge/-public-lightgrey) A link page's identity: the same answer as `GET /api/public/branding` below, for the tenant the link was made in, whatever address it is opened at - a link's page wears its tenant's name, logo and colours on a shared address too (the same rule as its templates: [TEMPLATES.md](TEMPLATES.md#who-edits-which-templates)). A token that names no live link - unknown, expired, withdrawn, of the other kind, or closed with its creator's right to share - answers exactly what `/api/public/branding` answers at that address, so the "not available" page says nothing about whose link it was. `Cache-Control: no-store`: the answer is the link's and changes when the link dies. The document served at `/s/{token}` and `/d/{token}` wears the same branding in its title, theme colour and icon. ### `GET /api/public/branding` ![public](https://img.shields.io/badge/-public-lightgrey) Who this instance says it is - the same record the admin **Branding** page writes. `Cache-Control: public, no-cache` with a strong **ETag** over the body: a reader always asks whether its copy is current, and an unchanged answer is a `304` with no body. ⚠ Not `max-age`: the offered languages ride this answer, and while it was held for a minute an administrator who installed a language pack did not see the language until the minute was up (v0.43.0). The tag is the body’s hash, so it moves for an app installed, removed or upgraded, a theme saved or a logo changed, with no version stamp for anybody to bump. `Vary: Accept-Language`, because `locale` is resolved from the visitor’s own header. ```json { "name": "Acme Files", "logo_url": "https://…/logo.svg", "accent": "#2f6ceb", "footer_text": "Acme Ltd · support@acme.example", "hide_powered_by": false, "theme": "system", "locale": "tr", "locales": ["en", "tr"], "ui_locales": [{ "code": "es", "source": "plugin", "plugin": "lang-es", "rtl": false }, { "code": "fr", "source": "plugin", "plugin": "lang-fr", "rtl": false }] } ``` `ui_locales` names the languages an installed **language pack** adds - the codes only, never their strings - and `rtl` says which way a language lays the interface out ([RTL](RTL.md)). One language's strings are `GET /api/public/ui-locales/{code}`, fetched when somebody picks it; it is cached the same way (a strong ETag with `no-cache`), which matters most there, because a complete catalogue is ~300 KB. `theme` is `system` | `light` | `dark` anything else reads as `system`). ⚠ Deliberately **not** the `/api/branding` payload: the SSO button label is not a stranger's business. (The operator's custom stylesheet is not on `/api/branding` either any more - it moved to `GET /api/me/custom-css`, behind auth, precisely so that no anonymous surface can receive it.) ### `GET /api/public/strings` ![public](https://img.shields.io/badge/-public-lightgrey) Every sentence a public page says - the server catalogue's `server.public.*`, the table the no-JavaScript share and drop pages render - in **one** language: `?lang=` when the server speaks it (a language pack's counts), else `Accept-Language`, else the instance's `default_locale`, else English. ```json { "lang": "tr", "dir": "ltr", "strings": { "drop_sub": "Aşağıya dosyaları sürükleyin veya seçin. Yalnızca yükleyebilirsiniz; klasördeki dosyalar size görünmez.", "drop_err_too_large": "{name} çok büyük (en fazla {mb} MB).", "files_left": "{count} dosya daha gönderilebilir", "served_by": "{name} ile paylaşıldı" } } ``` Keys come without the prefix and with their `{placeholders}` unfilled; every key is present (English where the language has no word of its own); a counted sentence lists the forms its language has as `_`, picked with `strings[key + "_" + Intl.PluralRules(lang).select(n)] || strings[key]`. The JavaScript share and file-request pages (`@brftech/filex` PublicLinkPage, and the admin's Corporate identity preview) say **only** these words: there is no second copy of them in the interface catalogue, so a limit reads the same on both pages and a language pack translates it once. Unauthenticated and `no-store` like every other `/api` answer that is not one of the four instance-identity answers. ### `GET /api/public/s/{token}` ![public](https://img.shields.io/badge/-public-lightgrey) ```json { "kind": "file", "needs_pin": true, "unlocked": false, "expired": false, "revoked": false, "locked": false, "expires_at": "2026-10-01T09:00:00Z", "visits_left": 3, "subject": "rapor.pdf", "node": { "name": "rapor.pdf", "size": 51234, "mime": "application/pdf" }, "app": { "plugin": "sign", "page": "signer", "title": {"tr": "…"}, "files": [{ "ref": "pub:0", "name": "sozlesme.pdf", "size": 9 }] } } ``` | Field | Meaning | |---|---| | `kind` | `file` · `folder` · `app` (the link carries an app plugin's page) | | `needs_pin` / `unlocked` | whether there is a gate, and whether this browser is through it | | `pin_max` | with `needs_pin`: the longest PIN any link may carry (the one PIN rule, `12`). The PIN box stops there instead of at a number of its own | | `expired` | the clock ran out. An administrator's **Revoke** moves the expiry to the moment it happened, so it lands here too - there is no second column, and one that could disagree with this would be worse than one word doing both | | `revoked` | dead for a reason that is **not** the clock: the visit/download ceiling is spent, the file is gone, or the app that answers it was stopped or removed | | `locked` | the PIN gate is shut after five wrong answers. **Not** a reason the link is dead - it lifts by itself | | `unavailable` | present (`true`) once unlocked when the item, or a folder above it, is an entry its storage could not answer for ([PLUGINS.md](PLUGINS.md#an-entry-your-stat-cannot-answer-for)). The link is alive and may answer again; until then `/s/` answers `409` with a page that says so | | `visits_left` | `max_downloads - download_count`, or `null` when there is no ceiling | | `node` | present once unlocked, for `file` / `folder` only | | `app` | present for `kind: "app"`; `files` are the copies the app exposed, once unlocked | A live link has `expired` and `revoked` both false; "can I use this" is `expired || revoked`. **404** for an unknown token and for a token of the other kind (a `/d/` link asked for here) - the same body for both, so an anonymous caller cannot separate "no such link" from "somebody else's link". **410** for a link that existed and is gone, carrying the *same object shape* so a client renders it from what it already parses. ⚠ For `kind: "app"` the `node` is **omitted**: the file behind an app link is the anchor its state and its follow-up job hang on, and the only bytes a visitor may have are the copies the app exposed. No storage driver is opened for an anonymous request. ### `POST /api/public/s/{token}/pin` ![public](https://img.shields.io/badge/-public-lightgrey) `{"pin": "1234"}` → **200** with the object above (`unlocked: true`) plus the unlock cookie · **401** `pin_wrong` · **429** `locked` (audited as `share.pin_locked`; the audit row carries the token's hash, never the token). With [bot protection](BOT-PROTECTION.md) on for `share_password` the body also carries `captcha_token` (or `captcha_local`), judged before the PIN: a refusal (**422** / **409**, as on the sign-in) spends none of the attempts. ### `POST /api/public/s/{token}/event` ![public](https://img.shields.io/badge/-public-lightgrey) The app surface. `{state, event, action_id, data}`; an empty body means `{"event": "open"}`, which counts a visit. Answers `{surface}`, or **202** `{accepted: true, job_id}` when the surface asks for a job - the job runs as the **link's creator**, on the link's document, with that user's ACL, and the visitor never sees the queue row. **401** `pin_required` until unlocked (checked *before* the app is consulted, so a locked link never reports anything about the instance behind it) · **404** when the link is not an app link or the runtime is unavailable · **410** when the app is stopped or uninstalled, **or when the account that opened the link is disabled or deleted** - one answer for all of them, so a stranger holding a token cannot tell which. The same fact turns `revoked` on in `GET /api/public/s/{token}`, and it is reversible: re-enabling the account brings its links back untouched. A job asked for here passes the **same submit-time gate as an authenticated run** (handlers `enqueueAsCreator`): the action is resolved through the registry - so one the administrator disabled or reserved to administrators is refused, with the CREATOR's admin status deciding the reserved case, because the creator's rights are what the job spends - the creator's ACL on the anchor is re-read (viewer, editor when the job writes, higher on `min_role`), the encrypted-folder and read-only refusals apply, and `params` are capped at the same 64 KiB. **403** `no_access` / `permission_denied` · **409** `link_unavailable` · **413** `params too large`. ⚠ All three carry ONE sentence and no detail: which of the reasons it was would tell a stranger holding a token how this instance is configured. ### `GET /api/public/s/{token}/file/{ref}` ![public](https://img.shields.io/badge/-public-lightgrey) One copy an app link exposed (`pub:N`), Range-capable, `inline`, `nosniff`, RFC 6266 filename. Behind the same PIN gate. An active kind (HTML, SVG, XML, unknown) carries `Content-Security-Policy: sandbox; default-src 'none'…`, as every file body filex serves from its own origin does (`httpx.ProtectServedFile`: the preview, `/api/files/read`, a share's `?inline=1`, a shared folder's entries). ### `GET /api/public/d/{token}` ![public](https://img.shields.io/badge/-public-lightgrey) The file request's state. ```json { "kind": "drop", "needs_pin": false, "unlocked": true, "expired": false, "revoked": false, "locked": false, "folder": "Gelen kutusu", "expires_at": "2026-10-01T09:00:00Z", "uploads_left": 18, "limits": { "max_files": 20, "max_file_size_mb": 100, "allowed_ext": ["pdf"], "ask_name": true, "name_max": 40 } } ``` ⚠ `folder` is the destination's **name**. Its contents are never listed and never counted - a file request is a blind drop. `limits.name_max` is the longest uploader name the server keeps (40 characters); the upload refuses a longer one with **400** `name_too_long` rather than cutting it. `pin_max` is present with `needs_pin`, as on a share. ### `POST /api/public/d/{token}/pin` ![public](https://img.shields.io/badge/-public-lightgrey) The same gate, the same cookie and the same statuses as the `/s/` PIN. ### `POST /api/public/d/{token}/captcha` ![public](https://img.shields.io/badge/-public-lightgrey) The [bot check](BOT-PROTECTION.md#file-requests-the-upload-pass) of one submission, before its upload: `{"captcha_token": "…"}` (or `captcha_local`) → **200** `{"pass": "…", "expires_at": "…"}` - signed, for this link only, ten minutes, spent by the upload - or the check's **422** / **409**. While `file_request` is not protected: **200** `{"pass": ""}`. ### `POST /api/public/d/{token}/upload` ![public](https://img.shields.io/badge/-public-lightgrey) While `file_request` is protected, the header `X-Captcha-Pass` carries the pass above; without one (or with a spent one, or another link's) the upload is refused before its body is read: **422** `captcha_required` / `captcha_failed` (`captcha`: `missing_pass` / `bad_pass`). `multipart/form-data` with `file[]` (or `file`), and optionally one `name[]` field per file, in the same order, carrying the file's exact name (a browser writes a `"` in a part's filename as `%22`; see [File names](FILE-NAMES.md#what-each-surface-does)), and `name` when `ask_name`. The PIN may be a form field or already answered on this browser (the cookie). Same ingest, limits, quota accounting and owner notification as the no-JS form - there is one upload path. **401** `bad_pin` · **429** `locked` / `rate_limited` · **410** `expired` · **404** `not_found` (also when the link's creator no longer holds `share.upload_links` there) · **413** `file_too_large` (the link's own limit, or its creator's largest file) · **415** `ext_not_allowed` (outside the link's list, or a type its creator's role blocks) · **422** `too_many_files` / `exceeds_remaining` · **400** `name_too_long` (an uploader name over `limits.name_max`) · **400** `bad_name` (a file name no door accepts) · **400** `file_name_too_long` (a file name longer than the storage holds). Every refusal carries `message`, the server's sentence in the visitor's language. ### Which answer `/s/` and `/d/` give A `GET` whose `Accept` names `text/html` (or `application/xhtml+xml`) is a browser navigating: it gets the **SPA shell**. Everything else gets the Go page or the bytes: - any other `Accept`, including `*/*` and an absent header - ⚠ this is what keeps `curl -O https://host/s/` downloading a file rather than collecting an HTML page, and it is why every existing test keeps measuring the server-rendered pages; - `POST` - the no-JS PIN form and the no-JS upload form submit to the same address; - the `X-Filex-Pin` header; - any of these query parameters: `nojs` (what the shell's own `