# Installing Link Menu System

This is the full install walkthrough — placement, first-run setup, the canary, the router
config, and how to verify both branches actually work. For a 60-second summary, see the
**Install** section of `README.md`; this document is the detailed version with verification
and troubleshooting.

You are installing **two cooperating halves**:

- **Portal** (`int/index.php`) — the link grid your people actually use, on an **internal**
  server.
- **Canary router** (`ext/index.php`) — the page you set as everyone's **start / new-tab
  page**, on the **public** web.

## The mental model (read this first)

The router is what makes the whole thing work, so understand it before you place a single
file:

1. Every device's start page is the **router** (a public URL — reachable from anywhere).
2. When it loads, the router runs a tiny image probe **in the browser** against a "canary"
   image that exists **only on the internal server**.
3. **Canary loads** → the browser can reach the internal network → redirect to the **internal
   portal**.
4. **Canary fails or times out (3 s)** → the browser is off-network → redirect to the
   **public fallback** URL.

The probe runs in the *visitor's browser*, not on the public web server. That is the design:
the public server never talks to your internal network — the browser does, and its success or
failure loading that one image *is* the on-network / off-network signal. **Get the canary
right and everything else follows.**

There is no "page not found" failure mode. The router always resolves to one of the two URLs;
the 3-second timeout is the backstop.

## Before you start

- A web host that runs **PHP** for each half. No database, no PHP extensions, no build step.
- Decide your three URLs up front. A real example (the reference install):

  | Role | What it is | Example |
  |------|-----------|---------|
  | **Router** (start page) | Public page you set as everyone's start URL | `https://transmissionsplusdayton.com/monster/` |
  | **External / fallback** | Where off-network devices land | `https://transmissionsplusdayton.com/menu/` |
  | **Internal portal** | Where on-network devices land | `https://monster/` |
  | **Canary image** | The probe target, served by the internal server | `https://monster/canary.png` |

  For a demo, both halves can sit side by side on one host; in a real install the portal is
  on the in-house server and the router is on the public web.

## Step 1 — Place the files

The bundle contains `int/` (portal) and `ext/` (router). Serve each as `index.php` at the
path you chose:

- **Portal:** deploy `int/` to the **internal** server. It ships `canary.png`,
  `lms_banner.png`, and `themes/`. Serve `int/index.php` as `index.php` at your internal path
  (e.g. `https://monster/`).
- **Router:** deploy `ext/` to the **public** web. Serve `ext/index.php` as `index.php` at
  your start-page path (e.g. `https://transmissionsplusdayton.com/monster/`).

## Step 2 — Portal first-run setup (create the admin account)

Browse to the **portal** URL. On the first hit it:

- auto-creates `config.json` next to it, seeded with a sample layout (six categories, one
  panel intentionally empty), and
- shows a gold **"First Run Setup"** bar at the top.

Enter a **username** and **password** and click **Secure Site**. The credential is stored as a
single bcrypt hash under the `fingerprint` key in `config.json` — there is no plaintext, no
separate admin file, and no indication which half is the username. You are logged in as admin
immediately.

After that, log in (or out) any time via the small **lock icon in the footer**. Admin mode is
where you edit links, panels, headings, the banner, and site settings in place, drag-and-drop
to reorder, and rotate the login ("Change Login").

## Step 3 — Confirm the canary is actually served (the load-bearing step)

The portal ships a real `int/canary.png`. The router probes **this exact file** to detect the
internal network — so it must be present and loadable at the URL you'll put in
`router-config.json`.

**Verify it directly.** From a browser **on the internal network**, open the canary URL itself
(e.g. `https://monster/canary.png`). You must see a tiny image with **no certificate warning
and no 404**.

If the canary is missing, the router probe gets a 404 → `onerror` → it sends *everyone* to the
external fallback, even on the internal network. Nothing errors visibly; it just quietly does
the wrong thing. **A missing or unservable canary is the single most common install mistake.**

Keep the canary a valid, tiny, loadable image. Never let it become empty or broken.

## Step 4 — Configure the router (`router-config.json`)

On its first hit, the router auto-writes `router-config.json` with placeholders. Edit it on
the server to your real URLs:

```json
{
    "external_url": "https://transmissionsplusdayton.com/menu/",
    "router_url":   "https://transmissionsplusdayton.com/monster/",
    "internal_url": "https://monster/",
    "canary_image": "https://monster/canary.png"
}
```

- **`internal_url`** — where canary-loaded (on-network) devices are sent.
- **`external_url`** — where canary-failed (off-network) devices are sent.
- **`canary_image`** — the full URL of the canary on the internal server (Step 3). This is the
  field that decides everything; double-check it.
- **`router_url`** — informational only (documents the router's own address); not used for
  routing.

## Step 5 — Set the start page

Set every device's browser **start / new-tab page** to the **router** URL — the public one
from Step 1, **not** the portal. That is the entire point: one start URL that resolves to the
internal portal on-network and to the public fallback off-network, and never strands anyone on
a dead page.

## Verify both branches

1. **On-network:** from a device that can reach the internal server, open the router URL. You
   should land on the **internal portal** within a moment.
2. **Off-network:** from a device that cannot reach the internal server, open the router URL.
   You should land on the **external fallback** after the probe fails (≤ 3 s).
3. **No off-network device handy?** Append **`?demo=external`** to the router URL to force the
   off-network branch (it probes a deliberately-missing canary). `?demo=internal` — or no
   parameter — probes the real canary. Harmless in production; nobody appends it to a start
   page.
4. **See what the router decided:** open your browser's **DevTools → Console** on the router
   page. It logs `Canary found` (→ internal), `Canary unreachable` (→ external, the probe
   errored), or `Canary timed out` (→ external, no response in 3 s).

## Troubleshooting

**It always routes external, even on the internal network.** The canary probe is failing for a
reason other than being off-network. In likelihood order:

1. **Canary missing / 404 on the internal server.** The #1 cause. Open the `canary_image` URL
   directly (Step 3) — you must see the image.
2. **Untrusted (self-signed) HTTPS cert on the internal host.** When *you* browse to the
   internal site you can click through a cert warning; an image probe **cannot** — it just
   fails silently → external. Use a cert the client browsers trust, or serve the canary over a
   host/scheme that loads without a prompt.
3. **`canary_image` points at the wrong URL** — a leftover placeholder or demo value in
   `router-config.json`. Re-check Step 4.
4. **Check the Console** (Step 4 of *Verify*): `unreachable` points at a cert or 404 problem;
   `timed out` points at a slow or unanswered host.

**The canary probe is blocked as mixed content.** If the router page is served over **https**,
the `canary_image` must also be **https** — a `http://` canary is blocked by the browser.

**Forgot the admin password / locked out.** Delete the `fingerprint` key from `config.json` to
re-trigger first-run setup (your layout is preserved). Deleting the whole `config.json` also
works but resets the layout to the seed.

## Security: before you go live on a public host

`config.json` holds the bcrypt admin hash and, as shipped, lives **inside the webroot** — so
it may be fetchable by URL. The hash is bcrypt (not plaintext), but on a public host you want
direct HTTP access blocked. Two layers:

- **Shipped mitigation:** the bundle includes a `.htaccess` in `int/` and `ext/` that denies
  direct HTTP fetch of `*.json` on **Apache / LiteSpeed**. Confirm it took effect — a server
  with `AllowOverride None` ignores `.htaccess`. On **nginx**, `.htaccess` does nothing; add
  the equivalent `deny` rule from [`NGINX.md`](NGINX.md).
- **Stronger isolation (optional):** for belt-and-suspenders, move `config.json` /
  `router-config.json` outside the webroot and adapt the PHP to read them from there (via
  `APP_*` env vars). This is a per-deployment hardening step you opt into — the stock install
  does not do it. **Do not put a real or weak credential on a public host** without at least
  the deny rule above in place. See [`NGINX.md`](NGINX.md) for details.

The router half (`ext/`) holds no secrets — only public URLs — so the credential concern is
the portal's, on the internal server.

## What is code vs. instance state

| File | What it is | Shipped in bundle? |
|------|-----------|--------------------|
| `int/index.php`, `ext/index.php` | the two PHP pages | yes |
| `int/canary.png`, `int/lms_banner.png`, `int/themes/*.css` | assets | yes |
| `int/.htaccess`, `ext/.htaccess` | deny `*.json` over HTTP (Apache/LiteSpeed; see `NGINX.md`) | yes |
| `config.json` | portal layout, site settings, admin `fingerprint` hash | no — auto-created at first run, edited on the server |
| `router-config.json` | the router's four URLs | no — auto-created on first hit, edited on the server |

Instance state is generated and edited **on the server** and is never part of the bundle.
Re-installing or upgrading the code never touches it.

## Versioning

Each PHP file carries an internal `lms <build>.<ts6>` stamp near the top. Auto-generated JSON
files carry a `_lms_version` field tying the data to the code that wrote it.

---

MIT — see `LICENSE`. © 2026 smisco &lt;info@smisco.biz&gt;
Website: https://smisco.biz/lms
