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

85 KiB

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 DEFINERs, 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.
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 and The appeal.
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.
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.
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.
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_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.
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.
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-.v4
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.
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.
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.
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.
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.
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_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:

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.

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 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 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:

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):

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 for the auth options. For the sysadmin pages add:

    '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 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 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.

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):

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
$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) 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.

2. Create api/agent.php (1-line stub):

<?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
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 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 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.

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.

// ─── 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 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 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 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 — 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.

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
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.
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:

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:

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.

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 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:

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.

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.

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:

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:

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)

# 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)