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.
- Auth & sessions
- Capabilities
- File browsing
- Uploads (multipart)
- Archives
- Sharing
- Thumbnails
- Versions
- Realtime (WebSocket)
- Operations (long-running)
- Admin: storages
- Admin: plugins
- Admin: users
- Admin: quota
- Admin: external services
- Admin: protection & antivirus
- Admin: webhooks
- Admin: sync runs
- Admin: audit log
Auth markers
| Symbol | Meaning |
|---|---|
| No auth | |
| Any authenticated user | |
| Admin role required | |
| A session/token or a signed URL — see the route |
Auth is provided either by a session cookie (filex_session) or a Bearer token (Authorization: Bearer <jwt>). Both are accepted on the same routes.
Auth & sessions
POST /api/auth/login
Local-driver password login.
Request
{ "email": "admin@local", "password": "kT9_x4Pq2Nm-BvLs" }Response 200
{
"user": { "id": 1, "email": "admin@local", "username": "admin", "role": "admin" },
"token": "eyJhbGc...",
"expires_at": "2026-05-05T12:00:00Z"
}The session cookie is set by the same response. The Bearer token is for SPA embeds that prefer header auth.
Status codes: 200 ok · 401 invalid creds · 429 rate-limited.
POST /api/auth/logout
Invalidates the session cookie / token.
GET /api/auth/oidc/start
Redirects (302) the browser to the configured OIDC issuer authorise URL.
Query: ?next=/path/to/return/to (optional)
GET /api/auth/oidc/callback
OIDC redirect target. Validates code, exchanges for tokens, creates/updates the user, sets the session cookie, redirects to next.
GET /api/auth/me
Response 200
{
"user": {
"id": 1, "email": "admin@local", "username": "admin",
"role": "admin", "groups": ["filex-admin"],
"avatar_url": "data:image/jpeg;base64,…"
}
}PATCH /api/auth/profile
Patches the caller's own email, display_name, locale, timezone and avatar_url. Absent fields are left alone.
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 asX-Filex-Presence-Avatar(same accepted shapes, same cap). Without it the row falls back to initials.
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.
Capabilities
GET /api/capabilities
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)
{
"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": "" },
"convert": { "enabled": false, "url": "", "state": "" }
},
"onlyoffice_url": "https://docs.example.com",
"drawio_url": "",
"convert_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
}Cached client-side for 1h. share_max_ttl_days is the longest life a new share link may be given (0 = no ceiling; PROTECTION.md).
⚠⚠ 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) — so for a request carrying no usable credential the url is dropped from every external.<service> entry and the flat onlyoffice_url / drawio_url / convert_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 two consumers need a real host in the browser: the draw.io iframe and the convert modal. OnlyOffice does not — the browser gets its document-server URL from the authenticated POST /api/files/onlyoffice/config (documentServerUrl) — 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 }, where requires names the external service the editor needs ("onlyoffice", "drawio", or absent for the built-in code and markdown editors).
"newdoc_types": [
{ "ext": "docx", "group": "document", "mime": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "requires": "onlyoffice" },
{ "ext": "md", "group": "text", "mime": "text/markdown; charset=utf-8" }
]⚠ 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.
⚠ 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
List the contents of a directory.
Query
| Param | Type | Default | Notes |
|---|---|---|---|
path | string | / | URL-encoded; e.g. /storage1/sub/folder |
sort | enum | name | name | size | modified |
dir | enum | asc | asc | desc |
limit | int | 1000 | max items per page |
offset | int | 0 | pagination offset |
Response 200
{
"path": "/storage1/sub",
"entries": [
{
"name": "report.pdf", "type": "file", "size": 102400,
"modified": "2026-04-22T10:00:00Z",
"mime": "application/pdf",
"etag": "abc123",
"is_image": false, "is_video": false, "thumb_url": "/api/files/thumb/42?exp=…&sig=…",
"id": 4711
},
{
"name": "photos", "type": "dir", "size": 0,
"modified": "2026-04-23T08:00:00Z", "id": 4712
}
],
"total": 2,
"storage": { "name": "storage1", "driver": "s3", "readonly": false }
}Status codes: 200 ok · 403 forbidden · 404 path missing.
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/raw?path=…
Stream the raw file bytes. Sends Content-Type, Content-Length, and honours Range: for partial GETs (video / audio scrub).
POST /api/files/move
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.
{
"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.
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.)
POST /api/files/copy
Same shape, 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. Full behaviour: Moving files between storages.
Refusals are at submit time, not in the worker: 400 unknown target adapter · 403 read-only target storage (with a hint) · 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).
⚠ Before v0.27.0 the destination's <adapter>:// 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
The unified form behind the three per-verb endpoints:
{ "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".
POST /api/files/mkdir
{ "path": "/storage1/new-folder" }POST /api/files/rename
{ "path": "/storage1/old.txt", "new_name": "new.txt" }POST /api/files/delete
{ "paths": ["/storage1/a.txt", "/storage1/sub/"] }Returns 200 + { deleted: ["..."], failed: [{ path: "...", error: "..." }] }.
GET /api/files/manager/shared-with-me
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. |
{ "files": [ /* listing entries, same shape as /api/files/manager */ ],
"storages": ["marketing"], "total": 2, "limit": 100, "offset": 0 }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. See RBAC.md.
GET /api/files/search?q=…
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. |
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. Full reference: SEARCH.md.
POST /api/files/save-text
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.
⚠ 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.
Uploads (multipart)
For files >5 MB. Smaller files can use POST /api/files/upload (single-shot multipart/form-data).
POST /api/files/upload/init
Request
{
"storage_id": 1,
"path": "storage1://big.iso",
"filename": "big.iso",
"size": 5368709120,
"mime": "application/octet-stream",
"chunk_bytes": 16777216
}⚠ mime is accepted and ignored here. The type is re-derived at finalize from the stored object and the extension, so sending a wrong one is harmless and sending a right one buys nothing. (The staged endpoint, POST /api/files/upload/staged/init, does honour it.)
storage_id may be omitted when path carries an adapter prefix; filename is optional and folded onto path when both are sent (an upload to a storage root arrives as path: "adapter://" plus a filename). chunk_bytes is a request: the server raises anything below 5 MiB and re-balances so an upload never exceeds 10 000 parts — use the part_size it answers with.
Response 200
{
"upload_id": "u_AbCdEf",
"part_urls": [
"https://s3.example.com/...&partNumber=1&X-Amz-Sig=...",
"https://s3.example.com/...&partNumber=2&X-Amz-Sig=..."
],
"part_size": 16777216,
"part_count": 320,
"expires_at": "2026-04-29T00:00:00Z"
}part_urls is a flat list of URLs, one per part in order — the browser PUTs each chunk straight to its own URL, then calls /finalize (or /abort).
⚠ There is no chunk-through-filex fallback on this endpoint. A driver that cannot do multipart at all (local, sftp, ftp, webdav) answers
501 storage does not support multipart uploadatinit— earlier versions of this page described aPOST /api/files/upload/chunkroute as the fallback; that route does not exist. Measured 2026-08-19.
⚠⚠ A plugin storage that declares
multipartpasses the check atinitand then usually answers no part URLs (part_urls: null), because a plugin's multipart is built for the staged-upload commit, where filex pushes the parts itself. There is nothing for the browser to PUT to — use the staged path for plugin storages.
⚠ This whole endpoint is the older browser-chunked path, kept for older embedders. No filex client speaks it any more: the staged path (UPLOADS.md) replaced it everywhere, works on every driver, and is the only one that can resume.
POST /api/files/upload/finalize
{
"upload_id": "u_AbCdEf",
"etags": [
{ "part": 1, "etag": "..." },
{ "part": 2, "etag": "..." }
]
}Response 200 { "id": 99, "path": "/storage1/big.iso", "size": 5368709120, "etag": "..." }
POST /api/files/upload/abort
{ "upload_id": "u_AbCdEf" }Cancels the upload and discards staged chunks.
Archives
Server-side zip handling.
⚠ There is no archive size limit. This page used to name a FILEX_LIMITS_MAX_ARCHIVE_BYTES "default 1 GiB"; no such variable is read and internal/api/handlers/archive.go performs no size check, so an operator who believed the cap existed had none. Bound it at the proxy, or with the storage quota, until the handler grows one.
POST /api/files/archive/list
Request
{ "path": "/storage1/archive.zip" }Response 200
{
"entries": [
{ "name": "a.txt", "size": 100, "is_dir": false, "modified": "..." },
{ "name": "sub/", "size": 0, "is_dir": true, "modified": "..." }
]
}POST /api/files/archive/extract
{
"path": "/storage1/archive.zip",
"dest": "/storage1/extracted/",
"members": ["sub/a.txt"]
}dest defaults to the archive's own folder. members extracts just those entries; omit it for the whole archive.
⚠ There is no overwrite flag. This page used to document "overwrite": false and extraction has always overwritten by name — no handler field, no check — so a caller who passed it got a 200 and the opposite of what they asked for. (No endpoint uses DisallowUnknownFields, which is why the key vanished silently.)
Returns 202 + { operation_id: "op_..." } and runs in background.
POST /api/files/archive/add
{
"path": "/storage1/bundle.zip",
"files": [
{ "source": "/storage1/a.txt", "name": "a.txt" },
{ "source": "/storage1/sub/report.pdf", "name": "docs/report.pdf" }
]
}path is the archive to write, files[].source is what to read and files[].name is where it lands inside the zip. ⚠ The {paths, dest, compression} body this page used to show was never the contract — the handler requires path + files and answers 400 missing path or files for anything else. Returns 202 + { operation_id: "op_..." }.
Sharing
PIN-protected, time-limited, optionally download-capped public links.
POST /api/files/share
Request
{
"path": "/storage1/report.pdf",
"ttl": "168h",
"max_downloads": 10,
"pin": "1234",
"comment": "for the auditors"
}Response 200
{
"id": 42,
"url": "https://files.example.com/s/Xy3kPq",
"token": "Xy3kPq",
"expires_at": "2026-05-05T12:00:00Z",
"expiry_clamped": false,
"max_downloads": 10
}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). 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
List shares the caller owns.
Response 200
{
"shares": [
{ "id": 42, "path": "/storage1/report.pdf", "token": "Xy3kPq",
"expires_at": "...", "max_downloads": 10, "downloads": 3, "created_at": "..." }
]
}DELETE /api/files/share/:id
Revokes a share.
GET /s/:token
HTML viewer page (server-rendered Vue island).
POST /api/share/:token/verify
{ "pin": "1234" }Returns short-lived download_token to be used with /api/share/:token/download.
GET /api/share/:token/download?dt=…
Streams the file. Increments the download counter; rejects if exceeded.
Thumbnails
GET /api/files/thumb/{id}
Query
| Param | Type | Notes |
|---|---|---|
exp | int | Unix seconds the stamp expires at |
sig | hex | HMAC-SHA256 of "<id>.<exp>" under settings.thumb_signing_key |
One rendered size (there is no size parameter). Serves image/jpeg with Cache-Control: private, max-age=86400, or 404 when the node's thumbnail state is not ready.
Takes one of two proofs, and 401s with neither:
- the stamp above, which the file listing already puts on every
thumb_urlit emits — that is what lets a bare<img src>render, since it sends noAuthorizationheader and theSameSite=Laxsession cookie is not sent by an<img>in a third-party embed; - or an authenticated caller (cookie / bearer) who clears the node's tenancy, the token's
root:confinement and an ACL check at viewer level. An unreachable node answers 404, a readable-but-not-permitted one 403.
⚠ The stamp is a capability for one node's preview until exp, not an identity. FILEX_THUMBS_URL_TTL (default 24h) bounds it. See thumbnails.md.
⚠ The public folder-share page does not use this route; it serves the same artefact via /s/{token}/f/<path>?thumb=1, scoped to the share token.
Versions
Snapshots of a file's earlier content. Storage layout, retention and the overwrite guard are in TRASH-VERSIONING.md.
⚠ Every route here takes a raw node_id, so every one of them resolves the node and authorizes it before acting: existence → tenancy → root: confinement → RBAC. The first three answer 404 (indistinguishable from a node that does not exist); the RBAC refusal is 403 insufficient permission.
GET /api/files/versions
?node_id=N — the version timeline. Requires viewer on the file: a viewer can already read it, so its history is not withheld from them.
POST /api/files/versions/snapshot
{"node_id": N} — record the current content as a new version. Requires editor: it writes an object into the node's storage.
POST /api/files/versions/restore
{"node_id": N, "version_id": V, "snapshot_current": false} — copy version V back over the live file. Requires editor.
⚠ A destructive write: it goes through the same pre-write guard as every other write surface, so the bytes it replaces are snapshotted first and a snapshot that cannot be taken answers 503 SNAPSHOT_FAILED rather than destroying them. snapshot_current only does work when that guard is off (FILEX_VERSIONS_ON_OVERWRITE=0) — otherwise the guard has already taken it and recording the same bytes twice would spend a retention slot on a duplicate.
DELETE /api/admin/versions/{id}
Hard-delete one version row and its backing .versions/… object.
Realtime (WebSocket)
POST /api/files/ws-ticket
Mints a short-lived ticket for the socket, and returns the absolutews_url to open. An embedded client that cannot send a cookie uses this; a same-origin browser can upgrade with its session instead.
GET /api/ws
The WebSocket. Subscribe to the folders on screen and receive presence frames (who else is here, what they have focused) and change frames (something in this folder moved).
⚠ Two things break integrations if they are not known:
- an explorer with a healthy socket does not poll — the 12 s re-listing is the fallback for a socket that failed. A reverse proxy that does not pass the upgrade on this path turns live updates into a folder that refreshes twice a minute, silently;
- the same-origin, cookie-authenticated upgrade is origin-checked, so the proxy must preserve the real
Hostheader.
Frame shapes, the coalescing contract (a burst is merged into one frame per window, carrying count) and the client-side debounce advice are in REALTIME.md.
Operations (long-running)
Copy / extract / archive create kick off background ops.
GET /api/files/ops
List the caller's ops.
Response 200
{
"ops": [
{
"id": "op_AbCd", "kind": "copy", "status": "running",
"progress": 0.42, "started_at": "...", "eta_seconds": 120
}
]
}GET /api/files/ops/:id
Single op detail; same shape + final error if failed.
status is one of queued | running | completed | failed | cancelled.
POST /api/files/ops/:id/cancel
Best-effort cancel. Returns 200 regardless; check status afterwards.
Admin: storages
GET /api/admin/storage-drivers
The config contract every registered storage driver declares: its fields, their type, which one is the storage root, which hold credentials, defaults and an i18n key per label. Admin UIs render their storage forms from this instead of hardcoding a field list, and the root‑path guard reads the same declaration. Response 200
[
{ "driver": "s3", "label": "S3 / Hetzner / MinIO", "i18n_key": "storages.driver.s3",
"capabilities": { "read": true, "write": true, "presign": true },
"fields": [
{ "key": "bucket", "type": "string", "required": true, "secret": false,
"label": "Bucket", "i18n_key": "storages.fields.bucket" },
{ "key": "prefix", "type": "string", "required": true, "root": true,
"label": "Prefix", "i18n_key": "storages.fields.prefix" }
] }
]See STORAGE.md → Driver descriptors. GET /api/capabilities keeps its plain storage_drivers: []string name list.
A plugin driver (
plugin:<name>) appears in this list too, with the fields the plugin described — which is what lets an admin form render a driver that did not exist when the frontend was built. See PLUGINS.md.
Admin: plugins
Storage drivers that live outside the binary. Instance-wide, and in multi-tenant mode supertenant-only (a tenant admin gets 403 supertenant_only, not an empty list). With FILEX_PLUGINS_DISABLED=1 every route answers 503 plugins_disabled. ⚠ The two are checked in that order: a tenant admin is refused before being told whether the operator has plugins switched on. Full picture: PLUGINS.md.
GET /api/admin/plugins
Every registered plugin plus its live state — the row is the admin's intent, the state is what the manager sees right now. Response 200
{
"dir": "/data/plugins",
"requires_signature": false,
"conformance": "enforce",
"plugins": [
{ "id": 1, "name": "memfs", "kind": "binary", "binary": "memfs",
"sha256": "9f2c…", "enabled": true, "version": "1.0.0",
"driver": "memfs", "state": "running", "restarts": 0,
"label": "In-memory (example)", "field_count": 1, "in_use": 1,
"capabilities": { "write": true, "delete": true, "set_mtime": true,
"presign": false, "multipart": true },
"conformance": {
"verified": true, "scratch": "selftest",
"ran_at": "2026-08-19T09:14:02Z",
"results": [
{ "name": "write", "status": "pass", "took_ms": 1180400 },
{ "name": "presign", "status": "skip", "detail": "not declared", "took_ms": 0 }
]
},
"load": { "in_flight": 0, "waited": 0, "rejected": 0, "max_in_flight": 10 } }
]
}state is one of running · starting · failed · refused · disabled; state_error carries the reason for the last two. in_use counts storages on this plugin's driver.
Top level: requires_signature says this instance refuses unsigned binaries (trusted keys are configured), and conformance is the mode — enforce · warn · off. Both are published so a surface can state the rules before an install instead of after a rejection.
Per plugin: conformance is the last probe report — verified, scratch (selftest when it ran against the plugin's own throwaway instance, storage when it ran against a real storage), and one results entry per probe with status pass · fail · skip and a detail written for the plugin's author. Absent means never probed — "unverified", which is not the same as failed. load is live: in-flight operations, callers that had to wait, and callers that were rejected (anything above 0 is a user meeting an error because the plugin is saturated).
⚠ Two different things are called
conformancein one document: a mode at the top level, a report inside each plugin. Read the level before the name.
⚠
took_msis a Gotime.Duration, whichencoding/jsonwrites as nanoseconds despite the field name. Divide by 1e6 before printing milliseconds.
POST /api/admin/plugins
Install, in one of three shapes — the Content-Type picks which:
| Shape | Body |
|---|---|
| upload | multipart/form-data with name, file and optionally signature |
| download | {"name":"…","url":"https://…","sha256":"…","signature":"…"} — the hash is required |
| remote | {"name":"…","kind":"remote","address":"http(s)://…","token":"…"} |
201 with the same object as above. 409 when the name is taken, 400 for a bad name ([a-z0-9][a-z0-9_-]{0,31}), a missing hash, a remote plugin with no FILEX_SECRET_KEY configured to seal its token, or — when requires_signature is true — a missing or unverifiable signature (a detached ed25519 signature over the binary's lower-case hex sha256).
⚠ 201 does not mean usable. Installing writes the row and starts the plugin; describe and the conformance probes happen after that, asynchronously. A plugin that declares a capability it cannot perform is accepted here and then lands in
refusedwithstate_errorcontaining "fails its own claims", and its driver is never registered. PollGET /api/admin/pluginsfor the state rather than treating the 201 as the answer.
POST /api/admin/plugins/{id}/upgrade
multipart/form-data with file (and signature when required). Replaces a binary plugin's file while keeping the row, the name, the driver and every storage built on it — remove+install would take the registration with it, and a storage whose driver has gone cannot open.
Sequence: stop → swap the file → start → describe → conformance. 200 with the plugin's status when the new binary comes up. Otherwise 400 with {"error": "…the previous one was restored", "plugin": {…}} — the old binary is put back and started again, and the body carries the status so a page can show what is running now instead of leaving the operator guessing. 400 too for a remote plugin: it is upgraded where it runs.
PATCH /api/admin/plugins/{id}
{"enabled": true|false}. Disabling unregisters the driver, so storages on it stop opening — they are not deleted.
POST /api/admin/plugins/{id}/restart
Stop and start it. The way out of refused once the cause is fixed. The conformance probes run again on every start, so a fixed plugin proves itself without an extra step.
DELETE /api/admin/plugins/{id}
204. Removes the registration and, for a binary plugin, its directory. Storages created on it are left in place.
GET /api/admin/storages
Response 200
{
"storages": [
{ "id": 1, "name": "Local", "driver": "local", "readonly": false,
"config_summary": "/var/lib/filex/local-storage", "last_sync": "..." }
]
}POST /api/admin/storages
Request (driver-specific fields)
{
"name": "Hetzner archive",
"driver": "s3",
"config": {
"bucket": "...", "region": "...", "endpoint": "...",
"access_key": "...", "secret_key": "..."
},
"readonly": false,
"sync_interval": "5m"
}Response 200 { "id": 7, "name": "Hetzner archive", ... }
⚠ A storage on a plugin driver (
plugin:<name>) is probed against this exact configuration before the row is written: filex opens the driver, exercises every capability the plugin declared inside a scratch folder (.filex-conformance-<random>, removed afterwards) and answers 400 with the failing probe if it does not hold up — includingthe plugin providing "plugin:x" is not runningwhen the driver is not currently registered. Built-in drivers are not probed;FILEX_PLUGIN_CONFORMANCE=off(orwarn) skips the gate. The whole check is bounded at 2 minutes, so a plugin that accepts connections and then says nothing cannot hang the save. See PLUGINS.md → Conformance.
PATCH /api/admin/storages/:id
Same body shape; partial updates allowed. A plugin storage is re-probed on every change — the operator may have just pointed it at a different bucket, and a configuration that half works fails the same way a half-working plugin does: in the user's hands, looking like filex.
DELETE /api/admin/storages/:id
Removes the storage and its DB cache rows. Files in the underlying backend are not deleted.
POST /api/admin/storages/:id/sync
Triggers an immediate sync run. Returns 202 + { run_id: "..." }; poll via /api/admin/sync-runs/:id, or read this storage's history at GET /api/admin/storages/:id/sync-runs.
POST /api/admin/storages/test
Validates a connection without persisting. ⚠ The candidate configuration is in the body — there is no :id in this path, because the usual caller is the create form, which has no storage to name yet.
Admin: users
GET /api/admin/users
List users. In multi-tenant mode the list is confined to the caller's tenant (the supertenant sees all). Each row carries used_bytes and quota_bytes (0 = unlimited) and enabled, so a usage table costs one call.
GET /api/admin/users/{id}
POST /api/admin/users
{
"email": "newuser@example.com",
"display_name": "New User",
"role": "user",
"password": "...",
"provider_id": 3
}provider_id homes the user in a tenant. Omit it and the user lands in the caller's tenant. A tenant admin may only name their own provider (403 otherwise); an id that matches no provider is 400. There is no foreign key behind the column, so it is validated here.
PATCH /api/admin/users/{id}
Partial update — only the fields present in the body are touched: password, display_name, role, locale, timezone, enabled, provider_id.
enabled: false cuts access without deleting anything: the account cannot log in (local, OIDC or /dav), existing sessions stop working, and every API token it minted is refused. Files, quota and grants are untouched. Disabling — like deleting or demoting — the last admin is refused with 409.
provider_id re-homes the user into another tenant. Restricted to an unscoped or supertenant caller (403 otherwise).
GET|POST|PATCH /api/admin/users/{id}/quota
Read or set one user's quota — see Admin: quota. POST /api/admin/users/{id}/quota/recompute rebuilds used_bytes from node sizes.
DELETE /api/admin/users/{id}
Deletes the account row. The last remaining admin cannot be deleted (409), not even by itself.
What happens to their files: nothing. No storage object is ever removed. The node rows survive and nodes.owner_id becomes NULL, so the files become unowned — still present, still listed, still reachable by anyone whose access does not depend on that user. Deletion is not a way to reclaim space; move or delete the files first if that is the intent.
Precisely, on DELETE:
Kept, with the user dropped (SET NULL) | Removed with the user (CASCADE) |
|---|---|
nodes.owner_id — the files themselves | sessions |
shares.created_by — see below | api_tokens |
file_grants.created_by | file_grants.user_id — access granted to them |
audit_log.user_id — history stays readable | notifications, user_node_meta, node_comments |
Share links the user created stay live: public resolution never looks at created_by. But revoking one does, and a NULL creator matches nobody — so an orphaned link can only be revoked by an admin, via DELETE /api/admin/shares/{id}. Audit those before deleting a user who shared a lot.
Their usage_bytes row goes with the account, so those bytes stop counting toward any per-user total while the files remain on storage. To keep the account's history and quota intact, prefer enabled: false over deletion.
Admin: quota
Quota is per user. There is no per-provider (tenant) quota — see MULTI-TENANCY.md. Every id in this section is a user id; passing a provider id answers 404.
Two spellings, same handlers:
| Nested (preferred) | Flat (original) |
|---|---|
GET /api/admin/users/{id}/quota | GET /api/admin/quota/{user_id} |
POST / PATCH /api/admin/users/{id}/quota | POST /api/admin/quota/{user_id} |
POST /api/admin/users/{id}/quota/recompute | POST /api/admin/quota/{user_id}/recompute |
GET …/quota
{ "used_bytes": 1234, "quota_bytes": 5368709120, "percent_used": 0.00002, "unlimited": false }A user who has never had a quota set reads back quota_bytes: 0, unlimited: true — that is not an error. An id that names no user is 404.
POST|PATCH …/quota
{ "quota_bytes": 5368709120 }0 means unlimited; negative is 400. Returns the fresh snapshot.
POST …/quota/recompute
Rebuilds used_bytes from the summed size of the nodes the user owns. Worth running after bulk imports, or after deleting a user whose files were left behind (their bytes stop being attributed to anyone).
The caller's own snapshot is at GET /api/files/quota/me.
GET /api/files/quota/storages
"How full is this drive", for somebody who is not an administrator — the same per-storage total /api/admin/storages carries in stats.total_size_bytes, for the storages the caller is allowed to see.
{ "storages": [ { "name": "team", "used_bytes": 85022, "file_count": 31 } ] }⚠ It is not /api/files/quota/me under another name. That one is a per-USER sum across every storage (SUM(nodes.size) WHERE owner_id = me); printing it under one drive's name would be a number about the person wearing a label about the drive. This is a property of the drive, and every reader of the same drive gets the same figure.
The filter is the one the explorer's own root listing applies: list the enabled storages, drop every one whose RBAC grant set is not StorageVisible. A storage the caller cannot open is not reported at all — not its size, not its name, not a zero row. Tenancy is closed before that (the handler reads the tenant-scoped store), and a root-confined caller — an API token carrying root:<adapter>://<rel>, or a trusted proxy's X-Filex-Root — is looking at a folder rather than a drive, so it is answered with the storage only when the confinement is the storage root, and with an empty list otherwise.
Cost: one COUNT(*) + SUM(size) aggregate per visible storage, memoised process-wide for 15 s and keyed by storage id, so a page that shows every drive costs one pass per drive per quarter-minute no matter how many people have it open. A storage whose count fails is omitted rather than reported as 0; the caller's card falls back to naming the kind of thing.
Admin: external services
⚠ Instance-wide, and in multi-tenant mode supertenant-only. There is one document server, one converter, and one shared JWT secret behind them, so this surface decides where every tenant's documents are sent and what credential signs the handoff. A tenant admin gets 403 supertenant_only on every verb here, reads included. Single-tenant installs are unaffected — the ordinary admin still administers everything. See MULTI-TENANCY.md.
GET /api/admin/external
Response 200
{
"entries": [
{ "Name": "onlyoffice", "Enabled": true, "URL": "https://docs.example.com",
"SecretEnc": "***", "OptionsJSON": "{}",
"LastCheck": "2026-09-06T08:28:32Z", "LastState": "ok",
"env_managed": false },
{ "Name": "drawio", "Enabled": false, "URL": "", "SecretEnc": "",
"OptionsJSON": "{}", "LastCheck": null, "LastState": "unconfigured",
"env_managed": true }
]
}LastState is one of ok | unreachable | disabled | unconfigured | unknown. Secrets are never returned — a configured one reads "***".
env_managed says the service is pinned by env/config.yaml. Its row is re-asserted from there at every boot, so a PATCH to it applies immediately but does not survive a restart.
PATCH /api/admin/external/:name
{ "enabled": true, "url": "https://docs.example.com", "secret": "…",
"options_json": "{}" }Every field is optional; an omitted one keeps its stored value.
⚠ A secret of exactly "***" is ignored, because that is what GET returns in place of a stored secret and a UI that re-sends what it was shown would otherwise overwrite the real one.
Response 200 — { "ok": true, "env_managed": false }, plus a note when env_managed is true.
The change is live: the running process reads this row on every use, so the editor, the diagram embed and the converter pick it up on the next request. No restart.
POST /api/admin/external/:name/test
Probes the service's health endpoint — ${url}/healthcheck for onlyoffice, ${url}/healthz for convert, the bare URL for drawio — with a 3 s timeout, stores the verdict on the row and returns 200 + { ok, name, reachable, url, state }. An unknown name is 404.
⚠ Reachable is not the same as configured: OnlyOffice also needs a JWT secret, and a Document Server with no secret set in filex answers this probe happily while refusing every editor session.
Admin: protection & antivirus
⚠ Instance-wide, and in multi-tenant mode supertenant-only. Every value here is a single global row: switching antivirus off, or pointing clamd elsewhere, does it for the whole instance. The read is gated too — it returns the clamd address in force and a live reachability probe. A tenant admin gets 403 supertenant_only on every verb here, reads included. Single-tenant installs are unaffected — the ordinary admin still administers everything. See MULTI-TENANCY.md.
GET /api/admin/protection
Returns the trash-retention window, the version keep count, the share-link life ceiling and the whole antivirus block — the switch, the mode, the clamd address, the size ceiling, the editor save-scan window — plus a status sub-object describing what this process is actually doing: what would answer (clamscan / clamdscan / clamd), whether it is reachable, its version, and restart_pending.
⚠ restart_pending is true for as long as the stored configuration differs from what the running process booted with. The switch, the mode and the address take effect at the next restart, in both directions; the size ceiling and the save window apply to the next file scanned.
PATCH /api/admin/protection
Partial update; echoes the fresh GET shape. Values are validated on save rather than clamped later — an out-of-range number or an address like clamav 3310 is a 400, and daemon mode with no address is refused as well.
⚠ The scanner binary is deliberately not settable here: it is a path this server executes, so an admin-writable field would turn an admin account into arbitrary command execution. It stays in FILEX_CLAMAV_BIN. Full semantics: PROTECTION.md.
Admin: webhooks
Five routes under /api/admin/webhooks manage webhook v2 targets — rows, each with its own URL, its own signing secret and its own per-event allow-list. GET masks the secret to a secret_set boolean and never returns the value. One event produces one POST per matching destination.
| Route | Purpose |
|---|---|
GET /api/admin/webhooks | List targets (secrets masked) plus each one's last delivery. |
POST /api/admin/webhooks | Create: name, url, optional secret, optional events allow-list, enabled. |
PATCH /api/admin/webhooks/:id | Partial update. |
DELETE /api/admin/webhooks/:id | Remove the target. |
POST /api/admin/webhooks/:id/test | Send a test delivery. |
⚠ /api/admin/notify governs the legacy single webhook (FILEX_WEBHOOK_URL); these govern the v2 targets. The event catalogue — including file.updated, which from v0.34.0 replaces file.uploaded for a write that overwrote an existing file — is in NOTIFICATIONS.md.
Admin: sync runs
GET /api/admin/sync-runs
Query: ?storage_id=…&limit=50&offset=0
Response 200
{
"runs": [
{
"id": 12, "storage_id": 1, "status": "completed",
"started_at": "...", "finished_at": "...",
"added": 4, "updated": 2, "removed": 1, "errors": 0
}
]
}GET /api/admin/sync-runs/:id
Includes per-error detail array.
Admin: audit log
GET /api/admin/audit
Query: ?user_id=&action=&from=&to=&limit=100
Response 200
{
"entries": [
{
"entry": {
"id": 9001,
"user_id": 1,
"action": "share.create",
"target_type": "share",
"target_id": "42",
"metadata": { "ttl": "168h", "max_downloads": 10 },
"ip": "1.2.3.4",
"created_at": "2026-09-05T10:11:12Z"
},
"user_email": "admin@local"
}
],
"total": 1,
"limit": 100,
"offset": 0
}⚠ On a demo instance (FILEX_DEMO_MODE) ip comes back as hidden on the demo. The page itself stays readable — it is one of the operator surfaces a demo exists to show — but the addresses in it belong to the other visitors, and on a public demo every visitor can read it. The same masking applies to the recent_activity block of GET /api/admin/dashboard, which carries the same rows. An ordinary install is untouched; see DEMO.md.
⚠ Both the envelope and the action list on this page used to be invented. The key is entries (not events), each row wraps the entry under entry with user_email beside it, and the fields are target_type / target_id / metadata / created_at — not resource / meta / ts / ua.
action values are produced by exactly one place, internal/auth/audit_middleware.go, and this is the whole set:
auth_provider.test · auth_provider.update · external.test · external.update · file.archive_add · file.archive_extract · file.delete · file.restore · file.star · file.tags_set · file.upload · file.upload_abort · profile.password_change · profile.update · search.rebuild · settings.update · share.create · share.delete · share.revoke · sharex.upload · storage.create · storage.delete · storage.sync_trigger · storage.test · storage.update · sync.action · totp.disable · totp.enroll · totp.verify · trash.empty · user.create · user.delete · user.password_reset · user.quota_recompute · user.quota_set · user.update · version.delete · version.restore — plus AI-admin calls, which carry the same names under an ai. prefix.
⚠ Filtering by ?action= is an exact match, so the eight values this page used to list and no code ever writes (auth.login, auth.logout, auth.failed, file.move, file.copy, storage.add, user.disable, admin.config_change) returned an empty page forever. Note in particular storage.create, not storage.add. Sign-in and sign-out are not audited at all today; do not build an alert on them.
Error envelope
All error responses use the same shape:
{
"error": "validation_failed",
"message": "size must be > 0",
"details": { "field": "size" }
}Common error codes: unauthorised, forbidden, not_found, validation_failed, rate_limited, conflict, internal, storage_unreachable, quota_exceeded.
