Files Developer Guide - edmozley/freeitsm GitHub Wiki

๐Ÿ› ๏ธ Files - Developer Guide

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.


1. ๐Ÿ“ The 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

2. The permission model and the ONE resolver

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.

Levels

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.

How a level is worked out

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:

  1. tree() - every folder, once.
  2. The analyst's direct grants plus their teams' (active teams only, via analyst_teams), MAX(level) per folder.
  3. If there are no grants anywhere, return [] straight away - the common case, and free.
  4. Otherwise resolve each folder recursively: its own best grant, max'd with the parent's resolved level while inherit_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.

tree() prunes deleted ancestors

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.

"Documents" is not the top level

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.

The other entry points

  • FilesAcl::entries() - the Permissions window: a folder's own rows, then each ancestor's while inheriting, marked inherited with where they came from.
  • folder.php inherit - turning inheritance off with copy: true copies the parent's effective entries in as the folder's own, GREATEST() where one exists already. Windows' Copy/Remove.
  • permissions.php save - 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() in transfer.php). Creating at the top level needs Cap::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 as take_ownership with the level they held before.

What each operation needs

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.


3. The audit trail

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), and files_audit has no foreign keys on purpose - the trail must outlive the analyst, the file and the folder it mentions. target_path is worked out from folder_id plus item_name when the caller does not give one.
  • IP and user agent are recorded from the request.
  • detail is JSON: versions and sizes, SHA-256 on upload, from/to on 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.php audits only a request starting at byte 0; a resumed or seeking download sends many.
  • The tree is not audited. list.php?tree=1 shows 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).


4. Storage

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:

  • .bin and a random name. A .bin cannot 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, IIS web.config); nginx has no in-directory equivalent, so deploy/nginx/freeitsm.conf returns 404 for /uploads/. On Docker the root is the files volume. D009 (api/system/debug-tools/D009_guarded_paths.php) fetches a probe file from uploads/files over HTTP and fails if it comes back.

filesAbsolutePath() - never trust the row

/**
 * 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).

The storage root

  • Default uploads/files/ inside the app (filesDefaultStorageRoot()); stored in system_settings as files_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).

Copies share bytes

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.

Soft deletes

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.


5. Chunked uploads

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 answers conflict: true unless on_conflict says version (a new version of that file) or rename (filesFreeName(): "Report (2).pdf"). A clash with a folder or shortcut of the same name is always renamed. It writes a files_uploads row and an empty <root>/_incoming/<token>.part.
  • chunk must arrive in order. offset greater than received_bytes is refused with the server's received; offset at 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 into yyyy/mm/dd/<hex>.bin, its MIME sniffed, and the item, version and current_version_id written in one transaction. A new version whose file was deleted or moved away meanwhile becomes a new file instead. Audited as upload or new_version with size and SHA-256.
  • Tokens belong to their uploader: every action after start looks the row up by token and analyst_id.

Housekeeping

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.

The client

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.


6. download.php and view.php

Two endpoints, because there are two different permissions.

download.php - Download level

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.php - View level, through a token

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=:

  1. POST {action: open, id, version?} - View on the folder. Audits a view (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.
  2. 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. But api/settings/get_system_settings.php returns system_settings to 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-stream as an attachment, with no file name, so "Save as" offers nothing meaningful;
  • always nosniff and Content-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.


7. Shortcuts

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 with ok: false when 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.
  • place is 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.

Drag and drop, client side

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 to transfer.php (move, or copy with Ctrl) and shortcuts to shortcut.php place.
  • dropOnDesktop() - nothing is stored on the desktop, so a file or folder becomes a new desktop shortcut (create, where: desktop), and a shortcut is placed there.
  • Files from the computer (Files in dataTransfer.types) upload into the target folder; on the desktop they get a notice instead.

8. The viewer

assets/js/files-viewer.js. renderInto(host, info, opts) draws a file into a window body or the phone overlay and returns {destroy()}.

Libraries load on first use

 * 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().

pdf.js

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.mjs and pdf.worker.min.mjs are renamed .js, because IIS and older nginx do not serve .mjs as 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.js for .docx

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, FORM and friends are dropped with their contents; anything outside the XHTML namespace is treated as not allowed;
  • attributes kept: href on A only for https:, http:, mailto: or # (external links get target=_blank rel="noopener noreferrer"); src on IMG only for data:image/(png|jpeg|gif|webp|bmp);base64; alt; colspan/rowspan as small integers. Everything else goes.

SheetJS for spreadsheets - and zips

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 entryPath like outer/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.

View-only in the client

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.

Watermark resolution

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.


9. The desktop page

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.


10. The window manager

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}). single re-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 onGeometry hook with {x, y, w, h, max} for the window's kind; the desktop debounces it into prefs.php.
  • Snap - while dragging, the pointer at the top edge means maximise, the left or right edge means half the screen; #fdSnapGhost shows 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, window blur, and any pointermove with buttons === 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] with on / auto / off; --fd-top is the header's height only when on. In auto a 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.

11. Phones

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.


12. Capabilities, settings and logos

Capabilities

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 -> Folders

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.

The desktop logo

filesDesktopLogo() in includes/files/storage.php:

  • files_logo_mode - company (default) or none;
  • files_logo_position - top-right (default) or bottom-right;
  • company means the logo of the analyst's active company (getActiveTenantId()), from tenants.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.

Company logos

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, via uploadStoreFile(), 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.

13. i18n

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.


14. Traps

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_admin shortcut to includes/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 .bin files - a purge must check nothing else points at one (ยง4).

15. Testing

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 calling api/files/*.php with that session's cookie. That exercises the real endpoints - the bootstrap, module access and filesNeed() - rather than the functions on their own. A POST from a signed-in session needs the session's CSRF token (csrf_token in the session, sent as X-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 set window.Worker = undefined before 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

โš ๏ธ **GitHub.com Fallback** โš ๏ธ