-
Notifications
You must be signed in to change notification settings - Fork 15
Asset Scanning Developer Guide
How a printed square of ink becomes an updated database row, and every piece in between.
This is the journey guide. Its sibling Asset QR labels — Developer Guide covers the other half — the two identifiers, tag uniqueness, and getting labels onto paper. Start there if your question is "what is asset_tag versus qr_token"; stay here if it is "what happens when somebody points a phone at one".
The one-line summary: there are three scanning surfaces, they share one resolution rule and one write path, and the module they live in runs two different mobile strategies on purpose.
Colour key: 🧠 shared rule · 🔌 API · 🖥️ page · 📦 vendored · 🔗 routing · ⚙️ setting · 🎨 styling · 🌍 i18n
| 🎨 | File | Its job in the scan journey |
|---|---|---|
| 🧠 | includes/asset_labels.php |
assetIdForToken(), assetEnsureToken(), assetLabelUrl(), assetPublicBaseUrl(), assetLabelsSchemaReady()
|
| 🔗 | .htaccess |
RewriteRule ^a/([A-Za-z0-9]+)$ asset-management/scan.php?token=$1 [L,QSA] |
| 🖥️ | asset-management/scan.php |
Where a phone-camera scan lands. One asset, full facts, two edits |
| 🖥️ | asset-management/scanner.php |
The in-app continuous scanner. Look up / Stocktake, torch, undo, manual entry |
| 🖥️ | asset-management/assign-tags.php |
The USB-scanner reverse flow: serial in, tag on, repeat |
| 📦 |
assets/js/vendor/jsQR.js + .LICENSE
|
QR decoder (Apache-2.0). The iOS fallback, lazy-loaded |
| 📦 | assets/js/qrcode.min.js |
QR generator — already bundled for MFA enrolment, reused for labels |
| 🔌 | api/assets/resolve_scan.php |
token → asset summary |
| 🔌 | api/assets/find_asset.php |
serial | hostname | tag → asset summary. Same JSON shape
|
| 🔌 | api/assets/update_asset_field.php |
The single write door, shared with the desktop editor |
| 🔌 |
api/assets/get_asset_status_types.php, get_asset_locations.php
|
The two pickers, each already company-scoped by its own rule |
| 🧠 | includes/services/assets.php |
AssetsService::updateFields() — validation, audit rows, warranty sync, no-op skip |
| 🧠 | includes/tenancy.php |
analystCanAccessAsset(), activeTenantFilter()
|
| ⚙️ | system_settings.messaging_public_base_url |
What the QR points at. Set in Tickets → Settings → Messaging |
| 🎨 |
assets/css/mobile.css (LAYER 14/15), assets/js/mobile.js
|
Make the module usable on a phone — deliberately not used by the three pages above |
| 🌍 |
lang/en/asset-management.php, lang/pt-BR/asset-management.php
|
list.scan, list.assign_tags — the entry points. The three pages themselves are English-only |
They are not redundant. Each exists because the other two are wrong for its job.
| Native phone camera | In-app scanner | USB barcode scanner | |
|---|---|---|---|
| Page | scan.php |
scanner.php |
assign-tags.php |
| How you arrive | Point the camera at a label; the OS offers the URL | Assets → Scan | Focus a text box, pull the trigger |
| Reads | Our QR | Our QR and the maker's barcode | Whatever the gun sends |
| Decoder | The operating system's |
BarcodeDetector or bundled jsQR
|
None — the gun types |
| The job | "What is this thing?" | "Update 500 of them" | "Put pre-printed tags onto assets" |
| Direction | label → asset | label → asset (repeatedly) | asset → label |
The USB one deserves a note because it looks like it needs code and doesn't: a barcode scanner is a keyboard. It types the barcode and presses Enter. That is why assign-tags.php is two plain <input>s and no scanning logic at all, and why it says "A scanner is just a keyboard — no setup needed."
The in-app scanner exists solely because of a leaving-the-app problem. The native camera is genuinely better for one asset — no app, no page, no permission prompt. It collapses at forty: leave FreeITSM, open the camera, tap the banner, edit, and repeat the whole dance forty times. scanner.php keeps the camera on screen so the loop is scan → applied → scan.
A QR encodes an absolute URL, because the phone reading it has no idea what the app's base path is:
https://<public base>/a/<qr_token>
Three things collaborate:
assetLabelUrl($token) builds it. .htaccess routes /a/<token> to scan.php?token=<token>. assetPublicBaseUrl() answers the hard part — what hostname does the outside world reach this install on?
That last one is the single most consequential line in the feature, and it is deliberately not derived from the current request:
A label is printed once and lives on a laptop for three years. Deriving the host from whatever address the printing analyst happened to be using would bake that into physical objects.
So it reads the stored setting system_settings.messaging_public_base_url (via messagingPublicBaseUrl()), falling back to the request only when there is nothing stored.
The key is named for messaging while doing install-wide work. It was reused rather than duplicated because it answers exactly the right question, and an install that already told us for WhatsApp webhooks shouldn't have to say it twice. It wants a generic rename with a read-both fallback next time settings are touched — the note is in assetPublicBaseUrl()'s docblock so it can't be quietly forgotten.
1. localhost in a QR code can never work. To the phone, localhost is the phone. This is the most expensive possible mistake here — you find out after 500 labels are on 500 laptops — so labels.php refuses to be quiet about it:
$labelHost = parse_url(assetPublicBaseUrl(), PHP_URL_HOST) ?: '';
$hostIsLocal = in_array(strtolower($labelHost), ['localhost', '127.0.0.1', '::1'], true);…and prints a red banner naming the host, where the setting lives, and the reassurance that reprinting doesn't change the codes — only the address inside them.
2. The sub-folder double-append. The setting is documented as scheme://host, but people paste the URL they actually use — which on a sub-folder install carries the folder, and on a tunnel is copied wholesale out of the address bar. Adding BASE_URL again would encode …/freeitsm-app/freeitsm-app/a/<token> onto physical labels. So both forms are accepted:
if ($root !== '' && substr($host, -strlen($root)) === $root) {
$root = ''; // the pasted base already carries the app folder
}Self-hosters behind a firewall need a public hostname. Two traps, neither of them FreeITSM's:
- A free ngrok URL rotates when the tunnel restarts. Labels printed against it die. Fine for a test, unusable for kit.
-
Free ngrok shows an interstitial to any browser user-agent — a phone scan lands on "You are about to visit…" and needs one Visit Site tap first. Worth knowing before you conclude the feature is broken. (
curldoesn't trigger it, so a command-line check will happily tell you everything is fine.)
Cloudflare Tunnel is the recommendation for a real install — a stable hostname that survives reboots. It is already the recommendation for WhatsApp webhooks, for the same reason and via the same setting.
A decoded string is one of two things, and the only decision the client makes is which endpoint to ask:
decoded text
│
├─ /^https?:\/\/\S*?\/a\/([A-Za-z0-9]{6,})\/?$/ ──► resolve_scan.php?token=…
│ (our own label)
└─ anything else ──────────────────────────────────► find_asset.php?q=…
(maker's serial, hostname, tag)
│
same JSON shape either way
│
┌─────────────────────┴─────────────────────┐
Look up mode Stocktake mode
./?asset_id=N update_asset_field.php
The two endpoints return the same shape on purpose — id, hostname, service_tag, asset_tag, asset_status_id, location_id, type_name, status_name, location_name — so the scanner renders one card and never branches on which answered.
find_asset.php gained asset_status_id / location_id additively for this: the already-set check and the undo both need the previous value, and a second round trip to get it would be silly. assign-tags.php ignores the extra columns.
Worth understanding, because they look like they should agree and shouldn't:
| Where | Pattern | Why this strictness |
|---|---|---|
.htaccess |
^a/([A-Za-z0-9]+)$ |
Routing. Permissive — its job is to get the request to a PHP file, not to validate |
assetIdForToken() |
^[a-f0-9]{8,64}$ |
Validation. Tokens are bin2hex(random_bytes(10)) = 20 hex chars; junk from a mis-scan never reaches the database |
scanner.php client |
[A-Za-z0-9]{6,} |
Dispatch only. It picks an endpoint; the server still decides whether the token is real |
Both scan.php and scanner.php write through api/assets/update_asset_field.php, which is a thin adapter over AssetsService::updateFields(). Nothing about scanning has its own update logic, and that is the point: asset history, warranty-calendar sync and validation happen exactly as they do on the desktop.
Four behaviours inherited for free:
-
Unknown fields are ignored, and the endpoint additionally whitelists a narrow lifecycle set (
asset_status_id,location_id,purchase_date,warranty_expiry, …) so an unexpected field is rejected before the service ever sees it. -
No-ops are skipped. An unchanged value
continues before any UPDATE or audit row is written. -
One audit row per changed field, with display values resolved (so history reads In Storage, not
5). -
syncWarranty()re-runs whenwarranty_expirymoves.
if ($comparableNew === $comparableOld || …) {
continue; // no actual change
}It is why a stocktake over 500 assets doesn't write 500 meaningless history rows. It is also the safest way to exercise this write path in a test: post an asset's current value and nothing is written at all — the round trip is proved, the data untouched. That trick is how the endpoint was verified without mutating the dev database.
The scanner nonetheless compares client-side as well, purely so it can say "Already set — counted as seen" rather than "Saved". A stocktake that reports rewriting everything it looked at is lying to you.
The endpoint calls analystCanAccessAsset() and the service independently calls assertScope(). That is intentional defence in depth: the endpoint guard makes the refusal a clean "Asset not found", and the service guard means any future caller — the REST API's PATCH /assets/{id} uses the same service — cannot forget it.
The token is not a password. It is an unguessable name for a row. Everything else is enforced exactly as it is for any other asset read:
-
A login is required.
scan.phplogged-out shows "Sign in to view this asset" rather than redirecting — the label is in your hand, so signing in and scanning again is genuinely the shortest way back, and a redirect would silently drop what you asked for. -
Module access (
assets) is required — D005 classifiesresolve_scan.phpas read · Module access: 'assets', matchingfind_asset.php. - Company scope applies. And critically:
An unknown token and another company's token return the identical answer. Distinguishing them would turn a label into an oracle for "does this asset exist somewhere on this install?"
That rule is repeated in both scan.php ($problem = 'unknown' for either) and resolve_scan.php (asset: null for either). If you add a third scanning surface, it is the rule to copy first.
Why the QR doesn't encode the asset id: …/a/4711 invites somebody to try 4712. The token isn't secret, but there is no reason to hand out an enumerable index of the estate to anyone who photographs one label.
Chrome/Android uses the browser's own BarcodeDetector; iPhones fall back to bundled jsQR. No camera frame is ever uploaded — the same principle as label generation, where qrcode.min.js was reused specifically so nothing is sent to a third-party QR service.
This is the piece that surprises people, and it is the most important thing on this page.
| The three scan pages | The Assets module proper | |
|---|---|---|
| Files |
scan.php, scanner.php, assign-tags.php
|
asset-management/index.php, table.php, dashboard, settings, servers |
| Strategy | Mobile-FIRST | Mobile-ADAPTED |
| Stylesheets |
theme.css only, plus their own small inline sheet |
inbox.css + page CSS + mobile.css (@media LAYER 14/15) |
| JS | Self-contained |
mobile.js, wrapping the module's own globals |
| Why | There is no desktop version to protect | Desktop must be byte-identical; see Mobile: Assets |
The scan pages are narrow-first surfaces with big touch targets, built for someone standing in a store room. Loading mobile.css into them would be actively wrong: it carries the inbox's body { height: 100dvh } and .main-container { overflow: hidden } pane-stack rules, which would clip a simple scrolling page. The same reasoning already applies to the landing page — see Mobile-Friendly §"Where mobile CSS actually lives".
Both strategies still obey the same device rules. Notably every field on all of these pages is 16px, because iOS zooms anything smaller on focus — and on a mobile-adapted page a zoomed layout reflows to desktop width and switches the entire @media block off. On a mobile-first page it merely looks bad. Same fix, different blast radius. (Techniques §3)
The hinge is worth tracing, because it is the bit that "hangs together" invisibly:
- The Scan link lives in the assets list header — a mobile-adapted surface, styled by
mobile.cssLAYER 14 (.assets-tag-linkgets a real tap height; the two links are grouped in.asset-count-actionsbecause the row isspace-between). - It navigates to
scanner.php— a mobile-first page with none of that CSS. - In Look up mode the scanner navigates to
./?asset_id=N— back onto the mobile-adapted module. -
index.php's deep-link handler callsselectAsset(n)after the lookups resolve. -
mobile.jswrapsselectAsset, so that call pushes the detail pane and a history entry.
Net effect: scanning a label drops you straight onto the asset's detail pane, full-screen, with the device Back button returning to the list. Nothing in the scanner knows about panes; it just navigates, and the wrap does the rest. (Verified: body[data-mobile-pane]="detail", sub-bar showing LT-001, at a true 360px viewport.)
| Mode | A scan means |
|---|---|
| Look up | Open that asset (and stop the camera) |
| Stocktake | Apply the status/location chosen up front, then re-arm immediately |
The stocktake settings are chosen before scanning rather than confirmed after each one. That is the whole value of the mode: a scan needs no follow-up tap.
The subtlest bug in the feature, and it was caught in testing rather than by reasoning:
Nine seconds resting the camera on one label reported "4 scanned". That is a 300-asset stocktake reporting 500.
Two separate mechanisms, often confused:
| Mechanism | Guards against | Scope |
|---|---|---|
lastText / lastAt, 2.5s |
Network chatter — the camera decodes the same code many times a second | The decoded string |
seenIds (a Set) |
A dishonest tally and duplicate list rows | The asset, for the whole session |
Undo last deletes the id from seenIds, because having undone one you are very likely about to re-scan it with the right settings. The counter reports seenIds.size, so an undone asset stops counting as done.
Only the most recent asset, only its last changes. A full multi-step history would be a promise the page can't honestly keep once somebody walks off and scans another thirty. It stores {assetId, changes:[{field, value, was}], label} and writes was back through the same endpoint.
if ('BarcodeDetector' in window) { … detector = new BarcodeDetector({formats:['qr_code']}); }
if (!detector) await loadScript(JSQR); // iOS onlyChrome/Android decodes natively. iOS Safari has no BarcodeDetector, and hand-rolling a QR decoder is not a reasonable thing to do, so jsQR (Apache-2.0) is vendored — and fetched only on the fallback path, so Android never downloads 256KB it won't use.
On vendoring it responsibly. It is handed every camera frame, so it was taken from the npm tarball with the published SHA-1 and SHA-512 both verified, confirmed byte-identical to the CDN copy, and audited for XMLHttpRequest / fetch / WebSocket / localStorage / document.cookie / eval / new Function — it uses none. npm ships no minified build, so the verifiable 256KB file was preferred over an unverifiable 130KB one.
setInterval(…, 200) — five times a second. Every frame is far more work than the job needs and it cooks the battery on a long stocktake; 200ms is indistinguishable from instant to a human hand.
getUserMedia is refused outside HTTPS (localhost aside). That is a browser rule, not ours. The page checks window.isSecureContext first and explains, because a dead black rectangle is the worst possible answer:
if (!window.isSecureContext) {
state.innerHTML = 'The camera needs a secure (https) address.<br>Type a tag or serial below instead…';
return;
}Manual entry is always present for exactly this reason — plus damaged labels, and kit that only carries the maker's own barcode.
-
Torch — shown only when
track.getCapabilities().torchexists, rather than promising a button that does nothing. -
Buzz + blip —
navigator.vibrateand a short WebAudio oscillator, because you are looking at the kit, not the screen. Both wrapped intry— audio is a nicety, never a failure. -
The camera is released on
visibilitychangeandpagehide, so a phone doesn't sit in a pocket with the sensor and the light running. - The reticle is a guide, not a crop. Decoding uses the whole frame; insisting people line the code up exactly is how a scanner ends up slower than typing.
Both label columns arrive via Database Verification, so everything naming them must survive an install that has pulled the update and not run it. assetLabelsSchemaReady() (cached per request) guards:
| Caller | Degrades to |
|---|---|
get_assets.php |
NULL AS asset_tag — critical: naming the column unconditionally makes the whole asset list Unknown column
|
find_asset.php |
drops the tag from both the SELECT and the WHERE |
scan.php |
a plain "labels aren't set up yet" page |
scanner.php |
renders the explanation instead of the camera and its script — nothing starts up with nothing to find |
resolve_scan.php |
"run Database Verification", not "not recognised" — otherwise you go hunting for a bad label when the answer is a missing column |
labels.php |
the same, instead of a sheet of broken codes |
That last distinction is the general lesson: a schema gate should say what is actually wrong, or it sends people to debug the wrong layer.
You can test all of this without a phone. The recipe, in the order it was actually used:
-
Round-trip the codec. Render a real label URL with the bundled generator (
qrcode.min.js), decode it with the bundled decoder (jsQR), assert the text survives and the token regex extracts it. Include a manufacturer serial to prove it yields no token, and a blank white image as a negative control (must decode tonull). -
Drive the real camera path. Dump the QR module matrix from JS, build a Y4M in PHP (
YUV4MPEG2 W640 H480 F10:1 Ip A1:1 C420; Y plane0x00/0xFF, U and V all0x80), and hand it to Chrome. The page then decodes off a genuine video track. -
Authenticate the harness by setting
document.cookie = 'PHPSESSID=…'in<head>—HttpOnlyblocks JS reading a cookie, not setting one — sofetchcalls resolve for real. - Prove company isolation with a positive control. On a single-company install every answer is "yes" and the test is vacuous, so build the two-company case inside a transaction and roll it back.
| Trap | Symptom | Fix |
|---|---|---|
--use-file-for-fake-video-capture without --use-fake-device-for-media-stream
|
Silently ignored; you get the rolling test pattern and decode nothing | Pass both |
--virtual-time-budget |
Races page timers ahead of wall-clock, so real camera I/O never completes — videoWidth stays 0 forever |
Drop it; hold the load event open with a deliberately slow resource (<img src="slow.php"> that sleeps), since --dump-dom fires on load |
getAccessibleTenantIds() memoises per analyst in a process-level static
|
The positive control answers from the negative's cache | Run the two halves as separate PHP processes |
| Saving a rendered page to a file to test it | Drops the query string, so ?asset_id=… handlers no-op and you "prove" a deep link is broken |
Put the query back on the iframe src
|
The membership tables, for that isolation test, are analyst_tenant_access and team_tenant_access (via analyst_teams) — not analyst_tenants.
| What you see | What it actually is |
|---|---|
Red banner on the print sheet naming localhost
|
messaging_public_base_url unset or local. Set it and reprint — the codes don't change, only the address inside them |
| "You are about to visit…" before the asset | Free ngrok's browser interstitial. One tap. Not FreeITSM |
| Labels that worked last week now 404 | A free tunnel URL rotated. Use a stable hostname |
| "Sign in to view this asset" | Working as designed — the scan requires a login, and this is honester than a redirect that loses the scan |
| "This label isn't an asset you can see" | Either an unknown token or another company's — indistinguishable on purpose |
| Camera area explains it needs https |
window.isSecureContext false. Typing still works |
| "Asset labels need a database update" | Database Verification hasn't run |
| Black rectangle where the camera should be | Not an expected state — every known cause has a message. Check the browser console |
-
Record a stocktake as an event, rather than only as field changes.
assets.last_seenis agent-synced, so it must not be reused for this — it would fight the agent. Astocktake_runs/stocktake_seenpair is the honest shape. -
Batch the writes. One
update_asset_field.phpcall per field per asset is fine at 500 and wasteful at 5,000;AssetsService::updateFields()already takes an array, so the endpoint is the only thing in the way. -
More barcode formats.
BarcodeDetectorsupports Code 128 / Code 39 out of the box;jsQRis QR-only, so the iOS path would need a second library. Worth it only if people actually ask. - Offline queue. A store room is exactly where signal dies. This is a real gap — currently a failed save says so and is lost.
- A third scanning surface? Copy the same-answer-for-unknown-and-no-access rule first.
- Asset QR labels — the analyst-facing guide
- Asset QR labels — Developer Guide — tags, tokens, printing, the print-house CSV
- Mobile: Assets — the mobile-adapted half of the module
- Mobile: Techniques & Tricks — the iOS reflow trap and headless verification
- Assets · Multi-Tenancy · Database Verification — Developer Guide
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
- ↳ 🔢 Ticket numbering
- ↳ 🙋 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)