- shared/platform-tools: ea3616a → 12f26fd (27 commits). Traz o módulo de audit (f761d99, o pin da frota), «manter sessão iniciada», o registo de segurança da frota (o gancho que o login já tinha passa a funcionar) e a correção do IP do cliente encontrada nesta auditoria (último X-Forwarded-For / X-Real-IP), promovida ao partilhado. client_ip() da app delega nele. - config/functions_audit.php: audit() sobre platform_audit(); tabela audit_log criada no primeiro uso, segredos ocultados, nunca parte a ação. - Registado: login, falha de login, bloqueio por excesso de tentativas, logout, pedidos de recuperação, mudança/definição de palavra-passe, registo e criação de utilizadores, convites, perfil (antes/depois), todas as definições (db_update_setting, com valor anterior), cada passo da lixeira (apagar, restaurar, eliminar de vez) e ações nas mensagens. - Sistema → Auditoria (views/sysadmin/audit.php), só sysadmin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
275 lines
14 KiB
Markdown
275 lines
14 KiB
Markdown
# How the andmore platforms are built
|
|
|
|
Seven sites — **dance**, **design**, **fotografia**, **hub2**, **mstudio**,
|
|
**quack**, **silver** — share one VPS, one MariaDB, one reverse proxy, and this
|
|
submodule. They are separate products with separate databases, but they are
|
|
built the same way on purpose, so that knowing one means knowing all seven.
|
|
|
|
Two of them are worth naming as special cases, because they are the shape the
|
|
conventions have to survive: **mstudio** is a second instance of fotografia's
|
|
platform under different branding, and **quack** is a classroom game that grew
|
|
up outside this submodule and was brought in later. Where this document says
|
|
"every site", those two are included — that is the point of them.
|
|
|
|
This document records the reasoning behind that shape. It is descriptive, not
|
|
aspirational: everything here is a decision already visible in the code. When a
|
|
choice below stops being true, change the code or change this file — a
|
|
convention nobody follows is worse than no convention.
|
|
|
|
---
|
|
|
|
## 1. Plain PHP, no framework, no build step
|
|
|
|
PHP 8.4 with PDO, server-side rendering, Bootstrap 5. Composer is used for
|
|
autoloading and real libraries; there is no bundler, no transpiler, no
|
|
`node_modules` in production.
|
|
|
|
The reason is maintenance horizon. These are small products that must keep
|
|
working for years with long idle stretches between changes. A build step is a
|
|
second thing that can rot — toolchain versions, lockfiles, a CI runner — and it
|
|
rots silently while the site still appears fine. Editing a `.php` file and
|
|
reloading is a workflow that cannot break.
|
|
|
|
The cost is real and accepted: no type checking beyond PHP's own, no component
|
|
model, more repetition in templates. That trade is worth it at this size and
|
|
would not be at ten times the size.
|
|
|
|
## 2. Comments explain why, never what
|
|
|
|
The code is unusually densely commented, and almost none of it restates the
|
|
line below. A comment earns its place by recording something the reader cannot
|
|
recover from the code: a constraint, a rejected alternative, a bug that a
|
|
previous version had.
|
|
|
|
```php
|
|
// MariaDB does not read cgroup limits -- without this it sizes its buffer
|
|
// pool against all 7.8GB of host RAM and then gets OOM-killed by mem_limit.
|
|
- --innodb-buffer-pool-size=1G
|
|
```
|
|
|
|
That comment is worth more than the line it sits above, because the line looks
|
|
arbitrary without it and someone will eventually "clean it up".
|
|
|
|
Where a decision is a knowing compromise, it is labelled as one. The site
|
|
containers mount their own source read-write so the backoffice updater can run
|
|
`git pull`, and the compose file says so under an **ACCEPTED RISK** heading
|
|
naming the exact exposure. A documented risk is a decision; an undocumented one
|
|
is an accident waiting to be discovered by someone else.
|
|
|
|
## 3. Shared code lives here, once
|
|
|
|
Anything two platforms both need moves into `platform-tools` and is consumed as
|
|
a git submodule. Edit one file, every platform gets it.
|
|
|
|
The failure mode this exists to prevent is genuine and has already happened:
|
|
three sites independently grew their own AES-256-GCM secret helpers with three
|
|
different key derivations, all writing the same `enc:v1:` prefix, all mutually
|
|
unreadable, and nothing surfaced the divergence until something tried to read
|
|
across sites. Copies do not stay identical. They stay *similar*, which is worse,
|
|
because similar code invites the assumption that it is the same.
|
|
|
|
The corollary: **a site must not reimplement something this submodule provides.**
|
|
If the shared version is wrong for one site, fix it here with a parameter, or
|
|
say plainly in the site's own docs why it deviates.
|
|
|
|
**The site declares; the shared code does not guess.** Each site's
|
|
`config/db.php` makes one `platform_connect()` call that states its PDO, base
|
|
URL, environment, column map, roles, session and sysadmin guard. Before it,
|
|
every tool probed for variants — a global `$pdo` or `App\Database`, a
|
|
`base_url()` or a `getBaseUrl()` — and each new tool had to remember them all.
|
|
The same call runs `platform_conformance()`: the database is compared with what
|
|
the shared code expects, and a missing column that would switch a defence off
|
|
without an error fails loudly in development and turns the hub card yellow in
|
|
production (§7).
|
|
|
|
**New tools reach every site without a file on the site.** The shared pages are
|
|
served by one front controller (`public/platform.php?tool=…`) and the shared
|
|
endpoints by another (`public/api/platform.php?endpoint=…`), both from closed
|
|
route tables here. A per-page stub on each of seven sites was seven chances to
|
|
forget one.
|
|
|
|
Submodules have one sharp edge worth stating: `git pull` in a site advances the
|
|
submodule *pointer* but leaves its working tree on the old commit, silently and
|
|
with no error. Every deploy script therefore runs `git submodule update
|
|
--init --recursive` as a separate, mandatory step.
|
|
|
|
## 4. Configuration has two homes, and a rule for choosing
|
|
|
|
`.env` for anything needed to reach or decrypt the database, or to repair the
|
|
site when it is broken. The `system_settings` table for anything an operator
|
|
should be able to change without shell access.
|
|
|
|
The test: *can the system read this after a cold start with an empty database,
|
|
and would I still need it if the app were broken?*
|
|
|
|
This is why `DB_*` and `APP_SECRET_KEY` can never move to the database — the
|
|
first is needed to reach it, the second to decrypt it. And it is why
|
|
`GIT_HTTP_TOKEN` and `HUB_OPS_SECRET` stay in `.env` by choice rather than
|
|
necessity: they are the credentials used to *repair* a platform, and a repair
|
|
channel must not depend on the thing it repairs.
|
|
|
|
Full precedence rules and the secret-encryption contract are in
|
|
[README.md](README.md#settings-and-secrets).
|
|
|
|
## 5. Secrets are encrypted at rest, because backups exist
|
|
|
|
The nightly job keeps many copies of each database. A plaintext SMTP password
|
|
would therefore sit in every one of them, and the backups live on disk next to
|
|
everything else. So secret-valued settings are encrypted, and the key lives only
|
|
in `.env` — never in the database, never in git. A leaked dump alone is not
|
|
enough to recover credentials.
|
|
|
|
Each platform keeps its **own** `APP_SECRET_KEY`. A secret encrypted on hub2
|
|
deliberately does not decrypt on dance: one compromised dump must not unlock the
|
|
estate.
|
|
|
|
Which keys count as secret is decided centrally, by name, in
|
|
`platform_settings_is_secret()` — never by the calling page. The previous
|
|
arrangement had each site keeping its own list, and a credential added later on
|
|
a different screen was simply missed.
|
|
|
|
## 6. Authentication is mechanism, not policy
|
|
|
|
Sessions, password hashing, login throttling, guards and password recovery live
|
|
in `functions_auth.php` and `functions_password_reset.php`. None of it contains
|
|
business logic, so there was never a reason for one version per site — and the
|
|
separate versions had exactly the drift you would expect. dance ran a bare
|
|
`session_start()` with no HttpOnly, no SameSite and no strict mode, never
|
|
rotated the session id on login, kept the password hash in `$_SESSION`, and
|
|
accepted unlimited login attempts. The other three had each solved some of that,
|
|
separately.
|
|
|
|
Two things stay per-site, deliberately:
|
|
|
|
- **The users table.** The columns genuinely differ — fotografia's users carry
|
|
CalDAV credentials, silver's carry a guardian. The shared code adapts through
|
|
the `schema` given to `platform_connect()` (`auth_schema()` underneath),
|
|
declaring only the columns that differ, rather than demanding one shape, because renaming a column
|
|
with 147 references is a migration and adapting is a function call.
|
|
|
|
The same map covers *what you sign in with*. Six sites match on `email`;
|
|
quack matches on `username` and keeps `email` as the address recovery mails
|
|
to, so `auth_schema()` carries a `login` key separate from `email`. It
|
|
defaults to null, meaning "same as email" — a site that never thought about
|
|
it gets the behaviour it already had.
|
|
- **The roles.** `sysadmin`, `admin` and `user` are common and mean the same
|
|
everywhere. Past that the vocabulary is the product's: teacher, student,
|
|
parent, staff, client, formador, aluno. A site registers its own with a rank,
|
|
so
|
|
`auth_at_least('admin')` works without anyone hardcoding a list, and an
|
|
unrecognised role ranks zero — a typo can never grant access.
|
|
|
|
Password recovery goes out over the same SMTP transport as everything else, and
|
|
stores only the SHA-256 of its token: a leaked database dump must not be usable
|
|
to reset anyone's password.
|
|
|
|
## 7. Fail loudly; silence must mean healthy
|
|
|
|
The most expensive bug in this estate's history was not a crash. dance's nightly
|
|
backup ran every night, printed `DB backup FAILED`, exited `0`, and touched its
|
|
success heartbeat. Both monitoring signals — an empty error log and a fresh
|
|
heartbeat — reported health while zero backups existed. It took four
|
|
independent faults to produce that, and every one of them was individually
|
|
survivable; what made it invisible was the exit code.
|
|
|
|
A related habit from the same incident: dance ran `error_reporting(0)` in
|
|
production, which silences the log as well as the screen. Suppress *display*,
|
|
never *logging* — the log is the only thing that can explain a production
|
|
failure after the fact.
|
|
|
|
Two rules come from that:
|
|
|
|
- **An operation that failed must exit non-zero.** Printing the failure is not
|
|
reporting it; nothing downstream reads stdout.
|
|
- **Health signals must be positive, not the absence of a negative.** An empty
|
|
log is ambiguous between "everything succeeded" and "it never ran". Cron jobs
|
|
therefore touch a heartbeat file *only* on real success, and the check is the
|
|
heartbeat's freshness rather than the log's emptiness.
|
|
|
|
The same instinct applies in code: a missing configuration value should be a
|
|
loud error, not a quiet default that happens to be empty.
|
|
|
|
## 8. Expose the minimum
|
|
|
|
Only the reverse proxy publishes ports. MariaDB publishes none and is reachable
|
|
only on an internal Docker network. Each site has its own database user with
|
|
access to its own schema and nothing else.
|
|
|
|
Where a control surface must exist, it is bound to loopback and reached over an
|
|
SSH tunnel rather than firewalled — because Docker writes iptables rules
|
|
directly and a `ufw` rule would not actually apply. That distinction is written
|
|
into the compose file, since the safe-looking alternative is the one people
|
|
reach for.
|
|
|
|
Every container runs with `no-new-privileges`, a memory limit and a pid cap,
|
|
sized to its workload rather than copied between sites.
|
|
|
|
Application code sits outside the document root: the web root is `public/`, and
|
|
`shared/`, `config/` and `src/` are not reachable over HTTP. Anything that
|
|
genuinely must be callable goes through the two front controllers or a thin,
|
|
explicit stub in `public/api/` — and the front controllers only dispatch names
|
|
from a closed list; they never include a path taken from the request.
|
|
|
|
## 9. Migrations are append-only
|
|
|
|
A migration that has been applied is never edited. If it seeded a value that
|
|
later turns out wrong, a new migration corrects it. The applied set is tracked
|
|
in `applied_migrations`, and the schema is exported deterministically to
|
|
`database/schema.sql` so that a git diff on that file is a real changelog.
|
|
|
|
Editing an applied migration produces a database that matches no known state —
|
|
recoverable on the machine where it happened, and nowhere else.
|
|
|
|
**The filename is the order.** The runner globs `database/migration_*.sql` and
|
|
sorts by name, so the numeric prefix is not decoration — it is the only thing
|
|
saying what runs when. quack's were originally named after what they did, and
|
|
alphabetically `migration_multi_deck` (which creates `decks`) sorted *after*
|
|
`migration_anti_hoarding` (which updates it). Thirteen of thirty-five failed
|
|
on a fresh install, and nobody noticed for months because the only database
|
|
that mattered had been built incrementally, in the order the files were
|
|
written. A migration set that cannot rebuild the database from scratch is not
|
|
a migration set; it is a changelog that happens to be executable.
|
|
|
|
## 10. The page works without JavaScript
|
|
|
|
Pages render complete from the server. JavaScript filters, copies, reorders and
|
|
warns — it does not fetch the content. Without it a page degrades to *more*
|
|
information rather than less: a filter simply stays off and everything shows.
|
|
|
|
**Vendoring is the goal, not yet the practice.** hub2 serves every front-end
|
|
asset from itself and says so in its own code. quack does the same by a
|
|
different route — composer `post-install-cmd` downloads Bootstrap, jQuery,
|
|
DataTables and A-Frame into `public/assets/vendor/` at deploy time, and each
|
|
`<script>` falls back to the CDN only when the local file is absent, so the
|
|
fallback is visible in the page rather than assumed. dance, fotografia and
|
|
silver still load Bootstrap and Bootstrap Icons from `cdn.jsdelivr.net` — 12,
|
|
11 and 8 files respectively. So this is a direction, not yet a description:
|
|
new pages should vendor, and the remaining CDN links are debt worth paying
|
|
down, because a third party host is a runtime dependency the container
|
|
isolation cannot protect.
|
|
|
|
## 11. Portuguese for people, English for machines
|
|
|
|
User-facing text is European Portuguese (pt-PT). Code identifiers, commit
|
|
subjects and the infrastructure documentation are English. In-code comments are
|
|
written in whichever language the surrounding file already uses — consistency
|
|
within a file matters more than uniformity across the estate.
|
|
|
|
## 12. Data lives outside the checkout
|
|
|
|
Uploads, logs, caches and backups are bind-mounted from `/srv/data/<host>/` and
|
|
never live inside the git working tree. The checkout should be disposable: a
|
|
`git pull`, a rebuild, or a fresh clone must never risk user data, and `git
|
|
status` on a production site should be quiet enough that real drift stands out.
|
|
|
|
---
|
|
|
|
## When these conflict
|
|
|
|
They will. The ordering that has held so far:
|
|
|
|
1. **Do not lose data.** Backups and migrations win over convenience.
|
|
2. **Do not hide failure.** A loud error beats a graceful degradation nobody sees.
|
|
3. **Do not duplicate.** One implementation, parameterised, beats two that agree today.
|
|
4. **Do not surprise.** A boring, obvious solution beats a clever one, because the reader six months from now is the constraint.
|