Skip to content

Portal Was Down For Everyone Signed In

Ed Mozley edited this page Sep 1, 2026 · 1 revision

The portal was down for everyone who had signed in

Reported by email Β· Fixed in #1441


What you saw

The self-service portal accepted your password, and then gave you a page with nothing on it. Not an error, not a broken layout β€” the browser tab had the right title, the styling loaded, and the page was empty below it.

Every page behind the sign-in did the same thing: the dashboard, your tickets, the service catalogue, raising a ticket, the help centre and help. Six of them.

The only way to see what had happened was to view the page source, where the page stopped mid-attribute:

<div class="portal-header">
    <div class="portal-brand">
        <img src="<br />
<b>Fatal error</b>:  Uncaught Error: Call to undefined function brandingLogoUrl()
in .../self-service/includes/header.php:100

The sign-in page itself was fine. So was registration. That is the part worth holding on to β€” from outside, the portal looked completely healthy.


What was actually wrong

One missing line.

Update #1421 taught the portal to display a logo an administrator had configured, rather than the bundled one. The portal's shared page header stopped hardcoding the file and asked for it instead:

<img src="<?php echo htmlspecialchars(brandingLogoUrl()); ?>" alt="">

brandingLogoUrl() lives in includes/branding.php. The header never loaded that file. PHP looks a function name up when it reaches the call, not when it parses the file, so the page rendered perfectly until the exact byte where the function was needed β€” which is why you get a page with a <head>, a <body>, and then nothing.

The fix is the line that was missing:

require_once __DIR__ . '/../../includes/theme.php';
require_once __DIR__ . '/../../includes/branding.php';   // ← this
require_once __DIR__ . '/../../includes/timezone.php';

Why it looked healthy from the outside

self-service/login.php and self-service/register.php show the logo too, and both of them load the file themselves. They were changed in the same commit, correctly.

So the portal's entire public face β€” the only part you can reach without a password β€” went on working exactly as before. The failure began at the first page after sign-in and stopped there, in the one part of the portal that nobody can check without an account.

It is also the reason a reviewer would not spot it by reading the directory. Two of the three files that call the function sit right there in self-service/, both with the require at the top. The third is the one page you cannot see from the outside.


The shape of the change is the whole explanation

Nine hardcoded paths became nine calls to one helper. Here is what that commit did inside the portal:

 self-service/includes/header.php | 2 +-
 self-service/login.php           | 3 ++-
 self-service/register.php        | 3 ++-

Two files gained a line. One only swapped a line.

That is not a coincidence, it is the mechanism. In self-service/login.php and self-service/register.php the logo is set up near the top of the file, in among the requires β€” so replacing it put the author's eye on the require block, and the require went in. In self-service/includes/header.php the logo is a tag buried a hundred lines down in the markup, nowhere near the top of the file, and nothing about editing that line prompts you to look at what the file loads.

A find-and-replace across nine call sites asks "did every hardcoded path get replaced?". The question that needed asking was "can every file that now calls this reach it?" β€” and those are not the same audit.


Why nothing caught it

Four separate safety nets did not apply, and each one is worth knowing about on its own.

Check Why it missed this
php -l An undefined function is a runtime error. The broken file lints clean β€” verified against the exact broken revision.
The HTTP status code A PHP fatal is served as HTTP 200. Anything checking only the status reports a dead page as a pass.
Loading the portal to look at it The pages you can load without an account were the pages that worked.
The test suite There is no test that signs into the portal and loads a page. There is no test that loads any page and fails on a fatal.

The first two are general. A page can be completely dead and still answer 200 with a syntactically perfect file β€” so if you are checking pages by script, read the response body and treat Fatal error or Uncaught as a failure, whatever the status line says.


It was one place, not nine

The convention on these write-ups is to sweep for other instances of the same fault before writing anything, and this time the sweep is the reassuring half rather than the alarming one. Every call site in the product was checked:

File Loads includes/branding.php?
auth/login.php βœ…
index.php βœ…
forms/edit/index.php βœ…
forms/fill.php βœ…
forms/settings/index.php βœ…
morning-checks/index.php βœ…
system/branding/index.php βœ…
api/system/save_branding.php βœ…
self-service/login.php βœ…
self-service/register.php βœ…
self-service/includes/header.php ❌ β€” this bug

Ten of eleven were already right. (includes/services/network_mapper.php has a brandingSlot() of its own, which is an unrelated class method that happens to share the prefix β€” worth naming so the next person grepping for branding does not chase it.)


Files

πŸ“– read Β· πŸ–₯️ UI

🎨 File What changed
πŸ–₯️ self-service/includes/header.php one require_once, with a comment saying why it belongs here rather than in the six pages

How it was verified

A forged portal session against a real installation, then all six signed-in pages fetched and their bodies read:

index       ok    88,717 bytes
tickets     ok   106,808 bytes
help        ok    84,937 bytes
help-centre ok    77,522 bytes
catalogue   ok    85,556 bytes
new-ticket  ok   113,753 bytes

No Fatal error and no Uncaught in any of them.

And a positive control, which is the part that actually proves something. "No fatal error" would also be true if the require had pointed at an empty file, or if the function had failed and been swallowed. So the check was that the header renders the configured logo, not the bundled fallback:

<img src="/freeitsm-app/system/uploads/branding/d972b01e3866a83dfed62f3f3d244b65.png" alt="">

That is an uploaded file, read out of system_settings β€” so the file really loaded, the function really ran, and it really reached the database. Asserting the absence of an error proves much less than it appears to.


What this means for you

  • If your portal users can sign in and then see a blank page, this is it, and you are on update #1421 or #1422. Take #1441, or add the single require_once above to self-service/includes/header.php yourself.
  • Nothing was lost or corrupted. The pages could not be drawn; no data was touched, and no ticket raised through the portal before the upgrade went anywhere it should not have.
  • The analyst side was never affected, nor was portal sign-in, nor registration.

What is not fixed

  • There is still no smoke test. Nothing in the suite loads every page in the product and fails on a fatal, and nothing at all exercises the portal behind its sign-in. A test that forges a session, fetches each page and greps the body for Fatal error would have caught this in seconds, and would catch the whole family β€” any shared include that calls a function it does not load. It is the obvious next thing to build and it has not been built.
  • The general fault is not designed out. PHP resolving function names at call time is exactly what makes a helper like this pleasant to use everywhere, and also what lets a caller forget to load it with no warning until somebody visits the page. There is no static check in the project that would object.

Related

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally