-
Notifications
You must be signed in to change notification settings - Fork 15
Attached Documents Developer Guide
The schema, the endpoints, and how to give a module documents.
Permissions and search get their own page: Attached documents β permissions and search. The user-facing guide is Attached documents.
Built for discussion #76.
| File | Role |
|---|---|
includes/documents.php |
The entity registry, both permission functions, orphan collection, storage-key resolution. No module knows anything about documents; this knows nothing about any module. |
includes/documents_panel.php |
renderDocumentsPanel() for server-rendered records, documentsPanelAssets() for pages that mount it themselves |
| File | Role |
|---|---|
api/documents/save.php |
Create a document (file upload or external link) and attach it |
api/documents/find.php |
Search documents you may see, for attaching elsewhere |
api/documents/attach.php |
Attach an existing document to another record |
api/documents/list.php |
What is attached to a record. Paged |
api/documents/links.php |
Where one document lives β powers the β |
api/documents/download.php |
The boundary. Serves the file, checks access, records it |
api/documents/unlink.php |
Detach; collects the document if that was its last link |
| File | Role |
|---|---|
includes/search/documents_index.php |
Extraction queue, corpus indexing, the search visibility clause |
api/system/global_search.php |
Two βK sources: documents by name, and by content |
includes/search/search.php |
Applies the document clause inside searchScopeToSql()
|
| File | Role |
|---|---|
assets/js/documents.js |
The panel component and the β dialogue |
assets/css/documents.css |
Panel and dialogue styling |
scripts/documents_maintenance.php |
Extraction and orphan collection, for cron |
tests/document-permissions.php |
22 assertions |
tests/document-search-permissions.php |
9 assertions |
documents document_links
id id
kind ('file' | 'link') document_id βββ documents.id (FK, cascade)
title, description parent_type ('contract', 'asset', β¦)
storage_key parent_id β οΈ NO FK POSSIBLE
original_name, mime_type linked_by_id
size_bytes, content_hash created_datetime
external_url UNIQUE (document_id, parent_type, parent_id)
tenant_id, uploaded_by_id
created/updated/deleted document_text document_access_log
document_id (PK) document_id
status, extractor analyst_id, action, ip
extracted_text, chars created_datetime
The parent is a ROW, not a column on documents. That single choice is what lets one document belong to several records without a migration, and it is why deleting a record removes the link rather than a file somebody else is still using. It costs one table and one JOIN.
Five decisions came with it, all cheap at the time and expensive to retrofit:
-
A storage key, not a path.
documents.storage_keyis opaque;documentStoragePath()resolves it. Moving to another disk or to object storage is a change to that one function, not a data migration. -
One table for files and links, separated by
kind. Splitting later is mechanical; merging later means reconciling two sets of habits. - No permission columns. Ever. See the permissions page for why storing them is a bug and not merely duplication.
- Delete the link, not the document. Garbage-collect what that orphans.
-
content_hashso the same file attached eleven times can be recognised as one.
One entry in documentEntityRegistry():
'contract' => [
'module' => 'contracts', // key in allowed_modules β the read gate
'table' => 'contracts',
'label' => 'Contract', // shown in the UI
'url' => 'contracts/view.php?id=%d',
'title' => 'title', // column to display as the record's name
'alive' => null, // extra SQL keeping deleted parents out
'can' => null, // fn(PDO,$analystId,$id):bool β exact check
'filter' => null, // fn(PDO,$analystId,$alias):[sql,params]
],can and filter are the same question in two shapes β one row and a set. Pass null when module membership is the whole rule (contracts and tasks have no tenant_id).
β οΈ Do not invent a filter for a column that does not exist. The clause builder DROPS an entity type whose filter throws, so a filter on a missingtenant_iddoes not fail loudly β it silently hides every document on that entity.
Then mount the panel. Which of the two ways depends on how the page renders, and this is the thing that caught us out:
Server-rendered record β one line:
require_once __DIR__ . '/../includes/documents_panel.php';
renderDocumentsPanel('contract', (int) $contractId, '../');JS-rendered detail view β the page mounts it itself, because the container is rebuilt every time the user picks a different record:
documentsPanelAssets('../'); // once, in <head>FreeITSMDocuments.mount(document.getElementById('chgDocuments'), {
parentType: 'change',
parentId: c.id,
apiBase: '../api/documents/'
});Assets, Knowledge, Problems and Changes all take the second form. Mount, do not re-point β setParent() exists for a container that persists while the record changes, which none of them are.
document_text is its own table rather than a source_type column on attachment_text, for two reasons:
-
attachment_text's primary key is the attachment id alone, and a document id is a different small integer from a different table. They would collide. - The pipelines genuinely differ. An email attachment's text belongs to its ticket, so extraction ends by reindexing the ticket. A document has no ticket; it is its own corpus row.
What they share is attTextExtractFile(), which takes a path and returns text. That is the part worth sharing.
Two tiers, and mixing them up is a real bug we shipped and fixed:
| Tier | Handles | Test |
|---|---|---|
| 1, built in | plain text, OOXML (docx/xlsx/pptx) | attTextSupports($filename) |
| 2, Tika | PDF, images, scans, everything else | tikaConfigured($conn) && tikaHandles($filename) |
β οΈ attTextSupports()is tier 1 only. Using it as the whole test marks every PDFunsupportedat upload β "we looked and there is nothing to read", permanently β when the truth is "we never asked the thing that could". UsedocumentTextReadable().
| Status | Meaning |
|---|---|
pending |
We still owe this file. Will be retried |
extracted |
Done |
unsupported |
Nothing available can read this format |
failed |
We asked, and were answered badly |
Writing failed when the extractor is merely down would blacklist every PDF that arrived during a five-minute outage, permanently and silently. This is why Ed's three PDFs sat on pending while Tika was stopped and extracted themselves the moment it started.
document_links.parent_id is polymorphic, so no foreign key can protect it. Delete a contract and its links survive; nothing in the database objects.
The document is invisible from that instant β every permission check verifies the parent still exists β so nothing leaks. But without a sweep it is never collected: the row stays and the file sits on disk for ever.
documentsDetachParent($conn, 'contract', $id); // tidy, at the moment of deletion
documentsCollectOrphans($conn, $limit); // the net β correct on its ownThe sweep is the primary mechanism, not the hook. Twelve delete paths, plus bulk deletes, plus anything that ever removes a row in SQL, is twelve chances to forget β and forgetting is silent. It runs bounded from list.php and from the maintenance script.
β οΈ A gotcha worth keeping. The first version usedDELETE dl FROM document_links dl β¦ LIMIT n. MySQL does not acceptLIMITon a multi-table delete; the syntax error was caught, logged and swallowed, and the sweep reported "0 removed" β indistinguishable from a clean run. It is a single-tableDELETEnow, anddocumentsCollectOrphans()returns its errors so a caller can tell "nothing to do" from "it broke".
-
The upload folder must be denied by the web server.
uploads/documents/ships an.htaccessand aweb.config; nginx needs the rule indeploy/nginx/freeitsm.conf. Run D009 Guarded paths to find out what your server is actually honouring β a random stored filename is not a permission. -
window.tis guarded indocuments.js. This component lands on a dozen pages, which is a dozen chances to hit one that forgoti18n.js. -
Nothing in the panel's layout is inherited.
.fd-dropis a<label>; a page that does not make labels block-level left itinlineand the dashed border wrapped the text instead of drawing a box. -
The command palette drops result types it does not know. Adding a type server-side is not enough β it must be in the array in
command-palette.jsor the results vanish with no error. -
renderDocumentsPanel()renders nothing when$parentIdis 0. An unsaved record has nothing to attach to.
- Attached documents β the user-facing guide
- Attached documents β permissions and search
- Attachment text extraction β Developer Guide β the email-attachment pipeline this borrows from
-
Security β
includes/uploads.phpand the guarded folders
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- Theming & Dark Mode
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
- MobileβFriendly
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- Problem Management
- Tasks
- Assets
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
- Forms
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)