Files
fitme3070.pt/shared/platform-tools/README.md
T
andreandClaude Opus 5 4d40adbf64 Vendor platform-tools: everything this site runs is now in one repo
shared/platform-tools was a git submodule. On this branch it is a plain
directory of tracked files, frozen at ea3616abae30106df283e73a3e2f9e5bb0e6ae90
— the same commit fotografia.andmore.pt pins. Contents are byte-identical to
that tree, verified blob by blob.

The submodule caused every deployment failure this site has had:

  - .gitmodules named no `branch`, so the update fell back to
    refs/remotes/origin/HEAD — a ref a plain server-side clone never writes.
    "Atualizar submódulo" failed with "Unable to find refs/remotes/origin/HEAD
    revision in submodule path".
  - Its URL was http://, which the origin answers with 523.
  - This install is deployed by uploading files, and a submodule is invisible
    to that. The tree moved forward while shared/platform-tools stayed on the
    old pin, config/functions_secrets.php died on a missing compat_secrets.php,
    and the whole site — public pages included — went down.

Each of those was separately fixable. Together they mean a second repository
nobody can see from the deployment path, gating whether the first one boots. A
plain directory deploys like every other file and cannot fall out of step.

The cost is that platform-tools updates no longer arrive by bumping a pin; they
are a copy-over-and-commit. shared/platform-tools/VENDORED.md records the
upstream SHA, the procedure, and which four files differ from upstream in line
endings only (social/*.js, .css, functions_publications.php — CRLF upstream,
normalised to LF here; content otherwise identical).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 23:06:23 +01:00

938 lines
52 KiB
Markdown

# platform-tools
Shared sysadmin tooling — "Atualizar plataforma" page, "Backups" page, and related helpers — distributed as a git submodule across every andmore platform.
The goal: edit one file, every platform gets the update.
## What's in here
| File | Purpose |
|---|---|
| `index.php` | Sysadmin hub — single landing page with cards linking to the other tools. Extensible via `$sysadmin_tools` array in the stub. Loaded by each platform's `views/sysadmin/index.php` stub. |
| `platform_update.php` | Shared sysadmin page. A status header (version, branch, commit, state pills, the update button) over four tabs: **Atualizar** (restore point, git, submodules), **Automação** (auto-update webhook, pre-update backups), **Base de dados** (migrations, schema export), **Servidor** (OPcache, composer, error log, pre-requisites). Loaded by each platform's `views/sysadmin/platform_update.php` stub. |
| `functions_git.php` | `git_run(array $args, string $repo)` — runs git via `proc_open` array form, bypasses `safe.directory`, supports `GIT_HTTP_USERNAME` / `GIT_HTTP_TOKEN` for non-interactive HTTPS pulls. |
| `functions_migrations.php` | `db_ensure_migrations_table()`, `migrations_list_files()`, `migrations_status()`, `migrations_apply_file()`, `migrations_mark_all_applied()`, `migrations_split_sql()` — quote/comment-aware SQL splitter, tracks state in `applied_migrations`. |
| `functions_schema.php` | `schema_dump_path()`, `schema_build(PDO)`, `schema_dump_to_file(PDO)` — deterministic, structure-only dump of the host DB to `database/schema.sql` over PDO (`SHOW CREATE TABLE/VIEW`, no mysqldump). Strips timestamps, `AUTO_INCREMENT`, and view `DEFINER`s, sorts objects — so a git diff on `schema.sql` is a real changelog. Surfaced as the "Exportar esquema" button in the Migrations card. |
| `backups.php` | Shared sysadmin page — lists existing backups, buttons to trigger files-ZIP / DB-dump backups, download link per row. Loaded by each platform's `views/sysadmin/backups.php` stub. |
| `functions_backup.php` | `backup_dir()`, `backup_dir_protect()`, `backup_list()`, `backup_trigger($type)`, `backup_filename_is_safe()`, label/badge helpers. |
| `functions_csrf.php` | Shared CSRF token over the host session — `platform_csrf_token()`, `platform_csrf_field()`, `platform_csrf_ok()`. Every POST form rendered by `platform_update.php` / `backups.php` carries the token; the handlers reject POSTs without it. |
| `functions_settings.php` | DB-backed settings and secret storage — `platform_settings_get/set/all()`, `platform_settings_is_secret()`, `platform_secret_encrypt/decrypt/mask()`, `platform_smtp_config(PDO)`. The single implementation of secret encryption for the whole estate; see [Settings and secrets](#settings-and-secrets). |
| `functions_auth.php` | Shared authentication mechanics — `auth_session_start()` (HttpOnly/SameSite/Secure/strict mode), `auth_attempt()` (lookup + verify + transparent rehash + timing defence), `auth_login()`, `auth_schema()`, the ranked role registry, the guards, and a proxy-aware login throttle. See [Authentication](#authentication). |
| `functions_ip_ban.php` | IP banning with memory — `platform_ip_guard()`, `platform_ip_record_offence()`, `platform_ip_ban/unban()`. The estate's three per-IP defences all forget within the hour; this is the piece that remembers. See [IP banning](#ip-banning). |
| `ip_bans.php` | Shared sysadmin page for the above — active bans, addresses climbing toward one, and a manual ban form. |
| `functions_mail_rate.php` | Send ceiling — `platform_mail_rate_blocked()`, `platform_mail_rate_record()`. Two windows (per minute, per hour), enforced inside `platform_mail_send()` when a PDO is passed. Fails **closed**. |
| `functions_client_ip.php` | `platform_client_ip()` — the visitor's address behind the proxy, honouring `X-Forwarded-For` only when `TRUSTED_PROXY` allows. No dependencies and nothing executed on include, so a site that declares its own `auth_login()` can include it without colliding with `functions_auth.php`. `auth_client_ip()` is an alias for it. |
| `functions_tracking.php` | Visit-source analytics — `platform_track_visit()`, `platform_track_conversion()`, the report helpers. Records where a visitor came from (URL/campaign/referrer), on what device, and from which country. Came from fotografia; see [Visit tracking](#visit-tracking). |
| `tracking.php` | Shared sysadmin page for the above — sources with conversion rate, conversions per form, and breakdowns by country, device, OS, browser and referrer. |
| `bin/geo-build-index.php` | Compiles the all-countries IP→country index used by the analytics. Not versioned (~5 MB); regenerate from cron. |
| `functions_geo.php` | Country gating — `geo_mode()`, `geo_block_reason()`, `geo_form_block_reason()`, `geo_guard_site()`, `geo_ip_in_country()`. Refuses public form POSTs (or the whole site) from outside an allow-list of countries. See [Country gating](#country-gating). |
| `geo.php` | Shared sysadmin page — mode, country list, fail-closed toggle, data-file status, a "refresh lists" button and an IP probe. Loaded by each platform's `views/sysadmin/geo.php` stub. |
| `bin/geo-update.sh` | Refreshes the per-country CIDR lists in `data/geo/` from ipdeny.com. Validates before overwriting, so a bad download keeps the previous file. |
| `data/geo/<cc>.v4`, `<cc>.v6` | Committed CIDR allocations per country, read by `functions_geo.php`. Committed so a deploy never depends on ipdeny being reachable. |
| `functions_password_reset.php` | Password recovery over the shared SMTP transport — `password_reset_request/validate/consume/prune()`. Stores only the SHA-256 of the token. |
| `compat_secrets.php` | Re-exports the short names (`secret_encrypt`, `secret_decrypt`, `secret_is_encrypted`, `secret_mask`, `secrets_available` / `secrets_key_available`, `SECRET_PREFIX`) that existing site call sites use. A site includes this instead of writing its own cipher. |
| `bin/encrypt_secrets.php` | CLI — encrypts any secret-valued row still stored in plaintext. Idempotent, safe to call from `deploy.sh`, refuses to run unless connected to the schema `DB_NAME` names. |
| `api/backup_trigger.php` | Self-bootstrapping POST handler — auth + token + curl-fire-and-forget to the worker. Per-platform stub at `<host>/api/backup_trigger.php` is a 1-line require. |
| `api/backup_download.php` | Self-bootstrapping download handler — auth + filename validation + `readfile()`. Per-platform stub same shape. |
| `backups/config.php` | Loads host's `.env`, defines `BACKUP_TOKEN`, `BACKUP_KEEP_DAYS`, `BACKUP_FILES_DIR`, DB constants, `cleanOldBackups()`. |
| `backups/backup_maker.php` | Files ZIP worker — archives ONLY `<host>/uploads/` (the one folder with external data; code is reconstructible from git). Source folder overridable via `BACKUP_FILES_DIR` in `.env`. Writes to `<host>/backups/<file>.zip` and rotates old ZIPs. |
| `backups/db_backup.php` | DB dump worker. Writes to `<host>/backups/db_backup_<date>.zip`, **AES-256 encrypted** when `APP_SECRET_KEY` is set. See [Backup encryption](#backup-encryption). |
| `functions_agent.php` | Remote-ops agent helpers — `agent_verify_or_die($secret)` (HMAC over method+path+sha256(body)+date, ±5 min), `agent_status(PDO, $repo_root)` (git/migrations/schema-drift/last-backup/php/disk, all `function_exists()`-guarded), `agent_json()`. |
| `api/agent.php` | Self-bootstrapping remote-ops endpoint for the **id.andmore.pt fleet control plane**. Verifies the HMAC, dispatches an action allowlist (Fase 0: `status`). Per-platform stub at `<host>/api/agent.php` is a 1-line require. |
| `functions_auto_update.php` | Deploy-on-push — `auto_update_run(PDO, $repo_root, ?$ref)` (optional pre-update backup + pull + pending migrations + cache invalidation, behind a per-repo lock), `platform_pre_update_backup()` (triggers and *waits for* the configured backups; a failure aborts the update), `platform_respond_and_continue()`, `auto_update_verify()` (HMAC-SHA256 of the raw body, accepts `X-Gitea-Signature` and `X-Hub-Signature-256`), the on/off settings, and the recorded last-run state. Also `platform_status_summary()` / `platform_status_strip()` — the git-free, one-line platform state a host dashboard can render. See [Auto-update](#auto-update). |
| `api/auto_update.php` | Self-bootstrapping deploy webhook. Point a Gitea push webhook at `<host>/api/auto_update.php`; per-platform stub is a 1-line require. Secret in `.env` as `AUTO_UPDATE_SECRET`. Replies `202` immediately and updates with the connection closed — Gitea's delivery timeout is 5 s. |
| `functions_hub.php` | The hub client — API/ops keys (generated here, stored AES-encrypted, `.env` wins), the health/ops/pair endpoint auth, the pairing announce, and `hub_ticket_create()` / `hub_ticket_ready()`, which post to the hub's `/api/tickets.php`. Portable across stacks: every host helper is reached through `hub_env()`/`hub_setting()`/`hub_base_url()`. |
| `hub_integration.php` | Shared sysadmin page for the above — hub URL, key rotation, pairing state. Loaded by each platform's `views/sysadmin/hub_integration.php` stub. |
| `functions_feedback.php` | Feedback from the people who run the platform — reads the account from any of the fleet's three session shapes (`current_user()`, `$_SESSION['user']`, or loose `user_id`/`username`/`role` keys), the ticket categories, the context block (page, version, commit, account, browser) and `platform_feedback_submit()`. See [Feedback bubble](#feedback-bubble-fleet--hub-tickets). |
| `feedback_widget.php` | The bubble itself, bottom-right. Included from the host's backoffice footer; renders nothing for anyone who isn't an admin. Zero dependencies — no Bootstrap, no icon font, no jQuery. |
| `feedback_footer_link.php` | The same widget as a footer link instead of a floating bubble — for the public site, where the bottom-right corner is already taken (WhatsApp). A file rather than a flag so the host include stays one bare line either way. |
| `api/feedback.php` | The JSON endpoint the bubble posts to. Cookie-authenticated (not key-authenticated, unlike the rest of `api/`), so it needs the host's session — which it gets from `hub_bootstrap.php`, same as the others; the per-platform stub is a 1-line require. Every POST carries the `platform_csrf_ok()` token. |
## Auto-update
A push becomes a deploy. Gitea POSTs the push payload to
`<host>/api/auto_update.php`; the platform verifies the signature, pulls itself,
applies the migrations that arrived in the same commit, and invalidates the
OPcache and the settings caches. It is the "Atualizar plataforma" button with
nobody standing in front of it.
### Wiring it up
1. `AUTO_UPDATE_SECRET=<random>` in the host's `.env`. Generate one with
`openssl rand -hex 32`. It lives in `.env`, not `system_settings`, for the
same reason `GIT_HTTP_TOKEN` does: you need it in order to *repair* a site,
so it must not depend on the database being readable.
2. Create the stub `<host>/api/auto_update.php`:
`<?php require __DIR__ . '/../shared/platform-tools/api/auto_update.php';`
3. Gitea → repository → Settings → Webhooks → **Gitea**:
* Target URL: `https://<host>/api/auto_update.php`
* HTTP method `POST`, content type `application/json`
* Secret: the same value as `AUTO_UPDATE_SECRET`
* Trigger: **Push events**
4. Turn it on in the backoffice: *Atualizar plataforma* → **Auto-update** →
*Ligar*. Off by default — pulling code unattended is a decision, not a
default. "Correr agora" on that card runs the identical path by hand, which
is how you test the setup without pushing.
### What it will and will not do
It **fast-forwards only**. `git_safe_update()` tolerates mode-bit and submodule
drift, but real local commits or uncommitted edits stop the run and are reported
untouched — an unattended `reset --hard` is how you lose the hotfix somebody
applied on the server at 2 a.m. Force-syncing stays a deliberate, confirmed
click on the platform page.
It **ignores pushes to other branches**: the webhook fires for every branch, and
the payload's `ref` is compared to the branch this server has checked out.
It **runs one at a time**. A push burst delivers several webhooks for what is
really one state; a per-repo lock file makes the extras no-ops (HTTP 200,
`status: busy`).
Migrations on deploy are a second switch (`auto_update_migrations`, on by
default). Leaving it on is usually right — code and the schema it needs travel
in the same commit, so applying one without the other leaves a window where the
site is broken. Turn it off when a release needs a human to run the SQL.
It **answers before it works**. Gitea allows a delivery 5 seconds by default,
which a git pull alone exceeds, so the endpoint replies `202 accepted` and does
the work with the connection already closed (`fastcgi_finish_request()`, or
`Content-Length` + `Connection: close` on mod_php). The response body therefore
carries no outcome — that is what `auto_update_last_*` and the UI are for.
### Backup before updating
Two more switches, both **off** by default, in the *Backup antes de atualizar*
card: `update_backup_db` and `update_backup_files`. They apply to **both** ways
of updating — the *Atualizar* button and the webhook.
The backup runs before anything moves, and the update **waits for it**. That
wait is the whole point: `backup_trigger()` is fire-and-forget, so pulling
straight after it would race the dump and a migration could apply halfway
through — producing a "restore point" that is a snapshot of neither the old
schema nor the new one. libzip writes the archive under a temp name and renames
it on close, so the final filename appearing is a genuine completion signal.
**A backup that fails or times out aborts the update** (900 s cap, nothing
touched, reported). A toggle that promises a restore point and then quietly
proceeds without one is worse than no toggle at all.
Which to turn on:
| | Worth it? |
|---|---|
| `update_backup_db` | Usually yes. It is what lets you undo a migration that went wrong, and a dump is seconds on these sites. |
| `update_backup_files` | Usually no. Git never touches `uploads/`, so it is rarely what is missing after a deploy — and on a site with galleries it adds minutes and gigabytes to *every* update. |
The card shows the size and date of the last backup of each type, so the cost of
turning one on is visible before you turn it on rather than after the first push.
### Reading the result
Every run — applied, skipped or refused — writes `auto_update_last_*` settings.
The card on *Atualizar plataforma* shows the outcome and the full output; the
one-line strip from `platform_status_strip()` surfaces the same state on a host
dashboard. Response codes are meaningful in Gitea's delivery list: `200` updated
or skipped, `202` disabled, `401` bad signature, `409` update refused, `503` no
secret configured.
## Reverting an update
Every update that actually moves `HEAD` — button or webhook — records where it
came from. The *Ponto de restauro* card then offers one click back to that
commit, which is what makes "update, test, undo if it went wrong" a safe loop
rather than a gamble.
It is a `reset --hard` to the recorded commit, **not** a `git revert`. The
pulled commits still exist on the remote and in this repo's object store, so
moving the branch pointer back loses nothing and reproduces exactly the tree
that was running before. A `revert` would invent new commits that then have to
be pushed, leaving this server diverged from every other one. Afterwards the
platform is simply "behind" again, and *Atualizar* fast-forwards it right back.
Guard rails:
* The target SHA is read from our own settings row and re-validated as a real
commit in this repository. A forged POST cannot aim it anywhere.
* It refuses while there are uncommitted content changes, unless *Descartar
alterações locais* is ticked — `reset --hard` would destroy them, and whoever
is debugging a bad deploy is exactly the person likely to have edited a file
on the server.
* Submodules are realigned to the restored gitlink; OPcache and the settings
caches are dropped.
* The restore point is consumed on use. The next update records a fresh one.
**The database does not come back with the code.** Migrations applied during
the update stay applied, and old code against a new schema can be worse than
new code against it. So the restore point remembers which migrations ran and
which backup was taken immediately before, and both the card and the output say
so — with the backup filename to restore from, when `update_backup_db` was on.
That pairing is the reason to leave the DB backup toggle on.
## Settings and secrets
Configuration resolves through three layers. In precedence order, highest first:
1. the `system_settings` row, if one exists;
2. a `define()`d constant, where the site defines one;
3. `.env`.
`platform_settings_get()` returns its default — which is where `.env` enters —
only when the row is **absent or NULL**. An empty string is neither, so a blank
row is a real value that outranks everything below it. Delete the row to fall
back; do not blank it.
### Which layer a setting belongs in
The rule is: *can the system read this after a cold start with an empty
database, and would I still need it if the app were broken?*
| Answer | Home | Examples |
|---|---|---|
| Yes — bootstrap or break-glass | `.env` only | `DB_*` (needed to reach the DB), `APP_SECRET_KEY` (decrypts the table), `BACKUP_TOKEN`, `GIT_HTTP_*`, `HUB_OPS_SECRET` |
| No — runtime configuration | `system_settings` | `smtp_*`, `email_from*`, feature flags, limits, retention, integration keys |
Runtime configuration belongs in the database because an operator has to be able
to change it without shell access. Bootstrap and recovery credentials belong in
`.env` because a credential you need in order to *repair* a site must not depend
on the site being healthy.
`APP_SECRET_KEY` is `.env`-only for a stronger reason: it is the key that
decrypts `system_settings`, so storing it there would be circular.
### Secrets at rest
Secret-valued settings are encrypted with AES-256-GCM, stored as
`enc:v1:<base64(iv|tag|ciphertext)>`. The key is derived
`hash('sha256', 'am-secret|' . APP_SECRET_KEY)`, falling back to `BACKUP_TOKEN`
only when no `APP_SECRET_KEY` is set.
The point is the backups: the nightly job keeps many copies of the database, so
a plaintext password would sit in every one of them. The key lives only in
`.env` — never in the database, never in git — so a leaked dump on its own is
not enough to recover the credentials.
**Which keys are secret is decided by `platform_settings_is_secret()`, not by
the caller.** Any key ending in `_password`, `_secret`, `_token`, `_api_key` or
`_private_key` qualifies, plus an explicit list for names that do not follow the
convention. This used to be a per-site array — fotografia had
`['smtp_password','twilio_token']` hardcoded in `comms.php`, which is how
`ig_app_secret` came to be stored in the clear. A caller can no longer forget.
`platform_settings_set()` therefore encrypts on its own judgement. Pass `true`
or `false` explicitly only to override it. It also:
- refuses to double-wrap a value already carrying the `enc:v1:` prefix;
- stores `''` as `''`, since callers use empty to mean *cleared*, not *encrypt
the empty string*;
- logs loudly rather than quietly writing a secret in plaintext when no key is
available.
`platform_settings_all()` masks secrets unless asked not to. Passwords render as
`•••••••• (definida)` with no prefix at all — eight leading characters in a
screenshot is a real leak — while API keys keep a recognisable head and tail.
### One implementation, deliberately
Three sites had each grown their own copy of this cipher with three different
key derivations: dance used the domain `'dsa-secret|'`, fotografia
`'am-secret|'`, hub2 a plain `sha256` with no domain separation. All three wrote
the same `enc:v1:` prefix, so the values looked interchangeable while being
mutually unreadable, and nothing surfaced the difference until something tried
to read across sites.
There is now one implementation, here. A site that wants the short names
includes `compat_secrets.php`; it must not define its own.
Note that each site keeps its **own** `APP_SECRET_KEY`. A secret encrypted on
one site deliberately does not decrypt on another — a leaked dump from one must
not unlock the rest.
## Authentication
`functions_auth.php` holds the mechanics; the site keeps the policy. Wire it in
the bootstrap, after the PDO is open:
```php
require_once __DIR__ . '/../shared/platform-tools/functions_auth.php';
auth_schema(['password' => 'password', 'name' => 'full_name',
'active' => null, 'deleted_at' => 'deleted_at']); // surf's columns
auth_roles_register(['teacher' => 40, 'student' => 20, 'parent' => 20]);
auth_session_start(['name' => 'dsa_session']);
```
### The schema map
`auth_schema()` exists so the shared code can run against tables that already
differ — surf stores the hash in `password` and the name in `full_name`, the
others use `password_hash` and `name`; three sites deactivate with `is_active`
and surf with `deleted_at`. Renaming a column with 147 references in the site is
a migration; adapting is a function call. A site whose columns already match the
defaults need not call it at all.
| key | default | meaning |
|---|---|---|
| `table` | `users` | |
| `id` | `id` | |
| `email` | `email` | where password-recovery mail is sent |
| `login` | *null* | column the login form matches on; null means "use `email`" |
| `password` | `password_hash` | |
| `name` | `name` | |
| `role` | `role` | |
| `active` | `is_active` | null when the site has no active flag |
| `deleted_at` | *null* | set when the site soft-deletes instead |
| `last_login` / `last_ip` | `last_login_at` / `last_login_ip` | null to skip the stamp |
`login` and `email` are separate keys because they are separate questions.
quack signs in with `username` and still mails recovery links to `email`;
folding them into one key would either break its login or send its reset links
to a username. Everywhere else `login` stays null and `auth_attempt()` falls
back to `email`, which is the behaviour those sites already had.
### Roles
Three roles are core and mean the same everywhere: `sysadmin` (100), `admin`
(80), `user` (10). A site registers its own on top with a rank, and they sit in
the same ordering:
| site | registers |
|---|---|
| dance | `teacher` 40, `student` 20, `parent` 20 |
| design | `client` 10 |
| quack | `formador` 40, `aluno` 20 |
| silver | `student` 20 |
`auth_at_least('admin')` is then expressible without hardcoding a list, and
`auth_is_any(['teacher','parent'])` covers roles that are peers rather than
ranked. An unregistered role ranks **0** — below everything — so a typo in a
role name cannot grant access.
### What it fixes, and why it is worth knowing
Every item below was a real gap found when the separate implementations were
compared, and each existed because the sites solved the same problem apart:
- **Sessions.** dance ran a bare `session_start()` — no HttpOnly, no SameSite,
no Secure, no `use_strict_mode` — and never called `session_regenerate_id()`,
so a session fixed before login stayed valid after it.
- **Password hashes never improved.** `password_needs_rehash()` appeared twice
in the entire estate. `auth_attempt()` rehashes on login, the only moment the
plaintext exists. dance also pinned `PASSWORD_BCRYPT` while the others used
`PASSWORD_DEFAULT`, so the day DEFAULT moves, three sites migrate and one
silently does not.
- **Throttling.** dance had none. The shared one keys on identifier **and** IP:
on the identifier alone an attacker locks a real user out of their own
account; on IP alone a NAT'd office locks itself out. `X-Forwarded-For` is
honoured only when `TRUSTED_PROXY` says so — every site sits behind NPM, so
`REMOTE_ADDR` is always the proxy, and trusting the header unconditionally
would let a caller forge their throttle identity. That rule now lives in
`functions_client_ip.php` as `platform_client_ip()`; `auth_client_ip()` is an
alias. It moved out because the antispam in fotografia and mstudio needed the
same calculation and could not include this module — both declare their own
`auth_login()`, so the include died on "Cannot redeclare function".
- **Account enumeration.** `auth_attempt()` spends comparable time when the
account does not exist, so response timing does not reveal which addresses are
registered.
- **Open redirect.** `auth_intended_url()` accepts only a local absolute path,
so the login page cannot be used as a phishing hop.
Two tables are created on demand and declared in each site's migrations:
`auth_attempts` and `password_resets`. The throttle tolerates the pre-existing
`auth_attempts` on surf and fotografia, which carry a `success` column and log
successful logins too — those rows are excluded from the count, or signing in
successfully would move you toward being locked out.
### Password recovery
`password_reset_request()` delivers through `platform_mail_send()` with
`platform_smtp_config()`, so recovery works wherever the platform can already
send mail. Only the SHA-256 of the token is stored — a leaked dump cannot reset
anyone's password. Consuming a token burns every outstanding token for that
user, inside a transaction, and regenerates the session.
The public result is deliberately identical for a known address, an unknown one,
and a send failure. A transport error is reported separately in
`delivery_error`, for an operator only: folding it into `ok` would let the form
enumerate accounts whenever SMTP is down.
## Bootstrap contract — what the shared file expects
`platform_update.php` is a **view fragment** that expects the calling stub to have already:
1. Bootstrapped the host's DB so a global `$pdo` PDO is in scope.
2. Run the sysadmin auth guard (so the request only reaches this point if authorized).
3. Set `$repo_root` to the absolute path of the host repo (the directory containing `.git`).
Optionally, the stub may set any of these BEFORE the `require`:
| Variable | Default | Purpose |
|---|---|---|
| `$platform_layout_header` | `$repo_root/includes/header.php` | Top-of-page chrome |
| `$platform_layout_navbar` | `$repo_root/includes/navbar.php` | Nav |
| `$platform_layout_footer` | `$repo_root/includes/footer.php` | Footer |
| `$platform_dashboard_url` | `base_url('views/admin/dashboard.php')` if defined | "Voltar" button target |
| `$platform_error_log_file` | `ERROR_LOG_FILE` constant if defined, else card hidden | JSONL PHP error log path |
| `$GLOBALS['MIGRATIONS_DIR']` | `$repo_root/database` | Migrations folder |
The view also picks up these host helpers if they exist (via `function_exists`):
- `settings_cache_clear()` — invalidated after pull and after applying migrations
- `base_url($path)` — used for dashboard link default
- `env($key)` — used by `functions_git.php` for HTTPS credentials (falls back to `getenv()`)
## Wiring up a platform (standard `config/`-based stack)
This applies to **dsa**, **fotografia**, **moremio**, **tickets** — they share the same convention (`config/db.php` opens `$pdo`, `config/functions.php` loads helpers + `require_sysadmin()`).
**1. Add as a submodule** (run inside the platform repo):
```bash
git submodule add ssh://git@<gitea-host>/<org>/platform-tools.git shared/platform-tools
git commit -m "Add platform-tools submodule"
git push
```
**2. Replace [views/sysadmin/platform_update.php](views/sysadmin/platform_update.php):**
```php
<?php
$repo_root = dirname(__DIR__, 2);
require_once $repo_root . '/config/db.php';
require_once $repo_root . '/config/functions.php';
require_sysadmin();
require $repo_root . '/shared/platform-tools/platform_update.php';
```
**3. Replace [config/functions_git.php](config/functions_git.php)** (create if missing):
```php
<?php
require_once __DIR__ . '/../shared/platform-tools/functions_git.php';
```
**4. Replace [config/functions_migrations.php](config/functions_migrations.php):**
```php
<?php
require_once __DIR__ . '/../shared/platform-tools/functions_migrations.php';
```
These two `config/` stubs keep the existing `config/functions.php` loader working unchanged — it does `require_once 'functions_migrations.php'` and just gets the submodule version transparently.
**5. On every prod host, after pulling:**
```bash
git pull
git submodule update --init --recursive
```
> Since the "Atualizar (git pull)" button in `platform_update.php` now runs
> `git submodule update --init --recursive` itself after the pull, the second
> command is only needed for the **very first** checkout of a host whose
> `platform_update.php` page can't load yet (because the submodule it lives in
> isn't populated). Once the page loads once, the UI "Atualizar" keeps the
> submodule in sync — including pulling in a submodule freshly added upstream.
**6. To bump to a newer shared version** (run on dev, push, then `git pull` on prod):
```bash
git submodule update --remote shared/platform-tools
git add shared/platform-tools
git commit -m "Bump platform-tools"
git push
```
## Wiring up the sysadmin hub (`views/sysadmin/index.php`)
The hub is the single entry point — point the platform navbar at `views/sysadmin/index.php` and every other sysadmin tool is reachable from there. Cards link to the platform's own stubs (`base_url('views/sysadmin/platform_update.php')`, etc.), so the URLs stay stable even if you move the submodule.
**1. Create [views/sysadmin/index.php](views/sysadmin/index.php):**
```php
<?php
$repo_root = dirname(__DIR__, 2);
require_once $repo_root . '/config/db.php';
require_once $repo_root . '/config/functions.php';
require_sysadmin();
require $repo_root . '/shared/platform-tools/index.php';
```
**2. Point the navbar at the hub.** In `includes/navbar.php` (or wherever the sysadmin link lives), change the entry that currently points to `platform_update.php` (and any other individual tools) so a single "Sistema" link goes to `views/sysadmin/index.php`. Example pattern:
```php
<a href="<?= base_url('views/sysadmin/index.php') ?>">Sistema</a>
```
**3. Adding more tools later** — set `$sysadmin_tools` in the stub before the `require`:
```php
$sysadmin_tools = [
[
'title' => 'Mensagens',
'description' => 'Fila de email/SMS, retries, cancelamentos.',
'icon' => 'bi-envelope-paper',
'url' => base_url('views/sysadmin/messages.php'),
'color' => 'info',
'badge' => '3 falhadas', // optional
],
];
require $repo_root . '/shared/platform-tools/index.php';
```
The entry is appended to the default two cards (Atualizar plataforma + Backups).
## Wiring up the backups feature (standard `config/`-based stack)
The backups feature has a wider surface than `platform_update.php` — it needs a view, helpers, two API endpoints, and three worker scripts that run in separate HTTP requests (curl-triggered, token-protected). The submodule contains all of these; the platform just needs 4 thin stubs plus a writable `<host>/backups/` directory.
**Prerequisites:**
- `<host>/.env` defines `BACKUP_TOKEN=<random_string>` (used to authenticate the worker URLs). Optionally `BACKUP_KEEP_DAYS=7` and `BACKUP_FILES_DIR=uploads` (folder the files backup archives, relative to the host root — only uploads hold external data; everything else is reconstructible from git).
- `<host>/backups/` exists and is writable by PHP-FPM. The workers will `mkdir` it if missing but the parent must be writable.
- Add `backups/*.zip` to `.gitignore` (the workers write ZIP files into it).
- `<host>/config/env.php` defines `env_load()` + `env()` (already present on dsa, fotografia, moremio, tickets).
> **Under a `public/` docroot, the two worker wrappers are mandatory.** The
> workers live in `shared/platform-tools/backups/`, which is *not* reachable
> over HTTP once the web root is `public/`. Backups are triggered by the site
> calling itself, so it needs an entry point that exists: set
> `BACKUP_TRIGGER_URL_PREFIX=api/` in `.env` and create
> `public/api/db_backup.php` and `public/api/backup_maker.php` as one-line
> requires of the workers.
>
> Both halves are required, and getting one right is not enough. dance had the
> `.env` line and no wrappers, so the self-call fetched the site's own 404 page
> — which arrives with `curl_errno = 0` and therefore looks like a successful
> request. It produced no backups at all for as long as it ran.
**1. Replace [views/sysadmin/backups.php](views/sysadmin/backups.php):**
```php
<?php
$repo_root = dirname(__DIR__, 2);
require_once $repo_root . '/config/db.php';
require_once $repo_root . '/config/functions.php';
require_once $repo_root . '/config/functions_backup.php';
require_sysadmin();
require $repo_root . '/shared/platform-tools/backups.php';
```
**2. Replace [config/functions_backup.php](config/functions_backup.php)** (create if missing):
```php
<?php
require_once __DIR__ . '/../shared/platform-tools/functions_backup.php';
```
**3. Replace [api/backup_trigger.php](api/backup_trigger.php)** (create if missing):
```php
<?php
require __DIR__ . '/../shared/platform-tools/api/backup_trigger.php';
```
**4. Replace [api/backup_download.php](api/backup_download.php)** (create if missing):
```php
<?php
require __DIR__ . '/../shared/platform-tools/api/backup_download.php';
```
That's it. The workers live entirely in the submodule at `<host>/shared/platform-tools/backups/`. They're reached via HTTP at `base_url('shared/platform-tools/backups/backup_maker.php?token=…')` — the URL prefix is hardcoded in `functions_backup.php` but can be overridden by defining `BACKUP_TRIGGER_URL_PREFIX` before the file loads.
**.htaccess hardening (automatic)**: the ZIPs contain the full host tree including `.env`, so `<host>/backups/` must never be directly web-readable. `backup_dir()` and both workers write a deny-all `<host>/backups/.htaccess` automatically on first use (Apache 2.2 and 2.4 syntax). On nginx, add the equivalent `location ^~ /backups/ { deny all; }` yourself. Backup filenames also carry seconds + a random suffix (`backup_DD_MM_YYYY_HH_MM_SS_xxxxxx.zip`), so they can't be guessed even if the deny rule is missing; ZIPs are served exclusively through `api/backup_download.php`, which sets an attachment header. Files backups exclude `.git/`, `node_modules/`, `backups/` and `*.zip`.
## Wiring up the remote-ops agent (hub fleet control plane)
Lets the hub read this platform's status and (later phases) pull git, apply
migrations and take backups **remotely**, from one dashboard. Server-to-server,
HMAC-authenticated with a **dedicated** secret (never the content/notify ones).
> **Naming.** The control plane used to be `id.andmore.pt`. That service is gone
> — its SSO and notification relay were removed from every site — and **hub2 is
> the plane that connects to the apps now**. The endpoint, the HMAC scheme and
> `HUB_OPS_SECRET` are unchanged; only the operator moved. Older comments in
> this file and in the sites may still say "id".
**1. Submodule:** same as above (already present if backups/platform_update are wired).
**2. Create [api/agent.php](api/agent.php)** (1-line stub):
```php
<?php
require __DIR__ . '/../shared/platform-tools/api/agent.php';
```
**3. Add the secret to `<host>/.env`:**
```
HUB_OPS_SECRET=
```
Generate the value on `id.andmore.pt` → **Plataformas → <app> → "Gerar segredo de
operações"**, paste it here, then flip **"Ativar operações remotas"** on the same
screen. Empty secret → the agent answers `503` (disabled).
The agent derives nothing from config beyond `$repo_root = dirname(__DIR__, 3)`;
it reuses the same `functions_git/migrations/schema/backup` already in the
submodule, so a host wired for `platform_update.php` + backups needs only the
stub + the `.env` line. The hub reaches it at `<base_url>/api/agent.php` (URL
convention; no DB column).
## Feedback bubble (fleet → hub tickets)
The people who run these platforms every day are the first to notice what's
broken, and until now they had nowhere to say it without leaving their work,
opening another domain and signing in again. Most of that never got reported;
what did arrive came as a loose message with no page, no version and no account
attached.
The bubble sits bottom-right on every backoffice page. An admin writes a line
and a description; the platform attaches the page URL, the version and commit,
the account and the browser, and posts it to the hub's ticket desk through
`hub_ticket_create()`. It reaches the hub already tagged with the right app,
because the key that authenticates the call *is* the app's identity.
**Prerequisite:** the platform must be paired with the hub (`HUB_URL` +
`hub_api_key`, from *Integração com o hub*). Unpaired, the bubble renders for
sysadmins only, saying exactly that — a report channel that vanishes silently
when a key rotates loses precisely the reports that mattered.
**1. Endpoint stub** — `<host>/public/api/feedback.php` (or `<host>/api/` where
the docroot is the repo root, as on quack). One line, like every other endpoint
in `api/`:
```php
<?php
require __DIR__ . '/../../shared/platform-tools/api/feedback.php';
```
The endpoint is cookie-authenticated rather than key-authenticated, so it needs
the host's session — `hub_bootstrap.php` opens it, and knows both fleet stacks
(`config/db.php`, or `src/config.php` + `App\Database`).
**2. The bubble** — one include at the end of the backoffice footer
(`includes/footer.php`), just before `</body>`. It guards itself: not an admin,
no HTML.
```php
<?php include __DIR__ . '/../shared/platform-tools/feedback_widget.php'; ?>
```
**3. The public site** — a footer link instead of a floating bubble, because
the bottom-right corner there is already taken by the WhatsApp button. Include
it **inside** the `<footer>`, on the line where the link belongs:
```php
<?php include dirname(__DIR__, 2) . '/shared/platform-tools/feedback_footer_link.php'; ?>
```
Same widget, same panel, same endpoint — only the trigger changes, and the
link inherits the footer's colour and font so it fits a dark footer and a light
one without either being styled for. The choice lives in the module, not in the
host: both entry points are one bare include, and the day the decision changes
it changes for the whole fleet at once.
Two footers in one response render one widget, not two (`$GLOBALS` one-shot
flag). If the sysadmin hub page doesn't live at
`/views/sysadmin/hub_integration.php` on this platform, set
`$GLOBALS['platform_feedback_hub_url']` before the include — that's the only
link the widget emits.
**Checks after wiring:** open any backoffice page as an admin → bubble bottom
right; send one → green line with the ticket ref and a tracking link, and the
ticket in the hub carries the context block. Open the same page signed out, or
as a non-admin role → `view-source` has no `ptfb` markup at all. `GET
/api/feedback.php` answers `{"ok":false,"error":"Usa POST."}` once deployed —
a 404 there means the push never reached the server, and a 500 means the
submodule didn't come with it.
**Session shapes.** Nothing needs configuring per site: the module reads the
account from `current_user()`, from `$_SESSION['user']`, or from loose
`user_id`/`username`/`role` keys, in that order. Only the third needs a
database round-trip (for the e-mail, which the ticket desk requires and that
shape doesn't carry) and it is cached in the session.
## Wiring up surf (different stack — `src/` + `Database::getInstance()`)
Surf uses `src/` instead of `config/`, has no `require_sysadmin()`, no `setting()`, no settings cache. The shared page handles this gracefully (all those integrations are guarded with `function_exists()`), but the stub needs to adapt.
**1. Submodule:** same as above (`git submodule add … shared/platform-tools`).
**2. Replace [views/sysadmin/platform_update.php](views/sysadmin/platform_update.php):**
```php
<?php
require_once __DIR__ . '/../../src/config.php'; // session + .env + autoload
require_once __DIR__ . '/../../src/db.php';
// Auth guard — surf checks $_SESSION['user']['role']
if (($_SESSION['user']['role'] ?? '') !== 'sysadmin') {
header('Location: /views/auth/login.php');
exit;
}
$pdo = App\Database::getConnection();
$repo_root = dirname(__DIR__, 2);
$platform_layout_header = $repo_root . '/views/partials/head.php';
$platform_layout_navbar = $repo_root . '/views/partials/navbar.php';
$platform_layout_footer = $repo_root . '/views/partials/footer.php';
$platform_dashboard_url = '/views/admin/dashboard.php';
require $repo_root . '/shared/platform-tools/platform_update.php';
```
That's it. Surf has no `migration_*.sql` files (its schema is `database/install/ssc_school.sql`), so the migrations card will show empty. If surf later introduces migrations under `database/`, the card lights up automatically. To point at a non-default migrations directory, set `$GLOBALS['MIGRATIONS_DIR']` in the stub before the `require`.
The surf admin dashboard already links to `platform_update.php` from `views/admin/sysadmin.php` and `views/partials/navbar.php`, so no nav changes are needed.
## Country gating
Some platforms only ever serve one country. dance is the clear case: an academy
in Aveiro takes students who can walk into the building, so a registration from
abroad is spam by definition rather than a lost sale. In August 2026 fourteen bot
accounts were created there over five days, each one firing a confirmation email
at a third party's mailbox — the content filters never had a chance, because the
bots write plausible Portuguese names and use real addresses.
Two levers, because they carry very different risk:
| `GEO_MODE` | Effect |
|---|---|
| `off` | Module inert. The default when the variable is unset. |
| `forms` | Public POSTs are refused from outside the allow-list. Reading the site stays open, so search engines, link previews and an emigrant checking the timetable are unaffected. **Start here.** |
| `site` | The whole platform answers 403. Also hides the site from any crawler hosted abroad, which is rarely what anyone wants. |
```ini
GEO_MODE=forms
GEO_ALLOW_COUNTRIES=PT # ISO 3166-1 alpha-2, comma or space separated
GEO_FAIL_CLOSED=false # see below
```
Settings resolve **database first, then `.env`, then the default**, so the
sysadmin page can change behaviour without recreating the container — the `.env`
stays as the boot value, and a platform that never opens the page behaves exactly
as before. An empty stored row counts as "unset" and falls through to `.env`;
that is deliberate, because `platform_settings_get()` lets an empty row silently
outrank a populated `.env`, and that has cost this estate real debugging time.
Wire the page up with a stub at `views/sysadmin/geo.php`:
```php
<?php
$repo_root = dirname(__DIR__, 3);
require_once $repo_root . '/config/db.php';
require_once $repo_root . '/config/functions.php';
require_sysadmin();
require $repo_root . '/shared/platform-tools/geo.php';
```
The page refuses to save `site` mode together with fail-closed while no lists are
downloaded — that combination locks everyone out, including whoever is at the
keyboard, and cannot be undone from a browser.
Wire a form up the same way you wire `antispam_check()` — it returns the reason,
or null to carry on, and the caller decides what to do with it:
```php
if (($geoReason = geo_form_block_reason()) !== null) {
db_log_blocked_submission($pdo, 'register', $geoReason, 0, platform_client_ip() ?? '');
$error = 'Não foi possível criar a conta a partir desta ligação.';
}
```
For `GEO_MODE=site`, call `geo_guard_site()` once from the bootstrap. It is a
no-op under the other modes, so it is safe to leave wired in permanently and
switch behaviour from the env alone.
**Failure posture is fail-open, deliberately.** A missing or corrupt data file
logs loudly and lets the visitor through. Inverting that turns a stale file into
a total outage, while the worst case of failing open is the spam that already
gets through today. `GEO_FAIL_CLOSED=true` inverts it for a platform that would
rather refuse.
Private, loopback and link-local addresses always pass — behind NPM the
container talks to itself for cron and health checks, and a platform whose own
cron is geo-blocked fails in ways that take a long time to understand.
### The data
Per-country CIDR lists from ipdeny.com, one file per family, committed to the
repo. Refresh them with:
```bash
shared/platform-tools/bin/geo-update.sh pt
```
Allocations move slowly, so monthly is plenty:
```
17 4 3 * * /srv/sites/<site>/shared/platform-tools/bin/geo-update.sh pt
```
The script refuses to overwrite a good file with a short list, a non-CIDR body,
or an HTML error page served with 200 — any of which would otherwise mean "no
visitor is in Portugal", and with `GEO_FAIL_CLOSED` would lock the country out
of its own site.
Lookup packs the address with `inet_pton()` and binary-searches sorted ranges,
comparing the raw bytes. The same code path serves IPv4 and IPv6, and it steps
around the signed-32-bit trap `ip2long()` sets for anything above
`127.255.255.255`.
## Visit tracking
Answers "where did this person come from?" on three axes: **URL** (`?source=montra`,
`utm_*`, or the referring domain), **device** (mobile/tablet/desktop, OS, browser,
language) and **geography** (country, via the geo index). It grew on fotografia
and moved here so the other platforms could use it.
Counting visits per source is the easy half and the least useful. What tells you
whether a QR code is worth reprinting is how many *requests* it produced, so the
first-touch source is remembered in a cookie for the attribution window and
stamped on whatever the visitor eventually submits.
```php
platform_track_visit($pdo); // once, on a public page
platform_track_conversion($pdo, 'contacto'); // after a submission is accepted
```
Conversions go to one `tracking_conversions` table keyed by form name, rather
than a `source` column on each destination table. A site with contact, trial and
registration forms would otherwise need three migrations and three places where
attribution can be lost; this way it is one line per handler and the report is
the same across the estate. Both tables are created on demand — no migration
needed to adopt it.
Call `platform_track_conversion()` **after** the submission is accepted, never
before. A request the antispam or the country gate refused is not a conversion,
and counting it would make a campaign look better for attracting bots.
Wire the page up with a stub at `views/sysadmin/tracking.php`, same shape as the
other shared pages.
### Country attribution
`geo_ip_country()` reads `data/geo/country-index.v4|.v6`, built by
`bin/geo-build-index.php` from ipdeny's all-zones archives — 240 countries,
~263k ranges, about 5 MB compiled.
That index is **not** versioned, unlike the small allow-lists the country gate
uses. A binary that size refreshed monthly would bloat the history for nothing,
and a missing analytics country is a fine degradation — the gate's data is a
security decision, this is a statistic. Rebuild it from cron:
```
41 5 3 * * docker exec <site> php /var/www/html/shared/platform-tools/bin/geo-build-index.php
```
Two of ipdeny's zones are not countries and are excluded: `zz.zone` is a
catch-all of whole `/8` blocks that overlap real countries — leaving it in made
`8.8.8.8` resolve as "ZZ" instead of US — and `eu.zone` is a continent
aggregate. Without them, 2 overlapping ranges remain out of 262k, and those are
dropped at build time: binary search assumes disjoint ranges, and with overlaps
the answer depends on where the bisection happened to land.
No IP address is ever stored. The `ip_hash` exists only to approximate unique
visitors and is salted with a server-side secret. It is computed from
`platform_client_ip()` — the original used `REMOTE_ADDR`, which behind NPM is
always the proxy, so every visitor shared one hash and the report's "visitors"
column was permanently 1.
## IP banning
The estate had three per-IP defences and none of them blocked: the login
throttle forgets in fifteen minutes, the antispam counter resets on the hour,
and the country gate judges origin rather than behaviour. An address that comes
back every night at 3am looked like a new visitor to all three.
```php
platform_ip_guard($pdo, 'info@example.pt'); // once, early in the bootstrap
platform_ip_record_offence($pdo, 'antispam:contact'); // from each existing defence
```
Offences accumulate; the ladder decides the sentence:
| Offences in 24h | Ban |
|---|---|
| 3 | 1 hour |
| 5 | 24 hours |
| 8 | 7 days |
| 12 | permanent |
The climb is slow on purpose. An IP is often shared — a school, an office, a
mobile carrier's CGNAT — and banning permanently on the third strike catches far
more people than bots.
Two failure postures, opposite and deliberate. `platform_ip_banned()` fails
**open**: with no database nobody gets locked out, because an infrastructure
fault must not become an inaccessible site. `platform_mail_rate_blocked()` fails
**closed**: the choice there is between not sending one message and burning the
domain's sending reputation, and only the first recovers by itself.
Private, loopback and link-local addresses are exempt from both offences and
bans — the container talks to itself for cron and health checks, and a platform
that bans itself fails in ways that take a long time to understand.
Lifting a ban does **not** clear the offence history. If it did, the address
would drop back to rung zero and the ladder would restart, which would make every
administrator's mistake an invitation to try again.
## Backup encryption
A database dump sitting in the clear is the most sensitive file one of these
platforms produces: the `users` table with its hashes, every contact, and on
dance the signed contracts — which carry minors' data.
Dumps are now AES-256 encrypted inside the ZIP. The key is derived from
`APP_SECRET_KEY`, domain-separated (`am-backup|`) so the same secret is never
used for two purposes, and it only ever exists in `.env` — never in a dump.
**That has a consequence worth saying out loud: losing `APP_SECRET_KEY` means
losing every backup encrypted with it.** Keep it wherever the server credentials
are kept, not only in `.env`.
Without a key the dump is written in the clear and says so — on the page and in
the log. Refusing to back up for want of a key would be worse than an
unprotected backup: the alternative to a readable file is not a safe file, it is
no file.
Restore handles both: `restore_open_zip()` applies the key when there is one,
and `setPassword()` on an unencrypted archive is simply ignored — so artefacts
made before this change still restore without special handling.
## Tested assumptions
- The `applied_migrations` table uses `filename` as PK (VARCHAR 150). Platforms that already have a different schema (older DSA-style with `id` PK + `filename` UNIQUE) are compatible — `CREATE TABLE IF NOT EXISTS` is a no-op for the existing table, and the queries (`SELECT/INSERT … WHERE filename=?`) work against both shapes.
- All POST actions are idempotent or guarded with confirms, and every one requires the shared CSRF token (`functions_csrf.php`) on top of the host's sysadmin auth guard.
- `git_run` uses `proc_open` array form (no shell), so format strings with `%cd`, `%h`, `|` etc. pass through cleanly.
## Verification after wiring (per platform)
```bash
# 1. Syntax — platform_update stubs
php -l views/sysadmin/platform_update.php config/functions_git.php config/functions_migrations.php
# 2. Syntax — backups stubs (if wired)
php -l views/sysadmin/backups.php config/functions_backup.php api/backup_trigger.php api/backup_download.php
# 3. Submodule populated
ls shared/platform-tools/ # expect: platform_update.php, functions_git.php, functions_migrations.php, functions_schema.php, backups.php, functions_backup.php, api/, backups/, README.md
# 4. End-to-end platform_update (in browser, as sysadmin)
# - Open /views/sysadmin/platform_update.php
# - Confirm git state panel shows branch + SHA + commit history
# - Click "Verificar atualizações (fetch)" — see fetch output
# - Click "Aplicar" on an idempotent test migration — confirm applied_migrations row appears
# - Click "Exportar esquema" — success flash with table count; <host>/database/schema.sql created/updated
# - Re-click "Exportar esquema" without schema changes — `git diff database/schema.sql` shows NO change (determinism check)
# 5. End-to-end backups (in browser, as sysadmin)
# - Open /views/sysadmin/backups.php
# - Click "Backup de uploads" — page reloads with success flash and polls until <host>/backups/backup_DD_MM_YYYY_HH_MM_SS_xxxxxx.zip appears (ZIP contains only uploads/)
# - Click "Backup da base de dados" — same; db_backup_*.zip appears
# - Click the download icon on a row — browser downloads the ZIP
# - On the upstream platform-tools repo: edit a comment, push
# - On the platform: `git submodule update --remote && git -C shared/platform-tools log -1`
# → confirm new commit + visible on the page
```