Commit Graph
21 Commits
Author SHA1 Message Date
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 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 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 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 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 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 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 0e034232e9 Survive a stale platform-tools instead of 500ing the whole site
Deploying the local-SMTP commit took fitme3070.pt down:

    PHP Warning: require_once(.../shared/platform-tools/compat_secrets.php):
    Failed to open stream: No such file or directory
    in .../config/functions_secrets.php on line 20

config/functions_secrets.php is loaded on every request, so a hard require_once
on a submodule file is a site-wide fatal — public pages included — whenever
shared/platform-tools lags the code that needs it. It lagged: the server is
still on 603ff84, which predates compat_secrets.php, because "Atualizar
submódulo" was itself broken (fixed in 3626080, which the site had to be up to
deploy). A missing file should not be able to close that loop.

Three requires hardened, each to the failure the caller can actually report:

  - config/functions_secrets.php: falls back to plaintext passthrough helpers
    and logs loudly. secrets_available() then returns false, which is exactly
    what the Comunicações page already renders a warning banner for — so
    nothing silently pretends to be encrypted while the site keeps serving.

  - config/functions_queue.php (_flush_email): throws instead of fataling. A
    fatal killed the request outright, so flush_message()'s catch never ran and
    the row sat in 'sending' forever with no explanation. It now lands in
    last_error where the Monitor page shows it.

  - views/sysadmin/comms.php: renders "Submódulo desactualizado" naming the
    missing files, rather than a blank 500 on the page you would open to
    diagnose mail in the first place.

Verified by loading config/functions_secrets.php with no shared/ directory
present: the bootstrap completes, the warning is logged once, secrets_available()
is false, and setting_secret()/setting_or_env() still return usable values.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:13:44 +01:00
andreandClaude Opus 5 d5cb50b432 Send mail locally again: message_queue + PHPMailer over SMTP
Outbound email was relayed to the hub's /notify endpoint, so this install had
no SMTP configuration of its own and no way to see what had been sent. Bring
delivery back in-house, following the fotografia.andmore.pt stack rather than
inventing a second shape for it.

  - Submodule bumped 603ff84 -> ea3616a, the pin fotografia already uses. That
    is what provides functions_mail.php (shared transport), functions_settings.php
    (platform_smtp_config + encrypted settings) and compat_secrets.php. Verified
    no function-name collisions with this repo's own config/functions_settings.php:
    platform-tools namespaces everything platform_*.

  - PHPMailer 7.1.1 vendored at vendor/PHPMailer/src/. platform_mail_available()
    looks for exactly this hand-vendored layout before falling back to composer's
    autoloader, so the host needs no shell and no composer install. LGPL-2.1
    license text included.

  - config/functions_queue.php replaced: was a thin facade over the hub relay,
    now fotografia's real queue. Rows are claimed atomically, retried with
    exponential backoff up to max_attempts, and park in 'failed' with the real
    SMTP rejection in last_error.

  - SMTP resolution matches the fleet: .env wins for the SMTP_* constants, while
    the actual send goes through platform_smtp_config($pdo), which is DB-first.
    That ordering is deliberate -- the database is the half an operator can
    change from the backoffice without shell access. Empty SMTP_HOST falls back
    to PHP mail().

  - Sistema -> Comunicações (views/sysadmin/comms.php): credentials plus a live
    SMTP connection test and a verdict on what would happen to a message sent
    right now. Secret-valued settings are write-only in the form and encrypted
    at rest (AES-256-GCM, key from .env, never in the DB dump). Which keys count
    as secret is platform_settings_is_secret()'s decision, not a list kept here.

  - Sistema -> Mensagens (views/sysadmin/messages.php): the queue monitor.
    Filters, per-message detail with last_error, and retry/cancel/archive.
    Retry flushes inline so the operator sees the outcome immediately.

  - cron.php drains the queue every 5 minutes, serialised with a lock file, and
    runs retention once a day. Over HTTP it answers 404 until CRON_TOKEN is set
    to something other than replace_me. Deliberately not a copy of fotografia's
    cron: the session-reminder, download-digest and photo-archive steps have no
    modules here and would be dead code.

  - config/functions_notify_client.php deleted, and ANDMORE_ID_URL with it --
    the hub relay was its only caller.

Verified without a database: PHP lint across the tree; PHPMailer builds a full
MIME message with RFC 2047 UTF-8 subject encoding from the three vendored files;
platform_mail_available() finds them; platform_mail_make() returns a correctly
configured mailer; the diagnosis states report Operacional / Não configurado.
NOT verified: the settings encryption round-trip -- this machine's PHP has no
openssl extension, so platform_secret_key() returns null and values pass through
in clear. The code degrades safely (stores the value rather than losing it) and
the Comunicações page warns when the key is unavailable.

Deployment: apply database/migration_20260913_message_queue.sql, set SMTP in
Comunicações or .env, set APP_SECRET_KEY, and schedule cron.php.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:08:38 +01:00
andreandClaude Opus 5 aec7b47820 Replace Andmore ID SSO with local login
fitme3070.pt delegated all authentication to hub.andmore.pt: views/auth/*
were bounce stubs, and because this domain is outside the .andmore.pt cookie
scope the real handoff was a one-time HTTPS ticket redeem. Bring identity back
in-house — passwords live in users.password_hash, which the SSO migration
never dropped.

Removed: config/sso.php, IDENTITY_MODE, every SSO_* constant and env key,
identity_is_federated() and the cookie/ticket paths in the auth views and the
public-site bootstrap. ANDMORE_ID_URL stays — it is still the hub /notify
endpoint for outbound email/SMS, which the reset mails ride on, and has
nothing to do with authentication now.

Added, ported from the fotografia.andmore.pt sibling rather than invented:

  - auth_login() with transparent password_needs_rehash upgrades and the
    last_login trail; auth_register_client(); auth_set_password(); the
    single-use 2h reset tokens.
  - Real login / register / forgot_password / reset_password pages on a new
    anonymous shell (includes/auth_header.php). includes/header.php renders
    the backoffice sidebar and a logout button, which is wrong for a visitor
    who is not signed in yet.
  - safe_return_path(), so a crafted ?return_to= cannot bounce a freshly
    authenticated user off-site.
  - Per-IP throttling on login, register and reset-request. Successes count
    too, so a guessed password cannot be parlayed into stuffing the rest of
    the user list from the same IP.
  - views/user/set_password.php: admin override for anyone whose email is
    undeliverable. Prefers mailing an invite link so the backoffice never
    handles the password. A plain admin cannot act on a sysadmin, and nobody
    can use it on themselves — self-service goes through profile.php, which
    demands the current password so a hijacked session cannot lock the owner
    out.

Accounts provisioned during the SSO era have password_hash NULL. They get in
via the reset email, or by registering again with the same address:
auth_register_client() claims a passwordless row, but only a plain `client`
one, so knowing a staff address does not let anyone self-assign the role.

users.andmore_id is deliberately left in place. No code reads it any more, but
it records which central identity each row was linked to.

Deployment: apply database/migration_20260913_local_auth.sql (password_resets
+ auth_attempts) in the SAME sysadmin session that pulls this commit — the
forgot-password flow needs that table, and after the pull a login requires a
local password.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 21:00:57 +01:00
andreandClaude Opus 4.8 03102cf1a9 Fix horário calendar save: surface real error, validate sala
The save AJAX endpoint swallowed DB exceptions behind a generic
"Erro ao guardar" with no logging, making failures undiagnosable.
Now log the real exception (same sink as the global handler) and
echo the detail back in dev.

Also validate `local` against the allowed sala set instead of only
its length, so an out-of-enum value returns a clean 422 instead of
a 500 (horario.local is a strict ENUM).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 12:16:11 +01:00
andreandClaude Opus 4.8 9ad0c2b763 SSO: redeem one-time ticket on return from the hub
fitme3070.pt is off the .andmore.pt cookie scope, so am_sso never arrives
and login never provisioned the user. Redeem the ?sso_ticket=… the hub now
appends to the return URL instead.

- sso_redeem_ticket(): atomic single-use burn of sso_tickets in the central
  DB, bound to PLATFORM_SLUG, then provision the local row + FIT_SESS session.
- Extract _sso_provision_local() shared with the cookie path.
- views/auth/login.php redeems the ticket before the cookie fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 22:17:40 +01:00
andre 3e3382df57 idk 2026-06-12 21:20:23 +01:00
andreandClaude Opus 4.8 e68d357177 Initial import of fitme3070.pt platform
Plain-PHP fleet platform with id.andmore.pt SSO. Wires shared/platform-tools
as a submodule (platform_update, backups, git/migration tools) like the other
platforms.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 10:44:58 +01:00