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