From 22edadf0d70967795db8c7ffd4ba4de58bc534f0 Mon Sep 17 00:00:00 2001 From: andre Date: Mon, 26 Oct 2026 16:55:12 +0000 Subject: [PATCH] chore: stop tracking assistant notes --- .gitignore | 1 + CLAUDE.md | 186 ----------------------------------------------------- 2 files changed, 1 insertion(+), 186 deletions(-) delete mode 100644 CLAUDE.md diff --git a/.gitignore b/.gitignore index b158a05..0ae5d99 100644 --- a/.gitignore +++ b/.gitignore @@ -61,6 +61,7 @@ Thumbs.db .claude/worktrees/ .claude/ +CLAUDE.md # ─── Dependências ─────────────────────────────────────────────────────────── # vendor/ é output do composer install, exceto vendor/PHPMailer: o diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index d3a0587..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,186 +0,0 @@ -# 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 and the CrossFit page (`$nav_center_logo` set in `site/index.php` / `site/crossfit.php`, shared by `_open.php`; the CrossFit video header no longer prints its own logo, only a `visually-hidden` h1) the header starts taller (desktop >= 992px) with a second, big copy of the logo centred (fixed 52px height, so tall logos like the CrossFit one clear the links) (`.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**, a glimpse into the other site: painted in the first other gym's brand (`navbar.php` puts `gym_brand_vars()` of `$other_gyms[0]` on `.gym-switch`), a 5px strip in its accent on the slash, the face a shade darker (`--fitme-accent-dark` to 82% of it mixed with black) with a shadow inside the cut so the strip reads, and its logo in that gym's on-accent ink (`brightness(0)`, plus `invert(1)` with `.gym-switch--light-ink` when the on-accent is white). Paint is shared; geometry per breakpoint: desktop = absolute block at the right end of the bar (full height, width follows its buttons), left side cut on the brand slash (22px); below 992px = a full-width band across the bottom of the open menu, top edge slashed (18px). 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 - var(--nav-h)`, `--nav-h` = the header's real height, set by the `navbar.php` script, 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 text has no glow, only a tight `0 1px 2px` shadow (h1, `.hero-brand`, `.hero-tagline`) and the hero frosted-band blur (`.hero-section .container::before`) is at opacity .8. The hero subtitle (`.hero-text h1 span`, gym `hero_subtitle`) is a block strapline at `.42em` of the h1, weight 600, tracked out. The hero event card's border is solid `--ev-accent`, the same colour as its button (button text stays `--fitme-on-accent`). 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. -- **SEO per gym (2026-10-26):** `site/includes/include.php` builds title/description per page from the gym (`$gym_name`, `$loc_in` = «na Zona …»/«em Mira»); gym 1 keeps hand-written texts, other gyms get generated ones (never the 3070 texts). The gym's own `seo_title` / `seo_description` (Gestão → Ginásios) override the homepage each on its own. Keywords, author, og:site_name, favicon (gym logo if SVG) and the JSON-LD logo (`seo.php`) follow the gym. FitMe in's SEO texts were set in the dev DB only; production needs them entered in Gestão → Ginásios. -- `site/crossfit.php` decides the 404 for gyms without `has_crossfit` before `_open.php`, so it is a real 404 status. -- **Events per gym:** Eventos form «Mostrar em» writes `events.gym_id` (NULL = all gyms; `site_active_events()` filters on it). `site/evento.php` renders the event in its gym's brand/name/info. The hero event button text uses `--ev-on` (white/black computed from the event colour), not the page gym's on-accent. -- Navbar: the desktop bottom sweep line stops before the other-gym head via `--switch-w` (set by `setNavH` in `navbar.php`). The hero blur (`.hero-section .container::before`) has a second diagonal mask, so it is weaker at the top left. The «Marca uma aula experimental» link was removed; `#trialModal` stays (only reopened by `?agendar=erro`). -- The other-gym logo button is a light plate (92% white) with the logo in its own colours (2026-10-26; before: logo inked to the head's on-accent). -- YourFit modalidades use their brand glyphs: `fitme-yourfit-*.svg` were traced (2026-10-26) from the left tile of the wordmarks `uploads/modalidades/icons/yourfit-*.png` (150×33, the only source found) into one even-odd `currentColor` path, so they keep the CSS colour/hover and the transform animations (no stroke draw). - -## 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). The gallery edit page (`views/admin/galerias/edit.php`) takes several files at once (`images[]`, one flash with the count plus each refused file and why, from `db_gallery_image_upload(..., &$error)`; limits shown from `gallery_upload_max_bytes()` / `max_file_uploads` / `post_max_size`, and a POST over `post_max_size` gets its own message). A `.jxl` without fallback shows as a «JPEG XL» placeholder tile with its filename (Chrome can't draw it); with a fallback it uses `img_tag()`. -- **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)` (``, 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, Comunidade = cached posts). The gallery section background is a 7% brand-colour tint, not white. - -## Comunidade: Instagram posts the gym was tagged in (2026-10-26) -Section `comunidade` (after the gallery in `site/index.php`, navbar link «Comunidade», key in `gym_sections()`) shows the latest public Instagram posts where the gym's account was tagged, as a square grid linking to each post; hidden when there are none. `config/functions_instagram.php` mirrors the reviews module: Graph API `GET /{ig-user-id}/tags` (`INSTAGRAM_GRAPH`, v21.0), cache `config/cache/instagram_.cache.php`, lazy refresh under a non-blocking lock, old copy kept on error (retry after 1 h). Thumbnails (`thumbnail_url` for videos, `media_url` otherwise; only JPEGs from `cdninstagram.com`/`fbcdn.net`) are downloaded at refresh time to `uploads/instagram//.jpg` (git-ignored) because Instagram CDN URLs expire; stale ones are deleted. -- **Per gym, no migration:** `system_settings` keys `instagram_user_id_` and `instagram_token_` (encrypted with `secret_encrypt`; `.env` `INSTAGRAM_TOKEN_` wins); `instagram_max` (3–24, default 8) and `instagram_cache_hours` (default 6) are shared. Admin: Gestão → Site → «Comunidade (Instagram)» (`views/admin/instagram/index.php`, edits the gym in the switcher): token, account id, «Encontrar» (lists `me/accounts` Pages with their linked IG account), «Atualizar agora», status + preview. -- **Needs from Meta:** an Instagram professional (business/creator) account linked to a Facebook Page, a Meta app, and a token with `instagram_basic`, `instagram_manage_comments`, `pages_read_engagement` (a Business Manager system-user token does not expire; a long-lived user token lasts 60 days). Private posts and some reels without `media_url` are skipped. -- Gyms whose `sections` column is an explicit list (not NULL) don't get the new section until it is ticked in Ginásios → Secções. Not configured anywhere yet (2026-10-26): tested on dev with fake cached posts only. - -## 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á `