Skip to content

Files Recycle Bin Developer Guide

Ed Mozley edited this page Nov 15, 2026 · 1 revision

πŸ—‘οΈ Files recycle bin - Developer Guide

5.4.0 Β· Issue #293 Β· PR #353 by @cloudcogsio Β· User page: Files Β§ Deleting and the recycle bin Β· Decisions: Recycle bin - design Β· The delete/move rule: Folder control - Developer Guide

How a deleted file or folder waits in the bin, comes back, or goes for good - rows, shortcuts and the stored bytes - and the four rules that keep it from destroying something it should not.


1. πŸ“ The files

πŸ—„οΈ schema Β· 🧠 service Β· πŸ”Œ endpoint Β· ⏰ job Β· πŸ–₯️ UI Β· πŸ› οΈ admin tool Β· πŸ§ͺ test

🎨 File What it does
🧠 includes/services/files_recycle.php Every rule, once. FilesRecycleService: entries(), contents(), restore(), purge(), emptyBin(), purgeExpired(), retryPendingBytes(), heldEntries(), describe(), saveRetention()
🧠 includes/files/acl.php FilesAcl::allFolders(), levelsIncludingDeleted(), pathIncludingDeleted() - permissions as they were before the delete. Only the bin may use them
🧠 includes/services/files_versions.php filesPurgeBytesIfUnused() - deletes a stored object only when no files_versions row names it; returns in_use / deleted / unavailable / failed
🧠 includes/files/names.php filesFreeName() - "Name (2)" on restore. Moved unchanged out of includes/files/api_bootstrap.php so the service can use it
πŸ”Œ api/files/recycle.php GET the bin / a deleted folder's files; POST restore, purge, empty. Passes input in and out, nothing else
πŸ”Œ api/files/settings.php ?tab=recycle - the retention period, behind Cap::FILES_RECYCLE
⏰ cron/files_recycle_purge.php The files_recycle_purge job: purgeExpired() then retryPendingBytes()
πŸ—„οΈ files_purge_pending Stored keys whose bytes are still to be deleted. ix_files_folders_deleted, ix_files_items_deleted
πŸ—„οΈ system_settings files_recycle_bin_days (absent = never purge, 0 = keep for ever) and files_recycle_bin_since (when purging was switched on)
πŸ–₯️ assets/js/files-desktop.js openRecycle() - the Recycle bin window; a rescue row's location column
πŸ–₯️ assets/js/files-settings.js The Recycle bin settings tab, the "would delete N" confirm, the held count
πŸ› οΈ system/files-held/index.php System β†’ Maintenance β†’ Deleted folders kept (heldEntries()); card in system/maintenance/includes/tools.php
πŸ§ͺ tests/files-recycle-bin.php The PR's suite: visibility, restore, purge, bytes, links, retention, Empty, S3 (67 checks, more with live S3)
πŸ§ͺ tests/files-recycle-held.php The clock, held folders, rescue, the race, who acts (34 checks, each with a control)

2. What an entry is

A folder or file whose own row is deleted while everything above it is live. Things inside a deleted folder are not entries - they go and come back with it, as in Windows. allEntries() builds the list from FilesAcl::allFolders():

foreach ($all as $f) {
    if (!$f['deleted']) continue;
    if ($f['parent_id'] !== null && empty($all[$f['parent_id']]['live'])) continue;   // inside another entry
    $out[] = ['type' => 'folder', 'id' => $f['id'], ...];
}

Deleting stays a soft delete (deleted_by, deleted_datetime), exactly as before the bin existed. Seeing an entry needs View; restoring or purging needs Modify - the level that deleted it. Hidden still means not found.


3. Purge: rows first, bytes after, nothing forgotten

purge() is one transaction for the rows and a loop for the bytes:

  1. Lock the entry row - SELECT deleted_datetime ... FOR UPDATE. A restore or a rescue (Β§6) of the same folder waits.
  2. Then read what goes - the subtree, its files, their stored keys. ⚠️ Read after the lock, never before: a list read earlier could include a folder brought out of it a moment ago.
  3. Record every key in files_purge_pending, in the same transaction as the rows.
  4. Delete documents' links (files_item_id = NULL, the row stays), shortcuts, uploads, versions, items, permissions, folders (deepest first).
  5. Commit. Then delete each key's bytes through filesPurgeBytesIfUnused(); a key a copy still uses is only forgotten. Storage down, or out of time (PURGE_BYTES_SECONDS, 20) - the rest stays recorded for the job.
$st = $conn->prepare("SELECT deleted_datetime FROM files_folders WHERE id = ? FOR UPDATE");
$st->execute([$id]);
$still = $st->fetchColumn();
if ($expectDeletedAt !== null && $still !== $expectDeletedAt) throw new InvalidArgumentException('Folder not found.');
FilesAcl::reset();                         // the lists are read NOW, under the lock
$folders = self::subtree($conn, $id);

4. Who acts: a person, or the job - never "0"

purge() and restore() are for a person and refuse an id below 1 ('Not signed in.'). The job goes through the private purgeAs($conn, null, ...): null is the system, which needs no permission. Every helper that used to treat 0 as "the system" (canPurge, entryFor, subtreeAtLeast, blocked) now takes ?int and checks === null.

πŸ”‘ Why: $analystId = (int)$_SESSION['analyst_id'] is 0 for a missing id. With 0 meaning "the system", one missing session value would have meant no permission checks at all. Now it means refused.


5. Retention: the clock and the held folders

The clock - files_recycle_bin_since

saveRetention() writes the switch-on time when purging goes on - the first save, or from 0 (keep for ever) to a number - and clears it at 0. Changing 30 to 60 keeps it.

private static function clockStart(array $e, ?string $since): string
{
    return ($since !== null && $since > $e['deleted_at']) ? $since : $e['deleted_at'];
}

purgeExpired(), describe() (the "would delete N" count) and entries() (purge_at) all count from clockStart(). So a folder binned 100 days before anyone chose 30 days gets 30 days from the switch-on, never "deleted within the hour". describe($askDays) while purging is off counts from now: saving it would start the clock now, so nothing is due.

When a folder is restored, things deleted on their own inside it get deleted_datetime = now - their time starts again, or their old dates would get them purged at once.

Held - heldBack()

The job never purges a folder holding anything its deleter could not have purged by hand:

private static function heldBack(PDO $conn, array $e): bool
{
    if ($e['type'] !== 'folder') return false;              // a file: deleting it needed Modify on its folder
    if ($e['deleted_by'] === null) {                         // older rows: anything that stops inheriting
        foreach (self::subtree($conn, $e['id']) as $fid) if (... !inherit) return true;
        return false;
    }
    return !self::canPurge($conn, $e['deleted_by'], $e);    // canPurge AS THE DELETER
}

A held folder is skipped and counted (purgeExpired()['held']); the job's run summary says how many and where to look. heldEntries() lists them for System β†’ Maintenance β†’ Deleted folders kept (administrators: blocking folders are named in full). The Maintenance card counts those past their date; the settings tab shows the same count without names. entries() gives a held folder no purge_at.

A folder cannot be deleted while it holds anything its deleter cannot change (Folder control), so held folders are rare: mostly permissions changed after a delete, and rows whose deleter was not recorded.


6. Rescue: deleted along with a folder above it

Somebody who can see a subfolder S of a deleted folder A, but not A, would find S simply gone - it is inside an entry they cannot see. entries() adds a row for the highest folders inside A they can see (visibleTopsInside()), with rescue => true, location => '' and can_purge => false. A's name is never in it.

Restore on such a row goes to rescue() instead of the normal path:

  1. Needs Modify on S.
  2. Lock A's row FOR UPDATE, re-check S is still under it.
  3. freezePermissions() - if S inherits, copy its effective entries (walking allFolders(), deleted folders included) in as its own and stop it inheriting. Moving it then changes nobody's access: nobody gains, nobody loses.
  4. Move S to A's parent (live, or the top level), with a free name; clear its own deleted fields; restart the time of things deleted inside it.
  5. Audit restore with out_of_deleted_folder.

Whatever S holds goes with it, hidden subfolders included - their access is unchanged, so that is not "carrying off" anything.


7. Traps

Trap Why
Read the purge lists after FOR UPDATE Β§3 - a rescue could move a folder out between the read and the delete
FilesAcl::allFolders() is cached per request Call FilesAcl::reset() after any change and before re-reading under a lock
Never pass 0 for "the system" Β§4 - use purgeAs(null, ...)
A rescue row's parent_id is null and its location is '' The UI shows Deleted along with a folder above it. Never fill in the real parent - it is hidden from them
Copies share stored bytes Always delete through filesPurgeBytesIfUnused()
Sed on these files on Windows Turns CRLF into LF. Patch with a CRLF-aware script or the Edit tool

See also: Files - Developer Guide Β· Files storage - Developer Guide Β· Recycle bin - PR review Β· Code traps

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Responses to feedback

Blue sky thinking

Bugs resolved

Pull requests

Links

Clone this wiki locally