# 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=`, 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=` (`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-.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/.v4`, `.v6` | Committed CIDR allocations per country, read by `functions_geo.php`. Committed so a deploy never depends on ipdeny being reachable. `relay-.*` 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 `/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 `/uploads/` (the one folder with external data; code is reconstructible from git). Source folder overridable via `BACKUP_FILES_DIR` in `.env`. Writes to `/backups/.zip` and rotates old ZIPs. | | `backups/db_backup.php` | DB dump worker. Writes to `/backups/db_backup_.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 `/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 `/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 `/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=` 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 `/api/auto_update.php`: `/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:`. 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 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=`, 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/.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 ` (`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' => ]`. **A new endpoint goes into `platform_api_routes()` and reaches every site with no stub.** The old `api/.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 `) and every shared endpoint (`/api/platform?endpoint=`). 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';`. 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` 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:** - `/.env` defines `BACKUP_TOKEN=` (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). - `/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). - `/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/.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 `/backups/` must never be directly web-readable. `backup_dir()` and both workers write a deny-all `/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 /.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 `/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** — `/public/api/feedback.php` (or `/api/` where the docroot is the repo root, as on quack). One line, like every other endpoint in `api/`: ```php `. It guards itself: not an admin, no HTML. ```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 `