Skip to content

Trash & version history

filex protects against two everyday mistakes — deleting the wrong file and overwriting good content. Trash turns a delete into a reversible soft‑delete with a retention window. Versioning keeps historical snapshots of a file's contents so an earlier revision can be restored.

Both features live entirely inside the storage backend you already mounted (see STORAGE.md) — filex adds a hidden .filex-trash/ and a hidden .versions/ prefix on the same disk/bucket. There is no separate trash server or version store to provision.


Trash

How trash works

Deleting a file or folder from the explorer is a soft delete, not an erase:

  1. filex renames the underlying object on its storage backend to .filex-trash/<unix>-<rand>__<basename> (a collision‑proof key under the hidden trash prefix). Nothing is removed from disk/bucket yet.
  2. The DB row's deleted_at timestamp is set, and the original path is preserved in the row's storage_key column. The row's live path / path_hash are rewritten to the trash location, so a fresh upload at the original path still works.
  3. The item drops out of normal listings (the .filex-trash/ prefix is filtered out) but stays in the database, ready to restore.

Restore reverses step 1: the object is renamed back from .filex-trash/… to its original path, and the parent directory is re‑resolved so the row re‑attaches in the right place in the tree. If the original parent no longer exists, filex falls back to a root restore rather than leaving the row orphaned in trash.

Restoring enqueues a virus scan of every file it brings back (a folder restore scans its whole subtree). The trash is also where the antivirus job puts an infected file — quarantine and a user deletion produce the identical row — so a restore can release something ClamAV condemned, and the bytes may have been sitting there since before the current signature database. The scan is asynchronous, exactly like an upload's; see PROTECTION.md.

⚠⚠ The storage sync worker does not touch the trash, and it never un-deletes a row. It used to: it walked into .filex-trash/, found an object with no live row, found the soft-deleted one, and cleared deleted_at — so a deleted file came back on its own, and a quarantined one left quarantine, at the next sync pass. See ARCHITECTURE.md for the rule that replaced it and for how an install that already took the damage repairs itself.

Two edge behaviours worth knowing:

  • If the storage driver can rename, the trash step is a rename. If it can only copy, filex copies into the trash key and deletes the source afterwards, so the bytes still survive. Only a driver that can do neither falls back to a real hard delete — and then the item is deliberately not listed in the trash, because a Restore there could never work. None of the shipped drivers (local, S3, SFTP, FTP, WebDAV) fall into that case.
  • Deleting an item that is already in trash (its path is under .filex-trash/) hard‑deletes it permanently — this is how "empty a single item from trash" works.

Every delete surface uses the same trash

Deletion is not a web‑UI‑only concept. The web explorer, WebDAV, SFTP, FTPS, NFS, the S3 gateway, the AI/REST endpoints, the MCP tools, the CLI/sync client and the asynchronous batch‑ops worker all go through one shared helper (trash.Put), so an item deleted from any of them lands in the trash the same way and is restored the same way. A protocol added later inherits the behaviour by calling that helper instead of driving the storage driver itself.

The helper never destroys data: when a backend cannot preserve the bytes it reports that instead of deleting them, and the caller decides what to do. That is also what keeps the emitted events honest — file.trashed fires only when the bytes are genuinely restorable, file.deleted when they are really gone.

A folder goes to trash as one restorable unit: the folder row is retagged into the trash and its cached descendants are dragged along with it, so a single Restore brings the whole subtree back. Note that the descendants are still individual rows, and the trash listing is flat — a deleted folder therefore shows its children as separate entries even though restoring the folder is one action.

Sync clients delete in bulk. A single rclone sync --delete run can remove hundreds of files, and every one of them now lands in the trash. That is the point — the run is recoverable — but it also means a bulk delete does not free space until the retention window passes, and each file adds a row to the flat trash listing. Watch storage headroom after a large sync, and use the admin "empty trash" action when you need the space back immediately.

Quota and the trash

Trashed items keep counting against the owner's quota. Usage is decremented when an item is purged, not when it is trashed (see trash.purgeOne), and that is deliberate: bytes parked in .filex-trash/ still occupy the backend. Deleting does not free space — emptying the trash does. Every surface follows the same rule; none of them adjust quota at delete time.

⚠ Until v0.20 this was theory: nothing incremented usage_bytes at all, so nothing was counted and nothing was ever released either. The accounting is real now — see Quotas for the full set of rules (overwrite, move, restore, copy, purge) and where they live.

Retention & purge

Trashed items are kept for a fixed window, then hard‑deleted automatically.

SettingWhereDefaultMeaning
trash.retention_daysDB settings table30Days a soft‑deleted item survives before automatic purge. Missing, non‑numeric, or ≤ 0 values fall back to 30.

A daily background loop scans for nodes whose deleted_at is older than the retention window and, for each one:

  1. deletes the backing storage object (best‑effort — if the driver delete fails, the run logs a warning and still continues);
  2. decrements the owner's quota usage (files only);
  3. hard‑deletes the DB row.

The first tick fires one interval after startup, not immediately, so a restart‑looping server doesn't hammer the backend. The purge is batched (500 rows at a time) and reports a summary (scanned / deleted / failed / bytes).

Trash endpoints

User (authenticated session/token):

Method & pathBody / queryNotes
GET /api/files/manager/trash?storage_id=…&limit=…&offset=…Lists soft‑deleted items. limit defaults to 50 (max 500). Each entry shows the original name/path (not the internal trash key), deleted_at, size, storage_name, and ttl_days (days remaining before purge, floored at 0).
POST /api/files/manager/restore{ "node_id": 123 }Moves the file back to its original path and re‑attaches the row. Returns 409 { "code": "EXISTS", "name", "path" } when something already holds that path; nothing moves and the entry stays in the trash.

Both are filtered by access: a confined (root‑locked) caller only sees / can restore items whose original path is inside its root, and RBAC requires ≥viewer to see an item in the list and ≥editor on its original path to restore it (restore writes the file back).

An entry is judged on the path it was deleted from, never on its trash key. A row old enough to record no original path — its path is still inside .filex-trash/ — has nothing to judge, so it is neither listed nor restorable, for anybody; an admin can still purge it. Judging it on the bin would hand the answer to whoever holds a grant on .filex-trash/.

Admin only:

Method & pathBody / queryNotes
POST /api/admin/trash/empty?older_than_days=N or JSON { "older_than_days": N, "storage_id": … }Immediate purge of everything older than N days. 0 or missing wipes everything currently in trash. Returns { ok, purged, failed, scanned, bytes }.
DELETE /api/admin/trash/{id}Immediately hard‑delete one trashed node (storage object + quota + row).

Trash — failure modes & troubleshooting

A restored file reappeared at the storage root, not its old folder. Its original parent directory was itself deleted in the meantime. filex prefers a root restore over orphaning the row — move the file back manually once the folder exists again.

Restore answers 409 EXISTS. A file or folder now holds the original path. filex refuses rather than overwrite it or pour one folder into another. Rename or move what is there, then restore again.

Restore reports success but the file isn't back on disk. The DB flag is cleared best‑effort: if the driver's move step fails, filex still un‑trashes the row and logs a warning (trash restore move failed). Find the object under .filex-trash/ on the backend and move it to the original path by hand.

An item vanished from trash before its ttl_days reached 0. Either an admin ran empty trash / purged it, or it was deleted while already in trash (which is a permanent hard delete — see the edge behaviours above).

ttl_days shows 0 but the item is still listed. Purge runs on a daily tick — an expired item lingers until the next run. Admins can force it with POST /api/admin/trash/empty.

Can't delete (or restore) on a particular mount. That storage is likely read‑only — writes (including trashing and restoring) return 403 storage is read-only. See read‑only mounts.

Leftover .filex-trash/… objects on the backend. Purge deletes the DB row even when the storage delete fails (permissions, outage). The object is orphaned but harmless; delete it with your storage's own tooling.


Versioning

How versioning works

Before filex overwrites a file, it can snapshot the current bytes so you can roll back. Snapshots are copied into the same storage backend under .versions/<node_id>/<version_n>, and each is recorded as a node_versions row (version number, size, etag). Where the driver supports server‑side copy the snapshot is a fast backend copy; otherwise filex streams the bytes (read → write).

Only files are versioned. Directories and symlinks are skipped. A snapshot is also skipped when there is nothing to capture — a brand‑new file with no live content yet, or a row whose object isn't on the backend.

⚠ That last case is a silent skip: if the catalogued path and the object on the backend ever disagree, the guard finds nothing to snapshot and reports success. Every shipped driver normalises the key it is handed, so the two agree in practice — but a storage plugin that does not would lose history with no error anywhere. Plugin authors: normalise, and see PLUGINS.md.

⚠⚠ Until v0.34.0 that disagreement was not hypothetical: it happened to every moved or renamed file. Store.MoveNode did not update storage_key, so the row went on naming the old path, and versioning prefers storage_key over path — the snapshot stated ErrNotFound, reported "nothing to snapshot", and the destructive write went ahead with zero versions written. The column now follows the path on a live row, and migration 00033 repairs the rows that already exist, because nothing else would: the periodic walk writes seen_at and UpdateNodeMeta and neither statement touches this column. (It deliberately does not follow the path on a trashed row, where storage_key is the only record of where restore puts the file back.)

Restore copies a recorded version back over the live file and refreshes the node's size/etag. Passing snapshot_current: true snapshots the current content first, so the restore itself is reversible.

⚠ It also enqueues a virus scan of the restored file. Snapshots themselves are never scanned — every destructive write takes one, so scanning each would multiply the scan load by the edit rate for bytes nobody can reach — which left "overwrite the infected file with a clean one, then roll back" as a way to put an infected file live. The restore is where that is closed; see PROTECTION.md.

Version retention

SettingWhereDefaultMeaning
versions.keep_nDB settings table, written by Protection → Version retention (PATCH /api/admin/protection, accepted range 0–1000)0How many versions of a file to keep. 0 means "not configured": the daily retention sweep is off and the snapshot path applies its compile‑time safety trim of 20 instead.

So a file's history is trimmed to the newest 20 snapshots out of the box, and to keep_n when an operator sets one — the snapshot path honours the setting inline, so a value above 20 really does keep more (it used to claw every node back to 20 on the next snapshot, which made larger values meaningless). A keep_n > 0 additionally runs a daily sweep over every node that has version rows, so lowering the number reaches files nobody is editing; see Protection → Version retention.

Trimming removes both the node_versions row and the backing .versions/… object (best‑effort per object).

What triggers a snapshot

Every destructive write on the surfaces below. Each of them calls the pre‑write guard, which snapshots what is about to be lost:

SurfaceEndpoint / entry point
Browser upload (single POST)POST /api/files/manager?action=upload
Browser upload (staged / chunked)POST /api/files/upload/{id}/commit
Public file‑drop linkPOST /d/{token}
Legacy presigned multipartPOST /api/files/upload/finalize
Ticketed uploadPUT/POST /u/{ticket}
AI / REST writePOST /api/ai/upload
MCP file_write, file_zip, file_unzip/api/ai/mcp
ShareXPOST /api/sharex/upload
Archive extract / addPOST /api/files/archive/extract, /add
Text / code editor savePOST /api/files/save-text
OnlyOffice save‑backPOST /api/files/onlyoffice/callback
WebDAVPUT
S3 gatewayPutObject, CompleteMultipartUpload, CopyObject

⚠⚠ SFTP, FTPS and NFS are not in that table, and their absence is real, not an omission in the writing. Those three write through internal/protocolsync, which does not call the pre‑write guard, so a client that replaces a file over SFTP, FTPS or NFS destroys the previous bytes with no snapshot taken and no error. Everything else those surfaces share with the rest of filex — the catalogue row, the search index, the realtime frame, the antivirus scan, trash on delete — they do have. Versioning is the one gap. If version history is what you are relying on, keep those three protocols out of the write path, or check the file's history after the first overwrite rather than assuming it.

A snapshot is taken only when there is something to lose: the path already holds a catalogued file. A brand‑new file, a directory, and filex's own internal trees (.versions/, .thumbs/, .filex-trash/, .keepdir markers) cost one indexed lookup and nothing else.

This used to be untrue, and the untrue version was written down. Until the pre‑write guard landed, the only wired trigger really was the text‑editor save, while this page and the versioning package doc both described a guarantee that covered uploads and archive extraction. The practical effect was that re‑uploading a file over itself destroyed the old bytes with nothing kept, while editing the same file in the browser kept a version — so the feature looked like it worked right up until the moment you needed it.

If the snapshot cannot be taken, the write is refused. That is the whole point: losing version history is not a reason to also lose the file. The surfaces answer 503 with "code": "SNAPSHOT_FAILED" and the existing file is left untouched.

Two batch surfaces differ, deliberately. Archive extract and the AI/MCP unzip tool skip just the refused member and keep going, reporting a refused count alongside count/extracted — a guard refusal is transient and system‑caused, unlike the permanent, user‑caused skips in the same loop (a zip‑slip entry, a file/folder kind clash). If every member was refused and nothing landed, they answer 503 SNAPSHOT_FAILED with the count rather than a misleading 200 {"count":0}.

Turning it off: FILEX_VERSIONS_ON_OVERWRITE=0 makes the guard a no‑op — writes then behave exactly as they did before it existed. FILEX_VERSIONS_FAIL_OPEN=1 keeps the snapshot attempt but lets a failed one through instead of refusing the write. Both log a WARN at boot, so a non‑default state is visible without reading the config.

save-text has its own guardrails:

  • Body: { "path": "<adapter>://<relative/path>", "content": "…" }.
  • Extension whitelist: only text/code types round‑trip here — txt, md, json, jsonc, yaml/yml, toml, ini, env, csv, xml, svg, html, CSS/SCSS/LESS, JS/TS/JSX/Vue/Svelte, and common source languages (go, py, php, rb, rs, java, c/cpp/h, sh, sql, …), plus special filenames like Dockerfile, Makefile, .gitignore, .editorconfig. Anything else returns 415 extension not allowed for save-text — binary/office formats have dedicated edit channels (e.g. OnlyOffice).
  • Permission: requires ≥editor on the file (RBAC) → 403 otherwise.
  • Read‑only mount: returns 403 storage is read-only.

Versioning endpoints

User (authenticated session/token):

Method & pathBody / queryPermissionNotes
GET /api/files/versions?node_id=N≥viewerLists that node's snapshots, newest first (version number, size, etag, created).
POST /api/files/versions/snapshot{ "node_id": N }≥editorRecords the current content as a new version on demand — the "take a version now" button in the details panel's Activity tab, which is where a file's history and its comments live. Writes an object into the node's storage.
POST /api/files/versions/restore{ "node_id": N, "version_id": V, "snapshot_current": true }≥editorCopies version V back over the live file.
POST /api/files/save-text{ "path": "adapter://rel", "content": "…" }≥editorSaves text and snapshots the previous content first (see above).

Admin only:

Method & pathNotes
DELETE /api/admin/versions/{id}Hard‑delete one version row and its backing .versions/… object.

The permission column is load-bearing, and it is new. Before the release this note ships in, these routes had no ownership or ACL check of any kind: a viewer-role account could POST /restore and overwrite the live bytes of any file whose node id it could name, on single-tenant installs too. Restoring and snapshotting are writes, so they now need editor, exactly like save-text beside them; listing stays at viewer, because somebody who can read the file is not being told its history is a secret.

⚠ Every one of these takes a raw node_id, so the node is resolved and authorized before anything happens: existence (and not trashed — a trashed row's live path is its storage_key, so restoring onto one wrote bytes to a path the catalogue says holds nothing) → tenancy → the token's root: confinement → RBAC. The first three answer 404, identical to a node that never existed, so the endpoint cannot be used to discover which ids are real; only the RBAC refusal is 403 insufficient permission.

snapshot_current is honoured only when the pre-write guard is switched off (FILEX_VERSIONS_ON_OVERWRITE=0). With the guard on — the default — a restore already snapshots the bytes it is about to replace, and doing it twice would record identical content and spend a retention slot on the duplicate. See Restoring is a write below.

Restoring is a write

A restore replaces the live bytes at an unchanged path, so it is treated as one:

  • It goes through the pre-write guard. Before the copy, the bytes that are about to be destroyed are snapshotted, and a snapshot that cannot be taken refuses the restore with 503 SNAPSHOT_FAILED instead of overwriting them unrecoverably — the same contract every other write surface has. ⚠ Restore was the one write in filex that skipped this until the release this note ships in, so rolling back twice in a row destroyed whatever was live in between with nothing recorded.
  • It emits file.updated. Restores used to change a file's bytes and tell no webhook subscriber; they now go through the same post-write gate as an upload.
  • It enqueues a virus scan of the restored file — see above.
  • It needs ≥editor on the file, like every other write.

⚠ Because the guard already snapshots the outgoing content, snapshot_current only does work when the guard is switched off (FILEX_VERSIONS_ON_OVERWRITE=0). With the guard on, honouring both would record identical bytes twice and spend a retention slot on the duplicate.

Versioning — failure modes & troubleshooting

Version history is empty even though I've edited the file. Check which surface wrote it. SFTP, FTPS and NFS take no snapshot at all (see the table above) — that is the likeliest answer, and it fails silently. The other three: the guard is switched off (FILEX_VERSIONS_ON_OVERWRITE=0, which logs a WARN at boot); directories and symlinks are never versioned; and the first save of a new file has no prior content to snapshot.

A version I wanted is gone / "restore" can't find it. Retention keeps only the newest 20 versions per file — or versions.keep_n when an operator has set one — and older snapshots are trimmed after each new save. An admin DELETE /api/admin/versions/{id} also removes one permanently. Once trimmed/deleted, a version is unrecoverable.

version belongs to a different node. The version_id in a restore request doesn't belong to the node_id you sent. Re‑list with GET /api/files/versions?node_id=N and use an ID from that node.

I saved the file, but no new version appeared.save-text treats snapshotting as best‑effort: if the pre‑write snapshot fails (storage or DB hiccup) filex logs save-text: snapshot failed (continuing with write) and still saves your edit — you keep the new content, but that one pre‑edit state wasn't captured. Check the server log.

Can't save / snapshot on a particular mount. The storage is read‑only (403 storage is read-only) — no writes, so no snapshots either. Restore also writes the live file and needs a writable driver.


See also

  • STORAGE.md — mounts, adapters, read‑only mounts, quota
  • RBAC.md — viewer / editor / admin levels and confinement that gate the trash list, restore, and save‑text
  • CONFIGURATION.md — global config / env reference
  • SSO.md — sign‑in and account roles

Released under the MIT License.