Vendor platform-tools: everything this site runs is now in one repo
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>
This commit is contained in:
1 parent
0e034232e9
commit
4d40adbf64
67 files changed
+17478
-5
No files matched your search
@@ -0,0 +1,255 @@
|
||||
# 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.
|
||||
|
||||
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
|
||||
`auth_schema()` 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 is a thin, explicit stub in `public/api/`.
|
||||
|
||||
## 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.
|
||||
Reference in new issue
Block a user