Files Developer Guide - edmozley/freeitsm GitHub Wiki
How the Files module is put together: the files, the one permission resolver every endpoint asks, the audit writer, where the bytes live and why, the chunked upload protocol, the two ways bytes leave the server, the viewer, shortcuts, the window manager, and the traps found while building it. For the plain-language version see Files; for the phone layout, Mobile: Files.
Colour key: ๐๏ธ schema ยท ๐ read ยท โ๏ธ write ยท ๐ cross-module ยท ๐ฅ๏ธ UI/help ยท ๐ docs
| ๐จ | File | Purpose |
|---|---|---|
| ๐๏ธ | database/freeitsm.sql |
the seven files_* tables (files_folders, files_items, files_versions, files_permissions, files_audit, files_shortcuts, files_uploads) and tenants.logo_path
|
| ๐๏ธ | includes/db_verify_schema.php |
the same tables and tenants.logo_path in $schema, so Database Verify creates them on an upgrade |
| ๐๏ธ | api/system/db_verify.php |
the five foreign keys (all ON DELETE CASCADE; files_audit has none on purpose) |
| ๐๏ธ | includes/db_verify_indexes.php |
the ten files_* indexes |
| ๐๏ธ | includes/db_verify_modules.php |
maps the files_ prefix to the Files module for Database Verify's grouping |
| ๐ | includes/files/acl.php |
FilesAcl - the ONE place that answers "who may do what to this folder": levels, inheritance, the live tree, the watermark walk, paths |
| โ๏ธ | includes/files/audit.php |
filesAudit() - the ONE writer of files_audit, and FILES_AUDIT_ACTIONS
|
| โ๏ธ | includes/files/storage.php |
storage root, .bin paths, filesAbsolutePath() validation, the incoming folder, name cleaning, MIME detection, settings, per-person prefs, the desktop logo |
| ๐ | includes/files/api_bootstrap.php |
the start of every endpoint: session, module access, JSON, filesNeed() / filesNeedItem() ("not found" below View), filesNameTaken(), filesShortcutTarget(), filesFreeName(), filesApiRun()
|
| ๐ | api/files/list.php |
what is inside a folder (or "Documents", the top of what you can see); &tree=1 for the folder tree; breadcrumbs; the can flags. Audited as browse (tree requests are not) |
| ๐ | api/files/search.php |
names of folders and files anywhere the caller has View. Audited as search with the query |
| ๐ | api/files/recent.php |
the start menu's Recent files, read back out of files_audit and filtered through current permissions |
| ๐ | api/files/properties.php |
the Properties window: a folder's counts and size, or a file's versions with SHA-256. Audited as browse
|
| ๐ | api/files/principals.php |
analysts and teams to add in the Permissions window (active only) |
| ๐ | api/files/download.php |
streams a file as an attachment: Download level, Range, nosniff. Audited once per download |
| ๐ | api/files/view.php |
the viewer's door: POST open issues a session-held token; GET ?t= serves the bytes; POST entry audits something opened inside a zip |
| โ๏ธ | api/files/upload.php |
chunked uploads: start / chunk / finish / cancel, plus the housekeeping sweep |
| โ๏ธ | api/files/folder.php |
create, rename, delete (soft), watermark, inherit (with Copy/Remove) |
| โ๏ธ | api/files/item.php |
rename and delete (soft) a file |
| โ๏ธ | api/files/transfer.php |
move and copy - paste and drag and drop; copies share the source's .bin
|
| โ๏ธ | api/files/permissions.php |
GET the Permissions window; POST save (replace a folder's own entries); POST take_ownership
|
| โ๏ธ | api/files/shortcut.php |
create, rename, delete and place (drag and drop) shortcuts; GET ?desktop=1
|
| โ๏ธ | api/files/prefs.php |
the person's desktop colour, navbar mode, Explorer view and remembered window geometry |
| โ๏ธ | api/files/settings.php |
Files -> Settings: Storage (GET/POST) and Folders (GET every folder) |
| ๐ฅ๏ธ | files/index.php |
the desktop page: markup for the desktop, taskbar, start menu, context menu, preview and phone list, and window.FILES_BOOT
|
| ๐ฅ๏ธ | files/help.php |
the guide - sections from lang/en/files.php under help.*; ?embed=1 for the desktop's Help window |
| ๐ฅ๏ธ |
files/settings/index.php, assets/js/files-settings.js
|
Files -> Settings, Folders and Storage tabs |
| ๐ฅ๏ธ | files/settings/manifest.php |
the settings tabs and therefore the capabilities (see ยง12) |
| ๐ฅ๏ธ | assets/js/files-wm.js |
the window manager: windows, snap, taskbar previews, menus, dialogs, notifications, clock, navbar modes (ยง10) |
| ๐ฅ๏ธ | assets/js/files-desktop.js |
the apps: Explorer, folder tree, clipboard, drag and drop, uploads, Permissions, Properties, Recent, Search, Personalise, Help, start menu, desktop shortcuts, the phone list |
| ๐ฅ๏ธ | assets/js/files-viewer.js |
the viewer (ยง8) |
| ๐ฅ๏ธ | assets/js/files-icons.js |
FreeITSM's own inline-SVG icons: folders, a file page per type, app icons |
| ๐ฅ๏ธ | assets/css/files.css |
the desktop, windows, Explorer, viewer and print rules |
| ๐ฅ๏ธ | assets/css/mobile.css |
LAYER 45 - the phone list and the full-screen viewer (Mobile: Files) |
| ๐ฅ๏ธ | lang/en/files.php |
every string, desktop and help (ยง13) |
| ๐ฅ๏ธ |
assets/js/vendor/pdfjs/, assets/js/vendor/mammoth.browser.min.js, assets/js/vendor/xlsx.full.min.js
|
pdf.js, mammoth.js and SheetJS, loaded on first use only |
| ๐ | includes/capabilities.php |
Cap::FILES_MANAGE, Cap::FILES_FOLDERS, Cap::FILES_STORAGE
|
| ๐ | includes/uploads.php |
uploadPrepareDir() (deny files), uploadCleanName(), uploadDetectMime(), attachmentServeRules(), uploadFormatBytes() - shared with every other upload |
| ๐ |
includes/tenancy.php, includes/branding.php
|
the active company (for its logo) and the organisation logo fallback |
| ๐ |
api/system/tenant_logo.php, system/companies/index.php
|
a company's own logo, uploaded on System -> Companies (ยง12) |
| ๐ |
api/system/save_branding.php, api/system/get_branding.php, system/branding/index.php, lang/en/system.php
|
the Files desktop card: files_logo_mode, files_logo_position
|
| ๐ |
includes/functions.php, includes/waffle-menu.php, includes/module-colors.php, lang/en/common.php
|
the module's key, its waffle entry, colours and name |
| ๐ |
includes/feature_bingo/cards/files.php, includes/feature_bingo.php
|
seven Feature Bingo cards |
| ๐ | docker-compose.yml |
the files volume on /var/www/html/uploads/files
|
| ๐ | deploy/nginx/freeitsm.conf |
location ^~ /uploads/ { return 404; } covers the default storage root |
| ๐ | api/system/debug-tools/D009_guarded_paths.php |
probes uploads/files over HTTP and expects it denied |
| ๐ | VENDOR.md |
pdf.js 6.3.289, mammoth.js 1.13.0, SheetJS CE 0.20.3 - where each came from and what was changed |
| ๐ | README.md |
the Files row in the module table |
Everything about access is in FilesAcl (includes/files/acl.php). No endpoint works out a level any other way, and nothing anywhere has an is_admin shortcut. The header says so, in capitals:
* โ ๏ธ Admins are NOT special here. Cap::FILES_FOLDERS (which admins hold, like
* every capability) lets someone TAKE OWNERSHIP - add a Full control row for
* themselves, audited - but until they do, they see no more than anyone else.
* Never add an is_admin shortcut to this file.const NONE = 0; const VIEW = 1; const DOWNLOAD = 2; const UPLOAD = 3; const MODIFY = 4; const FULL = 5;Cumulative: a check is always level >= need. files_permissions holds grants only - one row per folder per principal (principal_type analyst or team), unique on (folder_id, principal_type, principal_id). There is no Deny and no "everyone" principal. Files have no permissions of their own; a file takes its folder's.
FilesAcl::levels($conn, $analystId) returns [folderId => level] for every folder the analyst can see at all - a folder missing from the result is invisible. Two queries per request, then PHP:
-
tree()- every folder, once. - The analyst's direct grants plus their teams' (active teams only, via
analyst_teams),MAX(level)per folder. - If there are no grants anywhere, return
[]straight away - the common case, and free. - Otherwise resolve each folder recursively: its own best grant,
max'd with the parent's resolved level whileinherit_permissions = 1. A memo doubles as a cycle guard - a corrupt loop resolves to nothing.
Both the tree and the levels are cached for the request. Anything that changes folders or grants calls FilesAcl::reset() before the next check, or it reads the old answer - upload.php resets before its finish-time re-check for exactly this reason.
Deleting a folder marks that one row. Its children are not touched, so tree() walks up from every folder and keeps only those whose every ancestor is live:
// โ ๏ธ A folder is live only if it AND every ancestor are live. Deleting
// a folder marks that one row; without this walk its children would
// drop out of the tree as orphans and anyone with a grant directly on
// one would find it floating at the top level.filesItem() applies the same rule to files: a file whose folder is not in the tree is gone.
list.php?folder=0 returns every folder you can see whose parent you cannot - not the folders with parent_id IS NULL. Someone granted only Finance / Invoices sees Invoices at the top with its path as a hint, and breadcrumbs stop at the first ancestor they cannot see.
Hidden means not found
filesNeed() is the guard every endpoint uses after the bootstrap:
function filesNeed(PDO $conn, int $analystId, int $folderId, int $need, string $what = ''): int
{
$lvl = FilesAcl::level($conn, $analystId, $folderId);
if ($lvl < FilesAcl::VIEW) filesApiFail('Folder not found.');
if ($lvl < $need) {
filesAudit($conn, $analystId, 'denied', ['folder_id' => $folderId],
['wanted' => FilesAcl::levelKey($need), 'held' => FilesAcl::levelKey($lvl), 'doing' => $what]);
filesApiFail('You do not have permission to do that here.');
}
return $lvl;
}Below View the answer is the same words a deleted or made-up id gets, so ids cannot be probed. Only a refusal at a level the caller can see is audited as denied - auditing the invisible ones would record what they guessed at.
The rule reaches further than the obvious places:
-
filesNameTaken()counts only folders the caller can see. Otherwise "that name is taken" would confirm a hidden folder exists. Two people can therefore end up with same-named top-level folders they cannot see each other's of - harmless, and the price of not leaking. - Shortcuts whose target the caller cannot see are left out of listings altogether (ยง7).
- Recent filters the audit rows through current permissions, so a file you have lost access to drops off rather than leaking its name.
-
FilesAcl::entries()- the Permissions window: a folder's own rows, then each ancestor's while inheriting, markedinheritedwith where they came from. -
folder.phpinherit- turning inheritance off withcopy: truecopies the parent's effective entries in as the folder's own,GREATEST()where one exists already. Windows' Copy/Remove. -
permissions.phpsave- replaces the folder's own entries; inherited ones are changed on the folder they come from. Each change goes into one audit row as added / changed / removed, with names resolved so the trail reads on its own. - A top-level folder's creator gets a Full control row in the same transaction, as does anyone moving or copying a folder to the top level (
filesGrantCreatorFull()intransfer.php). Creating at the top level needsCap::FILES_FOLDERS. -
Take ownership (
permissions.php,Cap::FILES_FOLDERS) works on a folder the caller cannot see - that is the point - and writes a Full control row for them, audited astake_ownershipwith the level they held before.
| Operation | Needs |
|---|---|
| browse, search, properties, view | View |
| download, copy (from) | Download - a copy is a copy taken away |
| upload, new folder, new version, shortcut in a folder, move/copy (to) | Upload on the target |
| rename, move (from), delete, change a folder shortcut | Modify |
| permissions, inheritance, watermark | Full control |
| create or land something at the top level, take ownership, Settings -> Folders | Cap::FILES_FOLDERS |
| Settings -> Storage | Cap::FILES_STORAGE |
transfer.php checks each entry on its own and returns {done, failed: [{type, id, error}]}, so one refusal does not stop the rest. Moving a folder into itself or a descendant is refused. A folder copy (filesCopyFolderTree()) brings along only subfolders and files the copier holds Download on - copying must not become a way to read what you cannot read.
filesAudit() in includes/files/audit.php is the ONE writer of files_audit. Ed's rule is that every action is audited and nothing is collapsed - unlike Knowledge, which keeps one view row per person per day, because the point of this module is being able to say exactly when somebody looked at something.
const FILES_AUDIT_ACTIONS = [
'browse', 'search', 'view', 'download', 'upload', 'new_version',
'create_folder', 'rename', 'move', 'copy', 'delete',
'permissions', 'inheritance', 'watermark', 'shortcut', 'take_ownership', 'denied', 'settings',
];An action not in the list is a programming error: it is written to error_log and nothing is stored.
-
Copied, not joined. The analyst's name and the item's path are copied into the row (
analyst_name,target_path), andfiles_audithas no foreign keys on purpose - the trail must outlive the analyst, the file and the folder it mentions.target_pathis worked out fromfolder_idplusitem_namewhen the caller does not give one. - IP and user agent are recorded from the request.
-
detailis JSON: versions and sizes, SHA-256 on upload,from/toon rename and move, the permission changes, the zip entry name, the search query. -
Best effort. An unwritable audit table never fails the action - but the failure goes to
error_log, because a silently broken audit table is the one failure nobody would notice. -
One row per download, not per range request.
download.phpaudits only a request starting at byte 0; a resumed or seeking download sends many. -
The tree is not audited.
list.php?tree=1shows names the person is already allowed to see, and expands far too often for a row each time to mean anything. Opening a folder is.
There is no reader yet. The Audit app is planned; recent.php is the only code that reads files_audit today (and it has an index for it: ix_files_audit_analyst).
Every file is stored as <storage root>/yyyy/mm/dd/<32 hex>.bin, dated by the UTC day it was uploaded. Nothing user-controlled ever reaches the filesystem: the original name, type, size and SHA-256 live in files_versions only.
Why each part:
-
.binand a random name. A.bincannot be executed by any web server, whatever the original was called, and a random name cannot be guessed or collide. - Dated folders keep any one directory small and make a manual backup or move by date easy.
-
Relative paths in the database (
2026/10/10/<hex>.bin), so the root can move without a data migration. -
Never served directly.
uploadPrepareDir()drops deny files into the root (Apache.htaccess, IISweb.config); nginx has no in-directory equivalent, sodeploy/nginx/freeitsm.confreturns 404 for/uploads/. On Docker the root is thefilesvolume. D009 (api/system/debug-tools/D009_guarded_paths.php) fetches a probe file fromuploads/filesover HTTP and fails if it comes back.
/**
* The absolute path of a stored version, or null if the row's path is not one
* we could have written. A row reading "../../config.php" must never be served.
*/
function filesAbsolutePath(PDO $conn, string $rel): ?string
{
if (!preg_match('#^\d{4}/\d{2}/\d{2}/[0-9a-f]{32}\.bin$#', $rel)) return null;
return filesStorageRoot($conn) . '/' . $rel;
}Both download.php and view.php go through it. A path that does not match answers 410, as does a .bin missing from disk (logged, because it means a broken restore or a moved root).
- Default
uploads/files/inside the app (filesDefaultStorageRoot()); stored insystem_settingsasfiles_storage_root, empty meaning the default. - Settings -> Storage validates a new root: no
.., must exist, must be writable. It moves nothing - the page tells the operator to copy the old root's contents first. Moving gigabytes inside a web request is not something to attempt. -
files_max_upload_mb, default 4096, 1 MB to 1 TB. - Storage usage counts distinct
storage_paths, because copies share them (below).
A copied file gets its own files_items row and a version 1 that points at the source's storage_path. A 2 GB copy is instant. The consequence is written into the schema:
-- One row per uploaded version. A copied file SHARES its source's storage_path
-- rather than duplicating the bytes, so a purge must check nothing else points
-- at a .bin before deleting it.Nothing purges today - deletes are soft and the .bin stays - but whoever writes the recycle bin's "empty" must honour this. ix_files_versions_path is there for that check.
folder.php and item.php delete by setting deleted_datetime / deleted_by. A folder delete marks only that row; tree() hides everything below it (ยง2). The foreign keys are ON DELETE CASCADE, but nothing hard-deletes a folder, so they only matter to a future purge.
How a 2 GB .exe gets in past PHP's upload_max_filesize, post_max_size and any proxy's body limit: the browser sends it in FILES_CHUNK_BYTES (4 MB) pieces, as raw bytes, not multipart.
POST upload.php?action=start {folder_id, name, size, on_conflict?: ask|version|rename}
-> {token, chunk, name} or {conflict: true, item_id, name}
POST upload.php?action=chunk&token=&offset= <raw bytes> -> {received}
POST upload.php?action=finish {token} -> {item_id, version_no, name}
POST upload.php?action=cancel {token}
-
start checks Upload on the folder and the size against
filesMaxUploadBytes(). A name clash answersconflict: trueunlesson_conflictsaysversion(a new version of that file) orrename(filesFreeName(): "Report (2).pdf"). A clash with a folder or shortcut of the same name is always renamed. It writes afiles_uploadsrow and an empty<root>/_incoming/<token>.part. -
chunk must arrive in order.
offsetgreater thanreceived_bytesis refused with the server'sreceived;offsetat or below it truncates back to that point and writes there, so retrying the last piece is safe. More than one chunk's worth, or more than the declared size, is refused. -
finish calls
FilesAcl::reset()and checks Upload again - a grant removed while a big file was going up must stop it landing. Then the size must match exactly, the file is hashed (hash_file('sha256')), renamed intoyyyy/mm/dd/<hex>.bin, its MIME sniffed, and the item, version andcurrent_version_idwritten in one transaction. A new version whose file was deleted or moved away meanwhile becomes a new file instead. Audited asuploadornew_versionwith size and SHA-256. -
Tokens belong to their uploader: every action after start looks the row up by token and
analyst_id.
There is no cron. Each start sweeps uploads abandoned more than a day ago (tab closed, network gone) - up to 50 rows and their .part files - and any .part file older than a day that has no row.
Uploads in files-desktop.js runs two files at a time (MAX_PARALLEL = 2) by XHR so it gets progress events, asks Replace / Keep both / Skip on a conflict (with "do this for every clash" when more are queued), and sets beforeunload while anything is queued or running. A failed piece fails the upload and sends cancel; it does not retry or resume.
Two endpoints, because there are two different permissions.
GET ?id=<file>[&version=<version id>]. Download on the file's folder, checked on every request; a View-only caller gets 403 "You can view this file but not download it." and a denied row. Always:
header('Content-Type: application/octet-stream');
header('X-Content-Type-Options: nosniff');
header('Content-Disposition: attachment; filename="..."; filename*=UTF-8\'\'...');
header('Accept-Ranges: bytes');
header('Cache-Control: private, no-store');Nothing uploaded here is ever rendered by the browser as a FreeITSM page. Single Range: bytes= requests get 206 (or 416), so a dropped 2 GB download resumes.
View-only exists: somebody may see a document without taking a copy. download.php refuses them, and view.php must not become a download link by another name. So the viewer never asks for ?id=:
-
POST
{action: open, id, version?}- View on the folder. Audits aview(with version, whether watermarked, whether they could download) and returns a token plus what the viewer needs:kind,can_download,version_no,is_current, and the watermark text. -
GET
?t=<token>- the bytes, for that token only.
The token:
-
lives in THIS person's PHP session (
$_SESSION['files_view']) - a copied URL is useless in any other browser; - is for one version of one file, and expires after
FILES_VIEW_TTL(4 hours - long enough for a training video); - is capped at 40 per session, oldest dropped;
- is re-checked against the folder's permissions on every request, so taking someone's access away stops a viewer that is already open.
Why not a signed token? A signed token needs a secret, and the natural place to keep one is
system_settings. Butapi/settings/get_system_settings.phpreturnssystem_settingsto anyone with Assets, Software, Tickets or System access - it masks the secrets it knows about, and a new key would not be on that list. A session-held token has no secret to keep anywhere, and is bound to one browser for free.
The bootstrap opens the session with read_and_close; open calls session_start() again to write the token and session_write_close() straight after.
The bytes response is never something the browser will run as a FreeITSM page:
- images, audio, video and PDF (
FILES_VIEW_INLINE_TYPES) are sent as themselves,inline, so<img>,<video>and pdf.js can use them; SVG is deliberately absent - it can carry script; - everything else is
application/octet-streamas an attachment, with no file name, so "Save as" offers nothing meaningful; - always
nosniffandContent-Security-Policy: sandbox; default-src 'none'; media-src 'self'; img-src 'self', in case anyone opens the URL directly; - Range is supported, so video seeks and pdf.js fetches only the pages it needs.
The watermark text is built on the server - the analyst's name from the session, the UTC time and REMOTE_ADDR - so the page cannot choose it.
files_shortcuts holds either a shortcut in a folder (folder_id set, analyst_id NULL - everyone who can see the folder sees it, if they can also see the target) or one on a person's desktop (analyst_id set, folder_id NULL). target_type is folder or item.
A shortcut grants nothing. filesShortcutTarget() in the bootstrap is the one place shortcuts meet permissions: it returns the target's name, folder, path and the caller's level - or null if the caller cannot see it.
- In a listing (
list.php), a shortcut whose target is hidden is left out altogether - otherwise its name would tell the viewer the target exists. - On the desktop (
GET shortcut.php?desktop=1) every shortcut comes back withok: falsewhen the target is gone or no longer shared. The name the person gave it stays (they saw it when they made it); nothing about the target's current state is revealed beyond "gone". The desktop shows it greyed out. - Creating one needs View on the target. In a folder it goes beside the target (the folder holding it) and needs Upload there; a top-level folder has nowhere beside it, so only desktop.
- Changing a folder shortcut needs Modify on its folder, and the shortcut is "not found" unless the caller can see both the folder and the target. A desktop shortcut belongs to its owner only.
-
placeis drag and drop:{action: place, id, to: <folder id>|desktop, copy?}. Moving needs what deleting needs; Ctrl-drag (copy) only reads the source, so View is enough there; the destination needs Upload, or is the caller's own desktop.
Shortcuts share their folder's name space - filesNameTaken() checks them, but only the ones the caller can see.
Explorer puts {from, entries} on the drag as application/x-freeitsm-files with effectAllowed = 'all'. Drop targets (Explorer.prototype.dropTarget()): the pane, each folder in it, the tree and the address bar's crumbs.
-
dropEntries()sends files and folders totransfer.php(move, or copy with Ctrl) and shortcuts toshortcut.phpplace. -
dropOnDesktop()- nothing is stored on the desktop, so a file or folder becomes a new desktop shortcut (create,where: desktop), and a shortcut isplaced there. - Files from the computer (
FilesindataTransfer.types) upload into the target folder; on the desktop they get a notice instead.
assets/js/files-viewer.js. renderInto(host, info, opts) draws a file into a window body or the phone overlay and returns {destroy()}.
* TRAP: libraries load on first use (pdf.js alone is ~1.7 MB) - never add them
* to files/index.php's <script> tags.loadScript() memoises a promise per URL; pdf.js is an ES module loaded with import().
Pages are drawn to canvas as they scroll into view (an IntersectionObserver with a 600px margin), at the device pixel ratio. getDocument() is given the token URL, so pdf.js fetches only the byte ranges it needs, and enableScripting: false, enableXfa: false. Two changes to the vendored copy, recorded in VENDOR.md:
-
pdf.min.mjsandpdf.worker.min.mjsare renamed.js, because IIS and older nginx do not serve.mjsas JavaScript. - The QuickJS scripting sandbox (
wasm/quickjs-eval.*) is removed - the viewer never runs PDF JavaScript, so it has no business shipping an interpreter for it.
Fit to width is measured when it is pressed, and while fitting a ResizeObserver keeps the page fitted as the window resizes; zooming by hand stops that (see Traps).
mammoth turns the document into plain semantic HTML - but the document is untrusted (a hyperlink can be javascript:), so the result goes through sanitise(), an allowlist run over a DOMParser document before it touches the page:
- tags not on the list are unwrapped (text kept);
SCRIPT,STYLE,IFRAME,SVG,MATH,FORMand friends are dropped with their contents; anything outside the XHTML namespace is treated as not allowed; - attributes kept:
hrefonAonly forhttps:,http:,mailto:or#(external links gettarget=_blank rel="noopener noreferrer");srconIMGonly fordata:image/(png|jpeg|gif|webp|bmp);base64;alt;colspan/rowspanas small integers. Everything else goes.
Spreadsheets are read with XLSX.read(..., {sheetRows: 5001}), one tab per sheet, every cell written through esc() as text. Capped at 5,000 rows and 200 columns, with a footer saying so. Word documents and spreadsheets are parsed in the browser, so they are refused above 60 MB.
Zips use SheetJS's own zip reader, XLSX.CFB - no extra library. The browser unpacks the zip in memory (up to 250 MB); an entry opens in the same viewer from those bytes:
- pdf.js gets
data:instead of a URL; - images and media get a
blob:URL typed by us from the extension (MEDIA_TYPES, no SVG), revoked on close; - a zip inside the zip is browsed the same way - entries build an
entryPathlikeouter/inner.zip > docs/a.pdf.
The server never sees which entry was opened unless told, so auditEntry() POSTs view.php {action: entry, id, version, entry, what: view|download}. That re-checks View or Download and writes the row with zip_entry in detail. Saving an entry is done from memory with a blob: link.
fdv-viewonly on the host: no Download button, contextmenu cancelled, user-select: none, controlsList="nodownload noplaybackrate" and no picture-in-picture on media, and @media print hides the viewer. The watermark is a tiled SVG data: image over the stage (.fdv-wm, pointer-events: none), drawn from the server's text, so it is in any screenshot.
None of this is protection - a View-only person's browser holds the bytes. It is a deterrent; the watermark is what makes a leak traceable, and the help says so.
files_folders.watermark is NULL (as the parent), 0 or 1. FilesAcl::watermark() walks up the folder tree to the nearest folder that says on or off; nothing set anywhere means off. It follows the tree, not permission inheritance - a folder that stops inheriting permissions still sits inside its parent. Setting it needs Full control and is audited as watermark with from, to and the effective result.
files/index.php renders the shell and hands the client window.FILES_BOOT: the API base, the person, their prefs (filesGetPrefs()), the logo (filesDesktopLogo()), the two capabilities, the chunk size, the largest upload, the modules for the start menu (filtered the same way the waffle is, so the two cannot disagree) and the help, settings and sign-out URLs.
Per-person preferences live in user_preferences with a files_ prefix: files_desktop_colour (#rrggbb, default #1e3a5f), files_navbar (on | auto | off), files_view (icons | details) and files_windows (geometry per window kind). prefs.php keeps only numbers from the geometry - nothing a page could be tricked into rendering. Desktop colour is the only appearance choice a person has (Ed, 2026-10-10); the logo is System -> Branding's.
The menus, buttons and greyed-out items in files-desktop.js are a courtesy: the server decides every permission.
assets/js/files-wm.js is written for Files rather than taken from a library (WinBox and friends), because the taskbar and its previews have to know everything about every window, and that coupling is most of the work. The apps in files-desktop.js only talk to it through window.FilesWM: open, windows, active, menu, closeMenus, dialog, notify, setNavbar, onGeometry, hidePreview, esc, L.
-
Windows -
WM.open({kind, key, single, title, icon, width, height, geometry, onClose, onFocus, onResize}).singlere-uses a window with the same kind and key (one viewer per file version, one Permissions window per folder). New windows cascade. -
Geometry - every move, resize, maximise and restore calls the
onGeometryhook with{x, y, w, h, max}for the window's kind; the desktop debounces it intoprefs.php. -
Snap - while dragging, the pointer at the top edge means maximise, the left or right edge means half the screen;
#fdSnapGhostshows the outline. Dragging a maximised or snapped window restores its size under the pointer at the same relative position, as Windows does. -
Previews - a deep clone of the window scaled into the preview box. A cloned canvas is blank, so each canvas is redrawn from its original; a video becomes a canvas holding its current frame. A minimised window is
display: none, so the clone is sized from its remembered geometry, never measured (see Traps). -
The stuck drag - a button released where the page never hears about it (over the Help window's iframe, outside the browser) used to leave the drag running, and the snap outline then appeared whenever the mouse passed the left edge. Drags and resizes now end on
pointerup,pointercancel,lostpointercapture, windowblur, and anypointermovewithbuttons === 0; and a click anywhere puts the outline away. - Menus - the menu element takes focus itself, not its first item: focusing an item looked like hovering it, and on an item with a submenu (View) opened the submenu before the mouse had moved. Arrow keys still start at the top.
-
The navbar -
body[data-navbar]withon/auto/off;--fd-topis the header's height only whenon. Inautoa 6px sensor at the top shows it, and it stays while the waffle or avatar menu opened from it is open. - Start menu - Ctrl+Esc (the Windows key cannot be caught in a browser). All modules swaps the left column rather than listing 25-odd modules under the apps, which made the menu taller than the screen.
Below 768px files-desktop.js renders #fdMobile (.fd-mobile) instead of opening an Explorer, and files-viewer.js opens files in a full-screen .fdm-viewer overlay. The desktop, taskbar, start menu and previews are hidden by mobile.css LAYER 45. The details are on Mobile: Files.
const FILES_MANAGE = 'files.manage'; // umbrella
const FILES_FOLDERS = 'files.folders';
const FILES_STORAGE = 'files.storage';files/settings/manifest.php is the single declaration of the settings tabs and therefore of the capabilities - the tab bar and the System -> Roles tick-boxes are derived from it. All three are marked sensitive: Folders lets someone create top-level folders, see every folder that exists, and take ownership of any folder (which grants themselves its files); Storage moves where every byte lives. There are deliberately no setting_keys on either tab: everything saves through api/files/settings.php, which validates the value and checks the same capability as the tab.
Access to the module itself is ordinary module access (requireModuleAccessJson('files') in the bootstrap). A grant to an analyst without the module does nothing until they have it.
settings.php?tab=folders returns every live folder, including ones the caller cannot open - path, entry count, file count, whether it inherits, and the caller's own level. Never files. This is "admins can see a folder exists" (Ed's rule); the way in is Take ownership, which is permissions.php and audited. The page flags a folder with no entries of its own that has nothing to inherit as nobody has access.
filesDesktopLogo() in includes/files/storage.php:
-
files_logo_mode-company(default) ornone; -
files_logo_position-top-right(default) orbottom-right; -
companymeans the logo of the analyst's active company (getActiveTenantId()), fromtenants.logo_path, else the organisation logo (brandingLogoUrl()).
Both keys are saved by api/system/save_branding.php (validated against those two lists) from the Files desktop card on System -> Branding, and read back by api/system/get_branding.php. The tenants.logo_path read is in a try - the column may not exist before Database Verify has run.
api/system/tenant_logo.php (admin only) uploads or removes a company's logo from System -> Companies. It saves immediately, existing companies only:
- stored in
system/uploads/branding/companies/under a random name FreeITSM chooses, viauploadStoreFile(), in a web-servable folder with execution blocked (uploadPrepareWebServableDir()) - the desktop shows it by URL; - PNG, JPEG, GIF and WebP only, 2 MB. No SVG - it can carry script and is served from FreeITSM's own origin;
- the old file is removed only when its path matches the pattern FreeITSM writes, and
filesCompanyLogoPathIsSafe()checks the stored path again before the desktop uses it.
Every string is in lang/en/files.php: the desktop's under app, ex, perm, viewer, up and the rest, and the help page's under help.<section>.p1..., one entry per paragraph, so files/help.php is only the shape. The pages export the common and files namespaces to window.translations.
The JavaScript calls L('key', 'English', params), which is files-wm.js's wrapper round window.tf('files.' + key, english, params). files-settings.js has its own copy of the same wrapper. The English at the call site and the English in the language file must agree - they were generated from the same calls.
Only lang/en/files.php exists so far; every other locale falls back to English.
The TRAP: comments in the Files code, as of this writing (git grep -n "TRAP:" -- files api/files includes/files assets/js/files-*.js assets/css/files.css). See Code traps for the convention.
| Where | The rule |
|---|---|
assets/js/files-viewer.js (header) |
Libraries load on first use (pdf.js alone is ~1.7 MB) - never add them to files/index.php's <script> tags. |
assets/js/files-viewer.js sanitise()
|
Elements inside <svg>/<math> report lower-case tag names, so every name check is case-insensitive (and the namespace is checked too). |
assets/js/files-viewer.js renderPdf()
|
"Fit" must be measured when it is asked for. It was worked out once at open, so after maximising the window it fitted the old, smaller window and the button looked dead. |
assets/js/files-viewer.js renderZip()
|
SheetJS's reader adds a marker entry "\u0001Sh33tJ5"; no real name starts with a control character, so anything that does is skipped (as is any .. part). |
assets/js/files-wm.js (header) |
A minimised window is display: none, so it has no layout. The preview clones it into a box sized from its remembered geometry - measuring a hidden element returns zeros and the preview is blank. |
assets/js/files-wm.js startDrag()
|
A button released where the page never hears about it left the drag running. No button held (buttons === 0) = the drag is over. |
assets/js/files-desktop.js |
A file from the computer dropped where nothing accepts it (a title bar, the taskbar) makes the browser open it - leaving Files. Swallow any drop nobody handled. |
assets/css/files.css |
Any display: rule beats the [hidden] attribute - the start menu and the transfers pill showed while "hidden". One body.fd-body [hidden] { display: none !important; } for the whole page. |
assets/css/files.css |
Never centre zoomable content with align-items/justify-content: center. When the page is wider than the window it overflows both sides and the left part can never be scrolled to. Auto margins centre when there is room and collapse to 0 when there is not. |
And the โ ๏ธ rules that are just as binding:
-
Never add an
is_adminshortcut toincludes/files/acl.php(ยง2). - Hidden means not found - endpoints answer "not found", never "access denied", for anything below View (ยง2).
-
A folder is live only if every ancestor is -
tree()(ยง2). -
Only folders the caller can see count in
filesNameTaken()(ยง2). - A shortcut grants nothing, and a hidden target hides its shortcut (ยง7).
- Changing the storage root moves nothing (ยง4).
-
Copies share
.binfiles - a purge must check nothing else points at one (ยง4).
There are no Files tests in tests/ yet. The module was tested against a running install:
-
The API over HTTP, as each person. Sessions were forged by writing PHP session files into the WAMP session folder (
c:/wamp64/tmp/sess_<id>) for different analysts, then callingapi/files/*.phpwith that session's cookie. That exercises the real endpoints - the bootstrap, module access andfilesNeed()- rather than the functions on their own. A POST from a signed-in session needs the session's CSRF token (csrf_tokenin the session, sent asX-CSRF-Token), so put one in the forged session. Rules like "hidden means not found", the finish-time re-check, the view token refusing another session and shortcuts never widening access want two requests each: the person who must be refused, and a positive control - the same request from someone who is allowed - so a refusal for the wrong reason does not pass. -
The desktop and viewer in headless Chrome. One catch: with
--virtual-time-budget, the pdf.js worker never gets its turn and the PDF stalls at "Loading...". The tests setwindow.Worker = undefinedbefore the viewer loads, so pdf.js runs its fake worker on the main thread. - Phones at 390px through an iframe - see Mobile: Files.
Test against an install whose data matters as if it does: put everything a test creates in one clearly named temporary top-level folder and delete it afterwards. files_audit keeps a row for every step regardless - that is the module working, not a leftover.
See also: Files ยท Mobile: Files ยท Code traps ยท Roles - Developer Guide ยท Database Verification - Developer Guide ยท Running on nginx ยท Feature Bingo - Developer Guide