22 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). On the home page ($nav_center_logo set in site/index.php, shared by _open.php) the header starts taller (desktop >= 992px) with a second, big (180px) copy of the logo centred (.navbar-brand-hero, aria-hidden); once scrollY >= 40 (script in navbar.php toggles .is-top) the bar shrinks, the big logo fades out and the 150px left logo fades in (opacity crossfade, no movement). The hero gets padding-top: 120px on desktop so its event card clears the tall bar. On desktop the brand is absolute at the left and the links are centred on the full bar width; nav padding and logo width live in CSS, not inline. The other-gym buttons (.gym-switch) sit in the match head: an absolute dark block at the right end of the bar (full bar height, width follows its buttons), its left side cut on the brand slash (22px) with a 5px accent strip and an ember glow (.gym-switch::before/::after); logos inside it are lightness-flipped (invert(1) hue-rotate(180deg)) so dark lettering reads on the dark head. 992-1199px: links shrink (.8125rem, --bs-navbar-nav-link-padding-x: .35rem) so the 3070 menu clears the logo and head. Menu links are .875rem bold. Below 992px the open menu fills the screen under the bar (absolute, 100dvh - 60px, links/buttons centred, opacity fade; not position:fixed because the header's backdrop-filter is its containing block). The bar is liquid glass in Chromium: backdrop-filter: url(#nav-glass) blur(6px) saturate(180%) (#nav-glass in include.php: blurred low-frequency feTurbulence -> feDisplacementMap, bends the page behind it), gated by @supports (-webkit-app-region: drag) (Chromium-only property); Safari/Firefox keep the plain blur. Brand slash template: the cut corners of the "in" box are one rule in style.css ("Brand slash (template)"): class="fx-slash" (both ends) or fx-slash-start (left only), size via --slash (default 4px); pseudo-elements are added to that rule's selector list. Used by the nav and section-title underlines and the CrossFit pill (--slash: 8px, no radius); never paste the polygon again. The hero subtitle (.hero-text h1 span, gym hero_subtitle) is a block strapline at .42em of the h1, tracked out. Logos (.navbar-brand img, .gym-switch-logo) get the same #pt-noise grain filter as h1/h2, and .hero-event-go, .cta-btn, .btn-primary joined the --noise button list. Section-title underlines (.section-title.deco h2:after) use the same slashed 4px bar as the nav underline. The hero event button text uses --fitme-on-accent (white on red). 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.jxltoimg_jxl_optimize(), which decodes withdjxl(or Imagick if it lists JXL), resizes with GD, re-encodes a smaller.jxlwithcjxl -d 1.0when resized, and always writes the fallbackfoto.jxl.webp(.jxl.jpgif GD has no WebP) beside it. Idempotent (skips when the fallback is newer and within the cap). With no decoder it returnsskipped(«JPEG XL sem descodificador no servidor»), uploads never fail. - No decoder exists yet (checked 2026-10-26): the VPS
fitmeimage (php:8.4-apache, Debian trixie) has GD only; the devwebdevops/php-apache:8.2has Imagick without JXL. Both needlibjxl-tools(cjxl/djxl) installed. 2026-10-26: added to the VPS/srv/stacks/fitme.andmore.pt/Dockerfileapt line (backupDockerfile.bak-20261026), active only afterdocker compose build && docker compose up -dthere (the user runs it); on dev it is installed by hand in the running container (apt-get install libjxl-toolsas root) and is lost when the TrueNAS app recreates it. Until then.jxluploads 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)(<picture><source type="image/jxl"><img src=fallback>, plain<img>otherwise),img_css_bg($rel)(hero:url(fallback)thenimage-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 insite/includes/gallery.php(lightbox shows the tile'scurrentSrc),site/index.phphero,include.phppreload (type="image/jxl"for a JXL hero),seo.php, navbar logos.picture { display: contents }instyle.csskeeps 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
.jxlyet. 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.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).