- 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>
1449 lines
85 KiB
Markdown
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)
|
|
```
|