-
Notifications
You must be signed in to change notification settings - Fork 15
Attachment Text Extraction Developer Guide
How text gets out of a file and into the search index, what is deliberately refused, and why the refusals are the interesting part.
The user-facing page is Searching inside tickets; the corpus this feeds is Search corpus β Developer Guide; the design reasoning is Full-text search Β§3.2, Β§6.4 and Β§8.2.
The one-line summary: extraction splits into a dependency-free tier that ships and an external-service tier that does not exist yet, the split is drawn along a security line rather than a convenience one, and the extracted text is stored as the durable record so no file is ever read twice.
Colour key: ποΈ schema Β· βοΈ shared service Β· π API Β· π₯οΈ page Β· π©Ί diagnostics Β· π§ͺ tests
| π¨ | File | What it does |
|---|---|---|
| βοΈ | includes/search/extract.php |
The extractor. Format support, the guards, OOXML unzipping, normalisation |
| βοΈ | includes/search/indexer.php |
searchIndexTicketAttachments() β path containment, the cache, corpus rows |
| ποΈ | database/freeitsm.sql |
attachment_text β the durable store |
| ποΈ | includes/db_verify_schema.php |
Its columns. attachment_id, so it is also in $primaryKeys
|
| π | api/system/search_status.php |
Counts by outcome, for the screen below |
| π₯οΈ | system/search/index.php |
System β Search β shows every attachment by outcome |
| π§ͺ | tests/search-extract/run.php |
25 assertions. No database, no HTTP β real files in a temp directory |
| Tier | Formats | Needs |
|---|---|---|
| 1 β shipped |
.txt .csv .log .md .json .xml .yml .ini, and .docx .xlsx .pptx
|
nothing |
| 2 β not built | PDF, legacy .doc .xls .ppt, images, anything scanned |
an external extractor |
The line is not "easy versus hard". It is whether parsing the format can be done without running a large third-party parser inside the web request.
Tier 1 formats are either already text, or a ZIP of XML that ZipArchive and a regex can handle. Tier 2 formats need a real document parser, and that is a different proposition:
-
The input is hostile. An attachment arrives from anyone who can email the service desk, through
api/tickets/check_mailbox_email.php, which has no authentication at all. Malformed documents are a well-trodden route to memory corruption and code execution, and this process holds the database credentials. - FreeITSM has no Composer. A PHP PDF library would be vendored by hand and ours to patch for its lifetime. A bundled library version was itself a finding in the August 2026 security review.
So tier 2 is deliberately out of process: one extraction service, in a container, reached over HTTP. Until an install has one, those formats are recorded as unsupported, which is an honest answer rather than silence.
π This is why "just add a PDF library" is the wrong instinct, even though it looks like the shortest path. The isolation boundary is the feature.
Partly, and it is the most reasonable-sounding wrong turn available, so it is worth writing down.
A text-layer PDF does keep its text in content streams, usually Flate-compressed, and PHP has zlib built in. Decompress the streams, pull out the Tj / TJ text-showing operators, done. This genuinely works on simple PDFs β the sort Word exports.
It breaks on real ones, in ways that are hard to notice:
-
Font encodings. Bytes map to glyphs through
/Differencesarrays, CMaps andToUnicodemaps. A subsetted font with none of those resolved produces mojibake that looks like text: plausible length, indexed happily, matches nothing. - Compressed cross-reference tables (PDF 1.5+). The xref lives inside an object stream, so naive parsing fails on a large share of modern documents.
- Encryption. Many PDFs with no password are still encrypted with an empty user password, and the streams need decrypting before anything can be read.
-
Text is positioned, not flowed.
TJarrays carry kerning offsets; reassembling words needs real handling, or you get the Β§6 welding problem again and worse.
π The objection is the failure mode, not the effort. unsupported is honest β the Search screen says so and an analyst looks elsewhere. Silent mojibake is worse than both: it looks indexed, so nobody investigates, and search quietly cannot find things it appears to cover.
If a pure-PHP tier is ever wanted, the rule has to be fail closed: handle only uncompressed-xref, unencrypted, standard-encoding files, mark everything else unsupported, and never emit doubtful text. That would catch a fair slice of "PDF printed from Word" with no service at all. It is a legitimate middle tier β it is just not this one, and it must never be the default, because a wrong-but-plausible index entry is the worst outcome search can produce.
search_documents holds a derived copy of this text. attachment_text holds the result of actually opening the file, and it is the thing that must survive.
This is Full-text search Β§3.3, and it earns its keep twice:
A rebuild reads no files. searchBackfillRun() re-derives every corpus row, and does it without opening a single attachment. For a .docx that would merely be wasteful. For tier 2 it would mean re-OCRing every scanned PDF an installation has ever received β hours of CPU, and with a paid extraction service, an actual bill, every time somebody clicks Rebuild index.
Live indexing reads no files either. searchIndexTicket() reindexes the whole ticket on every event (see the corpus guide Β§9 for why). Without the cache, a ticket with ten attachments would re-open and re-unzip all ten every time somebody added a note.
first sight of the file β extract β attachment_text β search_documents
every time after that β attachment_text β search_documents
| Column | Why |
|---|---|
attachment_id |
The PK. One row per attachment, so re-indexing is a lookup |
status |
The outcome, carried as a fact β see Β§4 |
extractor |
builtin today. Kept so a tier-2 install can find everything the built-in tier could only mark unsupported, and redo exactly those |
extracted_text |
The text itself |
chars |
Length, for the screen |
ON DELETE CASCADE from email_attachments: the extracted text is meaningless once the file it came from has gone.
Every attachment ends up with one of these, and System β Search lists the totals:
| Status | Means |
|---|---|
extracted |
Text was read in full |
truncated |
Read, but longer than the character cap |
too_large |
The file is over the size limit; never opened |
unsupported |
No extractor handles this format β every PDF, today |
failed |
An extractor tried and could not |
pending |
Queued for an extractor that runs off the request thread (tier 2) |
This is Β§3.4 of the design, and the reasoning is worth restating: a search that silently returns nothing, because a file was never readable, is worse than one that admits the file could not be read. The first teaches an analyst that the information is not there. The second tells them where to look instead.
extracted with empty text is a legitimate outcome β a document containing only images reads fine and yields nothing. It is not failed.
Every one of these is because the file came from a stranger.
const ATT_TEXT_MAX_UNZIPPED_BYTES = 104857600; // 100 MB total
const ATT_TEXT_MAX_ZIP_ENTRIES = 2000;A 40 KB .docx can declare terabytes of uncompressed XML. ZipArchive will hand it over one entry at a time until the process dies.
π The guard sums statIndex()['size'] across the archive and refuses BEFORE reading a single entry. Not "unpack it and see how big it got" β unpacking is precisely what kills you.
That is not theoretical. Writing the test for this guard exhausted PHP's memory limit twice: first building a 100 MB string, then because
ZipArchivebuffers everything added untilclose(). The test now uses an injected small cap, and the production limits are unchanged.
const ATT_TEXT_MAX_FILE_BYTES = 20971520; // 20 MB β never opened above this
const ATT_TEXT_MAX_CHARS = 200000; // text kept per attachmentPlain text files are read with a bounded file_get_contents($path, false, null, 0, $maxChars * 4) rather than whole β there is no reason to pull 20 MB into memory to discard nearly all of it. The * 4 is multi-byte headroom.
The check is lifted verbatim from api/tickets/get_attachment.php:
$realBase = realpath($baseDir);
$realFile = realpath($baseDir . '/' . $row['file_path']);
if ($realBase === false || $realFile === false
|| strncmp($realFile, $realBase . DIRECTORY_SEPARATOR, strlen($realBase) + 1) !== 0) { β¦ }file_path comes out of the database, so this is not reachable from a request parameter today. That is exactly why it must be here: a row written before the upload rules existed, or by anyone with a foothold in the database, must not be able to make the indexer read config.php and put its contents in a searchable table. Reading is only half the damage; indexing it is the other half.
realpath() on both sides resolves symlinks, and is required on Windows where the base arrives with forward slashes and returns with backslashes.
WHERE a.is_inline = 0 OR a.is_inline IS NULL. A signature logo is not a document, and without this it would be "extracted" on every reply in a thread to produce nothing.
Office formats wrap every run of text in its own element:
<w:r><w:t>Please</w:t></w:r><w:r><w:t>reboot</w:t></w:r>Strip the tags without putting something in their place and you get Pleasereboot β a word that exists in no dictionary and matches neither search term. The document appears indexed, the row has plausible length, and it is unfindable.
$s = preg_replace('/<[^>]*>/', ' ', $raw); // β a SPACE, not ''tests/search-extract/run.php asserts both halves: that QuarterlyFirewall does not appear, and that Quarterly Firewall does. The negative alone would pass on an empty string.
'docx' => ['word/document.xml'],
'xlsx' => ['xl/sharedStrings.xml'], // cell text, stored once each
'pptx' => ['ppt/slides/'], // trailing slash = every entry under itKnown gaps, stated honestly: .docx headers, footers, footnotes and comments live in their own parts and are not read. .xlsx numbers stored inline rather than in the shared string table are not captured, and neither are formulas. .pptx speaker notes are a separate part and are skipped. All are additive fixes β another entry in that map β and none is worth guessing at until somebody reports it.
searchIndexTicketAttachments() is pass 4 of searchIndexTicket(), alongside the subject, messages and notes. It therefore inherits everything the corpus guide Β§9 describes for free: it runs on every ticket event via the dispatch subscriber, it is ordering-immune, and it is self-healing.
The corpus row is shaped like any other:
| Field | Value |
|---|---|
source_type |
attachment |
source_id |
the attachment id |
ticket_id |
the ticket it hangs off β so it groups with that ticket's other hits |
title |
the filename |
body |
the extracted text |
is_internal |
0 β an attachment is as visible as its ticket; it is not a note |
Only extracted and truncated produce a corpus row. unsupported, too_large and failed are recorded in attachment_text and shown on the Search screen, but there is nothing to search.
php tests/search-extract/run.php β 25 assertions, no database, no HTTP. It builds real files in a temp directory and reads them back, because the risky part of this feature is file handling rather than SQL.
The assertions worth keeping when you change anything:
| Assertion | Guards against |
|---|---|
| adjacent OOXML runs do not weld together | Β§6, the silent one |
| β¦and are separated properly | the negative above passing on an empty string |
a PDF is unsupported, not failed
|
the honest-failure contract |
| a zip bomb is refused | Β§5.1 |
| CONTROL β the same archive reads under a larger cap | proving the refusal is the guard, not an unreadable file |
an empty file is extracted, not failed
|
image-only documents |
a .docx that is not a zip fails cleanly |
never throwing into the ticket's indexing |
The seam is already here, which was the point of building tier 1 first:
- A setting: extractor URL and timeout. Β§8.4 of the design sketches the screen; only the status half is built.
-
attTextSupports()gains the tier-2 formats when a URL is configured β it must stay format-aware, not "send everything and hope". - A queue. OCR takes seconds to minutes per page. It cannot run inside the mailbox poll or a request;
status = 'pending'exists for exactly this and is currently never written. -
extractoron each row becomes the service name, so an install that adds a service later can find everyunsupportedrow and redo just those.
Nothing in the corpus, the search function or the UI changes. That is what the durable-store split in Β§3 buys.
- Searching inside tickets β the user-facing page
- Search corpus β Developer Guide β the table this feeds, and the indexing seam
- Full-text search β the design, including the options ruled out
- Safe file uploads β how the files got there in the first place
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)