15 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 (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). 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 on a white face (.gym-switch-btn--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'; only the gyms form does; AddType image/jxl in .htaccess; GD can't resize it, so it is stored as uploaded; Chrome/Firefox may not display JXL, Safari does).
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.
Dev toolbar partilhada (2026-10-09)
- DEV ONLY, do platform-tools (
functions_dev.php, README "Dev tooling"; produção seguemain, 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 deincludes/footer.php),dev_login.php(na RAIZ: o site não tempublic/;.htaccesstem a regra^dev_login/?$com-f),scripts/dev_accounts.php(platform_dev_accounts_run). - Contas
admin@mail.pt/sysadmin@mail.pt(pw123). O login usaauth_login()+fit_stamp_login_ua()como o login real; CSRF do site = campo_csrf/$_SESSION['csrf_token'](verificação semexit).
Ambiente de dev: moldura e login de um clique (2026-10-13)
includes/env_ribbon.php(moldura às riscas + etiqueta DEV, só fora deAPP_ENV=prod) incluído no<head>deincludes/header.php,includes/auth_header.phpesite/includes/include.php.- A barra de login de dev (
includes/dev_toolbar.php) vai também emincludes/auth_footer.phpesite/includes/footer.php. Botões Cliente / Staff / Admin / Sysadmin (client@mail.ptestaff@mail.ptcriados porscripts/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 emassets/css/backoffice.css(tokens--bo-*, casca, mini, view transitions; carregado com?v=filemtimepor_asset_versioned); já não há<style>inline noheader.php. Prefixobo-nas peças novas;.sidebar,.topbar,.nav-linkmantêm-se. - Cabeçalho fixo
[☰][marca][↗ site público](↗ abrebase_url('/')em nova aba, único sítio), utilizador logo abaixo (avatar com iniciais, nome, crachá de papel, menu Perfil / Terminar sessão). A topbar fica só com o seletor «A editar», o tema e, em telemóvel, o botão que abre a gaveta. Um só manipulador (data-sidebar-toggle, emincludes/footer.php): desktop alterna mini (80px,localStorage bo-sidebar-mini), telemóvel abre o off-canvas. - Menu em
includes/header.phpcom os auxiliares deincludes/nav_helpers.php:bo_nav_link()(ativo por sufixo doSCRIPT_NAME,aria-current, title + aria-label),bo_nav_group_start/end()(grupo recolhível,data-short= 4 letras),bo_dashboard_path(). Ordem: Painel (admin:views/admin/dashboard.php; staff:views/staff/dashboard.php; cliente:views/user/dashboard.php), Gestão (subgrupos Site, Horários, Comercial, Legal), Utilizadores, Sistema por último. Cada ligação mantém o seuis_admin()/is_sysadmin(). - Estado sem flash: script no
<head>põehtml.bo-miniedata-bo-groups; estado dos grupos emlocalStorage bo-nav-groups; scroll da lista emsessionStorage bo-nav-scroll. Grupo novo = chave nova na lista:is(...)de grupos fechados embackoffice.css. - Mini: categorias em texto (
data-short), nunca ícones. Ícones: só Bootstrap Icons de contorno. includes/navbar.php(barra antiga) foi apagado;system_settings.phpjá não o inclui.views/staff/dashboard.php: saudação mínima (o staff ainda não alcança nada emviews/admin/).- Gotcha de dev: a tabela
trial_leadsnão existe na BD de dev, por issoviews/admin/trials/index.phpdá 500 (migração por aplicar; anterior a esta mudança).
Bootstrap re-tematizado (2026-10-13)
assets/css/bs-brand.css(carregado depois do Bootstrap emincludes/header.phpeincludes/auth_header.php) sobrescreve as cores do Bootstrap só com variáveis--bs-*e as variáveis de componente (.btn-*,.form-check-input,.page-link…), claro e escuro. A cor de marca é--fitme-accent(fallback#C2D500); todos os tons derivam dela comcolor-mix();--brand-oné o texto sobre a cor. Secondary/success/danger/warning/info têm valores próprios. Verificação:platform.php?tool=branding(Montra da marca) não deve mostrar nenhum azul por omissão. O site público ainda não carrega este ficheiro.
Pacotes de ícones partilhados (2026-10-13)
config/db.phpregista'icons' => ['packs' => ['bootstrap-icons','fontawesome'], 'default' => 'bootstrap-icons']só seshared/platform-tools/functions_icons.phpexiste. Flaticon NÃO está registado: o site usa PNGs (uploads/modalidades/icons), não a fonte.fit_icon_assets($packs, $fallbackLocal)(config/functions_icons.php) imprimept_icon_assets()ou, sem submódulo novo, os<link>locais. Backoffice (includes/header.php,includes/auth_header.php):bootstrap-icons; site público (site/includes/include.php):fontawesome(FA 6.7.2; as classesfas/far/fabe os nomes FA5 funcionam por aliases).- Sidebar > Sistema: «Pacotes de ícones» (sysadmin) ->
platform.php?tool=icons. - Sem uso quando o submódulo é novo (não apagados):
assets/vendor/bootstrap-icons/,site/assets/fonts/fontawesome/,site/assets/icon-fonts/,site/assets/css/flaticon.css.config/error_handler.phpainda usa bootstrap-icons por CDN (página de erro autónoma).