# 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__()` (`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/`, `//`), 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.php` is a one-line stub to `shared/platform-tools/api/brand.php` (public JSON: name, logos, colours; README "Brand feed"). Declared in `platform_connect(['brand' => ...])` in `config/db.php`; colours are the `:root` tokens (`--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 and `load_email_template()`: setting file in `uploads/public/logo/` (convention), then legacy `assets/img/logo/`, then the static logo. `views/sysadmin/system_settings.php` now uploads logos to `uploads/public/logo/` (dir created on demand, 0755; `uploads/.htaccess` blocks script execution); old files in `assets/img/logo/` still resolve as fallback and in the settings preview. Brand block in `config/db.php` already prefers the setting file via `site_logo_rel()`, else the static SVGs.