Files
fitme3070.pt/CLAUDE.md
T

20 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 with the logo big (240px) and centred (.has-center-logo.is-top, desktop >= 992px only) and glides back (0.5s on padding-top/left/top/transform/width) to the 150px left logo once scrollY >= 40 (script in navbar.php). On desktop the brand and the other-gym buttons (.gym-switch) are absolutely positioned (brand left, switch right and vertically centred on the whole header, even when tall), so the links are centred on the full bar width; nav padding and logo width live in CSS, not inline. 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 .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. 2026-10-26: added to the VPS /srv/stacks/fitme.andmore.pt/Dockerfile apt line (backup Dockerfile.bak-20261026), active only after docker compose build && docker compose up -d there (the user runs it); on dev it is installed by hand in the running container (apt-get install libjxl-tools as root) and is lost when the TrueNAS app recreates it. 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) (<picture><source type="image/jxl"><img src=fallback>, plain <img> 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 <head> 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á <style> inline no header.php. Prefixo bo- nas peças novas; .sidebar, .topbar, .nav-link mantêm-se.
  • Cabeçalho fixo [☰][marca][↗ site público] (↗ abre base_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, em includes/footer.php): desktop alterna mini (80px, localStorage bo-sidebar-mini), telemóvel abre o off-canvas.
  • Menu em includes/header.php com os auxiliares de includes/nav_helpers.php: bo_nav_link() (ativo por sufixo do SCRIPT_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 seu is_admin()/is_sysadmin().
  • Estado sem flash: script no <head> põe html.bo-mini e data-bo-groups; estado dos grupos em localStorage bo-nav-groups; scroll da lista em sessionStorage bo-nav-scroll. Grupo novo = chave nova na lista :is(...) de grupos fechados em backoffice.css.
  • Mini: categorias em texto (data-short), nunca ícones. Ícones: só Bootstrap Icons de contorno.
  • includes/navbar.php (barra antiga) foi apagado; system_settings.php já não o inclui.
  • views/staff/dashboard.php: saudação mínima (o staff ainda não alcança nada em views/admin/).
  • Gotcha de dev: a tabela trial_leads não existe na BD de dev, por isso views/admin/trials/index.php dá 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 em includes/header.php e includes/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 com color-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.php regista 'icons' => ['packs' => ['bootstrap-icons','fontawesome'], 'default' => 'bootstrap-icons'] só se shared/platform-tools/functions_icons.php existe. 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) imprime pt_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 classes fas/far/fab e 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.php ainda usa bootstrap-icons por CDN (página de erro autónoma).