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>
938 lines
52 KiB
Markdown
938 lines
52 KiB
Markdown
# platform-tools
|
|
|
|
Shared sysadmin tooling — "Atualizar plataforma" page, "Backups" page, and related helpers — distributed as a git submodule across every andmore platform.
|
|
|
|
The goal: edit one file, every platform gets the update.
|
|
|
|
## What's in here
|
|
|
|
| File | Purpose |
|
|
|---|---|
|
|
| `index.php` | Sysadmin hub — single landing page with cards linking to the other tools. 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 `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. 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](#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](#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-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()`. 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 — 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](#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](#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-bubble-fleet--hub-tickets). |
|
|
| `feedback_widget.php` | The bubble itself, bottom-right. Included from the host's backoffice footer; renders nothing for anyone who isn't an admin. Zero dependencies — no Bootstrap, no icon font, no jQuery. |
|
|
| `feedback_footer_link.php` | The same widget as a footer link instead of a floating bubble — for the public site, where the bottom-right corner is already taken (WhatsApp). A file rather than a flag so the host include stays one bare line either way. |
|
|
| `api/feedback.php` | The JSON endpoint the bubble posts to. Cookie-authenticated (not key-authenticated, unlike the rest of `api/`), so it needs the host's session — which it gets from `hub_bootstrap.php`, same as the others; the per-platform stub is a 1-line require. Every POST carries the `platform_csrf_ok()` token. |
|
|
|
|
## Auto-update
|
|
|
|
A push becomes a deploy. Gitea POSTs the push payload to
|
|
`<host>/api/auto_update.php`; the platform verifies the signature, pulls itself,
|
|
applies the migrations that arrived in the same commit, and invalidates the
|
|
OPcache and the settings caches. It is the "Atualizar plataforma" button with
|
|
nobody standing in front of it.
|
|
|
|
### Wiring it up
|
|
|
|
1. `AUTO_UPDATE_SECRET=<random>` in the host's `.env`. Generate one with
|
|
`openssl rand -hex 32`. It lives in `.env`, not `system_settings`, for the
|
|
same reason `GIT_HTTP_TOKEN` does: you need it in order to *repair* a site,
|
|
so it must not depend on the database being readable.
|
|
2. Create the stub `<host>/api/auto_update.php`:
|
|
`<?php require __DIR__ . '/../shared/platform-tools/api/auto_update.php';`
|
|
3. Gitea → repository → Settings → Webhooks → **Gitea**:
|
|
* Target URL: `https://<host>/api/auto_update.php`
|
|
* HTTP method `POST`, content type `application/json`
|
|
* Secret: the same value as `AUTO_UPDATE_SECRET`
|
|
* Trigger: **Push events**
|
|
4. Turn it on in the backoffice: *Atualizar plataforma* → **Auto-update** →
|
|
*Ligar*. Off by default — pulling code unattended is a decision, not a
|
|
default. "Correr agora" on that card runs the identical path by hand, which
|
|
is how you test the setup without pushing.
|
|
|
|
### What it will and will not do
|
|
|
|
It **fast-forwards only**. `git_safe_update()` tolerates mode-bit and submodule
|
|
drift, but real local commits or uncommitted edits stop the run and are reported
|
|
untouched — an unattended `reset --hard` is how you lose the hotfix somebody
|
|
applied on the server at 2 a.m. Force-syncing stays a deliberate, confirmed
|
|
click on the platform page.
|
|
|
|
It **ignores pushes to other branches**: the webhook fires for every branch, and
|
|
the payload's `ref` is compared to the branch this server has checked out.
|
|
|
|
It **runs one at a time**. A push burst delivers several webhooks for what is
|
|
really one state; a per-repo lock file makes the extras no-ops (HTTP 200,
|
|
`status: busy`).
|
|
|
|
Migrations on deploy are a second switch (`auto_update_migrations`, on by
|
|
default). Leaving it on is usually right — code and the schema it needs travel
|
|
in the same commit, so applying one without the other leaves a window where the
|
|
site is broken. Turn it off when a release needs a human to run the SQL.
|
|
|
|
It **answers before it works**. Gitea allows a delivery 5 seconds by default,
|
|
which a git pull alone exceeds, so the endpoint replies `202 accepted` and does
|
|
the work with the connection already closed (`fastcgi_finish_request()`, or
|
|
`Content-Length` + `Connection: close` on mod_php). The response body therefore
|
|
carries no outcome — that is what `auto_update_last_*` and the UI are for.
|
|
|
|
### Backup before updating
|
|
|
|
Two more switches, both **off** by default, in the *Backup antes de atualizar*
|
|
card: `update_backup_db` and `update_backup_files`. They apply to **both** ways
|
|
of updating — the *Atualizar* button and the webhook.
|
|
|
|
The backup runs before anything moves, and the update **waits for it**. That
|
|
wait is the whole point: `backup_trigger()` is fire-and-forget, so pulling
|
|
straight after it would race the dump and a migration could apply halfway
|
|
through — producing a "restore point" that is a snapshot of neither the old
|
|
schema nor the new one. libzip writes the archive under a temp name and renames
|
|
it on close, so the final filename appearing is a genuine completion signal.
|
|
|
|
**A backup that fails or times out aborts the update** (900 s cap, nothing
|
|
touched, reported). A toggle that promises a restore point and then quietly
|
|
proceeds without one is worse than no toggle at all.
|
|
|
|
Which to turn on:
|
|
|
|
| | Worth it? |
|
|
|---|---|
|
|
| `update_backup_db` | Usually yes. It is what lets you undo a migration that went wrong, and a dump is seconds on these sites. |
|
|
| `update_backup_files` | Usually no. Git never touches `uploads/`, so it is rarely what is missing after a deploy — and on a site with galleries it adds minutes and gigabytes to *every* update. |
|
|
|
|
The card shows the size and date of the last backup of each type, so the cost of
|
|
turning one on is visible before you turn it on rather than after the first push.
|
|
|
|
### Reading the result
|
|
|
|
Every run — applied, skipped or refused — writes `auto_update_last_*` settings.
|
|
The card on *Atualizar plataforma* shows the outcome and the full output; the
|
|
one-line strip from `platform_status_strip()` surfaces the same state on a host
|
|
dashboard. Response codes are meaningful in Gitea's delivery list: `200` updated
|
|
or skipped, `202` disabled, `401` bad signature, `409` update refused, `503` no
|
|
secret configured.
|
|
|
|
## Reverting an update
|
|
|
|
Every update that actually moves `HEAD` — button or webhook — records where it
|
|
came from. The *Ponto de restauro* card then offers one click back to that
|
|
commit, which is what makes "update, test, undo if it went wrong" a safe loop
|
|
rather than a gamble.
|
|
|
|
It is a `reset --hard` to the recorded commit, **not** a `git revert`. The
|
|
pulled commits still exist on the remote and in this repo's object store, so
|
|
moving the branch pointer back loses nothing and reproduces exactly the tree
|
|
that was running before. A `revert` would invent new commits that then have to
|
|
be pushed, leaving this server diverged from every other one. Afterwards the
|
|
platform is simply "behind" again, and *Atualizar* fast-forwards it right back.
|
|
|
|
Guard rails:
|
|
|
|
* The target SHA is read from our own settings row and re-validated as a real
|
|
commit in this repository. A forged POST cannot aim it anywhere.
|
|
* It refuses while there are uncommitted content changes, unless *Descartar
|
|
alterações locais* is ticked — `reset --hard` would destroy them, and whoever
|
|
is debugging a bad deploy is exactly the person likely to have edited a file
|
|
on the server.
|
|
* Submodules are realigned to the restored gitlink; OPcache and the settings
|
|
caches are dropped.
|
|
* The restore point is consumed on use. The next update records a fresh one.
|
|
|
|
**The database does not come back with the code.** Migrations applied during
|
|
the update stay applied, and old code against a new schema can be worse than
|
|
new code against it. So the restore point remembers which migrations ran and
|
|
which backup was taken immediately before, and both the card and the output say
|
|
so — with the backup filename to restore from, when `update_backup_db` was on.
|
|
That pairing is the reason to leave the DB backup toggle on.
|
|
|
|
## Settings and secrets
|
|
|
|
Configuration resolves through three layers. In precedence order, highest first:
|
|
|
|
1. the `system_settings` row, if one exists;
|
|
2. a `define()`d constant, where the site defines one;
|
|
3. `.env`.
|
|
|
|
`platform_settings_get()` returns its default — which is where `.env` enters —
|
|
only when the row is **absent or NULL**. An empty string is neither, so a blank
|
|
row is a real value that outranks everything below it. Delete the row to fall
|
|
back; do not blank it.
|
|
|
|
### Which layer a setting belongs in
|
|
|
|
The rule is: *can the system read this after a cold start with an empty
|
|
database, and would I still need it if the app were broken?*
|
|
|
|
| Answer | Home | Examples |
|
|
|---|---|---|
|
|
| Yes — bootstrap or break-glass | `.env` only | `DB_*` (needed to reach the DB), `APP_SECRET_KEY` (decrypts the table), `BACKUP_TOKEN`, `GIT_HTTP_*`, `HUB_OPS_SECRET` |
|
|
| No — runtime configuration | `system_settings` | `smtp_*`, `email_from*`, feature flags, limits, retention, integration keys |
|
|
|
|
Runtime configuration belongs in the database because an operator has to be able
|
|
to change it without shell access. Bootstrap and recovery credentials belong in
|
|
`.env` because a credential you need in order to *repair* a site must not depend
|
|
on the site being healthy.
|
|
|
|
`APP_SECRET_KEY` is `.env`-only for a stronger reason: it is the key that
|
|
decrypts `system_settings`, so storing it there would be circular.
|
|
|
|
### Secrets at rest
|
|
|
|
Secret-valued settings are encrypted with AES-256-GCM, stored as
|
|
`enc:v1:<base64(iv|tag|ciphertext)>`. The key is derived
|
|
`hash('sha256', 'am-secret|' . APP_SECRET_KEY)`, falling back to `BACKUP_TOKEN`
|
|
only when no `APP_SECRET_KEY` is set.
|
|
|
|
The point is the backups: the nightly job keeps many copies of the database, so
|
|
a plaintext password would sit in every one of them. The key lives only in
|
|
`.env` — never in the database, never in git — so a leaked dump on its own is
|
|
not enough to recover the credentials.
|
|
|
|
**Which keys are secret is decided by `platform_settings_is_secret()`, not by
|
|
the caller.** Any key ending in `_password`, `_secret`, `_token`, `_api_key` or
|
|
`_private_key` qualifies, plus an explicit list for names that do not follow the
|
|
convention. This used to be a per-site array — fotografia had
|
|
`['smtp_password','twilio_token']` hardcoded in `comms.php`, which is how
|
|
`ig_app_secret` came to be stored in the clear. A caller can no longer forget.
|
|
|
|
`platform_settings_set()` therefore encrypts on its own judgement. Pass `true`
|
|
or `false` explicitly only to override it. It also:
|
|
|
|
- refuses to double-wrap a value already carrying the `enc:v1:` prefix;
|
|
- stores `''` as `''`, since callers use empty to mean *cleared*, not *encrypt
|
|
the empty string*;
|
|
- logs loudly rather than quietly writing a secret in plaintext when no key is
|
|
available.
|
|
|
|
`platform_settings_all()` masks secrets unless asked not to. Passwords render as
|
|
`•••••••• (definida)` with no prefix at all — eight leading characters in a
|
|
screenshot is a real leak — while API keys keep a recognisable head and tail.
|
|
|
|
### One implementation, deliberately
|
|
|
|
Three sites had each grown their own copy of this cipher with three different
|
|
key derivations: dance used the domain `'dsa-secret|'`, fotografia
|
|
`'am-secret|'`, hub2 a plain `sha256` with no domain separation. All three wrote
|
|
the same `enc:v1:` prefix, so the values looked interchangeable while being
|
|
mutually unreadable, and nothing surfaced the difference until something tried
|
|
to read across sites.
|
|
|
|
There is now one implementation, here. A site that wants the short names
|
|
includes `compat_secrets.php`; it must not define its own.
|
|
|
|
Note that each site keeps its **own** `APP_SECRET_KEY`. A secret encrypted on
|
|
one site deliberately does not decrypt on another — a leaked dump from one must
|
|
not unlock the rest.
|
|
|
|
## Authentication
|
|
|
|
`functions_auth.php` holds the mechanics; the site keeps the policy. Wire it in
|
|
the bootstrap, after the PDO is open:
|
|
|
|
```php
|
|
require_once __DIR__ . '/../shared/platform-tools/functions_auth.php';
|
|
auth_schema(['password' => 'password', 'name' => 'full_name',
|
|
'active' => null, 'deleted_at' => 'deleted_at']); // surf's columns
|
|
auth_roles_register(['teacher' => 40, 'student' => 20, 'parent' => 20]);
|
|
auth_session_start(['name' => 'dsa_session']);
|
|
```
|
|
|
|
### The schema map
|
|
|
|
`auth_schema()` exists so the shared code can run against tables that already
|
|
differ — surf stores the hash in `password` and the name in `full_name`, the
|
|
others use `password_hash` and `name`; three sites deactivate with `is_active`
|
|
and surf with `deleted_at`. Renaming a column with 147 references in the site is
|
|
a migration; adapting is a function call. A site whose columns already match the
|
|
defaults need not call it at all.
|
|
|
|
| key | default | meaning |
|
|
|---|---|---|
|
|
| `table` | `users` | |
|
|
| `id` | `id` | |
|
|
| `email` | `email` | where password-recovery mail is sent |
|
|
| `login` | *null* | column the login form matches on; null means "use `email`" |
|
|
| `password` | `password_hash` | |
|
|
| `name` | `name` | |
|
|
| `role` | `role` | |
|
|
| `active` | `is_active` | null when the site has no active flag |
|
|
| `deleted_at` | *null* | set when the site soft-deletes instead |
|
|
| `last_login` / `last_ip` | `last_login_at` / `last_login_ip` | null to skip the stamp |
|
|
|
|
`login` and `email` are separate keys because they are separate questions.
|
|
quack signs in with `username` and still mails recovery links to `email`;
|
|
folding them into one key would either break its login or send its reset links
|
|
to a username. Everywhere else `login` stays null and `auth_attempt()` falls
|
|
back to `email`, which is the behaviour those sites already had.
|
|
|
|
### Roles
|
|
|
|
Three roles are core and mean the same everywhere: `sysadmin` (100), `admin`
|
|
(80), `user` (10). A site registers its own on top with a rank, and they sit in
|
|
the same ordering:
|
|
|
|
| site | registers |
|
|
|---|---|
|
|
| dance | `teacher` 40, `student` 20, `parent` 20 |
|
|
| design | `client` 10 |
|
|
| quack | `formador` 40, `aluno` 20 |
|
|
| silver | `student` 20 |
|
|
|
|
`auth_at_least('admin')` is then expressible without hardcoding a list, and
|
|
`auth_is_any(['teacher','parent'])` covers roles that are peers rather than
|
|
ranked. An unregistered role ranks **0** — below everything — so a typo in a
|
|
role name cannot grant access.
|
|
|
|
### What it fixes, and why it is worth knowing
|
|
|
|
Every item below was a real gap found when the separate implementations were
|
|
compared, and each existed because the sites solved the same problem apart:
|
|
|
|
- **Sessions.** dance ran a bare `session_start()` — no HttpOnly, no SameSite,
|
|
no Secure, no `use_strict_mode` — and never called `session_regenerate_id()`,
|
|
so a session fixed before login stayed valid after it.
|
|
- **Password hashes never improved.** `password_needs_rehash()` appeared twice
|
|
in the entire estate. `auth_attempt()` rehashes on login, the only moment the
|
|
plaintext exists. dance also pinned `PASSWORD_BCRYPT` while the others used
|
|
`PASSWORD_DEFAULT`, so the day DEFAULT moves, three sites migrate and one
|
|
silently does not.
|
|
- **Throttling.** dance had none. The shared one keys on identifier **and** IP:
|
|
on the identifier alone an attacker locks a real user out of their own
|
|
account; on IP alone a NAT'd office locks itself out. `X-Forwarded-For` is
|
|
honoured only when `TRUSTED_PROXY` says so — every site sits behind NPM, so
|
|
`REMOTE_ADDR` is always the proxy, and trusting the header unconditionally
|
|
would let a caller forge their throttle identity. That rule now lives in
|
|
`functions_client_ip.php` as `platform_client_ip()`; `auth_client_ip()` is an
|
|
alias. It moved out because the antispam in fotografia and mstudio needed the
|
|
same calculation and could not include this module — both declare their own
|
|
`auth_login()`, so the include died on "Cannot redeclare function".
|
|
- **Account enumeration.** `auth_attempt()` spends comparable time when the
|
|
account does not exist, so response timing does not reveal which addresses are
|
|
registered.
|
|
- **Open redirect.** `auth_intended_url()` accepts only a local absolute path,
|
|
so the login page cannot be used as a phishing hop.
|
|
|
|
Two tables are created on demand and declared in each site's migrations:
|
|
`auth_attempts` and `password_resets`. The throttle tolerates the pre-existing
|
|
`auth_attempts` on surf and fotografia, which carry a `success` column and log
|
|
successful logins too — those rows are excluded from the count, or signing in
|
|
successfully would move you toward being locked out.
|
|
|
|
### Password recovery
|
|
|
|
`password_reset_request()` delivers through `platform_mail_send()` with
|
|
`platform_smtp_config()`, so recovery works wherever the platform can already
|
|
send mail. Only the SHA-256 of the token is stored — a leaked dump cannot reset
|
|
anyone's password. Consuming a token burns every outstanding token for that
|
|
user, inside a transaction, and regenerates the session.
|
|
|
|
The public result is deliberately identical for a known address, an unknown one,
|
|
and a send failure. A transport error is reported separately in
|
|
`delivery_error`, for an operator only: folding it into `ok` would let the form
|
|
enumerate accounts whenever SMTP is down.
|
|
|
|
## Bootstrap contract — what the shared file expects
|
|
|
|
`platform_update.php` is a **view fragment** that expects the calling 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`).
|
|
|
|
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 migrations
|
|
- `base_url($path)` — used for dashboard link default
|
|
- `env($key)` — used by `functions_git.php` for HTTPS credentials (falls back to `getenv()`)
|
|
|
|
## 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):
|
|
|
|
```bash
|
|
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](views/sysadmin/platform_update.php):**
|
|
|
|
```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](config/functions_git.php)** (create if missing):
|
|
|
|
```php
|
|
<?php
|
|
require_once __DIR__ . '/../shared/platform-tools/functions_git.php';
|
|
```
|
|
|
|
**4. Replace [config/functions_migrations.php](config/functions_migrations.php):**
|
|
|
|
```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:**
|
|
|
|
```bash
|
|
git pull
|
|
git submodule update --init --recursive
|
|
```
|
|
|
|
> Since the "Atualizar (git pull)" button in `platform_update.php` now runs
|
|
> `git submodule update --init --recursive` itself after the pull, the second
|
|
> command is only needed for the **very first** checkout of a host whose
|
|
> `platform_update.php` page 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):
|
|
|
|
```bash
|
|
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](views/sysadmin/index.php):**
|
|
|
|
```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:
|
|
|
|
```php
|
|
<a href="<?= base_url('views/sysadmin/index.php') ?>">Sistema</a>
|
|
```
|
|
|
|
**3. Adding more tools later** — set `$sysadmin_tools` in the stub before the `require`:
|
|
|
|
```php
|
|
$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>/.env` defines `BACKUP_TOKEN=<random_string>` (used to authenticate the worker URLs). Optionally `BACKUP_KEEP_DAYS=7` and `BACKUP_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 will `mkdir` it if missing but the parent must be writable.
|
|
- Add `backups/*.zip` to `.gitignore` (the workers write ZIP files into it).
|
|
- `<host>/config/env.php` defines `env_load()` + `env()` (already present on dsa, fotografia, moremio, tickets).
|
|
|
|
> **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.
|
|
|
|
**1. Replace [views/sysadmin/backups.php](views/sysadmin/backups.php):**
|
|
|
|
```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](config/functions_backup.php)** (create if missing):
|
|
|
|
```php
|
|
<?php
|
|
require_once __DIR__ . '/../shared/platform-tools/functions_backup.php';
|
|
```
|
|
|
|
**3. Replace [api/backup_trigger.php](api/backup_trigger.php)** (create if missing):
|
|
|
|
```php
|
|
<?php
|
|
require __DIR__ . '/../shared/platform-tools/api/backup_trigger.php';
|
|
```
|
|
|
|
**4. Replace [api/backup_download.php](api/backup_download.php)** (create if missing):
|
|
|
|
```php
|
|
<?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 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:** same as above (already present if backups/platform_update are wired).
|
|
|
|
**2. Create [api/agent.php](api/agent.php)** (1-line stub):
|
|
|
|
```php
|
|
<?php
|
|
require __DIR__ . '/../shared/platform-tools/api/agent.php';
|
|
```
|
|
|
|
**3. Add the secret to `<host>/.env`:**
|
|
|
|
```
|
|
HUB_OPS_SECRET=
|
|
```
|
|
|
|
Generate the value on `id.andmore.pt` → **Plataformas → <app> → "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
|
|
<?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
|
|
<?php include __DIR__ . '/../shared/platform-tools/feedback_widget.php'; ?>
|
|
```
|
|
|
|
**3. The public site** — a footer link instead of a floating bubble, because
|
|
the bottom-right corner there is already taken by the WhatsApp button. Include
|
|
it **inside** the `<footer>`, on the line where the link belongs:
|
|
|
|
```php
|
|
<?php include dirname(__DIR__, 2) . '/shared/platform-tools/feedback_footer_link.php'; ?>
|
|
```
|
|
|
|
Same widget, same panel, same endpoint — only the trigger changes, and the
|
|
link inherits the footer's colour and font so it fits a dark footer and a light
|
|
one without either being styled for. The choice lives in the module, not in the
|
|
host: both entry points are one bare include, and the day the decision changes
|
|
it changes for the whole fleet at once.
|
|
|
|
Two footers in one response render one widget, not two (`$GLOBALS` one-shot
|
|
flag). 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](views/sysadmin/platform_update.php):**
|
|
|
|
```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. |
|
|
|
|
```ini
|
|
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
|
|
<?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:
|
|
|
|
```php
|
|
if (($geoReason = geo_form_block_reason()) !== null) {
|
|
db_log_blocked_submission($pdo, 'register', $geoReason, 0, platform_client_ip() ?? '');
|
|
$error = 'Não foi possível criar a conta a partir desta ligação.';
|
|
}
|
|
```
|
|
|
|
For `GEO_MODE=site`, call `geo_guard_site()` once from the bootstrap. It is a
|
|
no-op under the other modes, so it is safe to leave wired in permanently and
|
|
switch behaviour from the env alone.
|
|
|
|
**Failure posture is fail-open, deliberately.** A missing or corrupt data file
|
|
logs loudly and lets the visitor through. Inverting that turns a stale file into
|
|
a total outage, while the worst case of failing open is the spam that already
|
|
gets through today. `GEO_FAIL_CLOSED=true` inverts it for a platform that would
|
|
rather refuse.
|
|
|
|
Private, loopback and link-local addresses always pass — behind NPM the
|
|
container talks to itself for cron and health checks, and a platform whose own
|
|
cron is geo-blocked fails in ways that take a long time to understand.
|
|
|
|
### The data
|
|
|
|
Per-country CIDR lists from ipdeny.com, one file per family, committed to the
|
|
repo. Refresh them with:
|
|
|
|
```bash
|
|
shared/platform-tools/bin/geo-update.sh pt
|
|
```
|
|
|
|
Allocations move slowly, so monthly is plenty:
|
|
|
|
```
|
|
17 4 3 * * /srv/sites/<site>/shared/platform-tools/bin/geo-update.sh pt
|
|
```
|
|
|
|
The script refuses to overwrite a good file with a short list, a non-CIDR body,
|
|
or an HTML error page served with 200 — any of which would otherwise mean "no
|
|
visitor is in Portugal", and with `GEO_FAIL_CLOSED` would lock the country out
|
|
of its own site.
|
|
|
|
Lookup packs the address with `inet_pton()` and binary-searches sorted ranges,
|
|
comparing the raw bytes. The same code path serves IPv4 and IPv6, and it steps
|
|
around the signed-32-bit trap `ip2long()` sets for anything above
|
|
`127.255.255.255`.
|
|
|
|
## Visit tracking
|
|
|
|
Answers "where did this person come from?" on three axes: **URL** (`?source=montra`,
|
|
`utm_*`, or the referring domain), **device** (mobile/tablet/desktop, OS, browser,
|
|
language) and **geography** (country, via the geo index). It grew on fotografia
|
|
and moved here so the other platforms could use it.
|
|
|
|
Counting visits per source is the easy half and the least useful. What tells you
|
|
whether a QR code is worth reprinting is how many *requests* it produced, so the
|
|
first-touch source is remembered in a cookie for the attribution window and
|
|
stamped on whatever the visitor eventually submits.
|
|
|
|
```php
|
|
platform_track_visit($pdo); // once, on a public page
|
|
platform_track_conversion($pdo, 'contacto'); // after a submission is accepted
|
|
```
|
|
|
|
Conversions go to one `tracking_conversions` table keyed by form name, rather
|
|
than a `source` column on each destination table. A site with contact, trial and
|
|
registration forms would otherwise need three migrations and three places where
|
|
attribution can be lost; this way it is one line per handler and the report is
|
|
the same across the estate. Both tables are created on demand — no migration
|
|
needed to adopt it.
|
|
|
|
Call `platform_track_conversion()` **after** the submission is accepted, never
|
|
before. A request the antispam or the country gate refused is not a conversion,
|
|
and counting it would make a campaign look better for attracting bots.
|
|
|
|
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.
|
|
|
|
```php
|
|
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_migrations` table uses `filename` as PK (VARCHAR 150). Platforms that already have a different schema (older DSA-style with `id` PK + `filename` UNIQUE) are compatible — `CREATE TABLE IF NOT EXISTS` is a no-op for the existing table, and the queries (`SELECT/INSERT … WHERE filename=?`) work against both shapes.
|
|
- All POST actions are idempotent or guarded with confirms, and every one requires the shared CSRF token (`functions_csrf.php`) on top of the host's sysadmin auth guard.
|
|
- `git_run` uses `proc_open` array form (no shell), so format strings with `%cd`, `%h`, `|` etc. pass through cleanly.
|
|
|
|
## Verification after wiring (per platform)
|
|
|
|
```bash
|
|
# 1. Syntax — 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
|
|
```
|