Files
fitme3070.pt/shared/platform-tools/README.md
T
andreandClaude Opus 5.5 0e8b53f620 feat(audit): platform-tools atualizado (12f26fd) e registo de auditoria ligado
- shared/platform-tools: ea3616a → 12f26fd (27 commits). Traz o módulo de
  audit (f761d99, o pin da frota), «manter sessão iniciada», o registo de
  segurança da frota (o gancho que o login já tinha passa a funcionar) e a
  correção do IP do cliente encontrada nesta auditoria (último X-Forwarded-For
  / X-Real-IP), promovida ao partilhado. client_ip() da app delega nele.
- config/functions_audit.php: audit() sobre platform_audit(); tabela
  audit_log criada no primeiro uso, segredos ocultados, nunca parte a ação.
- Registado: login, falha de login, bloqueio por excesso de tentativas,
  logout, pedidos de recuperação, mudança/definição de palavra-passe,
  registo e criação de utilizadores, convites, perfil (antes/depois), todas
  as definições (db_update_setting, com valor anterior), cada passo da
  lixeira (apagar, restaurar, eliminar de vez) e ações nas mensagens.
- Sistema → Auditoria (views/sysadmin/audit.php), só sysadmin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:28:13 +01:00

1449 lines
85 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. Lists every routed tool by default; a site adds its own cards with the `sysadmin_tools` connector option. Served by the router at `platform.php?tool=index`. |
| `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). Served by the router at `platform.php?tool=platform_update`. |
| `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. Served by the router at `platform.php?tool=backups`. |
| `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)`, `platform_derived_key($purpose, ?PDO)`. The single implementation of secret encryption for the whole estate; see [Settings and secrets](#settings-and-secrets). |
| `functions_captcha.php` | A written challenge for any public form — `platform_captcha_field()`, `platform_captcha_verify()`, plus the appeal flow (`platform_captcha_appeal_offer/claim()`) that gives a refused submission a second chance instead of a silent drop. Self-hosted, stateless (HMAC-signed token), no JavaScript, no images, no third-party service. See [Captcha](#captcha) and [The appeal](#the-appeal--a-second-chance-after-a-refusal). |
| `router.php` + `functions_routes.php` | Front controller for the shared sysadmin pages: one site stub `public/platform.php`, every page at `platform.php?tool=<name>`, from a closed route table. See [Front controller](#front-controller). |
| `api/router.php` | The same for the shared endpoints: one site stub `public/api/platform.php`, every endpoint at `api/platform.php?endpoint=<name>` (`platform_api_routes()`). Only dispatches — each endpoint keeps its own auth. |
| `functions_connector.php` | `platform_connect($pdo, [...])` — one bootstrap call that wires shared auth, "manter sessão", the IP guard and security events, and records what the other tools need (PDO, base URL, env, guard, layout). `platform_conformance()` checks the database against what the shared code expects; reported as `checks.conformance` in the health endpoint, fatal in dev. See [Connecting a site](#connecting-a-site). |
| `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_remember.php` | "Manter sessão iniciada" — `auth_remember_issue()`, `auth_remember_resume()`, `auth_remember_clear()`, `auth_remember_forget_user()`, `auth_remember_active()`. Login persistente em `auth_remember_tokens`, selector/validator com o validator guardado em SHA-256 e rodado a cada utilização — um cookie roubado vale até a próxima visita do dono, e a discrepância que isso gera revoga a conta inteira. Extensão de `functions_auth.php`, que carrega por si. `auth_remember_resume($pdo, $opts)` aceita dois ganchos opcionais — `'allow'` (regra extra do site sobre quem pode entrar) e `'login'` (quem abre a sessão, quando o site guarda mais do que o `auth_login()`) — e o nome do cookie pode vir de uma constante `AUTH_REMEMBER_COOKIE`, que ganha ao `.env`. |
| `functions_login_ui.php` | The two pieces every fleet login form has: `platform_password_field()` (the password with the eye to show it — inline SVG, no icon font, one script per page, hidden again on submit) and `platform_remember_field()` («Manter sessão iniciada», with the real number of days). Each site keeps its own login page (brand, layout); these two stop varying. |
| `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()`, plus the two escape valves: `geo_ip_allowlisted()` (addresses that always pass) and `geo_relay_serves()` (iCloud Private Relay, scored by the country each egress actually serves). 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 — a one-line statement of what the gate is doing right now, mode, countries as grouped checkboxes (with a JS filter and a "no lists" badge on any country whose data is missing), the Private Relay toggle, the always-pass address list, fail-closed, data-file status, a "refresh lists" button and an IP probe. Served by the router at `platform.php?tool=geo`. |
| `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. |
| `bin/geo-relay-update.sh` | Refreshes `data/geo/relay-<cc>.v4|.v6` from Apple's published iCloud Private Relay egress ranges, **filtered to the countries you ask for** — the full list is 42k IPv4 and 246k IPv6 ranges. Same validate-before-overwrite posture. |
| `data/countries-pt.php` | The 72 countries the geo page offers as checkboxes, grouped and named in pt-PT. Deliberately not all 249: a page with 249 boxes is as unusable as a text field, and the page keeps a free-text escape hatch for the rest. |
| `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. `relay-<cc>.*` are the same thing for Private Relay egresses, from Apple. |
| `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` / `_WEEKS` / `_MONTHS`, `BACKUP_FILES_DIR`, DB constants, `backup_prune_report()`. |
| `bin/backup-prune.php` | CLI — applies the grandfather-father-son retention without taking a backup. `--dry-run` lists what would go. See [Retention](#retention). |
| `functions_audit.php` + `audit.php` | Audit log — `platform_audit()`, `platform_audit_diff()`, `platform_audit_query()`; writes into each site's existing `audit_log` through a column map, and the routed page `platform.php?tool=audit`. See [Audit log](#audit-log). |
| `functions_security_events.php` | Fleet security log — `platform_security_event()`, `hub_security_sync()`, `hub_security_apply_fleet()`, `hub_security_check()`. Local outbox, batched POST to the hub's `api/security_events.php`, and the fleet's IP bans applied locally as `source='fleet'`. See [Fleet bans and the security log](#fleet-bans-and-the-security-log). |
| `bin/hub-security-sync.php` | CLI — empties the outbox and pulls the fleet bans. Run from cron every 5 minutes. |
| `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 **hub's fleet control plane** (formerly id.andmore.pt's). 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. Served by the router at `platform.php?tool=hub_integration`. |
| `api/hub_ops.php` | The hub's remote actions on this platform, behind the **operations** key (not the health key), POST JSON `{"action": …}`. `backup.list` / `backup.trigger`; `update.run` (the full deploy path — pre-update backup, safe pull, migrations, caches — answering 202 before it works) / `update.status`; `git.status` / `git.pull` (code only); `migrations.list` / `migrations.apply_pending`; `ip_bans.list` / `ip_bans.ban` / `ip_bans.unban` (a ban made here gets `source='hub'` and reaches the fleet like any other); `tracking.summary` (aggregates only). Advertised by `hub_ops_actions()` in the health check, so the hub only shows what the platform can do. Per-platform stub is a 1-line require. |
| `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.
## Connecting a site
Every site's bootstrap is `config/db.php`: it loads `.env`, opens a global
`$pdo` and then makes one call.
```php
require_once dirname(__DIR__) . '/shared/platform-tools/functions_connector.php';
platform_connect($pdo, [
'base_url' => APP_BASE_URL,
'schema' => ['name' => 'full_name'], // only what differs from the standard
'roles' => ['staff' => 40, 'client' => 20],
'remember' => true, // "manter sessão iniciada"
'contact_email' => 'geral@example.pt', // shown on the 403 of a banned IP
'guard' => 'require_sysadmin', // for the shared sysadmin pages
]);
```
That wires the hardened session, the shared auth with the site's column map,
the IP guard (and with it the fleet bans and the security sync), and records the
PDO, base URL and environment so no tool has to guess them again — `hub_pdo()`
and `hub_base_url()` read the declared values first. A site with its own login
code passes `'auth' => 'own'`; one with no database passes `null` as the PDO.
**Conformance.** `platform_conformance($pdo)` reads `information_schema` once
and compares the tables the shared code writes — `users` (through the site's
`auth_schema()` map), `auth_attempts`, `auth_remember_tokens`, `ip_bans`,
`ip_offences`, `system_settings` — against what it expects. It looks only for
**silent** breakage: a missing column the shared code queries, or an old
`NOT NULL` column without a default that makes its `INSERT` fail. Both end the
same way — the defence fails open and nothing says so. Every issue carries the
fix (MariaDB `ADD COLUMN IF NOT EXISTS`, or the `auth_schema()` mapping).
- **Development** (`APP_ENV` in dev/development/local/staging/test):
`platform_connect()` throws `PlatformConformanceError` with the list — except
under `/api/`, `/sysadmin/`, `/setup.php` and `/platform.php`, where the migration runner that
fixes it lives, and in CLI.
- **Production**: never throws. The health endpoint reports
`checks.conformance`, and the hub card turns yellow.
- **No `APP_ENV`** is treated as production — the side where nothing breaks on
purpose — and reported as a warning.
- Tables that don't exist yet are fine: the shared code creates them on first
use, already in the right shape. Mapped differences (`full_name`, `username`,
surf's `password`) are reported as notes, so sites can converge one at a time.
## Front controller
One stub per site instead of one per page:
```php
<?php require __DIR__ . '/../shared/platform-tools/router.php'; // public/platform.php
```
and `'router' => true` in the site's `platform_connect()`. Every shared page
(`index`, `platform_update`, `backups`, `restore`, `ip_bans`, `geo`, `tracking`,
`hub_integration`, `smtp_status`) then opens at `platform.php?tool=<name>`, and
`platform_admin_url()` returns those URLs, so the shared pages' own links and
redirects follow without changes. A tool added to `functions_routes.php`
reaches every router site on the next submodule update — including its card on
the index, which lists every routed tool by default.
What the per-page stubs used to set by hand is declared once in
`platform_connect()`:
| Option | For |
|---|---|
| `guard` | who may enter (required: without it the router refuses — fails closed) |
| `guards` | per-tool guard, e.g. `['tracking' => 'require_admin']` |
| `replaced_tools` | a site page instead of a shared one, e.g. `['smtp_status' => 'views/sysadmin/comms.php']` |
| `layout` | `['header' => …, 'navbar' => …, 'footer' => …]` |
| `dashboard_url`, `migrations_dir` | the pages' "Voltar" (a string, or per tool: `['index' => 'views/admin/users.php']`, the rest going back to the index), and a non-default migrations folder |
| `sysadmin_tools`, `hide_tools` | the index's own cards (array or callable), and default cards to hide |
| `page_vars` | extra `platform_*` variables a page reads |
Query string rather than `/platform/backups`: pretty URLs need rewrite rules,
and the fleet runs Docker Apache, cPanel and possibly nginx. The old
`views/sysadmin/<page>.php` stubs become one-line 302 redirects (not 301, so a
rollback isn't stuck in browser caches).
**The API has its own router.** `public/api/platform.php` is a one-line stub
too:
```php
<?php require __DIR__ . '/../../shared/platform-tools/api/router.php'; // public/api/platform.php
```
and every shared endpoint answers at `api/platform.php?endpoint=<name>`
(`health`, `hub_ops`, `hub_pair`, `feedback`, `agent`, `auto_update`,
`backup_trigger`, `backup_download` — `platform_api_routes()`). The router only
dispatches: each endpoint still checks its own key, HMAC or session + CSRF. A
site with its own version of an endpoint declares it with
`'api_files' => ['backup_trigger' => <absolute path>]`. **A new endpoint goes
into `platform_api_routes()` and reaches every site with no stub.** The old
`api/<name>.php` stubs stay: Gitea webhooks and the per-platform paths stored in
the hub call them.
**Clean URLs.** With `'clean_urls' => true` the router links come out without
`.php` (`platform?tool=backups`, `api/platform?endpoint=health`). The site's
`.htaccess` needs the rule that serves an extensionless request from the
matching `.php` — without it those URLs 404:
```apache
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME}\.php -f
RewriteRule ^(.*)$ $1.php [L]
```
The health endpoint announces `tools` (tool → URL) and `api` (endpoint → URL)
for router sites, so the hub can link to the local page.
## Wiring up a new platform
Every site in the fleet is wired the same way since 2026-09. For a new one:
**1. The submodule** (inside the platform repo):
```bash
git submodule add ../platform-tools.git shared/platform-tools
git config -f .gitmodules submodule.shared/platform-tools.branch main
```
Clones then need `--recurse-submodules` (or `git submodule update --init`). The
"Atualizar" button keeps the submodule in step after every pull, including one
freshly added upstream, so this is only by hand on the very first checkout.
**2. The bootstrap.** `config/db.php` loads `.env`, opens a global `$pdo` and
calls `platform_connect()` — see [Connecting a site](#connecting-a-site) for
the auth options. For the sysadmin pages add:
```php
'router' => true,
'clean_urls' => true, // only with the .htaccess rule in step 4
'guard' => 'require_sysadmin',
```
plus whatever the site needs from the [front controller](#front-controller)
table (layout, "Voltar", migrations folder, its own cards, replaced tools).
`config/functions.php` provides `env()` and `base_url()`.
**3. Two one-line front controllers**, under the docroot:
```php
<?php require __DIR__ . '/../shared/platform-tools/router.php'; // public/platform.php
<?php require __DIR__ . '/../../shared/platform-tools/api/router.php'; // public/api/platform.php
```
That is every shared page (`/platform?tool=<name>`) and every shared endpoint
(`/api/platform?endpoint=<name>`). Point the navbar's "Sistema" link at
`platform_admin_url()`.
**4. Clean URLs** (optional — with it, step 2's `clean_urls`): the rule in the
site's `.htaccess` that serves `/x` from `x.php`, shown under
[Front controller](#front-controller).
**5. The URLs other systems call.** These stay one-line stubs under
`public/api/`, because something outside the site holds their path:
| Stub | Called by |
|---|---|
| `health.php` (→ `api/hub_health.php`), `hub_ops.php`, `hub_pair.php` | the hub — the paths go out with the pairing announce (`health_path`, `ops_path`, `pair_path` in `functions_hub.php`) and are stored per platform |
| `auto_update.php` | the Gitea push webhook |
| `agent.php` | the hub's HMAC agent |
| `backup_trigger.php`, `backup_download.php` | the shared backups page's own forms and links |
| `db_backup.php`, `backup_maker.php` | the site itself (the backup self-call; see below) |
| `feedback.php` | the feedback bubble |
A stub is `<?php require __DIR__ . '/../../shared/platform-tools/api/<file>.php';`.
Any of them can be pointed at the router instead — a webhook URL of
`…/api/platform?endpoint=auto_update` works the same. The stubs exist because
those URLs were configured before the router did.
**6. `.env`**: `APP_ENV`, `APP_BASE_URL`, `DB_*`, `APP_SECRET_KEY`,
`BACKUP_TOKEN` (+ `BACKUP_TRIGGER_URL_PREFIX=api/` under a `public/` docroot),
`AUTO_UPDATE_SECRET` for deploy-on-push, `HUB_URL` for the hub. Everything else
has a default or lives in `system_settings`.
**7. Pair with the hub** from *Sistema → Integração com o hub*, and add the
cron lines: `bin/hub-security-sync.php` every 5 minutes, the backup cron nightly.
**Bumping the shared version** (dev, push, then "Atualizar" on each host):
```bash
git submodule update --remote shared/platform-tools
git add shared/platform-tools && git commit -m "Bump platform-tools" && git push
```
quack sets `ignore = all` on the submodule and follows the branch tip instead;
`git_sync_submodules()` updates it with `--remote`.
### Per-page stubs (a site without the router)
Before the router every page had its own stub, and a site that does not declare
`'router' => true` still works that way. The shared page is a **view fragment**
that expects the 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`).
```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/platform_update.php';
```
Optionally, the stub may set any of these BEFORE the `require` — the router
sets them from the `platform_connect()` options instead:
| 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 |
| `$sysadmin_tools` (index only) | none | The site's own cards, appended to the default ones |
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()`)
As of 2026-09 every fleet site runs the router; their old
`views/sysadmin/<page>.php` files are 302 redirects to it.
## Wiring up the backups feature
The pages come with the router (`?tool=backups`, `?tool=restore`); what a site
adds is the storage, the endpoints and the self-call. The workers run in
separate HTTP requests (curl-triggered, token-protected).
**Prerequisites:**
- `<host>/.env` defines `BACKUP_TOKEN=<random_string>` (used to authenticate the worker URLs). Optionally `BACKUP_KEEP_DAYS=7`, `BACKUP_KEEP_WEEKS=4`, `BACKUP_KEEP_MONTHS=1` (see [Retention](#retention)) and `BACKUP_FILES_DIR=uploads` (the folder the files backup archives, relative to the host root — `public/uploads` under a `public/` docroot; 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()`.
- `config/functions_backup.php` requires the shared `functions_backup.php`. It is also where a site defines `BACKUP_TRIGGER_URL_PREFIX` when the `.env` doesn't, which is why the router and the hub-triggered backup load it first (`hub_load_backup_helpers()`).
- The endpoint stubs `api/backup_trigger.php` and `api/backup_download.php` (step 5 above).
> **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.
Without `BACKUP_TRIGGER_URL_PREFIX` the workers are reached at
`base_url('shared/platform-tools/backups/<worker>.php?token=…')`, which only
works where the repo root is the docroot.
**.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`.
### Retention
Grandfather-father-son, per type (DB and files are counted separately), applied
by `backup_prune()` at the end of every **successful** worker run — a failed
backup never prunes, because that is exactly when the old copies are the only
ones left. Kept:
- everything from the last 24 hours (the restore points of an update);
- the newest of each of the last `BACKUP_KEEP_DAYS` days that have a backup (7);
- the newest of each of the `BACKUP_KEEP_WEEKS` weeks before the oldest daily (4);
- the newest of each of the `BACKUP_KEEP_MONTHS` months before the oldest weekly (1);
- the newest backup of each type, always.
With the defaults that is 12 copies per type spanning about two months. Each
tier starts where the previous one ends — otherwise "1 monthly" is simply this
month's newest copy, which the daily tier already holds, and nothing older than
three weeks survives. Days are counted as *days with a backup*, not calendar
days, so a site whose backups stopped keeps its last ones instead of pruning to
nothing.
The date comes from the filename, not the mtime, so a copy moved between disks
keeps its age. `bin/backup-prune.php --dry-run` shows what a change to the
`BACKUP_KEEP_*` values would delete before you make it.
This keeps *more* history than the previous rule (delete everything older than
`BACKUP_KEEP_DAYS`) but far fewer files per week: several same-day copies —
nightly, pre-update, hub-triggered — collapse to one once they leave the 24-hour
window.
## 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:** see [Wiring up a new platform](#wiring-up-a-new-platform).
**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 the hub (the platform's page → "Gerar segredo de
operações"), paste it here, then turn remote operations on in the same place. 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 through the site's
`config/db.php`.
**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). The widget's one link — to *Integração com o hub* — is the router URL
on a router site, else `/views/sysadmin/hub_integration.php`; set
`$GLOBALS['platform_feedback_hub_url']` before the include to override it.
**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.
## Surf (silver)
Surf used to be a second stack — `src/config.php` + `App\Database`, no global
`$pdo`, `getBaseUrl()` instead of `base_url()` — and every tool here carried a
branch for it. Since 2026-09 it follows the same contract as the others:
`config/db.php` opens `$pdo` and calls `platform_connect()`, and
`config/functions.php` has `env()` and `base_url()`. `src/` is still its code
library (`App\Database` returns the same connection as `$pdo`).
What stays its own is declared in its `platform_connect()` rather than here:
- the guard is a closure on the session role (`guard`);
- the layout partials live in `views/partials/` (`layout`);
- migrations live directly in `database/` (`migrations_dir`, which the page
router and the API router both pass on as `$GLOBALS['MIGRATIONS_DIR']`);
- its own `api/backup_trigger.php` and `api/backup_download.php`, since it has no
`config/functions_backup.php` (`api_files`).
## Captcha
A question the visitor reads and answers. Two lines to add it, and it can be
added to as many forms as a platform has.
It is used two ways, and the second is the one that matters most here: **as a
gate**, on a form the invisible defences have nothing to score (registration,
password recovery); and **as an appeal** — the submission is refused as usual,
and instead of a silent drop the person gets a question that releases it. The
appeal is what recovers the people the country gate blocks by mistake, and it is
documented first for that reason.
```php
require_once $repo_root . '/shared/platform-tools/functions_captcha.php';
// in the form, inside the <form>:
echo platform_captcha_field('registo', $pdo);
// in the handler, after the CSRF check:
if (($erro = platform_captcha_verify('registo', $pdo)) !== null) {
$error = $erro; // already pt-PT, ready to show
}
```
The first argument is the **scope**. It goes into the signature, so a challenge
picked up on the contact form does not validate on the registration form, and
two forms on the same page are independent. Anything stable works as a scope —
use the form's own name.
### The appeal — a second chance after a refusal
This is what the module is actually for. The country gate refuses submissions
from outside Portugal, and that catches two kinds of person who are not spam at
all: **iCloud Private Relay users**, whose traffic leaves through Apple egress
addresses that the ipdeny lists attribute to another country, and **emigrants**.
Both got the thank-you page and then waited for a reply that was never coming,
because the submission had been dropped in silence.
Silence is still right for a bot. What was wrong was that a person had no way
back. So: the refused submission is held, the person is asked a question, and a
correct answer releases it.
```php
// ─── handler ────────────────────────────────────────────────────────────────
$appeal = null;
// 1. a submission already held, coming back with an answer
if (($libertada = platform_captcha_appeal_claim($pdo, 'contacto', $erroCaptcha)) !== null) {
processar_contacto($libertada); // exactly as if it had passed first time
$sucesso = true;
} elseif ($erroCaptcha !== null) {
$appeal = platform_captcha_appeal_token(); // wrong answer: same screen again
$error = $erroCaptcha;
} else {
// 2. a normal submission
if (($reason = platform_antispam_check($pdo, 'contacto', 'mensagens', $campos)) !== null) {
$appeal = platform_captcha_appeal_offer($pdo, 'contacto', $reason);
if ($appeal === null) {
$sucesso = true; // no appeal for this one: thank-you page, as always
}
} else {
processar_contacto($_POST);
$sucesso = true;
}
}
// ─── view ───────────────────────────────────────────────────────────────────
<?php if ($appeal !== null): ?>
<p>Não conseguimos confirmar o envio automaticamente. Responda à pergunta
abaixo e a sua mensagem segue tal como a escreveu.</p>
<?= platform_captcha_appeal_field($appeal, 'contacto', $pdo) ?>
<?php endif; ?>
```
`platform_captcha_appeal_claim()` has three outcomes, and the caller needs all
three: an array is the released submission (the original `$_POST`, minus the
mechanism fields); `null` with an error means the answer was wrong and the
screen should be printed again; `null` with no error means there was no appeal
in play and the normal flow continues.
Note what the site keeps: **this module never decides what a form means.** It
hands back the payload and the site creates the lead, opens the ticket, sends
the mail — exactly as it would have done on the first pass.
#### Four decisions worth not undoing by accident
**Not every refusal gets an appeal.** `honeypot` and `carimbo inválido` are
excluded by default (`appeal_never`): a hidden field nobody can see, and a
signature only our own form produces, have no innocent reading. Offering those a
second screen is explaining to a bot what caught it. Everything else — country,
per-IP ceiling, content score, expired stamp — has real false positives, and
those are what this is for.
**The submission stays on the server.** The second screen carries only the
answer. Re-posting the content in a hidden field would let it be edited after
being scored: a clean message on the first pass, the real text on the second.
**The appeal is not bound to the IP.** That would be the reflex, and it would
break the exact case this exists to fix — Private Relay changes the egress
address between requests, so the person comes back from a different IP than the
one they were refused on. What binds the appeal is the HMAC.
**A released appeal deletes the offences that refusal produced**, scoped to that
form, that address, and that moment. Without it the appeal is a trap: every
attempt to make contact adds an offence, and [the ladder](#ip-banning) bans at
three in 24 h — the third time a Private Relay user tried to reach us they would
lose the site entirely, right after we had confirmed they were human.
It does **not** lift an existing ban, and that one is a choice rather than a
consequence: a captcha that undoes a ban is an oracle — solve a puzzle, regain
access — and that is a far more attractive target than a contact form. Someone
already banned talks to a person, through the address on the 403 screen.
#### Storage and limits
The queue is its own table, `captcha_appeals`, created with `CREATE TABLE IF NOT
EXISTS` on first use exactly like the [IP ban](#ip-banning) tables — a site
should not need a migration to be able to undo a mistake. It is deliberately not
`blocked_submissions`: that table has a different shape per site, and it is a
record, not a work queue.
| Key | Default | Notes |
|---|---|---|
| `appeal_table` | `captcha_appeals` | Validated as an identifier before interpolation |
| `appeal_ttl` | `7200` | Two hours to answer. This is a person looking at a screen — and the shorter it is, the less time a refused submission sits stored |
| `appeal_tries` | `6` | Answers per held submission, then the appeal closes |
| `appeal_max_ip` | `5` | Appeals opened per IP per hour, so the queue cannot become a spam dump |
| `appeal_never` | `['honeypot', 'carimbo inválido']` | Substrings of the refusal reason that get no second chance |
Rows are pruned opportunistically whenever an appeal is opened, so there is no
extra cron to forget to install. `claimed_at` tells you how many false positives
the flow actually recovered — worth looking at after a week, because that number
is the argument for or against the country gate's current settings.
#### The other half of the iCloud Relay problem
The appeal recovers the person, but it still asks them to do something. The root
cause is an attribution error and is fixable on its own: Apple publishes the
Private Relay egress ranges, with the country and region each one serves, so a
relay user in Aveiro can be attributed to PT instead of being refused. Feeding
that list into `functions_geo.php` alongside the ipdeny data would remove most
of these refusals before they happen. **Not implemented** — the appeal flow does
not depend on it, and the two are worth doing in either order.
### Which of the three anti-spam modules to reach for
| | Refuses | Use it for |
|---|---|---|
| `functions_antispam.php` | silently | honeypot, blocklist, 24 h ceiling per e-mail and IP |
| `functions_antispam_forms.php` | silently | forms with free text worth scoring — contact, enquiry |
| `functions_captcha.php` | **out loud** | forms with nothing to score — registration, password recovery, a comment box |
The first two are invisible on purpose: a bot must not learn what caught it, and
a lead refused by mistake has to be recoverable from the log. That only works
when there is content to score and when the refusal can be silent. A password
recovery form has neither property — refusing it in silence leaves a real person
pressing the button forever — so it gets the captcha instead.
Using a captcha *and* the traps together is the normal case and is not
redundant: the traps catch the script that never looks at the page, the captcha
catches the one that does.
### What it is, and what it is not
Self-hosted, and deliberately not reCAPTCHA/hCaptcha/Turnstile. Three reasons,
none of them taste: [ASSETS.md](ASSETS.md) settled on 2026-09-01 that no
front-end asset leaves the network in production, and a third-party captcha is a
form that does not open when someone else's service is down; it runs third-party
JavaScript in the page, including — in the backoffice — in the session of
whoever is signed in as an admin; and it ships every visitor's IP and behaviour
to a third party, which on a European site is a GDPR decision nobody took.
The price is accepted: **this stops scripts, not a human paid to solve
challenges.** That was equally true of reCAPTCHA.
The challenge is either a small sum ("Quanto é sete mais 4?") or a letter of a
word ("Escreva a terceira letra da palavra CADEIRA"), picked per challenge. One
operand is spelled out and the other is in digits, which breaks the three-line
`/(\d+)\s*\+\s*(\d+)/` solver. Answers are accepted in digits or in words, upper
or lower case, with or without accents, and the Brazilian spellings
(`quatorze`, `dezesseis`, `dezenove`) count as right because whoever writes them
answered correctly.
**No JavaScript and no images**, per rule 10 of [PHILOSOPHY.md](PHILOSOPHY.md) —
and because a screen reader reads a written question and cannot read a distorted
image.
### No server state, and the one thing that costs
The challenge travels in an HMAC-signed token: timestamp, random nonce,
signature. The question *and its answer* are derived from the nonce with the
site's key, so the answer never leaves the server and there is nothing to store
between requests. No table, no required session, and it works behind any cache.
The cost is replay. **One question is one attempt** — the nonce is marked used
as soon as it is verified, right or wrong — but that mark lives in the session,
and `platform_captcha_verify()` will not start a session of its own: on a public
page that would set a cookie on everyone who walks past a form, which changes
the site's cookie policy and defeats any cache in front. With no active session
the defence degrades to "reusable within the window", which is why the default
ttl is 15 minutes rather than an hour.
So: on a page that already has a session (anything behind a login, and most
registration flows), single use is real. On a fully anonymous page, treat the
ttl as the limit and leave `offence_forged` on.
### Failure posture: closed
Unlike the country gate, a missing token is a refusal. A captcha whose absence
lets the request through is not a captcha — it would be enough not to send the
field. A site that forgets to print `platform_captcha_field()` therefore sees
its form refused on the first test, instead of shipping a defence that is
quietly switched off.
### Configuration
Call once, before the first field is rendered. Everything has a working default;
the table is what is worth changing.
```php
platform_captcha_config([
'type' => 'mixed', // 'math' | 'letter' | 'mixed'
'ttl' => 900, // seconds the challenge stays valid
'help' => '', // '' removes the help line under the field
'class_wrap' => '', // empty strings for a site without Bootstrap 5
]);
```
| Key | Default | Notes |
|---|---|---|
| `type` | `mixed` | `mixed` alternates sums and letters, so an automated solver has to handle two formats |
| `ttl` | `900` | Also the replay window when there is no session |
| `field_answer` / `field_token` | `captcha_answer` / `captcha_token` | Rename if the site already POSTs a field by that name |
| `class_wrap` / `class_label` / `class_input` / `class_help` | Bootstrap 5 classes | Empty string drops the attribute entirely |
| `help` | a one-line hint | `''` renders nothing |
| `single_use` | `true` | Only has an effect where a session is active |
| `offence_forged` | `true` | A forged or missing token counts toward [IP banning](#ip-banning) |
| `offence_wrong` | `false` | A wrong answer does **not**. The ladder bans at 3 offences in 24 h, and someone who mistypes "catorze" twice is not a bot — that would be banning people for failing a question we asked them |
| `words` | 16 accent-free pt-PT nouns | The pool for the letter challenge |
For a form that needs its own markup — a grid, a modal, a step in a wizard —
`platform_captcha_issue($scope, $pdo)` returns `question`, `token`, the two
field names and a unique `id`, and you write the HTML.
### The key
Challenges are signed with `platform_derived_key('captcha', $pdo)`, from
`functions_settings.php`: `APP_SECRET_KEY` where there is one, otherwise a
random value stored in `system_settings`, otherwise derived from the install's
own configuration. It is domain-separated, so an anti-spam stamp can never
validate here and vice versa. Pass the `$pdo` at every call site — a site that
passes it in the form and omits it in the handler would sign with one key and
verify with another on an install that has no `APP_SECRET_KEY`.
## 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
GEO_ALLOW_IPS= # addresses that always pass, see below
GEO_TRUST_RELAY=true # score iCloud Private Relay by the country it serves
```
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.
The page is `platform.php?tool=geo` on a router site (a site that doesn't use
the country gate hides it with `hide_tools`).
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.
### Two escape valves, because the gate has false positives
A country gate assumes the egress address is in the country the person is in.
That assumption is no longer true for everyone, and the two cases that break it
have both cost real leads.
**iCloud Private Relay.** Safari sends its traffic out through Apple's egress
addresses, registered where Apple registered them and not where the person is.
Against the ipdeny lists someone in Aveiro looks foreign, gets refused in
silence, and waits for a reply that is never coming.
Apple publishes which country each egress range serves, so the gate can ask the
right question about it:
```bash
shared/platform-tools/bin/geo-relay-update.sh pt
```
```
23 4 * * 1 /srv/sites/<site>/shared/platform-tools/bin/geo-relay-update.sh pt
```
That writes `data/geo/relay-pt.v4` and `.v6` — the same format as the ipdeny
lists, read by the same code, and 611 ranges rather than the 288k in Apple's
full file. **It is not a Relay whitelist**, and the per-country filtering is what
makes that true: a range serving France is never written, so a French relay user
is refused by the normal path like any other foreign address. Opening every
Relay range would let in any iCloud+ subscriber on earth, which is precisely
what the gate exists to prevent.
`GEO_TRUST_RELAY=false` turns it off. With no lists downloaded the module is
inert either way, so wiring it in ahead of the data is safe.
**Everyone else: `GEO_ALLOW_IPS`.** A list of addresses or CIDRs that always
pass — the client whose office VPN lands abroad, a monitoring service, the one
person who called today. It is checked *before* everything else, including the
fail-closed branch, because it is the only defence that still works when the
country lists are missing.
```ini
GEO_ALLOW_IPS=203.0.113.7, 198.51.100.0/24, 2001:db8::/32
```
Both are editable from the geo page, which also has a one-click "add my own
address" for the case where the person locked out is the operator.
**Order of decision**, and none of it runs when `GEO_MODE=off`:
1. private/loopback → pass (the container's own cron and health checks)
2. `GEO_ALLOW_IPS` → pass
3. a Private Relay egress serving an allowed country → pass
4. no usable country data → fail-open (or refuse under `GEO_FAIL_CLOSED`)
5. the address is in an allowed country → pass
6. otherwise → refused, with the reason the caller logs
A refusal at step 6 is exactly where the [captcha appeal](#the-appeal--a-second-chance-after-a-refusal)
picks up: these two features are the same problem solved at two ends, and both
are worth having. The relay lists remove most of the wrong refusals before they
happen; the appeal recovers the ones that still get through.
### 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. `geo-relay-update.sh` has the same posture, and the two run
independently: Apple being unreachable must not stop the country lists
refreshing, or the other way round.
The geo page's "Actualizar listas" button runs both scripts, and reports a
partial failure as a partial failure rather than as success.
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.
The page is `platform.php?tool=tracking` on a router site; fotografia
lets admins in, not only sysadmins, with `'guards' => ['tracking' => 'require_admin']`.
### 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 — platform_connect() does it ('contact_email')
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.
## Audit log
`functions_audit.php` — who did what, to what, when and from where, in each
site's `audit_log`, written by one function:
```php
platform_audit($pdo, 'user.delete', [
'target_type' => 'user',
'target_id' => 42,
'target_label' => 'Maria Silva',
'summary' => 'Sent to the recycle bin',
'changes' => platform_audit_diff($before, $after), // {field: {from, to}}
]);
```
The fleet had three incompatible audit logs (hub, dance, quack — different
columns, different functions) and five sites with none.
- **Existing tables are not renamed.** The writer reads the site table's columns
once per request and writes each canonical field into the column the site maps
it to — `platform_connect(['audit' => ['map' => ['actor_id' => 'user_id', …]]])`,
like the auth `schema`. A field with no column is dropped, a NOT NULL column is
never sent a NULL, text is cut to the column size, a non-numeric id is not
forced into an INT column. The site migrations add the missing columns so
nothing is lost from then on. A site with no table gets the canonical one on
first use.
- **Never breaks the action**: a failed write goes to the error log.
- **Secrets never enter**: any `changes` key that looks like one (password,
token, secret, api/ops key, hash, cookie, csrf, validator, pairing code) is
replaced by «(oculto)» at any depth, keeping the `{from, to}` shape.
- **The actor comes from the session** in any of the fleet's shapes, the IP is
the real one; callers pass `'actor'` when they know better (the hub acting
through ops, the deploy webhook, dance's attendance PIN).
- **Pruned** after `keep_days` (730 by default), opportunistically.
What the shared code records by itself, on every site: `auth.login` /
`auth.logout` (shared auth), every action of *Atualizar plataforma*
(`platform.<action>`, reads excluded), `platform.auto_update`, `backup.delete`,
`backup.restore`, `ip.ban` / `ip.unban`, `hub.<action>` from *Integração com o
hub* (keys never recorded), `settings.smtp`, `settings.geo`, and every mutating
`api/hub_ops.php` action with the hub as the actor.
The page is `platform.php?tool=audit` (`audit.php`): filters by who, action
prefix, type, text and dates, the before/after of each change, CSV export.
## Fleet bans and the security log
Once a platform is paired with the hub, what its defences refuse also goes to
the hub, and the hub's list of banned IPs comes back and is enforced here. An
address banned on dance is turned away at fotografia's door.
Nothing to wire beyond what already exists: `platform_ip_guard()` schedules the
sync, and the defences report through the functions they already call. Add the
cron line so a site with no traffic still receives the other sites' bans:
```
*/5 * * * * php /var/www/html/shared/platform-tools/bin/hub-security-sync.php
```
**What is reported.** `ip_ban`, `ip_unban`, `offence` (everything that reaches
`platform_ip_record_offence()`: antispam, captcha, country gate),
`login_failed` / `login_throttled`, `csrf_failed` (`platform_csrf_ok()`) and
`mail_rate_limited`. Login events come from `auth_throttle_hit()` /
`auth_throttle_check()`; a site with its own login code reports from its login
page instead:
```php
platform_security_event($pdo, 'login_failed', hub_security_mask_identifier($email));
hub_security_maybe_sync($pdo); // only needed if the site doesn't call platform_ip_guard()
```
Every email address in an event's detail is masked (`a***@example.pt`) before
it leaves the site — offence details often carry the submitter's address.
`platform_security_event_sink($callable)` replaces the outbox with a direct
call. The hub uses it: it is the fleet's record, so its own events are written
straight to its tables instead of being sent to itself.
**How it travels.** Events go into a local outbox (`hub_security_outbox`) and
leave in batches: at the end of any request, at most every 30 s while there are
events and every 5 min when there are none, after the response is sent where
the SAPI allows it (`fastcgi_finish_request()`); and from the cron script. One
request per event would put every attacker request behind a network call and
turn an attack on one site into an attack on the hub. The request carries the
same key the hub probes this platform with, so an unpaired site records
nothing — otherwise the outbox would grow forever. With the hub down, events
wait (capped at 5000, bans never dropped) and local banning works exactly as
before. `checks.security` in the health endpoint goes `false` when events have
been stuck for over an hour.
**The fleet's bans, locally.** They land in `ip_bans` with `source='fleet'`
and show as *frota* on the IP-ban page. The rules:
- A local active ban always wins over a copy — it was decided here, with this
site's reason.
- Copies are never sent back to the hub (`platform_ip_ban()` skips
`source='fleet'`), otherwise they would echo.
- **Lifting a ban anywhere lifts it fleet-wide.** Unbanning here sends
`ip_unban`; the hub lifts the fleet ban and the copies disappear from every
site on the next sync. Without that, a local unban of a copy would be undone
five minutes later. The site where the ban started keeps its own until it
expires or is lifted there.
- Copies that stop arriving are deleted — but only when the hub says the list
is complete. A truncated list says nothing about what it left out.
## 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 — the bootstrap and the two front controllers
php -l config/db.php public/platform.php public/api/platform.php
# 2. Submodule populated and on the expected commit
git -C shared/platform-tools log --oneline -1
# 3. Over HTTP (no session needed)
curl -s "https://<host>/api/platform?endpoint=bogus" # {"ok":false,"error":"endpoint desconhecido"} — an HTML 404 means the push never deployed
curl -s "https://<host>/api/platform?endpoint=health" # 401/503 JSON — the endpoint's own auth answering
curl -sI "https://<host>/platform?tool=index" # 302 to the login page
curl -sI "https://<host>/views/sysadmin/backups.php" # 302 to platform.php?tool=backups (sites that had the old stubs)
# 4. In the browser, as sysadmin
# - Sistema lists every routed tool plus the site's own cards
# - Atualizar plataforma: git state (branch, SHA, history), "Verificar atualizações", pending migrations,
# "Exportar esquema" twice → `git diff database/schema.sql` shows no change the second time (determinism)
# - Backups: "Backup da base de dados" → db_backup_*.zip appears; the download icon downloads it
# - Integração com o hub: paired, and the hub card shows the checks (incl. security and conformance)
```