# 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 (`is_default`); the other published gyms appear as highlighted buttons (`.gym-switch-btn`) at the right end of the public navbar (`site/includes/navbar.php`). `site/gym_chooser.php` is no longer used. Each gym has a brand colour (`gyms.brand_color`, `#RRGGBB`, migration `migration_20261013_gym_brand_color.sql`; gym 1 = green `#C2D500`, gym 2 = red): set in Gestão → Ginásios, emitted as `:root` overrides by `gym_brand_css()` in `site/includes/include.php`. New components must use `--fitme-accent*` / `--fitme-on-accent`, never a hard-coded colour; the CrossFit nav pill follows the brand colour too. The public navbar's bottom edge is an always-on, slow (14s) sweep in the brand colour (`.header-section::before/::after`, `nav-sweep`, static with reduced motion). On the home page (`$nav_center_logo` set in `site/index.php`, shared by `_open.php`) the header starts with the logo big (240px) and centred on its own row (`.has-center-logo.is-top`, desktop >= 992px only) and docks back to the 150px left logo once `scrollY >= 40` (script in `navbar.php`); the logo width lives in CSS (`.navbar-brand img`), not inline. The nav underline (`.nav-link:after`) is a 4px bar 3px under the text with slashed ends (`clip-path`, like the "in" box of the FitMe in logo). The hero event card (`.hero-event`) is square (no radius on card, badge or button), taller (88px min) and has a defocus gradient: `::after` is a `backdrop-filter: blur()` layer masked left->right, sharp on the left and most blurred on the right; the card itself must not get filter/backdrop-filter/opacity/mask or the blur stops reaching the hero photo. Each gym can have a `logo` (same migration, plus `migration_20261026_gym_logo.sql`: the column was added to the 2026-10-13 file after it had already run on dev, so dev had no `logo` column until 2026-10-26 — never edit a migration that has run, add a new one); the header image and the logo are uploaded in Gestão → Ginásios (`gyms_save_image()` → `uploads/gyms/`, validated by content, SVG through `svg_is_safe()`), not typed as paths; `db_gyms_create/update` skip `brand_color`/`logo` while the migration isn't applied (`_gyms_cols()`); the navbar button for another gym is drawn in that gym's brand colour (`gym_brand_vars()` inline); with a logo (`gym_logo()`: the gym's own, or for the default gym the bundled `logo_black.svg`) the button shows the logo in full colour straight on the frosted navbar, with no face and no darkening on hover (`.gym-switch-btn--logo`: thin brand outline, hover = spinning ring only, no glow, a thin dark `drop-shadow` outline on the image for contrast (a white halo washed out the lime FitMe 3070 logo); name as alt), without one it shows the name. The navbar brand on each gym's page uses that gym's logo too. Uploads (header + logo) also accept JPEG XL (`.jxl`, detected by signature in `upload_image_ext()` when the caller allows `'jxl'`; the gyms form and gallery photos (`db_gallery_image_upload()`) do; `AddType image/jxl` in `.htaccess`; GD can't resize it, so it is stored as uploaded; Chrome/Firefox may not display JXL, Safari does). The public gallery lists its folder by extension (`site/includes/gallery.php`, `site/index.php`: jpg/jpeg/png/gif/webp/jxl; before 2026-10-26 it only listed `*.jpg`, so PNG/WebP uploads never showed). Gallery photos are not in the scroll "hover" (`.is-centered`, main.js) any more; only a real hover lights them. Mensalidades (`.pricing-section::before`) has a slow (26s) drifting brand-colour glow over the metal texture (`pricing-drift`, static with reduced motion). An unpublished gym 404s publicly but previews for staff. ## Imagens / JPEG XL (2026-10-26) Fleet transition to JPEG XL (root `Y:\GitTea\CLAUDE.md`, "Images"); fitme3070 is the reference site. Uploads already accept `.jxl` (gyms header/logo, gallery photos). - **Optimizer** (`config/functions_images.php`, built 2026-10-26): `img_optimize()` hands `.jxl` to `img_jxl_optimize()`, which decodes with `djxl` (or Imagick if it lists JXL), resizes with GD, re-encodes a smaller `.jxl` with `cjxl -d 1.0` when resized, and always writes the fallback **`foto.jxl.webp`** (`.jxl.jpg` if GD has no WebP) beside it. Idempotent (skips when the fallback is newer and within the cap). With no decoder it returns `skipped` («JPEG XL sem descodificador no servidor»), uploads never fail. - **No decoder exists yet** (checked 2026-10-26): the VPS `fitme` image (php:8.4-apache, Debian trixie) has GD only; the dev `webdevops/php-apache:8.2` has Imagick without JXL. Both need `libjxl-tools` (cjxl/djxl) installed; until then `.jxl` uploads have no fallback and show only in browsers with JXL (Safari). After installing, press «Otimizar» on each gallery to backfill. - **Serving:** `img_tag($rel, $attrs)` (``, plain `` otherwise), `img_css_bg($rel)` (hero: `url(fallback)` then `image-set()` with the JXL), `img_compat_rel()` (single-URL places: lightbox link, JSON-LD image, admin preview), `img_list_dir()` (gallery listings, hides `*.jxl.*`). Used in `site/includes/gallery.php` (lightbox shows the tile's `currentSrc`), `site/index.php` hero, `include.php` preload (`type="image/jxl"` for a JXL hero), `seo.php`, navbar logos. `picture { display: contents }` in `style.css` keeps the old img sizing. - **Moves/deletes keep the pair together:** `img_fallback_follow($from, $to='')` in trash (`_trash_image_file`), `gyms_remove_image()`, `events_image_remove()`. - Still open: events/modalidades/team uploads don't accept `.jxl` yet. Every image input in the backoffice shows the current image next to it with a remove control (gyms form does; check events, modalidades, team, galerias, system settings). The navbar only links sections the gym shows **and** has content for (`site_section_has_content()` in `config/functions_site.php`: Galeria = photos in its folder, Modalidades, Equipa = active members (`site_team_active()`, also used by the page), Horário, Mensalidades). The gallery section background is a 7% brand-colour tint, not white. ## 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. ## Dev toolbar partilhada (2026-10-09) - DEV ONLY, do platform-tools (`functions_dev.php`, README "Dev tooling"; produção segue `main`, que não tem o ficheiro: tudo degrada para "não é dev"). - Ficheiros: `config/dev_guard.php` (shim), `includes/dev_toolbar.php` (incluído no fim de `includes/footer.php`), `dev_login.php` (na RAIZ: o site não tem `public/`; `.htaccess` tem a regra `^dev_login/?$` com `-f`), `scripts/dev_accounts.php` (`platform_dev_accounts_run`). - Contas `admin@mail.pt` / `sysadmin@mail.pt` (pw `123`). O login usa `auth_login()` + `fit_stamp_login_ua()` como o login real; CSRF do site = campo `_csrf` / `$_SESSION['csrf_token']` (verificação sem `exit`). ## Ambiente de dev: moldura e login de um clique (2026-10-13) - `includes/env_ribbon.php` (moldura às riscas + etiqueta DEV, só fora de `APP_ENV=prod`) incluído no `` de `includes/header.php`, `includes/auth_header.php` e `site/includes/include.php`. - A barra de login de dev (`includes/dev_toolbar.php`) vai também em `includes/auth_footer.php` e `site/includes/footer.php`. Botões Cliente / Staff / Admin / Sysadmin (`client@mail.pt` e `staff@mail.pt` criados por `scripts/dev_accounts.php`; o painel do staff é `views/staff/dashboard.php`, criado em 2026-10-13). - Auditoria da sidebar: `docs/audits/2026-10-13-fitme3070-sidebar.md` (em Y:/GitTea; aplicada, ver «Barra lateral» abaixo). ## Barra lateral (2026-10-13) - Padrão da frota (`.claude/skills/sysadmin-navbar-audit`). CSS todo em `assets/css/backoffice.css` (tokens `--bo-*`, casca, mini, view transitions; carregado com `?v=filemtime` por `_asset_versioned`); já não há `