Skip to content

Asset Handover Developer Guide

Ed Mozley edited this page Aug 13, 2026 · 1 revision

Asset handover β€” Developer Guide

Why the designer is blocks and not a canvas, why escaping order is the security of this feature, and why one renderer serves three destinations. The plain-language version is Who holds what, and handover documents.

Asked for in discussion #56. Overview and document in 605811aa, designer in 2aed483e.


1. πŸ“ The files involved

🟒 Reading

File Role
includes/services/assets.php usersHoldingAssets(), assetsForUser() β€” both tenancy-scoped
api/assets/get_users_with_assets.php The people list, with search
api/assets/get_user_assets.php One person and their equipment

🟠 The document

File Role
includes/services/handover_templates.php Block catalogue, merge codes, sanitiser, the renderer
includes/handover_styles.php The stylesheet, as a PHP function β€” see Β§4
asset-management/handover.php The printable page
api/assets/email_handover.php The emailed copy

βšͺ The designer

File Role
asset-management/settings/index.php The Handover tab and its JavaScript
asset-management/settings/manifest.php Tab declaration, gated on Cap::ASSETS_HANDOVER
api/assets/handover_templates.php CRUD + preview
includes/capabilities.php ASSETS_HANDOVER

πŸ”΅ Storage

Table Role
asset_handover_templates blocks is JSON; see Β§3 for why that is safe
users_assets Pre-existing. Who holds what, with assignment date, notes, expected return

2. 🧠 Why blocks, not a free-form editor

The obvious "designer" is a rich-text canvas. It is the wrong tool here for one structural reason:

The middle of this document is a repeating region. One row per asset, count unknown until render time. Every WYSIWYG editor either cannot express that, or expresses it as a loop the administrator has to author β€” at which point it is not a designer, it is a templating language with a nicer font.

So a document is an ordered list of blocks drawn from a fixed catalogue. The administrator reorders them, disables them, edits the words in the ones that have words, and picks columns on the one that is a table.

What that buys:

  • the stored template is always renderable β€” nothing arbitrary is ever kept
  • the equipment table stays a real <table> rather than hand-written markup
  • adding a block type to catalogue() makes it appear in the designer and the renderer, with no UI change

The designer JavaScript fetches ?action=meta and builds itself from the catalogue, so it never holds its own opinion about what a document may contain.


3. πŸ”’ The two things that make this safe

Escape first, substitute second

This is the security of the whole feature, and it is the sort of thing that is easy to get backwards.

private static function mergeText(string $text, array $values): string
{
    $escaped = htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
    $from = array_keys($values);
    $to   = array_map(fn($v) => htmlspecialchars($v, ENT_QUOTES, 'UTF-8'), array_values($values));
    return str_replace($from, $to, $escaped);
}
  • Escaping the finished string would escape our own markup along with it.
  • Substituting into unescaped text would let a value containing markup through.

And the values are not ours: {{employee.name}} is a users.display_name, which arrives from the portal, from inbound mail, from an import. ⚠️ This document is emailed, so a stored payload here reaches a mail client, not just a page somebody is already authenticated on.

Verified rather than assumed β€” a user named Alice <script>alert(1)</script> Johnson:

Check Result
Live <script> tags from the name 0
Correctly escaped occurrences 2
Positive control (real block still renders) yes

The sanitiser is the trust boundary

sanitiseBlocks() forces anything arriving from the browser into the catalogue's shape:

  • an unknown block type is dropped
  • an unknown text field on a known block is dropped
  • an unknown column is dropped
  • a missing field falls back to its default
  • a duplicate block type is dropped
  • text is capped at 2000 characters and stored as plain text, never markup

That is why renderBlocks() performs no validation of its own: by the time anything is stored, it is already known-good.

Verified with a hostile payload β€” unknown block absent from the output, a raw <b> in a text field escaped rather than rendered, an invented column not emitted as a header, and the positive control still rendering.

⚠️ A block the client never sent is appended, disabled, rather than dropped. Otherwise a template saved by an older client would permanently lose a block added to the catalogue since.


4. 🧠 One renderer, three destinations

HandoverTemplates::renderBlocks() produces the inside of the document. Three callers wrap it:

Caller Wrapper
handover.php Page chrome, toolbar, print CSS
api/assets/handover_templates.php?action=preview The designer's preview pane
api/assets/email_handover.php An HTML email with the CSS inlined

The preview therefore cannot lie. A designer whose preview is a separate approximation of the output is a designer nobody trusts, and the divergence is always discovered on a document somebody has already signed.

The stylesheet lives in includes/handover_styles.php as a PHP function returning a string, not a .css file, for one reason: the emailed copy has to carry its styles inside the message. A mail client will not fetch an external sheet. Same text, two delivery mechanisms.

⚠️ email_handover.php passes logo_path => null. The page uses a relative path for the logo, which resolves against the page's URL and would be a dead image in a mail client. Serving it absolutely would mean an authenticated URL a mail client cannot reach either β€” worth solving properly if anybody wants a logo in the emailed copy.


5. ⚠️ The stale-rows trap in users_assets

usersHoldingAssets() INNER JOINs users, on purpose.

users_assets has no foreign key, and older installs carry rows pointing at requesters who no longer exist β€” the development database used to build this has nine. A LEFT JOIN would list them as nameless people holding real equipment, which reads as data loss rather than as stale rows.

The cost is honest and worth stating: an asset assigned to a deleted requester is invisible on this screen. It is still on the asset record, which is where it can actually be fixed.


6. Printing rather than generating a PDF

handover.php is rendered server-side and printed by the browser. Both are deliberate.

Server-side, because this is the one page in the module somebody signs and files. It must not depend on JavaScript having run, a fetch having succeeded, or a library being present. What the printer puts on paper is what the server sent.

The browser's print dialogue, because it produces a true PDF via Save as PDF on every platform, keeps the text selectable and searchable, and inherits the fonts and logo already on the page. A jsPDF re-implementation would be a second layout to keep in step with this one β€” and the moment they drift, the PDF and the printout disagree.

Print CSS keeps signature blocks, the declaration and table rows from splitting across pages. ⚠️ A signature alone on page two is not a signed document.


7. Permissions

Cap::ASSETS_HANDOVER gates the settings tab and every write action on handover_templates.php. Reads are plain module access.

The split is intentional: designing the document is administration, producing one is daily work. Withholding the capability must not stop a service desk analyst printing a handover for a new starter.


8. Extending it

  • A new block: add it to catalogue() with its text fields, add a case in renderBlocks(), add the lang keys. The designer picks it up with no change.
  • A new merge code: add it to mergeCodes() and to mergeValues(). Both the palette and substitution follow.
  • A new table column: add it to assetColumns() and to the $defs map in renderAssetTable(), and make sure assetsForUser() selects it.

9. πŸ”΄ Outstanding

  • No logo in the emailed copy β€” see Β§4.
  • No per-template default per situation. One default for the whole install; an onboarding pack and a leaver's checklist both exist but the button always uses the default. A template picker on the handover screen is the obvious next step.
  • Nothing records that a handover happened. The document is produced on demand and not stored, so there is no history of "Alice signed for this on the 3rd". A signed-copy upload against the person would close that loop.
  • 23 locales. All new strings are English-only and fall back silently.

Related pages

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally