Skip to content

Asset Scanning Developer Guide

Ed Mozley edited this page Jul 29, 2026 · 1 revision

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.


1. 📁 The files involved

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

2. 🧭 Three ways to scan, and why there are three

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.


3. 🌐 The URL, and the setting that decides whether any of this works

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.

⚠️ It reuses the messaging setting, and that is flagged, not hidden

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.

The two ways this goes wrong in the real world

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
}

🚇 Tunnels: fine for testing, wrong for labels

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. (curl doesn'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.


4. 🔀 Resolving a scan

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 purposeid, 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.

Three regexes, deliberately different

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

5. ✍️ The write path — a phone edit is not a lesser edit

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:

  1. 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.
  2. No-ops are skipped. An unchanged value continues before any UPDATE or audit row is written.
  3. One audit row per changed field, with display values resolved (so history reads In Storage, not 5).
  4. syncWarranty() re-runs when warranty_expiry moves.

The no-op skip is load-bearing twice over

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.

Two scope checks, not one

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.


6. 🛡️ The security model

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.php logged-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 classifies resolve_scan.php as read · Module access: 'assets', matching find_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.

Decoding never leaves the device

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.


7. 📱 Two mobile strategies in one module, on purpose

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)

Where the two strategies meet

The hinge is worth tracing, because it is the bit that "hangs together" invisibly:

  1. The Scan link lives in the assets list header — a mobile-adapted surface, styled by mobile.css LAYER 14 (.assets-tag-link gets a real tap height; the two links are grouped in .asset-count-actions because the row is space-between).
  2. It navigates to scanner.php — a mobile-first page with none of that CSS.
  3. In Look up mode the scanner navigates to ./?asset_id=N — back onto the mobile-adapted module.
  4. index.php's deep-link handler calls selectAsset(n) after the lookups resolve.
  5. mobile.js wraps selectAsset, 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.)


8. 🔦 Inside scanner.php

Modes, because a scan has no inherent meaning

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.

Counting distinct assets, not reads

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.

Undo is deliberately shallow

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.

The decoder, and why it is lazy

if ('BarcodeDetector' in window) {  detector = new BarcodeDetector({formats:['qr_code']}); }
if (!detector) await loadScript(JSQR);      // iOS only

Chrome/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.

Decode cadence

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.

⚠️ Secure context

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.

Small things that matter in a store room

  • Torch — shown only when track.getCapabilities().torch exists, rather than promising a button that does nothing.
  • Buzz + blipnavigator.vibrate and a short WebAudio oscillator, because you are looking at the kit, not the screen. Both wrapped in try — audio is a nicety, never a failure.
  • The camera is released on visibilitychange and pagehide, 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.

9. 🛡️ The schema gates

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_tagcritical: 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.


10. 🧪 Testing a camera feature headlessly

You can test all of this without a phone. The recipe, in the order it was actually used:

  1. 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 to null).
  2. 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 plane 0x00/0xFF, U and V all 0x80), and hand it to Chrome. The page then decodes off a genuine video track.
  3. Authenticate the harness by setting document.cookie = 'PHPSESSID=…' in <head>HttpOnly blocks JS reading a cookie, not setting one — so fetch calls resolve for real.
  4. 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.

⚠️ Four traps, all hit here

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.


11. 🚧 Failure modes and what each looks like

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

12. 🔮 Extending it

  • Record a stocktake as an event, rather than only as field changes. assets.last_seen is agent-synced, so it must not be reused for this — it would fight the agent. A stocktake_runs / stocktake_seen pair is the honest shape.
  • Batch the writes. One update_asset_field.php call 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. BarcodeDetector supports Code 128 / Code 39 out of the box; jsQR is 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.

See also

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally