Commit Graph
65 Commits
Author SHA1 Message Date
andreandClaude Sonnet 5 7482dc98f2 docs: atualiza CLAUDE.md e ASSETS.md pós-migração Bootstrap/Composer
Ficaram desatualizados depois da sessão de fixes: CLAUDE.md ainda
dizia "Bootstrap 4+5 em simultâneo" (já não, ver 1e2068b) e listava
"sem srcset"/"sem create/edit unificado" sem as razões reais por trás
de cada um continuar em aberto. ASSETS.md ainda tinha a tabela de
antes da migração (duas versões de Bootstrap, jQuery duplicado,
"sem Composer") — reescrito para o estado real: Bootstrap 5.3.3
único, jQuery uma cópia, Composer ligado (composer.json/lock),
PHPMailer hand-vendored por decisão documentada (não por falta de
tentar).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 22:01:29 +01:00
andreandClaude Sonnet 5 4ddefa6dae fix(navbar): logo esticava a barra (altura 252px) — height:auto em falta
O logo tem style="width: 150px" sem height desde sempre; a auditoria
4.7 acrescentou width="756" height="252" (lidos do viewBox do SVG)
sem adicionar height:auto ao inline style. Sem height:auto, o browser
não escala a altura proporcionalmente ao width CSS — ficou fixa nos
252px do atributo, esticando a barra toda. Confirmado ao vivo:
getComputedStyle(img).height passou de "252px" para o valor
proporcional depois desta correção.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 22:00:45 +01:00
andreandClaude Sonnet 5 30e85b2438 fix(css): tipografia fluida com clamp() no site público (auditoria 4.5)
6 seletores tinham font-size a fazer step-function em vez de escalar:
.hero-text h1 (4 breakpoints), .section-title h2 (3), .page-top-section
h1, .icon-box-item h3/h4 e os dois .schedule_box>div (2 cada). Cada par/
trio de valores por media query passa a um único clamp() na regra base,
com a interpolação (vw) calculada para bater certo com o valor antigo
nas larguras 375/768/1400px+ testadas.

Ficam por mexer (não são apenas escala de tipo): .rounded_frame,
.hero-text .site-btn, .site-btn @479px e .schedule_frame (o font-size
muda junto com padding/width/min-width, é redimensionamento de layout,
não só tipografia); .footer-menu li a (idem, padding muda junto — alvos
de toque da auditoria 4.4); .contact-form/.newsletter-form input (o
valor sobe para 1rem no mobile, não desce — evita o zoom automático do
iOS ao focar); .md-card h3 (faz parte da troca de layout do cartão para
modal fixo, não é só escala); .schedule_box a (o valor de mobile já era
igual ao base, 11px, não havia degrau nenhum).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 21:51:13 +01:00
andreandClaude Sonnet 5 1e2068bea2 feat(css): site público passa de Bootstrap 4.3.1 (EOL) para 5.3.3
Auditoria: "Site público em Bootstrap 4.3.1 (EOL); backoffice em
5.3.3" — o backoffice já usa a 5.3.3 vendorizada (assets/vendor/bootstrap5/),
o site público passa a usar a mesma cópia em vez da 4.3.1.

Levantamento antes de mexer: a folha de estilo própria (style.css)
quase não usa classes utilitárias cruas do Bootstrap (grep confirma
zero ml-/mr-/pl-/pr-/float-left/float-right/custom-select/
custom-control/badge-/sr-only/font-weight-/text-left/text-right —
tudo classes próprias como .site-btn). O que precisava de mudar:

- CSS/JS: assets/vendor/bootstrap/ -> assets/vendor/bootstrap5/
  (bundle, inclui Popper).
- Os 7 atributos data-toggle/data-target/data-dismiss (BS4) ->
  data-bs-toggle/data-bs-target/data-bs-dismiss (BS5): toggle da nav
  mobile, os dois modais (aula experimental, inscrição), o botão de
  fechar do alerta. O seletor em main.js que lê data-target também
  mudou para não parar de encontrar os botões.
- .close -> .btn-close (BS5 renomeou o componente; markup sem o
  &times; interno, que o BS5 desenha via CSS).
- .form-row -> .row (BS4 tinha form-row com gutters menores; BS5 não
  tem, style.css não tinha overrides para form-row, confirmado).
- O apanhado maior: dois <script> inline chamavam
  jQuery('#trialModal').modal('show') ao estilo de plugin jQuery do
  BS4 (para reabrir o modal depois de um erro de submissão) — o BS5
  não regista plugins jQuery nenhuns, isto ficava morto em silêncio.
  Trocado por new bootstrap.Modal(...).show(), a API JS nativa do
  BS5. grep confirma zero outras chamadas .modal()/.collapse()/etc.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 21:47:15 +01:00
andreandClaude Sonnet 5 b837def5a0 refactor(horas): partilhar _form.php entre create e edit (audit 6.8)
Mesmo padrão de horario/: extrai dia/abertura/fecho para _form.php.
create.php formatava open/close sem substr() enquanto edit.php cortava
para "HH:MM" com substr(..., 0, 5) por vir de uma coluna TIME da BD;
unificado na versão de edit.php (sem efeito em create.php).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 21:45:14 +01:00
andreandClaude Sonnet 5 33b7f19d15 refactor(horario): partilhar _form.php entre create e edit (audit 6.8)
Extrai os campos comuns (modalidade, dia, início, fim, sala) para
_form.php. create.php formatava hora_inicio/hora_fim sem substr()
enquanto edit.php aplicava substr(..., 0, 5) para cortar os segundos
que vêm da coluna TIME da BD — unificado na versão de edit.php (mais
defensiva; sem efeito em create.php, cujos valores já vêm em "HH:MM").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 21:45:14 +01:00
andreandClaude Sonnet 5 b8c0422318 refactor(modalidades): partilhar _form.php entre create e edit (audit 6.8)
Extrai os campos comuns (nome, cor, ícone/upload, ordem, descrição, e a
biblioteca de ícones disponíveis) para _form.php, seguindo o padrão de
events/ e memberships/. A diferença genuína entre criar e editar — o
bloco de ícone mostra pré-visualização e checkbox "remover imagem
atual" só quando já existe um registo — fica como um if ($item ===
null) explícito dentro do parcial, tal como o hero_image em events/.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 21:45:14 +01:00
andreandClaude Sonnet 5 22dc14c8c8 refactor(equipa): partilhar _form.php entre create e edit (audit 6.8)
create.php e edit.php tinham os mesmos campos de formulário duplicados
lado a lado. Extrai o markup comum (nome, nascimento, área, estado,
foto, título, aptidões, formações, descrição) para _form.php, seguindo
o padrão já usado em events/ e memberships/. Cada view fica só com a
lógica que lhe é própria (criar vs. carregar registo existente,
upload/validação de foto, título da página, redirect).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 21:45:14 +01:00
andreandClaude Sonnet 5 595a37fd8b fix(site): width/height nas imagens da home (auditoria 4.7)
125 imagens na home, 0 com srcset e 122 sem width/height — o browser
só sabia o tamanho depois de descarregar, o que causa layout shift.

Uploads são redimensionados a um limite de lado maior (functions_images.php),
não a uma dimensão fixa (fotos de equipa vão de 389x512 a 512x512, ícones
de oferta de 128x128 a 256x256), por isso as dimensões não dão para
hardcode: site_img_dims_attr() em functions_site.php lê o ficheiro real
com getimagesize() por pedido, tal como o gallery.php já fazia, e
devolve o par width="" height="" ou "" se o ficheiro não existir. SVG
não passa por getimagesize() (sem decoder embutido) — svg_img_dims_attr()
lê os atributos width/height (ícones fitme-*.svg, 48x48 fixo) ou cai
para o viewBox (logos do navbar, só têm viewBox).

Aplicado a: ícones da oferta, fotos de equipa (card + painel de detalhe),
ícone de modalidade em modalidade_icon_html() e no horário (index.php e
includes/horario.php), logo do navbar.

srcset ficou de fora: as imagens já vêm coartadas para pouco mais do que
o maior tamanho a que aparecem (fotos de equipa a 512px cabidas numa caixa
de no máx. 200px CSS com object-fit:cover; ícones a 128-256px mostrados a
44px) — é a folga normal para ecrãs retina, não desperdício a resolver
com múltiplas resoluções. E o layout é sempre CSS-driven (width/height:
100% + object-fit), por isso um srcset não mudaria o que é descarregado
por variação de viewport, só pouparia bytes nalguns casos — gerar 2-3
tamanhos por upload via GD seria trabalho a mais para o ganho real neste
site. Fica só o fix de dimensões, que é o que resolve o CLS que a
auditoria mediu.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 21:44:29 +01:00
andreandClaude Sonnet 5 a42b6d33c0 fix(css): .schedule-dl-btn a 43.75px por arredondamento -> min-height:44px
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 17:54:14 +01:00
andreandClaude Sonnet 5 6faad68059 feat(backoffice): breadcrumbs no layout partilhado (fecha 6.7)
includes/header.php passa a renderizar Painel > $page_title por
omissão em qualquer página que não seja o próprio painel — cobre as
34 páginas do backoffice de uma vez, sem editar cada uma. Uma página
que queira um trilho mais rico define $breadcrumbs = [['label'=>...,
'url'=>...], ..., ['label'=>'Atual']] antes de incluir o header (o
último item, sem 'url', é a página corrente).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 17:53:15 +01:00
andreandClaude Sonnet 5 90efdc4bc1 fix(css): alvos de toque >=44px nos controlos reais (auditoria 4.4)
Corrige os controlos interativos (não o texto denso em grelha/rodapé,
que fica para outra passagem): .navbar-toggler (40->44 de altura),
.md-close/.td-close (26x28 -> 44x44, box explícita em vez de
dimensionar pelo glifo), .schedule-dl-btn (padding compensado por
margin negativa — o texto sublinhado não engorda visualmente),
.sdf-btn (43->44, min-height), ícones sociais do rodapé (min 44x44).

Medido com getBoundingClientRect() no site ao vivo a 375px antes e
depois de aplicar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 17:52:24 +01:00
andreandClaude Sonnet 5 11b2906488 fix(assets): remove a segunda cópia do jQuery 3.7.1 (auditoria 7.2)
site/assets/js/jquery-3.7.1.min.js e assets/vendor/jquery/jquery-3.7.1.min.js
eram byte-idênticas (md5 igual). O site público passa a carregar a
mesma cópia que o backoffice já usa (assets/ já é servido em
.htaccess); a duplicada sai do git.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 17:45:46 +01:00
andreandClaude Sonnet 5 8300f2c421 docs: CLAUDE.md — decisão de arquitetura escrita (fecha 6.1)
Documenta o padrão atual (páginas = controller+vista, config/functions_*.php
como models, sem front controller próprio) como decisão mantida, não
herança silenciosa do CodeIgniter — mais auth partilhado, trash,
CSRF, audit, multi-ginásio, platform-tools, deploy e Composer. Já
bloqueado por HTTP (.htaccess já negava CLAUDE.md por nome).

Inclui uma secção "What's not done" honesta: sem front controller
real, sem DataTables, Bootstrap 4+5 em simultâneo — para não passar a
imagem de que isto já está resolvido.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 17:44:27 +01:00
andreandClaude Sonnet 5 43e0fc7be6 refactor: move o SQL restante das vistas de multi-ginásio (fecha 6.3)
Os últimos 3 pontos de SQL direto em vistas, que tinham entrado depois
da auditoria original (trabalho de multi-ginásio): gyms/create.php e
gyms/edit.php partilhavam a mesma query da lista de galerias — agora
db_galleries_options() em functions_galerias.php (e simplificada: o
_gyms_has_col() defensivo já não é preciso, galleries.deleted_at
existe desde a migração de 25/09). reviews/index.php gravava
google_place_id à mão — agora db_gym_set_place_id(), separado do
db_gyms_update() geral para não auditar/validar o resto do registo
por uma só coluna.

Zero vistas com SQL direto agora (grep confirma).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 17:41:56 +01:00
andreandClaude Sonnet 5 fa87aabb87 feat(auth): migra login/sessão/roles para o auth partilhado (auditoria 1.3)
config/db.php liga-se com 'auth' => 'shared' em vez de 'own': login,
sessão, papéis e guardas passam a correr sobre
shared/platform-tools/functions_auth.php (auth_attempt/auth_login/
auth_at_least/auth_is/auth_session_start/auth_remember_*) em vez da
lógica local duplicada. Os ~30 ficheiros em views/ que chamam
require_login()/require_admin()/require_staff()/require_sysadmin()/
is_admin()/etc. não mudam — só o que está por baixo dessas funções.

- roles: staff=40, client=10 registados para preservar a hierarquia
  sysadmin>admin>staff>client que os antigos in_array() garantiam.
  `users` já tinha exactamente as colunas por omissão do auth_schema(),
  não foi preciso mapear nada.
- sessão: o bloco manual de ini_set()/session_start() em config/db.php
  foi substituído pela opção 'session' do platform_connect() — mesmo
  nome de cookie (FIT_SESS), mesmo SameSite=Lax, secure/httponly
  passam a ser geridos por auth_session_start().
- «Manter sessão iniciada»: config/functions_remember.php (a
  reimplementação local do selector/validator) foi eliminado a favor
  do módulo partilhado (auth_remember_issue/resume/clear via
  'remember' => true), que já cobria o mesmo desenho.
- auth_attempts: tinha (ip, scope) do throttle próprio da app
  (config/functions_throttle.php), não o `identifier` que o throttle
  partilhado exige. Migração aditiva
  (migration_20260925_shared_auth_attempts.sql) torna a tabela
  conforme sem tocar no throttle da app, que continua a ser o único
  usado para login/registo/recuperação — o throttle partilhado não é
  chamado, para não duplicar a mesma defesa com duas contagens.
- last_login_ua (sem equivalente partilhado) passa a ser gravado por
  fit_stamp_login_ua(), chamado a seguir a auth_login().

Testado ponta-a-ponta num contentor scratch na VPS (site-fitme:8.4,
cópia da BD de produção + migração aplicada): visitante anónimo é
redirigido, password errada dá o mesmo erro genérico e conta para o
throttle (bloqueia à 5ª tentativa, 429), login correcto regenera a
sessão, grava last_login_at/ip/ua, audita "auth.login" e (com
"lembrar-me") emite o cookie am_remember; esse cookie sozinho, numa
sessão nova, repõe a sessão certa com rotação do validator; logout
limpa sessão+cookie+token e audita "auth.logout"; platform.php
(guard=require_sysadmin) e views/admin/dashboard.php respondem 302
sem sessão e 200 com sessão de sysadmin. php -l limpo em todos os
ficheiros alterados. Migração já aplicada em produção (aditiva).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 17:25:54 +01:00
andreandClaude Sonnet 5 ccc644ac0d feat(composer): versiona composer.lock (phpmailer/phpmailer 7.1.1)
Gerado com composer install dentro do contentor reconstruído
(binário composer:2 no Dockerfile). Fixa a versão instalada —
mesma 7.1.1 que já estava vendorizada à mão em vendor/PHPMailer,
confirmando que são a mesma versão. vendor/phpmailer/ e
vendor/composer/ ficam fora do git (gitignored), gerados por
composer install a partir daqui; vendor/PHPMailer/ continua
hand-vendored e tracked (ver .gitignore e o commit anterior).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 17:17:57 +01:00
andreandClaude Sonnet 5 927cc1967d feat(composer): adiciona composer.json e liga o Composer à imagem
Auditoria 6.5/7.1: sem Composer nem composer.json. Segue exatamente o
que a fotografia já faz (mesma imagem base, mesmo Dockerfile) — a
frota não converte o código próprio para classes com namespace (nem
lá isso existe), o composer.json serve para declarar PHP/extensões e
dependências, e para o botão "Atualizar dependências" do backoffice.

vendor/PHPMailer continua hand-vendored e é o que o functions_mail.php
partilhado espera por caminho fixo (vendor/PHPMailer/src/...) — mudar
isso é código partilhado, fora do alcance de uma app só. composer
install vai criar vendor/phpmailer/phpmailer/ em paralelo (igual à
fotografia, "harmless but useless" no comentário do Dockerfile deles).

.gitignore: vendor/ passa a gitignored exceto vendor/PHPMailer/
(allowlist, como o fpdf da fotografia); composer.lock fica versionado.

Dockerfile em /srv/stacks/fitme.andmore.pt/ (fora deste repo) ganhou o
binário do composer:2 e unzip — próximo passo é rebuild da imagem.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 17:16:10 +01:00
andreandClaude Sonnet 5 df1aad8769 refactor: move raw SQL out of views into config/functions_db.php
Auditoria 6.3 apontava dashboard.php (2×), user/index.php e
user/profile.php como as únicas vistas com SQL direto, quebrando a
convenção da frota (config/functions_*.php como camada de models). As
quatro passam a chamar db_users_count_clients(), db_users_count_team(),
db_users_list() e db_user_update_profile() — mesmo SQL, mesmos
parâmetros, só movido para onde o resto do código já vai buscar dados.

Nota: views/admin/gyms/{create,edit}.php e views/admin/reviews/index.php
também têm SQL direto, mas não estavam no âmbito dos "3 vistas" da
auditoria (entraram depois, no trabalho de multi-ginásio) — por tratar
numa próxima passagem.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 17:06:00 +01:00
andreandClaude Sonnet 5 7037077b48 fix(css): anima transform/opacity em vez de layout (auditoria 5.5)
As 5 transições que disparavam layout (não paint) passam a usar
propriedades compositáveis, sem mudar o efeito visual:
- .skip-link: top -100px→8px vira transform: translateY(-108px→0)
- .nav-link:after (sublinhado): width 0→100% vira transform: scaleX(0→1)
  (width fica estático a 100%, position:absolute não afeta o layout à
  volta mesmo com a largura sempre cheia)
- .crossfit-nav-link, .icon-box-item .ib-icon, .sdf-btn: transition:
  all troca por uma lista explícita das propriedades que realmente
  mudam no :hover (background-color/color/border-color/transform) —
  o "all" também escutava mudanças de layout no resize por nada.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 16:55:56 +01:00
andre ff1d0fdc07 Site polish: tighter modalidades spacing, less schedule redundancy, plan section styling
- Modalidades: reduce gap between cards
- Horário: drop the day-title above the grid in single-day mode (the active
  day-filter button already names it); restyle "Guardar horário" as a plain
  link and drop the "(JPG)" suffix
- Mensalidades: give "Escolhe o teu plano" the same dark hammered-metal
  background as the reviews/footer sections instead of the plain light one
2026-09-25 13:01:47 +01:00
andreandClaude Sonnet 5 300655b92a feat(auth): «Manter sessão iniciada» (auditoria: item Médio)
config/functions_remember.php reimplementa o desenho selector/validator
do platform-tools (functions_remember.php) localmente, porque esta app
tem autenticação própria e não passa por functions_auth.php partilhado:
selector em claro e UNIQUE indexa a linha, só o SHA-256 do validator
fica na BD, hash_equals() na comparação, rotação a cada uso, e um
validator que não bate certo revoga TODOS os tokens da conta (sinal de
cookie em circulação).

- Checkbox no login; remember_issue() só quando marcada.
- require_login() tenta remember_resume() antes de mandar para o login.
- logout.php apaga só o token deste browser (não a conta toda).
- auth_set_password() revoga todos os tokens da conta — uma password
  nova não pode deixar um cookie antigo continuar a entrar.

Migração: database/migration_20260925_remember_me.sql (remember_tokens).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 13:01:18 +01:00
andreandClaude Sonnet 5 ee30e6f116 feat(auth): throttle de login esgotado alimenta o IP ban da frota
Cada vez que o throttle local bloqueia (5/min ou 30/h por IP, 10/50
por conta) chama platform_ip_record_offence() — já carregado pelo
platform_connect() de config/db.php. A frota lembra-se entre sessões
de throttle onde o throttle sozinho esquece ao fim de uma hora: 3
vezes em 24h = 1h de ban, 5 = 1 dia, 8 = 1 semana, 12 = permanente
(IP_BAN_LADDER, já em vigor desde 25/09 para todo o site).

Also: o aviso à frota de login falhado (functions_security_events.php)
já funciona — o comentário que dizia "não faz nada até ao refresh do
vendorizado" ficou desatualizado com o submódulo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 12:48:17 +01:00
andreandClaude Sonnet 5 0ea8eb1cde docs(platform-tools): regista a tentativa revertida de apertar permissões
config/cache/ e assets/img/seo/ passaram a volumes próprios
(/srv/data/fitme.andmore.pt/{cache,seo}), mas o checkout continua
664/2775: o deploy-on-push corre como www-data dentro do pedido do
webhook, não como deploy, e 644/2755 partiu a primeira push a seguir
("Permission denied" a criar um ficheiro novo). Documentado para a
próxima vez que alguém for tentar isto outra vez.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 12:47:06 +01:00
andreandClaude Sonnet 5 32a5c24ba2 feat(db): created_at nas 7 tabelas que não tinham (auditoria 3.1.5)
fitme3070_info, galleries, horario, modalidades, system_settings,
website_offer e work_hours passam a ter created_at (DEFAULT
CURRENT_TIMESTAMP, linhas existentes ficam com a data da migração).
Sem created_by de propósito: são o CRUD de um só admin e a audit_log
(ligada 2026-09-25) já regista quem criou/mudou cada linha.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 12:44:07 +01:00
andreandClaude Sonnet 5 a9cda24de3 docs(platform-tools): regista o deploy-on-push ligado (webhook Gitea id 6)
Master passa a deployar-se sozinho a cada push — webhook criado no
fitme3070.pt, AUTO_UPDATE_SECRET no .env do servidor,
system_settings.auto_update_enabled = 1. Fecha "produção sem
deploy-on-push" da auditoria (1.13/resumo Médio).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 12:35:09 +01:00
andreandClaude Sonnet 5 0ecc38774a feat(platform-tools): volta a ser submódulo, agora com branch fixo
Estava vendorizado como cópia simples desde 24/09 (VENDORED.md) depois
de um submódulo anterior ter partido a produção: .gitmodules sem
branch fazia "Atualizar submódulo" cair para refs/remotes/origin/HEAD,
uma ref que um clone só-servidor nunca cria, e a URL era http:// (523
na origem). Corrigidos os dois: branch = main fixo e URL https (via
../platform-tools.git, relativa como a fotografia usa). A terceira
razão do VENDORED.md — "o site é deployado por upload" — não se aplica
ao deploy.sh actual (é git pull), por isso deixou de justificar ficar
vendorizado depois de corrigir as outras duas.

Confirmado antes de converter: a cópia vendorizada era byte-idêntica
ao upstream @12f26fd (única diferença, CRLF→LF em 4 ficheiros de
social/, já notada no VENDORED.md) — nada foi perdido. Submódulo fixo
nesse mesmo commit. deploy.sh (fora do repo, em /srv/stacks/) passa a
correr `git submodule update --init --recursive` a seguir ao pull.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 12:30:26 +01:00
andreandClaude Sonnet 5 35415429e5 fix(platform-tools): renomeia $user em config/db.php para não colidir com o router
O router (platform.php) requer config/db.php e as páginas partilhadas no
mesmo âmbito plano, sem função a separar — o $user = env('DB_USER')
(a string do utilizador da BD) ficava visível mais tarde em
includes/header.php, que só substitui $user quando está vazio
($user ?? current_user() ?? …). O utilizador de sessão nunca entrava,
e $user['role'] rebentava com TypeError "Cannot access offset of type
string on string" em todas as páginas de sistema (Backups, Auditoria,
Actualizações, IP bans, Origem das visitas) — só a Geo escapava por não
usar esse layout. Confirmado em logs/php_errors.jsonl em produção.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 12:25:40 +01:00
andreandClaude Sonnet 5 f066eec7c7 feat(platform-tools): liga platform_connect() e o router de páginas de sistema
O bootstrap só reimplementava pedaços do platform-tools à mão (sessão
endurecida, IP do cliente) sem nunca chamar platform_connect() — sem
router, só 4 das ~9 páginas de sistema estavam ligadas (faltavam IP
bans, Geo, Origem das visitas, Hub), e o log de erro de arranque ainda
dizia "[fotografia bootstrap]", copiado sem adaptar.

Liga-se com 'auth' => 'own': a app mantém as suas próprias passwords e
sessão, porque a auth_attempts local (ip+scope) não tem a coluna
identifier que o throttle partilhado exige — trocar para 'shared'
partia o throttle. Ganha-se à mesma o bloqueio de IP da frota e o
router (platform.php / api/platform.php), com Comunicações e Fila de
mensagens declaradas como sysadmin_tools próprias (substituem o Estado
SMTP partilhado, que já cobrem). Os stubs de Backups/Actualizações/
Auditoria passam a reencaminhar para o router; o menu de Sistema passa
a incluir IP bans, Geo e Origem das visitas.

'conformance' => 'report' para nunca recusar pedidos — só reporta no
health endpoint — enquanto o resto da migração de autenticação fica
por decidir.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 12:16:43 +01:00
andreandClaude Sonnet 5 5cf0f14ad2 fix: correções Baixo da auditoria (escaping, dvh, transições, assets)
Fecha os itens 3.2.2, 3.3.1, 4.6, 5.2, 5.6, 5.7 e 7.4 do relatório de
auditoria de 2026-09-24: escapa saídas de site_img_url()/canonical,
valida identificadores de coluna em db_info_save(), troca 100vh por
100dvh, remove a última cor solta, encurta a transição para ~150ms,
acrescenta decoding=async/fetchpriority=high nas imagens da home, e
documenta as versões vendorizadas em ASSETS.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 12:16:33 +01:00
andreandClaude Opus 5.5 fcf86b60ea perf(backoffice): Summernote só nas páginas com editor
Os ~100 KB do editor e da tradução eram carregados em todas as páginas do
backoffice. Agora o footer só os pede quando existe um textarea.js-wysiwyg.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:43:42 +01:00
andreandClaude Opus 5.5 b202262da0 feat(rgpd): política de privacidade e termos, mapa a pedido, fotos locais, retenção, tema claro/escuro
- Política de privacidade reescrita: tabela de dados/finalidades/fundamento
  (inclui os pedidos de aula, inscrição e eventos com IP), prazos concretos,
  subcontratantes, Google Maps e reviews, cookies. Termos e condições novos
  (/home/termos), no rodapé e no sitemap. Campos [ENTRE PARÊNTESES RETOS]
  e revisão por jurista pendentes antes de publicar no domínio real.
- Google Maps só carrega ao carregar em «Mostrar mapa» (map_click_to_load);
  as fotos dos autores das reviews são guardadas pelo servidor em
  uploads/reviews/ e servidas localmente (review_avatar_url).
- Retenção que a política declara, aplicada pelo cron (04:00): leads com
  mais de 12 meses → lixeira; blocked_submissions e outbox de segurança
  apagados aos 90 dias (trash_retention_sweep, auditado).
- Backoffice e login com tema claro/escuro/automático: data-bs-theme
  aplicado no <head> antes do CSS (sem flash), escolha em localStorage,
  segue o sistema em tempo real, color-scheme e theme-color para os dois.
  Cores próprias passaram a variáveis do Bootstrap. lang="pt-PT".

Testado numa cópia da BD de produção: 72/72 verificações.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:39:05 +01:00
andreandClaude Opus 5.5 04c8c83e66 feat: aulas experimentais guardadas, anti-spam partilhado nos formulários, audit das escritas
- Pedido de aula experimental passa a ficar em trial_leads (por ginásio)
  ANTES do email: sem SMTP o visitante já não vê «erro» e o pedido não se
  perde. Gestão → Aulas experimentais: estados, notas, CSV (auditado),
  apagar para a lixeira; aviso «sem email» quando o aviso não saiu.
- Aula experimental, inscrição e eventos usam o anti-spam do platform-tools
  (honeypot, carimbo assinado com tempo mínimo, prova de JS, limite por IP,
  pontuação de conteúdo) e o bloqueio de IP, em vez de honeypots locais.
  Recusas ficam em blocked_submissions; resposta igual à de sucesso.
  Falha aberto se o módulo rebentar.
- Checkbox de consentimento da aula experimental passa a ser guardada.
- Audit das escritas de conteúdo: criar/editar equipa, modalidades,
  eventos, mensalidades, pedidos de inscrição, galerias, horário (incl.
  arrastar no calendário), horas, contactos e ginásios, com antes/depois
  campo a campo (audit_row/audit_change). Exportação CSV de inscrições
  auditada.
- .table-responsive em todas as tabelas do backoffice.
- Migração: database/migration_20260926_trial_leads.sql (só cria tabelas).

Testado numa cópia da BD de produção: 63/63 verificações HTTP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:33:24 +01:00
andreandClaude Opus 5.5 0e8b53f620 feat(audit): platform-tools atualizado (12f26fd) e registo de auditoria ligado
- shared/platform-tools: ea3616a → 12f26fd (27 commits). Traz o módulo de
  audit (f761d99, o pin da frota), «manter sessão iniciada», o registo de
  segurança da frota (o gancho que o login já tinha passa a funcionar) e a
  correção do IP do cliente encontrada nesta auditoria (último X-Forwarded-For
  / X-Real-IP), promovida ao partilhado. client_ip() da app delega nele.
- config/functions_audit.php: audit() sobre platform_audit(); tabela
  audit_log criada no primeiro uso, segredos ocultados, nunca parte a ação.
- Registado: login, falha de login, bloqueio por excesso de tentativas,
  logout, pedidos de recuperação, mudança/definição de palavra-passe,
  registo e criação de utilizadores, convites, perfil (antes/depois), todas
  as definições (db_update_setting, com valor anterior), cada passo da
  lixeira (apagar, restaurar, eliminar de vez) e ações nas mensagens.
- Sistema → Auditoria (views/sysadmin/audit.php), só sysadmin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:28:13 +01:00
andreandClaude Opus 5.5 5e300fc341 feat: vários ginásios (/3070/ e /<código postal>/), CRUD de ginásios, modalidades após a galeria
Estrutura multi-ginásio: as mesmas secções do site, conteúdo próprio por
ginásio, e cada um numa pasta da URL com o código postal da zona.

- migration_20260926_gyms: tabela gyms (pasta, nome, localidade, publicado,
  principal, secções visíveis, título/imagem do header, SEO, galeria, Place ID,
  página CrossFit) com o FitMe 3070 como ginásio 1 em /3070/; gym_id em team,
  modalidades (o horário segue a modalidade), work_hours, memberships,
  membership_leads e website_offer (tudo o existente → ginásio 1); events com
  gym_id NULL = todos; fitme3070_info passa a uma linha por ginásio (id = gym).
- Site: /<pasta>/, /<pasta>/crossfit, /<pasta>/agendar|inscricao. Ginásio por
  publicar → 404 para o público, pré-visualização (com faixa) para a equipa.
  / e /home → 301 para o ginásio principal (a manter a query); com mais de um
  ginásio publicado, a raiz passa a uma página para escolher. Secções
  desligadas não aparecem nem no menu; menu, canonical, JSON-LD, sitemap,
  formulários, e-mails e reviews (Place ID e cache) são do ginásio.
- Backoffice: Gestão → Ginásios (criar/editar/apagar, secções, e «começar
  com o conteúdo de» outro ginásio — cópias independentes) e o seletor
  «A editar» no topo; equipa, modalidades, horários, horário de
  funcionamento, mensalidades, pedidos, contactos e reviews trabalham sobre o
  ginásio escolhido, e um registo de outro ginásio não abre.
- Modalidades passam para logo a seguir à galeria das instalações.
- Galeria sem cantos arredondados (raio da marca).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:26:40 +01:00
andreandClaude Opus 5.5 2f8f68c61a chore: olho da password em todos os campos, JS morto removido, .gitignore
- assets/js/password-toggle.js, carregado nos dois layouts: liga os botões
  existentes e cria um para cada campo de password que não o tenha (eram 3
  em 12). aria-pressed, aria-label que muda, fora da ordem de tabulação, e
  volta a ocultar ao submeter.
- Removidas 23 bibliotecas/folhas sem nenhuma referência (193 ficheiros),
  incluindo o jQuery 3.2.1 (CVE-2020-11022/11023) que se servia por URL, o
  Splide (a galeria passou a masonry própria em d70f13a), owl, mixitup,
  masonry, magnific-popup, slicknav, jquery.lazy, fullpage, TweenMax,
  circle-progress, progressbar, o template polo e cópias antigas do
  Bootstrap/FontAwesome em site/assets.
- .table-responsive nas listagens de eventos e galerias.
- .gitignore: sem os 5 padrões herdados do fotografia; *.sql/*.dump
  ignorados (exceto migrações e o esquema); .trash/ das imagens; nota sobre
  vendor/ versionado de propósito. database/andmore_fitme3070_dev.sql passa a
  database/schema.sql (só estrutura, 0 INSERT).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:19:54 +01:00
andreandClaude Opus 5.5 895b3d58d7 fix(segurança): lixeira de 30 dias, CSRF em todas as ações, IP real, sessão e uploads
Correções da auditoria de 24/09 (audits/fitme3070-auditoria-2026-09-24.html).

Lixeira
- Tudo o que é importante passa a soft delete (deleted_at + deleted_by):
  equipa, modalidades, eventos, mensalidades, galerias, imagens, horário,
  horas de funcionamento, inscrições em eventos e pedidos de inscrição.
- config/functions_trash.php: um registo por tipo; apagar, restaurar, listar
  e eliminar leem todos dele. Eliminar de vez só com 30 dias na lixeira —
  a condição está no WHERE do DELETE, um POST forjado não apaga nada.
- Gestão → Lixeira (views/admin/trash/index.php): lista única com quem e
  quando, restauro a qualquer momento.
- Imagens de galeria: o site lista a pasta, por isso o ficheiro passa para
  .trash/ (negado pelo .htaccess) e volta no restauro.
- As listagens e o site público filtram deleted_at (horário também filtra
  modalidades apagadas). Migração: database/migration_20260925_trash.sql
  (só acrescenta colunas; tem de ser aplicada antes deste código).

CSRF
- csrf_check() recusa qualquer pedido que não seja POST (405). Antes saía
  sem verificar nada em GET, e dez ações de apagar/restaurar eram GET.
- Os dez handlers passaram a POST com token; links viraram formulários.

Autenticação e sessão
- client_ip(): X-Real-IP / último X-Forwarded-For, só vindo de proxy privado
  com TRUSTED_PROXY. O throttle contava o IP do proxy (limite global).
- Throttle também por conta (hash do email), além do IP.
- Sem oráculo de tempo: login verifica contra um hash de referência quando
  a conta não existe; a recuperação deixa o email na fila.
- Cookie de sessão Secure/HttpOnly/SameSite=Lax e use_strict_mode.
- Definições de sistema só para sysadmin; autocomplete=username.

Servidor e uploads
- .htaccess: nosniff, Referrer-Policy, X-Frame-Options, Permissions-Policy,
  CSP (frame-ancestors, base-uri, object-src, form-action); nega parciais
  _*.php, PHP soltos do platform-tools e *.md; 404 própria.
- Uploads validados pelo conteúdo (finfo/getimagesize); SVG só sem scripts,
  handlers on*=, foreignObject ou hrefs externos.

Testado numa cópia da BD de produção (fitme_audit_test) com um contentor
temporário: 34/34 verificações HTTP ponta-a-ponta.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:16:10 +01:00
andreandClaude Opus 5.5 d70f13aea0 feat(galeria): masonry em JS com lightbox, em vez do slider
- «Conhece as nossas instalações» (e a galeria do CrossFit) passa de slider
  Splide a masonry: layout próprio em JS (sem biblioteca), fotos com a
  proporção original, coluna mais baixa primeiro e algumas a ocupar duas
  colunas (só quando não deixam buraco). Reorganiza ao mudar de largura
  (ResizeObserver + resize) e as fotos entram em cascata ao aparecer.
  Sem JS fica uma grelha CSS.
- Clicar abre a foto num <dialog>: anterior/seguinte, setas, Esc, deslizar.
- O Splide deixa de ser carregado (CSS e JS).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:05:25 +01:00
andreandClaude Opus 5.5 cb0e4540f2 feat(modalidades): 6 ícones em falta, ícones animados, ícone centrado no cartão aberto
- Ícones novos (Bootcamp, YourFit Core, Run3070, Total Workout, On Ramp, Jump)
  do mesmo conjunto; só o LPO continua sem ícone no ficheiro de design.
- Os ícones fitme-*.svg passam a ir embutidos (modalidade_icon_html): cada
  traço tem pathLength=1 e desenha-se ao aparecer, no hover e ao abrir o
  cartão; cada família tem um movimento (levantar, soco, respirar, saltar,
  chama, pedalar, correr). Outros ícones continuam <img>.
- No cartão de detalhe (desktop) o ícone e o nome deslizam até ao centro
  vertical da coluna.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 22:02:58 +01:00
andreandClaude Opus 5.5 42f5919915 fix: telefones em formato internacional (+351) nos links tel: e no JSON-LD
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 21:58:16 +01:00
andreandClaude Opus 5.5 0587be7d14 feat: borda verde a rodar nos planos, textura metálica, contactos numa só fonte
- Mensalidades: ao passar o rato, a borda do cartão ganha um brilho verde num
  conic-gradient que roda (@property --pc-a animável), centrado no cursor; o
  cartão inclina-se e desliza para o rato como os da equipa.
- «O que dizem de nós» e rodapé: textura discreta de metal preto mate (grão de
  pintura eletrostática, brilho difuso, riscas quase invisíveis), em SVG/CSS.
- Contactos: o site lê só fitme3070_info (Gestão → Contactos). Os campos
  contact_*/social_* de Sistema → Definições não eram usados e foram editados
  por engano — saem do formulário, com um aviso a apontar para Contactos.
  site_info() deixa o WhatsApp só com dígitos (wa.me não aceita + nem espaços).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 21:57:15 +01:00
andreandClaude Opus 5.5 21ff1b55ff style: sombra discreta nos cartões de detalhe da equipa e das modalidades
O clip-path da animação cortava a box-shadow, por isso no desktop não havia
sombra nenhuma (e no telemóvel era pesada). Aberto, o recorte passa a ir 40px
para lá do cartão e a sombra é curta e suave, um pouco mais escura na equipa
por causa do fundo cinzento.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 21:51:34 +01:00
andreandClaude Opus 5.5 ee3fe69afb feat(modalidades): faixa rolável, cartão de detalhe como o da equipa, ícones novos
- A faixa de modalidades passa a ser um scroller a sério: toque, trackpad,
  roda do rato, arrastar e setas; continua a deslizar sozinha (~20px/s) e
  pára enquanto alguém interage, retomando 2,5s depois. Loop infinito sobre
  uma cópia do conjunto (a posição é mantida em float — scrollLeft += 0.3
  arredondava para zero).
- Em vez do «Saber mais», o cartão com descrição abre um painel que nasce do
  próprio cartão e estica para o lado (para a esquerda junto à margem
  direita), com inclinação 3D a seguir o rato; no telemóvel voa para o
  centro com o fundo escurecido. O painel vive fora do scroller para o
  overflow não o cortar.
- 14 ícones novos (SVG, traço 3) em uploads/modalidades/icons/fitme-*.svg.
- Títulos dos cartões em <h3>, h2#modalidades-title + aria-labelledby; sem
  ícone não se gera <img> vazio.
- #modalidades entra só com opacidade: o transform deixado pela animação de
  entrada prendia o fundo escurecido (position:fixed) dentro da secção.
- prefers-reduced-motion: os cartões de detalhe (equipa e modalidades)
  ficam fora da regra global que reduzia as transições a 0,01ms — com ela
  (ativa por defeito em Remote Desktop / Windows sem animações) o cartão
  aparecia de repente em vez de esticar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 21:47:17 +01:00
andreandClaude Opus 5.5 8ebf20f52f feat: horário em 3 dias, horário de funcionamento compacto, textura na equipa, estrutura/SEO
- Horário de aulas: cartões mais pequenos e estreitos; a partir de 992px mostra
  o dia anterior, hoje ao centro e o seguinte (a dar a volta à semana), com as
  horas alinhadas e os dias laterais esbatidos. O JPG continua a semana toda.
- Horário de funcionamento: cartões compactos numa <dl>, ordenados por hora.
- Equipa: fundo no cinzento da parede do ginásio com juntas de blocos e grão
  muito discretos (SVG inline).
- Tira o «FitMe 3070» do header e esconde a newsletter.
- Estrutura: um <main> em todas as páginas públicas (skip link aponta-lhe),
  h2 por secção com aria-labelledby, h3 nos itens em vez de h4, sem secções
  aninhadas, alts distintos na galeria e no h1 do CrossFit.
- robots.txt e sitemap.xml gerados com o domínio da instalação; SEO_NOINDEX
  no .env tira uma cópia de staging dos motores de pesquisa.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 21:46:21 +01:00
andreandClaude Opus 5.5 90ee2a93bc feat(equipa): o cartão de detalhe nasce da foto, estica e segue o rato
O cartão abre por cima da própria foto (mesmo sítio, tamanho e inclinação),
endireita-se e estica para o lado — para a esquerda nas fotos da ponta
direita — com o texto a entrar por trás. Aberto, inclina-se em 3D e desliza
uns píxeis na direção do rato; ao sair volta a dobrar-se para dentro da foto.
No telemóvel voa da foto para o centro (FLIP) e volta ao fechar.

- clip-path em px calculados no JS: o Chrome não interpola inset() com
  calc(100% - …) e o recorte saltava em vez de esticar.
- O cartão é posicionado sem transição antes de abrir; senão a transição do
  estado fechado arrancava primeiro e a abertura partia de valores antigos.
- Tira o «Saber mais» do cartão da equipa.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 21:28:32 +01:00
andreandClaude Opus 5.5 1614bf327b feat(reviews): ordenar por recentes/completas, só com foto, cartão com foto grande
A Places API (New) não tem parâmetro de ordenação e devolve no máximo 5
reviews, por isso ordem e filtro aplicam-se a essas 5: relevância (ordem da
Google), mais recentes (publishTime) ou mais completas (texto mais longo), e
opcionalmente só quem tem foto de perfil. Mudar estas opções já não apaga a
cache — só uma nova chave ou Place ID obriga a um novo pedido pago.

Cartão: foto de 84px (ou inicial), nome, data e estrelas grandes em cima;
texto mais pequeno por baixo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:46:00 +01:00
andreandClaude Opus 5.5 f95c9ebc52 feat: contactos editáveis, equipa com título/aptidões, mensalidades e reviews Google
- Contactos: nova página no backoffice (fitme3070_info) e 964 201 137 como
  telefone principal; o telefone secundário só aparece quando existe.
- Equipa: título, aptidões/funções e formações; o cartão mostra foto, nome e
  título e abre um cartão horizontal de detalhe (hover no desktop, toque no
  telemóvel) sem mexer na grelha.
- Mensalidades: cartões editáveis com um destaque, modal de inscrição que
  guarda o pedido (RGPD + estado + campos reservados para o CRM) e avisa o
  ginásio por e-mail; lista de pedidos com estados, notas e CSV.
- Reviews Google via Places API (New), filtradas por classificação mínima e
  em cache configurável; página de configuração com pesquisa do Place ID.
- Assets com ?v=<mtime>: site/assets é servido com cache imutável de 1 ano.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 14:58:57 +01:00
andreandClaude Opus 5.5 8a494d59a5 feat: o login reporta falhas à frota (activo no próximo refresh do platform-tools)
O login desta plataforma é próprio, por isso é daqui que o hub fica a saber
dos logins falhados e dos bloqueios do throttle. A cópia vendorizada do
platform-tools (ea3616a, ver VENDORED.md) ainda não traz
functions_security_events.php, e até lá isto não faz nada.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 16:07:01 +01:00
andreandClaude Opus 5 7097ccbe0b Error page: don't link stylesheets that do not exist
Every error page requested /assets/css/branding-<BRAND>.css and
/assets/css/style.css, and both 404'd: fitme3070 has no assets/css/ directory at
all — the backoffice styles are inlined in includes/header.php.

Cosmetic on its own, but actively misleading. Those two 404s are the loudest
thing in the console at precisely the moment someone opens the console to find
out what broke, and they read like the cause rather than a side effect of the
error page rendering. They sent this investigation after the wrong file twice.

Now each link is emitted only if the file is actually on disk, so a console on
an error page shows the real failure and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 23:27:49 +01:00
andreandClaude Opus 5 4d40adbf64 Vendor platform-tools: everything this site runs is now in one repo
shared/platform-tools was a git submodule. On this branch it is a plain
directory of tracked files, frozen at ea3616abae30106df283e73a3e2f9e5bb0e6ae90
— the same commit fotografia.andmore.pt pins. Contents are byte-identical to
that tree, verified blob by blob.

The submodule caused every deployment failure this site has had:

  - .gitmodules named no `branch`, so the update fell back to
    refs/remotes/origin/HEAD — a ref a plain server-side clone never writes.
    "Atualizar submódulo" failed with "Unable to find refs/remotes/origin/HEAD
    revision in submodule path".
  - Its URL was http://, which the origin answers with 523.
  - This install is deployed by uploading files, and a submodule is invisible
    to that. The tree moved forward while shared/platform-tools stayed on the
    old pin, config/functions_secrets.php died on a missing compat_secrets.php,
    and the whole site — public pages included — went down.

Each of those was separately fixable. Together they mean a second repository
nobody can see from the deployment path, gating whether the first one boots. A
plain directory deploys like every other file and cannot fall out of step.

The cost is that platform-tools updates no longer arrive by bumping a pin; they
are a copy-over-and-commit. shared/platform-tools/VENDORED.md records the
upstream SHA, the procedure, and which four files differ from upstream in line
endings only (social/*.js, .css, functions_publications.php — CRLF upstream,
normalised to LF here; content otherwise identical).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 23:06:23 +01:00