8.3 KiB
fitme3070 — architecture
Backoffice + public site for a gym (ginásio) chain, plain PHP 8.4, no framework. This file exists because the pattern below was inherited from a CodeIgniter 4 port and had never been written down as a decision (audit 2026-09-24, item 6.1) — it describes what the codebase actually does, kept deliberately, not a design proposal for a rewrite.
Request lifecycle
Every page is its own controller-and-view combined. A page in views/admin/*.php
or site/*.php does, in order: bootstrap (config/db.php + config/functions.php),
an auth guard, read $_POST/$_GET and call model functions, then fall through into
its own HTML. There is no separate template layer and no front controller — 71+ PHP
files are directly reachable by URL (docroot is the repo root; .htaccess denies
partials, shared/platform-tools/*.php, and *.md, but doesn't allowlist).
This is a known, accepted gap, not an oversight — see "What's not done" below.
Introducing a real front controller/router for the app's own pages would touch most of
views/, .htaccess's friendly-URL block, and every hardcoded base_url('views/...')
call; it's a genuine rewrite, tracked separately, not bundled into this doc.
Data layer: config/functions_*.php as models
SQL lives in config/functions_*.php, one file per domain (functions_team.php,
functions_galerias.php, functions_gyms.php, functions_memberships.php, …) plus
functions_db.php for cross-cutting users queries. Functions are named
db_<noun>_<verb>() (db_gyms_create, db_team_update, db_gallery_list). Views
never write SQL directly — if you're adding a query, it goes in the matching
functions_*.php, not inline in the page. (Audit 6.3, closed 2026-09-25 — grep -rl '\$pdo->\(query\|prepare\|exec\)' views/ should always return nothing.)
config/functions.php is the loader: every page requires config/db.php (opens
$pdo, starts the session, connects to platform-tools) then
config/functions.php (requires every functions_*.php in order). Read that file's
own header comment before adding a new one — order matters for a few (auth.php
before anything that calls require_login(), etc.).
Auth
Login/session/roles run on shared/platform-tools/functions_auth.php ('auth' => 'shared' in config/db.php, migrated 2026-09-25 — audit 1.3). config/auth.php keeps
the app's own guard names (require_login(), require_admin(), require_staff(),
require_sysadmin(), is_admin(), etc.) as a thin layer over the shared primitives, so
none of the ~30 call sites in views/ had to change. Role ladder:
sysadmin(100) > admin(80) > staff(40) > client(10, portal-only). "Manter sessão
iniciada" is the shared auth_remember_* module.
The login-attempt throttle (config/functions_throttle.php) stays the app's own,
deliberately not the shared one — see the comment in config/db.php for why (avoids
double-counting the same form with two independent throttles). A throttled login feeds
platform_ip_record_offence(), so repeat offenders get fleet-wide IP-banned.
Soft delete / trash
Deletes are soft (deleted_at/deleted_by) via trash_delete() in
config/functions_trash.php. Gestão → Lixeira lists everything deleted across entity
types and only allows permanent purge after 30 days (enforced in the DELETE's
WHERE, not just hidden in the UI). Adding a new deletable entity means adding it to
trash_types() in that file.
CSRF
csrf_check() (config/functions_csrf.php) refuses any non-POST request outright —
state changes must be POST forms carrying csrf_field()'s token. There is no PRG
(Post-Redirect-Get) convention beyond that; several admin pages still re-render the
form directly on POST rather than redirecting (audit flags this as inconsistent, not
yet unified — tracked separately, low priority).
Audit log
Models call audit_row() before a write and audit_change() after (diffs
before/after automatically); ad-hoc events elsewhere use audit('entity.verb', [...]).
Both come from shared/platform-tools via config/functions_audit.php. Read via
Sistema → Auditoria (platform.php?tool=audit).
Multi-gym
Since 2026-09-25, one install serves several physical gyms: the gyms table, one URL
folder per gym (/3070/, /<postal-code>/), content rows carry gym_id, and the
backoffice has an "a editar" gym switcher (admin_gym_id() /
admin_gym() in config/functions_gyms.php). / and /home 301 to the default gym.
An unpublished gym 404s publicly but previews for staff.
platform-tools
shared/platform-tools is a git submodule (pinned branch = main in
.gitmodules), not vendored — see PLATFORM_TOOLS.md for why it went vendored→submodule→
back, and don't repeat that mistake without reading it first. config/db.php wires it
via platform_connect(): shared auth (above), fleet IP-ban guard (runs on every
request), the sysadmin router (platform.php / api/platform.php — Backups,
Actualizações, Auditoria, IP bans, Geo, Origem das visitas, all one front controller,
just for those pages, not the app's own).
Deploy
Push to master → Gitea webhook → api/platform.php?endpoint=auto_update → the VPS
pulls, syncs the submodule, applies pending database/migration_*.sql files, done in
seconds. No staging branch; the VPS checkout is what's live. deploy.sh (on the
VPS, not in this repo) is the manual fallback. See PLATFORM_TOOLS.md.
Dependencies
composer.json/composer.lock are real (composer install works, composer audit/outdated are meaningful) — added 2026-09-25, matching the fotografia
platform's setup. vendor/PHPMailer/ is the one hand-vendored exception, gitignore-
allowlisted: shared/platform-tools/functions_mail.php hard-codes that exact path
fleet-wide, so Composer's own vendor/phpmailer/phpmailer/ exists in parallel but
isn't what actually gets require_once'd. See ASSETS.md for frontend asset versions.
Both the public site and the backoffice load Bootstrap 5.3.3 (unified 2026-09-25 — the
public site ran 4.3.1 before that); jQuery is one shared copy, not duplicated.
What's not done (tracked, not silently missing)
The full audit and its live status live in a separate report, not in this repo
(CLAUDE_GITEA/audits/fitme3070-auditoria-2026-09-24.html on the maintainer's
machine). As of 2026-09-25: no real front controller (6.2, above), no central listing
registry, no DataTables on backoffice listings, no srcset (deliberate — uploads are
already close to their CSS display size, see the audit item), views/admin/galerias/
still has separate create/edit (deliberately — they're not actually a duplicated pair,
see the audit item). None of these are urgent; none should be "fixed" as a side effect
of an unrelated change — each is its own piece of work.
Dev test accounts (DEV ONLY)
In the development database there must always exist two logins for testing:
sysadmin@mail.pt (role sysadmin) and admin@mail.pt (role admin), password 123.
Ensure/repair them with php scripts/dev_accounts.php (CLI only, idempotent upsert with
password_hash, active flags set). The script fails closed: it refuses unless .env has
APP_ENV=dev AND DB_HOST is 192.168.1.3:3181 or localhost. It must never be run, deployed
or adapted for production, and these accounts must never exist in a production database.
Brand feed (2026-10-09)
api/brand.phpis a one-line stub toshared/platform-tools/api/brand.php(public JSON: name, logos, colours; README "Brand feed"). Declared inplatform_connect(['brand' => ...])inconfig/db.php; colours are the:roottokens (--fitme-accent#c2d500,--fitme-dark#0b0b0d,--fitme-accent-dark#a6b600).logo_light= light-coloured logo (logo_crossfit_white.svg),logo_dark=logo_crossfit.svg.site_logo_rel()(config/functions.php) resolves logos for both the feed andload_email_template(): setting file inuploads/public/logo/(convention), then legacyassets/img/logo/, then the static logo.views/sysadmin/system_settings.phpnow uploads logos touploads/public/logo/(dir created on demand, 0755;uploads/.htaccessblocks script execution); old files inassets/img/logo/still resolve as fallback and in the settings preview. Brand block inconfig/db.phpalready prefers the setting file viasite_logo_rel(), else the static SVGs.