-
Notifications
You must be signed in to change notification settings - Fork 15
Asset QR Labels Developer Guide
Two identifiers, one short URL, and a page whose primary device is a phone. Shipped as #935.
The analyst-facing page is QR asset labels.
Colour key: π§ shared rule Β· π API Β· π₯οΈ page Β· ποΈ schema Β· π routing Β· π i18n Β· π docs
| π¨ | File | What it does |
|---|---|---|
| π§ | includes/asset_labels.php |
The whole rule: schema gate, token mint/resolve, per-company tag availability, and the label URL |
| π₯οΈ | asset-management/scan.php |
Where a scan lands. Mobile-first, with the two edits worth making while stood next to the asset |
| π₯οΈ | asset-management/labels.php |
The print sheet (four label pitches) and the CSV export for a print house |
| π₯οΈ | asset-management/assign-tags.php |
The reverse flow: scan the serial, scan the pre-printed tag, save, repeat |
| π₯οΈ | asset-management/scanner.php |
The continuous camera scanner (#938) β Look up / Stocktake, torch, undo, manual entry |
| π¦ |
assets/js/vendor/jsQR.js, assets/js/vendor/jsQR.LICENSE
|
QR decoder (Apache-2.0), the iOS fallback β loaded only when BarcodeDetector is missing |
| π | api/assets/save_asset_tag.php |
Sets the tag, enforcing per-company uniqueness |
| π | api/assets/find_asset.php |
Exact-match lookup by serial / hostname / tag, for the tagging loop and the scanner's non-label path |
| π | api/assets/resolve_scan.php |
Token β asset (#938), same shape as find_asset.php so the scanner needn't care which answered |
| π | api/assets/update_asset_field.php |
Reused unchanged by both scan.php and the scanner, so a phone edit takes the desktop code path |
| π | api/assets/get_assets.php |
Returns asset_tag (behind the schema gate) so the list can show the chip |
| π₯οΈ | asset-management/index.php |
The tag field, Print label, the Ctrl/Shift batch selection, and the Scan / Assign-tags links |
| π | .htaccess |
One rewrite: /a/<token> β scan.php
|
| ποΈ |
database/freeitsm.sql, includes/db_verify_schema.php, includes/db_verify_indexes.php
|
assets.asset_tag + assets.qr_token, one index each |
| π |
lang/en/asset-management.php, lang/pt-BR/asset-management.php
|
field.asset_tag*, list.*, detail.print_label
|
| π |
CHANGELOG.local.md, README.md, this wiki |
logged as #935, scanner as #938 |
Note what is not there for generating codes: no PDF library and no QR library β assets/js/qrcode.min.js was already bundled for MFA enrolment, so labels are drawn client-side with nothing new added and nothing sent to a third-party QR service.
Reading codes is the one place a dependency was unavoidable, and only for iPhones. Chrome/Android has a native BarcodeDetector; iOS Safari does not, and writing a QR decoder is not a reasonable thing to hand-roll. jsQR (Apache-2.0) is vendored and lazy-loaded only on the fallback path, so Android never downloads it. It was taken from the npm tarball after checking the published SHA-1 and SHA-512, and audited for network/eval/storage APIs β it has none, which matters for a library handed every camera frame.
This page is about getting a label made: the two identifiers, per-company tag uniqueness, and printing.
Everything about a label being read β the three scanning surfaces and why there are three, the public-base-URL problem that decides whether any code works at all, the resolve and write paths, the security rules, the two mobile strategies the module runs side by side, and how to test a camera without a phone β is its own deep dive:
`asset_tag` VARCHAR(64) NULL, -- the human number on the label: "LT0001"
`qr_token` VARCHAR(64) NULL, -- what the QR encodes: opaque, install-wide uniqueasset_tag |
qr_token |
|
|---|---|---|
| Read by | people | machines |
| Unique | per company | install-wide |
| Set by | an analyst | minted on first print |
| In the QR? | no | yes |
The question that forces this design: what if two companies both use LT0001? If the QR encoded the tag, it couldn't resolve. If it encoded assets.id, β¦/a/4711 would invite somebody to try 4712. So it encodes neither.
The token isn't a secret β the scan page requires a login and enforces company scope like any other asset read β but there is no reason to hand out an enumerable index of the estate to anyone who photographs one label.
The obvious schema is UNIQUE (tenant_id, asset_tag). It does not work here.
MySQL treats NULLs as distinct in a unique index, and tenant_id IS NULL means the Default company. So two Default-company assets could both be LT0001 while the index sat there looking like it was guarding them β and a unique index that silently doesn't apply is worse than no index at all, because everyone downstream trusts it.
Uniqueness is therefore enforced in assetTagAvailable(), using the null-safe equality operator so the Default company behaves like a named one:
"SELECT COUNT(*) FROM assets WHERE tenant_id <=> ? AND asset_tag = ?"This is not a new idea in this schema β hostname is checked the same way, for the same reason, and freeitsm.sql already said so. The index that is there (idx_assets_tag) is for lookup only, and says so in a comment.
uq_assets_qr_token is unique, safely: the token is install-wide and never NULL once minted.
RewriteRule ^a/([A-Za-z0-9]+)$ asset-management/scan.php?token=$1 [L,QSA]Every character in a QR is another module. These print at roughly 15mm square on the side of a laptop that then spends three years being knocked about, so a coarser, more forgiving code matters more than a tidy path. /a/<token> with a 20-character token keeps the code small enough to scan when scuffed.
The absolute URL comes from assetPublicBaseUrl(), which reuses messagingPublicBaseUrl() and its messaging_public_base_url setting rather than adding a second answer to "how does the outside world reach this install?". A label is printed once and lives on a laptop for years, so deriving it from whichever hostname the printing analyst happened to be using would bake that in permanently.
β οΈ Flagged rather than hidden: that setting is named for messaging and is now doing install-wide work. It wants a generic key (reading both, falling back) next time settings are touched.
Both columns are added by Database Verification, so anything that names them has to survive an install that has pulled the update and not yet run it. assetLabelsSchemaReady() (cached per request) guards:
| Caller | Degrades to |
|---|---|
get_assets.php |
NULL AS asset_tag β critical: naming the column unconditionally would turn the whole asset list into Unknown column
|
find_asset.php |
drops the tag from both the SELECT and the WHERE |
save_asset_tag.php |
refuses with "run Database Verification" |
scan.php |
a plain "labels aren't set up yet" page |
labels.php |
the same, instead of a sheet of broken codes |
scanner.php |
renders the explanation instead of the camera and its script, so nothing starts up with nothing to find |
resolve_scan.php |
says "run Database Verification" rather than "not recognised" β otherwise you go hunting for a bad label when the real answer is a missing column |
Same lesson as the snooze columns in the ticket list β see Snoozing tickets β Developer Guide Β§5.
This is the one page in FreeITSM whose primary device is a phone: you are stood in a store room holding the laptop. So it is a purpose-built narrow surface with its own small stylesheet, not the desktop asset editor put through mobile.css.
That is a deliberate departure from the mobile-friendly rollout's wrap-don't-edit rule, and the reason is simple: that rule exists to protect a desktop layout, and this page has no desktop layout to protect. The rule still applies in full to the Assets module proper.
What it does share is the hard-won detail:
- 46px touch targets, and 16px inputs β under 16px, iOS zooms on focus, the page reflows to desktop width, and mobile rules stop matching (the trap documented in Mobile-Friendly: Techniques Β§3).
-
Pinch-zoom left enabled β no
user-scalable=no. Reading a battered serial in a dim store room is exactly when someone needs to zoom. - Status and location save on change, with no Save button: the failure mode of a forgotten Save on a phone is losing the edit entirely.
Those two writes go through the module's existing update_asset_field.php, so a phone edit gets the same validation, history entry and warranty-calendar sync as a desktop one. A phone edit is not a lesser edit.
scan.php does not query the status and location lists itself. They're scoped by two different rules β locations are scoped data (activeTenantFilter), status types are a config list (globals + the company's own, minus its hidden ones via tenant_config_hidden) β so the page calls the module's own endpoints, which already implement both. A second copy of either would drift.
@page plus millimetre units, because millimetres are the unit label stock is sold in and the unit the printer thinks in. No PDF dependency: the browser's print dialog gives the same result with a live preview.
Four pitches are offered (65/40/24/12 per A4). Each label is a flex row of QR + text, break-inside: avoid so one never splits across pages, with dashed cut guides that exist only on screen.
π A bug worth remembering: the sheet keys were numeric strings (
'65','40'β¦), which PHP silently casts to integer array keys. A===comparison against the string from$_GETthen never matched, so every<option>rendered unselected β the browser showed the first one while the grid below used the real choice. A dropdown disagreeing with the page. Compare(string)$k.
There's no ITAM standard for handing labels to a print house, but there is a standard process β variable data printing β so the deliverable is one row per label with the QR payload as a literal URL, not a picture.
π PHP 8.4 deprecates relying on
fputcsv()'s default$escape. Withdisplay_errorson, that notice is written into the download β an HTML fragment in the middle of a CSV that a print house would merge onto 500 labels. Pass it explicitly;''also disables backslash escaping, which is what Excel and every CSV reader expects.api/intune/dashboard_drilldown.phphad the identical bug and was fixed alongside.
Printing assumes FreeITSM picks the numbers. Companies that buy pre-printed sequential tags need the opposite: the sticker exists, and the database must agree with it.
The loop is built around scanning what is already on the machine β the manufacturer's serial barcode β because hunting a list for "which laptop is this?" is the slow part of a tagging day. A barcode scanner is a keyboard that ends with Enter, so keydown on Enter is the entire interaction model; no driver, no pairing, no app.
Three deliberate behaviours:
-
find_asset.phpmatches exactly, never fuzzily. The one genuinely bad outcome is a tag on the wrong machine. Ranking (tag β serial β hostname) is done in PHP over at most five rows, not in SQL β ordering placeholders would have to be spliced around the tenant fragment's, and positional binding is strictly left-to-right, which is exactly how the wrong value gets bound six months later. - The matched asset is shown before the tag field, so a mis-scan is caught before it matters.
- A duplicate tag keeps you on the asset rather than resetting: the tag is in your hand and needs a decision.
Picking several assets mirrors the ticket inbox (#910) rather than inventing an idiom: a plain click still opens the asset, and Ctrl/Shift build a selection on top. No checkboxes, no "selection mode" toggle β both change the list for everyone to serve an occasional job.
π The open asset must be IN the selection. First cut cleared the selection on a plain click, so opening one asset and Ctrl-clicking a second gave you one, not two. Explorer and Outlook treat the highlighted row as selected. The fix seeds the selection inside
selectAsset()β not the click handler β so arriving by deep link behaves the same, and Clear returns to just the open asset rather than to nothing.
π Shift-click smeared the browser's text selection across the list, and Edge popped its selection mini-menu. One cause: text selection begins on
mousedown, so auser-select: noneclass applied in the click handler is always a beat too late for the first shift-click.preventDefault()on a shift-mousedowninside the list is what actually prevents it β and the mini-menu goes with it, because that popup is triggered by having text selected. The ticket inbox had the identical latent bug (#emailList.multi-selectingwas doing the too-late thing) and was fixed the same way.
- Tokens: minted once and stable on re-call, resolving back to the right asset; unknown and malformed tokens both resolve to null.
- Per-company tag uniqueness including the Default company β the case the unique index could not have caught: same tag refused within a company, allowed in another, and allowed on the asset that already holds it.
- Company scope with positive controls β a restricted analyst printing their own company's asset (control) and getting nothing for another's; the same token scanned by a restricted analyst ("not recognised") and by an all-company analyst (the asset), so the block is demonstrably the scope and not a broken token.
- The tagging loop driven as a scanner would β type, Enter, type, Enter β asserting the match, the save, the counter, the reset and focus, a duplicate keeping its place, and an unknown scan.
-
The selection, driven with real modifier-key events: plain click leaves one selected and the bar hidden; Ctrl-click gives two; Shift spans the block; Clear returns to the open asset. Plus
defaultPreventedasserted true for a shift-mousedown inside the list and false both for a plain mousedown and for a shift-mousedown outside it. -
The CSV re-checked for HTML leakage after the
fputcsvfix. - Pre-upgrade behaviour, and D005 confirming both new endpoints land on Module access: assets.
The scanner (#938) was verified against a fake camera, which turns out to be entirely practical:
-
A QR round-trip: a real label URL rendered with the bundled generator and decoded back with the bundled decoder, asserting the text survives and the token regex extracts it β plus a manufacturer serial (
ABC1234) correctly yielding no token, so it falls through tofind_asset.php. A blank white image as the negative control decodes tonull. -
The whole loop, end to end. The QR module matrix was written into a Y4M video and handed to Chrome via
--use-fake-device-for-media-stream --use-file-for-fake-video-capture=β¦. The page then genuinely decoded from a live video track and resolved the asset: LT0001 Β· LT-001 Β· Laptop Β· Active, zero JS errors, withjsQRconfirmed lazily loaded because that Chrome has noBarcodeDetectorβ i.e. the iPhone path is the one that was exercised. - The duplicate-count bug this caught. Nine seconds resting on one label first reported "4 scanned". That is a 300-asset stocktake reporting 500. Fixed with the per-session id set; re-run reported 1 scanned, 1 line.
- Cross-company isolation, with a positive control β and the install is single-company, so the honest test had to build the two-company case inside a transaction and roll it back: the same analyst was refused the asset when not a member of its company and allowed it when made one, with the token still resolving either way, proving the block is the access check and not a failed lookup.
β οΈ Two headless traps, both hit here.--use-file-for-fake-video-capturesilently does nothing without--use-fake-device-for-media-streamalongside it β you get the default rolling pattern and a test that decodes nothing. And--virtual-time-budgetraces page timers ahead of wall-clock, so real camera I/O never completes and the probe reportsvideoWidth=0; hold the load event open with a deliberately slow resource instead, so real time passes. (The virtual-time trap is the same one recorded in Collision detection.)
β οΈ Screenshot gotcha, rediscovered the hard way: on Windows,--window-size=360does not give a 360px layout β the OS clamps the window's minimum width, so the screenshot is cropped, which looks exactly like content overflowing the viewport. It nearly cost a "fix" for a non-bug. Render inside a 360px iframe in a larger window, and measuredocumentElement.scrollWidthagainstclientWidthbefore believing any narrow screenshot. Already recorded in Mobile-Friendly: Techniques Β§8.
- Auto-numbering ("next free tag in this company") β the availability check is already there; it needs a prefix convention and a per-company counter, and a decision about gaps.
-
Print from the table view β
labels.phptakes?ids=, so it's a selection UI ontable.php, nothing more. - Custom label sizes β deliberately left out of v1 rather than half-done; the pitch table is the place.
- Portal scanning (a requester scanning a label to report a fault) β would need a decision about what a non-analyst may see, and is a genuinely different feature.
- QR asset labels β the analyst-facing page
- Asset scanning β Developer Guide β the reading half: three scan surfaces, the public-base-URL problem, the resolve and write paths
- Assets β the module
- Mobile-Friendly: Techniques & Tricks β the iOS traps scan.php avoids
- Snoozing tickets β Developer Guide β the same schema-gate pattern
- Multi-Tenancy: worked examples β the scoping the guards rely on
-
Database Verification Developer Guide β
$schema, and regenerating the index list
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)