Attached Documents Developer Guide - edmozley/freeitsm GitHub Wiki
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.
7. Documents in the portal's Help Centre (3.7.0, #177)
The rule: a document on a knowledge_article can be downloaded from the self-service Help Centre by anyone who may read that article there, unless the link has show_in_help_centre = 0. No other parent_type ever reaches the portal by this route (ticket-note files have their own, api/self-service/get_document.php).
| File | Role | |
|---|---|---|
| ๐ข | includes/documents.php |
documentHelpCentreReady(), documentLinkHelpCentreDefault(), documentInsertLink() - the one writer of document_links
|
| ๐ข | includes/knowledge/help_centre_documents.php |
the article side: what an article lists, what a requester may download, Show/Hide |
| ๐ข | includes/knowledge/portal_reader.php |
portalCanReadArticle() - built on portalKnowledgeScope(), so it cannot disagree with the Help Centre's own pages |
| ๐ต | api/self-service/get_article_document.php |
the portal download; every refusal is the same 404 |
| ๐ต | api/documents/help_centre.php |
Show/Hide, with the attach/unlink checks; recorded in the article's history |
| ๐ต | api/documents/list.php |
adds show_in_help_centre per row and a help_centre summary on articles |
| โช | assets/js/documents.js |
the explanation line, the tags and the Show/Hide buttons - drawn only when the server sent help_centre
|
Things that will bite:
- The flag is on the LINK. A document is stored once and pinned to many records; showing it on one article must not show it anywhere else.
-
The column defaults to 0, and that is deliberate. Adding it gives every existing link 0, so nothing attached before anyone was told goes public on upgrade. New article links get 1 from
documentLinkHelpCentreDefault(). Never backfill existing rows to 1. (TRAP:inincludes/db_verify_schema.phpandincludes/documents.php.) -
Write links through
documentInsertLink(). It omits the column until Database Verification has added it - naming a missing column in an INSERT would break every upload on every module between pulling the code and running verification. -
Do not restate "may this requester read this article". Use
portalCanReadArticle(); company, audience, lifecycle and the access list all follow from the one scope.
Tested by tests/knowledge-help-centre-documents.php - see Developer Tests - Knowledge.
- 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