Author SHA1 Message Date
andre bab6eb87bf feat(site): slow brand-colour sweep along the bottom of the navbar 2026-10-26 09:54:32 +00:00
andre 3ffcf0fe38 fix(gyms): migration adding gyms.logo where the edited brand-colour migration had already run 2026-10-26 09:48:53 +00:00
andre 9c28be314b feat(gyms): current logo thumbnail next to the upload input; updated help text 2026-10-26 09:46:13 +00:00
andre d445ba64b9 feat(gyms): show gym logos in navbar buttons and brand; accept JPEG XL uploads 2026-10-26 09:35:46 +00:00
andreandClaude Sonnet 5.5 a85bec6851 fix(gyms): saving no longer fails before the brand-colour migration; upload header image and logo instead of typing paths
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 19:12:25 +01:00
andreandClaude Sonnet 5.5 965a123834 style(site): softer, denser h1 shadow (more blur and opacity)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 19:07:55 +01:00
andreandClaude Sonnet 5.5 68ac78097a style(site): finer grain on the hero frost
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 19:07:21 +01:00
andreandClaude Sonnet 5.5 3d30f09bb3 style(site): green frost tint on the hero band, discreet text shadow on the h1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 19:07:01 +01:00
andreandClaude Sonnet 5.5 938347779c style(site): hero blur as a horizontal frosted band (blur 100->0 outward, grain veil)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 19:06:17 +01:00
andreandClaude Sonnet 5.5 b2b736734e style(site): gradient blur behind the hero text, fading outward
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 19:05:02 +01:00
andreandClaude Sonnet 5.5 a2219ebd63 style(site): apply tuned grain from the lab (freq 1.98, 1 octave, overlay on buttons, 155px tile)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 19:01:18 +01:00
andreandClaude Sonnet 5.5 9e3eee227b chore(dev): lab - label the grain sliders as opacity
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:59:49 +01:00
andreandClaude Sonnet 5.5 72cf3dad98 feat(dev): lab - full grain controls (type, frequency, octaves, seed, contrast, softness, tile, blend, mono) + generated CSS
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:57:21 +01:00
andreandClaude Sonnet 5.5 e5f4fd8fc9 feat(dev): style lab page with sliders for the visual effects (dev only)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:55:26 +01:00
andre 18676ad6d0 feat(icons): load Bootstrap Icons and Font Awesome from the shared platform-tools packs
Register the icons packs in platform_connect, add fit_icon_assets() with local
fallback, and add a sysadmin 'Pacotes de icones' link to platform?tool=icons.
2026-10-13 18:55:12 +01:00
andreandClaude Sonnet 5.5 781a3a324e style(site): subtler noise (buttons slightly, headings 50%)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:52:15 +01:00
andreandClaude Sonnet 5.5 18556ca8b9 style(site): noise texture on buttons and h1/h2 lettering
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:51:17 +01:00
andreandClaude Sonnet 5.5 3d2a696162 fix(site): restore CSS lost in 6c14e64; gallery click-and-drag; no link underline + colour/glow hover; brand-colour CrossFit pill; lighter trial link; no gloss on team photos
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:49:53 +01:00
andreandClaude Sonnet 5.5 6c14e644b1 feat(site): auto-scrolling gallery strip (full-height + 2x2), centre-of-screen hover state, trial link as text under the hero button
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:44:31 +01:00
andreandClaude Sonnet 5.5 99c28f04b4 style(site): gallery tint is a brand gradient fading to transparent
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:41:03 +01:00
andreandClaude Sonnet 5.5 4b6329f529 style(site): YOURFIT icons keep their aspect ratio (wide box), black until hover
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:40:27 +01:00
andreandClaude Sonnet 5.5 356554db42 style(site): spinning brand edge + glow on open detail cards (team, Modalidades)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:39:24 +01:00
andreandClaude Sonnet 5.5 b884749a4e style(site): photos 10% desaturated with a discreet brand tint until hover
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:38:47 +01:00
andreandClaude Sonnet 5.5 f9ec3644c8 style(site): spinning-edge hover on Modalidades icon boxes and gallery tiles
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:37:59 +01:00
andreandClaude Sonnet 5.5 da90c5c681 style(site): square team photo frames (no border radius)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:35:43 +01:00
andreandClaude Sonnet 5.5 9017c849f6 style(site): team photo frames use the spinning-edge hover
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:35:16 +01:00
andreandClaude Sonnet 5.5 83398eb7f5 style(site): slower button lift transition
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:34:21 +01:00
andreandClaude Sonnet 5.5 9588f902c6 style(site): button hover like the Mensalidades cards (spinning brand edge, dark face, white text)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:33:27 +01:00
andreandClaude Sonnet 5.5 8e889adeb9 revert(site): drop the 3D neon button/photo-frame hover effect
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:31:34 +01:00
andreandClaude Sonnet 5.5 f28b2b8482 style(site): lighting model for the 3D neon buttons (AO, graded walls, bloom, shaded face)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:29:34 +01:00
andreandClaude Sonnet 5.5 4b99991c11 style(site): neon extrusion on the team photo frames
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:26:43 +01:00
andreandClaude Sonnet 5.5 db28427c1e style(site): single neon blink after a moment of hover
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:24:57 +01:00
andreandClaude Sonnet 5.5 d40f6d8b2d style(site): neon flicker on button hover
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:24:22 +01:00
andreandClaude Sonnet 5.5 348e7d6848 style(site): flatter wall gradient, softer box-shadow glow on buttons
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:24:01 +01:00
andreandClaude Sonnet 5.5 e52709b926 style(site): stronger neon on button hover (inner glow, halo, neon lettering)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:21:44 +01:00
andreandClaude Sonnet 5.5 7bf2a8542e style(site): angled 3D button (bottom + right walls), slower colour fade on hover
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:20:01 +01:00
andreandClaude Sonnet 5.5 acac3a191a style(site): pushable 3D neon buttons (lifting face + ::before wall, spring curves)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:18:17 +01:00
andreandClaude Sonnet 5.5 251644c393 style(site): button hover wall 4px with graded sides and lit bevel
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:13:20 +01:00
andreandClaude Sonnet 5.5 2a4596e7e3 style(site): neon lives in the extruded sides, plain face (no double outline)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:12:35 +01:00
andreandClaude Sonnet 5.5 9a42c91471 style(site): button hover as one continuous extruded wall, no ghost copy
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:11:47 +01:00
andreandClaude Sonnet 5.5 2a66f74275 style(site): neon outline on button hover face
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:11:06 +01:00
andreandClaude Sonnet 5.5 a94287e3d6 style(site): near-black button hover keeping 10% brand colour; no underline on buttons
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:10:09 +01:00
andreandClaude Sonnet 5.5 b103aeae58 style(site): white text on a deeper brand colour on button hover
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:06:43 +01:00
andreandClaude Sonnet 5.5 8ede31ce3a style(site): subtler 3D neon button hover (3px depth)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:06:18 +01:00
andreandClaude Sonnet 5.5 58d5199883 feat(gyms): gym logo field; other-gym navbar button uses that gym's brand colour and logo
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 17:45:17 +01:00
andreandClaude Sonnet 5.5 32f8d7a613 feat(branding): re-theme Bootstrap to the brand colour; 3D neon button hover; hide Mensalidades link without plans
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 17:16:50 +01:00
andreandClaude Sonnet 5.5 4b2823d1e5 feat(gyms): per-gym brand colour; CrossFit pill follows it
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 17:08:30 +01:00
andreandClaude Sonnet 5.5 716eec9f6d feat(site): root opens the main gym; other gyms as navbar buttons
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 17:06:59 +01:00
andreandClaude Sonnet 5.5 aa55d44406 feat(sidebar): fleet sidebar standard (header row, user in sidebar, collapsible groups, mini mode, no-flash transitions)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 16:56:46 +01:00
andreandClaude Sonnet 5.5 b73c23bf55 feat(dev): environment ribbon and one-click dev login on the public site and auth pages
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 16:56:40 +01:00
andreandClaude Sonnet 5.5 5f64ae57db feat(dev): adopt shared platform-tools dev toolbar and one-click dev login
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 22:10:58 +01:00
andreandClaude Sonnet 5.5 8edc37e43a chore(dev): dev_accounts script (fail-closed, dev only)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 21:58:06 +01:00
andreandClaude Sonnet 5.5 63adaf19ce chore: bump shared/platform-tools to 1c51c84 (brand feed)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 21:08:59 +01:00
andreandClaude Sonnet 5.5 4446224c19 feat(settings): save uploaded logos to uploads/public/logo
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 19:41:01 +01:00
andreandClaude Sonnet 5.5 79a0047f29 feat(brand): adopt platform-tools Brand feed; logo path convention in emails
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 15:37:31 +01:00
andreandClaude Sonnet 5.5 f2a9c5181b feat: mapa carrega logo no site; sysadmin edita utilizadores (incl. promover a admin)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 12:39:36 +01:00
andreandClaude Sonnet 5 4bf177289a feat(site): horário — esconde botão ao abrir, setas no desktop, swipe no telemóvel
- "Ver Horário das aulas" desaparece assim que o acordeão abre (clique no
  próprio botão ou o link "Horário" da navbar a abrir por JS) — deixa de
  ficar um botão grande por cima da grelha já visível.
- Setas ‹ › junto ao dia em foco também no desktop (vista de 3 dias): só o
  dia central as mostra, os laterais continuam sem elas (já são clicáveis
  diretamente).
- Deslizar no telemóvel/tablet muda de dia, com o mesmo efeito das setas —
  só reage a arrastos maioritariamente horizontais, para não roubar o
  scroll vertical da página.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 11:18:51 +01:00
andreandClaude Sonnet 5 b1e88781b8 feat(backoffice): DataTables cliente-only nas 11 listagens com CSV
Fecha o item 6.9 da auditoria (sem server-side/AJAX — todas as tabelas
estão bem abaixo de 60 linhas): ordenação, pesquisa, paginação, pt-PT
e Responsive (colapso em ecrãs estreitos), reaproveitando o botão
"Exportar CSV" já existente.

DataTables 2.3.4 + Responsive 3.0.6 vendorizados em
assets/vendor/datatables-2.3.4/ (mesma versão/decisão do hub.andmore.pt),
nunca de CDN. Tradução pt-PT oficial inline em assets/js/datatables-init.js,
que expõe initDataTable(selector, opts) — cada página dá um id estável à
tabela e marca a coluna de Ações como orderable:false/searchable:false.
Os vendor scripts só carregam nas 10 páginas que os usam (injeção dinâmica
depois do DOMContentLoaded, como o loader do Summernote), não no
header/footer partilhado.

views/admin/trials/index.php ficou de fora de propósito: cada linha tem
um <select> de estado e um <textarea> de notas editados in-line antes de
"Guardar" — um redraw da DataTable a meio dessa edição (ordenar, paginar)
arrisca perder ou baralhar o que ainda não foi submetido.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 10:34:09 +01:00
andreandClaude Sonnet 5 4222b8f2e0 feat(backoffice,site): Sistema consistente com a frota; horário como acordeão
- Sidebar do backoffice: Backups/Auditoria/IP bans/Geo/Origem das visitas/
  Atualizações deixam de ser um link cada (enumerados à mão) e passam a um
  só link "Sistema" para o índice partilhado do platform-tools
  (platform.php?tool=index), como a fotografia/design/dance já fazem — uma
  ferramenta nova no platform-tools passa a aparecer sozinha, sem link novo
  a escrever aqui. Definições, Mensagens e Comunicações continuam com link
  próprio (são páginas do fitme, não do router).
- Horário de Aulas (home e CrossFit, mesmo componente partilhado): a grelha
  fica fechada por omissão atrás de um botão "Ver Horário das aulas"
  (Bootstrap collapse); clicar em "Horário" na navbar (ou abrir a página já
  com #horario no URL) abre-a de imediato em vez de só rolar até uma secção
  fechada.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 10:14:46 +01:00
andreandClaude Sonnet 5 b5bbbd3b5f feat(site): mapa dos contactos a toda a largura
O mapa deixa de ser um cartão comprimido dentro da coluna de Informações
(220px) e passa a ocupar a largura toda numa linha própria, por baixo da
grelha Informações + Formulário (480px, mesma altura do .map de outras
páginas). A coluna de Informações fica só com o texto (Localização,
Telefone, E-mail, Horário de Funcionamento).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 09:04:53 +01:00
andreandClaude Sonnet 5 7e2798d0b7 feat(site): informações e contactos sem ícones, horário em texto; entra na frota
- Informações e contactos: os ícones fas ao lado do texto saem (desalinhavam
  em layout centrado — fa-envelope é mais largo que os outros e o texto ficava
  a começar em posições diferentes). Horário de Funcionamento deixa de ser
  cartões (dt/dd com barra escura) e passa a texto simples centrado, uma
  linha por dia, a condizer com o resto da coluna.
- Textarea "Mensagem" deixa de esticar até à base do mapa (chegava a 763px
  de altura, vazio) — volta ao min-height:150px + resize:vertical do CSS.
- CrossFit: removida a secção "A nossa Localização" duplicada, agora que a
  página tem a sua própria secção de Contactos com mapa incluído.
- Reviews: "Ver todas no Google" passa a link de texto simples (sublinhado);
  "Deixa a tua review" mantém-se o botão em destaque.
- api/health.php, api/hub_ops.php, api/hub_pair.php: stubs de uma linha para
  o platform-tools (mesmo padrão da fotografia/design/dance) — preparam a
  entrada do fitme3070 na Frota do hub.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 08:46:49 +01:00
andreandClaude Sonnet 5 65182eecd6 feat(site): horário sem barra de dias em cima, CrossFit ganha contactos
- Horário de aulas (home e CrossFit, mesmo componente partilhado): removida
  a barra de botões Seg/Ter/Qua/Qui/Sex/Sab acima da grelha — o próprio
  cabeçalho do dia (já existente) passa a ser sempre visível (antes só em
  desktop, 3 dias) e ganha setas ‹ › ao lado do nome do dia para mudar de
  dia em ecrãs de telemóvel/tablet. No desktop (3 dias lado a lado) as
  setas ficam escondidas — clicar no dia lateral já muda o dia em foco.
  CSS/JS morto do botão-filtro removido.
- Informações e contactos extraído para site/includes/contactos.php,
  partilhado pela home e pelo CrossFit — a página CrossFit ganha a mesma
  secção de contactos (localização, telefone, e-mail, horário de
  funcionamento, mapa, formulário) em vez de só linkar de volta à home.
  Hero e restantes secções do CrossFit não foram tocados.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 08:19:12 +01:00
andreandClaude Sonnet 5 5478952171 feat(site): secção Reviews em branco, horário de funcionamento nos contactos
- .reviews-section deixa de repetir o fundo metálico escuro da secção de
  mensalidades logo a seguir — passa a branca, restaurando a alternância de
  fundos ao longo da página. Textos e o botão "Ver todas no Google"
  recuperam as cores por omissão (escuras sobre branco); os cartões de
  review mantêm o próprio fundo escuro.
- Horário de Funcionamento deixa de ser secção própria e passa a viver
  dentro de "Informações" (Contactos), como mais um bloco de info, antes do
  mapa.
- Ícones de Localização/Telefone/E-mail alinhados: fa-envelope é mais largo
  que fa-map-marker-alt/fa-phone-volume, o que desalinhava o texto ao lado
  (4.8px de deslocamento, medido no browser); os ícones ganham uma largura
  fixa comum.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 02:39:21 +01:00
andreandClaude Fable 5 3f2c505c17 feat(rgpd): pedido de acesso/apagamento de dados + processo escrito (item 9.5)
- migration_20260925_rgpd_requests.sql: tabela rgpd_requests (email, tipo,
  estado, consentimento).
- config/functions_rgpd.php: rgpd_find() (acesso, art.º 15) e rgpd_erase()
  (apagamento por anonimização, art.º 17) sobre membership_leads/trial_leads/
  event_leads, + CRUD dos pedidos.
- Política de Privacidade: formulário público (POST + CSRF + anti-spam) para
  pedir acesso ou apagamento, e o processo escrito (resposta em 30 dias).
- Backoffice: página "Pedidos RGPD" (nav + index) para exportar os dados de um
  email (CSV, audit) ou anonimizá-los (POST + CSRF, audit).

O email está desligado, por isso os pedidos ficam guardados (como as leads),
não dependem de SMTP. Texto legal com placeholders — rever com jurista antes
do go-live (como já constava na Política).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-25 23:48:18 +01:00
andreandClaude Fable 5 116817e2de feat(backoffice): exportação CSV nas listagens, com audit
Novo helper csv_export() (config/functions.php): CSV Excel-friendly (BOM UTF-8
+ ';') e cada exportação fica no audit_log (RGPD: exportar dados pessoais tem
de ser rastreável). Botão "Exportar CSV" em todas as listagens do backoffice:
equipa, eventos, galerias, ginásios, horário, mensalidades, modalidades,
horário de funcionamento e lixeira (pedidos de inscrição e aulas experimentais
já tinham). Fecha a metade "exportação com permissões e audit" do item 6.9
(a única parte que o relatório considera ganho real à escala atual).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-25 23:43:41 +01:00
andreandClaude Fable 5 33158381bb chore(fonts): FontAwesome de 18 MB para 264 KB (remover build/source)
O diretório vendorizado incluía ficheiros de build/fonte que nunca são
carregados em runtime: js/, less/, scss/, metadata/, sprites/, svgs/, os
formatos de fonte não-woff2 (eot/svg/ttf/woff) e 13 folhas CSS redundantes.
Ficam só o que o site carrega de facto: css/all.css + 3 webfonts woff2
(solid/brands/regular). Sem alterar a fonte em si — nenhum ícone muda.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-25 23:29:53 +01:00
andreandClaude Fable 5 6d7b6456a9 fix(responsivo): elimina overflow horizontal de 3px em mobile
As regras `.container>.row` e `.contact-form .row` usavam margem negativa de
-15px (herança do Bootstrap 4), mas o container do Bootstrap 5 tem padding de
12px — sobrava 3px de overflow horizontal a 375px (scrollWidth 378 vs 375).
Passaram a -0.75rem (o gutter real do BS5), anulando exatamente o padding.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-25 23:25:23 +01:00
andreandClaude Fable 5 c5c4a818f2 fix(contraste+fontes): verde de texto legível em fundo claro e Roboto auto-hospedado
Contraste (auditoria 4.8, WCAG AA):
- Novo token --fitme-accent-ink #667000 (≈5.1:1 sobre #F7F8FA), aplicado aos 7
  usos de texto que estavam em --fitme-accent-dark (#A6B600 ≈2.1:1 em fundo claro).
  O --fitme-accent-dark mantém-se para fundos de botões em hover/active (onde tem
  texto escuro por cima — escurecê-lo quebraria esses estados).
- .section-eyebrow passa a usar o ink green nas secções claras (oferta, galeria,
  modalidades, horário, workhours); as secções escuras mantêm o verde vivo.

Fontes (CSS "Google Fonts ainda carregado do CDN"):
- Roboto (variável, latin, 43 KB woff2) em site/assets/fonts/roboto/.
- @font-face 100-900 em style.css; removidos os <link> para fonts.googleapis.com
  em include.php e gym_chooser.php. Ficam apenas os pedidos à API do Places
  (legítimos, server-side).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-25 23:22:06 +01:00
andreandClaude Fable 5 8a0c2272a4 chore(frontend): remove Bootstrap 4 morto e alinha breakpoints à grelha
- assets/vendor/bootstrap/ (cópia BS 4.3.1) já sem referências desde a
  migração para 5.3.3 (auditoria 5.1); removida do repositório.
- style.css: cabeçalho corrigido (Bootstrap 4.3.1 → 5.3.3).
- Breakpoints inventados alinhados à grelha (auditoria 4.1): 479px e os dois
  339px passaram a max-width: 575px (o tier xs do Bootstrap). Os cartões de
  detalhe (modalidade/equipa) passam a coluna em todos os telefones ≤575px.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-25 22:47:25 +01:00
andreandClaude Sonnet 5 231b8eb699 feat(security): CSP ganha script-src (auditoria: CSP ainda sem script-src)
'self' 'unsafe-inline' https://cdn.jsdelivr.net — levantamento antes
de mexer: só um host externo carrega <script> a sério (jsdelivr, o
html2canvas de views/admin/horario/instagram.php e o bootstrap-icons
da página de erro); tudo o resto (Bootstrap, jQuery, Summernote) já é
vendorizado desde a migração de 25/09. 'unsafe-inline' fica por sítio
ter vários <script> inline (tema claro/escuro, GA-like snippets) que
não valia a pena reescrever para nonce/hash nesta passagem — mas isto
já bloqueia o caso que mais importa: um <script src="outro-domínio">
injetado por uma falha de XSS deixa de carregar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 22:10:26 +01:00
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
1889 changed files with 4653 additions and 166875 deletions

No files matched your search

+9 -3
View File
@@ -59,9 +59,15 @@ Thumbs.db
.claude/
# ─── Dependências ───────────────────────────────────────────────────────────
# vendor/ ESTÁ versionado de propósito: não há Composer nesta app e o
# PHPMailer foi copiado à mão (vendor/PHPMailer). Quando houver composer.json,
# acrescentar aqui "vendor/" e retirá-lo do índice.
# vendor/ é output do composer install, exceto vendor/PHPMailer: o
# platform-tools partilhado (functions_mail.php) já espera esse caminho por
# require_once, igual em toda a frota — composer install criaria uma segunda
# árvore paralela (vendor/phpmailer/phpmailer/) que nada usa por si, mas fica
# para o botão "Atualizar dependências" do backoffice e para composer audit.
# composer.lock FICA versionado (fixa o que um deploy instala).
/vendor/*
!/vendor/PHPMailer/
!/vendor/PHPMailer/**
node_modules/
# ─── Lixo de editores / temporários ─────────────────────────────────────────
+4
View File
@@ -0,0 +1,4 @@
[submodule "shared/platform-tools"]
path = shared/platform-tools
url = ../platform-tools.git
branch = main
+19 -4
View File
@@ -1,6 +1,11 @@
# Disable directory listing
Options -Indexes
# JPEG XL uploads (gym header/logo): older mime.types don't know the extension.
<IfModule mod_mime.c>
AddType image/jxl .jxl
</IfModule>
# Block direct HTTP access to sensitive files
<FilesMatch "^\.(env|env\..*|git.*|htaccess|htpasswd)$">
Require all denied
@@ -16,6 +21,10 @@ Options -Indexes
RewriteEngine On
RewriteRule "(^|/)\.(?!well-known)" - [F,L]
# Server-side trees: never reachable by URL (the repo root is the docroot here, and
# the dev tree serves it from /GitTea/repos/fitme3070.pt/).
RewriteRule ^(config|includes|database|logs|backups|templates|vendor)(/|$) - [F,L]
# A raiz web é o repositório inteiro, por isso fecha-se o que não é página:
# - parciais (_form.php e afins) só se incluem, nunca se pedem;
# - PHP soltos do platform-tools: só api/ e backups/ (auto-chamada com token)
@@ -26,15 +35,18 @@ RewriteRule "^shared/platform-tools/[^/]+\.php$" - [F,L]
RewriteRule "\.md$" - [F,L]
# ── Headers de segurança ─────────────────────────────────────────────────────
# A CSP não restringe scripts ainda (o site tem scripts inline e incorporações
# da Google); fecha o que não parte nada: quem pode meter o site num iframe,
# <base>, plugins e para onde os formulários podem enviar.
# script-src: 'self' (todo o JS próprio, incl. Bootstrap/jQuery/Summernote
# vendorizados desde 25/09) + 'unsafe-inline' (o site tem vários <script>
# inline — remover isso é reescrever cada um para nonce/hash, não cabe aqui)
# + cdn.jsdelivr.net (html2canvas em views/admin/horario/instagram.php,
# bootstrap-icons na página de erro). Já bloqueia o que importa: um
# <script src="https://outro-dominio/..."> injetado não carrega.
<IfModule mod_headers.c>
Header always set X-Content-Type-Options "nosniff"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set X-Frame-Options "SAMEORIGIN"
Header always set Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()"
Header always set Content-Security-Policy "frame-ancestors 'self'; base-uri 'self'; object-src 'none'; form-action 'self' https://forms.zohopublic.eu"
Header always set Content-Security-Policy "frame-ancestors 'self'; base-uri 'self'; object-src 'none'; form-action 'self' https://forms.zohopublic.eu; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net"
</IfModule>
# ── Canonical host: force HTTPS + strip www ───────────────────────────────────
@@ -51,6 +63,9 @@ RewriteRule ^site/assets/ - [L]
RewriteRule ^assets/ - [L]
# ── Friendly URLs ─────────────────────────────────────────────────────────────
# DEV ONLY: dev_login.php 404s sozinho se o platform-tools não tiver functions_dev.php.
RewriteCond %{REQUEST_FILENAME}.php -f
RewriteRule ^dev_login/?$ dev_login.php [L]
RewriteRule ^home/?$ site/index.php [L]
RewriteRule ^home/equipa/?$ site/equipa.php [L,QSA]
RewriteRule ^home/modalidades/?$ site/modalidades.php [L,QSA]
+41
View File
@@ -0,0 +1,41 @@
# Assets de front-end desta app
Estado real a 25/09/2026 (auditoria em `CLAUDE_GITEA/audits/fitme3070-auditoria-2026-09-24.html`,
ponto 7.4). Composer real desde 25/09 (`composer.json`/`composer.lock`, ver `PLATFORM_TOOLS.md`),
mas ainda sem versão no nome das pastas dos ficheiros hand-vendored.
## O que está vendorizado e onde
| Biblioteca | Versão | Caminho | Usado em |
|------------------------|--------|----------------------------------------|----------------------|
| Bootstrap | 5.3.3 | `assets/vendor/bootstrap5/` | Site público + backoffice (unificado 25/09) |
| Bootstrap Icons | — | `assets/vendor/bootstrap-icons/` | Backoffice (agora carregado do pacote partilhado `bootstrap-icons` 1.13.1; esta cópia só como recurso) |
| jQuery | 3.7.1 | `assets/vendor/jquery/jquery-3.7.1.min.js` | Site público + backoffice (deduplicado 25/09) |
| Font Awesome Free | 5.13.1 | `site/assets/fonts/fontawesome/` | Site público: agora FA 6.7.2 do pacote partilhado `fontawesome`; esta cópia (~18 MB) só como recurso, sem uso se o submódulo tiver `functions_icons.php` |
| PHPMailer | 7.1.1 | `vendor/PHPMailer/` | Envio de email (`config/functions_mail.php`, partilhado) |
`assets/vendor/bootstrap/` (a cópia 4.3.1) ficou sem nenhuma referência depois da migração —
ainda no disco, ninguém a carrega, por remover (`git rm -r`, não feito ainda).
Nenhuma pasta hand-vendored tem a versão no nome; para confirmar a versão real de cada uma, ver
os comentários de cabeçalho dos `.min.css`/`.min.js` ou `VERSION`/`const VERSION` no caso do
PHPMailer. `composer.lock` já fixa a versão exata do que o Composer instala.
## Porque não segue por completo a convenção da frota
- **PHPMailer continua hand-vendored** em vez de vir do Composer da app: `shared/platform-tools/
functions_mail.php` (partilhado por toda a frota) exige `vendor/PHPMailer/src/PHPMailer.php` por
caminho fixo, não o layout `vendor/phpmailer/phpmailer/...` que o Composer geraria — mudar isso é
código partilhado, fora do alcance de uma app só (mesma razão que a fotografia mantém as duas
árvores). `composer.json` declara a dependência mesmo assim, para `composer audit`/`outdated`
saberem da versão.
- **Google Fonts ainda vem de CDN** (`site/includes/include.php`) — pendente self-hosting do
Roboto (ver [[fitme-audit-2026-09]] na memória do projeto, "Pending for André").
## Antes de adicionar ou atualizar uma biblioteca
1. Preferir Composer sempre que o pacote exista (ver a convenção da frota).
2. Sem Composer: descarregar uma vez, commitar, e nomear a pasta com a versão
(`vendor/<lib>-<versão>/`), não um nome genérico.
3. Nunca duplicar a mesma versão em duas pastas — importar de um único sítio.
4. Atualizar esta tabela.
+164
View File
@@ -0,0 +1,164 @@
# fitme3070 — architecture
Backoffice + public site for a gym (ginásio) chain, plain PHP 8.4, no framework. This
file exists because the pattern below was inherited from a CodeIgniter 4 port and had
never been written down as a decision (audit 2026-09-24, item 6.1) — it describes what
the codebase actually does, kept deliberately, not a design proposal for a rewrite.
## Request lifecycle
**Every page is its own controller-and-view combined.** A page in `views/admin/*.php`
or `site/*.php` does, in order: bootstrap (`config/db.php` + `config/functions.php`),
an auth guard, read `$_POST`/`$_GET` and call model functions, then fall through into
its own HTML. There is no separate template layer and no front controller — 71+ PHP
files are directly reachable by URL (docroot is the repo root; `.htaccess` denies
partials, `shared/platform-tools/*.php`, and `*.md`, but doesn't allowlist).
**This is a known, accepted gap, not an oversight** — see "What's not done" below.
Introducing a real front controller/router for the app's own pages would touch most of
`views/`, `.htaccess`'s friendly-URL block, and every hardcoded `base_url('views/...')`
call; it's a genuine rewrite, tracked separately, not bundled into this doc.
## Data layer: `config/functions_*.php` as models
SQL lives in `config/functions_*.php`, one file per domain (`functions_team.php`,
`functions_galerias.php`, `functions_gyms.php`, `functions_memberships.php`, …) plus
`functions_db.php` for cross-cutting `users` queries. Functions are named
`db_<noun>_<verb>()` (`db_gyms_create`, `db_team_update`, `db_gallery_list`). **Views
never write SQL directly** — if you're adding a query, it goes in the matching
`functions_*.php`, not inline in the page. (Audit 6.3, closed 2026-09-25 — `grep -rl
'\$pdo->\(query\|prepare\|exec\)' views/` should always return nothing.)
`config/functions.php` is the loader: every page requires `config/db.php` (opens
`$pdo`, starts the session, connects to platform-tools) then
`config/functions.php` (requires every `functions_*.php` in order). Read that file's
own header comment before adding a new one — order matters for a few (`auth.php`
before anything that calls `require_login()`, etc.).
## Auth
Login/session/roles run on `shared/platform-tools/functions_auth.php` (`'auth' =>
'shared'` in `config/db.php`, migrated 2026-09-25 — audit 1.3). `config/auth.php` keeps
the app's own guard names (`require_login()`, `require_admin()`, `require_staff()`,
`require_sysadmin()`, `is_admin()`, etc.) as a thin layer over the shared primitives, so
none of the ~30 call sites in `views/` had to change. Role ladder:
`sysadmin`(100) > `admin`(80) > `staff`(40) > `client`(10, portal-only). "Manter sessão
iniciada" is the shared `auth_remember_*` module.
The login-attempt throttle (`config/functions_throttle.php`) stays the app's own,
deliberately not the shared one — see the comment in `config/db.php` for why (avoids
double-counting the same form with two independent throttles). A throttled login feeds
`platform_ip_record_offence()`, so repeat offenders get fleet-wide IP-banned.
## Soft delete / trash
Deletes are soft (`deleted_at`/`deleted_by`) via `trash_delete()` in
`config/functions_trash.php`. Gestão → Lixeira lists everything deleted across entity
types and only allows permanent purge after 30 days (enforced in the `DELETE`'s
`WHERE`, not just hidden in the UI). Adding a new deletable entity means adding it to
`trash_types()` in that file.
## CSRF
`csrf_check()` (`config/functions_csrf.php`) refuses any non-POST request outright —
state changes must be POST forms carrying `csrf_field()`'s token. There is no PRG
(Post-Redirect-Get) convention beyond that; several admin pages still re-render the
form directly on POST rather than redirecting (audit flags this as inconsistent, not
yet unified — tracked separately, low priority).
## Audit log
Models call `audit_row()` before a write and `audit_change()` after (diffs
before/after automatically); ad-hoc events elsewhere use `audit('entity.verb', [...])`.
Both come from `shared/platform-tools` via `config/functions_audit.php`. Read via
Sistema → Auditoria (`platform.php?tool=audit`).
## Multi-gym
Since 2026-09-25, one install serves several physical gyms: the `gyms` table, one URL
folder per gym (`/3070/`, `/<postal-code>/`), content rows carry `gym_id`, and the
backoffice has an "a editar" gym switcher (`admin_gym_id()` /
`admin_gym()` in `config/functions_gyms.php`). `/` and `/home` 301 to the default gym (`is_default`); the other published gyms appear as highlighted buttons (`.gym-switch-btn`) at the right end of the public navbar (`site/includes/navbar.php`). `site/gym_chooser.php` is no longer used.
Each gym has a brand colour (`gyms.brand_color`, `#RRGGBB`, migration `migration_20261013_gym_brand_color.sql`; gym 1 = green `#C2D500`, gym 2 = red): set in Gestão → Ginásios, emitted as `:root` overrides by `gym_brand_css()` in `site/includes/include.php`. New components must use `--fitme-accent*` / `--fitme-on-accent`, never a hard-coded colour; the CrossFit nav pill follows the brand colour too. The public navbar's bottom edge is an always-on, slow (14s) sweep in the brand colour (`.header-section::before/::after`, `nav-sweep`, static with reduced motion). 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 on a white face (`.gym-switch-btn--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'`; only the gyms form does; `AddType image/jxl` in `.htaccess`; GD can't resize it, so it is stored as uploaded; Chrome/Firefox may not display JXL, Safari does).
An unpublished gym 404s publicly but previews for staff.
## platform-tools
`shared/platform-tools` is a **git submodule** (pinned `branch = main` in
`.gitmodules`), not vendored — see `PLATFORM_TOOLS.md` for why it went vendored→submodule→
back, and don't repeat that mistake without reading it first. `config/db.php` wires it
via `platform_connect()`: shared auth (above), fleet IP-ban guard (runs on every
request), the sysadmin router (`platform.php` / `api/platform.php` — Backups,
Actualizações, Auditoria, IP bans, Geo, Origem das visitas, all one front controller,
just for *those* pages, not the app's own).
## Deploy
Push to `master` → Gitea webhook → `api/platform.php?endpoint=auto_update` → the VPS
pulls, syncs the submodule, applies pending `database/migration_*.sql` files, done in
seconds. No staging branch; the VPS checkout **is** what's live. `deploy.sh` (on the
VPS, not in this repo) is the manual fallback. See `PLATFORM_TOOLS.md`.
## Dependencies
`composer.json`/`composer.lock` are real (`composer install` works, `composer
audit`/`outdated` are meaningful) — added 2026-09-25, matching the fotografia
platform's setup. `vendor/PHPMailer/` is the one hand-vendored exception, gitignore-
allowlisted: `shared/platform-tools/functions_mail.php` hard-codes that exact path
fleet-wide, so Composer's own `vendor/phpmailer/phpmailer/` exists in parallel but
isn't what actually gets `require_once`'d. See `ASSETS.md` for frontend asset versions.
Both the public site and the backoffice load Bootstrap 5.3.3 (unified 2026-09-25 — the
public site ran 4.3.1 before that); jQuery is one shared copy, not duplicated.
## What's not done (tracked, not silently missing)
The full audit and its live status live in a separate report, not in this repo
(`CLAUDE_GITEA/audits/fitme3070-auditoria-2026-09-24.html` on the maintainer's
machine). As of 2026-09-25: no real front controller (6.2, above), no central listing
registry, no DataTables on backoffice listings, no `srcset` (deliberate — uploads are
already close to their CSS display size, see the audit item), `views/admin/galerias/`
still has separate create/edit (deliberately — they're not actually a duplicated pair,
see the audit item). None of these are urgent; none should be "fixed" as a side effect
of an unrelated change — each is its own piece of work.
## Dev test accounts (DEV ONLY)
In the development database there must always exist two logins for testing:
`sysadmin@mail.pt` (role sysadmin) and `admin@mail.pt` (role admin), password `123`.
Ensure/repair them with `php scripts/dev_accounts.php` (CLI only, idempotent upsert with
`password_hash`, active flags set). The script fails closed: it refuses unless `.env` has
`APP_ENV=dev` AND `DB_HOST` is `192.168.1.3:3181` or localhost. It must never be run, deployed
or adapted for production, and these accounts must never exist in a production database.
## Brand feed (2026-10-09)
- `api/brand.php` is a one-line stub to `shared/platform-tools/api/brand.php` (public JSON: name, logos, colours; README "Brand feed"). Declared in `platform_connect(['brand' => ...])` in `config/db.php`; colours are the `:root` tokens (`--fitme-accent` #c2d500, `--fitme-dark` #0b0b0d, `--fitme-accent-dark` #a6b600). `logo_light` = light-coloured logo (`logo_crossfit_white.svg`), `logo_dark` = `logo_crossfit.svg`.
- `site_logo_rel()` (config/functions.php) resolves logos for both the feed and `load_email_template()`: setting file in `uploads/public/logo/` (convention), then legacy `assets/img/logo/`, then the static logo. `views/sysadmin/system_settings.php` now uploads logos to `uploads/public/logo/` (dir created on demand, 0755; `uploads/.htaccess` blocks script execution); old files in `assets/img/logo/` still resolve as fallback and in the settings preview. Brand block in `config/db.php` already prefers the setting file via `site_logo_rel()`, else the static SVGs.
## Dev toolbar partilhada (2026-10-09)
- DEV ONLY, do platform-tools (`functions_dev.php`, README "Dev tooling"; produção segue `main`, que não tem o ficheiro: tudo degrada para "não é dev").
- Ficheiros: `config/dev_guard.php` (shim), `includes/dev_toolbar.php` (incluído no fim de `includes/footer.php`), `dev_login.php` (na RAIZ: o site não tem `public/`; `.htaccess` tem a regra `^dev_login/?$` com `-f`), `scripts/dev_accounts.php` (`platform_dev_accounts_run`).
- Contas `admin@mail.pt` / `sysadmin@mail.pt` (pw `123`). O login usa `auth_login()` + `fit_stamp_login_ua()` como o login real; CSRF do site = campo `_csrf` / `$_SESSION['csrf_token']` (verificação sem `exit`).
## Ambiente de dev: moldura e login de um clique (2026-10-13)
- `includes/env_ribbon.php` (moldura às riscas + etiqueta DEV, só fora de `APP_ENV=prod`) incluído no `<head>` de `includes/header.php`, `includes/auth_header.php` e `site/includes/include.php`.
- A barra de login de dev (`includes/dev_toolbar.php`) vai também em `includes/auth_footer.php` e `site/includes/footer.php`. Botões Cliente / Staff / Admin / Sysadmin (`client@mail.pt` e `staff@mail.pt` criados por `scripts/dev_accounts.php`; o painel do staff é `views/staff/dashboard.php`, criado em 2026-10-13).
- Auditoria da sidebar: `docs/audits/2026-10-13-fitme3070-sidebar.md` (em Y:/GitTea; aplicada, ver «Barra lateral» abaixo).
## Barra lateral (2026-10-13)
- Padrão da frota (`.claude/skills/sysadmin-navbar-audit`). CSS todo em `assets/css/backoffice.css` (tokens `--bo-*`, casca, mini, view transitions; carregado com `?v=filemtime` por `_asset_versioned`); já não há `<style>` inline no `header.php`. Prefixo `bo-` nas peças novas; `.sidebar`, `.topbar`, `.nav-link` mantêm-se.
- Cabeçalho fixo `[☰][marca][↗ site público]` (↗ abre `base_url('/')` em nova aba, único sítio), utilizador logo abaixo (avatar com iniciais, nome, crachá de papel, menu Perfil / Terminar sessão). A topbar fica só com o seletor «A editar», o tema e, em telemóvel, o botão que abre a gaveta. Um só manipulador (`data-sidebar-toggle`, em `includes/footer.php`): desktop alterna mini (80px, `localStorage bo-sidebar-mini`), telemóvel abre o off-canvas.
- Menu em `includes/header.php` com os auxiliares de `includes/nav_helpers.php`: `bo_nav_link()` (ativo por sufixo do `SCRIPT_NAME`, `aria-current`, title + aria-label), `bo_nav_group_start/end()` (grupo recolhível, `data-short` = 4 letras), `bo_dashboard_path()`. Ordem: Painel (admin: `views/admin/dashboard.php`; staff: `views/staff/dashboard.php`; cliente: `views/user/dashboard.php`), Gestão (subgrupos Site, Horários, Comercial, Legal), Utilizadores, Sistema por último. Cada ligação mantém o seu `is_admin()`/`is_sysadmin()`.
- Estado sem flash: script no `<head>` põe `html.bo-mini` e `data-bo-groups`; estado dos grupos em `localStorage bo-nav-groups`; scroll da lista em `sessionStorage bo-nav-scroll`. Grupo novo = chave nova na lista `:is(...)` de grupos fechados em `backoffice.css`.
- Mini: categorias em texto (`data-short`), nunca ícones. Ícones: só Bootstrap Icons de contorno.
- `includes/navbar.php` (barra antiga) foi apagado; `system_settings.php` já não o inclui.
- `views/staff/dashboard.php`: saudação mínima (o staff ainda não alcança nada em `views/admin/`).
- Gotcha de dev: a tabela `trial_leads` não existe na BD de dev, por isso `views/admin/trials/index.php` dá 500 (migração por aplicar; anterior a esta mudança).
## Bootstrap re-tematizado (2026-10-13)
- `assets/css/bs-brand.css` (carregado depois do Bootstrap em `includes/header.php` e `includes/auth_header.php`) sobrescreve as cores do Bootstrap só com variáveis `--bs-*` e as variáveis de componente (`.btn-*`, `.form-check-input`, `.page-link`…), claro e escuro. A cor de marca é `--fitme-accent` (fallback `#C2D500`); todos os tons derivam dela com `color-mix()`; `--brand-on` é o texto sobre a cor. Secondary/success/danger/warning/info têm valores próprios. Verificação: `platform.php?tool=branding` (Montra da marca) não deve mostrar nenhum azul por omissão. O site público ainda não carrega este ficheiro.
## Pacotes de ícones partilhados (2026-10-13)
- `config/db.php` regista `'icons' => ['packs' => ['bootstrap-icons','fontawesome'], 'default' => 'bootstrap-icons']` só se `shared/platform-tools/functions_icons.php` existe. Flaticon NÃO está registado: o site usa PNGs (`uploads/modalidades/icons`), não a fonte.
- `fit_icon_assets($packs, $fallbackLocal)` (`config/functions_icons.php`) imprime `pt_icon_assets()` ou, sem submódulo novo, os `<link>` locais. Backoffice (`includes/header.php`, `includes/auth_header.php`): `bootstrap-icons`; site público (`site/includes/include.php`): `fontawesome` (FA 6.7.2; as classes `fas/far/fab` e os nomes FA5 funcionam por aliases).
- Sidebar > Sistema: «Pacotes de ícones» (sysadmin) -> `platform.php?tool=icons`.
- Sem uso quando o submódulo é novo (não apagados): `assets/vendor/bootstrap-icons/`, `site/assets/fonts/fontawesome/`, `site/assets/icon-fonts/`, `site/assets/css/flaticon.css`. `config/error_handler.php` ainda usa bootstrap-icons por CDN (página de erro autónoma).
+56
View File
@@ -0,0 +1,56 @@
# shared/platform-tools is a git submodule (again)
Was vendored as a plain committed directory from 2026-09-24 to 2026-09-25
(`VENDORED.md`, since removed — its content is folded in below). Switched back
to a proper submodule on 2026-09-25 once the two concrete problems that broke
it the first time were fixed:
- `.gitmodules` now pins `branch = main` explicitly. The old submodule had no
branch, so "Atualizar submódulo" fell back to `refs/remotes/origin/HEAD` — a
ref a plain server-side clone never creates — and failed with
`Unable to find refs/remotes/origin/HEAD revision`.
- The submodule URL is `../platform-tools.git` (relative, resolves to
`https://git.andmore.pt/andre/platform-tools.git`), not `http://`. The old
one used `http://`, which the origin answers with 523.
The third original reason ("the site is deployed by uploading files, so a
submodule is invisible to it") doesn't apply to fitme's actual deploy method —
`deploy.sh` does `git pull --ff-only` — so it no longer justifies staying
vendored once the other two are fixed. `deploy.sh` now runs
`git submodule update --init --recursive` after the pull, matching every other
`config/`-based platform in the fleet (fotografia, dsa, moremio, tickets).
**Before converting back (2026-09-25):** verified the vendored copy was
byte-identical to upstream `platform-tools@12f26fd` (the SHA it was frozen at)
— only difference was CRLF→LF normalization in four `social/` files, already
noted in the old `VENDORED.md`. Nothing site-specific was hand-patched into
the vendored copy, so nothing was lost by re-pointing at the submodule pinned
to that same commit.
To bump the submodule to a newer platform-tools commit: `git submodule update
--remote shared/platform-tools && git add shared/platform-tools && git commit`
(see the platform-tools README, "Wiring up a platform", step 6) — same as
fotografia.
## Deploy-on-push
`master` now deploys itself: Gitea webhook id 6 on `fitme3070.pt` (push, branch
`master`) POSTs to `api/platform.php?endpoint=auto_update` (the router — no
per-file `api/auto_update.php` stub needed, since the router's default target
for that endpoint already resolves to the same
`shared/platform-tools/api/auto_update.php` a stub would have required).
`AUTO_UPDATE_SECRET` is in `.env` on the VPS (not in git — see
`CLAUDE_GITEA/.auto-update-secrets` on Andre's machine), and
`system_settings.auto_update_enabled = '1'`. Toggle it off from Sistema →
Atualizar plataforma if a push needs to land without auto-deploying.
**Why the checkout stays group-writable (664/2775, `deploy:www-data`):** the
webhook's `git pull` runs *inside the PHP request*, as `www-data`, not as
`deploy`. Tried tightening to 644/2755 on 2026-09-25 (audit 2.6) and the very
next push failed with "Permission denied" creating a new file — reverted
within minutes. `config/cache/` and `assets/img/seo/` (the app's only two
runtime-write targets inside the checkout besides `uploads/`/`logs/`/
`backups/`, which already had their own volumes) got their own bind mounts
from `/srv/data/fitme.andmore.pt/` in `compose.yml`, same pattern fotografia
uses — but the checkout itself can't go read-only for `www-data` without
giving up deploy-on-push.
+3
View File
@@ -0,0 +1,3 @@
<?php
// Stub: o endpoint vive no platform-tools (Brand feed público, sem auth).
require __DIR__ . '/../shared/platform-tools/api/brand.php';
+4
View File
@@ -0,0 +1,4 @@
<?php
// Stub: o endpoint vive no platform-tools, partilhado por toda a frota.
// Ver shared/platform-tools/api/hub_health.php (e functions_hub.php).
require __DIR__ . '/../shared/platform-tools/api/hub_health.php';
+4
View File
@@ -0,0 +1,4 @@
<?php
// Stub: o endpoint vive no platform-tools, partilhado por toda a frota.
// Ver shared/platform-tools/api/hub_ops.php (e functions_hub.php).
require __DIR__ . '/../shared/platform-tools/api/hub_ops.php';
+4
View File
@@ -0,0 +1,4 @@
<?php
// Stub: o endpoint vive no platform-tools, partilhado por toda a frota.
// Ver shared/platform-tools/api/hub_pair.php (e functions_hub.php).
require __DIR__ . '/../shared/platform-tools/api/hub_pair.php';
+1
View File
@@ -0,0 +1 @@
<?php require __DIR__ . '/../shared/platform-tools/api/router.php';
+351
View File
@@ -0,0 +1,351 @@
/* Backoffice FitMe 3070: tokens da marca, casca (barra lateral, topbar, gaveta
em telemóvel) e acertos dos componentes. Carregado por includes/header.php
depois do Bootstrap. O Bootstrap nunca se edita: o que muda faz-se com
variáveis e com classes próprias (prefixo bo-). */
:root {
--bo-accent: #C2D500;
--bo-accent-rgb: 194,213,0;
--bo-dark: #0a0a0a;
--bo-dark-2: #151515;
--bo-surface: #eef0f4;
--bo-sidebar-w: 264px;
--bo-sidebar-w-mini: 80px;
--bo-topbar-h: 56px;
--bo-ease: .2s ease;
/* Texto da barra lateral (escura nos dois temas) */
--bo-nav-text: rgba(255,255,255,.62);
--bo-nav-text-muted: rgba(255,255,255,.5);
--bo-nav-line: rgba(255,255,255,.1);
--bo-nav-strong: #fff;
}
[data-bs-theme="dark"] { --bo-surface: #121416; }
body { background: var(--bo-surface); font-size: .925rem; }
a { color: var(--bs-emphasis-color); }
main { margin-left: var(--bo-sidebar-w); padding-top: calc(var(--bo-topbar-h) + 12px); }
/* ── Barra lateral ───────────────────────────────────────────────────────── */
/* Sem overflow aqui: o menu do utilizador sai da barra no modo mini.
Quem rola é só .bo-sidebar__nav; cabeçalho e utilizador ficam fixos. */
.sidebar {
position: fixed;
inset: 0 auto 0 0;
z-index: 100;
width: var(--bo-sidebar-w);
display: flex;
flex-direction: column;
background: var(--bo-dark);
}
/* Cabeçalho numa linha: [☰] [marca] [↗ site público] */
.bo-sidebar__head {
flex: none;
display: flex;
align-items: center;
gap: .5rem;
height: var(--bo-topbar-h);
padding: 0 .75rem;
border-bottom: 1px solid var(--bo-nav-line);
}
.bo-sidebar__toggle,
.bo-sidebar__site {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 40px;
min-height: 40px;
padding: 0;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--bo-nav-text);
font-size: 1.25rem;
line-height: 1;
text-decoration: none;
transition: background var(--bo-ease), color var(--bo-ease);
}
.bo-sidebar__site { font-size: 1rem; }
.bo-sidebar__toggle:hover,
.bo-sidebar__site:hover { background: rgba(255,255,255,.08); color: var(--bo-nav-strong); }
.bo-sidebar__brand {
flex: 1;
min-width: 0;
color: var(--bo-nav-strong);
font-weight: 700;
font-size: 1.05rem;
letter-spacing: .5px;
text-decoration: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bo-sidebar__brand i { color: var(--bo-accent); }
/* Utilizador logo abaixo do cabeçalho */
.bo-sidebar__user {
flex: none;
padding: .6rem .75rem;
border-bottom: 1px solid var(--bo-nav-line);
}
.bo-user-menu {
width: 100%;
display: flex;
align-items: center;
gap: .6rem;
padding: .35rem .45rem;
border: 1px solid var(--bo-nav-line);
border-radius: 8px;
background: transparent;
color: var(--bo-nav-strong);
text-align: left;
}
.bo-user-menu:hover,
.bo-user-menu.show { background: rgba(255,255,255,.08); color: var(--bo-nav-strong); }
.bo-user-menu::after { margin-left: auto; flex: none; }
.bo-user-menu__avatar {
flex: none;
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--bo-accent);
color: var(--bo-dark);
font-size: .8rem;
font-weight: 700;
}
.bo-user-menu__text { min-width: 0; display: flex; flex-direction: column; align-items: flex-start; line-height: 1.25; }
.bo-user-menu__name { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .85rem; font-weight: 600; }
.bo-user-menu__text .badge { font-size: .62rem; font-weight: 600; }
.bo-sidebar__user .dropdown-menu { min-width: 14rem; z-index: 1110; }
/* Lista de navegação: a única parte que rola */
.bo-sidebar__nav {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding: .5rem 0 1rem;
display: flex;
flex-direction: column;
gap: 2px;
}
.sidebar .bo-nav-item {
position: relative;
display: flex;
align-items: center;
gap: .6rem;
min-height: 40px;
padding: .5rem 1.25rem;
border-left: 3px solid transparent;
color: var(--bo-nav-text);
font-size: .875rem;
transition: background var(--bo-ease), color var(--bo-ease), border-color var(--bo-ease);
}
.sidebar .bo-nav-item i { flex: none; width: 1.25rem; text-align: center; font-size: 1.05rem; }
.sidebar .bo-nav-item:hover { color: var(--bo-nav-strong); background: rgba(255,255,255,.06); border-left-color: rgba(255,255,255,.2); }
.sidebar .bo-nav-item.active { color: var(--bo-nav-strong); background: rgba(var(--bo-accent-rgb),.12); border-left-color: var(--bo-accent); }
.sidebar .bo-nav-item.active i { color: var(--bo-accent); }
.sidebar .bo-nav-item--primary i { font-size: 1.25rem; }
/* Indentação = cadeia de dados: filhos de um grupo, e de um subgrupo. */
.sidebar .bo-nav-item--sub { padding-left: 2.3rem; font-size: .84rem; }
.sidebar .bo-nav-item--sub2 { padding-left: 3.2rem; font-size: .84rem; }
.bo-nav-item__label { min-width: 0; }
.sidebar .bo-nav-item:focus-visible,
.bo-nav-group__label:focus-visible,
.bo-sidebar__toggle:focus-visible,
.bo-sidebar__site:focus-visible,
.bo-user-menu:focus-visible,
.bo-sidebar__brand:focus-visible { outline: 2px solid var(--bo-accent); outline-offset: -2px; }
/* Grupos recolhíveis */
.bo-nav-group { margin-top: .25rem; }
.bo-nav-group__label {
display: flex;
align-items: center;
gap: .6rem;
width: 100%;
padding: .55rem 1.25rem;
border: 0;
background: transparent;
color: var(--bo-nav-text-muted);
font-family: inherit;
font-size: .68rem;
font-weight: 700;
letter-spacing: .14em;
text-align: left;
text-transform: uppercase;
cursor: pointer;
}
.bo-nav-group__label:hover { color: var(--bo-nav-strong); background: rgba(255,255,255,.04); }
.bo-nav-group__icon { flex: none; width: 1.25rem; text-align: center; font-size: 1.1rem; letter-spacing: 0; }
.bo-nav-group__text { flex: 1; min-width: 0; }
.bo-nav-group__chevron { font-size: .75rem; letter-spacing: 0; transition: rotate var(--bo-ease); }
.bo-nav-group__label[aria-expanded="false"] .bo-nav-group__chevron { rotate: -90deg; }
.bo-nav-group--sub > .bo-nav-group__label { padding-left: 1.9rem; }
/* Corpo recolhível: grelha 1fr -> 0fr anima a altura sem JavaScript a medir. */
.bo-nav-group__body {
display: grid;
grid-template-rows: 1fr;
transition: grid-template-rows var(--bo-ease), visibility var(--bo-ease);
}
.bo-nav-group__inner {
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 2px;
}
/* Fechado = classe (clique no grupo da página ativa) ou chave no atributo do
<html> posto pelo script do <head> antes da pintura. O grupo da página ativa
só fecha por clique (.is-closed), nunca pelo estado guardado. visibility
tira os links fechados da ordem de tabulação. Grupo novo = chave nova aqui. */
:is(.bo-nav-group.is-closed,
html[data-bo-groups~="gestao"] .bo-nav-group[data-group="gestao"]:not(.has-active),
html[data-bo-groups~="site"] .bo-nav-group[data-group="site"]:not(.has-active),
html[data-bo-groups~="horarios"] .bo-nav-group[data-group="horarios"]:not(.has-active),
html[data-bo-groups~="comercial"] .bo-nav-group[data-group="comercial"]:not(.has-active),
html[data-bo-groups~="legal"] .bo-nav-group[data-group="legal"]:not(.has-active),
html[data-bo-groups~="utilizadores"] .bo-nav-group[data-group="utilizadores"]:not(.has-active),
html[data-bo-groups~="sistema"] .bo-nav-group[data-group="sistema"]:not(.has-active)) > .bo-nav-group__body {
grid-template-rows: 0fr;
visibility: hidden;
}
/* Véu por trás da gaveta em telemóvel */
.sidebar-backdrop { display: none; position: fixed; inset: 0; z-index: 1090; background: rgba(0,0,0,.45); }
.sidebar-backdrop.show { display: block; }
/* ── Topbar ──────────────────────────────────────────────────────────────── */
.topbar {
position: fixed;
top: 0;
left: var(--bo-sidebar-w);
right: 0;
z-index: 99;
height: var(--bo-topbar-h);
padding: 0 1.5rem;
display: flex;
align-items: center;
gap: .5rem;
background: var(--bs-body-bg);
border-bottom: 1px solid var(--bs-border-color);
}
.bo-topbar__tools { display: flex; align-items: center; gap: .5rem; margin-left: auto; }
.menu-toggle {
display: none;
width: 44px;
height: 44px;
padding: 0;
border: 1px solid var(--bs-border-color);
border-radius: 8px;
background: var(--bs-body-bg);
color: var(--bs-body-color);
font-size: 1.35rem;
line-height: 1;
align-items: center;
justify-content: center;
}
.theme-toggle { min-width: 44px; min-height: 44px; }
/* ── Marca nos componentes ───────────────────────────────────────────────── */
.badge-role-sysadmin { background: var(--bo-accent); color: var(--bo-dark); }
.badge-role-admin { background: var(--bo-accent); color: var(--bo-dark); opacity: .85; }
.badge-role-staff { background: #6f42c1; }
.badge-role-client { background: #adb5bd; color: #212529; }
.btn-outline-primary { color: var(--bo-accent); border-color: var(--bo-accent); }
.btn-outline-primary:hover { background: var(--bo-accent); border-color: var(--bo-accent); color: var(--bo-dark); }
.btn-primary { background: var(--bo-accent); border-color: var(--bo-accent); color: var(--bo-dark); }
.btn-primary:hover { background: #a8ba00; border-color: #a8ba00; color: var(--bo-dark); }
.card { border: none; box-shadow: 0 1px 3px rgba(0,0,0,.04); }
[data-bs-theme="dark"] .table-light { --bs-table-bg: var(--bs-tertiary-bg); --bs-table-color: var(--bs-body-color); }
/* Summernote (WYSIWYG): alinhado com o aspeto do backoffice */
.note-editor.note-frame { border-color: #ced4da; border-radius: .375rem; }
.note-editor .note-editing-area .note-editable { font-size: .95rem; }
.note-editor.note-frame.note-focused { border-color: var(--bo-accent); box-shadow: 0 0 0 .2rem rgba(var(--bo-accent-rgb), .25); }
.note-toolbar { background: var(--bs-tertiary-bg); }
.note-btn.btn-primary { background: var(--bo-accent); border-color: var(--bo-accent); color: var(--bo-dark); }
/* ── Telemóvel: barra lateral em gaveta ──────────────────────────────────── */
@media (max-width: 991.98px) {
.sidebar {
transform: translateX(-100%);
transition: transform .25s ease;
z-index: 1100;
box-shadow: 2px 0 18px rgba(0,0,0,.3);
}
.sidebar.open { transform: translateX(0); }
main { margin-left: 0; }
.topbar { left: 0; padding: 0 .75rem; }
.menu-toggle { display: inline-flex; }
/* Tabelas largas rolam dentro do cartão em vez de alargar a página */
main .card > .card-body { overflow-x: auto; -webkit-overflow-scrolling: touch; }
}
@media (max-width: 575.98px) {
/* Cabeçalhos de página (título + botões) empilham em vez de transbordar */
main .d-flex.justify-content-between.align-items-center { flex-wrap: wrap; gap: .5rem; }
/* 16px nos campos: o iOS não faz zoom ao focar */
.form-control, .form-select { font-size: 16px; }
/* Margens confortáveis em telemóvel */
/* !important: .p-3 é uma utilitária do Bootstrap e tem !important de origem */
.p-3 { padding: .85rem !important; }
}
/* ── Modo mini (só ecrã grande; classe posta no <html> pelo script do <head>) ── */
@media (min-width: 992px) {
html.bo-mini { --bo-sidebar-w: var(--bo-sidebar-w-mini); }
html.bo-mini .bo-sidebar__head {
flex-direction: column;
justify-content: center;
height: auto;
padding: .5rem 0;
gap: .25rem;
}
html.bo-mini .bo-sidebar__brand { display: none; }
html.bo-mini .bo-sidebar__user { padding: .5rem; }
html.bo-mini .bo-user-menu { justify-content: center; padding: .25rem; }
html.bo-mini .bo-user-menu__text,
html.bo-mini .bo-user-menu::after { display: none; }
html.bo-mini .bo-sidebar__nav { padding: .5rem 0; }
html.bo-mini .sidebar .bo-nav-item,
html.bo-mini .sidebar .bo-nav-item--sub,
html.bo-mini .sidebar .bo-nav-item--sub2 { justify-content: center; padding: .5rem; }
html.bo-mini .bo-nav-item__label { display: none; }
html.bo-mini .bo-nav-item__badge {
position: absolute;
top: 2px;
right: 8px;
margin: 0;
padding: .15em .4em;
font-size: .6rem;
}
/* Categoria no mini: sigla de texto (data-short), sem ícone nem chevron. */
html.bo-mini .bo-nav-group__label,
html.bo-mini .bo-nav-group--sub > .bo-nav-group__label { justify-content: center; padding: .55rem .25rem; font-size: 0; }
html.bo-mini .bo-nav-group__label::before { content: attr(data-short); font-size: .66rem; font-weight: 700; letter-spacing: .08em; }
html.bo-mini .bo-nav-group__text,
html.bo-mini .bo-nav-group__icon,
html.bo-mini .bo-nav-group__chevron { display: none; }
}
/* ── Navegação sem piscar: só o conteúdo faz fade; barra e topbar ficam ── */
@media (prefers-reduced-motion: no-preference) {
@view-transition { navigation: auto; }
.sidebar { view-transition-name: bo-sidebar; }
.topbar { view-transition-name: bo-topbar; }
::view-transition-old(root),
::view-transition-new(root) { animation-duration: .14s; }
}
@media (prefers-reduced-motion: reduce) {
.sidebar,
.sidebar .bo-nav-item,
.bo-nav-group__body,
.bo-nav-group__chevron { transition: none; }
}
+253
View File
@@ -0,0 +1,253 @@
/* ── Bootstrap re-themed to the FitMe brand ──────────────────────────────────
Loaded after bootstrap.min.css (and before backoffice.css). Bootstrap's source
is never edited: everything here is --bs-* variables plus the component-scoped
variables Bootstrap 5.3 exposes (.btn-*, .form-check-input, .page-link …).
The brand colour is a single token, --fitme-accent (a gym's own colour on the
public site, see gym_brand_css()); every shade below is derived from it with
color-mix(), so changing the brand colour re-themes all of Bootstrap at once.
The other semantic colours (success/danger/warning/info) are tuned to sit next
to the brand without being confused with it. */
:root,
[data-bs-theme="light"],
[data-bs-theme="dark"] {
--brand: var(--fitme-accent, #C2D500);
--brand-ink: color-mix(in srgb, var(--brand) 55%, #000); /* text on light surfaces */
--brand-on: var(--fitme-on-accent, #0B0B0D); /* text on the brand colour */
--bs-primary: var(--brand);
--bs-primary-rgb: var(--fitme-accent-rgb, 194, 213, 0);
--bs-secondary: #3D403E;
--bs-secondary-rgb: 61, 64, 62;
--bs-success: #2E9E5B;
--bs-success-rgb: 46, 158, 91;
--bs-danger: #D6322E;
--bs-danger-rgb: 214, 50, 46;
--bs-warning: #F2A900;
--bs-warning-rgb: 242, 169, 0;
--bs-info: #2C9FD0;
--bs-info-rgb: 44, 159, 208;
--bs-light: #F7F8FA;
--bs-light-rgb: 247, 248, 250;
--bs-dark: #0B0B0D;
--bs-dark-rgb: 11, 11, 13;
--bs-focus-ring-color: color-mix(in srgb, var(--brand) 40%, transparent);
}
/* Subtle / emphasis / border tints: light theme (tint 80 / 60, shade 60) … */
:root,
[data-bs-theme="light"] {
--bs-primary-text-emphasis: color-mix(in srgb, var(--brand) 40%, #000);
--bs-primary-bg-subtle: color-mix(in srgb, var(--brand) 20%, #fff);
--bs-primary-border-subtle: color-mix(in srgb, var(--brand) 40%, #fff);
--bs-secondary-text-emphasis: #191a19;
--bs-secondary-bg-subtle: #e5e6e5;
--bs-secondary-border-subtle: #c5c6c5;
--bs-success-text-emphasis: #124f2c;
--bs-success-bg-subtle: #d5ecde;
--bs-success-border-subtle: #acdabd;
--bs-danger-text-emphasis: #561411;
--bs-danger-bg-subtle: #f7d6d5;
--bs-danger-border-subtle: #efadac;
--bs-warning-text-emphasis: #613f00;
--bs-warning-bg-subtle: #fceecc;
--bs-warning-border-subtle: #fadf99;
--bs-info-text-emphasis: #11404e;
--bs-info-bg-subtle: #d5ecf6;
--bs-info-border-subtle: #abd9ec;
--bs-link-color: var(--brand-ink);
--bs-link-color-rgb: 0, 0, 0;
--bs-link-hover-color: color-mix(in srgb, var(--brand) 40%, #000);
}
/* … dark theme (tint 40, shade 80 / 60) */
[data-bs-theme="dark"] {
--bs-primary-text-emphasis: color-mix(in srgb, var(--brand) 60%, #fff);
--bs-primary-bg-subtle: color-mix(in srgb, var(--brand) 20%, #000);
--bs-primary-border-subtle: color-mix(in srgb, var(--brand) 40%, #000);
--bs-secondary-text-emphasis: #b1b2b1;
--bs-secondary-bg-subtle: #131413;
--bs-secondary-border-subtle: #252726;
--bs-success-text-emphasis: #82c59d;
--bs-success-bg-subtle: #09200f;
--bs-success-border-subtle: #1c5f36;
--bs-danger-text-emphasis: #e88380;
--bs-danger-bg-subtle: #2b0a09;
--bs-danger-border-subtle: #801e1c;
--bs-warning-text-emphasis: #f7ca66;
--bs-warning-bg-subtle: #302200;
--bs-warning-border-subtle: #916800;
--bs-info-text-emphasis: #80c5e3;
--bs-info-bg-subtle: #0e2833;
--bs-info-border-subtle: #1a5f7d;
--bs-link-color: var(--brand);
--bs-link-color-rgb: var(--fitme-accent-rgb, 194, 213, 0);
--bs-link-hover-color: color-mix(in srgb, var(--brand) 75%, #fff);
}
/* ── Components: Bootstrap hard-codes white text on solid primary/semantic
fills; the brand colour is light, so the text must be dark. ─────────────── */
.btn-primary {
--bs-btn-color: var(--brand-on);
--bs-btn-bg: var(--brand);
--bs-btn-border-color: var(--brand);
--bs-btn-hover-color: var(--brand-on);
--bs-btn-hover-bg: color-mix(in srgb, var(--brand) 86%, #000);
--bs-btn-hover-border-color: color-mix(in srgb, var(--brand) 80%, #000);
--bs-btn-focus-shadow-rgb: var(--fitme-accent-rgb, 194, 213, 0);
--bs-btn-active-color: var(--brand-on);
--bs-btn-active-bg: color-mix(in srgb, var(--brand) 78%, #000);
--bs-btn-active-border-color: color-mix(in srgb, var(--brand) 70%, #000);
--bs-btn-disabled-color: var(--brand-on);
--bs-btn-disabled-bg: var(--brand);
--bs-btn-disabled-border-color: var(--brand);
}
.btn-outline-primary {
--bs-btn-color: var(--brand-ink);
--bs-btn-border-color: var(--brand);
--bs-btn-hover-color: var(--brand-on);
--bs-btn-hover-bg: var(--brand);
--bs-btn-hover-border-color: var(--brand);
--bs-btn-focus-shadow-rgb: var(--fitme-accent-rgb, 194, 213, 0);
--bs-btn-active-color: var(--brand-on);
--bs-btn-active-bg: var(--brand);
--bs-btn-active-border-color: var(--brand);
--bs-btn-disabled-color: var(--brand-ink);
--bs-btn-disabled-border-color: var(--brand);
}
[data-bs-theme="dark"] .btn-outline-primary { --bs-btn-color: var(--brand); --bs-btn-disabled-color: var(--brand); }
.btn-secondary {
--bs-btn-bg: var(--bs-secondary);
--bs-btn-border-color: var(--bs-secondary);
--bs-btn-hover-bg: #2f3230;
--bs-btn-hover-border-color: #2b2d2c;
--bs-btn-active-bg: #2b2d2c;
--bs-btn-active-border-color: #262826;
--bs-btn-disabled-bg: var(--bs-secondary);
--bs-btn-disabled-border-color: var(--bs-secondary);
}
.btn-outline-secondary {
--bs-btn-color: var(--bs-secondary);
--bs-btn-border-color: var(--bs-secondary);
--bs-btn-hover-bg: var(--bs-secondary);
--bs-btn-hover-border-color: var(--bs-secondary);
--bs-btn-active-bg: var(--bs-secondary);
--bs-btn-active-border-color: var(--bs-secondary);
--bs-btn-disabled-color: var(--bs-secondary);
--bs-btn-disabled-border-color: var(--bs-secondary);
}
[data-bs-theme="dark"] .btn-outline-secondary { --bs-btn-color: #9a9d9b; --bs-btn-border-color: #5b5f5d; }
.btn-success { --bs-btn-bg: var(--bs-success); --bs-btn-border-color: var(--bs-success); --bs-btn-hover-bg: #27864d; --bs-btn-hover-border-color: #257d49; --bs-btn-active-bg: #257d49; --bs-btn-active-border-color: #227545; --bs-btn-disabled-bg: var(--bs-success); --bs-btn-disabled-border-color: var(--bs-success); }
.btn-danger { --bs-btn-bg: var(--bs-danger); --bs-btn-border-color: var(--bs-danger); --bs-btn-hover-bg: #b62a27; --bs-btn-hover-border-color: #ab2825; --bs-btn-active-bg: #ab2825; --bs-btn-active-border-color: #a02622; --bs-btn-disabled-bg: var(--bs-danger); --bs-btn-disabled-border-color: var(--bs-danger); }
.btn-warning { --bs-btn-bg: var(--bs-warning); --bs-btn-border-color: var(--bs-warning); --bs-btn-hover-bg: #f5b61f; --bs-btn-hover-border-color: #f4b212; --bs-btn-active-bg: #f6bb2b; --bs-btn-active-border-color: #f4b212; --bs-btn-disabled-bg: var(--bs-warning); --bs-btn-disabled-border-color: var(--bs-warning); }
.btn-info { --bs-btn-bg: var(--bs-info); --bs-btn-border-color: var(--bs-info); --bs-btn-hover-bg: #52b0d9; --bs-btn-hover-border-color: #42a9d6; --bs-btn-active-bg: #5bb5dc; --bs-btn-active-border-color: #42a9d6; --bs-btn-disabled-bg: var(--bs-info); --bs-btn-disabled-border-color: var(--bs-info); }
.btn-outline-success { --bs-btn-color: var(--bs-success); --bs-btn-border-color: var(--bs-success); --bs-btn-hover-bg: var(--bs-success); --bs-btn-hover-border-color: var(--bs-success); --bs-btn-active-bg: var(--bs-success); --bs-btn-active-border-color: var(--bs-success); --bs-btn-disabled-color: var(--bs-success); --bs-btn-disabled-border-color: var(--bs-success); }
.btn-outline-danger { --bs-btn-color: var(--bs-danger); --bs-btn-border-color: var(--bs-danger); --bs-btn-hover-bg: var(--bs-danger); --bs-btn-hover-border-color: var(--bs-danger); --bs-btn-active-bg: var(--bs-danger); --bs-btn-active-border-color: var(--bs-danger); --bs-btn-disabled-color: var(--bs-danger); --bs-btn-disabled-border-color: var(--bs-danger); }
.btn-outline-warning { --bs-btn-color: #b07a00; --bs-btn-border-color: var(--bs-warning); --bs-btn-hover-bg: var(--bs-warning); --bs-btn-hover-border-color: var(--bs-warning); --bs-btn-active-bg: var(--bs-warning); --bs-btn-active-border-color: var(--bs-warning); --bs-btn-disabled-color: #b07a00; --bs-btn-disabled-border-color: var(--bs-warning); }
.btn-outline-info { --bs-btn-color: #1f7ea6; --bs-btn-border-color: var(--bs-info); --bs-btn-hover-bg: var(--bs-info); --bs-btn-hover-border-color: var(--bs-info); --bs-btn-active-bg: var(--bs-info); --bs-btn-active-border-color: var(--bs-info); --bs-btn-disabled-color: #1f7ea6; --bs-btn-disabled-border-color: var(--bs-info); }
[data-bs-theme="dark"] .btn-outline-warning { --bs-btn-color: var(--bs-warning); --bs-btn-disabled-color: var(--bs-warning); }
[data-bs-theme="dark"] .btn-outline-info { --bs-btn-color: var(--bs-info); --bs-btn-disabled-color: var(--bs-info); }
.btn-success, .btn-danger, .btn-info, .btn-outline-success, .btn-outline-danger, .btn-outline-info { --bs-btn-hover-color: #fff; --bs-btn-active-color: #fff; }
.btn-success, .btn-danger, .btn-info { --bs-btn-color: #fff; --bs-btn-disabled-color: #fff; }
.btn-warning, .btn-outline-warning { --bs-btn-hover-color: #0B0B0D; --bs-btn-active-color: #0B0B0D; }
.btn-warning { --bs-btn-color: #0B0B0D; --bs-btn-disabled-color: #0B0B0D; }
/* ── Solid fills that use the colour as background ───────────────────────── */
.text-bg-primary { color: var(--brand-on) !important; background-color: var(--brand) !important; }
.text-bg-secondary { background-color: var(--bs-secondary) !important; }
.text-bg-success { background-color: var(--bs-success) !important; }
.text-bg-danger { background-color: var(--bs-danger) !important; }
.text-bg-warning { color: #0B0B0D !important; background-color: var(--bs-warning) !important; }
.text-bg-info { color: #fff !important; background-color: var(--bs-info) !important; }
.link-primary { color: var(--brand-ink) !important; }
[data-bs-theme="dark"] .link-primary { color: var(--brand) !important; }
/* ── Forms ───────────────────────────────────────────────────────────────── */
.form-control:focus, .form-select:focus {
border-color: color-mix(in srgb, var(--brand) 70%, #000);
box-shadow: 0 0 0 .25rem var(--bs-focus-ring-color);
}
.form-check-input:checked,
.form-check-input[type="checkbox"]:indeterminate {
background-color: var(--brand);
border-color: var(--brand);
}
/* the tick / dot / knob are inline SVGs with a hard-coded white fill */
.form-check-input:checked[type="checkbox"] {
--bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%230B0B0D' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e");
}
.form-check-input:checked[type="radio"] {
--bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%230B0B0D'/%3e%3c/svg%3e");
}
.form-check-input:focus { border-color: var(--brand); box-shadow: 0 0 0 .25rem var(--bs-focus-ring-color); }
.form-switch .form-check-input:focus {
--bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23B9C65A'/%3e%3c/svg%3e");
}
.form-switch .form-check-input:checked {
--bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%230B0B0D'/%3e%3c/svg%3e");
}
.form-range::-webkit-slider-thumb { background-color: var(--brand); }
.form-range::-webkit-slider-thumb:active { background-color: color-mix(in srgb, var(--brand) 60%, #fff); }
.form-range::-moz-range-thumb { background-color: var(--brand); }
.form-range::-moz-range-thumb:active { background-color: color-mix(in srgb, var(--brand) 60%, #fff); }
.form-range:focus::-webkit-slider-thumb { box-shadow: 0 0 0 1px #fff, 0 0 0 .25rem var(--bs-focus-ring-color); }
.form-range:focus::-moz-range-thumb { box-shadow: 0 0 0 1px #fff, 0 0 0 .25rem var(--bs-focus-ring-color); }
.was-validated .form-control:valid, .form-control.is-valid,
.was-validated .form-select:valid, .form-select.is-valid { border-color: var(--bs-success); }
.was-validated .form-control:invalid, .form-control.is-invalid,
.was-validated .form-select:invalid, .form-select.is-invalid { border-color: var(--bs-danger); }
/* ── Navigation & lists ──────────────────────────────────────────────────── */
.nav {
--bs-nav-link-color: var(--bs-link-color);
--bs-nav-link-hover-color: var(--bs-link-hover-color);
}
.nav-pills {
--bs-nav-pills-link-active-color: var(--brand-on);
--bs-nav-pills-link-active-bg: var(--brand);
}
.nav-tabs .nav-link.active, .nav-tabs .nav-item.show .nav-link { border-top: 2px solid var(--brand); }
.pagination {
--bs-pagination-color: var(--bs-link-color);
--bs-pagination-hover-color: var(--bs-link-hover-color);
--bs-pagination-focus-color: var(--bs-link-hover-color);
--bs-pagination-focus-box-shadow: 0 0 0 .25rem var(--bs-focus-ring-color);
--bs-pagination-active-color: var(--brand-on);
--bs-pagination-active-bg: var(--brand);
--bs-pagination-active-border-color: var(--brand);
}
.list-group { --bs-list-group-active-color: var(--brand-on); --bs-list-group-active-bg: var(--brand); --bs-list-group-active-border-color: var(--brand); }
.dropdown-menu { --bs-dropdown-link-active-color: var(--brand-on); --bs-dropdown-link-active-bg: var(--brand); }
.accordion {
--bs-accordion-active-color: var(--bs-primary-text-emphasis);
--bs-accordion-active-bg: var(--bs-primary-bg-subtle);
--bs-accordion-btn-focus-border-color: var(--brand);
--bs-accordion-btn-focus-box-shadow: 0 0 0 .25rem var(--bs-focus-ring-color);
}
/* ── Feedback ────────────────────────────────────────────────────────────── */
.progress { --bs-progress-bar-bg: var(--brand); --bs-progress-bar-color: var(--brand-on); }
.alert-primary {
--bs-alert-color: var(--bs-primary-text-emphasis);
--bs-alert-bg: var(--bs-primary-bg-subtle);
--bs-alert-border-color: var(--bs-primary-border-subtle);
--bs-alert-link-color: var(--bs-primary-text-emphasis);
}
.table-primary {
--bs-table-color: var(--bs-primary-text-emphasis);
--bs-table-bg: var(--bs-primary-bg-subtle);
--bs-table-border-color: var(--bs-primary-border-subtle);
--bs-table-striped-bg: color-mix(in srgb, var(--bs-primary-bg-subtle) 94%, #000);
--bs-table-hover-bg: color-mix(in srgb, var(--bs-primary-bg-subtle) 92%, #000);
--bs-table-active-bg: color-mix(in srgb, var(--bs-primary-bg-subtle) 90%, #000);
}
.border-primary { border-color: var(--brand) !important; }
.bg-primary { background-color: var(--brand) !important; color: var(--brand-on); }
.text-primary { color: var(--brand-ink) !important; }
[data-bs-theme="dark"] .text-primary { color: var(--brand) !important; }
.bg-primary-subtle { background-color: var(--bs-primary-bg-subtle) !important; }
.text-primary-emphasis { color: var(--bs-primary-text-emphasis) !important; }
.border-primary-subtle { border-color: var(--bs-primary-border-subtle) !important; }
+59
View File
@@ -0,0 +1,59 @@
// DataTables (classic jQuery plugin) init, partilhado pelas listagens do
// backoffice que já tinham exportação CSV (audit 6.9). Cliente-only — todas
// as tabelas aqui são pequenas (bem abaixo de 60 linhas), por isso sem
// server-side processing nem AJAX.
//
// Tradução pt-PT oficial da DataTables (i18n/pt-PT.json), inline: não se
// carrega de CDN nenhum, pela mesma razão que o Roboto e o FontAwesome
// passaram a ser servidos daqui (RGPD — sem pedidos a terceiros antes do
// consentimento).
var DATATABLES_PT_PT = {
decimal: ',',
emptyTable: 'Não existem dados disponíveis na tabela',
info: 'A mostrar registos de _START_ até _END_ de um total de _TOTAL_ registos',
infoEmpty: 'A mostrar registos de 0 até 0 de um total de 0 registos',
infoFiltered: '(filtrados de um total de _MAX_ registos)',
infoPostFix: '',
thousands: '.',
lengthMenu: 'Mostrar _MENU_ registos',
loadingRecords: 'A carregar...',
processing: 'A processar...',
search: 'Pesquisar:',
zeroRecords: 'Não foram encontrados resultados',
paginate: {
first: 'Primeiro',
last: 'Último',
next: 'Seguinte',
previous: 'Anterior'
},
aria: {
sortAscending: ': ativar para ordenar a coluna de forma ascendente',
sortDescending: ': ativar para ordenar a coluna de forma descendente'
},
select: {
rows: {
_: 'Selecionadas %d linhas',
0: 'Nenhuma linha selecionada',
1: 'Selecionada 1 linha'
}
}
};
// opts pode trazer columnDefs (p.ex. marcar a coluna de Ações como
// orderable:false/searchable:false) e qualquer outra opção da DataTable —
// é feito merge profundo por cima destes valores por omissão.
function initDataTable(selector, opts) {
if (!window.jQuery || !jQuery.fn || !jQuery.fn.DataTable) return null;
var $table = jQuery(selector);
if (!$table.length) return null;
return $table.DataTable(jQuery.extend(true, {
language: DATATABLES_PT_PT,
pageLength: 25,
lengthMenu: [[10, 25, 50, -1], [10, 25, 50, 'Todos']],
responsive: true,
// Sem ordenação inicial: a ordem que a página já desenha (mais
// recente primeiro, ordem manual, etc.) é a que faz sentido por
// omissão — impor uma coluna aqui só a desfazia sem avisar.
order: []
}, opts || {}));
}
-7
View File
@@ -1,7 +0,0 @@
/*!
* Bootstrap v4.3.1 (https://getbootstrap.com/)
* Copyright 2011-2019 The Bootstrap Authors
* Copyright 2011-2019 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
*/:root{--blue:#007bff;--indigo:#6610f2;--purple:#6f42c1;--pink:#e83e8c;--red:#dc3545;--orange:#fd7e14;--yellow:#ffc107;--green:#28a745;--teal:#20c997;--cyan:#17a2b8;--white:#fff;--gray:#6c757d;--gray-dark:#343a40;--primary:#007bff;--secondary:#6c757d;--success:#28a745;--info:#17a2b8;--warning:#ffc107;--danger:#dc3545;--light:#f8f9fa;--dark:#343a40;--breakpoint-xs:0;--breakpoint-sm:576px;--breakpoint-md:768px;--breakpoint-lg:992px;--breakpoint-xl:1200px;--font-family-sans-serif:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-family-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace}*,::after,::before{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}article,aside,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-size:1rem;font-weight:400;line-height:1.5;color:#212529;text-align:left;background-color:#fff}[tabindex="-1"]:focus{outline:0!important}hr{box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem}p{margin-top:0;margin-bottom:1rem}abbr[data-original-title],abbr[title]{text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;border-bottom:0;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#007bff;text-decoration:none;background-color:transparent}a:hover{color:#0056b3;text-decoration:underline}a:not([href]):not([tabindex]){color:inherit;text-decoration:none}a:not([href]):not([tabindex]):focus,a:not([href]):not([tabindex]):hover{color:inherit;text-decoration:none}a:not([href]):not([tabindex]):focus{outline:0}code,kbd,pre,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em}pre{margin-top:0;margin-bottom:1rem;overflow:auto}figure{margin:0 0 1rem}img{vertical-align:middle;border-style:none}svg{overflow:hidden;vertical-align:middle}table{border-collapse:collapse}caption{padding-top:.75rem;padding-bottom:.75rem;color:#6c757d;text-align:left;caption-side:bottom}th{text-align:inherit}label{display:inline-block;margin-bottom:.5rem}button{border-radius:0}button:focus{outline:1px dotted;outline:5px auto -webkit-focus-ring-color}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,input{overflow:visible}button,select{text-transform:none}select{word-wrap:normal}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{padding:0;border-style:none}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}input[type=date],input[type=datetime-local],input[type=month],input[type=time]{-webkit-appearance:listbox}textarea{overflow:auto;resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;max-width:100%;padding:0;margin-bottom:.5rem;font-size:1.5rem;line-height:inherit;color:inherit;white-space:normal}progress{vertical-align:baseline}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:none}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}summary{display:list-item;cursor:pointer}template{display:none}[hidden]{display:none!important}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-bottom:.5rem;font-weight:500;line-height:1.2}.h1,h1{font-size:2.5rem}.h2,h2{font-size:2rem}.h3,h3{font-size:1.75rem}.h4,h4{font-size:1.5rem}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:6rem;font-weight:300;line-height:1.2}.display-2{font-size:5.5rem;font-weight:300;line-height:1.2}.display-3{font-size:4.5rem;font-weight:300;line-height:1.2}.display-4{font-size:3.5rem;font-weight:300;line-height:1.2}hr{margin-top:1rem;margin-bottom:1rem;border:0;border-top:1px solid rgba(0,0,0,.1)}.small,Line truncated
/*# sourceMappingURL=bootstrap.min.css.map */
-1
View File
@@ -1 +0,0 @@
{"version":3,"sources":["../../scss/bootstrap.scss","../../scss/_root.scss","../../scss/_reboot.scss","dist/css/bootstrap.css","../../scss/vendor/_rfs.scss","bootstrap.css","../../scss/mixins/_hover.scss","../../scss/_type.scss","../../scss/mixins/_lists.scss","../../scss/_images.scss","../../scss/mixins/_image.scss","../../scss/mixins/_border-radius.scss","../../scss/_code.scss","../../scss/_grid.scss","../../scss/mixins/_grid.scss","../../scss/mixins/_breakpoints.scss","../../scss/mixins/_grid-framework.scss","../../scss/_tables.scss","../../scss/mixins/_table-row.scss","../../scss/_forms.scss","../../scss/mixins/_transition.scss","../../scss/mixins/_forms.scss","../../scss/mixins/_gradients.scss","../../scss/_buttons.scss","../../scss/mixins/_buttons.scss","../../scss/_transitions.scss","../../scss/_dropdown.scss","../../scss/mixins/_caret.scss","../../scss/mixins/_nav-divider.scss","../../scss/_button-group.scss","../../scss/_input-group.scss","../../scss/_custom-forms.scss","../../scss/_nav.scss","../../scss/_navbar.scss","../../scss/_card.scss","../../scss/_breadcrumb.scss","../../scss/_pagination.scss","../../scss/mixins/_pagination.scss","../../scss/_badge.scss","../../scss/mixins/_badge.scss","../../scss/_jumbotron.scss","../../scss/_alert.scss","../../scss/mixins/_alert.scss","../../scss/_progress.scss","../../scss/_media.scss","../../scss/_list-group.scss","../../scss/mixins/_list-group.scss","../../scss/_close.scss","../../scss/_toasts.scss","../../scss/_modal.scss","../../scss/_tooltip.scss","../../scss/mixins/_reset-text.scss","../../scss/_popover.scss","../../scss/_carousel.scss","../../scss/mixins/_clearfix.scss","../../scss/_spinners.scss","../../scss/utilities/_align.scss","../../scss/mixins/_background-variant.scss","../../scss/utilities/_background.scss","../../scss/utilities/_borders.scss","../../scss/utilities/_display.scss","../../scss/utilities/_embed.scss","../../scss/utilities/_flex.scss","../../scss/utilities/_float.scss","../../scss/utilities/_overflow.scss","../../scss/utilities/_position.scss","../../scss/utilities/_screenreaders.scss","../../scss/mixins/_screen-reader.scss","../../scss/utilities/_shadows.scss","../../scss/utilities/_sizing.scss","../../scss/utilities/_stretched-link.scss","../../scss/utilities/_spacing.scss","../../scss/utilities/_text.scss","../../scss/mixins/_text-truncate.scss","../../scss/mixins/_text-emphasis.scss","../../scss/mixins/_text-hide.scss","../../scss/utilities/_visibility.scss","../../scss/_print.scss"],"names":[],"mappings":"AAAA;;;;;ACCA,MAGI,OAAA,QAAA,SAAA,QAAA,SAAA,QAAA,OAAA,QAAA,MAAA,QAAA,SAAA,QAAA,SAAA,QAAA,QAAA,QAAA,OAAA,QAAA,OAAA,QAAA,QAAA,KAAA,OAAA,QAAA,YAAA,QAIA,UAAA,QAAA,YAAA,QAAA,UAAA,QAAA,OAAA,QAAA,UAAA,QAAA,SAAA,QAAA,QAAA,QAAA,OAAA,QAIA,gBAAA,EAAA,gBAAA,MAAA,gBAAA,MAAA,gBAAA,MAAA,gBAAA,OAKF,yBAAA,aAAA,CAAA,kBAAA,CAAA,UAAA,CAAA,MAAA,CAAA,gBAAA,CAAA,KAAA,CAAA,WAAA,CAAA,UAAA,CAAA,mBAAA,CAAA,gBAAA,CAAA,iBAAA,CAAA,mBACA,wBAAA,cAAA,CAAA,KAAA,CAAA,MAAA,CAAA,QAAA,CAAA,iBAAA,CAAA,aAAA,CAAA,UCAF,ECqBA,QADA,SDjBE,WAAA,WAGF,KACE,YAAA,WACA,YAAA,KACA,yBAAA,KACA,4BAAA,YAMF,QAAA,MAAA,WAAA,OAAA,OAAA,OAAA,OAAA,KAAA,IAAA,QACE,QAAA,MAUF,KACE,OAAA,EACA,YAAA,aAAA,CAAA,kBAAA,CAAA,UAAA,CAAA,MAAA,CAAA,gBAAA,CAAA,KAAA,CAAA,WAAA,CAAA,UAAA,CAAA,mBAAA,CAAA,gBAAA,CAAA,iBAAA,CAAA,mBEgFI,UAAA,KF9EJ,YAAA,IACA,YAAA,IACA,MAAA,QACA,WAAA,KACA,iBAAA,KGYF,0CHCE,QAAA,YASF,GACE,WAAA,YACA,OAAA,EACA,SAAA,QAaF,GAAA,GAAA,GAAA,GAAA,GAAA,GACE,WAAA,EACA,cAAA,MAOF,EACE,WAAA,EACA,cAAA,KChBF,0BD2BA,YAEE,gBAAA,UACA,wBAAA,UAAA,OAAA,gBAAA,UAAA,OACA,OAAA,KACA,cAAA,EACA,iCAAA,KAAA,yBAAA,KAGF,QACE,cAAA,KACA,WAAA,OACA,YAAA,QCrBF,GDwBA,GCzBA,GD4BE,WAAA,EACA,cAAA,KAGF,MCxBA,MACA,MAFA,MD6BE,cAAA,EAGF,GACE,YAAA,IAGF,GACE,cAAA,MACA,YAAA,EAGF,WACE,OAAA,EAAA,EAAA,KAGF,ECzBA,OD2BE,YAAA,OAGF,MExFI,UAAA,IFiGJ,IC9BA,IDgCE,SAAA,SEnGE,UAAA,IFqGF,YAAA,EACA,eAAA,SAGF,IAAM,OAAA,OACN,IAAM,IAAA,MAON,EACE,MAAA,QACA,gBAAA,KACA,iBAAA,YIhLA,QJmLE,MAAA,QACA,gBAAA,UASJ,cACE,MAAA,QACA,gBAAA,KI/LA,oBJkME,MAAA,QACA,gBAAA,KC/BJ,KACA,IDuCA,ICtCA,KD0CE,YAAA,cAAA,CAAA,KAAA,CAAA,MAAA,CAAA,QAAA,CAAA,iBAAA,CAAA,aAAA,CAAA,UEpJE,UAAA,IFwJJ,IAEE,WAAA,EAEA,cAAA,KAEA,SAAA,KAQF,OAEE,OAAA,EAAA,EAAA,KAQF,IACE,eAAA,OACA,aAAA,KAGF,IAGE,SAAA,OACA,eAAA,OAQF,MACE,gBAAA,SAGF,QACE,YAAA,OACA,eAAA,OACA,MAAA,QACA,WAAA,KACA,aAAA,OAGF,GAGE,WAAA,QAQF,MAEE,QAAA,aACA,cAAA,MAMF,OAEE,cAAA,EAOF,aACE,QAAA,IAAA,OACA,QAAA,IAAA,KAAA,yBC1EF,OD6EA,MC3EA,SADA,OAEA,SD+EE,OAAA,EACA,YAAA,QErPE,UAAA,QFuPF,YAAA,QAGF,OC7EA,MD+EE,SAAA,QAGF,OC7EA,OD+EE,eAAA,KAMF,OACE,UAAA,OC7EF,cACA,aACA,cDkFA,OAIE,mBAAA,OCjFF,6BACA,4BACA,6BDoFE,sBAKI,OAAA,QCpFN,gCACA,+BACA,gCDwFA,yBAIE,QAAA,EACA,aAAA,KCvFF,qBD0FA,kBAEE,WAAA,WACA,QAAA,EAIF,iBC1FA,2BACA,kBAFA,iBDoGE,mBAAA,QAGF,SACE,SAAA,KAEA,OAAA,SAGF,SAME,UAAA,EAEA,QAAA,EACA,OAAA,EACA,OAAA,EAKF,OACE,QAAA,MACA,MAAA,KACA,UAAA,KACA,QAAA,EACA,cAAA,MEjSI,UAAA,OFmSJ,YAAA,QACA,MAAA,QACA,YAAA,OAGF,SACE,eAAA,SGzGF,yCFGA,yCD4GE,OAAA,KG1GF,cHkHE,eAAA,KACA,mBAAA,KG9GF,yCHsHE,mBAAA,KAQF,6BACE,KAAA,QACA,mBAAA,OAOF,OACE,QAAA,aAGF,QACE,QAAA,Line truncated
-7
View File
@@ -1,7 +0,0 @@
/*!
* Bootstrap v4.3.1 (https://getbootstrap.com/)
* Copyright 2011-2019 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors)
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
*/
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports,require("jquery"),require("popper.js")):"function"==typeof define&&define.amd?define(["exports","jquery","popper.js"],e):e((t=t||self).bootstrap={},t.jQuery,t.Popper)}(this,function(t,g,u){"use strict";function i(t,e){for(var n=0;n<e.length;n++){var i=e[n];i.enumerable=i.enumerable||!1,i.configurable=!0,"value"in i&&(i.writable=!0),Object.defineProperty(t,i.key,i)}}function s(t,e,n){return e&&i(t.prototype,e),n&&i(t,n),t}function l(o){for(var t=1;t<arguments.length;t++){var r=null!=arguments[t]?arguments[t]:{},e=Object.keys(r);"function"==typeof Object.getOwnPropertySymbols&&(e=e.concat(Object.getOwnPropertySymbols(r).filter(function(t){return Object.getOwnPropertyDescriptor(r,t).enumerable}))),e.forEach(function(t){var e,n,i;e=o,i=r[n=t],n in e?Object.defineProperty(e,n,{value:i,enumerable:!0,configurable:!0,writable:!0}):e[n]=i})}return o}g=g&&g.hasOwnProperty("default")?g.default:g,u=u&&u.hasOwnProperty("default")?u.default:u;var e="transitionend";function n(t){var e=this,n=!1;return g(this).one(_.TRANSITION_END,function(){n=!0}),setTimeout(function(){n||_.triggerTransitionEnd(e)},t),this}var _={TRANSITION_END:"bsTransitionEnd",getUID:function(t){for(;t+=~~(1e6*Math.random()),document.getElementById(t););return t},getSelectorFromElement:function(t){var e=t.getAttribute("data-target");if(!e||"#"===e){var n=t.getAttribute("href");e=n&&"#"!==n?n.trim():""}try{return document.querySelector(e)?e:null}catch(t){return null}},getTransitionDurationFromElement:function(t){if(!t)return 0;var e=g(t).css("transition-duration"),n=g(t).css("transition-delay"),i=parseFloat(e),o=parseFloat(n);return i||o?(e=e.split(",")[0],n=n.split(",")[0],1e3*(parseFloat(e)+parseFloat(n))):0},reflow:function(t){return t.offsetHeight},triggerTransitionEnd:function(t){g(t).trigger(e)},supportsTransitionEnd:function(){return Boolean(e)},isElement:function(t){return(t[0]||t).nodeType},typeCheckConfig:function(t,e,n){for(var i in n)if(Object.prototype.hasOwnProperty.call(n,i)){var o=n[i],r=e[i],s=r&&_.isElement(r)?"element":(a=r,{}.toString.call(a).match(/\s([a-z]+)/i)[1].toLowerCase());if(!new RegExp(o).test(s))throw new Error(t.toUpperCase()+': Option "'+i+'" provided type "'+s+'" but expected type "'+o+'".')}var a},findShadowRoot:function(t){if(!document.documentElement.attachShadow)return null;if("function"!=typeof t.getRootNode)return t instanceof ShadowRoot?t:t.parentNode?_.findShadowRoot(t.parentNode):null;var e=t.getRootNode();return e instanceof ShadowRoot?e:null}};g.fn.emulateTransitionEnd=n,g.event.special[_.TRANSITION_END]={bindType:e,delegateType:e,handle:function(t){if(g(t.target).is(this))return t.handleObj.handler.apply(this,arguments)}};var o="alert",r="bs.alert",a="."+r,c=g.fn[o],h={CLOSE:"close"+a,CLOSED:"closed"+a,CLICK_DATA_API:"click"+a+".data-api"},f="alert",d="fade",m="show",p=function(){function i(t){this._element=t}var t=i.prototype;return t.close=function(t){var e=this._element;t&&(e=this._getRootElement(t)),this._triggerCloseEvent(e).isDefaultPrevented()||this._removeElement(e)},t.dispose=function(){g.removeData(this._element,r),this._element=null},t._getRootElement=function(t){var e=_.getSelectorFromElement(t),n=!1;return e&&(n=document.querySelector(e)),n||(n=g(t).closest("."+f)[0]),n},t._triggerCloseEvent=function(t){var e=g.Event(h.CLOSE);return g(t).trigger(e),e},t._removeElement=function(e){var n=this;if(g(e).removeClass(m),g(e).hasClass(d)){var t=_.getTransitionDurationFromElement(e);g(e).one(_.TRANSITION_END,function(t){return n._destroyElement(e,t)}).emulateTransitionEnd(t)}else this._destroyElement(e)},t._destroyElement=function(t){g(t).detach().trigger(h.CLOSED).remove()},i._jQueryInterface=function(n){return this.each(function(){var t=g(this),e=t.data(r);e||(e=new i(this),t.data(r,e)),"close"===n&&e[n](this)})},i._handleDismiss=function(e){return function(t){t&&t.preventDefault(),e.close(this)}},s(i,null,[{key:"VERSION",get:function(){return"4.3.1"}}]),i}();g(document).on(h.CLICK_DATA_API,'[data-dismiss="alert"]',p._handleDismiss(new p)),g.fn[o]=p._jQueryInterface,g.fn[o].Constructor=p,g.fn[o].noConflict=function(){return g.fn[o]=c,p._jQueryInterface};var v="button",y="bs.button",E="."+y,C=".data-api",T=g.fn[v],S="active",b="btn",I="focus",D='[data-toggle^="button"]',w='[data-toggle="buttons"]',A='input:not([type="hidden"])',N=".active",O=".btn",k={CLICK_DATA_API:"click"+E+C,FOCUS_BLUR_DATA_API:"focus"+E+C+" blur"+E+C},P=function(){function n(t){this._element=t}var t=n.prototype;return t.toggle=function(){var t=!0,e=!0,n=g(this._element).closest(w)[0];if(n){var i=this._element.querySelector(A);if(i){if("radio"===i.type)if(i.checked&&this._element.classList.contains(S))t=!1;else{var o=n.querySelector(N);o&&g(o).removeClass(S)}if(t){if(i.hasAttribute("disabled")||n.hasAttribute("disabled")||i.classList.contains("disabled")||n.classList.contains("disabled"))return;i.checked=!this._element.classList.contains(S),g(i).trigger("chLine truncated
//# sourceMappingURL=bootstrap.min.js.map
+27
View File
@@ -0,0 +1,27 @@
# DataTables 2.3.4 + Responsive 3.0.6
Descarregado de `https://cdn.datatables.net/2.3.4/` e
`https://cdn.datatables.net/responsive/3.0.6/` a 2026-09-26 e **commitado**.
Servido daqui, nunca do CDN (RGPD — sem pedidos a terceiros antes do
consentimento, mesma razão do Roboto e do FontAwesome vendorizados).
Mesma escolha que o `hub.andmore.pt` já tinha feito
(`public/assets/vendor/datatables-2.3.4/`) — sem esse pacote no packagist
mais recente que a 1.10.21, vendorizar à mão é a alternativa a ficar preso a
essa versão antiga (usa `$.isArray`/`$.trim`, removidos na jQuery 4).
A tradução pt-PT não vem de ficheiro nenhum daqui — está inline em
`assets/js/datatables-init.js`, pela mesma razão de não ir buscar nada a um
CDN.
## Como atualizar
curl -O https://cdn.datatables.net/<versão>/js/dataTables.min.js
curl -O https://cdn.datatables.net/<versão>/js/dataTables.bootstrap5.min.js
curl -O https://cdn.datatables.net/<versão>/css/dataTables.bootstrap5.min.css
curl -O https://cdn.datatables.net/responsive/<versão>/js/dataTables.responsive.min.js
curl -O https://cdn.datatables.net/responsive/<versão>/js/responsive.bootstrap5.min.js
curl -O https://cdn.datatables.net/responsive/<versão>/css/responsive.bootstrap5.min.css
numa pasta nova `datatables-<versão>`, apontar as 11 páginas de listagem
(`assets/js/datatables-init.js` fica na mesma) e apagar a antiga.
@@ -0,0 +1,5 @@
:root{--dt-row-selected: 13, 110, 253;--dt-row-selected-text: 255, 255, 255;--dt-row-selected-link: 228, 228, 228;--dt-row-stripe: 0, 0, 0;--dt-row-hover: 0, 0, 0;--dt-column-ordering: 0, 0, 0;--dt-header-align-items: center;--dt-header-vertical-align: middle;--dt-html-background: white}:root.dark{--dt-html-background: rgb(33, 37, 41)}table.dataTable tbody td.dt-control{text-align:center;cursor:pointer}table.dataTable tbody td.dt-control:before{display:inline-block;box-sizing:border-box;content:"";border-top:5px solid transparent;border-left:10px solid rgba(0, 0, 0, 0.5);border-bottom:5px solid transparent;border-right:0px solid transparent}table.dataTable tbody tr.dt-hasChild td.dt-control:before{border-top:10px solid rgba(0, 0, 0, 0.5);border-left:5px solid transparent;border-bottom:0px solid transparent;border-right:5px solid transparent}table.dataTable tfoot:empty{display:none}html.dark table.dataTable td.dt-control:before,:root[data-bs-theme=dark] table.dataTable td.dt-control:before,:root[data-theme=dark] table.dataTable td.dt-control:before{border-left-color:rgba(255, 255, 255, 0.5)}html.dark table.dataTable tr.dt-hasChild td.dt-control:before,:root[data-bs-theme=dark] table.dataTable tr.dt-hasChild td.dt-control:before,:root[data-theme=dark] table.dataTable tr.dt-hasChild td.dt-control:before{border-top-color:rgba(255, 255, 255, 0.5);border-left-color:transparent}div.dt-scroll{width:100%}div.dt-scroll-body thead tr,div.dt-scroll-body tfoot tr{height:0}div.dt-scroll-body thead tr th,div.dt-scroll-body thead tr td,div.dt-scroll-body tfoot tr th,div.dt-scroll-body tfoot tr td{height:0 !important;padding-top:0px !important;padding-bottom:0px !important;border-top-width:0px !important;border-bottom-width:0px !important}div.dt-scroll-body thead tr th div.dt-scroll-sizing,div.dt-scroll-body thead tr td div.dt-scroll-sizing,div.dt-scroll-body tfoot tr th div.dt-scroll-sizing,div.dt-scroll-body tfoot tr td div.dt-scroll-sizing{height:0 !important;overflow:hidden !important}table.dataTable thead>tr>th:active,table.dataTable thead>tr>td:active{outline:none}table.dataTable thead>tr>th.dt-orderable-asc span.dt-column-order:before,table.dataTable thead>tr>th.dt-ordering-asc span.dt-column-order:before,table.dataTable thead>tr>td.dt-orderable-asc span.dt-column-order:before,table.dataTable thead>tr>td.dt-ordering-asc span.dt-column-order:before{position:absolute;display:block;bottom:50%;content:"▲";content:"▲"/""}table.dataTable thead>tr>th.dt-orderable-desc span.dt-column-order:after,table.dataTable thead>tr>th.dt-ordering-desc span.dt-column-order:after,table.dataTable thead>tr>td.dt-orderable-desc span.dt-column-order:after,table.dataTable thead>tr>td.dt-ordering-desc span.dt-column-order:after{position:absolute;display:block;top:50%;content:"▼";content:"▼"/""}table.dataTable thead>tr>th.dt-orderable-asc span.dt-column-order,table.dataTable thead>tr>th.dt-orderable-desc span.dt-column-order,table.dataTable thead>tr>th.dt-ordering-asc span.dt-column-order,table.dataTable thead>tr>th.dt-ordering-desc span.dt-column-order,table.dataTable thead>tr>td.dt-orderable-asc span.dt-column-order,table.dataTable thead>tr>td.dt-orderable-desc span.dt-column-order,table.dataTable thead>tr>td.dt-ordering-asc span.dt-column-order,table.dataTable thead>tr>td.dt-ordering-desc span.dt-column-order{position:relative;width:12px;height:24px}table.dataTable thead>tr>th.dt-orderable-asc span.dt-column-order:before,table.dataTable thead>tr>th.dt-orderable-asc span.dt-column-order:after,table.dataTable thead>tr>th.dt-orderable-desc span.dt-column-order:before,table.dataTable thead>tr>th.dt-orderable-desc span.dt-column-order:after,table.dataTable thead>tr>th.dt-ordering-asc span.dt-column-order:before,table.dataTable thead>tr>th.dt-ordering-asc span.dt-column-order:after,table.dataTable thead>tr>th.dt-ordering-desc span.dt-column-order:before,table.dataTable thead>tr>th.dt-ordering-desc span.dt-column-order:after,table.dataTable thead>tr>td.dt-orderable-asc span.dt-column-order:before,table.dataTable thead>tr>td.dt-orderable-asc span.dt-column-order:after,table.dataTable thead>tr>td.dt-orderable-desc span.dt-column-order:before,table.dataTable thead>tr>td.dt-orderable-desc span.dt-column-order:after,table.dataTable thead>tr>td.dt-ordering-asc span.dt-column-order:before,table.dataTable thead>tr>td.dt-ordering-asc span.dt-column-order:after,table.dataTable thead>tr>td.dt-ordering-desc span.dt-column-order:before,table.dataTable thead>tr>td.dt-ordering-desc span.dt-column-order:after{left:0;opacity:.125;line-height:9px;font-size:.8em}table.dataTable thead>tr>th.dt-orderable-asc,table.dataTable thead>tr>th.dt-orderable-desc,table.dataTable thead>tr>td.dt-orderable-asc,table.dataTable thead>tr>td.dt-orderable-desc{cursor:pointer}table.dataTable thead>tr>th.dt-orderable-asc:hover,table.dataTable thead>tr>th.dt-orderable-desc:hover,table.dataTable thead>tr>td.dt-orderable-asc:hover,table.dataTable thead>tr>td.dt-orderable-desc:hover{oLine truncated
*
* ©2020 SpryMedia Ltd, all rights reserved.
* License: MIT datatables.net/license/mit
*/table.table.dataTable{clear:both;margin-bottom:0;max-width:none;border-spacing:0}table.table.dataTable.table-striped>tbody>tr:nth-of-type(2n+1)>*{box-shadow:none}table.table.dataTable>:not(caption)>*>*{background-color:var(--bs-table-bg)}table.table.dataTable>tbody>tr{background-color:transparent}table.table.dataTable>tbody>tr.selected>*{box-shadow:inset 0 0 0 9999px rgb(13, 110, 253);box-shadow:inset 0 0 0 9999px rgb(var(--dt-row-selected));color:rgb(255, 255, 255);color:rgb(var(--dt-row-selected-text))}table.table.dataTable>tbody>tr.selected a{color:rgb(228, 228, 228);color:rgb(var(--dt-row-selected-link))}table.table.dataTable.table-striped>tbody>tr:nth-of-type(2n+1)>*{box-shadow:inset 0 0 0 9999px rgba(var(--dt-row-stripe), 0.05)}table.table.dataTable.table-striped>tbody>tr:nth-of-type(2n+1).selected>*{box-shadow:inset 0 0 0 9999px rgba(13, 110, 253, 0.95);box-shadow:inset 0 0 0 9999px rgba(var(--dt-row-selected), 0.95)}table.table.dataTable.table-hover>tbody>tr:hover>*{box-shadow:inset 0 0 0 9999px rgba(var(--dt-row-hover), 0.075)}table.table.dataTable.table-hover>tbody>tr.selected:hover>*{box-shadow:inset 0 0 0 9999px rgba(13, 110, 253, 0.975);box-shadow:inset 0 0 0 9999px rgba(var(--dt-row-selected), 0.975)}div.dt-container div.dt-layout-start>*:not(:last-child){margin-right:1em}div.dt-container div.dt-layout-end>*:not(:first-child){margin-left:1em}div.dt-container div.dt-layout-full{width:100%}div.dt-container div.dt-layout-full>*:only-child{margin-left:auto;margin-right:auto}div.dt-container div.dt-layout-table>div{display:block !important}@media screen and (max-width: 767px){div.dt-container div.dt-layout-start>*:not(:last-child){margin-right:0}div.dt-container div.dt-layout-end>*:not(:first-child){margin-left:0}}div.dt-container{position:relative}div.dt-container div.dt-length label{font-weight:normal;text-align:left;white-space:nowrap}div.dt-container div.dt-length select{width:auto;display:inline-block;margin-right:.5em}div.dt-container div.dt-search{text-align:right}div.dt-container div.dt-search label{font-weight:normal;white-space:nowrap;text-align:left}div.dt-container div.dt-search input{margin-left:.5em;display:inline-block;width:auto}div.dt-container div.dt-paging{margin:0}div.dt-container div.dt-paging ul.pagination{margin:2px 0;flex-wrap:wrap}div.dt-container div.dt-row{position:relative}div.dt-scroll-head table.dataTable{margin-bottom:0 !important}div.dt-scroll-body{border-bottom-color:var(--bs-border-color);border-bottom-width:var(--bs-border-width);border-bottom-style:solid}div.dt-scroll-body>table{border-top:none;margin-top:0 !important;margin-bottom:0 !important}div.dt-scroll-body>table>tbody>tr:first-child{border-top-width:0}div.dt-scroll-body>table>thead>tr{border-width:0 !important}div.dt-scroll-body>table>tbody>tr:last-child>*{border-bottom:none}div.dt-scroll-foot>.dt-scroll-footInner{box-sizing:content-box}div.dt-scroll-foot>.dt-scroll-footInner>table{margin-top:0 !important;border-top:none}div.dt-scroll-foot>.dt-scroll-footInner>table>tfoot>tr:first-child{border-top-width:0 !important}@media screen and (max-width: 767px){div.dt-container div.dt-length,div.dt-container div.dt-search,div.dt-container div.dt-info,div.dt-container div.dt-paging{text-align:center}div.dt-container .row{--bs-gutter-y: 0.5rem}div.dt-container div.dt-paging ul.pagination{justify-content:center !important}}table.dataTable.table-sm>thead>tr th.dt-orderable-asc,table.dataTable.table-sm>thead>tr th.dt-orderable-desc,table.dataTable.table-sm>thead>tr th.dt-ordering-asc,table.dataTable.table-sm>thead>tr th.dt-ordering-desc,table.dataTable.table-sm>thead>tr td.dt-orderable-asc,table.dataTable.table-sm>thead>tr td.dt-orderable-desc,table.dataTable.table-sm>thead>tr td.dt-ordering-asc,table.dataTable.table-sm>thead>tr td.dt-ordering-desc{padding-right:.25rem}table.dataTable.table-sm>thead>tr th.dt-orderable-asc span.dt-column-order,table.dataTable.table-sm>thead>tr th.dt-orderable-desc span.dt-column-order,table.dataTable.table-sm>thead>tr th.dt-ordering-asc span.dt-column-order,table.dataTable.table-sm>thead>tr th.dt-ordering-desc span.dt-column-order,table.dataTable.table-sm>thead>tr td.dt-orderable-asc span.dt-column-order,table.dataTable.table-sm>thead>tr td.dt-orderable-desc span.dt-column-order,table.dataTable.table-sm>thead>tr td.dt-ordering-asc span.dt-column-order,table.dataTable.table-sm>thead>tr td.dt-ordering-desc span.dt-column-order{right:.25rem}table.dataTable.table-sm>thead>tr th.dt-type-date span.dt-column-order,table.dataTable.table-sm>thead>tr th.dt-type-numeric span.dt-column-order,table.dataTable.table-sm>thead>tr td.dt-type-date span.dt-column-order,table.dataTable.table-sm>thead>tr td.dt-type-numeric span.dt-column-order{left:.25rem}div.dt-scroll-head table.table-bordered{border-bottom-width:0}div.table-responsive>div.dt-container>div.row{margin:0}div.table-responsive>div.dt-container>div.row>div[class^=col-]:first-child{padding-left:0}div.table-responsive>div.dt-container>div.row>dLine truncated
@@ -0,0 +1,4 @@
/*! DataTables Bootstrap 5 integration
* © SpryMedia Ltd - datatables.net/license
*/
!function(n){var o,r;"function"==typeof define&&define.amd?define(["jquery","datatables.net"],function(t){return n(t,window,document)}):"object"==typeof exports?(o=require("jquery"),r=function(t,e){e.fn.dataTable||require("datatables.net")(t,e)},"undefined"==typeof window?module.exports=function(t,e){return t=t||window,e=e||o(t),r(t,e),n(e,0,t.document)}:(r(window,o),module.exports=n(o,window,window.document))):n(jQuery,window,document)}(function(d,t,e){"use strict";var n=d.fn.dataTable;return d.extend(!0,n.defaults,{renderer:"bootstrap"}),d.extend(!0,n.ext.classes,{container:"dt-container dt-bootstrap5",search:{input:"form-control form-control-sm"},length:{select:"form-select form-select-sm"},processing:{container:"dt-processing card"},layout:{row:"row mt-2 justify-content-between",cell:"d-md-flex justify-content-between align-items-center",tableCell:"col-12",start:"dt-layout-start col-md-auto me-auto",end:"dt-layout-end col-md-auto ms-auto",full:"dt-layout-full col-md"}}),n.ext.renderer.pagingButton.bootstrap=function(t,e,n,o,r){var a=["dt-paging-button","page-item"],o=(o&&a.push("active"),r&&a.push("disabled"),d("<li>").addClass(a.join(" ")));return{display:o,clicker:d("<button>",{class:"page-link",role:"link",type:"button"}).html(n).appendTo(o)}},n.ext.renderer.pagingContainer.bootstrap=function(t,e){return d("<ul/>").addClass("pagination").append(e)},n});
+4
View File
@@ -0,0 +1,4 @@
/*! DataTables 2.3.4
* © SpryMedia Ltd - datatables.net/license
*/
!function(n){"use strict";var r;"function"==typeof define&&define.amd?define(["jquery"],function(e){return n(e,window,document)}):"object"==typeof exports?(r=require("jquery"),"undefined"==typeof window?module.exports=function(e,t){return e=e||window,t=t||r(e),n(t,e,e.document)}:module.exports=n(r,window,window.document)):window.DataTable=n(jQuery,window,document)}(function(H,W,S){"use strict";function f(e){var t=parseInt(e,10);return!isNaN(t)&&isFinite(e)?t:null}function c(e,t,n,r){var a=typeof e,o="string"==a;return"number"==a||"bigint"==a||!(!r||!_(e))||(t&&o&&(e=E(e,t)),n&&o&&(e=e.replace(P,"")),!isNaN(parseFloat(e))&&isFinite(e))}function n(e,t,n,r){var a;return!(!r||!_(e))||("string"!=typeof e||!e.match(/<(input|select)/i))&&(_(a=e)||"string"==typeof a)&&!!c(w(e),t,n,r)||null}function v(e,t,n,r){var a=[],o=0,i=t.length;if(void 0!==r)for(;o<i;o++)e[t[o]]&&e[t[o]][n]&&a.push(e[t[o]][n][r]);else for(;o<i;o++)e[t[o]]&&a.push(e[t[o]][n]);return a}function h(e,t){var n,r=[];void 0===t?(t=0,n=e):(n=t,t=e);for(var a=t;a<n;a++)r.push(a);return r}function A(e){for(var t=[],n=0,r=e.length;n<r;n++)e[n]&&t.push(e[n]);return t}var T,X,r,e,V=function(e,P){var E,k,M;return V.factory(e,P)?V:this instanceof V?H(e).DataTable(P):(k=void 0===(P=e),M=(E=this).length,k&&(P={}),this.api=function(){return new X(this)},this.each(function(){var t=1<M?tt({},P,!0):P,e=0,n=this.getAttribute("id"),r=V.defaults,a=H(this);if("table"!=this.nodeName.toLowerCase())z(null,0,"Non-table node initialisation ("+this.nodeName+")",2);else{t.on&&t.on.options&&lt(a,"options",t.on.options),a.trigger("options.dt",t),Q(r),K(r.column),B(r,r,!0),B(r.column,r.column,!0),B(r,H.extend(t,st(a.data())),!0);var o=V.settings;for(e=0,j=o.length;e<j;e++){var i=o[e];if(i.nTable==this||i.nTHead&&i.nTHead.parentNode==this||i.nTFoot&&i.nTFoot.parentNode==this){var l=(void 0!==t.bRetrieve?t:r).bRetrieve,s=(void 0!==t.bDestroy?t:r).bDestroy;if(k||l)return i.oInstance;if(s){new V.Api(i).destroy();break}return void z(i,0,"Cannot reinitialise DataTable",3)}if(i.sTableId==this.id){o.splice(e,1);break}}null!==n&&""!==n||(n="DataTables_Table_"+V.ext._unique++,this.id=n),a.children("colgroup").remove();var u,c=H.extend(!0,{},V.models.oSettings,{sDestroyWidth:a[0].style.width,sInstance:n,sTableId:n,colgroup:H("<colgroup>").prependTo(this),fastData:function(e,t,n){return q(c,e,t,n)}}),n=(c.nTable=this,c.oInit=t,o.push(c),c.api=new X(c),c.oInstance=1===E.length?E:a.dataTable(),Q(t),t.aLengthMenu&&!t.iDisplayLength&&(t.iDisplayLength=Array.isArray(t.aLengthMenu[0])?t.aLengthMenu[0][0]:H.isPlainObject(t.aLengthMenu[0])?t.aLengthMenu[0].value:t.aLengthMenu[0]),t=tt(H.extend(!0,{},r),t),$(c.oFeatures,t,["bPaginate","bLengthChange","bFilter","bSort","bSortMulti","bInfo","bProcessing","bAutoWidth","bSortClasses","bServerSide","bDeferRender"]),$(c,t,["ajax","fnFormatNumber","sServerMethod","aaSorting","aaSortingFixed","aLengthMenu","sPaginationType","iStateDuration","bSortCellsTop","iTabIndex","sDom","fnStateLoadCallback","fnStateSaveCallback","renderer","searchDelay","rowId","caption","layout","orderDescReverse","orderIndicators","orderHandler","titleRow","typeDetect",["iCookieDuration","iStateDuration"],["oSearch","oPreviousSearch"],["aoSearchCols","aoPreSearchCols"],["iDisplayLength","_iDisplayLength"]]),$(c.oScroll,t,[["sScrollX","sX"],["sScrollXInner","sXInner"],["sScrollY","sY"],["bScrollCollapse","bCollapse"]]),$(c.oLanguage,t,"fnInfoCallback"),Y(c,"aoDrawCallback",t.fnDrawCallback),Y(c,"aoStateSaveParams",t.fnStateSaveParams),Y(c,"aoStateLoadParams",t.fnStateLoadParams),Y(c,"aoStateLoaded",t.fnStateLoaded),Y(c,"aoRowCallback",t.fnRowCallback),Y(c,"aoRowCreatedCallback",t.fnCreatedRow),Y(c,"aoHeaderCallback",t.fnHeaderCallback),Y(c,"aoFooterCallback",t.fnFooterCallback),Y(c,"aoInitComplete",t.fnInitComplete),Y(c,"aoPreDrawCallback",t.fnPreDrawCallback),c.rowIdFn=U(t.rowId),t.on&&Object.keys(t.on).forEach(function(e){lt(a,e,t.on[e])}),c),d=(V.__browser||(f={},V.__browser=f,p=H("<div/>").css({position:"fixed",top:0,left:-1*W.pageXOffset,height:1,width:1,overflow:"hidden"}).append(H("<div/>").css({position:"absolute",top:1,left:1,width:100,overflow:"scroll"}).append(H("<div/>").css({width:"100%",height:10}))).appendTo("body"),d=p.children(),u=d.children(),f.barWidth=d[0].offsetWidth-d[0].clientWidth,f.bScrollbarLeft=1!==Math.round(u.offset().left),p.remove()),H.extend(n.oBrowser,V.__browser),n.oScroll.iBarWidth=V.__browser.barWidth,c.oClasses),f=(H.extend(d,V.ext.classes,t.oClasses),a.addClass(d.table),c.oFeatures.bPaginate||(t.iDisplayStart=0),void 0===c.iInitDisplayStart&&(c.iInitDisplayStart=t.iDisplayStart,c._iDisplayStart=t.iDisplayStart),t.iDeferLoading),h=(null!==f&&(c.deferLoading=!0,u=Array.isArray(f),c._iRecordsDisplay=u?f[0]:f,c._iRecordsTotal=u?f[1]:f),[]),p=this.getElementsByTagName("thead"),n=Ne(c,p[0]);if(t.aoColumns)h=t.aoColumns;else if(n.length)for(j=n[e=0].length;e<j;e++)h.push(null);for(e=0,j=h.length;e<j;e++)ee(c);var g,m,v,b,y,D,x,S=c,w=t.aoColumnDefs,T=hLine truncated
@@ -0,0 +1,4 @@
/*! Responsive 3.0.6
* © SpryMedia Ltd - datatables.net/license
*/
!function(n){var i,r;"function"==typeof define&&define.amd?define(["jquery","datatables.net"],function(e){return n(e,window,document)}):"object"==typeof exports?(i=require("jquery"),r=function(e,t){t.fn.dataTable||require("datatables.net")(e,t)},"undefined"==typeof window?module.exports=function(e,t){return e=e||window,t=t||i(e),r(e,t),n(t,e,e.document)}:(r(window,i),module.exports=n(i,window,window.document))):n(jQuery,window,document)}(function(b,y,d){"use strict";function a(e,t){if(!i.versionCheck||!i.versionCheck("2"))throw"DataTables Responsive requires DataTables 2 or newer";this.s={childNodeStore:{},columns:[],current:[],dt:new i.Api(e)},this.s.dt.settings()[0].responsive||(t&&"string"==typeof t.details?t.details={type:t.details}:t&&!1===t.details?t.details={type:!1}:t&&!0===t.details&&(t.details={type:"inline"}),this.c=b.extend(!0,{},a.defaults,i.defaults.responsive,t),(e.responsive=this)._constructor())}var i=b.fn.dataTable,e=(b.extend(a.prototype,{_constructor:function(){var o=this,r=this.s.dt,t=b(y).innerWidth(),e=(r.settings()[0]._responsive=this,b(y).on("orientationchange.dtr",i.util.throttle(function(){var e=b(y).innerWidth();e!==t&&(o._resize(),t=e)})),r.on("row-created.dtr",function(e,t,n,i){-1!==b.inArray(!1,o.s.current)&&b(">td, >th",t).each(function(e){e=r.column.index("toData",e);!1===o.s.current[e]&&b(this).css("display","none").addClass("dtr-hidden")})}),r.on("destroy.dtr",function(){r.off(".dtr"),b(r.table().body()).off(".dtr"),b(y).off("resize.dtr orientationchange.dtr"),r.cells(".dtr-control").nodes().to$().removeClass("dtr-control"),b(r.table().node()).removeClass("dtr-inline collapsed"),b.each(o.s.current,function(e,t){!1===t&&o._setColumnVis(e,!0)})}),this.c.breakpoints.sort(function(e,t){return e.width<t.width?1:e.width>t.width?-1:0}),this._classLogic(),this.c.details);!1!==e.type&&(o._detailsInit(),r.on("column-visibility.dtr",function(){o._timer&&clearTimeout(o._timer),o._timer=setTimeout(function(){o._timer=null,o._classLogic(),o._resizeAuto(),o._resize(!0),o._redrawChildren()},100)}),r.on("draw.dtr",function(){o._redrawChildren()}),b(r.table().node()).addClass("dtr-"+e.type)),r.on("column-calc.dt",function(e,t){for(var n=o.s.current,i=0;i<n.length;i++){var r=t.visible.indexOf(i);!1===n[i]&&0<=r&&t.visible.splice(r,1)}}),r.on("preXhr.dtr",function(){var e=[];r.rows().every(function(){this.child.isShown()&&e.push(this.id(!0))}),r.one("draw.dtr",function(){o._resizeAuto(),o._resize(),r.rows(e).every(function(){o._detailsDisplay(this,!1)})})}),r.on("draw.dtr",function(){r.page.info().serverSide&&(o.s.childNodeStore={}),o._controlClass()}).ready(function(){o._resizeAuto(),o._resize(),r.on("column-sizing.dtr",function(){o._resizeAuto(),o._resize()})}),r.on("column-reorder.dtr",function(e,t,n){o._classLogic(),o._resizeAuto(),o._resize(!0)})},_colGroupAttach:function(e,t,n){var i=null;if(t[n].get(0).parentNode!==e[0]){for(var r=n+1;r<t.length;r++)if(e[0]===t[r].get(0).parentNode){i=r;break}null!==i?t[n].insertBefore(t[i][0]):e.append(t[n])}},_childNodes:function(e,t,n){var i=t+"-"+n;if(this.s.childNodeStore[i])return this.s.childNodeStore[i];for(var r=[],o=e.cell(t,n).node().childNodes,s=0,d=o.length;s<d;s++)r.push(o[s]);return this.s.childNodeStore[i]=r},_childNodesRestore:function(e,t,n){var i=t+"-"+n;if(this.s.childNodeStore[i]){var r=e.cell(t,n).node(),e=this.s.childNodeStore[i];if(0<e.length){for(var o=e[0].parentNode.childNodes,s=[],d=0,a=o.length;d<a;d++)s.push(o[d]);for(var l=0,c=s.length;l<c;l++)r.appendChild(s[l]),console.log("restore",i,r,s[l])}this.s.childNodeStore[i]=void 0}},_columnsVisibility:function(n){for(var i=this.s.dt,e=this.s.columns,t=e.map(function(e,t){return{columnIdx:t,priority:e.priority}}).sort(function(e,t){return e.priority!==t.priority?e.priority-t.priority:e.columnIdx-t.columnIdx}),r=b.map(e,function(e,t){return!1===i.column(t).visible()?"not-visible":(!e.auto||null!==e.minWidth)&&(!0===e.auto?"-":-1!==b.inArray(n,e.includeIn))}),o=0,s=0,d=r.length;s<d;s++)!0===r[s]&&(o+=e[s].minWidth);var a=i.settings()[0].oScroll,a=a.sY||a.sX?a.iBarWidth:0,l=i.table().container().offsetWidth-a-o;for(s=0,d=r.length;s<d;s++)e[s].control&&(l-=e[s].minWidth);var c=!1;for(s=0,d=t.length;s<d;s++){var u=t[s].columnIdx;"-"===r[u]&&!e[u].control&&e[u].minWidth&&(c||l-e[u].minWidth<0?r[u]=!(c=!0):r[u]=!0,l-=e[u].minWidth)}var h=!1;for(s=0,d=e.length;s<d;s++)if(!e[s].control&&!e[s].never&&!1===r[s]){h=!0;break}for(s=0,d=e.length;s<d;s++)e[s].control&&(r[s]=h),"not-visible"===r[s]&&(r[s]=!1);return-1===b.inArray(!0,r)&&(r[0]=!0),r},_classLogic:function(){function d(e,t,n,i){var r,o,s;if(n){if("max-"===n)for(r=a._find(t).width,o=0,s=l.length;o<s;o++)l[o].width<=r&&u(e,l[o].name);else if("min-"===n)for(r=a._find(t).width,o=0,s=l.length;o<s;o++)l[o].width>=r&&u(e,l[o].name);else if("not-"===n)for(o=0,s=l.length;o<s;o++)-1===l[o].name.indexOf(i)&&u(e,l[o].name)}else c[e].includeIn.push(t)}var a=this,l=this.c.breakpoints,c=this.s.dt.columns().eq(0).map(function(e){var e=this.columLine truncated
@@ -0,0 +1 @@
table.dataTable.dtr-inline.collapsed>tbody>tr>td.child,table.dataTable.dtr-inline.collapsed>tbody>tr>th.child,table.dataTable.dtr-inline.collapsed>tbody>tr>td.dataTables_empty{cursor:default !important}table.dataTable.dtr-inline.collapsed>tbody>tr>td.child:before,table.dataTable.dtr-inline.collapsed>tbody>tr>th.child:before,table.dataTable.dtr-inline.collapsed>tbody>tr>td.dataTables_empty:before{display:none !important}table.dataTable.dtr-inline.collapsed>tbody>tr>td.dtr-control,table.dataTable.dtr-inline.collapsed>tbody>tr>th.dtr-control{cursor:pointer}table.dataTable.dtr-inline.collapsed>tbody>tr>td.dtr-control:before,table.dataTable.dtr-inline.collapsed>tbody>tr>th.dtr-control:before{margin-right:.5em;display:inline-block;box-sizing:border-box;content:"";border-top:5px solid transparent;border-left:10px solid rgba(0, 0, 0, 0.5);border-bottom:5px solid transparent;border-right:0px solid transparent}table.dataTable.dtr-inline.collapsed>tbody>tr>td.dtr-control.arrow-right::before,table.dataTable.dtr-inline.collapsed>tbody>tr>th.dtr-control.arrow-right::before{border-top:5px solid transparent;border-left:0px solid transparent;border-bottom:5px solid transparent;border-right:10px solid rgba(0, 0, 0, 0.5)}table.dataTable.dtr-inline.collapsed>tbody>tr.dtr-expanded>td.dtr-control:before,table.dataTable.dtr-inline.collapsed>tbody>tr.dtr-expanded>th.dtr-control:before{border-top:10px solid rgba(0, 0, 0, 0.5);border-left:5px solid transparent;border-bottom:0px solid transparent;border-right:5px solid transparent}table.dataTable.dtr-inline.collapsed.compact>tbody>tr>td.dtr-control,table.dataTable.dtr-inline.collapsed.compact>tbody>tr>th.dtr-control{padding-left:.333em}table.dataTable.dtr-column>tbody>tr>td.dtr-control,table.dataTable.dtr-column>tbody>tr>th.dtr-control,table.dataTable.dtr-column>tbody>tr>td.control,table.dataTable.dtr-column>tbody>tr>th.control{cursor:pointer}table.dataTable.dtr-column>tbody>tr>td.dtr-control:before,table.dataTable.dtr-column>tbody>tr>th.dtr-control:before,table.dataTable.dtr-column>tbody>tr>td.control:before,table.dataTable.dtr-column>tbody>tr>th.control:before{display:inline-block;box-sizing:border-box;content:"";border-top:5px solid transparent;border-left:10px solid rgba(0, 0, 0, 0.5);border-bottom:5px solid transparent;border-right:0px solid transparent}table.dataTable.dtr-column>tbody>tr>td.dtr-control.arrow-right::before,table.dataTable.dtr-column>tbody>tr>th.dtr-control.arrow-right::before,table.dataTable.dtr-column>tbody>tr>td.control.arrow-right::before,table.dataTable.dtr-column>tbody>tr>th.control.arrow-right::before{border-top:5px solid transparent;border-left:0px solid transparent;border-bottom:5px solid transparent;border-right:10px solid rgba(0, 0, 0, 0.5)}table.dataTable.dtr-column>tbody>tr.dtr-expanded td.dtr-control:before,table.dataTable.dtr-column>tbody>tr.dtr-expanded th.dtr-control:before,table.dataTable.dtr-column>tbody>tr.dtr-expanded td.control:before,table.dataTable.dtr-column>tbody>tr.dtr-expanded th.control:before{border-top:10px solid rgba(0, 0, 0, 0.5);border-left:5px solid transparent;border-bottom:0px solid transparent;border-right:5px solid transparent}table.dataTable>tbody>tr.child{padding:.5em 1em}table.dataTable>tbody>tr.child:hover{background:transparent !important}table.dataTable>tbody>tr.child ul.dtr-details{display:inline-block;list-style-type:none;margin:0;padding:0}table.dataTable>tbody>tr.child ul.dtr-details>li{border-bottom:1px solid #efefef;padding:.5em 0}table.dataTable>tbody>tr.child ul.dtr-details>li:first-child{padding-top:0}table.dataTable>tbody>tr.child ul.dtr-details>li:last-child{padding-bottom:0;border-bottom:none}table.dataTable>tbody>tr.child span.dtr-title{display:inline-block;min-width:75px;font-weight:bold}div.dtr-modal{position:fixed;box-sizing:border-box;top:0;left:0;height:100%;width:100%;z-index:100;padding:10em 1em}div.dtr-modal div.dtr-modal-display{position:absolute;top:0;left:0;bottom:0;right:0;width:50%;height:fit-content;max-height:75%;overflow:auto;margin:auto;z-index:102;overflow:auto;background-color:#f5f5f7;border:1px solid black;border-radius:.5em;box-shadow:0 12px 30px rgba(0, 0, 0, 0.6)}div.dtr-modal div.dtr-modal-content{position:relative;padding:2.5em}div.dtr-modal div.dtr-modal-content h2{margin-top:0}div.dtr-modal div.dtr-modal-close{position:absolute;top:6px;right:6px;width:22px;height:22px;text-align:center;border-radius:3px;cursor:pointer;z-index:12}div.dtr-modal div.dtr-modal-background{position:fixed;top:0;left:0;right:0;bottom:0;z-index:101;background:rgba(0, 0, 0, 0.6)}@media screen and (max-width: 767px){div.dtr-modal div.dtr-modal-display{width:95%}}html.dark table.dataTable>tbody>tr>td.dtr-control:before,html[data-bs-theme=dark] table.dataTable>tbody>tr>td.dtr-control:before{border-left-color:rgba(255, 255, 255, 0.5) !important}html.dark table.dataTable>tbody>tr>td.dtr-control.arrow-right::before,html[data-bs-theme=dark] table.dataTable>tbody>tr>td.dtr-control.arrow-right::before{border-rightLine truncated
@@ -0,0 +1,4 @@
/*! Bootstrap 5 integration for DataTables' Responsive
* © SpryMedia Ltd - datatables.net/license
*/
!function(t){var o,n;"function"==typeof define&&define.amd?define(["jquery","datatables.net-bs5","datatables.net-responsive"],function(e){return t(e,window,document)}):"object"==typeof exports?(o=require("jquery"),n=function(e,d){d.fn.dataTable||require("datatables.net-bs5")(e,d),d.fn.dataTable.Responsive||require("datatables.net-responsive")(e,d)},"undefined"==typeof window?module.exports=function(e,d){return e=e||window,d=d||o(e),n(e,d),t(d,e,e.document)}:(n(window,o),module.exports=t(o,window,window.document))):t(jQuery,window,document)}(function(s,e,l){"use strict";var u,d=s.fn.dataTable,t=d.Responsive.display,f=t.modal,p=s('<div class="modal fade dtr-bs-modal" role="dialog"><div class="modal-dialog" role="document"><div class="modal-content"><div class="modal-header"><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button></div><div class="modal-body"/></div></div></div>'),o=e.bootstrap;return d.Responsive.bootstrap=function(e){o=e},t.modal=function(r){var e;return!u&&o.Modal&&(e=function(){var e=d.use("bootstrap");if(e)return e;if(o)return o;throw new Error("No Bootstrap library. Set it with `DataTable.use(bootstrap);`")}(),u=new e.Modal(p[0])),function(e,d,t,o){if(u){var n,a,i=t();if(!1===i)return!1;if(d){if(!s.contains(l,p[0])||e.index()!==p.data("dtr-row-idx"))return null;p.find("div.modal-body").empty().append(i)}else r&&r.header&&(a=(n=p.find("div.modal-header")).find("button").detach(),n.empty().append('<h4 class="modal-title">'+r.header(e)+"</h4>").append(a)),p.find("div.modal-body").empty().append(i),p.data("dtr-row-idx",e.index()).one("hidden.bs.modal",o).appendTo("body"),u.show();return!0}return f(e,d,t,o)}},d});
+22
View File
@@ -0,0 +1,22 @@
{
"name": "andmore/fitme3070",
"description": "FitMe 3070 — site público + backoffice de ginásio (PHP simples, sem framework, sem build step).",
"type": "project",
"license": "proprietary",
"require": {
"php": ">=8.4",
"ext-pdo": "*",
"ext-pdo_mysql": "*",
"ext-mbstring": "*",
"ext-gd": "*",
"ext-curl": "*",
"ext-json": "*",
"ext-zip": "*",
"ext-intl": "*",
"phpmailer/phpmailer": "^7.1"
},
"config": {
"vendor-dir": "vendor",
"optimize-autoloader": true
}
}
Generated
+111
View File
@@ -0,0 +1,111 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "339b2034d0585b4537a1b64bf59fb577",
"packages": [
{
"name": "phpmailer/phpmailer",
"version": "v7.1.1",
"source": {
"type": "git",
"url": "https://github.com/PHPMailer/PHPMailer.git",
"reference": "1bc1716a507a65e039d4ac9d9adebbbd0d346e15"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/1bc1716a507a65e039d4ac9d9adebbbd0d346e15",
"reference": "1bc1716a507a65e039d4ac9d9adebbbd0d346e15",
"shasum": ""
},
"require": {
"ext-ctype": "*",
"ext-filter": "*",
"ext-hash": "*",
"php": ">=5.5.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "^1.0",
"doctrine/annotations": "^1.2.6 || ^1.13.3",
"php-parallel-lint/php-console-highlighter": "^1.0.0",
"php-parallel-lint/php-parallel-lint": "^1.3.2",
"phpcompatibility/php-compatibility": "^10.0.0@dev",
"squizlabs/php_codesniffer": "^3.13.5",
"yoast/phpunit-polyfills": "^1.0.4"
},
"suggest": {
"decomplexity/SendOauth2": "Adapter for using XOAUTH2 authentication",
"directorytree/imapengine": "For uploading sent messages via IMAP, see gmail example",
"ext-imap": "Needed to support advanced email address parsing according to RFC822",
"ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses",
"ext-openssl": "Needed for secure SMTP sending and DKIM signing",
"greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication",
"hayageek/oauth2-yahoo": "Needed for Yahoo XOAUTH2 authentication",
"league/oauth2-google": "Needed for Google XOAUTH2 authentication",
"psr/log": "For optional PSR-3 debug logging",
"symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)",
"thenetworg/oauth2-azure": "Needed for Microsoft XOAUTH2 authentication"
},
"type": "library",
"autoload": {
"psr-4": {
"PHPMailer\\PHPMailer\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1-only"
],
"authors": [
{
"name": "Marcus Bointon",
"email": "phpmailer@synchromedia.co.uk"
},
{
"name": "Jim Jagielski",
"email": "jimjag@gmail.com"
},
{
"name": "Andy Prevost",
"email": "codeworxtech@users.sourceforge.net"
},
{
"name": "Brent R. Matzelle"
}
],
"description": "PHPMailer is a full-featured email creation and transfer class for PHP",
"support": {
"issues": "https://github.com/PHPMailer/PHPMailer/issues",
"source": "https://github.com/PHPMailer/PHPMailer/tree/v7.1.1"
},
"funding": [
{
"url": "https://github.com/Synchro",
"type": "github"
}
],
"time": "2026-05-18T08:06:14+00:00"
}
],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
"php": ">=8.4",
"ext-pdo": "*",
"ext-pdo_mysql": "*",
"ext-mbstring": "*",
"ext-gd": "*",
"ext-curl": "*",
"ext-json": "*",
"ext-zip": "*",
"ext-intl": "*"
},
"platform-dev": {},
"plugin-api-version": "2.9.0"
}
+50 -34
View File
@@ -1,19 +1,13 @@
<?php
// Auth guards — require functions.php (and session_start) before this file.
// Auth guards — require functions.php (and the shared session, started by
// platform_connect() in config/db.php) before this file.
//
// This install owns its passwords: they live in `users.password_hash` and
// views/auth/login.php is a real form. There is no identity provider in front
// of the app.
function require_login(): array {
if (empty($_SESSION['user_id'])) {
$return = $_SERVER['REQUEST_URI'] ?? '';
$qs = $return ? ('?return_to=' . urlencode($return)) : '';
header('Location: ' . base_url('views/auth/login.php' . $qs));
exit;
}
return $_SESSION['user'];
}
// Auditoria 2026-09-24, ponto 1.3: estas funções deixaram de ter lógica
// própria de sessão/roles — delegam em
// shared/platform-tools/functions_auth.php (auth_check/auth_user/
// auth_at_least/auth_is/...), ligado em config/db.php ('auth' => 'shared').
// Os nomes e assinaturas mantêm-se exactamente iguais porque há ~30
// ficheiros em views/ a chamá-las; só o que está por baixo mudou.
// ─── Role ladder ─────────────────────────────────────────────────────────────
// sysadmin — super-admin (backups, platform update, app_base_url).
@@ -24,6 +18,12 @@ function require_login(): array {
// client — portal-only.
// is_admin()/require_admin() = sysadmin OR admin. is_staff()/require_staff()
// also includes the plain `staff` role.
//
// A hierarquia é agora expressa por posto numérico (auth_roles_register() em
// config/db.php: sysadmin=100, admin=80, staff=40, client=10) em vez dos
// antigos in_array() — auth_at_least('admin') dá exactamente o mesmo
// resultado que in_array($role, ['sysadmin','admin']) dava, porque staff(40)
// e client(10) ficam sempre abaixo de admin(80).
// Resolves the dashboard path for a given role. Used by index.php, the auth
// pages, and require_admin()'s bounce fallback so all four agree on routing.
@@ -36,12 +36,27 @@ function post_login_destination(string $role): string {
};
}
function require_login(): array {
// O «manter sessão iniciada» já corre em config/db.php
// (platform_connect(['remember' => true, ...]) chama auth_remember_resume()
// logo no arranque, antes de qualquer view), por isso não há aqui nada
// equivalente ao antigo remember_resume() manual — se havia um cookie
// válido, $_SESSION['user'] já está preenchido a esta altura.
if (!auth_check()) {
$return = $_SERVER['REQUEST_URI'] ?? '';
$qs = $return ? ('?return_to=' . urlencode($return)) : '';
header('Location: ' . base_url('views/auth/login.php' . $qs));
exit;
}
return auth_user();
}
// Accepts sysadmin + admin only. Plain staff and clients are bounced to the
// dashboard appropriate for their role (avoids redirect loops when an admin-
// only page bounces a staff user).
function require_admin(): array {
$user = require_login();
if (!in_array($user['role'], ['sysadmin', 'admin'])) {
if (!auth_at_least('admin')) {
header('Location: ' . base_url(post_login_destination($user['role'] ?? '')));
exit;
}
@@ -52,7 +67,7 @@ function require_admin(): array {
// Clients are redirected to the public index.
function require_staff(): array {
$user = require_login();
if (!in_array($user['role'], ['sysadmin', 'admin', 'staff'])) {
if (!auth_at_least('staff')) {
header('Location: ' . base_url('index.php'));
exit;
}
@@ -65,7 +80,7 @@ function require_staff(): array {
// the role-appropriate sessions list.
function require_session_ownership(array $session): array {
$user = require_staff();
if (in_array($user['role'], ['sysadmin', 'admin'], true)) return $user;
if (auth_at_least('admin')) return $user;
if ((int) ($session['photographer_id'] ?? 0) !== (int) $user['id']) {
header('Location: ' . base_url('views/sessions/index.php'));
exit;
@@ -77,7 +92,7 @@ function require_session_ownership(array $session): array {
// Plain admins are sent back to their dashboard.
function require_sysadmin(): array {
$user = require_login();
if ($user['role'] !== 'sysadmin') {
if (!auth_is('sysadmin')) {
header('Location: ' . base_url(post_login_destination($user['role'] ?? '')));
exit;
}
@@ -85,34 +100,34 @@ function require_sysadmin(): array {
}
function current_user(): ?array {
return $_SESSION['user'] ?? null;
return auth_user();
}
// True for sysadmin + admin (NOT plain staff).
function is_admin(): bool {
return in_array(current_user()['role'] ?? '', ['sysadmin', 'admin']);
return auth_at_least('admin');
}
// True for any backoffice user (sysadmin + admin + staff). Use this to show
// a UI element that any non-client backoffice user should see.
function is_staff(): bool {
return in_array(current_user()['role'] ?? '', ['sysadmin', 'admin', 'staff']);
return auth_at_least('staff');
}
function is_sysadmin(): bool {
return (current_user()['role'] ?? '') === 'sysadmin';
return auth_is('sysadmin');
}
// True for plain admins (not sysadmin, not staff). Useful for "hide from
// sysadmin" edge cases; most code wants is_admin() or is_sysadmin().
function is_admin_role(): bool {
return (current_user()['role'] ?? '') === 'admin';
return auth_is('admin');
}
// True for plain staff only — the assignment-only role. Used to scope queries
// to "photographer_id = me" instead of returning all sessions.
function is_staff_role(): bool {
return (current_user()['role'] ?? '') === 'staff';
return auth_is('staff');
}
// Back-compat shim: every is_designer() caller now means "is plain admin".
@@ -120,21 +135,22 @@ function is_designer(): bool {
return is_admin_role();
}
// Refreshes the session's user fields (name/email/role) without a full
// login — used after a profile edit or an admin-driven role change, where a
// full auth_login() (rehash checks, last_login stamp, audit "auth.login")
// would be the wrong event to log. Still rotates the session id: any
// privilege/credential change should get a fresh one.
function auth_set_session(array $user): void {
// Rotate the session id on every privilege change (login, registration,
// password reset). Defeats session fixation: an attacker who pre-seeded
// FIT_SESS now ends up holding a dead id while the real user gets a fresh
// one. The CSRF token is rotated alongside the session id.
auth_session_regenerate();
if (session_status() === PHP_SESSION_ACTIVE) {
session_regenerate_id(true);
unset($_SESSION['csrf_token']);
}
$_SESSION['user_id'] = $user['id'];
$_SESSION['user_id'] = (int) $user['id'];
$_SESSION['user'] = [
'id' => $user['id'],
'name' => $user['name'],
'email' => $user['email'],
'role' => $user['role'],
'id' => (int) $user['id'],
'name' => $user['name'],
'email' => $user['email'],
'role' => $user['role'],
];
}
+100 -21
View File
@@ -15,7 +15,7 @@ env_load(__DIR__ . '/../.env');
function _bootstrap_fail(string $logMsg): void {
@mkdir(__DIR__ . '/../logs', 0775, true);
@error_log('[' . date('c') . '] ' . $logMsg . "\n", 3, __DIR__ . '/../logs/db_error.log');
error_log('[fotografia bootstrap] ' . $logMsg);
error_log('[fitme bootstrap] ' . $logMsg);
http_response_code(500);
header('Content-Type: text/html; charset=utf-8');
$tpl = __DIR__ . '/../templates/error_500.php';
@@ -40,16 +40,19 @@ if (getenv('DB_PASS') === false) {
_bootstrap_fail('required env var DB_PASS is not set (empty string is allowed)');
}
$host = env('DB_HOST', '127.0.0.1');
$port = (int) env('DB_PORT', 3306);
$db = env('DB_NAME');
$user = env('DB_USER');
$pass = env('DB_PASS');
$host = env('DB_HOST', '127.0.0.1');
$port = (int) env('DB_PORT', 3306);
$db = env('DB_NAME');
// Not $user: that name leaks into the router's flat include scope (router.php
// requires this file at top level, no function wrapper) and would shadow the
// real logged-in user array that includes/header.php expects.
$dbUser = env('DB_USER');
$pass = env('DB_PASS');
$dsn = "mysql:host=$host;port=$port;dbname=$db;charset=utf8mb4";
try {
$pdo = new PDO($dsn, $user, $pass, [
$pdo = new PDO($dsn, $dbUser, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
// Real server-side prepares (not driver emulation): a tighter defence
// against injection than emulated string-interpolation. Safe here — the
@@ -63,19 +66,95 @@ try {
_bootstrap_fail('PDO connect failed: ' . $e->getMessage());
}
// Distinct session cookie per app so installs that share a host (e.g.
// development.andmore.pt/id.andmore.pt/ and /fitme3070.pt/) don't overwrite
// each other's PHPSESSID. Must run before session_start().
// ─── Ligação ao platform-tools ───────────────────────────────────────────────
//
// Cookie e modo estrito definidos aqui, no código, para valerem em qualquer
// servidor: antes o cookie saía só com path=/ (sem Secure, HttpOnly nem
// SameSite) e o PHP aceitava IDs de sessão inventados pelo cliente.
if (session_status() === PHP_SESSION_NONE) {
ini_set('session.use_strict_mode', '1');
ini_set('session.use_only_cookies', '1');
ini_set('session.cookie_httponly', '1');
ini_set('session.cookie_samesite', 'Lax');
if (request_is_https()) ini_set('session.cookie_secure', '1');
session_name('FIT_SESS');
session_start();
// Auditoria 2026-09-24, ponto 1.3: a app passou a usar a autenticação
// partilhada (`shared/platform-tools/functions_auth.php`) em vez da sua
// própria — login, sessão, roles e guardas (config/auth.php,
// config/functions_user.php) agora chamam auth_attempt()/auth_login()/
// auth_at_least()/etc. por baixo, com os mesmos nomes de função de sempre
// para os ~30 ficheiros que os chamam não mudarem.
//
// O cookie de sessão (nome, Secure/HttpOnly/SameSite, modo estrito) passa a
// ser inteiramente gerido por auth_session_start() através da opção
// 'session' abaixo — o bloco que estava aqui antes (ini_set + session_start
// manuais) foi removido para não haver dois sítios a configurar a mesma
// sessão.
//
// `users` já tem exactamente as colunas por omissão do auth_schema()
// (id, email, name, password_hash, role, is_active, last_login_at,
// last_login_ip — confirmado via information_schema em produção), por isso
// não é preciso mapear nada em 'schema'.
//
// auth_attempts (o throttle da APP, config/functions_throttle.php — ip+scope,
// cobre login/registo/recuperação) continua a ser o único throttle de login
// usado; o throttle do módulo partilhado (auth_throttle_check/hit, que exige
// `identifier`) não é chamado, para não duplicar a defesa do mesmo
// formulário com duas contagens diferentes. A tabela foi tornada conforme na
// mesma (database/migration_20260925_shared_auth_attempts.sql) para o
// relatório de saúde abaixo ('conformance' => 'report') não acusar erro e
// para deixar a porta aberta a usar o throttle partilhado no futuro. Ver
// views/auth/login.php para a nota completa.
require_once dirname(__DIR__) . '/shared/platform-tools/functions_connector.php';
// Pacotes de ícones partilhados (platform?tool=icons, pt_icon_assets()). Só se o
// submódulo já os tem; sem ele os layouts caem nas cópias locais (fit_icon_assets()).
// Flaticon NÃO está registado: o site usa PNGs (uploads/modalidades/icons), não a fonte.
$iconsFile = dirname(__DIR__) . '/shared/platform-tools/functions_icons.php';
if (is_file($iconsFile)) {
require_once $iconsFile;
}
$iconsOpt = is_file($iconsFile)
? ['icons' => ['packs' => ['bootstrap-icons', 'fontawesome'], 'default' => 'bootstrap-icons']]
: [];
platform_connect($pdo, $iconsOpt + [
'auth' => 'shared',
'roles' => [
// Preserva a hierarquia que config/auth.php já tinha (sysadmin >
// admin > staff > client, por in_array(), não por posto numérico):
// staff acima de client, e os dois abaixo de admin(80), para
// require_staff()/is_staff() continuarem a excluir clientes e
// require_admin()/is_admin() continuarem a excluir staff e clientes.
// sysadmin(100)/admin(80)/user(10) já vêm registados por omissão.
'staff' => 40,
'client' => 10,
],
'session' => [
'name' => 'FIT_SESS',
'samesite' => 'Lax',
// secure/httponly ficam a cargo de auth_session_start(): secure
// segue o mesmo critério de HTTPS que o bloco antigo usava,
// httponly está sempre ligado.
],
'remember' => true, // «Manter sessão iniciada» — agora auth_remember_*
// do platform-tools; ver views/auth/login.php.
// Brand feed público (api/brand.php): nome, logos e cores para o hub.
// Cores = tokens :root de site/assets/css/style.css. logo_light = logo de
// cor clara (fundos escuros), logo_dark = logo escuro (fundos claros).
// name: omitido de propósito (APP_NAME ainda não existe aqui; o default é hub_app_name()).
'brand' => [
'logo_light' => fn() => site_logo_rel('logo_light_filename', 'site/assets/img/logos/logo_crossfit_white.svg'),
'logo_dark' => fn() => site_logo_rel('logo_dark_filename', 'site/assets/img/logos/logo_crossfit.svg'),
'colors' => ['primary' => '#c2d500', 'secondary' => '#0b0b0d', 'accent' => '#a6b600'],
],
'guard' => 'require_sysadmin',
'router' => true,
'conformance' => 'report', // nunca recusa — só reporta no health endpoint
// A Comunicações desta app já cobre SMTP (config + testes); o Estado SMTP
// partilhado seria um segundo editor da mesma configuração.
'replaced_tools' => ['smtp_status' => 'views/sysadmin/comms.php'],
'hide_tools' => ['smtp_status'],
'sysadmin_tools' => [[
'title' => 'Comunicações',
'description' => 'Configuração SMTP, fila de mensagens e testes de envio.',
'icon' => 'bi-broadcast',
'url' => 'views/sysadmin/comms.php',
'color' => 'info',
], [
'title' => 'Fila de mensagens',
'description' => 'O que saiu, o que falhou e porquê.',
'icon' => 'bi-envelope-paper',
'url' => 'views/sysadmin/messages.php',
'color' => 'secondary',
]],
]);
+19
View File
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
// DEV ONLY. A definição de «ambiente de desenvolvimento» é a do platform-tools
// (functions_dev.php). Carregado em todas as páginas (rodapé): se o submódulo
// ainda não tem o ficheiro (produção segue o main do platform-tools), degrada
// para «não é dev» em vez de dar fatal.
$_dev_shared = dirname(__DIR__) . '/shared/platform-tools/functions_dev.php';
if (is_file($_dev_shared)) require_once $_dev_shared;
unset($_dev_shared);
if (!function_exists('dev_environment_problem')) {
function dev_environment_problem(?array $env = null): ?string {
return function_exists('platform_dev_environment_problem')
? platform_dev_environment_problem($env) : 'platform-tools sem functions_dev.php';
}
}
if (!function_exists('dev_environment_ok')) {
function dev_environment_ok(?array $env = null): bool { return dev_environment_problem($env) === null; }
}
+48 -4
View File
@@ -15,8 +15,11 @@ require_once __DIR__ . '/functions_secrets.php'; // setting_or_env()/setting_s
require_once __DIR__ . '/functions_db.php'; // generic DB helpers
require_once __DIR__ . '/functions_audit.php'; // registo de auditoria (platform-tools) + audit()
require_once __DIR__ . '/functions_trash.php'; // lixeira: soft delete + eliminação só após 30 dias
require_once __DIR__ . '/functions_user.php'; // auth_login/register, password reset, user creators
require_once __DIR__ . '/functions_user.php'; // register/reset/user creators (auth itself: platform-tools)
require_once __DIR__ . '/auth.php'; // require_login/admin/staff/sysadmin(), role helpers
// «Manter sessão iniciada»: shared/platform-tools/functions_remember.php,
// ligado por platform_connect(['remember' => true]) em config/db.php — já não
// há um functions_remember.php local (auditoria 1.3).
require_once __DIR__ . '/functions_throttle.php'; // per-IP attempt throttle for the auth endpoints
require_once __DIR__ . '/functions_migrations.php';// platform_update.php migrations runner
@@ -24,6 +27,7 @@ require_once __DIR__ . '/functions_migrations.php';// platform_update.php migrat
require_once __DIR__ . '/functions_queue.php'; // message_queue + enqueue_*/flush_message
require_once __DIR__ . '/functions_csrf.php';
require_once __DIR__ . '/functions_html.php'; // allowlist HTML sanitiser (clean_richtext/clean_map_embed)
require_once __DIR__ . '/functions_icons.php'; // fit_icon_assets(): pacotes de ícones partilhados + recurso local
require_once __DIR__ . '/ci_compat.php'; // CI4 view-helper shim (script_tag/link_tag/view/esc/session)
require_once __DIR__ . '/functions_gyms.php'; // multi-gym: gyms, URL folders, admin gym switcher
require_once __DIR__ . '/functions_site.php'; // public-site data (site_info/offer/team/modalidades/horario)
@@ -40,6 +44,7 @@ require_once __DIR__ . '/functions_reviews.php'; // reviews Google (Places
require_once __DIR__ . '/functions_trials.php'; // pedidos de aula experimental
require_once __DIR__ . '/functions_privacy.php'; // mapa e fotos da Google só a pedido / servidos localmente
require_once __DIR__ . '/functions_forms.php'; // anti-spam + bloqueio de IP dos formulários públicos
require_once __DIR__ . '/functions_rgpd.php'; // pedidos de acesso/apagamento (art.º 15/17)
// ─── DB-backed settings → legacy constants ───────────────────────────────────
// Resolved once from the settings cache so call sites read plain constants.
@@ -97,6 +102,45 @@ function format_date_pt(string $date): string {
return (new DateTime($date))->format('d/m/Y');
}
// ─── CSV export (backoffice listings) ────────────────────────────────────────
// Excel-friendly CSV (UTF-8 BOM + ';'). Every export is recorded in the audit
// log — RGPD requires personal-data exports to be traceable.
function csv_export(string $type, string $filename, string $summary, array $header, array $rows): void {
audit('export.' . $type, [
'target_type' => $type,
'target_label' => 'CSV (' . count($rows) . ' registos)',
'summary' => $summary,
]);
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $filename . '-' . date('Y-m-d') . '.csv"');
$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF");
fputcsv($out, $header, ';', '"', '');
foreach ($rows as $row) {
fputcsv($out, $row, ';', '"', '');
}
fclose($out);
exit;
}
// ─── Logo path (platform convention + static fallback) ───────────────────────
// Setting <key> holds a file name. Convention: uploads/public/logo/<file>; the
// settings page still stores uploads in assets/img/logo/ (legacy), so that is
// accepted too. Empty setting / missing file -> $fallback (site-relative, no
// leading slash), or '' when that file is missing as well.
function site_logo_rel(string $settingKey, string $fallback): string {
$file = basename((string) setting($settingKey, ''));
$root = dirname(__DIR__) . '/';
if ($file !== '') {
foreach (['uploads/public/logo/', 'assets/img/logo/'] as $dir) {
if (is_file($root . $dir . $file)) {
return $dir . rawurlencode($file);
}
}
}
return is_file($root . $fallback) ? $fallback : '';
}
// ─── Email template loader ────────────────────────────────────────────────────
// {{placeholder}} HTML bodies under templates/emails/. Built locally, delivered
// by id.andmore.pt via the enqueue_email()/flush_message() façade.
@@ -107,9 +151,9 @@ function load_email_template(string $template, array $vars): string {
}
$html = file_get_contents($path);
$brand = htmlspecialchars(APP_NAME, ENT_QUOTES, 'UTF-8');
$logo = (string) setting('logo_light_filename', '');
$logoTag = $logo !== ''
? '<img src="' . htmlspecialchars(base_url('assets/img/logo/' . rawurlencode($logo)), ENT_QUOTES, 'UTF-8')
$rel = site_logo_rel('logo_light_filename', 'site/assets/img/logos/logo_crossfit_white.svg');
$logoTag = $rel !== ''
? '<img src="' . htmlspecialchars(base_url($rel), ENT_QUOTES, 'UTF-8')
. '" alt="' . $brand . '" style="max-height:60px;border:0;">'
: '<p style="margin:0;color:#fff;font-size:22px;font-weight:bold;letter-spacing:1px;">' . $brand . '</p>';
$html = str_replace('{{logo_html}}', $logoTag, $html);
+30
View File
@@ -38,6 +38,36 @@ function db_get_active_admins(PDO $pdo): array {
)->fetchAll(PDO::FETCH_ASSOC);
}
// Every user, in role order — the listing at views/user/index.php.
function db_users_list(PDO $pdo): array {
return $pdo->query(
"SELECT id, name, email, phone, role, is_active, password_hash, last_login_at, last_login_ua
FROM users ORDER BY FIELD(role,'sysadmin','admin','staff','client'), name"
)->fetchAll(PDO::FETCH_ASSOC);
}
// Dashboard widget counts (views/admin/dashboard.php).
function db_users_count_clients(PDO $pdo): int {
return (int) $pdo->query("SELECT COUNT(*) FROM users WHERE role = 'client'")->fetchColumn();
}
function db_users_count_team(PDO $pdo): int {
return (int) $pdo->query("SELECT COUNT(*) FROM users WHERE role IN ('admin','staff')")->fetchColumn();
}
// Profile self-edit (views/user/profile.php) — the contact-details half of
// the form; the password half already goes through auth_set_password().
function db_user_update_profile(PDO $pdo, int $userId, array $data): void {
$pdo->prepare(
"UPDATE users SET phone=?, address=?, city=?, postal_code=?,
email_opt_out=?, sms_opt_out=?, marketing_consent=?
WHERE id=?"
)->execute([
$data['phone'], $data['address'], $data['city'], $data['postal_code'],
$data['email_opt_out'], $data['sms_opt_out'], $data['marketing_consent'], $userId,
]);
}
// PT-PT label for the role ladder.
function role_label(string $role): string {
return [
+6
View File
@@ -8,6 +8,12 @@ function db_gallery_list(PDO $pdo): array {
return $pdo->query("SELECT * FROM galleries WHERE deleted_at IS NULL ORDER BY name")->fetchAll(PDO::FETCH_ASSOC);
}
// id => name pairs for a <select> (gyms create/edit "gallery" picker).
function db_galleries_options(PDO $pdo): array {
return $pdo->query("SELECT id, name FROM galleries WHERE deleted_at IS NULL ORDER BY id")
->fetchAll(PDO::FETCH_KEY_PAIR);
}
function db_gallery_get(PDO $pdo, int $id): ?array {
$stmt = $pdo->prepare("SELECT * FROM galleries WHERE id = ? LIMIT 1");
$stmt->execute([$id]);
+119 -18
View File
@@ -41,7 +41,7 @@ function gym_defaults(): array {
'slug' => '', 'name' => '', 'locality' => '', 'postal_code' => '', 'is_published' => 0,
'is_default' => 0, 'sections' => null, 'hero_title' => '', 'hero_subtitle' => '',
'hero_image' => '', 'seo_title' => '', 'seo_description' => '', 'gallery_id' => null,
'google_place_id' => '', 'has_crossfit' => 0, 'item_order' => 0,
'google_place_id' => '', 'has_crossfit' => 0, 'brand_color' => '', 'logo' => '', 'item_order' => 0,
];
}
@@ -103,6 +103,32 @@ function gym_section_on(array $gym, string $key): bool {
return !is_array($list) || in_array($key, $list, true);
}
// Brand colour of a gym as :root overrides for the public site. Derives the
// hover shade, the text-on-light ink and the text colour that stays readable on
// the colour (dark on light brands, white on dark ones). '' = stylesheet default.
function gym_brand_vars(array $gym): string {
$hex = strtoupper((string) ($gym['brand_color'] ?? ''));
if (!preg_match('/^#[0-9A-F]{6}$/', $hex)) return '';
[$r, $g, $b] = [hexdec(substr($hex, 1, 2)), hexdec(substr($hex, 3, 2)), hexdec(substr($hex, 5, 2))];
$shade = fn(float $k) => sprintf('#%02X%02X%02X', (int) round($r * $k), (int) round($g * $k), (int) round($b * $k));
$lum = (0.2126 * $r + 0.7152 * $g + 0.0722 * $b) / 255;
return '--fitme-accent:' . $hex . ';--fitme-accent-rgb:' . "$r, $g, $b" . ';--fitme-accent-dark:' . $shade(.86)
. ';--fitme-accent-ink:' . $shade($lum > .5 ? .5 : .85) . ';--fitme-on-accent:' . ($lum > .5 ? '#0B0B0D' : '#FFFFFF');
}
// The gym's logo (relative path). The main gym falls back to the site's own
// FitMe 3070 logo, so its navbar button has one before anything is uploaded.
function gym_logo(array $gym): string {
$logo = (string) ($gym['logo'] ?? '');
if ($logo === '' && !empty($gym['is_default'])) $logo = 'site/assets/img/logos/logo_black.svg';
return $logo;
}
function gym_brand_css(array $gym): string {
$v = gym_brand_vars($gym);
return $v === '' ? '' : ':root{' . $v . '}';
}
function gym_url(array $gym, string $path = ''): string {
return base_url(rawurlencode((string) $gym['slug']) . '/' . ltrim($path, '/'));
}
@@ -159,8 +185,22 @@ function gyms_from_post(PDO $pdo, array $post, array &$errors, ?int $id = null):
'gallery_id' => (int) ($post['gallery_id'] ?? 0) ?: null,
'google_place_id' => mb_substr(trim((string) ($post['google_place_id'] ?? '')), 0, 120),
'has_crossfit' => !empty($post['has_crossfit']) ? 1 : 0,
'logo' => mb_substr(trim((string) ($post['logo'] ?? '')), 0, 255),
'brand_color' => strtoupper(trim((string) ($post['brand_color'] ?? ''))),
'item_order' => (int) ($post['item_order'] ?? 0),
];
// Header image + logo come from file inputs (a fresh upload wins, "remover"
// clears it, otherwise the current value posted in the hidden field stays).
foreach (['hero' => 'hero_image', 'logo' => 'logo'] as $kind => $field) {
if (!empty($post[$kind . '_remove'])) $data[$field] = '';
$file = $_FILES[$kind . '_file'] ?? null;
if ($file && ($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) {
$err = null;
$saved = gyms_save_image($file, $kind, $err);
if ($saved !== null) $data[$field] = $saved;
else $errors[] = ($kind === 'hero' ? 'Imagem do header: ' : 'Logótipo: ') . ($err ?? 'ficheiro inválido.');
}
}
$on = array_values(array_intersect(array_keys(gym_sections()), (array) ($post['sections'] ?? [])));
$data['sections'] = count($on) === count(gym_sections()) ? null : json_encode($on);
@@ -173,25 +213,42 @@ function gyms_from_post(PDO $pdo, array $post, array &$errors, ?int $id = null):
$other = db_gyms_by_slug($pdo, $slug);
if ($other && (int) $other['id'] !== (int) $id) $errors[] = 'Já existe um ginásio com essa pasta da URL.';
}
if ($data['logo'] !== '' && !preg_match('~^(site/assets/img|uploads)/[A-Za-z0-9_./ -]+\.(svg|png|webp|jpe?g|jxl)$~i', $data['logo'])) {
$errors[] = 'Logótipo: imagem inválida.';
}
if ($data['brand_color'] !== '' && !preg_match('/^#[0-9A-F]{6}$/', $data['brand_color'])) {
$errors[] = 'Cor da marca: formato #RRGGBB (ex.: #C2D500).';
}
if ($data['is_default'] && !$data['is_published']) $errors[] = 'O ginásio principal tem de estar publicado.';
if ($data['hero_image'] !== '' && !preg_match('~^(site/assets/img|uploads)/[A-Za-z0-9_./ -]+\.(jpe?g|png|webp)$~i', $data['hero_image'])) {
$errors[] = 'Imagem do header: caminho de uma imagem do site (ex.: site/assets/img/home.jpg).';
if ($data['hero_image'] !== '' && !preg_match('~^(site/assets/img|uploads)/[A-Za-z0-9_./ -]+\.(jpe?g|png|webp|jxl)$~i', $data['hero_image'])) {
$errors[] = 'Imagem do header: imagem inválida.';
}
return $data;
}
function _gyms_params(array $d): array {
return [$d['slug'], $d['name'], $d['locality'], $d['postal_code'], $d['is_published'], $d['is_default'],
$d['sections'], $d['hero_title'], $d['hero_subtitle'], $d['hero_image'], $d['seo_title'],
$d['seo_description'], $d['gallery_id'], $d['google_place_id'], $d['has_crossfit'], $d['item_order']];
// Columns written by create/update, in order. brand_color and logo were added by
// migration_20261013_gym_brand_color.sql: an install that hasn't run it yet keeps
// working (they are just not saved) instead of failing on an unknown column.
function _gyms_cols(PDO $pdo, array $d): array {
$cols = [
'slug' => $d['slug'], 'name' => $d['name'], 'locality' => $d['locality'], 'postal_code' => $d['postal_code'],
'is_published' => $d['is_published'], 'is_default' => $d['is_default'], 'sections' => $d['sections'],
'hero_title' => $d['hero_title'], 'hero_subtitle' => $d['hero_subtitle'], 'hero_image' => $d['hero_image'],
'seo_title' => $d['seo_title'], 'seo_description' => $d['seo_description'], 'gallery_id' => $d['gallery_id'],
'google_place_id' => $d['google_place_id'], 'has_crossfit' => $d['has_crossfit'],
];
foreach (['brand_color', 'logo'] as $opt) {
if (_gyms_has_col($pdo, 'gyms', $opt)) $cols[$opt] = $d[$opt] ?? '';
}
$cols['item_order'] = $d['item_order'];
return $cols;
}
function db_gyms_create(PDO $pdo, array $d): int {
$cols = _gyms_cols($pdo, $d);
$pdo->beginTransaction();
$pdo->prepare("INSERT INTO gyms (slug, name, locality, postal_code, is_published, is_default, sections,
hero_title, hero_subtitle, hero_image, seo_title, seo_description, gallery_id,
google_place_id, has_crossfit, item_order, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, NOW(), NOW())")->execute(_gyms_params($d));
$pdo->prepare('INSERT INTO gyms (' . implode(', ', array_keys($cols)) . ', created_at, updated_at) VALUES ('
. implode(',', array_fill(0, count($cols), '?')) . ', NOW(), NOW())')->execute(array_values($cols));
$id = (int) $pdo->lastInsertId();
if ($d['is_default']) $pdo->prepare("UPDATE gyms SET is_default = 0 WHERE id <> ?")->execute([$id]);
// Its own contacts row (id = gym id), starting from the gym's name.
@@ -206,14 +263,10 @@ function db_gyms_create(PDO $pdo, array $d): int {
function db_gyms_update(PDO $pdo, int $id, array $d): bool {
$__before = audit_row($pdo, 'gyms', 'id', (int) $id);
$cols = _gyms_cols($pdo, $d);
$pdo->beginTransaction();
$params = _gyms_params($d); $params[] = $id;
$st = $pdo->prepare("UPDATE gyms SET slug = ?, name = ?, locality = ?, postal_code = ?, is_published = ?,
is_default = ?, sections = ?, hero_title = ?, hero_subtitle = ?, hero_image = ?,
seo_title = ?, seo_description = ?, gallery_id = ?, google_place_id = ?,
has_crossfit = ?, item_order = ?, updated_at = NOW()
WHERE id = ?");
$st->execute($params);
$st = $pdo->prepare('UPDATE gyms SET ' . implode(' = ?, ', array_keys($cols)) . ' = ?, updated_at = NOW() WHERE id = ?');
$st->execute(array_merge(array_values($cols), [$id]));
$changed = $st->rowCount() > 0;
if ($d['is_default']) $pdo->prepare("UPDATE gyms SET is_default = 0 WHERE id <> ?")->execute([$id]);
$pdo->commit();
@@ -222,6 +275,54 @@ function db_gyms_update(PDO $pdo, int $id, array $d): bool {
return $__r;
}
// ─── Header image / logo uploads ──────────────────────────────────────────────
// Files live in uploads/gyms/ (uploads/.htaccess blocks script execution) and the
// DB keeps the site-relative path, which is what the public pages already read.
function gyms_upload_dir(): string {
return dirname(__DIR__) . '/uploads/gyms/';
}
// Validates by content (upload_image_ext, SVGs through svg_is_safe), stores the
// file under a random name and returns "uploads/gyms/<name>", or null with $error.
function gyms_save_image(array $file, string $kind, ?string &$error = null): ?string {
if (($file['error'] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) {
$error = ($file['error'] === UPLOAD_ERR_INI_SIZE || $file['error'] === UPLOAD_ERR_FORM_SIZE)
? 'o ficheiro é demasiado grande.' : 'o envio falhou.';
return null;
}
if (($file['size'] ?? 0) > 6 * 1024 * 1024) { $error = 'máximo 6 MB.'; return null; }
$allowed = $kind === 'hero' ? ['jpg', 'jpeg', 'png', 'webp', 'jxl'] : ['svg', 'png', 'webp', 'jpg', 'jpeg', 'jxl'];
$ext = upload_image_ext($file, $allowed);
if ($ext === null) {
$error = $kind === 'hero' ? 'usa JPG, PNG, WebP ou JPEG XL.' : 'usa SVG, PNG, WebP, JPG ou JPEG XL (SVG sem scripts).';
return null;
}
$dir = gyms_upload_dir();
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) { $error = 'não foi possível criar a pasta de envio.'; return null; }
$name = $kind . '_' . bin2hex(random_bytes(6)) . '.' . $ext;
if (!move_uploaded_file($file['tmp_name'], $dir . $name)) { $error = 'não foi possível guardar o ficheiro.'; return null; }
if ($ext !== 'svg' && function_exists('img_optimize')) {
img_optimize($dir . $name, $kind === 'hero' ? IMG_MAX_GALLERY : 600);
}
return 'uploads/gyms/' . $name;
}
// Deletes an uploaded image that is no longer referenced (never a bundled one).
function gyms_remove_image(?string $rel): void {
$rel = (string) $rel;
if (!preg_match('~^uploads/gyms/[A-Za-z0-9_.-]+$~', $rel)) return;
$path = gyms_upload_dir() . basename($rel);
if (is_file($path)) @unlink($path);
}
// Single-field setter for views/admin/reviews/index.php — that page only
// touches google_place_id, not the rest of the gym record db_gyms_update()
// would validate/audit.
function db_gym_set_place_id(PDO $pdo, int $id, string $placeId): void {
$pdo->prepare("UPDATE gyms SET google_place_id = ?, updated_at = NOW() WHERE id = ?")
->execute([$placeId, $id]);
}
// Soft delete. The default gym can't go (the root URL needs somewhere to land).
function db_gyms_delete(PDO $pdo, int $id): bool {
$st = $pdo->prepare("UPDATE gyms SET deleted_at = NOW(), is_published = 0
+18
View File
@@ -0,0 +1,18 @@
<?php
// Ícones: pacotes partilhados do platform-tools (pt_icon_assets()) com as cópias
// locais como recurso quando o submódulo ainda não os tem (produção com o
// submódulo antigo). Registo dos pacotes: config/db.php ('icons').
//
// $packs : ids dos pacotes a carregar (ex.: ['fontawesome'])
// $localHrefs: <link> locais de recurso, caminhos para link_tag()/base_url()
function fit_icon_assets(array $packs, array $localHrefs): string {
if (function_exists('pt_icon_assets')) {
$html = pt_icon_assets($packs);
if ($html !== '') return $html;
}
$out = '';
foreach ($localHrefs as $href) {
$out .= '<link href="' . htmlspecialchars(base_url($href), ENT_QUOTES) . '" rel="stylesheet">' . "\n";
}
return $out;
}
+9
View File
@@ -39,6 +39,15 @@ function upload_image_ext(array $file, array $allowed): ?string {
}
$raster = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp'];
// JPEG XL: o getimagesize()/GD não o conhecem e o finfo só nas versões
// recentes, por isso vê-se pela assinatura (codestream FF 0A ou contentor
// ISOBMFF "JXL "). Só entra onde quem chama pôs 'jxl' em $allowed; o
// img_optimize() salta-o (não há GD para JXL).
if (in_array('jxl', $allowed, true)) {
$head = (string) @file_get_contents($tmp, false, null, 0, 12);
if (str_starts_with($head, "\xFF\x0A") || $head === "\x00\x00\x00\x0CJXL \x0D\x0A\x87\x0A") return 'jxl';
}
if (isset($raster[$mime]) || $mime === '') {
$info = @getimagesize($tmp);
$ext = $info ? ($raster[$info['mime']] ?? null) : null;
+5
View File
@@ -43,6 +43,11 @@ function info_from_post(array $post, array &$errors): array {
function db_info_save(PDO $pdo, array $data): bool {
$cols = array_keys(info_fields());
foreach ($cols as $c) {
if (!preg_match('/^[A-Za-z0-9_]+$/', $c)) {
throw new InvalidArgumentException("Nome de coluna inválido: $c");
}
}
// One row per gym (id = gyms.id): the gym being edited in the backoffice.
$gid = admin_gym_id();
$__before = audit_row($pdo, 'fitme3070_info', 'id', (int) $gid);
+2 -16
View File
@@ -9,22 +9,8 @@
// e o browser pede-a ao próprio site.
function map_click_to_load(string $iframeHtml, string $mapsLink = ''): string {
if (trim($iframeHtml) === '') return '';
static $n = 0;
$id = 'map-consent-' . (++$n);
$esc = fn(string $s): string => htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
$link = $mapsLink !== ''
? '<a href="' . $esc($mapsLink) . '" target="_blank" rel="noopener" class="map-consent-link">Abrir no Google Maps</a>'
: '';
return '<div class="map-consent" id="' . $id . '" style="position:absolute;inset:0;display:flex;flex-direction:column;'
. 'align-items:center;justify-content:center;gap:.6rem;padding:1rem;text-align:center;color:#f5f5f5;">'
. '<i class="fas fa-map-marker-alt" aria-hidden="true" style="font-size:1.8rem;color:#C2D500"></i>'
. '<button type="button" class="site-btn" style="min-height:44px" '
. 'onclick="var w=document.getElementById(\'' . $id . '\');var t=w.nextElementSibling;'
. 'w.insertAdjacentHTML(\'afterend\',t.innerHTML);w.remove();">Mostrar mapa</button>'
. '<small style="max-width:32em;opacity:.8">O mapa é do Google Maps: ao abri-lo, a Google recebe o seu endereço IP e pode guardar cookies.</small>'
. $link
. '</div><template>' . $iframeHtml . '</template>';
// O mapa aparece logo com a página; o nome ficou por compatibilidade.
return trim($iframeHtml);
}
function review_avatar_url(array $r): string {
+74
View File
@@ -0,0 +1,74 @@
<?php
// RGPD — pedidos de acesso (art.º 15.º) e apagamento (art.º 17.º).
//
// Os formulários públicos guardam PII em membership_leads, trial_leads e
// event_leads, sempre identificável pelo email. O "apagamento" aqui é
// anonimização: a linha fica (estatística/audit), mas o nome/email/telefone
// passam a placeholders — o dado pessoal deixa de existir sem quebrar nada.
function rgpd_pii_tables(): array {
return [
'membership_leads' => 'Pedidos de inscrição',
'trial_leads' => 'Aulas experimentais',
'event_leads' => 'Inscrições em eventos',
];
}
// Devolve os dados pessoais associados a um email, organizados por tabela.
function rgpd_find(PDO $pdo, string $email): array {
$out = [];
foreach (rgpd_pii_tables() as $table => $label) {
$st = $pdo->prepare(
"SELECT name, email, phone, message, created_at FROM `$table` WHERE email = ? ORDER BY created_at DESC"
);
$st->execute([$email]);
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
if ($rows) $out[$table] = $rows;
}
return $out;
}
// Anonimiza os dados pessoais de um email em todas as tabelas públicas.
// Devolve o número de registos alterados.
function rgpd_erase(PDO $pdo, string $email): int {
$n = 0;
foreach (array_keys(rgpd_pii_tables()) as $table) {
$st = $pdo->prepare(
"UPDATE `$table` SET name = 'Apagado (RGPD)', email = '', phone = '', message = '' WHERE email = ?"
);
$st->execute([$email]);
$n += $st->rowCount();
}
return $n;
}
function db_rgpd_request_create(PDO $pdo, array $d): int {
$st = $pdo->prepare(
"INSERT INTO rgpd_requests (gym_id, email, request_type, message, consent) VALUES (?, ?, ?, ?, ?)"
);
$st->execute([
(int) ($d['gym_id'] ?? 0),
(string) $d['email'],
(string) $d['request_type'],
(string) ($d['message'] ?? ''),
(int) ($d['consent'] ?? 0),
]);
return (int) $pdo->lastInsertId();
}
function db_rgpd_request_get(PDO $pdo, int $id): ?array {
$st = $pdo->prepare("SELECT * FROM rgpd_requests WHERE id = ? LIMIT 1");
$st->execute([$id]);
return $st->fetch(PDO::FETCH_ASSOC) ?: null;
}
function db_rgpd_requests_list(PDO $pdo): array {
return $pdo->query(
"SELECT * FROM rgpd_requests WHERE deleted_at IS NULL ORDER BY created_at DESC"
)->fetchAll(PDO::FETCH_ASSOC);
}
function db_rgpd_request_mark(PDO $pdo, int $id, string $status, int $by): void {
$st = $pdo->prepare("UPDATE rgpd_requests SET status = ?, processed_at = NOW(), processed_by = ? WHERE id = ?");
$st->execute([$status, $by, $id]);
}
+52 -2
View File
@@ -36,7 +36,10 @@ function site_navbar(?array $gym = null): array {
$items = ['Home' => ['#home', null]];
foreach (['Modalidades' => 'modalidades', 'Equipa' => 'equipa', 'Horário' => 'horario',
'Mensalidades' => 'mensalidades', 'Contactos' => 'contactos'] as $label => $sec) {
if (gym_section_on($gym, $sec)) $items[$label] = ['#' . $sec, $sec];
if (!gym_section_on($gym, $sec)) continue;
// No active plans → the section isn't rendered, so no link to it either.
if ($sec === 'mensalidades' && !empty($GLOBALS['pdo']) && !site_memberships($GLOBALS['pdo'], (int) $gym['id'])) continue;
$items[$label] = ['#' . $sec, $sec];
}
$nav = [];
foreach ($items as $label => [$hash]) $nav[$label] = gym_url($gym, $hash);
@@ -104,6 +107,51 @@ function site_img_url($value, $base_dir): string {
return base_url(rtrim((string) $base_dir, '/') . '/' . ltrim($value, '/'));
}
// Same value/$base_dir resolution as site_img_url(), but to a filesystem path
// (for getimagesize()) instead of a URL. Empty string for a remote http(s)
// image or anything that doesn't resolve to a local file.
function site_img_path($value, $base_dir): string {
$value = (string) $value;
if ($value === '' || substr($value, 0, 4) === 'http') return '';
$root = dirname(__DIR__);
$rel = (substr($value, 0, 8) === 'uploads/')
? $value
: rtrim((string) $base_dir, '/') . '/' . ltrim($value, '/');
return $root . '/' . $rel;
}
// width="" height="" attribute string for an uploaded image, read from the
// file itself (uploads aren't resized to one fixed size — see
// config/functions_images.php — so this can't be hardcoded). Empty string
// when the file is missing/unreadable, so the <img> just renders without the
// attributes rather than with bogus 0x0 ones.
function site_img_dims_attr($value, $base_dir): string {
$path = site_img_path($value, $base_dir);
if ($path === '') return '';
return svg_img_dims_attr($path);
}
// width="" height="" for any local raster or SVG file. getimagesize() covers
// raster; for SVG (which it doesn't decode) this reads the width/height
// attributes and falls back to the viewBox aspect ratio when only that is
// set (e.g. the site logos). Empty string when the file is missing or has no
// size info to go on.
function svg_img_dims_attr(string $path): string {
if (!is_file($path)) return '';
if (strtolower(pathinfo($path, PATHINFO_EXTENSION)) !== 'svg') {
$info = @getimagesize($path);
return $info ? 'width="' . (int) $info[0] . '" height="' . (int) $info[1] . '"' : '';
}
$svg = (string) @file_get_contents($path, false, null, 0, 4096);
if (preg_match('/\bwidth="([\d.]+)"/', $svg, $mw) && preg_match('/\bheight="([\d.]+)"/', $svg, $mh)) {
return 'width="' . (int) round((float) $mw[1]) . '" height="' . (int) round((float) $mh[1]) . '"';
}
if (preg_match('/\bviewBox="[\d.\-]+\s+[\d.\-]+\s+([\d.]+)\s+([\d.]+)"/', $svg, $mv)) {
return 'width="' . (int) round((float) $mv[1]) . '" height="' . (int) round((float) $mv[2]) . '"';
}
return '';
}
// Schedule joined to modalidades (name/colour/icon), optionally filtered by
// local (e.g. 'crossfit'). Mirrors Horario_model::getAll().
function site_horario(PDO $pdo, string $local = '', ?int $gym_id = null): array {
@@ -194,5 +242,7 @@ function modalidade_icon_html(?string $icon): string {
return $svg;
}
}
return '<img loading="lazy" draggable="false" src="' . htmlspecialchars(site_img_url($icon, 'uploads/modalidades/icons')) . '" alt="">';
$dims = site_img_dims_attr($icon, 'uploads/modalidades/icons');
return '<img loading="lazy" decoding="async" draggable="false" ' . $dims
. ' src="' . htmlspecialchars(site_img_url($icon, 'uploads/modalidades/icons')) . '" alt="">';
}
+35 -47
View File
@@ -1,8 +1,11 @@
<?php
// ─── Users: lookup, authentication, registration, password reset ─────────────
// This install owns its identity. Passwords live in `users.password_hash`;
// views/auth/login.php is a real form and require_login() (config/auth.php)
// bounces unauthenticated visitors there. Nothing is delegated to a hub.
// ─── Users: lookup, registration, password reset ─────────────────────────────
// This install owns its identity — passwords live in `users.password_hash` —
// but auditoria 2026-09-24 ponto 1.3 moved credential verification and
// session establishment to shared/platform-tools/functions_auth.php
// (auth_attempt()/auth_login(), wired in config/db.php). What is left here is
// fitme-specific: registration, backoffice user creation, password reset
// tokens and their emails — none of which has a shared equivalent.
//
// parse_user_agent() lives in config/functions.php; db_get_user() /
// db_get_all_staff() / db_get_active_admins() live in config/functions_db.php
@@ -26,46 +29,28 @@ function admin_exists(PDO $pdo): bool {
}
// ─── Authentication ──────────────────────────────────────────────────────────
//
// Credential verification is now auth_attempt($pdo, $email, $password) from
// shared/platform-tools/functions_auth.php — same account-enumeration timing
// defence and transparent rehash, one implementation instead of two. Login
// itself (session + audit "auth.login" + last_login_at/ip stamp) is
// auth_login($pdo, $user) from the same file. See views/auth/login.php.
//
// last_login_ua has no shared equivalent (functions_auth.php only tracks
// last_login/last_ip) and is still fitme's own — stamped separately, right
// after auth_login(), by fit_stamp_login_ua() below.
// Hash bcrypt (custo 12) de uma password aleatória descartada — só serve para
// igualar o tempo de resposta quando a conta não existe.
const AUTH_DUMMY_HASH = '$2y$12$luPhZpP46FkyufPCM1d8Gu4lPNS3A/I0gSvoBiNEL3lZAauMkuzH2';
// Verifies credentials and stamps the login trail. Returns the user row on
// success, null on bad email / bad password / inactive account / no password
// set. Callers must not leak which of the four it was.
function auth_login(PDO $pdo, string $email, string $password): ?array {
$stmt = $pdo->prepare(
"SELECT * FROM users WHERE email = ? AND is_active = 1 AND password_hash IS NOT NULL LIMIT 1"
);
$stmt->execute([strtolower(trim($email))]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$user) {
// Sem conta, verifica na mesma contra um hash de referência: um bcrypt
// de custo 12 demora dezenas de ms, e responder mais depressa quando o
// email não existe diria a quem tenta quais emails têm conta.
password_verify($password, AUTH_DUMMY_HASH);
return null;
}
if (!password_verify($password, $user['password_hash'])) {
return null;
}
// Transparently upgrade hashes when PHP's default algorithm/cost moves on.
if (password_needs_rehash($user['password_hash'], PASSWORD_DEFAULT)) {
$pdo->prepare("UPDATE users SET password_hash = ? WHERE id = ?")
->execute([password_hash($password, PASSWORD_DEFAULT), $user['id']]);
}
$ip = client_ip();
// Stamps the user-agent of the browser that just logged in. Best-effort:
// shown in views/user/index.php ("last seen from …") but never worth failing
// a login over.
function fit_stamp_login_ua(PDO $pdo, int $userId): void {
$ua = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 512) ?: null;
$pdo->prepare("UPDATE users SET last_login_at = NOW(), last_login_ip = ?, last_login_ua = ? WHERE id = ?")
->execute([$ip, $ua, $user['id']]);
$user['last_login_at'] = date('Y-m-d H:i:s');
$user['last_login_ip'] = $ip;
$user['last_login_ua'] = $ua;
return $user;
try {
$pdo->prepare("UPDATE users SET last_login_ua = ? WHERE id = ?")
->execute([$ua, $userId]);
} catch (Throwable $e) {
error_log('[auth] last_login_ua stamp failed: ' . $e->getMessage());
}
}
// Writes a new password hash. Shared by the reset flow, the profile page and
@@ -74,7 +59,10 @@ function auth_login(PDO $pdo, string $email, string $password): ?array {
// take the account back.
function auth_set_password(PDO $pdo, int $userId, string $password): void {
$pdo->prepare("UPDATE users SET password_hash = ? WHERE id = ?")
->execute([password_hash($password, PASSWORD_DEFAULT), $userId]);
->execute([auth_password_hash($password), $userId]);
// Uma password nova invalida qualquer «manter sessão iniciada» antigo —
// sem isto, um cookie roubado antes da mudança continuava a entrar depois.
auth_remember_forget_user($pdo, $userId);
$self = (int) ($_SESSION['user_id'] ?? 0) === $userId;
audit($self ? 'auth.password_change' : 'user.password_set', [
'target_type' => 'user', 'target_id' => $userId,
@@ -120,7 +108,7 @@ function auth_register_client(PDO $pdo, array $data): array {
}
$pdo->prepare(
"UPDATE users SET name = ?, phone = COALESCE(?, phone), password_hash = ?, is_active = 1 WHERE id = ?"
)->execute([$name, $phone, password_hash($data['password'], PASSWORD_DEFAULT), $row['id']]);
)->execute([$name, $phone, auth_password_hash($data['password']), $row['id']]);
audit('user.register', ['target_type' => 'user', 'target_id' => (int) $row['id'], 'target_label' => $name,
'summary' => 'Registo que reclamou uma conta de cliente sem palavra-passe', 'actor' => null]);
return ['id' => (int) $row['id']];
@@ -136,7 +124,7 @@ function auth_register_client(PDO $pdo, array $data): array {
$name,
$email,
$phone,
password_hash($data['password'], PASSWORD_DEFAULT),
auth_password_hash($data['password']),
!empty($data['marketing_consent']) ? 1 : 0,
]);
} catch (PDOException $e) {
@@ -161,7 +149,7 @@ function auth_create_sysadmin(PDO $pdo, string $name, string $email, string $pas
$stmt->execute([
$name,
strtolower(trim($email)),
password_hash($password, PASSWORD_DEFAULT),
auth_password_hash($password),
]);
$id = (int) $pdo->lastInsertId();
audit('user.create', ['target_type' => 'user', 'target_id' => $id, 'target_label' => $name,
@@ -190,7 +178,7 @@ function _create_backoffice_user(PDO $pdo, string $role, string $name, string $e
$name,
strtolower(trim($email)),
$phone ?: null,
password_hash($password, PASSWORD_DEFAULT),
auth_password_hash($password),
$role,
]);
} catch (PDOException $e) {
@@ -0,0 +1,25 @@
-- ─── created_at nas tabelas que ainda não têm ────────────────────────────────
-- Auditoria 2026-09-24, ponto 3.1.5: fitme3070_info, galleries, horario,
-- modalidades, system_settings, website_offer e work_hours não registam
-- quando uma linha foi criada. Sem isso, uma fila de definições antigas ou
-- uma modalidade que já lá estava no dia 1 é indistinguível de uma criada
-- ontem.
--
-- Sem created_by de propósito: estas sete são o CRUD de um só admin (a
-- audit_log, ligada em 2026-09-25, já regista quem criou/mudou cada linha —
-- ver config/functions_audit.php). Uma coluna created_by aqui seria uma
-- segunda fonte da mesma resposta, sem valor a mais.
--
-- DEFAULT CURRENT_TIMESTAMP em vez de NULL: as linhas já existentes ficam com
-- a data da migração (a única data honesta disponível), e nenhum INSERT
-- existente que não mencione created_at parte.
--
-- Safe to re-run (IF NOT EXISTS é extensão do MariaDB).
ALTER TABLE `fitme3070_info` ADD COLUMN IF NOT EXISTS `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP AFTER `id`;
ALTER TABLE `galleries` ADD COLUMN IF NOT EXISTS `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP AFTER `name`;
ALTER TABLE `horario` ADD COLUMN IF NOT EXISTS `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP AFTER `local`;
ALTER TABLE `modalidades` ADD COLUMN IF NOT EXISTS `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP AFTER `gym_id`;
ALTER TABLE `system_settings` ADD COLUMN IF NOT EXISTS `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP AFTER `setting_value`;
ALTER TABLE `website_offer` ADD COLUMN IF NOT EXISTS `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP AFTER `gym_id`;
ALTER TABLE `work_hours` ADD COLUMN IF NOT EXISTS `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP AFTER `gym_id`;
@@ -0,0 +1,27 @@
-- ─── «Manter sessão iniciada» ────────────────────────────────────────────────
-- Auditoria 2026-09-24, "Sem «Manter sessão iniciada»" (Médio). A sessão do
-- PHP morre com o browser e o garbage collector apaga-a do servidor bem antes
-- disso — esticar o cookie da sessão não chega, é preciso estado próprio.
--
-- Selector/validator (o mesmo desenho do platform-tools functions_remember.php,
-- implementado localmente porque esta app tem a sua própria autenticação — ver
-- config/functions_remember.php): selector em claro e UNIQUE (é o índice da
-- consulta), só o hash SHA-256 do validator fica guardado. Quem levar um dump
-- desta tabela não consegue forjar um cookie a partir dela.
--
-- Safe to re-run.
CREATE TABLE IF NOT EXISTS `remember_tokens` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` INT UNSIGNED NOT NULL,
`selector` CHAR(32) NOT NULL,
`validator_hash` CHAR(64) NOT NULL,
`expires_at` DATETIME NOT NULL,
`last_ip` VARCHAR(45) NOT NULL DEFAULT '',
`user_agent` VARCHAR(255) NOT NULL DEFAULT '',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_remember_selector` (`selector`),
KEY `idx_remember_user` (`user_id`),
KEY `idx_remember_expires` (`expires_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,20 @@
-- RGPD (art.º 15.º/17.º): pedidos de acesso e de apagamento de dados pessoais.
-- O visitante submete um pedido na Política de Privacidade; fica aqui (o email
-- está desligado, por isso o pedido é guardado, como as leads). O backoffice
-- processa: acesso = exportar os dados; apagamento = anonimizar.
CREATE TABLE IF NOT EXISTS `rgpd_requests` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`gym_id` INT NOT NULL DEFAULT 0,
`email` VARCHAR(190) NOT NULL,
`request_type` ENUM('acesso','apagamento') NOT NULL,
`message` TEXT NULL,
`status` ENUM('novo','concluido') NOT NULL DEFAULT 'novo',
`consent` TINYINT(1) NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`processed_at` DATETIME NULL DEFAULT NULL,
`processed_by` INT UNSIGNED NULL DEFAULT NULL,
`deleted_at` DATETIME NULL DEFAULT NULL,
`deleted_by` INT UNSIGNED NULL DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_rgpd_requests_status` (`status`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,34 @@
-- ─── auth_attempts: torna-a conforme com o throttle do platform-tools ────────
-- Auditoria 2026-09-24, ponto 1.3: a app passa a usar o auth partilhado
-- (shared/platform-tools/functions_auth.php) em vez do seu próprio login/
-- sessão/roles. Ver config/db.php ('auth' => 'shared').
--
-- Esta tabela já existia (migration_20260913_local_auth.sql) com o formato do
-- throttle PRÓPRIO da app (config/functions_throttle.php: `ip` + `scope`,
-- cobre login/registo/recuperação). O throttle partilhado
-- (auth_throttle_check/hit em functions_auth.php) espera `identifier` + `ip`
-- e falharia (INSERT INTO auth_attempts (identifier, ip) ...) com um erro de
-- SQL — silenciosamente engolido pelo try/catch do throttle partilhado, que
-- por desenho falha aberto e deixaria de bloquear ninguém.
--
-- Decisão: a app CONTINUA a usar só o seu próprio throttle (login+registo+
-- recuperação, dois níveis IP+conta) — não passa a chamar
-- auth_throttle_check()/auth_throttle_hit() em lado nenhum, para não duplicar
-- a defesa contra o mesmo formulário com duas contagens diferentes. Esta
-- migração só torna a TABELA conforme com o que o platform_conformance()
-- (config/db.php, 'conformance' => 'report') verifica, para o relatório de
-- saúde ficar limpo e para deixar a porta aberta a usar o throttle partilhado
-- no futuro sem precisar de outra migração.
--
-- `identifier` e `success` são aditivos, com omissão — invisíveis para o
-- INSERT/SELECT do throttle próprio, que continua a nomear só `ip`+`scope`.
-- `scope` passa a ter omissão (era NOT NULL sem omissão) porque é a única
-- coluna que o INSERT do throttle partilhado não preenche; sem omissão esse
-- INSERT falhava em modo estrito.
--
-- Safe to re-run (IF NOT EXISTS é extensão do MariaDB).
ALTER TABLE `auth_attempts` ADD COLUMN IF NOT EXISTS `identifier` VARCHAR(190) NOT NULL DEFAULT '' AFTER `id`;
ALTER TABLE `auth_attempts` ADD COLUMN IF NOT EXISTS `success` TINYINT(1) NOT NULL DEFAULT 0 AFTER `attempted_at`;
ALTER TABLE `auth_attempts` MODIFY COLUMN `scope` VARCHAR(20) NOT NULL DEFAULT '';
ALTER TABLE `auth_attempts` ADD INDEX IF NOT EXISTS `idx_identifier_time` (`identifier`, `attempted_at`);
@@ -0,0 +1,8 @@
-- Cor de marca por ginásio (hex #RRGGBB). Vazio = verde FitMe por defeito.
-- Ginásio 1 = verde; o ginásio 2 usa vermelho (definir em Gestão → Ginásios).
-- Safe to re-run.
ALTER TABLE `gyms` ADD COLUMN IF NOT EXISTS `brand_color` VARCHAR(7) NOT NULL DEFAULT '' AFTER `has_crossfit`;
UPDATE `gyms` SET `brand_color` = '#C2D500' WHERE `id` = 1 AND `brand_color` = '';
-- Logótipo do ginásio (caminho relativo, ex.: site/assets/img/logos/ginasio.svg).
ALTER TABLE `gyms` ADD COLUMN IF NOT EXISTS `logo` VARCHAR(255) NOT NULL DEFAULT '' AFTER `brand_color`;
+4
View File
@@ -0,0 +1,4 @@
-- Logótipo do ginásio. A coluna foi acrescentada a migration_20261013_gym_brand_color.sql
-- depois de essa migração já ter corrido nalgumas bases (dev incluída), que por isso
-- ficaram sem ela. Safe to re-run.
ALTER TABLE `gyms` ADD COLUMN IF NOT EXISTS `logo` VARCHAR(255) NOT NULL DEFAULT '' AFTER `brand_color`;
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
/**
* DEV ONLY -- login de um clique da barra de dev (includes/dev_toolbar.php).
* Fica na raiz porque este site não tem public/ (o .htaccess encaminha
* /dev_login para aqui). O guarda, o 404 antes de tocar na BD/sessão, o POST e o
* papel permitido são do platform-tools (platform_dev_login_handle). Aqui só: o
* CSRF do site e a abertura da sessão como o login real (auth_login), sem
* palavra-passe. As contas são as de scripts/dev_accounts.php.
*/
$_dev_shared = __DIR__ . '/shared/platform-tools/functions_dev.php';
if (!is_file($_dev_shared)) { http_response_code(404); exit; } // submódulo sem o ficheiro (produção)
require_once $_dev_shared;
platform_dev_login_handle([
'roles' => ['client' => 'client@mail.pt', 'staff' => 'staff@mail.pt',
'admin' => 'admin@mail.pt', 'sysadmin' => 'sysadmin@mail.pt'],
'bootstrap' => function () { global $pdo; require __DIR__ . '/config/db.php'; require_once __DIR__ . '/config/functions.php'; },
'csrf_ok' => fn(): bool => is_string($_POST['_csrf'] ?? null) && is_string($_SESSION['csrf_token'] ?? null)
&& $_SESSION['csrf_token'] !== '' && hash_equals($_SESSION['csrf_token'], $_POST['_csrf']),
'login' => function (string $role, string $email): ?string {
global $pdo;
$st = $pdo->prepare('SELECT * FROM users WHERE email = ? AND role = ? AND is_active = 1 LIMIT 1');
$st->execute([$email, $role]);
$user = $st->fetch(PDO::FETCH_ASSOC);
if (!$user) return null; // conta em falta: corra php scripts/dev_accounts.php
auth_login($pdo, $user);
fit_stamp_login_ua($pdo, (int) $user['id']);
return base_url(post_login_destination($role));
},
]);
+1
View File
@@ -4,5 +4,6 @@
</main>
<script src="<?= base_url('assets/vendor/bootstrap5/js/bootstrap.bundle.min.js') ?>"></script>
<?= script_tag('assets/js/password-toggle.js') ?>
<?php include __DIR__ . '/dev_toolbar.php'; // DEV ONLY: sem saída fora de dev ?>
</body>
</html>
+3 -1
View File
@@ -29,7 +29,8 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070';
})();
</script>
<link href="<?= base_url('assets/vendor/bootstrap5/css/bootstrap.min.css') ?>" rel="stylesheet">
<link href="<?= base_url('assets/vendor/bootstrap-icons/bootstrap-icons.css') ?>" rel="stylesheet">
<link href="<?= htmlspecialchars(_asset_versioned('assets/css/bs-brand.css'), ENT_QUOTES) ?>" rel="stylesheet">
<?= fit_icon_assets(['bootstrap-icons'], ['assets/vendor/bootstrap-icons/bootstrap-icons.css']) ?>
<style>
:root {
--bo-accent: #C2D500;
@@ -65,6 +66,7 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070';
/* 16px inputs stop iOS from auto-zooming on focus */
@media (max-width: 575.98px) { .form-control { font-size: 16px; } .auth-card { padding: 1.5rem; } }
</style>
<?php include __DIR__ . '/env_ribbon.php'; // moldura de aviso fora de produção ?>
</head>
<body>
<main class="auth-card">
+11
View File
@@ -0,0 +1,11 @@
<?php
// DEV ONLY -- barra de login de um clique (platform-tools). Não imprime nada
// fora de dev. Incluída por includes/footer.php, auth_footer.php e site/includes/footer.php.
require_once __DIR__ . '/../config/dev_guard.php';
if (!function_exists('platform_dev_toolbar')) return;
platform_dev_toolbar([
'login_url' => base_url('dev_login'),
'logout_url' => base_url('views/auth/logout.php'),
'roles' => ['client' => 'Cliente', 'staff' => 'Staff', 'admin' => 'Admin', 'sysadmin' => 'Sysadmin'],
'csrf_field' => 'csrf_field',
]);
+62
View File
@@ -0,0 +1,62 @@
<?php
/**
* Non-production environment marker.
*
* Dev and prod are the same codebase on near-identical URLs, so it is genuinely
* easy to edit a real client's session while believing you are on dev. This
* paints an unmissable hazard frame around every page whenever APP_ENV is not
* 'prod', plus a corner chip naming the environment.
*
* Included from <head> in both includes/header.php (backoffice) and
* site/includes/head.php (public site). It hangs off html::before / html::after
* rather than a wrapper element, so no page markup has to change and nothing
* can shift the layout: the frame is fixed, on top, and click-through.
*
* Reads env() directly rather than the APP_ENV constant, because the public
* site does not always load config/functions.php.
*/
$_env = strtolower((string) (function_exists('env') ? env('APP_ENV', 'prod') : 'prod'));
if ($_env === 'prod' || $_env === 'production') return;
?>
<style>
/* Faixa de aviso de ambiente — só fora de produção. */
html::after {
content: '';
position: fixed;
inset: 0;
z-index: 2147483646;
pointer-events: none; /* nunca intercepta cliques */
border: 6px solid transparent;
border-image: repeating-linear-gradient(
45deg,
#111 0 14px,
#f5c400 14px 28px
) 6;
}
html::before {
content: '<?= htmlspecialchars(strtoupper($_env), ENT_QUOTES) ?>';
position: fixed;
top: 6px;
left: 50%;
transform: translateX(-50%);
z-index: 2147483647;
pointer-events: none;
padding: .1rem .8rem .15rem;
background: #f5c400;
color: #111;
font: 700 .7rem/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
letter-spacing: .18em;
border-radius: 0 0 .35rem .35rem;
box-shadow: 0 1px 4px rgba(0, 0, 0, .35);
}
/* Em ecrãs pequenos a moldura rouba demasiado espaço horizontal. */
@media (max-width: 575.98px) {
html::after { border-width: 4px; border-image-slice: 4; }
html::before { font-size: .6rem; top: 4px; }
}
/* Ao imprimir não faz sentido nenhum. */
@media print {
html::after, html::before { display: none; }
}
</style>
+84 -13
View File
@@ -78,23 +78,93 @@ document.addEventListener('DOMContentLoaded', function () {
mq.addEventListener('change', function () { if (pref === 'auto') apply('auto'); });
})();
// Mobile off-canvas sidebar: hamburger toggles .sidebar.open + the backdrop;
// tapping the backdrop, a nav link, Escape, or resizing to desktop closes it.
// Barra lateral. Em ecrã grande o hamburger alterna entre completa e mini (so
// o texto das categorias, lembrado em localStorage); em telemovel abre/fecha a
// gaveta por cima do conteudo. Um so manipulador para os dois botoes. O CSS
// decide o que cada estado mostra; o estado inicial ja veio do <head>.
(function () {
var sb = document.getElementById('sidebar');
var tg = document.getElementById('menuToggle');
var bd = document.getElementById('sidebarBackdrop');
if (!sb || !tg) return;
var mq = window.matchMedia('(max-width: 991.98px)');
function open() { sb.classList.add('open'); if (bd) bd.classList.add('show'); document.body.style.overflow = 'hidden'; tg.setAttribute('aria-expanded', 'true'); }
function close() { sb.classList.remove('open'); if (bd) bd.classList.remove('show'); document.body.style.overflow = ''; tg.setAttribute('aria-expanded', 'false'); }
tg.addEventListener('click', function () { sb.classList.contains('open') ? close() : open(); });
if (bd) bd.addEventListener('click', close);
sb.querySelectorAll('.nav-link').forEach(function (a) {
a.addEventListener('click', function () { if (mq.matches) close(); });
var toggles = document.querySelectorAll('[data-sidebar-toggle]');
var root = document.documentElement;
var nav = sb && sb.querySelector('.bo-sidebar__nav');
if (!sb || !toggles.length) return;
var mq = window.matchMedia('(min-width: 992px)');
function store(k, v) { try { localStorage.setItem(k, v); } catch (e) {} }
function sync() {
var desktop = mq.matches;
var open = desktop ? !root.classList.contains('bo-mini') : sb.classList.contains('open');
toggles.forEach(function (t) {
t.setAttribute('aria-expanded', open ? 'true' : 'false');
t.setAttribute('aria-label', open ? (desktop ? 'Reduzir menu' : 'Fechar menu') : (desktop ? 'Expandir menu' : 'Abrir menu'));
});
if (bd) bd.classList.toggle('show', !desktop && open);
document.body.style.overflow = (!desktop && open) ? 'hidden' : '';
}
function setDrawer(open) { sb.classList.toggle('open', open); sync(); }
toggles.forEach(function (t) {
t.addEventListener('click', function () {
if (mq.matches) {
store('bo-sidebar-mini', root.classList.toggle('bo-mini') ? '1' : '0');
sync();
} else {
setDrawer(!sb.classList.contains('open'));
}
});
});
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
window.addEventListener('resize', function () { if (!mq.matches) close(); });
if (bd) bd.addEventListener('click', function () { setDrawer(false); });
sb.querySelectorAll('.bo-nav-item').forEach(function (a) {
a.addEventListener('click', function () { if (!mq.matches) setDrawer(false); });
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && sb.classList.contains('open')) {
setDrawer(false);
var o = document.querySelector('.menu-toggle');
if (o && o.offsetParent) o.focus();
}
});
if (mq.addEventListener) mq.addEventListener('change', function () { setDrawer(false); });
sync();
// Grupos recolhiveis. O estado inicial veio do <head> (atributo no <html>);
// aqui so se liga o clique e se mantem atributo, armazenamento e aria alinhados.
var saved = {};
try { saved = JSON.parse(localStorage.getItem('bo-nav-groups') || '{}'); } catch (e) {}
function groupOpen(g) {
if (g.classList.contains('has-active')) return !g.classList.contains('is-closed');
return !saved[g.getAttribute('data-group')];
}
function paint(g) {
g.querySelector('[data-group-toggle]').setAttribute('aria-expanded', groupOpen(g) ? 'true' : 'false');
}
var groups = sb.querySelectorAll('.bo-nav-group[data-group]');
groups.forEach(function (g) {
g.querySelector('[data-group-toggle]').addEventListener('click', function () {
var key = g.getAttribute('data-group');
var willClose = groupOpen(g);
if (g.classList.contains('has-active')) g.classList.toggle('is-closed', willClose);
if (willClose) saved[key] = true; else delete saved[key];
store('bo-nav-groups', JSON.stringify(saved));
root.setAttribute('data-bo-groups', Object.keys(saved).join(' '));
groups.forEach(paint);
});
});
groups.forEach(paint);
// A lista rola sozinha: lembrar a posicao entre paginas e mostrar o item ativo.
if (nav) {
try { nav.scrollTop = +sessionStorage.getItem('bo-nav-scroll') || 0; } catch (e) {}
var cur = nav.querySelector('[aria-current="page"]');
if (cur) {
var r = cur.getBoundingClientRect(), n = nav.getBoundingClientRect();
if (r.top < n.top || r.bottom > n.bottom) cur.scrollIntoView({ block: 'center' });
}
addEventListener('pagehide', function () {
try { sessionStorage.setItem('bo-nav-scroll', nav.scrollTop); } catch (e) {}
});
}
})();
</script>
<style>
@@ -105,5 +175,6 @@ main:after {
margin-top: 2rem;
}
</style>
<?php include __DIR__ . '/dev_toolbar.php'; ?>
</body>
</html>
+118 -194
View File
@@ -25,203 +25,110 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070';
document.documentElement.setAttribute('data-theme-pref', pref);
})();
</script>
<script>
// Estado da barra lateral antes da primeira pintura (senão cada página salta
// de 264 para 80px): modo mini no <html> e grupos fechados em data-bo-groups.
try{var d=document.documentElement;if(localStorage.getItem('bo-sidebar-mini')==='1')d.classList.add('bo-mini');
var g=JSON.parse(localStorage.getItem('bo-nav-groups')||'{}');d.setAttribute('data-bo-groups',Object.keys(g).filter(function(k){return g[k]}).join(' '));}catch(e){}
</script>
<link href="<?= base_url('assets/vendor/bootstrap5/css/bootstrap.min.css') ?>" rel="stylesheet">
<link href="<?= base_url('assets/vendor/bootstrap-icons/bootstrap-icons.css') ?>" rel="stylesheet">
<link href="<?= htmlspecialchars(_asset_versioned('assets/css/bs-brand.css'), ENT_QUOTES) ?>" rel="stylesheet">
<?= fit_icon_assets(['bootstrap-icons'], ['assets/vendor/bootstrap-icons/bootstrap-icons.css']) ?>
<link href="<?= base_url('assets/vendor/summernote/summernote-bs5.min.css') ?>" rel="stylesheet">
<style>
:root {
--bo-accent: #C2D500;
--bo-accent-rgb: 194,213,0;
--bo-dark: #0a0a0a;
--bo-dark-2: #151515;
--bo-surface: #eef0f4;
}
[data-bs-theme="dark"] { --bo-surface: #121416; }
body { background: var(--bo-surface); font-size: .925rem; }
.sidebar { position: fixed; top: 0; bottom: 0; left: 0; z-index: 100;
padding: 68px 0 0; width: 240px; background: var(--bo-dark); overflow-y: auto; }
.sidebar .nav-link { color: rgba(255,255,255,.55); padding: .55rem 1.25rem; font-size: .875rem;
border-left: 3px solid transparent; transition: all .2s; }
.sidebar .nav-link:hover { color: #fff; background: rgba(255,255,255,.06); border-left-color: rgba(255,255,255,.2); }
.sidebar .nav-link.active { color: #fff; background: rgba(var(--bo-accent-rgb),.12); border-left-color: var(--bo-accent); }
.sidebar .nav-link i { width: 20px; margin-right: .5rem; text-align: center; }
.sidebar .sidebar-brand { position: fixed; top: 0; left: 0; width: 240px; padding: 1rem 1.25rem;
color: #fff; font-weight: 700; font-size: 1.1rem; background: var(--bo-dark);
z-index: 101; letter-spacing: .5px; border-bottom: 1px solid rgba(255,255,255,.06); }
.sidebar .sidebar-brand i { color: var(--bo-accent); }
.sidebar .sidebar-section { color: rgba(255,255,255,.3); font-size: .65rem; text-transform: uppercase;
letter-spacing: 1.5px; padding: 1.25rem 1.25rem .35rem; }
main { margin-left: 240px; padding-top: 68px; }
.topbar { position: fixed; top: 0; left: 240px; right: 0; z-index: 99; height: 56px;
background: var(--bs-body-bg); border-bottom: 1px solid var(--bs-border-color); padding: 0 1.5rem;
display: flex; align-items: center; justify-content: space-between; }
.topbar .user-dropdown { display: flex; align-items: center; gap: .5rem; margin-left: auto; }
.badge-role-sysadmin { background: var(--bo-accent); color: var(--bo-dark); }
.badge-role-admin { background: var(--bo-accent); color: var(--bo-dark); opacity: .85; }
.badge-role-staff { background: #6f42c1; }
.badge-role-client { background: #adb5bd; color: #212529; }
.btn-outline-primary { color: var(--bo-accent); border-color: var(--bo-accent); }
.btn-outline-primary:hover { background: var(--bo-accent); border-color: var(--bo-accent); color: var(--bo-dark); }
.btn-primary { background: var(--bo-accent); border-color: var(--bo-accent); color: var(--bo-dark); }
.btn-primary:hover { background: #a8ba00; border-color: #a8ba00; color: var(--bo-dark); }
.card { border: none; box-shadow: 0 1px 3px rgba(0,0,0,.04); }
[data-bs-theme="dark"] .table-light { --bs-table-bg: var(--bs-tertiary-bg); --bs-table-color: var(--bs-body-color); }
.theme-toggle { min-width: 44px; min-height: 44px; }
a { color: var(--bs-emphasis-color); }
/* Summernote (WYSIWYG) — align with the backoffice look */
.note-editor.note-frame { border-color: #ced4da; border-radius: .375rem; }
.note-editor .note-editing-area .note-editable { font-size: .95rem; }
.note-editor.note-frame.note-focused { border-color: var(--bo-accent); box-shadow: 0 0 0 .2rem rgba(var(--bo-accent-rgb), .25); }
.note-toolbar { background: var(--bs-tertiary-bg); }
.note-btn.btn-primary { background: var(--bo-accent); border-color: var(--bo-accent); color: var(--bo-dark); }
/* ── Mobile: off-canvas sidebar + hamburger ──────────────────────────── */
.sidebar { transition: transform .25s ease; }
.menu-toggle { display: none; width: 44px; height: 44px; padding: 0; border: 1px solid var(--bs-border-color);
background: var(--bs-body-bg); color: var(--bs-body-color); border-radius: 8px; font-size: 1.35rem;
line-height: 1; align-items: center; justify-content: center; }
.sidebar-backdrop { display: none; position: fixed; inset: 0; z-index: 1090; background: rgba(0,0,0,.45); }
.sidebar-backdrop.show { display: block; }
@media (max-width: 991.98px) {
.sidebar { transform: translateX(-100%); width: 264px; z-index: 1100;
box-shadow: 2px 0 18px rgba(0,0,0,.3); }
.sidebar .sidebar-brand { width: 264px; z-index: 1101; }
.sidebar.open { transform: translateX(0); }
main { margin-left: 0; }
.topbar { left: 0; padding: 0 .75rem; }
.menu-toggle { display: inline-flex; }
/* Wide tables scroll inside their card instead of overflowing the page */
main .card > .card-body { overflow-x: auto; -webkit-overflow-scrolling: touch; }
}
@media (max-width: 575.98px) {
.topbar .user-dropdown .badge,
.topbar .user-dropdown .u-name { display: none; }
/* Page-header rows (title + action buttons) stack instead of overflowing */
main .d-flex.justify-content-between.align-items-center { flex-wrap: wrap; gap: .5rem; }
/* 16px inputs stop iOS from auto-zooming on focus */
.form-control, .form-select { font-size: 16px; }
/* Comfortable content gutters on phones */
.p-3 { padding: .85rem !important; }
}
</style>
<link href="<?= htmlspecialchars(_asset_versioned('assets/css/backoffice.css'), ENT_QUOTES) ?>" rel="stylesheet">
<?php include __DIR__ . '/env_ribbon.php'; // moldura de aviso fora de produção ?>
</head>
<body>
<nav class="sidebar" id="sidebar">
<div class="sidebar-brand"><i class="bi bi-lightning-charge-fill me-2"></i><?= htmlspecialchars($app_name) ?></div>
<div class="sidebar-section">Principal</div>
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link<?= ($_SERVER['SCRIPT_NAME'] ?? '') === '/views/admin/dashboard.php' ? ' active' : '' ?>"
href="<?= base_url('views/admin/dashboard.php') ?>"><i class="bi bi-speedometer2"></i>Painel</a>
</li>
<?php if (is_staff()): ?>
<li class="nav-item">
<a class="nav-link" href="<?= base_url('/') ?>" target="_blank" rel="noopener"><i class="bi bi-box-arrow-up-right"></i>Site público</a>
</li>
<?php require_once __DIR__ . '/nav_helpers.php'; $dash = bo_dashboard_path(); ?>
<aside class="sidebar" id="sidebar" aria-label="Menu principal">
<div class="bo-sidebar__head">
<button type="button" class="bo-sidebar__toggle" data-sidebar-toggle
aria-label="Reduzir menu" aria-expanded="true" aria-controls="sidebar">
<i class="bi bi-list" aria-hidden="true"></i>
</button>
<a class="bo-sidebar__brand" href="<?= base_url($dash) ?>"><i class="bi bi-lightning-charge me-2" aria-hidden="true"></i><?= htmlspecialchars($app_name) ?></a>
<?php // Único sítio que abre o site público (nova aba). ?>
<a class="bo-sidebar__site" href="<?= base_url('/') ?>" target="_blank" rel="noopener"
title="Ver site público" aria-label="Ver site público"><i class="bi bi-box-arrow-up-right" aria-hidden="true"></i></a>
</div>
<div class="dropdown bo-sidebar__user">
<button class="bo-user-menu dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false"
title="<?= htmlspecialchars($user['name']) ?>">
<span class="bo-user-menu__avatar" aria-hidden="true"><?= htmlspecialchars(bo_initials((string) $user['name'])) ?></span>
<span class="bo-user-menu__text">
<span class="bo-user-menu__name"><?= htmlspecialchars($user['name']) ?></span>
<span class="badge rounded-pill badge-role-<?= htmlspecialchars($user['role']) ?>"><?= htmlspecialchars(role_label($user['role'])) ?></span>
</span>
</button>
<ul class="dropdown-menu">
<li class="px-3 pt-2 pb-1"><div class="fw-semibold small lh-sm"><?= htmlspecialchars($user['name']) ?></div></li>
<li><hr class="dropdown-divider my-1"></li>
<li><a class="dropdown-item" href="<?= base_url('views/user/profile.php') ?>"><i class="bi bi-person me-2" aria-hidden="true"></i>Perfil</a></li>
<li><a class="dropdown-item text-danger" href="<?= base_url('views/auth/logout.php') ?>"><i class="bi bi-box-arrow-right me-2" aria-hidden="true"></i>Terminar sessão</a></li>
</ul>
</div>
<nav class="bo-sidebar__nav" aria-label="Navegação">
<?= bo_nav_link($dash, 'bi-speedometer2', 'Painel', ['mod' => 'primary']) ?>
<?php if (is_admin()): ?>
<?php bo_nav_group_start('gestao', 'Gestão', 'bi-briefcase'); ?>
<?php bo_nav_group_start('site', 'Site', 'bi-window', true); ?>
<?= bo_nav_link('views/admin/gyms/index.php', 'bi-buildings', 'Ginásios', ['mod' => 'sub2', 'match' => 'views/admin/gyms/']) ?>
<?= bo_nav_link('views/admin/modalidades/index.php', 'bi-grid-3x3-gap', 'Modalidades', ['mod' => 'sub2', 'match' => 'views/admin/modalidades/']) ?>
<?= bo_nav_link('views/admin/galerias/index.php', 'bi-images', 'Galerias', ['mod' => 'sub2', 'match' => 'views/admin/galerias/']) ?>
<?= bo_nav_link('views/admin/events/index.php', 'bi-megaphone', 'Eventos', ['mod' => 'sub2', 'match' => 'views/admin/events/']) ?>
<?= bo_nav_link('views/admin/info/edit.php', 'bi-telephone', 'Contactos', ['mod' => 'sub2', 'match' => 'views/admin/info/']) ?>
<?= bo_nav_link('views/admin/reviews/index.php', 'bi-star', 'Reviews Google', ['mod' => 'sub2', 'match' => 'views/admin/reviews/']) ?>
<?php bo_nav_group_end(); ?>
<?php bo_nav_group_start('horarios', 'Horários', 'bi-clock-history', true); ?>
<?= bo_nav_link('views/admin/work_hours/index.php', 'bi-clock', 'Horário de Funcionamento', ['mod' => 'sub2', 'match' => 'views/admin/work_hours/']) ?>
<?= bo_nav_link('views/admin/horario/index.php', 'bi-calendar-week', 'Horário de Aulas', ['mod' => 'sub2', 'match' => 'views/admin/horario/']) ?>
<?php bo_nav_group_end(); ?>
<?php bo_nav_group_start('comercial', 'Comercial', 'bi-cash-coin', true); ?>
<?= bo_nav_link('views/admin/memberships/index.php', 'bi-credit-card-2-front', 'Mensalidades', ['mod' => 'sub2', 'match' => 'views/admin/memberships/']) ?>
<?php $__tn = function_exists('db_trial_leads_count_new') ? db_trial_leads_count_new($pdo, admin_gym_id()) : 0; ?>
<?= bo_nav_link('views/admin/trials/index.php', 'bi-person-check', 'Aulas experimentais', ['mod' => 'sub2', 'match' => 'views/admin/trials/', 'badge' => $__tn]) ?>
<?= bo_nav_link('views/admin/team/index.php', 'bi-person-workspace', 'Equipa', ['mod' => 'sub2', 'match' => 'views/admin/team/']) ?>
<?php bo_nav_group_end(); ?>
<?php bo_nav_group_start('legal', 'Legal', 'bi-shield-check', true); ?>
<?= bo_nav_link('views/admin/rgpd/index.php', 'bi-file-earmark-lock', 'Pedidos RGPD', ['mod' => 'sub2', 'match' => 'views/admin/rgpd/']) ?>
<?= bo_nav_link('views/admin/trash/index.php', 'bi-trash3', 'Lixeira', ['mod' => 'sub2', 'match' => 'views/admin/trash/']) ?>
<?php bo_nav_group_end(); ?>
<?php bo_nav_group_end(); ?>
<?php bo_nav_group_start('utilizadores', 'Utilizadores', 'bi-people'); ?>
<?= bo_nav_link('views/user/index.php', 'bi-person-vcard', 'Utilizadores', ['mod' => 'sub', 'match' => ['views/user/index.php', 'views/user/edit.php']]) ?>
<?php bo_nav_group_end(); ?>
<?php endif; ?>
</ul>
<?php if (is_admin()): ?>
<div class="sidebar-section">Gestão</div>
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/gyms/') !== false) ? ' active' : '' ?>"
href="<?= base_url('views/admin/gyms/index.php') ?>"><i class="bi bi-buildings-fill"></i>Ginásios</a>
</li>
<li class="nav-item">
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/team/') !== false) ? ' active' : '' ?>"
href="<?= base_url('views/admin/team/index.php') ?>"><i class="bi bi-people-fill"></i>Equipa</a>
</li>
<li class="nav-item">
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/modalidades/') !== false) ? ' active' : '' ?>"
href="<?= base_url('views/admin/modalidades/index.php') ?>"><i class="bi bi-grid-3x3-gap-fill"></i>Modalidades</a>
</li>
<li class="nav-item">
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/galerias/') !== false) ? ' active' : '' ?>"
href="<?= base_url('views/admin/galerias/index.php') ?>"><i class="bi bi-images"></i>Galerias</a>
</li>
<li class="nav-item">
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/events/') !== false) ? ' active' : '' ?>"
href="<?= base_url('views/admin/events/index.php') ?>"><i class="bi bi-megaphone-fill"></i>Eventos</a>
</li>
<li class="nav-item">
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/work_hours/') !== false) ? ' active' : '' ?>"
href="<?= base_url('views/admin/work_hours/index.php') ?>"><i class="bi bi-clock-fill"></i>Horário de Funcionamento</a>
</li>
<li class="nav-item">
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/horario/') !== false) ? ' active' : '' ?>"
href="<?= base_url('views/admin/horario/index.php') ?>"><i class="bi bi-calendar-week-fill"></i>Horário de Aulas</a>
</li>
<li class="nav-item">
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/memberships/') !== false) ? ' active' : '' ?>"
href="<?= base_url('views/admin/memberships/index.php') ?>"><i class="bi bi-credit-card-2-front-fill"></i>Mensalidades</a>
</li>
<li class="nav-item">
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/trials/') !== false) ? ' active' : '' ?>"
href="<?= base_url('views/admin/trials/index.php') ?>"><i class="bi bi-person-check-fill"></i>Aulas experimentais<?php
$__tn = function_exists('db_trial_leads_count_new') ? db_trial_leads_count_new($pdo, admin_gym_id()) : 0;
if ($__tn): ?> <span class="badge bg-danger ms-1"><?= (int) $__tn ?></span><?php endif; ?></a>
</li>
<li class="nav-item">
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/reviews/') !== false) ? ' active' : '' ?>"
href="<?= base_url('views/admin/reviews/index.php') ?>"><i class="bi bi-star-fill"></i>Reviews Google</a>
</li>
<li class="nav-item">
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/info/') !== false) ? ' active' : '' ?>"
href="<?= base_url('views/admin/info/edit.php') ?>"><i class="bi bi-telephone-fill"></i>Contactos</a>
</li>
<li class="nav-item">
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/trash/') !== false) ? ' active' : '' ?>"
href="<?= base_url('views/admin/trash/index.php') ?>"><i class="bi bi-trash3-fill"></i>Lixeira</a>
</li>
</ul>
<?php endif; ?>
<?php if (is_admin()): ?>
<div class="sidebar-section">Utilizadores</div>
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link<?= ($_SERVER['SCRIPT_NAME'] ?? '') === '/views/user/index.php' ? ' active' : '' ?>"
href="<?= base_url('views/user/index.php') ?>"><i class="bi bi-person-badge"></i>Utilizadores</a>
</li>
</ul>
<?php endif; ?>
<?php if (is_sysadmin()): ?>
<div class="sidebar-section">Sistema</div>
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link<?= ($_SERVER['SCRIPT_NAME'] ?? '') === '/views/sysadmin/system_settings.php' ? ' active' : '' ?>"
href="<?= base_url('views/sysadmin/system_settings.php') ?>"><i class="bi bi-sliders"></i>Definições</a>
</li>
<li class="nav-item">
<a class="nav-link<?= ($_SERVER['SCRIPT_NAME'] ?? '') === '/views/sysadmin/backups.php' ? ' active' : '' ?>"
href="<?= base_url('views/sysadmin/backups.php') ?>"><i class="bi bi-database-fill-gear"></i>Backups</a>
</li>
<li class="nav-item">
<a class="nav-link<?= ($_SERVER['SCRIPT_NAME'] ?? '') === '/views/sysadmin/messages.php' ? ' active' : '' ?>"
href="<?= base_url('views/sysadmin/messages.php') ?>"><i class="bi bi-envelope-paper"></i>Mensagens</a>
</li>
<li class="nav-item">
<a class="nav-link<?= ($_SERVER['SCRIPT_NAME'] ?? '') === '/views/sysadmin/audit.php' ? ' active' : '' ?>"
href="<?= base_url('views/sysadmin/audit.php') ?>"><i class="bi bi-journal-text"></i>Auditoria</a>
</li>
<li class="nav-item">
<a class="nav-link<?= ($_SERVER['SCRIPT_NAME'] ?? '') === '/views/sysadmin/comms.php' ? ' active' : '' ?>"
href="<?= base_url('views/sysadmin/comms.php') ?>"><i class="bi bi-send"></i>Comunicações</a>
</li>
<li class="nav-item">
<a class="nav-link<?= ($_SERVER['SCRIPT_NAME'] ?? '') === '/views/sysadmin/platform_update.php' ? ' active' : '' ?>"
href="<?= base_url('views/sysadmin/platform_update.php') ?>"><i class="bi bi-arrow-repeat"></i>Actualizações</a>
</li>
</ul>
<?php endif; ?>
</nav>
<?php if (is_sysadmin()): ?>
<?php bo_nav_group_start('sistema', 'Sistema', 'bi-gear'); ?>
<?= bo_nav_link('views/sysadmin/system_settings.php', 'bi-sliders', 'Definições', ['mod' => 'sub']) ?>
<?= bo_nav_link('views/sysadmin/messages.php', 'bi-envelope-paper', 'Mensagens', ['mod' => 'sub']) ?>
<?= bo_nav_link('views/sysadmin/comms.php', 'bi-send', 'Comunicações', ['mod' => 'sub']) ?>
<?php // Backups/Auditoria/IP bans/Geo/Origem das visitas/Atualizações/Hub: um só
// link para o índice partilhado do platform-tools (como a fotografia/design/
// dance fazem), em vez de um por ferramenta — uma ferramenta nova no
// platform-tools passa a aparecer sozinha, sem precisar de link novo aqui. ?>
<?= bo_nav_link('platform.php?tool=index', 'bi-cpu', 'Sistema', ['mod' => 'sub',
'active' => (($_GET['tool'] ?? 'index') === 'index' && str_contains($_SERVER['REQUEST_URI'] ?? '', 'platform.php'))]) ?>
<?php if (function_exists('pt_icon_packs')): // pacotes de ícones só existem no submódulo novo ?>
<?= bo_nav_link('platform.php?tool=icons', 'bi-grid-3x3-gap', 'Pacotes de ícones', ['mod' => 'sub',
'active' => (($_GET['tool'] ?? '') === 'icons')]) ?>
<?php endif; ?>
<?php bo_nav_group_end(); ?>
<?php endif; ?>
</nav>
</aside>
<div class="sidebar-backdrop" id="sidebarBackdrop" aria-hidden="true"></div>
<main>
<div class="topbar">
<button type="button" class="menu-toggle" id="menuToggle" aria-label="Abrir menu" aria-controls="sidebar">
<i class="bi bi-list"></i>
<?php // Só em telemóvel: abre a gaveta (a barra está fora do ecrã). ?>
<button type="button" class="menu-toggle" data-sidebar-toggle aria-label="Abrir menu" aria-expanded="false" aria-controls="sidebar">
<i class="bi bi-list" aria-hidden="true"></i>
</button>
<?php
// Which gym the content pages edit (functions_gyms.php). Switching goes back
@@ -244,7 +151,7 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070';
<noscript><button class="btn btn-sm btn-outline-secondary">Mudar</button></noscript>
</form>
<?php endif; ?>
<div class="user-dropdown">
<div class="bo-topbar__tools">
<div class="dropdown">
<button class="btn btn-sm btn-outline-secondary theme-toggle" type="button" data-bs-toggle="dropdown"
aria-expanded="false" aria-label="Tema: automático" id="themeToggle">
@@ -256,14 +163,31 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070';
<li><button type="button" class="dropdown-item" data-theme-set="auto"><i class="bi bi-circle-half me-2" aria-hidden="true"></i>Automático</button></li>
</ul>
</div>
<span class="badge rounded-pill badge-role-<?= htmlspecialchars($user['role']) ?>">
<?= htmlspecialchars(role_label($user['role'])) ?>
</span>
<span class="u-name"><?= htmlspecialchars($user['name']) ?></span>
<a href="<?= base_url('views/user/profile.php') ?>" class="btn btn-sm btn-outline-secondary"><i class="bi bi-person"></i></a>
<a href="<?= base_url('views/auth/logout.php') ?>" class="btn btn-sm btn-outline-danger"><i class="bi bi-box-arrow-right"></i></a>
</div>
</div>
<?php
// Breadcrumbs: a page sets $breadcrumbs = [['label'=>'Equipa','url'=>base_url('views/admin/team/index.php')], ['label'=>'Editar']]
// (last item has no 'url' — it's the current page) for a full trail; with
// nothing set, this falls back to Painel > $page_title, which is right for
// every top-level listing page and wrong for nothing.
$breadcrumbs = $breadcrumbs ?? (
$page_title !== 'Painel'
? [['label' => 'Painel', 'url' => base_url('views/admin/dashboard.php')], ['label' => $page_title]]
: []
);
if ($breadcrumbs): ?>
<nav aria-label="breadcrumb" class="px-3 px-md-4 pt-3">
<ol class="breadcrumb mb-0 small">
<?php foreach ($breadcrumbs as $__i => $__bc): ?>
<?php if (!empty($__bc['url']) && $__i < count($breadcrumbs) - 1): ?>
<li class="breadcrumb-item"><a href="<?= htmlspecialchars($__bc['url']) ?>"><?= htmlspecialchars($__bc['label']) ?></a></li>
<?php else: ?>
<li class="breadcrumb-item active" aria-current="page"><?= htmlspecialchars($__bc['label']) ?></li>
<?php endif; ?>
<?php endforeach; ?>
</ol>
</nav>
<?php endif; ?>
<div class="p-3 p-md-4">
<?php
// Show flash messages if any
+96
View File
@@ -0,0 +1,96 @@
<?php
// Auxiliares da barra lateral do backoffice (incluído por includes/header.php).
// Ficam aqui, e não no HTML, para o menu ser só uma lista de itens: o ativo,
// o aria-current, o title/aria-label (o modo mini só mostra o ícone) e a
// abertura dos grupos decidem-se num sítio.
/** Painel de cada papel: o admin tem o seu, o staff o dele, o cliente a área de sócio. */
function bo_dashboard_path(): string
{
if (is_admin()) return 'views/admin/dashboard.php';
if (is_staff()) return 'views/staff/dashboard.php';
return 'views/user/dashboard.php';
}
/**
* O pedido atual está nesta página? Compara pelo FIM do caminho (e não com ===):
* em dev o site vive num subcaminho do host (/GitTea/repos/fitme3070.pt/...).
* Um caminho terminado em «/» é uma pasta inteira (inclui edit.php, create.php...).
* Aceita uma lista: basta uma coincidir.
*
* @param string|string[] $paths
*/
function bo_nav_active($paths): bool
{
$script = str_replace('\\', '/', (string) ($_SERVER['SCRIPT_NAME'] ?? ''));
foreach ((array) $paths as $p) {
$p = '/' . ltrim($p, '/');
if (str_ends_with($p, '/') ? str_contains($script, $p) : str_ends_with($script, $p)) {
return true;
}
}
return false;
}
/**
* Uma ligação da barra lateral.
* Opções: match (caminho/s para o «ativo», por omissão o próprio href), mod
* ('sub' | 'sub2' | 'primary'), badge (inteiro; só aparece se > 0).
*/
function bo_nav_link(string $href, string $icon, string $label, array $o = []): string
{
$active = array_key_exists('active', $o) ? (bool) $o['active'] : bo_nav_active($o['match'] ?? strtok($href, '?'));
$mod = isset($o['mod']) ? ' bo-nav-item--' . $o['mod'] : '';
$l = htmlspecialchars($label, ENT_QUOTES, 'UTF-8');
$badge = (int) ($o['badge'] ?? 0);
return '<a class="nav-link bo-nav-item' . $mod . ($active ? ' active' : '') . '" href="' . htmlspecialchars(base_url($href), ENT_QUOTES, 'UTF-8') . '"'
. ' title="' . $l . '" aria-label="' . $l . '"' . ($active ? ' aria-current="page"' : '') . '>'
. '<i class="bi ' . htmlspecialchars($icon, ENT_QUOTES, 'UTF-8') . '" aria-hidden="true"></i>'
. '<span class="bo-nav-item__label">' . $l . '</span>'
. ($badge > 0 ? '<span class="badge bg-danger ms-auto bo-nav-item__badge">' . $badge . '</span>' : '')
. '</a>' . "\n";
}
/**
* Abre um grupo recolhível. O conteúdo fica em buffer até bo_nav_group_end(),
* que precisa de saber se algum item é o ativo (o grupo da página atual abre
* sempre). $sub = subcategoria dentro de outro grupo (recuada).
*/
function bo_nav_group_start(string $key, string $label, string $icon, bool $sub = false): void
{
$GLOBALS['bo_nav_group_stack'][] = [$key, $label, $icon, $sub];
ob_start();
}
/** Fecha o grupo aberto por bo_nav_group_start() e escreve o markup final. */
function bo_nav_group_end(): void
{
$items = (string) ob_get_clean();
[$key, $label, $icon, $sub] = array_pop($GLOBALS['bo_nav_group_stack']);
$hasActive = str_contains($items, 'aria-current="page"');
$id = 'bo-grp-' . $key;
$k = htmlspecialchars($key, ENT_QUOTES, 'UTF-8');
$l = htmlspecialchars($label, ENT_QUOTES, 'UTF-8');
$i = htmlspecialchars($icon, ENT_QUOTES, 'UTF-8');
$short = htmlspecialchars(mb_strtoupper(mb_substr($label, 0, 4)), ENT_QUOTES, 'UTF-8');
echo '<div class="bo-nav-group' . ($sub ? ' bo-nav-group--sub' : '') . ($hasActive ? ' has-active' : '') . '" data-group="' . $k . '">'
. '<button class="bo-nav-group__label" type="button" data-short="' . $short . '" aria-expanded="true"'
. ' aria-controls="' . $id . '" data-group-toggle="' . $k . '" title="' . $l . '" aria-label="' . $l . '">'
. '<i class="bi ' . $i . ' bo-nav-group__icon" aria-hidden="true"></i>'
. '<span class="bo-nav-group__text">' . $l . '</span>'
. '<i class="bi bi-chevron-down bo-nav-group__chevron" aria-hidden="true"></i></button>'
. '<div class="bo-nav-group__body" id="' . $id . '"><div class="bo-nav-group__inner">' . $items . '</div></div>'
. '</div>' . "\n";
}
/** Iniciais para o avatar (até duas letras: primeiro e último nome). */
function bo_initials(string $name): string
{
// Só palavras que começam por letra: «Staff (dev)» dá «SD»... não «S(».
preg_match_all('/\p{L}[\p{L}\p{M}-]*/u', $name, $m);
$parts = $m[0];
if (!$parts) return '?';
$first = mb_substr($parts[0], 0, 1);
$last = count($parts) > 1 ? mb_substr($parts[count($parts) - 1], 0, 1) : '';
return mb_strtoupper($first . $last);
}
-18
View File
@@ -1,18 +0,0 @@
<?php
// Standalone top navbar — only outputs when header.php hasn't been included yet.
// (system_settings.php includes both header.php + navbar.php, so this defers.)
if (defined('ADMIN_SHELL_OPENED') && ADMIN_SHELL_OPENED) return;
?>
<link href="<?= base_url('assets/vendor/bootstrap5/css/bootstrap.min.css') ?>" rel="stylesheet">
<link href="<?= base_url('assets/vendor/bootstrap-icons/bootstrap-icons.css') ?>" rel="stylesheet">
<nav class="navbar navbar-expand navbar-dark px-3 mb-0" style="position:sticky;top:0;z-index:99;background:var(--bo-accent, #C2D500);">
<a class="navbar-brand fw-bold text-dark" href="<?= base_url('views/admin/dashboard.php') ?>">
<i class="bi bi-lightning-charge-fill me-2"></i><?= htmlspecialchars(defined('APP_NAME') ? APP_NAME : 'FitMe 3070') ?>
</a>
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<span class="nav-link text-dark"><?= htmlspecialchars($user['name'] ?? '') ?></span>
</li>
<li class="nav-item"><a class="nav-link text-dark" href="<?= base_url('views/auth/logout.php') ?>"><i class="bi bi-box-arrow-right"></i></a></li>
</ul>
</nav>
+282
View File
@@ -0,0 +1,282 @@
<?php
/**
* Style lab (DEV ONLY): the public site's components on one page, with sliders
* for the visual effects added to style.css, so values can be tuned by eye and
* then copied back into the CSS. Nothing here is saved; reload resets it.
* Refuses to run when APP_ENV is prod.
*/
require __DIR__ . '/config/db.php';
require __DIR__ . '/config/functions.php';
$env = strtolower((string) (function_exists('env') ? env('APP_ENV', 'prod') : 'prod'));
if ($env === 'prod' || $env === 'production') { http_response_code(404); exit; }
$gyms = function_exists('db_gyms_published') ? db_gyms_published($pdo) : [];
$img_team = base_url('uploads/team/foto/1.jpg');
$img_gal = [base_url('site/assets/img/home.jpg'), base_url('site/assets/img/crossfit.jpg'), base_url('site/assets/img/footer-bg-gym.jpg')];
?><!doctype html>
<html lang="pt">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>Laboratório de estilo — FitMe</title>
<?= link_tag('assets/vendor/bootstrap5/css/bootstrap.min.css') ?>
<?= link_tag('site/assets/fonts/fontawesome/css/all.css') ?>
<?= link_tag('site/assets/css/style.css') ?>
<style id="lab-overrides"></style>
<style>
body { padding-bottom: 40px; }
.lab-panel { position: fixed; top: 12px; right: 12px; z-index: 2000; width: 300px; max-height: calc(100vh - 24px); overflow: auto;
background: rgba(11,11,13,.94); color: #fff; padding: 14px 16px; border-radius: 8px; box-shadow: 0 8px 30px rgba(0,0,0,.5); font-size: .85rem; }
.lab-panel h2 { font-size: 1rem; margin: 0 0 10px; color: var(--fitme-accent, #C2D500); filter: none; }
.lab-panel label { display: flex; justify-content: space-between; gap: 8px; margin: 10px 0 2px; }
.lab-panel output { color: var(--fitme-accent, #C2D500); font-variant-numeric: tabular-nums; }
.lab-panel input[type=range] { width: 100%; accent-color: var(--fitme-accent, #C2D500); }
.lab-panel input[type=color] { width: 100%; height: 34px; border: 0; background: none; padding: 0; }
.lab-panel button.reset { margin-top: 12px; width: 100%; border: 1px solid #555; background: none; color: #fff; padding: 6px; border-radius: 4px; }
.lab-panel .hint { color: #aaa; font-size: .75rem; margin-top: 10px; }
.lab-sec { padding: 56px 0; }
.lab-sec.dark { background: var(--fitme-dark-2); color: #fff; }
.lab-sec.dark h1, .lab-sec.dark h2 { color: #fff; }
.lab-row { display: flex; flex-wrap: wrap; gap: 16px; align-items: center; margin: 18px 0; }
.lab-label { font-size: .75rem; letter-spacing: .08em; text-transform: uppercase; opacity: .6; margin: 26px 0 6px; }
.lab-gal { display: flex; gap: 12px; }
.lab-gal .gal-tile { width: 220px; height: 150px; }
.lab-card-wrap { max-width: 270px; }
@media (max-width: 991px) { .lab-panel { position: static; width: auto; max-height: none; margin: 12px; } }
</style>
</head>
<body>
<?php include __DIR__ . '/includes/env_ribbon.php'; ?>
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
<filter id="pt-noise" x="0" y="0" width="100%" height="100%" color-interpolation-filters="sRGB">
<feTurbulence id="lab-turb" type="fractalNoise" baseFrequency=".9" numOctaves="2" seed="4" result="n"/>
<feColorMatrix id="lab-sat" in="n" type="saturate" values="0" result="g"/>
<feGaussianBlur id="lab-blur" in="g" stdDeviation="0" result="gb"/>
<feComponentTransfer in="gb" result="grain">
<feFuncR id="lab-fr" type="linear" slope=".15" intercept=".86"/>
<feFuncG id="lab-fg" type="linear" slope=".15" intercept=".86"/>
<feFuncB id="lab-fb" type="linear" slope=".15" intercept=".86"/>
<feFuncA type="linear" slope="0" intercept="1"/>
</feComponentTransfer>
<feBlend id="lab-blend" in="SourceGraphic" in2="grain" mode="multiply" result="mix"/>
<feComposite in="mix" in2="SourceGraphic" operator="in"/>
</filter>
</svg>
<aside class="lab-panel" aria-label="Controlos">
<h2>Laboratório de estilo</h2>
<label for="c-color">Cor da marca <output id="o-color">#C2D500</output></label>
<input type="color" id="c-color" value="#c2d500">
<label for="c-btn">Opacidade do grão — botões <output id="o-btn"></output></label>
<input type="range" id="c-btn" min="0" max="50" value="16">
<label for="c-head">Opacidade do grão — títulos h1/h2 <output id="o-head"></output></label>
<input type="range" id="c-head" min="0" max="100" value="25">
<label for="c-sat">Saturação das fotos <output id="o-sat"></output></label>
<input type="range" id="c-sat" min="40" max="100" value="90">
<label for="c-glow">Brilho dos links (hover) <output id="o-glow"></output></label>
<input type="range" id="c-glow" min="0" max="100" value="60">
<label for="c-spin">Volta da aresta rotativa <output id="o-spin"></output></label>
<input type="range" id="c-spin" min="10" max="100" value="32">
<label for="c-ring">Espessura da aresta (px) <output id="o-ring"></output></label>
<input type="range" id="c-ring" min="1" max="6" value="2">
<h2 style="margin-top:18px">Textura do grão</h2>
<label for="c-type">Tipo</label>
<select id="c-type" class="form-select form-select-sm"><option value="fractalNoise">Fino (fractalNoise)</option><option value="turbulence">Manchado (turbulence)</option></select>
<label for="c-freq">Tamanho do grão (frequência) <output id="o-freq"></output></label>
<input type="range" id="c-freq" min="5" max="200" value="90">
<label for="c-oct">Detalhe (oitavas) <output id="o-oct"></output></label>
<input type="range" id="c-oct" min="1" max="5" value="2">
<label for="c-seed">Semente <output id="o-seed"></output></label>
<input type="range" id="c-seed" min="0" max="50" value="4">
<label for="c-contrast">Contraste do grão <output id="o-contrast"></output></label>
<input type="range" id="c-contrast" min="20" max="250" value="100">
<label for="c-soft">Suavidade (desfoque) <output id="o-soft"></output></label>
<input type="range" id="c-soft" min="0" max="20" value="0">
<label for="c-tile">Mosaico dos botões (px) <output id="o-tile"></output></label>
<input type="range" id="c-tile" min="40" max="400" value="140">
<label for="c-bbm">Mistura nos botões</label>
<select id="c-bbm" class="form-select form-select-sm"><option>normal</option><option>multiply</option><option>overlay</option><option>soft-light</option><option>hard-light</option><option>screen</option><option>darken</option><option>lighten</option></select>
<label for="c-hbm">Mistura nos títulos</label>
<select id="c-hbm" class="form-select form-select-sm"><option>multiply</option><option>overlay</option><option>soft-light</option><option>screen</option><option>darken</option><option>lighten</option></select>
<label style="justify-content:flex-start;align-items:center"><input type="checkbox" id="c-mono" checked> Só cinzento (sem cor no grão)</label>
<label for="c-css">CSS gerado (copiar)</label>
<textarea id="c-css" class="form-control form-control-sm" rows="9" readonly style="font-family:monospace;font-size:.7rem"></textarea>
<button type="button" class="reset" id="c-reset">Repor valores</button>
<p class="hint">Passa o rato pelos botões, links, fotos, moldura e cartão para ver os efeitos. Os valores não são guardados.</p>
</aside>
<section class="lab-sec dark">
<div class="container">
<h1>Ginásio, Box, Aulas <span style="color:var(--fitme-accent)">&amp; Acompanhamento</span></h1>
<div class="lab-row">
<a href="#" class="site-btn">Inscreve-te já!</a>
<a href="#" class="site-btn sb-ghost">Ghost</a>
<a href="#" class="site-btn sb-dark">Dark</a>
<a href="#" class="site-btn sb-line" style="color:#fff;border-color:#fff">Line</a>
<button type="button" class="hero-text-link">Marca uma aula experimental</button>
</div>
<p>Um <a href="#">link no texto</a> sobre fundo escuro.</p>
</div>
</section>
<section class="lab-sec">
<div class="container">
<div class="section-title text-center deco">
<span class="section-eyebrow">Secção</span>
<h2>Título de secção (h2)</h2>
<p class="section-subtitle">Subtítulo de exemplo para ver o grão no h2 sobre fundo claro.</p>
</div>
<div class="lab-label">Botões e links</div>
<div class="lab-row">
<a href="#" class="site-btn">Botão principal</a>
<a href="#" class="nav-link crossfit-nav-link" style="display:inline-block;margin:0 !important">CrossFit</a>
<a href="#" class="gym-switch-btn">FitMe Mira</a>
<?php foreach (array_slice($gyms, 0, 2) as $g): ?><a href="#" class="gym-switch-btn" style="<?= htmlspecialchars(gym_brand_vars($g)) ?>"><?= htmlspecialchars($g['name']) ?></a><?php endforeach; ?>
</div>
<p>Texto corrido com um <a href="#">link normal</a> e outro <a href="#">link mais comprido para testar</a>.</p>
<div class="lab-label">Galeria (fotos)</div>
<div class="lab-gal">
<?php foreach ($img_gal as $i => $src): ?>
<div class="gal-tile"><a href="#" class="gal-link"><img src="<?= htmlspecialchars($src) ?>" alt="" draggable="false"></a></div>
<?php endforeach; ?>
</div>
<div class="lab-label">Equipa (moldura)</div>
<div class="trainer-item" style="display:inline-block">
<div class="trainer-pic"><img src="<?= htmlspecialchars($img_team) ?>" alt=""></div>
<h3 style="color:inherit">Nome do membro</h3>
</div>
<div class="lab-label">Modalidades (ícone)</div>
<div class="row" style="max-width:520px">
<div class="col-6"><div class="icon-box-item"><div class="ib-icon"><i class="fas fa-dumbbell"></i></div><h3>Musculação</h3></div></div>
<div class="col-6"><div class="icon-box-item"><div class="ib-icon"><i class="fas fa-bicycle"></i></div><h3>Cycling</h3></div></div>
</div>
<div class="lab-label">Mensalidades (cartão)</div>
<div class="lab-card-wrap">
<div class="price-card"><h3>Plano exemplo</h3><p>Passa o rato por cima.</p><a href="#" class="site-btn">Inscrever</a></div>
</div>
</div>
</section>
<script>
(function () {
var root = document.documentElement, ov = document.getElementById('lab-overrides');
var $ = function (id) { return document.getElementById(id); };
var DEF = { color: '#c2d500', btn: 16, head: 25, sat: 90, glow: 60, spin: 32, ring: 2,
type: 'fractalNoise', freq: 90, oct: 2, seed: 4, contrast: 100, soft: 0, tile: 140, bbm: 'normal', hbm: 'multiply', mono: true };
var st = Object.assign({}, DEF);
function noiseUrl() {
var f = (st.freq / 100).toFixed(2), op = (st.btn / 100).toFixed(3), c = st.contrast / 100;
var svg = "<svg xmlns='http://www.w3.org/2000/svg' width='" + st.tile + "' height='" + st.tile + "'>" +
"<filter id='n' x='0' y='0' width='100%' height='100%'>" +
"<feTurbulence type='" + st.type + "' baseFrequency='" + f + "' numOctaves='" + st.oct + "' seed='" + st.seed + "' stitchTiles='stitch'/>" +
(st.mono ? "<feColorMatrix type='saturate' values='0'/>" : "") +
(st.soft > 0 ? "<feGaussianBlur stdDeviation='" + (st.soft / 10).toFixed(1) + "'/>" : "") +
(c !== 1 ? "<feComponentTransfer><feFuncR type='linear' slope='" + c + "' intercept='" + ((1 - c) / 2).toFixed(3) + "'/><feFuncG type='linear' slope='" + c + "' intercept='" + ((1 - c) / 2).toFixed(3) + "'/><feFuncB type='linear' slope='" + c + "' intercept='" + ((1 - c) / 2).toFixed(3) + "'/></feComponentTransfer>" : "") +
"</filter><rect width='100%' height='100%' filter='url(#n)' opacity='" + op + "'/></svg>";
return 'url("data:image/svg+xml,' + svg.replace(/</g, '%3C').replace(/>/g, '%3E').replace(/#/g, '%23').replace(/%(?![0-9A-F]{2})/g, '%25') + '")';
}
function brandVars(hex) {
var r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16), b = parseInt(hex.slice(5, 7), 16);
var sh = function (k) { return '#' + [r, g, b].map(function (v) { return ('0' + Math.round(v * k).toString(16)).slice(-2); }).join('').toUpperCase(); };
var lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
return { '--fitme-accent': hex.toUpperCase(), '--fitme-accent-rgb': r + ', ' + g + ', ' + b, '--fitme-accent-dark': sh(.86),
'--fitme-accent-ink': sh(lum > .5 ? .5 : .85), '--fitme-on-accent': lum > .5 ? '#0B0B0D' : '#FFFFFF' };
}
function apply() {
var v = brandVars(st.color);
Object.keys(v).forEach(function (k) { root.style.setProperty(k, v[k]); });
var url = noiseUrl();
root.style.setProperty('--noise', url);
// headings: the SVG filter
var s = st.head / 100, c = st.contrast / 100;
var slope = (s * .6 * c).toFixed(3), icpt = (1 - s * .6 * c / 2 - s * .6 * c / 2).toFixed(3);
['lab-fr', 'lab-fg', 'lab-fb'].forEach(function (id) { $(id).setAttribute('slope', slope); $(id).setAttribute('intercept', icpt); });
$('lab-turb').setAttribute('type', st.type);
$('lab-turb').setAttribute('baseFrequency', (st.freq / 100).toFixed(2));
$('lab-turb').setAttribute('numOctaves', st.oct);
$('lab-turb').setAttribute('seed', st.seed);
$('lab-sat').setAttribute('values', st.mono ? '0' : '1');
$('lab-blur').setAttribute('stdDeviation', (st.soft / 10).toFixed(1));
$('lab-blend').setAttribute('mode', st.hbm);
var glow = st.glow / 100, spin = (st.spin / 10).toFixed(1) + 's';
ov.textContent =
'.site-btn,.gym-switch-btn,.crossfit-nav-link { background-size: ' + st.tile + 'px ' + st.tile + 'px !important; background-blend-mode: ' + st.bbm + ' !important; }' +
'.gal-link img, .trainer-item .trainer-pic img { filter: saturate(' + (st.sat / 100) + '); }' +
'.gal-link:hover img, .trainer-item .trainer-pic:hover img { filter: saturate(1) brightness(1.05); }' +
'a:where(:not(.site-btn,.gym-switch-btn,.gal-link,.nav-link)):is(:hover,:focus-visible) { text-shadow: 0 0 10px rgba(var(--fitme-accent-rgb),' + (.6 * glow * 1.67).toFixed(2) + '), 0 0 22px rgba(var(--fitme-accent-rgb),' + (.3 * glow * 1.67).toFixed(2) + ') !important; }' +
'.site-btn::before,.site-btn::after,.gym-switch-btn::before,.gym-switch-btn::after,.price-card::before,.price-card::after,.gal-link::after,.icon-box-item .ib-icon::after,.trainer-item .trainer-pic:hover { animation-duration: ' + spin + ' !important; }' +
'.site-btn,.gym-switch-btn { --ring: ' + st.ring + 'px; }';
$('o-color').textContent = st.color.toUpperCase();
$('o-btn').textContent = st.btn + '%';
$('o-head').textContent = st.head + '%';
$('o-sat').textContent = st.sat + '%';
$('o-glow').textContent = st.glow + '%';
$('o-spin').textContent = spin;
$('o-ring').textContent = st.ring;
$('o-freq').textContent = (st.freq / 100).toFixed(2);
$('o-oct').textContent = st.oct;
$('o-seed').textContent = st.seed;
$('o-contrast').textContent = st.contrast + '%';
$('o-soft').textContent = (st.soft / 10).toFixed(1);
$('o-tile').textContent = st.tile;
$('c-css').value =
':root {\n --noise: ' + url + ';\n}\n' +
'.site-btn, .gym-switch-btn, .crossfit-nav-link {\n background-image: var(--noise) !important;\n background-size: ' + st.tile + 'px ' + st.tile + 'px !important;' +
(st.bbm !== 'normal' ? '\n background-blend-mode: ' + st.bbm + ' !important;' : '') + '\n}\n' +
'/* títulos: <filter id="pt-noise"> */\n' +
'feTurbulence type="' + st.type + '" baseFrequency="' + (st.freq / 100).toFixed(2) + '" numOctaves="' + st.oct + '" seed="' + st.seed + '"\n' +
'feColorMatrix saturate ' + (st.mono ? 0 : 1) + ' · feGaussianBlur ' + (st.soft / 10).toFixed(1) + '\n' +
'feFunc* slope="' + slope + '" intercept="' + icpt + '" · feBlend mode="' + st.hbm + '"';
}
var map = { 'c-color': 'color', 'c-btn': 'btn', 'c-head': 'head', 'c-sat': 'sat', 'c-glow': 'glow', 'c-spin': 'spin', 'c-ring': 'ring',
'c-type': 'type', 'c-freq': 'freq', 'c-oct': 'oct', 'c-seed': 'seed', 'c-contrast': 'contrast', 'c-soft': 'soft', 'c-tile': 'tile',
'c-bbm': 'bbm', 'c-hbm': 'hbm', 'c-mono': 'mono' };
var textual = { 'c-color': 1, 'c-type': 1, 'c-bbm': 1, 'c-hbm': 1 };
Object.keys(map).forEach(function (id) {
$(id).addEventListener('input', function () {
st[map[id]] = id === 'c-mono' ? this.checked : (textual[id] ? this.value : +this.value);
apply();
});
});
$('c-reset').addEventListener('click', function () {
st = Object.assign({}, DEF);
Object.keys(map).forEach(function (id) { if (id === 'c-mono') $(id).checked = DEF.mono; else $(id).value = DEF[map[id]]; });
apply();
});
apply();
})();
</script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
<?php require __DIR__ . '/shared/platform-tools/router.php';
+16
View File
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
/**
* DEV ONLY -- upsert idempotente das contas de teste fixas (sysadmin@mail.pt e
* admin@mail.pt, palavra-passe 123). Só CLI. A lógica e o guarda (APP_ENV dev E
* BD 192.168.1.3:3181, ou localhost com DEV_ALLOW_LOCAL=1) são do platform-tools.
* php scripts/dev_accounts.php
*/
$_dev_shared = dirname(__DIR__) . '/shared/platform-tools/functions_dev.php';
if (!is_file($_dev_shared)) { fwrite(STDERR, "REFUSED: platform-tools sem functions_dev.php\n"); exit(1); }
require_once $_dev_shared;
exit(platform_dev_accounts_run([
'flags' => ['is_active' => 1],
'accounts' => ['sysadmin@mail.pt' => 'sysadmin', 'admin@mail.pt' => 'admin',
'client@mail.pt' => 'client', 'staff@mail.pt' => 'staff'],
]));
-36
View File
@@ -1,36 +0,0 @@
# OS
.DS_Store
Thumbs.db
# Editor / IDE
.vscode/
.idea/
*.swp
*.swo
# Secrets
.env
.env.local
.env.*.local
# Logs
*.log
# AI assistant files — not part of the project
CLAUDE.md
AGENTS.md
.claude/
GEMINI.md
.gemini/
.cursorrules
.cursorignore
.aider*
AIDER.md
COPILOT*.md
# Índice de países da analítica: ~5 MB, gerado por bin/geo-build-index.php.
# Não é versionado -- um binário deste tamanho refrescado mensalmente inchava o
# histórico. Sem ele a analítica regista o país como desconhecido, que é um
# degradar aceitável; as listas do BLOQUEIO por país (pt.v4/pt.v6) essas ficam
# versionadas, porque aí dados em falta seriam uma decisão de segurança.
data/geo/country-index.*
-80
View File
@@ -1,80 +0,0 @@
# Assets de front-end na frota andmore
Convenção fixada a 2026-09-01, quando os oito sites deixaram de carregar
JavaScript e CSS de CDNs.
## A regra
**Nada de front-end sai para a rede em produção.** Tudo é servido do próprio
domínio, está no git, e tem a versão no nome da pasta.
public/vendor/<biblioteca>-<versão>/… (o hub usa public/assets/vendor/,
que é onde já tinha o Bootstrap)
Porquê, e não é gosto por arrumação:
- um CDN em baixo leva o back-office com ele — e o CSS do Bootstrap a faltar
não é «sem estilo», é uma página ilegível;
- um CDN comprometido corre código de terceiros **na sessão de quem está
autenticado como administrador**, e nenhuma destas tags tinha SRI;
- e uma versão que vem de `@1` ou `@5.3` muda debaixo dos pés sem ninguém
decidir nada.
## De onde vem cada coisa
**Composer, sempre que o pacote exista.** Instala-se com `composer require` e
copia-se o ficheiro de distribuição para `public/vendor/`:
twbs/bootstrap → bootstrap-<versão>/{css,js}/
twbs/bootstrap-icons → bootstrap-icons-<versão>/ (+ fonts/)
components/jquery → jquery-<versão>/jquery.min.js
**Vendorizado à mão**, quando não há pacote. Descarrega-se uma vez, commita-se,
e deixa-se um `ORIGEM.md` ao lado com o URL de origem e o comando para
actualizar. É o caso do DataTables, Chart.js, Cropper, Flatpickr, SortableJS,
html2canvas, qrcodejs, signature_pad, html5-qrcode, Font Awesome, jszip e
pdfmake.
## DataTables: build combinado, e porque não vem do composer
O único pacote no packagist é `datatables/datatables`, **congelado na 1.10.21
(2020)**, que usa `$.isArray` e `$.trim` — ambas removidas na jQuery 4. Ir pelo
composer prendia a frota à jQuery 3 para sempre.
Usa-se o build combinado do construtor oficial, que é **um ficheiro** com o
core, os Buttons, o Responsive e a integração Bootstrap 5:
https://cdn.datatables.net/v/bs5/dt-2.3.4/b-3.2.6/r-3.0.6/datatables.min.js
https://cdn.datatables.net/v/bs5/dt-2.3.4/b-3.2.6/r-3.0.6/datatables.min.css
Substituiu até cinco `<script>` por site. Os botões de exportação (CSV, Excel,
imprimir) já vêm lá dentro; só o `jszip` e o `pdfmake` ficam à parte, e só onde
se exporta.
## A armadilha, que já custou duas homes
**`base_url()` não existe em todo o lado.** Na fotografia e na mstudio, o site
público (`public/index.php`, `public/site/includes/head.php`) não carrega o
`config/functions.php`. Um `<?= base_url('vendor/…') ?>` ali dá fatal e a
página passa a servir o ecrã de erro.
**Usa sempre o caminho absoluto `/vendor/…`.** O docroot é a raiz do domínio em
todos os sites, portanto é equivalente, e funciona haja ou não bootstrap.
## Não usar
- `is_file($local) ? $local : $cdn` — não é uma rede de segurança. É a promessa
de que, no dia em que um ficheiro faltar, a página vai buscar código a um
terceiro sem ninguém dar por isso.
- `<link rel="preconnect">` para CDNs — não há para onde ligar.
- Cópias sem versão no nome (`assets/js/dataTables.min.js`). Ninguém sabe qual
é, e a próxima pessoa não tem como descobrir sem comparar bytes.
- **jQuery slim.** Não traz `$.ajax`, e portanto nenhum DataTables em
server-side processing funciona com ela. A dance esteve assim.
## Estado a 2026-09-01
Todos os sites: **jQuery 4.0.0** e **DataTables 2.3.4**. Zero CDN.
Continua a sair para a rede, por decisão à parte: o **Google Fonts**, em dance,
fotografia, mstudio, silver, quack e design.
-274
View File
@@ -1,274 +0,0 @@
# How the andmore platforms are built
Seven sites — **dance**, **design**, **fotografia**, **hub2**, **mstudio**,
**quack**, **silver** — share one VPS, one MariaDB, one reverse proxy, and this
submodule. They are separate products with separate databases, but they are
built the same way on purpose, so that knowing one means knowing all seven.
Two of them are worth naming as special cases, because they are the shape the
conventions have to survive: **mstudio** is a second instance of fotografia's
platform under different branding, and **quack** is a classroom game that grew
up outside this submodule and was brought in later. Where this document says
"every site", those two are included — that is the point of them.
This document records the reasoning behind that shape. It is descriptive, not
aspirational: everything here is a decision already visible in the code. When a
choice below stops being true, change the code or change this file — a
convention nobody follows is worse than no convention.
---
## 1. Plain PHP, no framework, no build step
PHP 8.4 with PDO, server-side rendering, Bootstrap 5. Composer is used for
autoloading and real libraries; there is no bundler, no transpiler, no
`node_modules` in production.
The reason is maintenance horizon. These are small products that must keep
working for years with long idle stretches between changes. A build step is a
second thing that can rot — toolchain versions, lockfiles, a CI runner — and it
rots silently while the site still appears fine. Editing a `.php` file and
reloading is a workflow that cannot break.
The cost is real and accepted: no type checking beyond PHP's own, no component
model, more repetition in templates. That trade is worth it at this size and
would not be at ten times the size.
## 2. Comments explain why, never what
The code is unusually densely commented, and almost none of it restates the
line below. A comment earns its place by recording something the reader cannot
recover from the code: a constraint, a rejected alternative, a bug that a
previous version had.
```php
// MariaDB does not read cgroup limits -- without this it sizes its buffer
// pool against all 7.8GB of host RAM and then gets OOM-killed by mem_limit.
- --innodb-buffer-pool-size=1G
```
That comment is worth more than the line it sits above, because the line looks
arbitrary without it and someone will eventually "clean it up".
Where a decision is a knowing compromise, it is labelled as one. The site
containers mount their own source read-write so the backoffice updater can run
`git pull`, and the compose file says so under an **ACCEPTED RISK** heading
naming the exact exposure. A documented risk is a decision; an undocumented one
is an accident waiting to be discovered by someone else.
## 3. Shared code lives here, once
Anything two platforms both need moves into `platform-tools` and is consumed as
a git submodule. Edit one file, every platform gets it.
The failure mode this exists to prevent is genuine and has already happened:
three sites independently grew their own AES-256-GCM secret helpers with three
different key derivations, all writing the same `enc:v1:` prefix, all mutually
unreadable, and nothing surfaced the divergence until something tried to read
across sites. Copies do not stay identical. They stay *similar*, which is worse,
because similar code invites the assumption that it is the same.
The corollary: **a site must not reimplement something this submodule provides.**
If the shared version is wrong for one site, fix it here with a parameter, or
say plainly in the site's own docs why it deviates.
**The site declares; the shared code does not guess.** Each site's
`config/db.php` makes one `platform_connect()` call that states its PDO, base
URL, environment, column map, roles, session and sysadmin guard. Before it,
every tool probed for variants — a global `$pdo` or `App\Database`, a
`base_url()` or a `getBaseUrl()` — and each new tool had to remember them all.
The same call runs `platform_conformance()`: the database is compared with what
the shared code expects, and a missing column that would switch a defence off
without an error fails loudly in development and turns the hub card yellow in
production (§7).
**New tools reach every site without a file on the site.** The shared pages are
served by one front controller (`public/platform.php?tool=…`) and the shared
endpoints by another (`public/api/platform.php?endpoint=…`), both from closed
route tables here. A per-page stub on each of seven sites was seven chances to
forget one.
Submodules have one sharp edge worth stating: `git pull` in a site advances the
submodule *pointer* but leaves its working tree on the old commit, silently and
with no error. Every deploy script therefore runs `git submodule update
--init --recursive` as a separate, mandatory step.
## 4. Configuration has two homes, and a rule for choosing
`.env` for anything needed to reach or decrypt the database, or to repair the
site when it is broken. The `system_settings` table for anything an operator
should be able to change without shell access.
The test: *can the system read this after a cold start with an empty database,
and would I still need it if the app were broken?*
This is why `DB_*` and `APP_SECRET_KEY` can never move to the database — the
first is needed to reach it, the second to decrypt it. And it is why
`GIT_HTTP_TOKEN` and `HUB_OPS_SECRET` stay in `.env` by choice rather than
necessity: they are the credentials used to *repair* a platform, and a repair
channel must not depend on the thing it repairs.
Full precedence rules and the secret-encryption contract are in
[README.md](README.md#settings-and-secrets).
## 5. Secrets are encrypted at rest, because backups exist
The nightly job keeps many copies of each database. A plaintext SMTP password
would therefore sit in every one of them, and the backups live on disk next to
everything else. So secret-valued settings are encrypted, and the key lives only
in `.env` — never in the database, never in git. A leaked dump alone is not
enough to recover credentials.
Each platform keeps its **own** `APP_SECRET_KEY`. A secret encrypted on hub2
deliberately does not decrypt on dance: one compromised dump must not unlock the
estate.
Which keys count as secret is decided centrally, by name, in
`platform_settings_is_secret()` — never by the calling page. The previous
arrangement had each site keeping its own list, and a credential added later on
a different screen was simply missed.
## 6. Authentication is mechanism, not policy
Sessions, password hashing, login throttling, guards and password recovery live
in `functions_auth.php` and `functions_password_reset.php`. None of it contains
business logic, so there was never a reason for one version per site — and the
separate versions had exactly the drift you would expect. dance ran a bare
`session_start()` with no HttpOnly, no SameSite and no strict mode, never
rotated the session id on login, kept the password hash in `$_SESSION`, and
accepted unlimited login attempts. The other three had each solved some of that,
separately.
Two things stay per-site, deliberately:
- **The users table.** The columns genuinely differ — fotografia's users carry
CalDAV credentials, silver's carry a guardian. The shared code adapts through
the `schema` given to `platform_connect()` (`auth_schema()` underneath),
declaring only the columns that differ, rather than demanding one shape, because renaming a column
with 147 references is a migration and adapting is a function call.
The same map covers *what you sign in with*. Six sites match on `email`;
quack matches on `username` and keeps `email` as the address recovery mails
to, so `auth_schema()` carries a `login` key separate from `email`. It
defaults to null, meaning "same as email" — a site that never thought about
it gets the behaviour it already had.
- **The roles.** `sysadmin`, `admin` and `user` are common and mean the same
everywhere. Past that the vocabulary is the product's: teacher, student,
parent, staff, client, formador, aluno. A site registers its own with a rank,
so
`auth_at_least('admin')` works without anyone hardcoding a list, and an
unrecognised role ranks zero — a typo can never grant access.
Password recovery goes out over the same SMTP transport as everything else, and
stores only the SHA-256 of its token: a leaked database dump must not be usable
to reset anyone's password.
## 7. Fail loudly; silence must mean healthy
The most expensive bug in this estate's history was not a crash. dance's nightly
backup ran every night, printed `DB backup FAILED`, exited `0`, and touched its
success heartbeat. Both monitoring signals — an empty error log and a fresh
heartbeat — reported health while zero backups existed. It took four
independent faults to produce that, and every one of them was individually
survivable; what made it invisible was the exit code.
A related habit from the same incident: dance ran `error_reporting(0)` in
production, which silences the log as well as the screen. Suppress *display*,
never *logging* — the log is the only thing that can explain a production
failure after the fact.
Two rules come from that:
- **An operation that failed must exit non-zero.** Printing the failure is not
reporting it; nothing downstream reads stdout.
- **Health signals must be positive, not the absence of a negative.** An empty
log is ambiguous between "everything succeeded" and "it never ran". Cron jobs
therefore touch a heartbeat file *only* on real success, and the check is the
heartbeat's freshness rather than the log's emptiness.
The same instinct applies in code: a missing configuration value should be a
loud error, not a quiet default that happens to be empty.
## 8. Expose the minimum
Only the reverse proxy publishes ports. MariaDB publishes none and is reachable
only on an internal Docker network. Each site has its own database user with
access to its own schema and nothing else.
Where a control surface must exist, it is bound to loopback and reached over an
SSH tunnel rather than firewalled — because Docker writes iptables rules
directly and a `ufw` rule would not actually apply. That distinction is written
into the compose file, since the safe-looking alternative is the one people
reach for.
Every container runs with `no-new-privileges`, a memory limit and a pid cap,
sized to its workload rather than copied between sites.
Application code sits outside the document root: the web root is `public/`, and
`shared/`, `config/` and `src/` are not reachable over HTTP. Anything that
genuinely must be callable goes through the two front controllers or a thin,
explicit stub in `public/api/` — and the front controllers only dispatch names
from a closed list; they never include a path taken from the request.
## 9. Migrations are append-only
A migration that has been applied is never edited. If it seeded a value that
later turns out wrong, a new migration corrects it. The applied set is tracked
in `applied_migrations`, and the schema is exported deterministically to
`database/schema.sql` so that a git diff on that file is a real changelog.
Editing an applied migration produces a database that matches no known state —
recoverable on the machine where it happened, and nowhere else.
**The filename is the order.** The runner globs `database/migration_*.sql` and
sorts by name, so the numeric prefix is not decoration — it is the only thing
saying what runs when. quack's were originally named after what they did, and
alphabetically `migration_multi_deck` (which creates `decks`) sorted *after*
`migration_anti_hoarding` (which updates it). Thirteen of thirty-five failed
on a fresh install, and nobody noticed for months because the only database
that mattered had been built incrementally, in the order the files were
written. A migration set that cannot rebuild the database from scratch is not
a migration set; it is a changelog that happens to be executable.
## 10. The page works without JavaScript
Pages render complete from the server. JavaScript filters, copies, reorders and
warns — it does not fetch the content. Without it a page degrades to *more*
information rather than less: a filter simply stays off and everything shows.
**Vendoring is the goal, not yet the practice.** hub2 serves every front-end
asset from itself and says so in its own code. quack does the same by a
different route — composer `post-install-cmd` downloads Bootstrap, jQuery,
DataTables and A-Frame into `public/assets/vendor/` at deploy time, and each
`<script>` falls back to the CDN only when the local file is absent, so the
fallback is visible in the page rather than assumed. dance, fotografia and
silver still load Bootstrap and Bootstrap Icons from `cdn.jsdelivr.net` — 12,
11 and 8 files respectively. So this is a direction, not yet a description:
new pages should vendor, and the remaining CDN links are debt worth paying
down, because a third party host is a runtime dependency the container
isolation cannot protect.
## 11. Portuguese for people, English for machines
User-facing text is European Portuguese (pt-PT). Code identifiers, commit
subjects and the infrastructure documentation are English. In-code comments are
written in whichever language the surrounding file already uses — consistency
within a file matters more than uniformity across the estate.
## 12. Data lives outside the checkout
Uploads, logs, caches and backups are bind-mounted from `/srv/data/<host>/` and
never live inside the git working tree. The checkout should be disposable: a
`git pull`, a rebuild, or a fresh clone must never risk user data, and `git
status` on a production site should be quiet enough that real drift stands out.
---
## When these conflict
They will. The ordering that has held so far:
1. **Do not lose data.** Backups and migrations win over convenience.
2. **Do not hide failure.** A loud error beats a graceful degradation nobody sees.
3. **Do not duplicate.** One implementation, parameterised, beats two that agree today.
4. **Do not surprise.** A boring, obvious solution beats a clever one, because the reader six months from now is the constraint.
File diff suppressed because it is too large. Load diff
-49
View File
@@ -1,49 +0,0 @@
# platform-tools is vendored here, not a submodule
This directory used to be a git submodule pointing at
`git.andmore.pt/andre/platform-tools`. On the `main` branch it is a plain
directory of tracked files.
**Frozen at:** `12f26fd1cba8ce3229c598ac2f2e9a788cd7cf75` (`fix(client_ip): X-Real-IP / último X-Forwarded-For, nunca o primeiro`)
— refreshed on 2026-09-25 from `ea3616a`, 27 commits later. It is the fleet
pin (`f761d99`, audit module) plus `a8c681b` (login form pieces, remember
options) and the client-IP fix found in this app's audit.
History: `ea3616a` (2026-09-13, same pin as fotografia at the time) →
`12f26fd` (2026-09-25).
## Why
The submodule was the single biggest source of deployment failures on this
install:
- `.gitmodules` named no `branch`, so "Atualizar submódulo" fell back to
`refs/remotes/origin/HEAD`, a ref a plain server-side clone never creates.
It failed with `Unable to find refs/remotes/origin/HEAD revision`.
- Its URL used `http://`, which the origin answers with 523.
- The site is deployed by uploading files. A submodule is invisible to that:
the rest of the tree moved forward while `shared/platform-tools` stayed on an
old commit, and `config/functions_secrets.php` then died on a missing
`compat_secrets.php` — taking every page, public ones included, down with it.
A plain directory deploys like every other file. There is no second repository
to be out of step with the first.
## The trade-off
Fixes to platform-tools no longer arrive by bumping a pin. To take an update:
1. Clone or pull `platform-tools` somewhere separate.
2. Check out the commit you want.
3. Copy its files over this directory (delete removed ones).
4. Update the SHA above, and commit here with that SHA in the message.
Keep recording the upstream SHA. It is the only thing that makes "which version
is this?" answerable.
## Note on line endings
`social/engine.js`, `social/functions_publications.php`, `social/page.js` and
`social/publications.css` are stored upstream with CRLF. They were normalised to
LF when vendored. The content is otherwise byte-identical; diff with
`tr -d '\r'` to compare against upstream.
-21
View File
@@ -1,21 +0,0 @@
<?php
// Self-bootstrapping remote-ops endpoint for the id.andmore.pt fleet plane —
// config/ stack (fitme3070, fotografia, dsa, moremio, tickets).
//
// Assumed location: <host>/shared/platform-tools/api/agent.php
// dirname(__DIR__, 3) === <host>
// The per-platform stub at <host>/api/agent.php is a one-line require of this file.
//
// The action set lives in functions_agent.php, so a host that needs its own
// stub (surf sets MIGRATIONS_DIR first) still shares it.
$repo_root = dirname(__DIR__, 3);
require_once $repo_root . '/config/db.php'; // opens $pdo, starts the session
require_once $repo_root . '/config/functions.php'; // env(), helpers, migrations stub
require_once __DIR__ . '/../functions_agent.php';
$secret = (string) (function_exists('env')
? (env('HUB_OPS_SECRET', '') ?: '')
: (getenv('HUB_OPS_SECRET') ?: ''));
agent_dispatch($pdo, $repo_root, $secret);
-88
View File
@@ -1,88 +0,0 @@
<?php
// Self-bootstrapping deploy webhook. Point a Gitea (or GitHub) push webhook at
// <host>/api/auto_update.php and the platform updates itself on every push to
// the branch it is running.
//
// Assumed location: <host>/shared/platform-tools/api/auto_update.php
// dirname(__DIR__, 3) === <host>
// The per-platform stub at <host>/api/auto_update.php is a one-line require.
//
// Answers JSON always, and with a status code the webhook UI can be read from:
// 202 accepted (work continues after the reply) / disabled
// 200 skipped — push was for another branch
// 401 bad signature · 405 wrong method · 503 no secret configured
//
// A refusal is deliberately a 4xx/5xx: Gitea shows the delivery in red and
// keeps the response body, which is the only place a rejected delivery would
// ever be noticed before someone opens the platform page.
//
// Everything that can be answered instantly IS answered instantly, and only the
// slow part runs after the reply. Gitea allows a webhook delivery 5 seconds by
// default; a git pull alone exceeds that and a pre-update backup exceeds it by
// minutes, so a synchronous reply would mark every successful deploy as failed.
// The outcome is recorded in auto_update_last_* — the platform page and the
// dashboard strip are where you read it.
$repo_root = dirname(__DIR__, 3);
require_once $repo_root . '/config/db.php'; // opens $pdo, starts the session
require_once $repo_root . '/config/functions.php'; // env(), helpers
require_once __DIR__ . '/../functions_auto_update.php';
header('Content-Type: application/json; charset=utf-8');
$reply = function (int $code, array $data): never {
http_response_code($code);
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
};
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$reply(405, ['ok' => false, 'error' => 'Só POST.']);
}
// Read the body ONCE and verify against those exact bytes — re-reading
// php://input after the fact can come back empty and would silently turn the
// signature check into a no-op.
$body = (string) file_get_contents('php://input');
$secret = auto_update_secret();
if ($secret === '') {
$reply(503, ['ok' => false, 'error' => 'AUTO_UPDATE_SECRET não está definido no .env desta plataforma.']);
}
if (!auto_update_verify($body, $secret)) {
$reply(401, ['ok' => false, 'error' => 'Assinatura ausente ou inválida.']);
}
// Signature first, switch second: an unauthenticated caller learns nothing
// about this platform, not even whether the feature is on.
if (!auto_update_enabled($pdo)) {
$reply(202, ['ok' => true, 'status' => 'disabled',
'message' => 'Auto-update desligado nesta plataforma.']);
}
// A push to a branch this server is not running is not our business — the
// webhook fires for every branch, and pulling one you are not on does nothing
// useful at best.
$ref = auto_update_payload_branch($body);
if ($ref !== null && function_exists('git_run')) {
$cur = git_run(['rev-parse', '--abbrev-ref', 'HEAD'], $repo_root);
$cur = $cur['ok'] ? trim($cur['output']) : '';
if ($cur !== '' && $cur !== $ref) {
$reply(200, ['ok' => true, 'status' => 'skipped',
'message' => "Push para {$ref}; este servidor está em {$cur}."]);
}
}
// Server-to-server past this point: drop the session lock so the pull (which
// can take a while) does not hold up the operator's own browsing.
@session_write_close();
// Answer Gitea now; do the slow work with the connection already closed.
platform_respond_and_continue(202, [
'ok' => true,
'status' => 'accepted',
'message' => 'Atualização iniciada. O resultado fica registado na plataforma.',
'ref' => $ref,
]);
auto_update_run($pdo, $repo_root, $ref);
@@ -1,43 +0,0 @@
<?php
// Self-bootstrapping download handler. Assumed location:
// <host>/shared/platform-tools/api/backup_download.php
$repo_root = dirname(__DIR__, 3);
require_once $repo_root . '/config/db.php';
require_once $repo_root . '/config/functions.php';
require_once $repo_root . '/config/functions_backup.php';
require_sysadmin();
$filename = $_GET['file'] ?? '';
if (!backup_filename_is_safe($filename)) {
http_response_code(400);
exit('Ficheiro inválido.');
}
$dir = backup_dir();
$filepath = $dir . DIRECTORY_SEPARATOR . $filename;
$real = realpath($filepath);
if ($real === false || !str_starts_with($real, $dir . DIRECTORY_SEPARATOR)) {
http_response_code(403);
exit('Acesso negado.');
}
if (!is_file($real)) {
http_response_code(404);
exit('Ficheiro não encontrado.');
}
$size = filesize($real);
header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Content-Length: ' . $size);
header('Cache-Control: no-store, no-cache, must-revalidate');
header('Pragma: no-cache');
if (ob_get_level()) ob_end_clean();
readfile($real);
exit;
@@ -1,55 +0,0 @@
<?php
// Self-bootstrapping POST handler. Assumed location:
// <host>/shared/platform-tools/api/backup_trigger.php
// dirname(__DIR__, 3) === <host>
$repo_root = dirname(__DIR__, 3);
require_once $repo_root . '/config/db.php';
require_once $repo_root . '/config/functions.php';
require_once $repo_root . '/config/functions_backup.php';
require_once __DIR__ . '/../functions_csrf.php';
require_once __DIR__ . '/../functions_settings.php'; // platform_admin_url()
require_sysadmin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: ' . platform_admin_url('backups.php'));
exit;
}
if (!platform_csrf_ok()) {
$_SESSION['backup_flash'] = [
'type' => 'danger',
'message' => 'Pedido rejeitado (token CSRF ausente ou inválido). Recarrega a página e repete.',
];
header('Location: ' . platform_admin_url('backups.php'));
exit;
}
$type = $_POST['type'] ?? '';
if (!in_array($type, ['files', 'db'], true)) {
$_SESSION['backup_flash'] = [
'type' => 'danger',
'message' => 'Tipo de backup inválido.',
];
header('Location: ' . platform_admin_url('backups.php'));
exit;
}
$count_before = count(backup_list());
$ok = backup_trigger($type);
$_SESSION['backup_flash'] = $ok
? [
'type' => 'success',
'message' => 'Backup iniciado. A lista atualiza automaticamente quando o ficheiro aparecer.',
// Lets the backups page poll until a new file shows up.
'await_count' => $count_before,
]
: [
'type' => 'danger',
'message' => 'Falha ao iniciar o backup. Verifique os logs do servidor.',
];
header('Location: ' . platform_admin_url('backups.php'));
exit;
-69
View File
@@ -1,69 +0,0 @@
<?php
// ─── Endpoint da bolha de feedback ───────────────────────────────────────────
//
// POST multipart/form-data (o que um <form> envia), resposta JSON:
// {"ok":true,"ref":"T-1234","track_url":"https://…"}
// {"ok":false,"error":"…"}
//
// Ao contrário dos outros endpoints desta pasta, este NÃO se autentica por
// chave: quem chama é o browser de um administrador com sessão aberta neste
// site. Precisa por isso da sessão e dos helpers de autenticação do site — e
// obtém-nos do mesmo hub_bootstrap.php que os outros, que abre o config/db.php
// do site e deixa em cima da mesa a sessão aberta, o $pdo e o functions_hub.
//
// Assim o stub de cada site é uma linha, igual à dos restantes endpoints.
//
// Sendo autenticado por cookie, é atacável por CSRF: um site qualquer podia
// levar o browser de um administrador a abrir tickets em nome dele. Daí o
// token do platform_csrf_ok(). Não substitui a verificação do papel — CSRF e
// permissão respondem a perguntas diferentes.
declare(strict_types=1);
require_once __DIR__ . '/../hub_bootstrap.php';
require_once __DIR__ . '/../functions_feedback.php';
require_once __DIR__ . '/../functions_csrf.php';
header('Content-Type: application/json; charset=utf-8');
// Nada disto deve ficar em cache nem ser partilhado entre sessões.
header('Cache-Control: no-store');
function feedback_out(int $status, array $payload): never
{
http_response_code($status);
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
feedback_out(405, ['ok' => false, 'error' => 'Usa POST.']);
}
$user = platform_feedback_user();
if (!$user || !platform_feedback_is_admin($user)) {
// 403 e não 401: quem chega aqui sem sessão não tem como a obter a partir
// deste endereço, e um 401 mandaria o browser pedir credenciais.
feedback_out(403, ['ok' => false, 'error' => 'Sessão terminada ou sem permissão. Recarrega a página.']);
}
if (!platform_csrf_ok()) {
feedback_out(403, ['ok' => false, 'error' => 'Pedido rejeitado (token expirado). Recarrega a página e tenta outra vez.']);
}
$res = platform_feedback_submit([
'title' => (string) ($_POST['title'] ?? ''),
'description' => (string) ($_POST['description'] ?? ''),
'category' => (string) ($_POST['category'] ?? 'problema'),
'blocking' => !empty($_POST['blocking']),
'page_url' => (string) ($_POST['page_url'] ?? ''),
], $user);
// 200 mesmo quando o hub recusa: o erro já vem escrito para a pessoa ler, e é
// o corpo que a bolha mostra. Um 4xx aqui só faria o fetch tratar como avaria
// de rede uma coisa que tem explicação.
feedback_out(200, [
'ok' => (bool) $res['ok'],
'error' => (string) ($res['error'] ?? ''),
'ref' => (string) ($res['ref'] ?? ''),
'track_url' => (string) ($res['track_url'] ?? ''),
]);
-180
View File
@@ -1,180 +0,0 @@
<?php
// Estado desta plataforma, para o monitor do hub. Só leitura: nenhum parâmetro
// muda comportamento e nenhum dado de negócio sai daqui. As ações vivem noutro
// endpoint (api/hub_ops.php) e com outra chave — ver ../functions_hub.php.
//
// Formato: docs/CONTRATO_AGENTE_SAUDE.md do hub. `git` e `backup` são entradas
// DENTRO de `checks`; um check em falta aparece como «não reportado» e um check
// a `false` pinta o cartão de amarelo.
//
// **`ok` de topo não é o AND dos checks.** Segundo o contrato, `ok:false`
// significa «responde, mas está inutilizável» — e é isso que o hub mostra como
// «Em baixo». Uma mensagem falhada na fila ou uma cópia antiga não põem um site
// em baixo: são checks a false, que já dão o aviso amarelo. Aqui `ok` segue a
// base de dados, que é o que decide se a plataforma consegue sequer servir.
//
// Cada verificação corre isolada: uma que rebente reporta-se como falha, em vez
// de derrubar a resposta inteira — que apareceria no hub como app inacessível,
// que é uma coisa diferente e mais alarmante.
//
// O stub de cada site (public/api/health.php) é uma linha a incluir isto.
declare(strict_types=1);
require_once __DIR__ . '/../hub_bootstrap.php';
hub_key_verify_or_die();
$health_check = static function (callable $fn): array {
try {
return $fn();
} catch (Throwable $e) {
return ['ok' => false, 'detail' => 'erro no check: ' . $e->getMessage()];
}
};
$root = $repo_root;
$checks = [];
// ─── Plataformas estáticas ───────────────────────────────────────────────────
//
// O apex (andmore.pt) não tem base de dados — ver a nota no hub_bootstrap.php.
// Com ela caem quatro checks: base de dados, SMTP, fila e migrações. Nenhum se
// pode medir, e o contrato diz que um check em falta aparece como «não
// reportado», que é exactamente a verdade.
//
// O que não se faz é deixá-los cair calados: sem uma palavra, o cartão no hub
// fica com quatro linhas em branco e ninguém sabe se aquilo é uma montra
// estática ou uma app com o arranque partido. O check `storage` diz qual é.
if (!$hub_has_db_layer) {
$checks['storage'] = ['ok' => true, 'detail' => 'montra estática — sem base de dados'];
}
if ($pdo instanceof PDO) $checks['database'] = $health_check(function () use ($pdo) {
$version = (string) $pdo->query('SELECT VERSION()')->fetchColumn();
$tables = (int) $pdo->query('SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE()')->fetchColumn();
return ['ok' => true, 'detail' => $version . ', ' . $tables . ' tabelas'];
});
// SMTP: abre a ligação e fecha. NÃO envia nada — isto corre de poucos em poucos
// minutos e um envio por sondagem seria spam a si próprio.
if ($pdo instanceof PDO) $checks['smtp'] = $health_check(function () use ($pdo) {
// O SMTP da frota é configurado no backoffice, não no .env (as chaves do
// .env são placeholders em branco de propósito). Ler só o .env dava «sem
// SMTP configurado» em sites que o têm a funcionar há meses — daí o
// platform_smtp_config(), que é o resolvedor canónico da frota.
$cfg = platform_smtp_config($pdo);
$host = trim((string) $cfg['host']);
if ($host === '') return ['ok' => true, 'detail' => 'sem SMTP configurado'];
$port = (int) ($cfg['port'] ?: 587);
$e1 = 0; $e2 = '';
$sock = @fsockopen($host, $port, $e1, $e2, 5);
if (!$sock) return ['ok' => false, 'detail' => "$host:$port — $e2"];
$greeting = trim((string) fgets($sock, 512));
fclose($sock);
return ['ok' => str_starts_with($greeting, '220'), 'detail' => "$host:$port — $greeting"];
});
// Fila de envios. Só as plataformas que a têm reportam: o contrato diz que um
// check ausente aparece como «não reportado», que é a verdade, em vez de um
// `ok` que não mediu nada.
$queue = !($pdo instanceof PDO) ? [] : $health_check(function () use ($pdo) {
if (!$pdo->query("SHOW TABLES LIKE 'message_queue'")->fetch()) return [];
$rows = $pdo->query("SELECT status, COUNT(*) c FROM message_queue GROUP BY status")->fetchAll(PDO::FETCH_KEY_PAIR);
$failed = (int) ($rows['failed'] ?? 0);
$pending = (int) ($rows['pending'] ?? 0);
return ['ok' => $failed === 0, 'detail' => $pending . ' por enviar, ' . $failed . ' falhadas',
'pending' => $pending, 'failed' => $failed];
});
if ($queue) $checks['queue'] = $queue;
// Migrações por aplicar — o sinal de que um `git pull` ficou a meio e a app
// está a correr contra um esquema antigo.
if ($pdo instanceof PDO) $checks['migrations'] = $health_check(function () use ($pdo) {
hub_load_migration_helpers();
// Não medir não é estar bem: sem runner isto diz-se por medir, e o hub
// mostra-o como aviso em vez de um «tudo ok» que esconde migrações à espera.
if (!function_exists('migrations_status')) {
return ['ok' => false, 'detail' => 'runner indisponível — por medir', 'measured' => false];
}
$pending = 0;
foreach (migrations_status($pdo) as $m) if (empty($m['applied_at'])) $pending++;
return ['ok' => $pending === 0, 'detail' => $pending . ' por aplicar', 'pending' => $pending];
});
// Espaço em disco: uma cópia de segurança que não cabe é uma cópia que não há.
$checks['disk'] = $health_check(function () use ($root) {
$free = @disk_free_space($root);
$total = @disk_total_space($root);
if ($free === false) return ['ok' => true, 'detail' => 'indisponível'];
$used = ($total && $total > 0) ? (int) round(100 * ($total - $free) / $total) : null;
return ['ok' => $free > 512 * 1024 * 1024,
'detail' => round($free / 1073741824, 2) . ' GB livres',
'used_percent' => $used, 'free_bytes' => (int) $free];
});
// Git e cópias entram como checks — não como chaves de topo. Ambos devolvem []
// quando não se aplicam (num cPanel sem repositório, por exemplo), e o contrato
// diz que um check ausente é «não reportado», não uma falha.
$git = $health_check(fn() => hub_git_check($root));
if ($git) $checks['git'] = $git;
// Numa montra estática não se pergunta por cópias: não há base de dados a
// despejar e o código está todo no git, que o check acima já reporta. Sem esta
// guarda o backup_list() -- que existe sempre, porque vem do platform-tools --
// respondia «nenhuma cópia encontrada» e punha o cartão a amarelo para sempre,
// por uma cópia que não faria sentido nenhum existir.
$backup = $hub_has_db_layer ? $health_check(fn() => hub_backup_check()) : [];
if ($backup) $checks['backup'] = $backup;
// Envio de eventos de segurança ao hub. [] numa plataforma por emparelhar ou
// sem base de dados — não reportado, como os outros.
$security = !($pdo instanceof PDO) ? [] : $health_check(function () use ($pdo) {
require_once __DIR__ . '/../functions_security_events.php';
return hub_security_check($pdo);
});
if ($security) $checks['security'] = $security;
// A base de dados tem a forma que o código partilhado espera? Reporta as avarias
// silenciosas (uma coluna em falta que desliga o throttle sem erro nenhum) —
// ver functions_connector.php. Só leitura, uma consulta ao information_schema.
if ($pdo instanceof PDO) $checks['conformance'] = $health_check(function () use ($pdo) {
require_once __DIR__ . '/../functions_connector.php';
return platform_conformance_check($pdo);
});
hub_json([
// Só a base de dados: ver a nota no topo sobre o que significa `ok`. Numa
// plataforma estática não há base de dados que decida nada -- o que torna a
// plataforma inutilizável é não responder, e nesse caso não se chega aqui.
'ok' => $hub_has_db_layer ? !empty($checks['database']['ok']) : true,
'app' => hub_app_name(),
'version' => hub_version($root, $git),
'env' => hub_env('APP_ENV', 'prod'),
'php_version' => PHP_VERSION,
'generated_at' => date('c'),
'checks' => $checks,
// Extra fora do contrato — o hub guarda tudo em payload_json. Diz onde
// ficam as ações e quais são, para não ter de manter uma lista por
// plataforma. Repara que exigem OUTRA chave.
//
// Com as operações desligadas não se anuncia endpoint nem lista de ações.
// O apex nem sequer tem o stub do hub_ops.php — anunciar seis ações e um
// caminho que devolve 404 é pior do que não anunciar nada: quem lê a
// resposta fica a achar que a plataforma sabe fazer coisas que não sabe.
'ops' => hub_ops_key() === '' ? [
'endpoint' => null,
'actions' => [],
'enabled' => false,
'auth' => 'X-Hub-Key: <chave de operações>',
] : [
'endpoint' => hub_base_url('api/hub_ops.php'),
'actions' => hub_ops_actions(),
'enabled' => true,
'auth' => 'X-Hub-Key: <chave de operações>',
],
// As páginas de sistema deste site, para o hub ligar para a página local
// (o recurso quando o hub não chega). Só com router: ver functions_routes.php.
'tools' => function_exists('platform_routes_announced') ? platform_routes_announced() : [],
// E os endpoints pelo router da API — o caminho para endpoints novos.
'api' => function_exists('platform_api_routes_announced') ? platform_api_routes_announced() : [],
]);
-226
View File
@@ -1,226 +0,0 @@
<?php
// Ações remotas do hub sobre esta plataforma: cópias de segurança, atualização
// e migrações. Chave DIFERENTE da do api/health.php, e POST — o que muda alguma
// coisa não entra por GET, para não ser disparado por um link ou um prefetch.
//
// POST JSON: {"action": "...", ...}
//
// **A atualização e as cópias são independentes de propósito.** Num alojamento
// cPanel o `git pull` falha ou nem existe (sem shell, sem git, sem permissões
// de escrita no tree) — e isso não pode impedir o hub de mandar fazer uma cópia
// da base de dados, que é a operação que realmente protege o cliente. Por isso
// `git.pull` devolve `ok:false` com a explicação em vez de rebentar, e as ações
// de cópia não passam por git nenhum.
//
// O stub de cada site (public/api/hub_ops.php) é uma linha a incluir isto.
declare(strict_types=1);
require_once __DIR__ . '/../hub_bootstrap.php';
hub_ops_verify_or_die();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
hub_json(['ok' => false, 'error' => 'usa POST'], 405);
}
$raw = (string) file_get_contents('php://input');
$req = json_decode($raw, true);
$action = is_array($req) ? (string) ($req['action'] ?? '') : (string) ($_POST['action'] ?? '');
$root = $repo_root;
// Numa montra estática não há esquema a migrar nem base de dados a despejar
// (ver a nota do hub_bootstrap.php). Recusa-se aqui, com a razão escrita, em
// vez de deixar as ações chamarem métodos sobre um $pdo a null — um erro de PHP
// apareceria no hub como plataforma avariada, que é uma coisa diferente.
if (!$pdo instanceof PDO
&& in_array($action, ['migrations.list', 'migrations.apply_pending', 'update.run', 'update.status',
'ip_bans.list', 'ip_bans.ban', 'ip_bans.unban', 'tracking.summary'], true)) {
hub_json(['ok' => false, 'error' => 'plataforma sem base de dados — não há migrações a aplicar'], 400);
}
// Auditoria: o que o hub mandou fazer aqui fica escrito, com o hub como autor.
// Só as ações que mudam alguma coisa; as leituras (list, status) não.
if ($pdo instanceof PDO && function_exists('platform_audit')
&& in_array($action, ['backup.trigger', 'update.run', 'git.pull', 'migrations.apply_pending', 'ip_bans.ban', 'ip_bans.unban'], true)) {
$params = is_array($req) ? $req : $_POST; unset($params['action']);
platform_audit($pdo, 'hub.' . $action, ['actor' => ['id' => null, 'name' => 'hub', 'role' => 'hub'],
'target_type' => 'platform', 'changes' => $params ?: null]);
}
switch ($action) {
// ── Cópias de segurança ──────────────────────────────────────────────────
case 'backup.list':
hub_load_backup_helpers();
hub_json(['ok' => true, 'backups' => function_exists('backup_list') ? backup_list() : [],
'summary' => hub_backup_summary()]);
case 'backup.trigger':
$type = is_array($req) ? (string) ($req['type'] ?? 'db') : 'db';
if (!in_array($type, ['db', 'files'], true)) {
hub_json(['ok' => false, 'error' => 'tipo inválido (db|files)'], 400);
}
hub_load_backup_helpers();
if (!function_exists('backup_trigger')) {
hub_json(['ok' => false, 'error' => 'backups não disponíveis nesta plataforma'], 503);
}
$ok = backup_trigger($type);
// O backup_trigger() é «dispara e esquece»: o ficheiro pode ainda estar
// a ser escrito quando isto responde. O resumo devolvido é o de ANTES,
// e é o hub que volta a perguntar — dizer o contrário seria mentir
// sobre uma cópia que ainda não existe.
hub_json([
'ok' => $ok,
'type' => $type,
'started' => $ok,
'note' => $ok ? 'cópia iniciada; volta a consultar daqui a instantes' : 'não foi possível iniciar a cópia',
'summary' => hub_backup_summary(),
], $ok ? 200 : 500);
// ── Atualização ──────────────────────────────────────────────────────────
case 'git.status':
case 'git.pull':
require_once __DIR__ . '/../functions_git.php';
if (!function_exists('git_run') || !is_dir($root . '/.git')) {
hub_json(['ok' => false, 'error' => 'esta instalação não é um repositório git (típico em cPanel) — as cópias continuam a funcionar'], 503);
}
if ($action === 'git.status') {
$branch = git_run(['rev-parse', '--abbrev-ref', 'HEAD'], $root);
$head = git_run(['log', '-1', '--format=%h|%cd|%s', '--date=short'], $root);
$dirty = git_run(['status', '--porcelain'], $root);
hub_json(['ok' => true,
'branch' => $branch['ok'] ? trim($branch['output']) : null,
'head' => $head['ok'] ? trim($head['output']) : null,
'dirty' => $dirty['ok'] ? (trim($dirty['output']) !== '') : null,
]);
}
$pull = git_run(['pull', '--ff-only'], $root);
$output = $pull['output'] !== '' ? $pull['output'] : '(sem saída)';
if ($pull['ok']) {
// O ff-only recusa-se a fundir; um repositório divergente falha aqui
// em vez de inventar um merge que ninguém pediu. Os submódulos vão
// pelo git_sync_submodules(), que respeita os que seguem o branch.
$sub = git_sync_submodules($root);
$output .= "\n\n[submodules]\n" . implode("\n", $sub['log']);
if (function_exists('opcache_reset')) @opcache_reset();
if (function_exists('settings_cache_clear')) settings_cache_clear();
if (function_exists('platform_settings_cache_clear')) platform_settings_cache_clear();
$output .= "\n\n(OPcache + cache de definições invalidados)";
}
hub_json(['ok' => $pull['ok'], 'output' => $output]);
// ── Atualização completa ─────────────────────────────────────────────────
// O mesmo caminho do webhook de deploy (auto_update_run): cópia antes de
// atualizar se estiver ligada, pull que pára em trabalho local, migrações
// pendentes e caches. É o que o botão do hub deve fazer — o git.pull acima
// fica para quem quer só o código.
//
// Responde já e trabalha depois de fechar a ligação: a cópia prévia pode
// demorar minutos e o hub só espera MONITOR_OPS_TIMEOUT_SECONDS. O
// resultado lê-se a seguir com update.status.
case 'update.run':
require_once __DIR__ . '/../functions_auto_update.php';
hub_load_backup_helpers();
if (!function_exists('git_run') || !is_dir($root . '/.git')) {
hub_json(['ok' => false, 'error' => 'esta instalação não é um repositório git (típico em cPanel) — as cópias continuam a funcionar'], 503);
}
@session_write_close();
platform_respond_and_continue(202, [
'ok' => true,
'status' => 'accepted',
'note' => 'Atualização iniciada. O resultado fica em update.status daqui a instantes.',
]);
auto_update_run($pdo, $root, null);
exit;
case 'update.status':
require_once __DIR__ . '/../functions_auto_update.php';
hub_json(['ok' => true, 'last' => auto_update_last($pdo)]);
// ── Bloqueio de IP ───────────────────────────────────────────────────────
// A mesma lista e as mesmas acções da página ip_bans.php deste site, para o
// hub mostrar numa aba. Um banimento feito daqui fica com source='hub' e,
// como qualquer outro, segue para a frota.
case 'ip_bans.list':
require_once __DIR__ . '/../functions_ip_ban.php';
hub_json(['ok' => true, 'bans' => platform_ip_ban_list($pdo, 200),
'offenders' => platform_ip_offenders($pdo, 7, 30)]);
case 'ip_bans.ban':
case 'ip_bans.unban':
require_once __DIR__ . '/../functions_ip_ban.php';
$ip = trim((string) (is_array($req) ? ($req['ip'] ?? '') : ''));
if (!filter_var($ip, FILTER_VALIDATE_IP)) {
hub_json(['ok' => false, 'error' => 'endereço IP inválido'], 400);
}
if ($action === 'ip_bans.unban') {
hub_json(['ok' => platform_ip_unban($pdo, $ip), 'ip' => $ip]);
}
if (platform_ip_ban_exempt($ip)) {
hub_json(['ok' => false, 'error' => 'endereços internos não podem ser banidos'], 400);
}
// `minutes: null` é permanente; ausente é um dia.
$minutes = array_key_exists('minutes', $req) && $req['minutes'] === null
? null : max(1, (int) ($req['minutes'] ?? 1440));
$reason = trim((string) ($req['reason'] ?? '')) ?: 'banido a partir do hub';
$ok = platform_ip_ban($pdo, $ip, $reason, $minutes, 'hub', null);
hub_json(['ok' => $ok, 'ip' => $ip, 'minutes' => $minutes], $ok ? 200 : 500);
// ── Origem das visitas ───────────────────────────────────────────────────
// Só agregados — origens, formulários, países, dispositivos. Nenhuma visita
// individual sai daqui, nem o hash do IP.
case 'tracking.summary':
require_once __DIR__ . '/../functions_tracking.php';
$days = (int) (is_array($req) ? ($req['days'] ?? 30) : 30);
if (!in_array($days, [7, 30, 90, 365], true)) $days = 30;
platform_tracking_ensure_tables($pdo);
hub_json([
'ok' => true,
'days' => $days,
'sources' => array_slice(platform_tracking_report($pdo, $days), 0, 25),
'forms' => platform_tracking_conversions_by_form($pdo, $days),
'countries' => platform_tracking_breakdown($pdo, 'country', $days, 10),
'devices' => platform_tracking_breakdown($pdo, 'device_type', $days, 6),
]);
// ── Migrações ────────────────────────────────────────────────────────────
// Um pull sem as migrações aplicadas deixa a app a correr contra um esquema
// antigo, portanto o hub tem de conseguir fazer as duas coisas.
//
// Numa plataforma estática não há esquema nenhum a migrar (ver a nota do
// hub_bootstrap.php). Diz-se assim, em vez de rebentar com um erro de PHP a
// chamar métodos sobre null — que o hub mostraria como app avariada.
case 'migrations.list':
hub_load_migration_helpers();
if (!function_exists('migrations_status')) hub_json(['ok' => false, 'error' => 'runner indisponível'], 503);
hub_json(['ok' => true, 'migrations' => migrations_status($pdo)]);
case 'migrations.apply_pending':
hub_load_migration_helpers();
if (!function_exists('migrations_status') || !function_exists('migrations_apply_file')) {
hub_json(['ok' => false, 'error' => 'runner indisponível'], 503);
}
$applied = [];
$error = null;
foreach (migrations_status($pdo) as $row) {
if (!empty($row['applied_at'])) continue;
$r = migrations_apply_file($pdo, $row['filename'], null);
if (!empty($r['ok'])) {
$applied[] = $row['filename'];
} else {
// Pára à primeira falha: a seguinte pode depender desta, e
// continuar a aplicar às cegas é como um esquema fica a meio.
$error = $row['filename'] . ': ' . ($r['error'] ?? 'erro');
break;
}
}
if ($applied && function_exists('settings_cache_clear')) settings_cache_clear();
hub_json(['ok' => $error === null, 'applied' => $applied, 'error' => $error]);
default:
hub_json(['ok' => false, 'error' => 'ação desconhecida: ' . $action,
'actions' => hub_ops_actions()], 400);
}
-47
View File
@@ -1,47 +0,0 @@
<?php
// Emparelhamento: o hub chama aqui quando o André aprova esta plataforma na
// Frota. É a ÚNICA etapa em que as chaves saem daqui, e saem numa ligação que o
// hub iniciou — não numa que esta plataforma abriu para um URL que lhe deram.
//
// Autenticação: o código de emparelhamento que esta plataforma gerou e enviou
// no anúncio. Uso único, com validade. Não há chave de API nesta etapa, porque
// é precisamente ela que está a ser entregue.
//
// O stub de cada site (public/api/hub_pair.php) é uma linha a incluir isto.
declare(strict_types=1);
require_once __DIR__ . '/../hub_bootstrap.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
hub_json(['ok' => false, 'error' => 'usa POST'], 405);
}
$req = json_decode((string) file_get_contents('php://input'), true);
$code = is_array($req) ? (string) ($req['pairing_code'] ?? '') : '';
$ops = is_array($req) && !empty($req['with_ops']);
if ($code === '') hub_json(['ok' => false, 'error' => 'código em falta'], 400);
// O emparelhamento automático guarda o estado e as chaves no system_settings,
// portanto precisa mesmo de base de dados. Uma montra estática (ver a nota do
// hub_bootstrap.php) inscreve-se à mão: chaves no .env do stack, linha na
// tabela `apps` do hub. Diz-se isso, em vez de rebentar sobre um $pdo a null.
if (!$pdo instanceof PDO) {
hub_json(['ok' => false,
'error' => 'plataforma sem base de dados — inscreve-se à mão, com as chaves no .env'], 400);
}
$res = hub_pair_complete($pdo, $code, $ops);
if (empty($res['ok'])) {
// 403 e não 404: o pedido chegou ao sítio certo, o que falha é a permissão.
hub_json(['ok' => false, 'error' => $res['error']], 403);
}
hub_json([
'ok' => true,
'app' => hub_app_name(),
'api_key' => $res['api_key'],
'ops_key' => $res['ops_key'], // null quando o hub não pediu operações
'health' => hub_base_url('api/health.php'),
'ops' => hub_base_url('api/hub_ops.php'),
]);
-36
View File
@@ -1,36 +0,0 @@
<?php
// ─── Front controller dos endpoints partilhados ─────────────────────────────
//
// Um só stub por site para toda a API partilhada:
//
// <?php require __DIR__ . '/../../shared/platform-tools/api/router.php'; // public/api/platform.php
//
// e cada endpoint responde em api/platform.php?endpoint=<nome> (ou
// api/platform?endpoint=… com 'clean_urls'). A lista é fechada
// (platform_api_routes()), e cada endpoint continua a autenticar-se a si
// próprio: este ficheiro só carrega o arranque do site e despacha.
//
// Um site com implementação própria de um endpoint declara-a no
// platform_connect(): 'api_files' => ['backup_trigger' => <caminho absoluto>]
// (a silver tem guardas com outros nomes). O 'migrations_dir' do site chega ao
// agente, como chega às páginas.
declare(strict_types=1);
require_once __DIR__ . '/../hub_bootstrap.php'; // $repo_root, $pdo, o arranque do site
require_once __DIR__ . '/../functions_routes.php';
$endpoint = (string) ($_GET['endpoint'] ?? '');
$file = platform_api_routes()[$endpoint] ?? null;
if ($file === null) {
hub_json(['ok' => false, 'error' => 'endpoint desconhecido'], 404);
}
$cfg = function_exists('platform_config') ? platform_config() : [];
if (!empty($cfg['migrations_dir'])) {
$GLOBALS['MIGRATIONS_DIR'] = (string) $cfg['migrations_dir'];
}
$target = (string) ($cfg['api_files'][$endpoint] ?? (dirname(__DIR__) . '/' . $file));
unset($cfg, $file);
require $target;
-154
View File
@@ -1,154 +0,0 @@
<?php
// ─── Registo de auditoria (página partilhada) ───────────────────────────────
//
// /platform?tool=audit. Lê o `audit_log` do site pela forma canónica
// (platform_audit_query(), functions_audit.php), seja qual for a forma da
// tabela, e mostra quem fez o quê, a quê e quando, com o antes e o depois de
// cada alteração. Só leitura. Exporta o filtro em CSV.
if (!isset($pdo) || !$pdo instanceof PDO) {
http_response_code(500);
exit('audit.php: sem PDO — o arranque do site (config/db.php) tem de abrir a ligação primeiro.');
}
require_once __DIR__ . '/functions_audit.php';
$filters = [
'actor' => trim((string) ($_GET['actor'] ?? '')),
'action' => trim((string) ($_GET['action'] ?? '')),
'target_type' => trim((string) ($_GET['target_type'] ?? '')),
'target_id' => trim((string) ($_GET['target_id'] ?? '')),
'q' => trim((string) ($_GET['q'] ?? '')),
'from' => preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) ($_GET['from'] ?? '')) ? (string) $_GET['from'] : '',
'to' => preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) ($_GET['to'] ?? '')) ? (string) $_GET['to'] : '',
];
$perPage = 50;
$page = max(1, (int) ($_GET['p'] ?? 1));
// Exportação: o filtro inteiro (até 5000 linhas), em CSV que o Excel abre em pt.
if (($_GET['export'] ?? '') === 'csv') {
$rows = platform_audit_query($pdo, $filters, 5000);
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="auditoria-' . date('Y-m-d') . '.csv"');
$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF");
fputcsv($out, ['Quando', 'Quem', 'Papel', 'Ação', 'Tipo', 'Id', 'Alvo', 'Resumo', 'Alterações', 'IP'], ';');
foreach ($rows as $r) {
fputcsv($out, [$r['created_at'], $r['actor_name'], $r['actor_role'], $r['action'], $r['target_type'],
$r['target_id'], $r['target_label'], $r['summary'], $r['changes'], $r['ip']], ';');
}
fclose($out);
exit;
}
$rows = platform_audit_query($pdo, $filters, $perPage + 1, ($page - 1) * $perPage);
$hasNext = count($rows) > $perPage;
$rows = array_slice($rows, 0, $perPage);
$actions = platform_audit_distinct($pdo, 'action');
$types = platform_audit_distinct($pdo, 'target_type');
$selfUrl = strtok((string) ($_SERVER['REQUEST_URI'] ?? ''), '?') ?: '';
$link = function (array $q) use ($selfUrl, $filters): string {
$base = isset($_GET['tool']) ? ['tool' => (string) $_GET['tool']] : [];
return $selfUrl . '?' . http_build_query($base + array_filter($q + $filters, fn($v) => $v !== '' && $v !== null));
};
/** Uma alteração em HTML: tabela campo / antes / depois, ou o texto tal como veio. */
$renderChanges = function (?string $json): string {
if ($json === null || $json === '') return '';
$data = json_decode($json, true);
if (!is_array($data)) return '<pre class="small mb-0">' . htmlspecialchars($json) . '</pre>';
$fmt = fn($v) => $v === null ? '<span class="text-muted">—</span>'
: htmlspecialchars(is_scalar($v) ? (string) $v : json_encode($v, JSON_UNESCAPED_UNICODE));
$isDiff = $data && array_reduce($data, fn($ok, $v) => $ok && is_array($v) && (array_key_exists('from', $v) || array_key_exists('to', $v)), true);
if (!$isDiff) return '<pre class="small mb-0" style="white-space:pre-wrap">' . htmlspecialchars(json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)) . '</pre>';
$h = '<table class="table table-sm mb-0 small"><thead><tr><th>Campo</th><th>Antes</th><th>Depois</th></tr></thead><tbody>';
foreach ($data as $k => $v) {
$h .= '<tr><td>' . htmlspecialchars((string) $k) . '</td><td class="text-danger">' . $fmt($v['from'] ?? null) . '</td><td class="text-success">' . $fmt($v['to'] ?? null) . '</td></tr>';
}
return $h . '</tbody></table>';
};
$layout_header = $platform_layout_header ?? ($repo_root . '/includes/header.php');
$layout_navbar = $platform_layout_navbar ?? ($repo_root . '/includes/navbar.php');
$layout_footer = $platform_layout_footer ?? ($repo_root . '/includes/footer.php');
$dashboard_url = $platform_dashboard_url ?? (function_exists('platform_admin_url') ? platform_admin_url('index.php') : 'index.php');
$pageTitle = 'Registo de auditoria';
if (is_file($layout_header)) include $layout_header;
if (is_file($layout_navbar)) include $layout_navbar;
?>
<div class="container py-4" style="max-width:1200px;">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
<div>
<h2 class="mb-1"><i class="bi bi-journal-text me-2"></i>Registo de auditoria</h2>
<p class="text-muted small mb-0">Quem fez o quê, a quê e quando. Os segredos nunca entram no registo.</p>
</div>
<div class="d-flex gap-2">
<a class="btn btn-sm btn-outline-secondary" href="<?= htmlspecialchars($link(['export' => 'csv'])) ?>"><i class="bi bi-download me-1"></i>Exportar CSV</a>
<a class="btn btn-sm btn-outline-secondary" href="<?= htmlspecialchars($dashboard_url) ?>">Voltar</a>
</div>
</div>
<form method="get" class="card card-body mb-3">
<?php if (isset($_GET['tool'])): ?><input type="hidden" name="tool" value="<?= htmlspecialchars((string) $_GET['tool']) ?>"><?php endif; ?>
<div class="row g-2 align-items-end">
<div class="col-md-2"><label class="form-label small mb-1">Quem</label>
<input class="form-control form-control-sm" name="actor" value="<?= htmlspecialchars($filters['actor']) ?>" placeholder="nome"></div>
<div class="col-md-2"><label class="form-label small mb-1">Ação</label>
<input class="form-control form-control-sm" name="action" list="audit-actions" value="<?= htmlspecialchars($filters['action']) ?>" placeholder="ex.: user.">
<datalist id="audit-actions"><?php foreach ($actions as $a): ?><option value="<?= htmlspecialchars((string) $a) ?>"><?php endforeach; ?></datalist></div>
<div class="col-md-2"><label class="form-label small mb-1">Tipo</label>
<select class="form-select form-select-sm" name="target_type"><option value="">Todos</option>
<?php foreach ($types as $t): ?><option value="<?= htmlspecialchars((string) $t) ?>" <?= $filters['target_type'] === (string) $t ? 'selected' : '' ?>><?= htmlspecialchars((string) $t) ?></option><?php endforeach; ?>
</select></div>
<div class="col-md-2"><label class="form-label small mb-1">Texto</label>
<input class="form-control form-control-sm" name="q" value="<?= htmlspecialchars($filters['q']) ?>" placeholder="alvo, resumo…"></div>
<div class="col-6 col-md-1"><label class="form-label small mb-1">De</label>
<input type="date" class="form-control form-control-sm" name="from" value="<?= htmlspecialchars($filters['from']) ?>"></div>
<div class="col-6 col-md-1"><label class="form-label small mb-1">Até</label>
<input type="date" class="form-control form-control-sm" name="to" value="<?= htmlspecialchars($filters['to']) ?>"></div>
<div class="col-md-2 d-flex gap-2">
<button class="btn btn-sm btn-primary flex-grow-1">Filtrar</button>
<a class="btn btn-sm btn-outline-secondary" href="<?= htmlspecialchars($link(['actor' => '', 'action' => '', 'target_type' => '', 'target_id' => '', 'q' => '', 'from' => '', 'to' => ''])) ?>">Limpar</a>
</div>
</div>
</form>
<div class="card">
<div class="table-responsive">
<table class="table table-sm table-hover align-middle mb-0">
<thead><tr><th>Quando</th><th>Quem</th><th>Ação</th><th>Alvo</th><th>Resumo</th><th>IP</th></tr></thead>
<tbody>
<?php if (!$rows): ?><tr><td colspan="6" class="text-muted p-4 text-center">Nada no registo com este filtro.</td></tr><?php endif; ?>
<?php foreach ($rows as $r): $ch = $renderChanges($r['changes']); ?>
<tr>
<td class="small text-nowrap"><?= htmlspecialchars(date('d/m/Y H:i:s', strtotime((string) $r['created_at']))) ?></td>
<td class="small"><?= $r['actor_name'] !== null && $r['actor_name'] !== '' ? htmlspecialchars((string) $r['actor_name']) : '<span class="text-muted">sistema</span>' ?>
<?php if (!empty($r['actor_role'])): ?><div class="text-muted"><?= htmlspecialchars((string) $r['actor_role']) ?></div><?php endif; ?></td>
<td class="small"><a href="<?= htmlspecialchars($link(['action' => (string) $r['action'], 'p' => null])) ?>"><code><?= htmlspecialchars((string) $r['action']) ?></code></a></td>
<td class="small">
<?php if (!empty($r['target_type'])): ?><a class="text-muted" href="<?= htmlspecialchars($link(['target_type' => (string) $r['target_type'], 'target_id' => (string) ($r['target_id'] ?? ''), 'p' => null])) ?>"><?= htmlspecialchars((string) $r['target_type']) ?><?= $r['target_id'] !== null && $r['target_id'] !== '' ? ' #' . htmlspecialchars((string) $r['target_id']) : '' ?></a><?php endif; ?>
<?php if (!empty($r['target_label'])): ?><div><?= htmlspecialchars((string) $r['target_label']) ?></div><?php endif; ?>
</td>
<td class="small">
<?= htmlspecialchars((string) ($r['summary'] ?? '')) ?>
<?php if ($ch !== ''): ?>
<details class="mt-1"><summary class="text-muted" style="cursor:pointer">alterações</summary><?= $ch ?></details>
<?php endif; ?>
</td>
<td class="small text-muted"><?= htmlspecialchars((string) ($r['ip'] ?? '')) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php if ($page > 1 || $hasNext): ?>
<div class="card-footer d-flex justify-content-between">
<?php if ($page > 1): ?><a class="btn btn-sm btn-outline-secondary" href="<?= htmlspecialchars($link(['p' => $page - 1])) ?>">&larr; Mais recentes</a><?php else: ?><span></span><?php endif; ?>
<span class="small text-muted align-self-center">página <?= $page ?></span>
<?php if ($hasNext): ?><a class="btn btn-sm btn-outline-secondary" href="<?= htmlspecialchars($link(['p' => $page + 1])) ?>">Mais antigas &rarr;</a><?php else: ?><span></span><?php endif; ?>
</div>
<?php endif; ?>
</div>
</div>
<?php if (is_file($layout_footer)) include $layout_footer; ?>
-438
View File
@@ -1,438 +0,0 @@
<?php
// ─── Shared backups view ─────────────────────────────────────────────────────
//
// Bootstrap contract — the stub at <host>/views/sysadmin/backups.php must:
// 1. Load $pdo (via $repo_root/config/db.php + functions.php)
// 2. Run the sysadmin auth guard
// 3. Set $repo_root
// Optional override variables (same as platform_update.php):
// $platform_layout_header / $platform_layout_navbar / $platform_layout_footer
// $platform_dashboard_url
require_once __DIR__ . '/functions_backup.php';
require_once __DIR__ . '/functions_csrf.php';
// Load BACKUP_KEEP_DAYS (and BACKUP_TOKEN) from the worker config if not yet defined.
if (!defined('BACKUP_KEEP_DAYS')) {
require_once __DIR__ . '/backups/config.php';
}
if (!isset($repo_root) || !is_string($repo_root)) {
$repo_root = realpath(dirname(__DIR__, 2)) ?: dirname(__DIR__, 2);
}
$repo_root = rtrim($repo_root, '/');
// ── Apagar backup (POST à própria página, PRG) ──────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delete_backup') {
if (!platform_csrf_ok()) {
$_SESSION['backup_flash'] = [
'type' => 'danger',
'message' => 'Pedido rejeitado (token CSRF ausente ou inválido).',
];
} else {
$fn = (string) ($_POST['filename'] ?? '');
$dir = backup_dir();
$real = realpath($dir . DIRECTORY_SEPARATOR . $fn);
if (!backup_filename_is_safe($fn) || $real === false
|| !str_starts_with($real, $dir . DIRECTORY_SEPARATOR)) {
$_SESSION['backup_flash'] = ['type' => 'danger', 'message' => 'Ficheiro inválido.'];
} elseif (@unlink($real)) {
if (function_exists('platform_audit')) platform_audit($pdo ?? null, 'backup.delete', ['target_type' => 'backup', 'target_label' => $fn]);
$_SESSION['backup_flash'] = ['type' => 'success', 'message' => "Backup {$fn} apagado."];
} else {
$_SESSION['backup_flash'] = ['type' => 'danger', 'message' => "Não foi possível apagar {$fn} (permissões?)."];
}
}
header('Location: ' . $_SERVER['REQUEST_URI'], true, 303);
exit;
}
// ── Restaurar backup (POST à própria página, PRG) ───────────────────────────
// Destrutivo: substitui o estado actual. Exige CSRF + confirmação escrita, e
// restore_db()/restore_files() tiram sempre um backup de segurança antes de
// escrever, cujo nome é devolvido na flash para servir de "desfazer".
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'restore_backup') {
require_once __DIR__ . '/functions_restore.php';
if (!platform_csrf_ok()) {
$_SESSION['backup_flash'] = [
'type' => 'danger',
'message' => 'Pedido rejeitado (token CSRF ausente ou inválido).',
];
} elseif (trim((string) ($_POST['confirm'] ?? '')) !== 'RESTAURAR') {
// Guarda contra o clique por reflexo: um confirm() do browser é
// demasiado fácil de despachar sem ler.
$_SESSION['backup_flash'] = [
'type' => 'danger',
'message' => 'Confirmação incorrecta — restauro cancelado. Nada foi alterado.',
];
} else {
$fn = (string) ($_POST['filename'] ?? '');
$purge = !empty($_POST['purge']);
@set_time_limit(0);
ignore_user_abort(true);
$info = restore_inspect($fn);
if (!$info['ok']) {
$_SESSION['backup_flash'] = [
'type' => 'danger',
'message' => 'Restauro recusado: ' . (string) $info['error'],
];
} elseif ($info['type'] === 'db' && !isset($pdo)) {
$_SESSION['backup_flash'] = [
'type' => 'danger',
'message' => 'Restauro da base de dados indisponível: o arranque do site (config/db.php) não abriu a ligação à base de dados.',
];
} else {
$r = ($info['type'] === 'db')
? restore_db($pdo, $fn)
: restore_files($fn, ['purge' => $purge]);
if (!empty($r['ok'])) {
if ($info['type'] === 'db') {
$detail = sprintf(
'%d statements aplicados. Tabelas: %s → %s.',
(int) $r['statements'],
$r['tables_before'] ?? '?',
$r['tables_after'] ?? '?'
);
if (!empty($r['dropped_extra'])) {
$detail .= ' Tabelas removidas por não existirem no backup: '
. implode(', ', $r['dropped_extra']) . '.';
}
} else {
$detail = sprintf(
'%d ficheiro(s) restaurado(s)%s.',
(int) $r['extracted'],
!empty($r['purged']) ? ', ' . (int) $r['purged'] . ' apagado(s)' : ''
);
}
// A flash é renderizada com htmlspecialchars(), por isso é texto
// simples: markup aqui apareceria literalmente ao utilizador, e
// escapar aqui daria escape duplo.
$undo = !empty($r['safety_backup'])
? ' Estado anterior guardado em ' . (string) $r['safety_backup']
. ' — restaura esse ficheiro para desfazer.'
: '';
$_SESSION['backup_flash'] = [
'type' => 'success',
'message' => 'Restaurado de ' . $fn . '. ' . $detail . $undo,
];
} else {
$safe = !empty($r['safety_backup'])
? ' O backup de segurança ' . (string) $r['safety_backup']
. ' foi criado antes da falha — usa-o para voltar atrás.'
: '';
$_SESSION['backup_flash'] = [
'type' => 'danger',
'message' => 'Restauro falhou: ' . (string) ($r['error'] ?? 'erro desconhecido') . $safe,
];
}
}
}
header('Location: ' . $_SERVER['REQUEST_URI'], true, 303);
exit;
}
$flash = $_SESSION['backup_flash'] ?? null;
unset($_SESSION['backup_flash']);
$backups = backup_list();
// ── Auto-refresh após "backup iniciado" ─────────────────────────────────────
// O trigger devolve o nº de backups antes do disparo; enquanto a contagem não
// subir, a página recarrega sozinha (limite de tentativas para não ficar em
// loop se o worker falhar).
$await_count = null;
$await_tries = 0;
if (is_array($flash) && isset($flash['await_count'])) {
$await_count = (int) $flash['await_count'];
} elseif (isset($_GET['pt_await'])) {
$await_count = (int) $_GET['pt_await'];
$await_tries = max(0, (int) ($_GET['pt_tries'] ?? 0));
}
$awaiting = $await_count !== null && count($backups) <= $await_count && $await_tries < 20;
$layout_header = $platform_layout_header ?? ($repo_root . '/includes/header.php');
$layout_navbar = $platform_layout_navbar ?? ($repo_root . '/includes/navbar.php');
$layout_footer = $platform_layout_footer ?? ($repo_root . '/includes/footer.php');
$base_url_fn = function_exists('base_url') ? 'base_url' : null;
$dashboard_url = $platform_dashboard_url
?? (function_exists('base_url') ? base_url('views/admin/dashboard.php') : 'dashboard.php');
$pageTitle = 'Backups';
if (is_file($layout_header)) include $layout_header;
if (is_file($layout_navbar)) include $layout_navbar;
?>
<div class="container py-4" style="max-width:1000px;">
<?php
// O estado da encriptação tem de estar à vista. Um backup em claro parece
// igual a um encriptado na listagem, e a diferença só se descobre no dia em
// que o ficheiro sai do servidor.
$bkEnc = function_exists('platform_backup_encryption_available') && platform_backup_encryption_available();
?>
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
<div>
<h2 class="mb-1"><i class="bi bi-archive me-2"></i>Backups</h2>
<p class="text-muted small mb-0">Gerir cópias de segurança dos uploads e da base de dados. O código reconstitui-se a partir do git — só a pasta <code>uploads/</code> tem informação externa.</p>
<p class="small mb-0 mt-1">
<?php if ($bkEnc): ?>
<span class="badge text-bg-success"><i class="bi bi-lock-fill me-1"></i>AES-256</span>
<span class="text-muted">Os dumps novos saem encriptados. A chave deriva da <code>APP_SECRET_KEY</code> —
<strong>perdê-la é perder os backups</strong>; guarde-a fora do servidor.</span>
<?php else: ?>
<span class="badge text-bg-warning text-dark"><i class="bi bi-unlock-fill me-1"></i>Sem encriptação</span>
<span class="text-muted">Sem <code>APP_SECRET_KEY</code> no <code>.env</code>, os dumps saem em claro —
e levam a tabela de utilizadores, os contactos e os contratos assinados.</span>
<?php endif; ?>
</p>
</div>
<div class="d-flex gap-2 flex-wrap align-items-center">
<a href="<?= htmlspecialchars($dashboard_url) ?>" class="btn btn-outline-secondary btn-sm">Voltar</a>
<form method="post" action="<?= htmlspecialchars($base_url_fn ? base_url('api/backup_trigger.php') : 'api/backup_trigger.php') ?>">
<?= platform_csrf_field() ?><input type="hidden" name="type" value="files">
<button type="submit" class="btn btn-outline-secondary">
<i class="bi bi-archive me-1"></i>Backup de uploads
</button>
</form>
<form method="post" action="<?= htmlspecialchars($base_url_fn ? base_url('api/backup_trigger.php') : 'api/backup_trigger.php') ?>">
<?= platform_csrf_field() ?><input type="hidden" name="type" value="db">
<button type="submit" class="btn btn-outline-primary">
<i class="bi bi-database-down me-1"></i>Backup da base de dados
</button>
</form>
</div>
</div>
<?php if (is_array($flash) && !empty($flash['type'])): ?>
<?php $flash_type = (string) $flash['type']; ?>
<div class="alert alert-<?= htmlspecialchars($flash_type) ?> alert-dismissible fade show d-flex align-items-center gap-2" role="alert">
<i class="bi bi-<?= $flash_type === 'success' ? 'check-circle' : 'exclamation-triangle' ?> flex-shrink-0"></i>
<div><?= htmlspecialchars((string) ($flash['message'] ?? '')) ?></div>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
<?php endif; ?>
<?php if ($awaiting): ?>
<div class="alert alert-info d-flex align-items-center gap-2" role="status">
<span class="spinner-border spinner-border-sm flex-shrink-0"></span>
<div>A aguardar o novo backup… a lista atualiza automaticamente
<span class="text-muted">(tentativa <?= $await_tries + 1 ?>/20)</span></div>
</div>
<?php elseif ($await_count !== null && $await_tries >= 20): ?>
<div class="alert alert-warning" role="alert">
O backup ainda não apareceu ao fim de <?= $await_tries ?> verificações — confirma os logs do servidor.
</div>
<?php endif; ?>
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="fw-semibold">Cópias de segurança disponíveis</span>
<span class="badge text-bg-secondary"><?= count($backups) ?></span>
</div>
<?php if (empty($backups)): ?>
<div class="card-body text-center text-muted py-5">
<i class="bi bi-inbox fs-2 d-block mb-2 opacity-50"></i>
Nenhuma cópia de segurança encontrada.
</div>
<?php else: ?>
<div class="table-responsive">
<table class="table table-hover mb-0 small align-middle">
<thead class="table-dark">
<tr>
<th>Ficheiro</th>
<th>Tipo</th>
<th>Tamanho</th>
<th>Data de criação</th>
<th class="text-end">Ações</th>
</tr>
</thead>
<tbody>
<?php foreach ($backups as $b): ?>
<tr>
<td class="font-monospace text-break"><?= htmlspecialchars($b['filename']) ?></td>
<td>
<span class="badge text-bg-<?= backup_type_badge($b['type']) ?>">
<?= backup_type_label($b['type']) ?>
</span>
</td>
<td class="text-nowrap"><?= htmlspecialchars($b['size_human']) ?></td>
<td class="text-muted text-nowrap"><?= date('d/m/Y H:i', $b['created_at']) ?></td>
<td class="text-end">
<a href="<?= htmlspecialchars(($base_url_fn ? base_url('api/backup_download.php') : 'api/backup_download.php')) ?>?file=<?= urlencode($b['filename']) ?>"
class="btn btn-sm btn-outline-secondary"
title="Transferir" aria-label="Transferir <?= htmlspecialchars($b['filename']) ?>">
<i class="bi bi-download"></i>
</a>
<button type="button"
class="btn btn-sm btn-outline-warning js-restore"
data-filename="<?= htmlspecialchars($b['filename']) ?>"
data-type="<?= htmlspecialchars($b['type']) ?>"
data-label="<?= htmlspecialchars(backup_type_label($b['type'])) ?>"
data-created="<?= htmlspecialchars(date('d/m/Y H:i', $b['created_at'])) ?>"
title="Restaurar" aria-label="Restaurar <?= htmlspecialchars($b['filename']) ?>">
<i class="bi bi-arrow-counterclockwise"></i>
</button>
<form method="POST" class="d-inline m-0"
onsubmit="return confirm('Apagar <?= htmlspecialchars(addslashes($b['filename'])) ?>? Esta ação não pode ser desfeita.');">
<?= platform_csrf_field() ?><input type="hidden" name="action" value="delete_backup">
<input type="hidden" name="filename" value="<?= htmlspecialchars($b['filename']) ?>">
<button type="submit" class="btn btn-sm btn-outline-danger"
title="Apagar" aria-label="Apagar <?= htmlspecialchars($b['filename']) ?>">
<i class="bi bi-trash"></i>
</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<div class="card-footer text-muted small">
<i class="bi bi-info-circle me-1"></i>
Retenção automática, por tipo: <?= htmlspecialchars(backup_retention_summary()) ?>.
O resto é apagado no fim de cada backup.
Os ficheiros estão armazenados na pasta <code>backups/</code> no servidor.
</div>
</div>
</div>
<!-- ── Modal de restauro ───────────────────────────────────────────────────
Um único modal para todas as linhas, preenchido a partir dos data-* do
botão clicado. Um confirm() do browser não chega aqui: é preciso mostrar
o que vai ser substituído, oferecer o purge só para ficheiros, e exigir
uma confirmação escrita antes de activar o botão. -->
<div class="modal fade" id="ptRestoreModal" tabindex="-1" aria-labelledby="ptRestoreLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<form method="POST" class="modal-content">
<?= platform_csrf_field() ?>
<input type="hidden" name="action" value="restore_backup">
<input type="hidden" name="filename" id="ptRestoreFilename" value="">
<div class="modal-header">
<h5 class="modal-title" id="ptRestoreLabel">
<i class="bi bi-arrow-counterclockwise me-2"></i>Restaurar backup
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Fechar"></button>
</div>
<div class="modal-body">
<p class="mb-2">Vai restaurar:</p>
<p class="font-monospace small text-break mb-1" id="ptRestoreName"></p>
<p class="text-muted small mb-3">
<span class="badge text-bg-secondary" id="ptRestoreType"></span>
<span id="ptRestoreCreated" class="ms-2"></span>
</p>
<div class="alert alert-warning small mb-3" role="alert">
<strong>Isto substitui o estado actual.</strong>
<span id="ptRestoreScopeDb" class="d-none">
Toda a base de dados é substituída pelo conteúdo do backup. Tabelas que
não existam no backup são removidas.
</span>
<span id="ptRestoreScopeFiles" class="d-none">
A pasta de uploads é reposta a partir do backup.
</span>
<br>
É tirado automaticamente um backup do estado actual antes de escrever —
o nome aparece no fim para poderes desfazer.
</div>
<div class="form-check mb-3 d-none" id="ptRestorePurgeWrap">
<input class="form-check-input" type="checkbox" name="purge" value="1" id="ptRestorePurge">
<label class="form-check-label small" for="ptRestorePurge">
Apagar ficheiros que não existam no backup (espelho exacto).
<span class="text-danger">Desligado por omissão: um backup parcial apagaria uploads em uso.</span>
</label>
</div>
<label for="ptRestoreConfirm" class="form-label small mb-1">
Escreve <code>RESTAURAR</code> para confirmar:
</label>
<input type="text" class="form-control" name="confirm" id="ptRestoreConfirm"
autocomplete="off" placeholder="RESTAURAR">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancelar</button>
<button type="submit" class="btn btn-warning" id="ptRestoreSubmit" disabled>
<i class="bi bi-arrow-counterclockwise me-1"></i>Restaurar
</button>
</div>
</form>
</div>
</div>
<script>
(function () {
var modalEl = document.getElementById('ptRestoreModal');
if (!modalEl || typeof bootstrap === 'undefined') return;
var modal = new bootstrap.Modal(modalEl);
var confirmInput = document.getElementById('ptRestoreConfirm');
var submitBtn = document.getElementById('ptRestoreSubmit');
document.querySelectorAll('.js-restore').forEach(function (btn) {
btn.addEventListener('click', function () {
var isDb = btn.dataset.type === 'db';
document.getElementById('ptRestoreFilename').value = btn.dataset.filename;
document.getElementById('ptRestoreName').textContent = btn.dataset.filename;
document.getElementById('ptRestoreType').textContent = btn.dataset.label || btn.dataset.type;
document.getElementById('ptRestoreCreated').textContent = btn.dataset.created || '';
document.getElementById('ptRestoreScopeDb').classList.toggle('d-none', !isDb);
document.getElementById('ptRestoreScopeFiles').classList.toggle('d-none', isDb);
// purge só faz sentido para ficheiros; garante que não vai marcado
// de uma abertura anterior.
document.getElementById('ptRestorePurgeWrap').classList.toggle('d-none', isDb);
document.getElementById('ptRestorePurge').checked = false;
confirmInput.value = '';
submitBtn.disabled = true;
modal.show();
});
});
confirmInput.addEventListener('input', function () {
submitBtn.disabled = confirmInput.value.trim() !== 'RESTAURAR';
});
})();
</script>
<script>
// Anti-duplo-clique: bloqueia reenvios e mostra spinner no botão submetido.
document.addEventListener('submit', function (e) {
var form = e.target;
if (e.defaultPrevented || !(form instanceof HTMLFormElement)) return;
if (form.dataset.ptSubmitted) { e.preventDefault(); return; }
form.dataset.ptSubmitted = '1';
form.querySelectorAll('button[type="submit"]').forEach(function (btn) {
btn.disabled = true;
btn.insertAdjacentHTML('afterbegin',
'<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>');
});
});
<?php if ($awaiting): ?>
// Recarrega até o novo backup aparecer (contagem no servidor decide).
setTimeout(function () {
var url = new URL(window.location.href);
url.searchParams.set('pt_await', '<?= (int) $await_count ?>');
url.searchParams.set('pt_tries', '<?= (int) ($await_tries + 1) ?>');
window.location.replace(url.toString());
}, 8000);
<?php else: ?>
// Poll terminado (ou nunca ativo): limpa os parâmetros de acompanhamento.
if (window.location.search.indexOf('pt_await') !== -1) {
var cleanUrl = new URL(window.location.href);
cleanUrl.searchParams.delete('pt_await');
cleanUrl.searchParams.delete('pt_tries');
window.history.replaceState(null, '', cleanUrl.toString());
}
<?php endif; ?>
</script>
<?php if (is_file($layout_footer)) include $layout_footer; ?>
@@ -1,185 +0,0 @@
<?php
/**
* Backup dos ficheiros do site — APENAS a pasta de uploads.
*
* Tudo o resto no host é reconstituível a partir do git (código) ou do backup
* da base de dados; os uploads são a única pasta com informação externa. Os
* caminhos dentro do ZIP ficam relativos à raiz do host (uploads/...), por
* isso um restore é extrair o ZIP na raiz.
*
* Assumed location: <host>/shared/platform-tools/backups/backup_maker.php
* → $sourceDir = <host>/<BACKUP_FILES_DIR> (default: uploads; ver config.php)
* → $backupDir = <host>/backups (where the ZIP lands)
*
* Via CLI : php shared/platform-tools/backups/backup_maker.php
* Via HTTP: GET shared/platform-tools/backups/backup_maker.php?token=SEU_TOKEN
*/
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/../functions_backup.php';
// --- Autenticação -----------------------------------------------------------
if (php_sapi_name() !== 'cli') {
$token = $_GET['token'] ?? $_POST['token'] ?? '';
if (!hash_equals(BACKUP_TOKEN, $token)) {
http_response_code(403);
die(json_encode(['status' => 'error', 'message' => 'Acesso negado.']));
}
ignore_user_abort(true);
set_time_limit(0);
}
// --- Configuração -----------------------------------------------------------
$siteRoot = dirname(__DIR__, 3); // <host>
$backupDir = $siteRoot . '/backups'; // <host>/backups
if (!is_dir($backupDir)) @mkdir($backupDir, 0775, true);
$siteRoot = realpath($siteRoot) ?: $siteRoot;
$backupDir = realpath($backupDir) ?: $backupDir;
backup_dir_protect($backupDir);
// BACKUP_FILES_DIR='' is an explicit opt-out, not a misconfiguration: some
// hosts keep no local user content at all (surf serves media from Nextcloud
// over WebDAV), so there is genuinely nothing to archive. Report and exit 0
// rather than die() -- a scheduled run must not be marked failed for it.
if (trim(BACKUP_FILES_DIR) === '') {
echo "Backup de ficheiros desactivado (BACKUP_FILES_DIR vazio) — este host não guarda ficheiros localmente.\n";
return;
}
// BACKUP_FILES_DIR aceita uma LISTA separada por vírgulas, não só uma pasta.
//
// Era uma única pasta, e isso perdia dados em silêncio: o design guarda os
// modelos 3D em public/threed/ e o resto em public/uploads/, por isso um
// backup "de ficheiros" trazia metade do conteúdo do utilizador e ninguém
// reparava até tentar restaurar. Uma pasta continua a ser válido -- a maioria
// dos sites tem só uma.
$requestedDirs = array_values(array_filter(array_map(
fn($d) => trim($d, " \t/"),
explode(',', BACKUP_FILES_DIR)
), fn($d) => $d !== ''));
$sourceDirs = []; // rótulo relativo => caminho absoluto
foreach ($requestedDirs as $rel) {
$abs = realpath($siteRoot . '/' . $rel) ?: '';
if ($abs === '' || !is_dir($abs)) {
// Falhar alto: uma pasta configurada que não existe é quase sempre um
// erro de configuração, e continuar em silêncio produz exactamente o
// backup incompleto que isto existe para evitar.
die("Erro: pasta de uploads não encontrada: $siteRoot/$rel"
. " (verifica BACKUP_FILES_DIR no .env).\n");
}
$sourceDirs[$rel] = $abs;
}
if (!$sourceDirs) {
die("Erro: BACKUP_FILES_DIR não resolveu nenhuma pasta válida.\n");
}
// Seconds + random suffix: no same-minute overwrite, and the download URL
// can't be guessed even if the folder deny rule is missing.
$filename = 'backup_' . date('d_m_Y_H_i_s') . '_' . bin2hex(random_bytes(3)) . '.zip';
$outputPath = "$backupDir/$filename";
echo "Raiz do site : $siteRoot\n";
echo "Pastas origem: " . implode(', ', array_keys($sourceDirs)) . "\n";
echo "Pasta backup : $backupDir\n";
echo "Destino ZIP : $outputPath\n\n";
if (!class_exists('ZipArchive')) {
die("Erro: extensão ZipArchive não disponível.\n");
}
if (!is_writable($backupDir)) {
die("Erro: sem permissão de escrita em: $backupDir\n");
}
// --- Criar ZIP --------------------------------------------------------------
$zip = new ZipArchive();
$result = $zip->open($outputPath, ZipArchive::CREATE | ZipArchive::OVERWRITE);
if ($result !== true) {
die("Erro ao criar ZIP (código: $result)\n");
}
// Só a pasta de uploads é percorrida — código, vendor/ e afins são
// reconstituíveis a partir do git e ficam de fora por construção. Dentro dos
// uploads NADA é excluído por nome/extensão: tudo ali é conteúdo externo de
// utilizadores (um .zip carregado por um utilizador é dado legítimo). Única
// exclusão: a própria pasta backups/, caso algum host a tenha aninhado aqui.
$skipped = 0;
$added = 0;
$errors = 0;
foreach ($sourceDirs as $relRoot => $sourceDir) {
$filter = new RecursiveCallbackFilterIterator(
new RecursiveDirectoryIterator($sourceDir, RecursiveDirectoryIterator::SKIP_DOTS),
function ($file) use ($backupDir, &$skipped): bool {
$realPath = $file->getRealPath();
if ($realPath === false) { $skipped++; return false; }
if ($realPath === $backupDir || str_starts_with($realPath, $backupDir . DIRECTORY_SEPARATOR)) {
$skipped++;
return false;
}
return true;
}
);
$iterator = new RecursiveIteratorIterator($filter, RecursiveIteratorIterator::SELF_FIRST);
// Entrada raiz explícita: o restore extrai na raiz do host, e um backup de
// uma pasta de uploads vazia continua a ser um ZIP válido.
$zip->addEmptyDir($relRoot);
foreach ($iterator as $file) {
$realPath = $file->getRealPath();
// Entradas prefixadas com uploads/... para o ZIP extraído na raiz do host
// repor os ficheiros no sítio certo. Ancorado em $sourceDir (não em
// $siteRoot) para funcionar mesmo quando uploads é um symlink para fora
// da raiz do site.
$relativePath = $relRoot . '/'
. str_replace(DIRECTORY_SEPARATOR, '/', substr($realPath, strlen($sourceDir) + 1));
if ($file->isDir()) {
$zip->addEmptyDir($relativePath);
} else {
if (!is_readable($realPath)) {
echo "AVISO: sem permissão de leitura — $realPath\n";
$errors++;
continue;
}
// addFile() streams no close() — não carrega o ficheiro em memória.
if (!$zip->addFile($realPath, $relativePath)) {
echo "AVISO: falha ao adicionar — $realPath\n";
$errors++;
continue;
}
$added++;
}
}
} // fim do foreach por pasta configurada
echo "Âmbito: " . implode('/, ', array_keys($sourceDirs)) . "/ (o código reconstitui-se a partir do git)\n";
if (!$zip->close()) {
die("Erro: falha ao finalizar o ficheiro ZIP.\n");
}
clearstatcache(true, $outputPath);
if (!file_exists($outputPath)) {
die("Erro: o ficheiro ZIP não foi criado.\n");
}
// --- Resultado --------------------------------------------------------------
$bytes = filesize($outputPath);
$size = $bytes >= 1024 * 1024
? round($bytes / 1024 / 1024, 2) . ' MB'
: round($bytes / 1024, 1) . ' KB';
echo "Backup concluído!\n";
echo "Ficheiro : $outputPath\n";
echo "Tamanho : $size\n";
echo "Ficheiros: $added adicionados, $skipped ignorados" . ($errors ? ", $errors erros" : '') . "\n";
backup_prune_report();
-82
View File
@@ -1,82 +0,0 @@
<?php
/**
* Configuração partilhada pelos scripts de backup.
* Assumed location: <host>/shared/platform-tools/backups/config.php
*
* Valores vêm do .env na raiz do projecto (<host>/.env, gitignored). Os
* fallbacks abaixo só são usados se o .env estiver ausente — em produção,
* mete sempre o .env preenchido.
*/
$host_root = dirname(__DIR__, 3);
// Three host patterns coexist:
// - fotografia-style : config/env.php exposes env_load(), needs an explicit call
// - dsa/moremio-style: config/env.php auto-loads .env via an IIFE; require is enough
// - surf-style : no config/env.php -- config/db.php loads .env with
// vlucas/phpdotenv, and the workers run without it, so
// they read .env themselves (the fallback below).
// Requiring config/env.php unconditionally fataled on the third layout.
if (is_file($host_root . '/config/env.php')) {
require_once $host_root . '/config/env.php';
if (function_exists('env_load')) {
env_load($host_root . '/.env');
}
}
// Fallback reader for hosts with no config/env.php, and for CLI invocations
// that run before any framework bootstrap: parse .env directly rather than
// assuming something else already did.
if (!function_exists('env')) {
function env(string $key, $default = null) {
$v = getenv($key);
if ($v === false && array_key_exists($key, $_ENV)) $v = $_ENV[$key];
if ($v !== false && $v !== null) return $v;
static $parsed = null;
if ($parsed === null) {
$parsed = [];
$f = dirname(__DIR__, 3) . '/.env';
if (is_file($f) && is_readable($f)) {
foreach (file($f, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#' || !str_contains($line, '=')) continue;
[$k, $val] = array_pad(explode('=', $line, 2), 2, '');
$parsed[trim($k)] = trim(trim($val), " \"'");
}
}
}
return array_key_exists($key, $parsed) ? $parsed[$key] : $default;
}
}
define('BACKUP_TOKEN', env('BACKUP_TOKEN', 'replace_me'));
// Retenção avô-pai-filho — ver backup_gfs_keep() em functions_backup.php.
define('BACKUP_KEEP_DAYS', (int) env('BACKUP_KEEP_DAYS', 7));
define('BACKUP_KEEP_WEEKS', (int) env('BACKUP_KEEP_WEEKS', 4));
define('BACKUP_KEEP_MONTHS', (int) env('BACKUP_KEEP_MONTHS', 1));
// Pasta (relativa à raiz do host) que o backup de ficheiros arquiva. Só os
// uploads têm informação externa — todo o resto é reconstituível a partir do
// git — por isso o default é 'uploads'. Overridável no .env se um host
// guardar os uploads noutro sítio.
define('BACKUP_FILES_DIR', trim((string) env('BACKUP_FILES_DIR', 'uploads'), '/'));
// Credenciais da base de dados — sem fallbacks reais: se o .env faltar, a
// ligação falha alto em vez de tentar um host/utilizador "mágico".
define('DB_HOST', env('DB_HOST', 'localhost'));
define('DB_PORT', (int) env('DB_PORT', 3306));
define('DB_NAME', env('DB_NAME', ''));
define('DB_USER', env('DB_USER', ''));
define('DB_PASS', env('DB_PASS', ''));
define('DB_CHARSET', 'utf8mb4');
/** Poda avô-pai-filho no fim de um worker (ver backup_prune()), com o relatório no output. */
function backup_prune_report(): void
{
$r = backup_prune();
foreach ($r['deleted'] as $f) echo "Apagado (retenção): $f\n";
foreach ($r['errors'] as $f) echo "ERRO a apagar: $f\n";
echo 'Rotação: ' . count($r['deleted']) . ' apagado(s), ' . count($r['kept']) . ' mantido(s) — '
. backup_retention_summary($r['policy']) . ".\n";
}
-187
View File
@@ -1,187 +0,0 @@
<?php
/**
* Backup da base de dados — gera um dump SQL completo em ZIP.
*
* Assumed location: <host>/shared/platform-tools/backups/db_backup.php
* → $backupDir = <host>/backups (where the ZIP lands)
*
* Via CLI : php shared/platform-tools/backups/db_backup.php
* Via HTTP: GET shared/platform-tools/backups/db_backup.php?token=SEU_TOKEN
*/
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/../functions_backup.php';
// --- Autenticação -----------------------------------------------------------
if (php_sapi_name() !== 'cli') {
$token = $_GET['token'] ?? $_POST['token'] ?? '';
if (!hash_equals(BACKUP_TOKEN, $token)) {
http_response_code(403);
die(json_encode(['status' => 'error', 'message' => 'Acesso negado.']));
}
ignore_user_abort(true);
set_time_limit(0);
}
// --- Configuração -----------------------------------------------------------
$siteRoot = dirname(__DIR__, 3);
$backupDir = $siteRoot . '/backups';
if (!is_dir($backupDir)) @mkdir($backupDir, 0775, true);
$backupDir = realpath($backupDir) ?: $backupDir;
backup_dir_protect($backupDir);
// Sem ZipArchive não há backup: um .sql em claro na pasta backups/ nunca pode
// ser o artefacto final (não aparece na UI, não roda, e é texto simples).
if (!class_exists('ZipArchive')) {
die("Erro: extensão ZipArchive não disponível — backup cancelado.\n");
}
// Seconds + random suffix: no same-minute overwrite, unguessable URL.
$sqlFile = 'db_backup_' . date('d_m_Y_H_i_s') . '_' . bin2hex(random_bytes(3)) . '.sql';
$zipFile = str_replace('.sql', '.zip', $sqlFile);
$sqlPath = $backupDir . '/' . $sqlFile;
$zipPath = $backupDir . '/' . $zipFile;
echo "Base de dados: " . DB_NAME . "\n";
echo "Destino ZIP : $zipPath\n\n";
if (!is_writable($backupDir)) {
die("Erro: sem permissão de escrita em: $backupDir\n");
}
// --- Ligação PDO ------------------------------------------------------------
try {
$dsn = sprintf('mysql:host=%s;port=%d;dbname=%s;charset=%s', DB_HOST, DB_PORT, DB_NAME, DB_CHARSET);
$pdo = new PDO($dsn, DB_USER, DB_PASS, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
} catch (PDOException $e) {
die("Erro de ligação à base de dados: " . $e->getMessage() . "\n");
}
// --- Gerar dump SQL (streaming) ----------------------------------------------
// Escreve o dump diretamente para o .sql à medida que lê — nunca constrói a
// base de dados inteira numa string PHP. As leituras de dados usam queries
// não-bufferizadas (uma de cada vez, cursor fechado antes da query seguinte),
// por isso o pico de memória é ~1 batch de 500 linhas, independentemente do
// tamanho das tabelas.
function dumpToFile(PDO $pdo, string $sqlPath): void
{
$fh = fopen($sqlPath, 'wb');
if ($fh === false) {
die("Erro: não foi possível escrever o ficheiro SQL.\n");
}
fwrite($fh, "-- Backup: " . DB_NAME . "\n");
fwrite($fh, "-- Data : " . date('Y-m-d H:i:s') . "\n\n");
fwrite($fh, "SET NAMES utf8mb4;\n");
fwrite($fh, "SET FOREIGN_KEY_CHECKS = 0;\n\n");
$tables = $pdo->query("SHOW FULL TABLES WHERE Table_type = 'BASE TABLE'")->fetchAll(PDO::FETCH_COLUMN);
foreach ($tables as $table) {
$quoted = '`' . str_replace('`', '``', (string) $table) . '`';
// Estrutura
$create = $pdo->query("SHOW CREATE TABLE $quoted")->fetch();
fwrite($fh, "DROP TABLE IF EXISTS $quoted;\n");
fwrite($fh, $create['Create Table'] . ";\n\n");
// Dados — linha a linha, INSERTs em blocos de 500.
$pdo->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false);
$stmt = $pdo->query("SELECT * FROM $quoted");
$columns = null;
$values = [];
$flush = function () use ($fh, $quoted, &$columns, &$values) {
if (!$values) return;
fwrite($fh, "INSERT INTO $quoted ($columns) VALUES\n" . implode(",\n", $values) . ";\n");
$values = [];
};
while (($row = $stmt->fetch(PDO::FETCH_ASSOC)) !== false) {
if ($columns === null) {
$columns = '`' . implode('`, `', array_keys($row)) . '`';
}
$escaped = array_map(
fn($v) => $v === null ? 'NULL' : $pdo->quote((string) $v),
array_values($row)
);
$values[] = '(' . implode(', ', $escaped) . ')';
if (count($values) >= 500) $flush();
}
$flush();
$stmt->closeCursor();
$pdo->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, true);
fwrite($fh, "\n");
}
fwrite($fh, "SET FOREIGN_KEY_CHECKS = 1;\n");
fclose($fh);
}
echo "A gerar dump SQL...\n";
dumpToFile($pdo, $sqlPath);
// --- Empacotar em ZIP -------------------------------------------------------
$zip = new ZipArchive();
$result = $zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE);
if ($result !== true) {
@unlink($sqlPath);
die("Erro ao criar ZIP (código: $result)\n");
}
// addFile() streams no close() — o .sql não passa pela memória.
$zip->addFile($sqlPath, $sqlFile);
// Encriptação AES-256 quando há chave. Um dump em claro no disco leva a tabela
// users com os hashes, os contactos, e os contratos assinados -- que no dance
// incluem dados de menores.
//
// Sem chave sai em claro, e diz-se. Recusar o backup por falta de chave seria
// pior: a alternativa a um ficheiro legível não é um ficheiro seguro, é
// ficheiro nenhum.
require_once dirname(__DIR__) . '/functions_backup.php';
$bkKey = platform_backup_key();
if ($bkKey !== null && method_exists($zip, 'setEncryptionName') && defined('ZipArchive::EM_AES_256')) {
$zip->setPassword($bkKey);
if ($zip->setEncryptionName($sqlFile, ZipArchive::EM_AES_256)) {
echo "Encriptação: AES-256\n";
} else {
echo "AVISO: a encriptação falhou — o backup vai em claro.\n";
error_log('[backup] setEncryptionName falhou; artefacto em claro');
}
} else {
echo "AVISO: sem APP_SECRET_KEY — o backup vai em claro.\n";
error_log('[backup] sem chave de encriptação; artefacto em claro');
}
if (!$zip->close()) {
@unlink($sqlPath);
die("Erro: falha ao finalizar o ficheiro ZIP.\n");
}
// Remover .sql temporário
@unlink($sqlPath);
clearstatcache(true, $zipPath);
if (!file_exists($zipPath)) {
die("Erro: o ficheiro ZIP não foi criado.\n");
}
// --- Resultado --------------------------------------------------------------
$bytes = filesize($zipPath);
$size = $bytes >= 1024 * 1024
? round($bytes / 1024 / 1024, 2) . ' MB'
: round($bytes / 1024, 1) . ' KB';
echo "Backup da BD concluído!\n";
echo "Ficheiro : $zipPath\n";
echo "Tamanho : $size\n";
// Só se chega aqui com a cópia nova no disco: a poda nunca corre depois de uma
// cópia falhada, que é quando as antigas são as únicas que há.
backup_prune_report();
@@ -1,126 +0,0 @@
<?php
/**
* Restaura um backup produzido por db_backup.php ou backup_maker.php.
*
* Assumed location: <host>/shared/platform-tools/backups/restore_runner.php
*
* Via CLI :
* php shared/platform-tools/backups/restore_runner.php <ficheiro.zip> [opções]
* Opções: --dry-run apenas valida e mostra o que faria
* --no-safety não tira backup de segurança (NÃO recomendado)
* --purge (só ficheiros) apaga o que não estiver no artefacto
* --list lista os backups disponíveis e sai
*
* Via HTTP: POST restore_runner.php com token=<BACKUP_TOKEN>&file=<ficheiro.zip>
* GET é recusado de propósito: restaurar não é uma operação idempotente de
* leitura e não deve ser possível dispará-la com um link.
*
* TODO RESTAURO É DESTRUTIVO. Por omissão tira sempre um backup do estado
* actual primeiro e devolve o nome desse ficheiro no relatório.
*/
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/../functions_restore.php';
$IS_CLI = (php_sapi_name() === 'cli');
// --- Autenticação -----------------------------------------------------------
if (!$IS_CLI) {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
header('Content-Type: application/json');
die(json_encode(['ok' => false, 'error' => 'Use POST.']));
}
$token = $_POST['token'] ?? '';
if (BACKUP_TOKEN === '' || BACKUP_TOKEN === 'replace_me' || !hash_equals(BACKUP_TOKEN, (string) $token)) {
http_response_code(403);
header('Content-Type: application/json');
die(json_encode(['ok' => false, 'error' => 'Acesso negado.']));
}
ignore_user_abort(true);
set_time_limit(0);
}
// --- Argumentos -------------------------------------------------------------
if ($IS_CLI) {
$args = array_slice($argv, 1);
$flags = array_values(array_filter($args, fn($a) => str_starts_with($a, '--')));
$files = array_values(array_filter($args, fn($a) => !str_starts_with($a, '--')));
$file = $files[0] ?? '';
$dryRun = in_array('--dry-run', $flags, true);
$safety = !in_array('--no-safety', $flags, true);
$purge = in_array('--purge', $flags, true);
$list = in_array('--list', $flags, true);
} else {
$file = (string) ($_POST['file'] ?? '');
$dryRun = !empty($_POST['dry_run']);
$safety = ($_POST['safety'] ?? '1') !== '0';
$purge = !empty($_POST['purge']);
$list = !empty($_POST['list']);
}
function out(array $data, bool $cli): void {
if (!$cli) { header('Content-Type: application/json'); echo json_encode($data, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE); return; }
foreach ($data as $k => $v) {
if (is_array($v)) $v = $v ? implode(', ', array_map('strval', $v)) : '(nenhum)';
elseif (is_bool($v)) $v = $v ? 'sim' : 'não';
elseif ($v === null) $v = '-';
printf(" %-16s %s\n", $k, $v);
}
}
if ($list) {
$rows = backup_list();
if ($IS_CLI) {
echo "Backups disponíveis em " . backup_dir() . ":\n";
foreach ($rows as $r) printf(" %-46s %-6s %s\n", $r['filename'], $r['type'], $r['size_human']);
if (!$rows) echo " (nenhum)\n";
} else { header('Content-Type: application/json'); echo json_encode(['ok'=>true,'backups'=>$rows]); }
exit;
}
if ($file === '') {
out(['ok' => false, 'error' => 'Indique o ficheiro a restaurar (ou --list).'], $IS_CLI);
exit($IS_CLI ? 1 : 0);
}
// --- Validação (sempre, mesmo fora do dry-run) ------------------------------
$info = restore_inspect($file);
if ($IS_CLI) {
echo "Artefacto : {$file}\n";
echo "Tipo : " . ($info['type'] ?? '?') . "\n";
echo "Tamanho : " . ($info['size_human'] ?? '?') . "\n";
echo "Entradas : " . ($info['entries'] ?? 0) . "\n\n";
}
if (!$info['ok']) { out(['ok' => false, 'error' => $info['error']], $IS_CLI); exit($IS_CLI ? 1 : 0); }
if ($dryRun) {
out(['ok' => true, 'dry_run' => true, 'type' => $info['type'],
'would_restore' => $info['type'] === 'db'
? 'base de dados ' . DB_NAME
: (defined('BACKUP_FILES_DIR') ? BACKUP_FILES_DIR : 'uploads'),
'safety_backup' => $safety ? 'sim (antes de escrever)' : 'NÃO',
'purge' => $purge], $IS_CLI);
exit;
}
// --- Execução ---------------------------------------------------------------
if ($info['type'] === 'db') {
try {
$dsn = 'mysql:host=' . DB_HOST . ';port=' . DB_PORT . ';dbname=' . DB_NAME . ';charset=' . DB_CHARSET;
$pdo = new PDO($dsn, DB_USER, DB_PASS, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => true,
]);
} catch (PDOException $e) {
out(['ok' => false, 'error' => 'Ligação à BD falhou: ' . $e->getMessage()], $IS_CLI);
exit($IS_CLI ? 1 : 0);
}
$report = restore_db($pdo, $file, ['safety' => $safety]);
} else {
$report = restore_files($file, ['safety' => $safety, 'purge' => $purge]);
}
out($report, $IS_CLI);
exit($IS_CLI ? ($report['ok'] ? 0 : 1) : 0);
@@ -1,30 +0,0 @@
<?php
// Aplica a retenção avô-pai-filho à pasta backups/ sem fazer uma cópia nova.
//
// php shared/platform-tools/bin/backup-prune.php [--dry-run]
// 30 4 * * * php /var/www/html/shared/platform-tools/bin/backup-prune.php
//
// Os workers já podam no fim de cada cópia. Isto serve para um site cujas cópias
// pararam (a poda nunca corre depois de uma cópia falhada), para ver o que uma
// mudança de BACKUP_KEEP_* apagaria antes de a fazer (--dry-run), e para podar
// depois de a mudar.
declare(strict_types=1);
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit;
}
require_once dirname(__DIR__) . '/backups/config.php';
require_once dirname(__DIR__) . '/functions_backup.php';
$dryRun = in_array('--dry-run', $argv, true);
$r = backup_prune($dryRun);
echo 'Política: ' . backup_retention_summary($r['policy']) . "\n";
foreach ($r['kept'] as $k) printf(" mantém %s (%s)\n", $k['filename'], $k['reason']);
foreach ($r['deleted'] as $f) printf(" %s %s\n", $dryRun ? 'apagaria' : 'apagou ', $f);
foreach ($r['errors'] as $f) printf(" ERRO %s\n", $f);
exit($r['errors'] ? 1 : 0);
@@ -1,77 +0,0 @@
<?php
// Encrypt any secret-valued row in system_settings that is still plaintext.
//
// docker exec -u www-data <container> php \
// /var/www/html/shared/platform-tools/bin/encrypt_secrets.php [--dry-run]
//
// Idempotent -- rows already carrying the enc:v1: prefix are left alone -- so it
// is safe to call from deploy.sh on every deploy. Exits non-zero if a row that
// should be encrypted could not be, so a deploy notices.
//
// Every site opens $pdo in its config/db.php; that is the whole bootstrap.
declare(strict_types=1);
$repoRoot = dirname(__DIR__, 3);
$dryRun = in_array('--dry-run', $argv, true);
$pdo = null;
if (is_file($repoRoot . '/config/db.php')) {
require_once $repoRoot . '/config/db.php'; // defines $pdo
if (is_file($repoRoot . '/config/functions.php')) {
require_once $repoRoot . '/config/functions.php';
}
}
if (!$pdo instanceof PDO) {
fwrite(STDERR, "encrypt_secrets: could not open a PDO connection for {$repoRoot}\n");
exit(2);
}
// A bootstrap with a hardcoded fallback schema would connect somewhere
// harmless-looking when the environment did not reach it, and "encrypt"
// nothing, reporting success. Refuse unless the live connection is the schema
// DB_NAME actually names.
$expected = (string) ($_ENV['DB_NAME'] ?? getenv('DB_NAME') ?: '');
$actual = (string) $pdo->query('SELECT DATABASE()')->fetchColumn();
if ($expected !== '' && $actual !== $expected) {
fwrite(STDERR, "encrypt_secrets: connected to '{$actual}' but DB_NAME is '{$expected}' -- refusing to run.\n");
exit(2);
}
require_once dirname(__DIR__) . '/functions_settings.php';
if (!platform_settings_secret_available()) {
fwrite(STDERR, "encrypt_secrets: no APP_SECRET_KEY (or BACKUP_TOKEN) configured -- refusing to run.\n");
exit(3);
}
if ($dryRun) {
// Report without writing: list secret-named rows still in plaintext.
$rows = $pdo->query("SELECT setting_key, setting_value FROM system_settings")->fetchAll(PDO::FETCH_ASSOC);
$pending = [];
foreach ($rows as $row) {
$k = (string) $row['setting_key'];
$v = (string) $row['setting_value'];
if (platform_settings_is_secret($k) && $v !== '' && !str_starts_with($v, PLATFORM_SECRET_PREFIX)) {
$pending[] = $k;
}
}
echo $pending
? "would encrypt: " . implode(', ', $pending) . "\n"
: "nothing to encrypt -- all secret rows are empty or already encrypted\n";
exit(0);
}
$report = platform_settings_encrypt_existing($pdo);
foreach ($report['encrypted'] as $k) echo "encrypted: {$k}\n";
foreach ($report['skipped'] as $k) echo "skipped: {$k}\n";
foreach ($report['failed'] as $k) fwrite(STDERR, "FAILED: {$k}\n");
printf(
"%d encrypted, %d skipped, %d failed\n",
count($report['encrypted']), count($report['skipped']), count($report['failed'])
);
exit($report['failed'] ? 1 : 0);
@@ -1,169 +0,0 @@
#!/usr/bin/env php
<?php
// Compila o índice de país por endereço, para a analítica de visitas.
//
// php bin/geo-build-index.php
//
// O bloqueio por país (functions_geo.php) só precisa das listas dos países
// permitidos, que são pequenas e ficam versionadas. Isto é outra coisa: para
// dizer DE ONDE veio uma visita é preciso o mundo inteiro -- 240 países e
// ~263 mil ranges. Em texto são 4,7 MB e a compilar dá ~2,6 MB.
//
// Por isso o índice NÃO vai para o repositório: um binário desse tamanho
// refrescado todos os meses inchava o histórico sem nada em troca. Gera-se aqui
// e, se faltar, a analítica regista o país como desconhecido -- que é um
// degradar aceitável, ao contrário do bloqueio, onde dados em falta seriam uma
// decisão de segurança.
//
// Formato, escolhido para se poder pesquisar sem carregar nada para memória:
// registos de largura fixa, ordenados, e busca binária por fseek(). São
// centenas de milhares de ranges — lê-los todos a cada visita custaria mais do
// que a própria página.
//
// .v4 → 10 bytes: início(4, big-endian) + fim(4) + código do país(2)
// .v6 → 34 bytes: início(16) + fim(16) + código do país(2)
//
// Os bytes vêm de inet_pton(), por isso a ordem lexicográfica é a ordem
// numérica e o strcmp() serve de comparador nas duas famílias.
const V4_URL = 'https://www.ipdeny.com/ipblocks/data/countries/all-zones.tar.gz';
const V6_URL = 'https://www.ipdeny.com/ipv6/ipaddresses/blocks/ipv6-all-zones.tar.gz';
$dataDir = dirname(__DIR__) . '/data/geo';
@mkdir($dataDir, 0775, true);
require_once dirname(__DIR__) . '/functions_geo.php'; // geo_cidr_to_range()
$tmp = sys_get_temp_dir() . '/geo-index-' . getmypid();
@mkdir($tmp, 0700, true);
register_shutdown_function(static function () use ($tmp) {
foreach (glob($tmp . '/*') ?: [] as $f) { @unlink($f); }
@rmdir($tmp);
});
$status = 0;
foreach ([['v4', V4_URL, 4], ['v6', V6_URL, 16]] as [$fam, $url, $addrLen]) {
fwrite(STDERR, "$fam: a descarregar...\n");
$tar = "$tmp/$fam.tar.gz";
if (!download($url, $tar)) {
fwrite(STDERR, " falhou o download -- o índice anterior mantém-se\n");
$status = 1;
continue;
}
$work = "$tmp/$fam";
@mkdir($work, 0700, true);
exec('tar xzf ' . escapeshellarg($tar) . ' -C ' . escapeshellarg($work) . ' 2>&1', $out, $code);
if ($code !== 0) {
fwrite(STDERR, " o arquivo não abriu -- ignorado\n");
$status = 1;
continue;
}
// Nem toda a zona do ipdeny é um país. O zz.zone é um apanha-tudo de blocos
// /8 inteiros (1.0.0.0/8, 8.0.0.0/8…) que se sobrepõem aos países reais, e
// o eu.zone é um agregado continental. Deixá-los entrar fazia o 8.8.8.8 sair
// como "ZZ" em vez de US, porque a busca binária cai no range que apanhar --
// e com sobreposições não há garantia nenhuma de ser o mais específico.
// Fora estes dois, sobram 2 sobreposições em 262 mil.
$notCountries = ['ZZ', 'EU', 'AP'];
$ranges = [];
foreach (glob("$work/*.zone") ?: [] as $file) {
// ipdeny nomeia cada ficheiro pelo código ISO: pt.zone, br.zone…
$cc = strtoupper(basename($file, '.zone'));
if (!preg_match('/^[A-Z]{2}$/', $cc) || in_array($cc, $notCountries, true)) {
continue;
}
foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') {
continue;
}
$r = geo_cidr_to_range($line);
// Um ficheiro v4 não pode contaminar o índice v6 nem o contrário:
// os registos são de largura fixa e um intruso desalinhava tudo a
// partir dali, o que a busca binária não tem como detectar.
if ($r === null || strlen($r[0]) !== $addrLen) {
continue;
}
$ranges[] = [$r[0], $r[1], $cc];
}
}
if (count($ranges) < 1000) {
fwrite(STDERR, ' só ' . count($ranges) . " ranges — recusado, fica o índice anterior\n");
$status = 1;
continue;
}
usort($ranges, static fn($a, $b) => strcmp($a[0], $b[0]));
// As poucas sobreposições que restam são descartadas, ficando a primeira.
// A busca binária pressupõe ranges disjuntos: com sobreposições devolveria
// um resultado que depende de onde a bissecção calhou aterrar, o que é pior
// do que não responder -- daria países diferentes para o mesmo endereço
// consoante o tamanho do índice.
$clean = [];
$lastEnd = null;
$dropped = 0;
foreach ($ranges as $r) {
if ($lastEnd !== null && strcmp($r[0], $lastEnd) <= 0) {
$dropped++;
continue;
}
$clean[] = $r;
$lastEnd = $r[1];
}
$ranges = $clean;
if ($dropped > 0) {
fwrite(STDERR, " $dropped ranges sobrepostos descartados\n");
}
// Escrita para ficheiro temporário e rename: o rename é atómico, por isso
// um processo a ler nunca apanha o índice a meio de ser escrito.
$outFile = "$dataDir/country-index.$fam";
$tmpOut = $outFile . '.tmp';
$fh = fopen($tmpOut, 'wb');
if ($fh === false) {
fwrite(STDERR, " não consegui escrever em $tmpOut\n");
$status = 1;
continue;
}
foreach ($ranges as [$start, $end, $cc]) {
fwrite($fh, $start . $end . $cc);
}
fclose($fh);
@chmod($tmpOut, 0664);
rename($tmpOut, $outFile);
printf(
" %s: %d ranges, %.1f MB\n",
$fam,
count($ranges),
filesize($outFile) / 1048576
);
}
exit($status);
function download(string $url, string $dest): bool
{
$fh = fopen($dest, 'wb');
if ($fh === false) {
return false;
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_FILE => $fh,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 180,
CURLOPT_FAILONERROR => true,
]);
$ok = curl_exec($ch) !== false;
curl_close($ch);
fclose($fh);
return $ok && filesize($dest) > 1024;
}
@@ -1,100 +0,0 @@
#!/usr/bin/env bash
#
# Refresh the iCloud Private Relay egress ranges that functions_geo.php reads.
#
# geo-relay-update.sh pt # one or more ISO country codes
# geo-relay-update.sh # falls back to $GEO_ALLOW_COUNTRIES
#
# Why this exists: Private Relay sends Safari traffic out through Apple's egress
# addresses, which are registered where Apple registered them and not where the
# person is. Against the ipdeny lists a subscriber in Aveiro looks foreign, gets
# refused, and never hears back. Apple publishes which country each egress range
# serves, so the gate can ask the right question about it.
#
# It writes relay-<cc>.v4 / relay-<cc>.v6 — same format as the ipdeny lists, read
# by the same code. **Only the countries you ask for**, and that is the point:
# the published list is 42k IPv4 ranges and 246k IPv6 (855 KB and 6.7 MB), while
# Portugal is 611 of them. The whole file would be a 6.7 MB commit every week and
# a 6.7 MB parse on every request, to answer a question about one country.
#
# Keeping it per-country is also what stops this becoming a Relay whitelist: a
# range serving France is simply never written, so a French relay user is
# refused by the normal path exactly like any other foreign address.
#
# Apple adds ranges as it adds capacity, so weekly is reasonable:
#
# 23 4 * * 1 /srv/sites/<site>/shared/platform-tools/bin/geo-relay-update.sh pt
#
# The files are committed, same as the ipdeny data and for the same reason: a
# deploy on a box that cannot reach Apple still has working data — as current as
# the last commit.
set -euo pipefail
DATA_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/data/geo"
URL="${ICLOUD_RELAY_URL:-https://mask-api.icloud.com/egress-ip-ranges.csv}"
# Apple serves relay capacity for a country from at least a few dozen ranges.
# Fewer than this means the country code was wrong, the format changed, or the
# download was truncated — and overwriting a good file with it would quietly
# turn every relay visitor back into a foreigner.
MIN_ROWS=10
countries=("$@")
if [ ${#countries[@]} -eq 0 ]; then
read -r -a countries <<< "${GEO_ALLOW_COUNTRIES//[,;]/ }"
fi
if [ ${#countries[@]} -eq 0 ]; then
echo "uso: $(basename "$0") <cc> [cc...] (ou defina GEO_ALLOW_COUNTRIES)" >&2
exit 2
fi
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
echo "iCloud Private Relay: a descarregar $URL"
if ! curl -fsS --max-time 60 "$URL" -o "$tmp/raw.csv"; then
echo "FALHOU o download — os ficheiros anteriores mantêm-se." >&2
exit 1
fi
failed=0
for cc in "${countries[@]}"; do
cc="$(echo "$cc" | tr '[:upper:]' '[:lower:]')"
[ -n "$cc" ] || continue
up="$(echo "$cc" | tr '[:lower:]' '[:upper:]')"
# O formato da Apple é: cidr,countryCode,region,city, — só as duas
# primeiras colunas interessam, e uma linha que não seja exactamente isso é
# descartada em vez de adivinhada: uma mudança de formato tem de aparecer
# como "poucas gamas" e parar o script, não como um ficheiro meio lido.
awk -F',' -v want="$up" '
$1 ~ /\// && toupper($2) == want {
if ($1 ~ /:/) { print $1 > v6 } else { print $1 > v4 }
}
' v4="$tmp/$cc.v4" v6="$tmp/$cc.v6" "$tmp/raw.csv" || true
touch "$tmp/$cc.v4" "$tmp/$cc.v6"
rows4=$(wc -l < "$tmp/$cc.v4" | tr -d ' ')
rows6=$(wc -l < "$tmp/$cc.v6" | tr -d ' ')
total=$((rows4 + rows6))
if [ "$total" -lt "$MIN_ROWS" ]; then
echo "FALHOU $up: só $total gamas (mínimo $MIN_ROWS). Ficheiros anteriores mantidos." >&2
failed=1
continue
fi
# Ordenados no disco para um git diff entre duas actualizações se conseguir
# ler. O módulo volta a ordená-los ao carregar, porque esta ordem é textual
# e a procura precisa da ordem dos bytes empacotados.
sort -u "$tmp/$cc.v4" -o "$tmp/$cc.v4"
sort -u "$tmp/$cc.v6" -o "$tmp/$cc.v6"
mkdir -p "$DATA_DIR"
mv "$tmp/$cc.v4" "$DATA_DIR/relay-$cc.v4"
mv "$tmp/$cc.v6" "$DATA_DIR/relay-$cc.v6"
echo "OK $up: $rows4 gamas IPv4, $rows6 IPv6 → $DATA_DIR/relay-$cc.{v4,v6}"
done
exit "$failed"
-95
View File
@@ -1,95 +0,0 @@
#!/usr/bin/env bash
#
# Refresh the per-country CIDR lists that functions_geo.php reads.
#
# geo-update.sh pt # one or more ISO country codes
# geo-update.sh # falls back to $GEO_ALLOW_COUNTRIES
#
# Source is ipdeny.com, which publishes one aggregated zone file per country
# from the RIR allocation data. Allocations move slowly, so monthly is plenty:
#
# 17 4 3 * * /srv/sites/<site>/shared/platform-tools/bin/geo-update.sh pt
#
# The files are committed to the repo. This script only refreshes them, so a
# deploy on a box that cannot reach ipdeny still has working data — it is just
# as current as the last commit. That is the whole reason for committing them.
#
# Nothing is overwritten unless the download passed every check: a truncated
# file, an HTML error page served with 200, or a suspiciously short list would
# otherwise turn into "no visitor is in Portugal" — which, with GEO_FAIL_CLOSED,
# locks the country out of its own site.
set -euo pipefail
DATA_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/data/geo"
V4_URL="https://www.ipdeny.com/ipblocks/data/countries"
V6_URL="https://www.ipdeny.com/ipv6/ipaddresses/blocks"
# A real country zone has hundreds of ranges. Anything under this is a bad
# download, not a small country -- the smallest national allocations still run
# to dozens of blocks, and we would rather keep yesterday's file.
MIN_V4_LINES=20
MIN_V6_LINES=1
countries=("$@")
if [ ${#countries[@]} -eq 0 ]; then
read -r -a countries <<< "${GEO_ALLOW_COUNTRIES//[,;]/ }"
fi
if [ ${#countries[@]} -eq 0 ]; then
echo "uso: $(basename "$0") <cc> [cc...] (ou defina GEO_ALLOW_COUNTRIES)" >&2
exit 2
fi
mkdir -p "$DATA_DIR"
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
status=0
fetch_zone() {
local cc="$1" family="$2" url="$3" min="$4"
local out="$DATA_DIR/${cc}.${family}"
local work="$tmp/${cc}.${family}"
if ! curl -fsS --max-time 60 -o "$work" "$url"; then
echo " ${cc}.${family}: download falhou -- fica o ficheiro anterior" >&2
return 1
fi
# Every line must be a bare CIDR. An HTML error page fails here, which is
# the check that actually matters: those arrive with a 200.
if grep -qvE '^[0-9a-fA-F:.]+/[0-9]{1,3}$' "$work"; then
echo " ${cc}.${family}: conteúdo não é uma lista de CIDR -- ignorado" >&2
return 1
fi
local n
n=$(wc -l < "$work")
if [ "$n" -lt "$min" ]; then
echo " ${cc}.${family}: só $n linhas (mínimo $min) -- ignorado" >&2
return 1
fi
sort -V -o "$work" "$work"
if [ -f "$out" ] && cmp -s "$work" "$out"; then
echo " ${cc}.${family}: sem alterações ($n)"
return 0
fi
mv "$work" "$out"
echo " ${cc}.${family}: actualizado ($n ranges)"
}
for cc in "${countries[@]}"; do
cc="$(echo "$cc" | tr '[:upper:]' '[:lower:]')"
[[ "$cc" =~ ^[a-z]{2}$ ]] || { echo " '$cc' não é um código ISO -- ignorado" >&2; status=1; continue; }
echo "$cc:"
fetch_zone "$cc" v4 "$V4_URL/${cc}.zone" "$MIN_V4_LINES" || status=1
# IPv6 is published separately and some countries have no file at all; a
# missing v6 list is not an error, the v4 list still gates those visitors.
fetch_zone "$cc" v6 "$V6_URL/${cc}.zone" "$MIN_V6_LINES" || true
done
exit $status
@@ -1,29 +0,0 @@
<?php
// Envia ao hub os eventos de segurança em espera e aplica os banimentos da frota.
//
// */5 * * * * php /var/www/html/shared/platform-tools/bin/hub-security-sync.php
//
// Sem isto a sincronização acontece na mesma, no fim dos pedidos normais — mas
// um site sem visitas nunca chegaria a receber os banimentos dos outros, e uma
// caixa de saída cheia depois de o hub ter estado em baixo esvazia-se aqui de uma
// vez em vez de um lote por visita.
declare(strict_types=1);
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit;
}
// O mesmo arranque dos endpoints do hub: resolve os dois stacks da frota.
require dirname(__DIR__) . '/hub_bootstrap.php';
require_once dirname(__DIR__) . '/functions_security_events.php';
if (!$pdo instanceof PDO) {
echo json_encode(['ok' => true, 'skipped' => 'plataforma sem base de dados']), "\n";
exit(0);
}
$res = hub_security_sync($pdo, 50);
echo json_encode($res, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), "\n";
exit(!empty($res['ok']) ? 0 : 1);
-59
View File
@@ -1,59 +0,0 @@
<?php
// ─── Short secret-helper names, backed by the shared implementation ──────────
//
// fotografia and hub2 each grew their own config/functions_secrets.php with
// secret_encrypt()/secret_decrypt() in it. Same enc:v1: wire format, but not
// the same key: fotografia derived it domain-separated ('am-secret|' . key)
// while hub2 used a plain sha256 of APP_SECRET_KEY. A value written by one was
// unreadable by the other, and dance and silver had no implementation at all.
//
// There is now one implementation, in functions_settings.php. This file only
// re-exports it under the short names those call sites already use, so a site
// keeps calling secret_encrypt() and gets the estate-wide behaviour.
//
// A site includes this instead of defining its own:
// require_once __DIR__ . '/../shared/platform-tools/compat_secrets.php';
require_once __DIR__ . '/functions_settings.php';
if (!defined('SECRET_PREFIX')) {
define('SECRET_PREFIX', PLATFORM_SECRET_PREFIX);
}
if (!function_exists('secret_encrypt')) {
function secret_encrypt(string $plain): string {
return platform_secret_encrypt($plain);
}
}
if (!function_exists('secret_decrypt')) {
function secret_decrypt(?string $stored): string {
return platform_secret_decrypt($stored);
}
}
if (!function_exists('secret_is_encrypted')) {
function secret_is_encrypted(string $stored): bool {
return str_starts_with($stored, PLATFORM_SECRET_PREFIX);
}
}
if (!function_exists('secret_mask')) {
function secret_mask(string $plain, string $key = ''): string {
return platform_secret_mask($plain, $key);
}
}
// Both spellings existed: fotografia called it secrets_available(), hub2
// secrets_key_available(). Keep both so neither site's call sites need editing.
if (!function_exists('secrets_available')) {
function secrets_available(): bool {
return platform_settings_secret_available();
}
}
if (!function_exists('secrets_key_available')) {
function secrets_key_available(): bool {
return platform_settings_secret_available();
}
}
-100
View File
@@ -1,100 +0,0 @@
<?php
// ─── Países para a página de bloqueio por país ──────────────────────────────
//
// Nomes em português europeu, agrupados pela ordem por que alguém os procura
// num site desta frota — e não por ordem alfabética do código, que é a ordem
// de quem escreve a lista e não a de quem a lê.
//
// **Não é a ISO 3166 inteira**, de propósito. São 249 códigos, e uma página com
// 249 caixas é a mesma coisa que um campo de texto: ninguém encontra lá nada.
// Estão aqui os que um negócio português tem alguma hipótese de querer — a
// Europa toda, os países de língua portuguesa, os destinos de emigração e as
// meia dúzia de economias grandes. Para o resto, a página tem um campo de
// escape onde se escreve o código à mão, e um código que já esteja gravado
// aparece sempre, mesmo que não conste desta lista.
//
// Um país que falte aqui não é um país bloqueado: isto é a lista do que se
// consegue escolher com o rato, não a lista do que o módulo sabe tratar.
return [
'Mais usados' => [
'PT' => 'Portugal',
'ES' => 'Espanha',
'FR' => 'França',
'GB' => 'Reino Unido',
'CH' => 'Suíça',
'LU' => 'Luxemburgo',
'DE' => 'Alemanha',
'BR' => 'Brasil',
],
'Resto da Europa' => [
'AD' => 'Andorra',
'AL' => 'Albânia',
'AT' => 'Áustria',
'BA' => 'Bósnia e Herzegovina',
'BE' => 'Bélgica',
'BG' => 'Bulgária',
'BY' => 'Bielorrússia',
'CY' => 'Chipre',
'CZ' => 'Chéquia',
'DK' => 'Dinamarca',
'EE' => 'Estónia',
'FI' => 'Finlândia',
'GI' => 'Gibraltar',
'GR' => 'Grécia',
'HR' => 'Croácia',
'HU' => 'Hungria',
'IE' => 'Irlanda',
'IS' => 'Islândia',
'IT' => 'Itália',
'LI' => 'Listenstaine',
'LT' => 'Lituânia',
'LV' => 'Letónia',
'MC' => 'Mónaco',
'MD' => 'Moldávia',
'ME' => 'Montenegro',
'MK' => 'Macedónia do Norte',
'MT' => 'Malta',
'NL' => 'Países Baixos',
'NO' => 'Noruega',
'PL' => 'Polónia',
'RO' => 'Roménia',
'RS' => 'Sérvia',
'RU' => 'Rússia',
'SE' => 'Suécia',
'SI' => 'Eslovénia',
'SK' => 'Eslováquia',
'SM' => 'São Marinho',
'UA' => 'Ucrânia',
'VA' => 'Vaticano',
],
'Língua portuguesa' => [
'AO' => 'Angola',
'CV' => 'Cabo Verde',
'GQ' => 'Guiné Equatorial',
'GW' => 'Guiné-Bissau',
'MO' => 'Macau',
'MZ' => 'Moçambique',
'ST' => 'São Tomé e Príncipe',
'TL' => 'Timor-Leste',
],
'Fora da Europa' => [
'AE' => 'Emirados Árabes Unidos',
'AR' => 'Argentina',
'AU' => 'Austrália',
'CA' => 'Canadá',
'CL' => 'Chile',
'CN' => 'China',
'CO' => 'Colômbia',
'IL' => 'Israel',
'IN' => 'Índia',
'JP' => 'Japão',
'MA' => 'Marrocos',
'MX' => 'México',
'NZ' => 'Nova Zelândia',
'TR' => 'Turquia',
'US' => 'Estados Unidos',
'VE' => 'Venezuela',
'ZA' => 'África do Sul',
],
];
-449
View File
@@ -1,449 +0,0 @@
2.80.0.0/14
5.43.0.0/18
5.158.0.0/18
5.183.96.0/24
5.183.97.0/24
5.183.98.0/23
5.206.224.0/21
5.249.0.0/17
5.253.128.0/22
5.253.180.0/22
31.22.128.0/17
31.25.8.0/23
37.28.192.0/18
37.189.0.0/16
45.92.68.0/23
45.135.172.0/22
45.154.64.0/22
45.156.128.0/22
46.50.0.0/17
46.189.128.0/17
62.28.0.0/16
62.48.128.0/18
62.48.192.0/18
62.169.64.0/18
62.233.41.0/24
62.249.0.0/19
66.33.88.0/24
77.54.0.0/16
77.91.200.0/21
77.95.168.0/24
78.29.128.0/18
78.108.208.0/22
78.130.0.0/17
78.137.192.0/18
79.168.0.0/15
80.172.0.0/16
80.243.80.0/20
80.244.10.0/24
81.20.240.0/20
81.84.0.0/16
81.90.48.0/20
81.193.0.0/16
82.102.4.0/24
82.102.7.0/24
82.154.0.0/15
83.132.0.0/16
83.144.128.0/18
83.174.0.0/18
83.223.160.0/19
83.223.224.0/19
83.240.128.0/17
84.23.192.0/19
84.90.0.0/15
84.252.88.0/22
85.8.226.0/24
85.138.0.0/15
85.240.0.0/13
87.103.0.0/17
87.196.0.0/16
88.157.0.0/16
88.210.64.0/18
88.214.128.0/18
89.26.128.0/17
89.31.224.0/21
89.109.64.0/18
89.114.0.0/15
89.152.0.0/14
89.180.0.0/15
89.214.0.0/16
91.90.170.0/24
91.194.170.0/23
91.194.182.0/23
91.198.47.0/24
91.198.90.0/24
91.198.182.0/24
91.199.116.0/24
91.205.112.0/22
91.209.16.0/24
91.212.59.0/24
91.213.65.0/24
91.216.117.0/24
91.220.190.0/24
91.227.100.0/24
91.230.40.0/24
91.231.216.0/23
91.241.3.0/24
92.118.109.0/24
92.243.81.0/24
92.250.0.0/17
93.94.56.0/21
93.102.0.0/16
93.108.0.0/16
94.46.12.0/22
94.46.16.0/20
94.46.128.0/21
94.46.141.0/24
94.46.142.0/24
94.46.160.0/20
94.46.176.0/21
94.46.251.0/24
94.60.0.0/14
94.126.144.0/21
94.126.168.0/21
94.132.0.0/15
94.231.208.0/22
95.69.0.0/17
95.92.0.0/14
95.136.0.0/17
95.172.160.0/19
103.117.124.0/22
103.243.124.0/22
104.244.8.0/22
109.48.0.0/14
109.71.40.0/21
109.105.208.0/21
109.109.160.0/22
128.65.224.0/19
130.185.80.0/21
139.83.0.0/16
144.64.0.0/16
146.19.95.0/24
146.19.249.0/24
146.193.0.0/16
147.78.200.0/22
148.63.0.0/16
148.69.0.0/16
148.71.0.0/16
149.90.0.0/16
150.237.126.0/23
158.162.0.0/16
159.255.48.0/20
161.230.0.0/16
168.182.128.0/17
176.61.144.0/21
176.78.0.0/15
176.111.104.0/21
176.119.193.0/24
176.121.10.0/24
176.121.255.0/24
176.124.252.0/24
176.221.32.0/21
178.166.0.0/17
185.0.25.0/24
185.1.116.0/24
185.2.84.0/22
185.11.164.0/22
185.12.116.0/22
185.15.20.0/22
185.17.228.0/22
185.31.156.0/22
185.32.36.0/22
185.32.188.0/22
185.36.101.0/24
185.37.208.0/22
185.38.140.0/22
185.38.173.0/24
185.39.208.0/22
185.41.116.0/22
185.42.162.0/24
185.51.92.0/22
185.58.80.0/22
185.63.180.0/22
185.72.152.0/22
185.76.4.0/22
185.79.224.0/22
185.80.76.0/22
185.83.20.0/22
185.83.212.0/23
185.83.215.0/24
185.83.248.0/22
185.88.16.0/22
185.89.192.0/22
185.90.56.0/22
185.92.96.0/22
185.95.220.0/22
185.96.172.0/22
185.98.248.0/22
185.99.232.0/22
185.101.176.0/22
185.105.8.0/24
185.112.251.0/24
185.113.140.0/22
185.113.156.0/22
185.117.225.0/24
185.118.112.0/22
185.124.124.0/22
185.126.88.0/22
185.133.76.0/22
185.133.136.0/22
185.136.52.0/22
185.142.131.0/24
185.146.92.0/22
185.160.115.0/24
185.162.180.0/23
185.162.182.0/24
185.162.183.0/24
185.165.104.0/22
185.166.144.0/24
185.169.96.0/22
185.170.67.0/24
185.175.91.0/24
185.175.184.0/22
185.180.140.0/22
185.204.111.0/24
185.208.203.0/24
185.210.156.0/24
185.214.32.0/22
185.218.12.0/22
185.219.129.0/24
185.219.131.0/24
185.222.8.0/22
185.222.200.0/22
185.224.164.0/22
185.226.196.0/22
185.229.60.0/22
185.231.233.0/24
185.236.228.0/22
185.240.248.0/22
185.248.35.0/24
188.37.0.0/16
188.80.0.0/14
188.93.192.0/21
188.93.224.0/21
188.93.232.0/21
188.125.192.0/19
188.140.0.0/17
188.214.159.0/24
188.214.192.0/24
188.250.0.0/15
192.12.232.0/24
192.26.231.0/24
192.26.236.0/24
192.26.239.0/24
192.35.246.0/24
192.67.76.0/24
192.68.186.0/24
192.68.209.0/24
192.68.216.0/24
192.68.221.0/24
192.68.224.0/24
192.76.242.0/24
192.80.20.0/24
192.80.21.0/24
192.82.127.0/24
192.82.214.0/24
192.84.13.0/24
192.84.15.0/24
192.84.62.0/24
192.86.138.0/24
192.88.17.0/24
192.88.123.0/24
192.88.250.0/23
192.88.252.0/23
192.88.254.0/24
192.91.177.0/24
192.92.133.0/24
192.92.135.0/24
192.92.142.0/24
192.92.143.0/24
192.92.144.0/24
192.92.145.0/24
192.92.146.0/24
192.92.147.0/24
192.92.148.0/24
192.92.149.0/24
192.92.150.0/24
192.92.151.0/24
192.92.152.0/24
192.92.153.0/24
192.94.24.0/24
192.101.118.0/24
192.103.147.0/24
192.104.35.0/24
192.104.36.0/24
192.104.37.0/24
192.104.48.0/24
192.107.122.0/24
192.111.101.0/24
192.112.30.0/24
192.112.31.0/24
192.112.32.0/24
192.112.45.0/24
192.122.238.0/23
192.122.240.0/23
192.122.242.0/24
192.132.53.0/24
192.132.55.0/24
192.133.15.0/24
192.133.54.0/24
192.133.55.0/24
192.133.108.0/24
192.135.129.0/24
192.135.187.0/24
192.135.219.0/24
192.136.31.0/24
192.136.49.0/24
192.136.52.0/24
192.138.86.0/24
192.138.204.0/24
192.147.76.0/24
192.147.77.0/24
192.147.78.0/24
192.147.155.0/24
192.148.103.0/24
192.150.192.0/24
192.150.193.0/24
192.152.47.0/24
192.152.174.0/24
192.153.13.0/24
192.156.210.0/24
192.160.245.0/24
192.160.246.0/23
192.160.248.0/23
192.160.251.0/24
192.162.17.0/24
192.188.10.0/24
192.190.174.0/24
192.195.195.0/24
192.199.16.0/20
192.207.196.0/24
193.16.103.0/24
193.17.0.0/24
193.25.164.0/23
193.27.11.0/24
193.36.190.0/24
193.37.153.0/24
193.39.195.0/24
193.43.40.0/24
193.47.185.0/24
193.53.22.0/24
193.58.118.0/24
193.104.52.0/24
193.107.13.0/24
193.109.93.0/24
193.109.184.0/21
193.111.42.0/24
193.126.0.0/16
193.136.0.0/16
193.137.0.0/16
193.163.151.0/24
193.164.0.0/24
193.176.0.0/24
193.192.4.0/22
193.192.8.0/22
193.200.216.0/24
193.203.106.0/23
193.218.156.0/22
193.227.238.0/23
193.236.0.0/19
193.236.32.0/19
193.236.64.0/19
193.236.96.0/20
193.236.112.0/21
193.236.120.0/22
193.236.124.0/22
193.236.128.0/20
193.236.144.0/21
193.236.152.0/21
193.236.160.0/20
193.236.176.0/23
193.236.178.0/23
193.236.180.0/22
193.236.184.0/21
193.236.192.0/18
193.242.124.0/24
194.6.229.0/24
194.8.30.0/24
194.11.23.0/24
194.38.128.0/19
194.39.84.0/24
194.39.124.0/22
194.62.41.0/24
194.62.240.0/22
194.65.0.0/19
194.65.32.0/19
194.65.64.0/18
194.65.128.0/17
194.79.64.0/19
194.107.127.0/24
194.110.76.0/24
194.117.0.0/19
194.117.32.0/22
194.117.36.0/22
194.117.40.0/21
194.117.48.0/23
194.117.75.0/24
194.117.93.0/24
194.125.242.0/23
194.140.195.0/24
194.140.232.0/24
194.140.238.0/24
194.145.121.0/24
194.153.132.0/24
194.169.156.0/24
194.169.215.0/24
194.210.0.0/16
195.8.0.0/19
195.8.58.0/23
195.22.0.0/19
195.23.0.0/16
195.35.66.0/24
195.47.200.0/24
195.54.168.0/23
195.60.80.224/27
195.66.127.0/24
195.72.136.0/22
195.85.221.0/24
195.88.104.0/23
195.138.0.0/19
195.170.168.0/24
195.184.240.0/22
195.200.252.0/23
195.225.180.0/22
195.234.47.0/24
195.234.134.0/24
195.245.128.0/18
195.245.197.0/24
195.245.234.0/24
195.246.238.0/23
198.56.128.0/17
203.171.64.0/19
212.0.160.0/19
212.13.32.0/19
212.18.160.0/19
212.52.23.0/24
212.55.128.0/19
212.55.160.0/20
212.55.176.0/21
212.104.208.0/23
212.113.160.0/19
213.13.0.0/16
213.22.0.0/16
213.30.0.0/17
213.58.0.0/16
213.63.0.0/17
213.63.128.0/17
213.134.26.0/24
213.138.224.0/19
213.141.0.0/19
213.146.192.0/19
213.190.192.0/19
213.205.64.0/19
213.228.128.0/19
213.228.160.0/19
217.28.143.0/24
217.65.73.0/24
217.70.64.0/20
217.129.0.0/16
217.168.112.0/21
-120
View File
@@ -1,120 +0,0 @@
2a0a:5b00::/29
2a0a:36c0::/29
2a0a:9000::/29
2a0a:e3c0::/29
2a0b:94c0::/32
2a0b:8400::/29
2a0b:c1c0::/29
2a0b:e640::/29
2a0b:e740::/29
2a0c:3840::/29
2a0c:9740::/29
2a0c:b800::/29
2a0c:c2c0::/29
2a0d:3b80::/29
2a0d:82c0::/29
2a0d:94c0::/29
2a0d:2340::/29
2a0d:7200::/29
2a0d:e300::/29
2a0d:f600::/29
2a0e:1f40::/29
2a0e:5f00::/29
2a0e:48c0::/29
2a0e:bc00::/29
2a0e:ec00::/30
2a0e:ec04::/31
2a0f:7700::/29
2a0f:8900::/29
2a0f:c440::/29
2a0f:c700::/29
2a0f:d300::/29
2a00:1c60::/32
2a00:1650::/29
2a00:bc20::/32
2a00:c560::/29
2a00:d180::/29
2a00:fa00::/32
2a01:8::/29
2a01:10::/29
2a01:b020::/32
2a02:2a60::/29
2a02:8f0::/32
2a02:26c8::/32
2a02:27e0::/32
2a02:138::/32
2a02:740::/32
2a02:818::/29
2a02:870::/29
2a03:3f60::/32
2a03:8bc0::/32
2a03:9c80::/32
2a03:73c0::/32
2a03:84e0::/32
2a03:6420::/32
2a03:da0::/32
2a03::/32
2a04:6d80::/29
2a04:53c0::/29
2a04:8940::/29
2a04:b040::/29
2a04:e040::/29
2a05:9f00::/29
2a05:7540::/29
2a05:7800::/29
2a05:9640::/29
2a05:a040::/29
2a05:c800::/29
2a05:df40::/29
2a06:2000::/29
2a06:cdc0::/29
2a06:e640::/29
2a07:4f40::/29
2a09:5b00::/29
2a09:58c0::/32
2a09:2840::/29
2a09:5640::/32
2a09:be41::/32
2a09:be42::/31
2a09:be44::/30
2a10:1f80::/29
2a10:3c0::/29
2a10:7b40::/29
2a10:5000::/29
2a10:a4c0::/29
2a10:b280::/29
2a10:e440::/32
2a10:ee40::/29
2a11:9ec0::/29
2a11:6740::/29
2a11:bd80::/30
2a11:c00::/29
2a11:e740::/32
2a12:26c0::/29
2a12:49c0::/29
2a12:c1c0::/29
2a13:3fc0::/29
2a13:67c0::/29
2a13:580::/29
2a13:de80::/29
2a13:f2c0::/29
2a14:3840::/29
2a14:da80::/29
2001:4cc0::/32
2001:7f8:a::/48
2001:7f8:c7::/48
2001:7f8:f9::/48
2001:8a0::/29
2001:15d8::/29
2001:35c0::/29
2001:67c:1b40::/48
2001:67c:2d44::/48
2001:67c:13ec::/48
2001:67c:c0c::/48
2001:678:12f0::/48
2001:678:1094::/48
2001:690::/29
2001:818::/29
2001:1588::/29
2001:a40::/29
2001:b18::/32
@@ -1,76 +0,0 @@
104.28.136.119/32
104.28.136.120/32
104.28.138.54/32
104.28.138.55/32
104.28.139.148/32
104.28.139.149/32
104.28.143.15/32
104.28.146.45/32
104.28.146.46/32
104.28.146.47/32
104.28.146.48/32
104.28.146.49/32
104.28.34.141/32
104.28.34.142/32
104.28.34.143/32
104.28.34.144/32
104.28.42.78/32
104.28.42.79/32
104.28.53.18/32
104.28.53.19/32
104.28.86.43/32
104.28.86.44/32
104.28.87.85/32
104.28.87.86/32
104.28.87.87/32
104.28.87.88/32
104.28.88.103/32
104.28.88.104/32
104.28.88.105/32
104.28.88.106/32
104.28.96.131/32
104.28.96.132/32
140.248.40.172/31
140.248.40.174/31
140.248.40.176/31
140.248.40.178/31
146.75.168.156/31
146.75.168.158/31
146.75.168.160/31
146.75.168.162/31
146.75.182.52/31
146.75.182.54/31
146.75.182.56/31
146.75.182.58/31
146.75.185.156/31
146.75.185.158/31
146.75.185.160/31
146.75.185.162/31
146.75.210.52/31
146.75.210.54/31
146.75.210.56/31
146.75.210.58/31
172.225.158.178/31
172.225.158.180/30
172.225.158.216/31
172.225.159.64/28
172.225.161.144/28
172.225.162.192/28
172.225.163.62/31
172.225.163.80/30
172.225.163.84/31
172.225.178.176/28
172.225.178.192/28
172.225.179.50/31
172.225.179.52/30
172.225.179.64/31
172.225.192.208/28
172.225.193.58/31
172.225.193.60/30
172.225.193.64/31
172.225.212.0/28
172.225.212.16/30
172.225.212.20/31
172.225.212.22/31
172.225.212.240/28
172.225.243.64/28
-535
View File
@@ -1,535 +0,0 @@
2606:54c0:4fe8::/45
2606:54c0:4ff0::/45
2606:54c0:4ff8::/45
2606:54c0:5000::/45
2606:54c0:5008::/45
2606:54c0:5010::/45
2606:54c3:0:1152::/64
2606:54c3:0:132a::/64
2606:54c3:0:16a0::/64
2606:54c3:0:1992::/64
2606:54c3:0:857::/64
2606:54c3:0:ac1::/64
2a02:26f7:cb00:4000::/64
2a02:26f7:cb00:400::/56
2a02:26f7:cb00:500::/58
2a02:26f7:cb00:5280::/64
2a02:26f7:cb00:5281::/64
2a02:26f7:cb00:5282::/64
2a02:26f7:cb00:5283::/64
2a02:26f7:cb00:5284::/64
2a02:26f7:cb00:5285::/64
2a02:26f7:cb00:5286::/64
2a02:26f7:cb00:5287::/64
2a02:26f7:cb00:5288::/64
2a02:26f7:cb00:5289::/64
2a02:26f7:cb00:528a::/64
2a02:26f7:cb00:528b::/64
2a02:26f7:cb00:528c::/64
2a02:26f7:cb00:528d::/64
2a02:26f7:cb00:528e::/64
2a02:26f7:cb00:528f::/64
2a02:26f7:cb00:5290::/64
2a02:26f7:cb00:5291::/64
2a02:26f7:cb00:5292::/64
2a02:26f7:cb00:5293::/64
2a02:26f7:cb00:5294::/64
2a02:26f7:cb00:5295::/64
2a02:26f7:cb00:5296::/64
2a02:26f7:cb00:5297::/64
2a02:26f7:cb00:5298::/64
2a02:26f7:cb00:5299::/64
2a02:26f7:cb00:529a::/64
2a02:26f7:cb00:529b::/64
2a02:26f7:cb00:529c::/64
2a02:26f7:cb00:529d::/64
2a02:26f7:cb00:529e::/64
2a02:26f7:cb00:529f::/64
2a02:26f7:cb00:52a0::/64
2a02:26f7:cb00:52a1::/64
2a02:26f7:cb00:52a2::/64
2a02:26f7:cb00:52a3::/64
2a02:26f7:cb00:52a4::/64
2a02:26f7:cb00:52a5::/64
2a02:26f7:cb00:52a6::/64
2a02:26f7:cb00:52a7::/64
2a02:26f7:cb00:52a8::/64
2a02:26f7:cb00:52a9::/64
2a02:26f7:cb00:52aa::/64
2a02:26f7:cb00:52ab::/64
2a02:26f7:cb00:52ac::/64
2a02:26f7:cb00:52ad::/64
2a02:26f7:cb00:52ae::/64
2a02:26f7:cb00:52af::/64
2a02:26f7:cb00:52b0::/64
2a02:26f7:cb00:52b1::/64
2a02:26f7:cb00:52b2::/64
2a02:26f7:cb00:52b3::/64
2a02:26f7:cb00:52b4::/64
2a02:26f7:cb00:52b5::/64
2a02:26f7:cb00:52b6::/64
2a02:26f7:cb00:52b7::/64
2a02:26f7:cb00:52b8::/64
2a02:26f7:cb00:52b9::/64
2a02:26f7:cb00:52ba::/64
2a02:26f7:cb00:52bb::/64
2a02:26f7:cb00:52bc::/64
2a02:26f7:cb00:52bd::/64
2a02:26f7:cb00:52be::/64
2a02:26f7:cb00:52bf::/64
2a02:26f7:cb00:52c0::/64
2a02:26f7:cb00:52c1::/64
2a02:26f7:cb00:52c2::/64
2a02:26f7:cb00:52c3::/64
2a02:26f7:cb00:52c4::/64
2a02:26f7:cb00:52c5::/64
2a02:26f7:cb00:52c6::/64
2a02:26f7:cb00:52c7::/64
2a02:26f7:cb00:52c8::/64
2a02:26f7:cb00:52c9::/64
2a02:26f7:cb00:52ca::/64
2a02:26f7:cb00:52cb::/64
2a02:26f7:cb00:52cc::/64
2a02:26f7:cb00:52cd::/64
2a02:26f7:cb00:52ce::/64
2a02:26f7:cb00:52cf::/64
2a02:26f7:cb00:52d0::/64
2a02:26f7:cb00:52d1::/64
2a02:26f7:cb00:52d2::/64
2a02:26f7:cb00:52d3::/64
2a02:26f7:cb00:52d4::/64
2a02:26f7:cb00:52d5::/64
2a02:26f7:cb00:52d6::/64
2a02:26f7:cb00:52d7::/64
2a02:26f7:cb00:52d8::/64
2a02:26f7:cb00:52d9::/64
2a02:26f7:cb00::/54
2a02:26f7:cb01:4000::/64
2a02:26f7:cb02:4000::/64
2a02:26f7:cb02:400::/56
2a02:26f7:cb02:500::/58
2a02:26f7:cb02:5280::/64
2a02:26f7:cb02:5281::/64
2a02:26f7:cb02:5282::/64
2a02:26f7:cb02:5283::/64
2a02:26f7:cb02:5284::/64
2a02:26f7:cb02:5285::/64
2a02:26f7:cb02:5286::/64
2a02:26f7:cb02:5287::/64
2a02:26f7:cb02:5288::/64
2a02:26f7:cb02:5289::/64
2a02:26f7:cb02:528a::/64
2a02:26f7:cb02:528b::/64
2a02:26f7:cb02:528c::/64
2a02:26f7:cb02:528d::/64
2a02:26f7:cb02:528e::/64
2a02:26f7:cb02:528f::/64
2a02:26f7:cb02:5290::/64
2a02:26f7:cb02:5291::/64
2a02:26f7:cb02:5292::/64
2a02:26f7:cb02:5293::/64
2a02:26f7:cb02:5294::/64
2a02:26f7:cb02:5295::/64
2a02:26f7:cb02:5296::/64
2a02:26f7:cb02:5297::/64
2a02:26f7:cb02:5298::/64
2a02:26f7:cb02:5299::/64
2a02:26f7:cb02:529a::/64
2a02:26f7:cb02:529b::/64
2a02:26f7:cb02:529c::/64
2a02:26f7:cb02:529d::/64
2a02:26f7:cb02:529e::/64
2a02:26f7:cb02:529f::/64
2a02:26f7:cb02:52a0::/64
2a02:26f7:cb02:52a1::/64
2a02:26f7:cb02:52a2::/64
2a02:26f7:cb02:52a3::/64
2a02:26f7:cb02:52a4::/64
2a02:26f7:cb02:52a5::/64
2a02:26f7:cb02:52a6::/64
2a02:26f7:cb02:52a7::/64
2a02:26f7:cb02:52a8::/64
2a02:26f7:cb02:52a9::/64
2a02:26f7:cb02:52aa::/64
2a02:26f7:cb02:52ab::/64
2a02:26f7:cb02:52ac::/64
2a02:26f7:cb02:52ad::/64
2a02:26f7:cb02:52ae::/64
2a02:26f7:cb02:52af::/64
2a02:26f7:cb02:52b0::/64
2a02:26f7:cb02:52b1::/64
2a02:26f7:cb02:52b2::/64
2a02:26f7:cb02:52b3::/64
2a02:26f7:cb02:52b4::/64
2a02:26f7:cb02:52b5::/64
2a02:26f7:cb02:52b6::/64
2a02:26f7:cb02:52b7::/64
2a02:26f7:cb02:52b8::/64
2a02:26f7:cb02:52b9::/64
2a02:26f7:cb02:52ba::/64
2a02:26f7:cb02:52bb::/64
2a02:26f7:cb02:52bc::/64
2a02:26f7:cb02:52bd::/64
2a02:26f7:cb02:52be::/64
2a02:26f7:cb02:52bf::/64
2a02:26f7:cb02:52c0::/64
2a02:26f7:cb02:52c1::/64
2a02:26f7:cb02:52c2::/64
2a02:26f7:cb02:52c3::/64
2a02:26f7:cb02:52c4::/64
2a02:26f7:cb02:52c5::/64
2a02:26f7:cb02:52c6::/64
2a02:26f7:cb02:52c7::/64
2a02:26f7:cb02:52c8::/64
2a02:26f7:cb02:52c9::/64
2a02:26f7:cb02:52ca::/64
2a02:26f7:cb02:52cb::/64
2a02:26f7:cb02:52cc::/64
2a02:26f7:cb02:52cd::/64
2a02:26f7:cb02:52ce::/64
2a02:26f7:cb02:52cf::/64
2a02:26f7:cb02:52d0::/64
2a02:26f7:cb02:52d1::/64
2a02:26f7:cb02:52d2::/64
2a02:26f7:cb02:52d3::/64
2a02:26f7:cb02:52d4::/64
2a02:26f7:cb02:52d5::/64
2a02:26f7:cb02:52d6::/64
2a02:26f7:cb02:52d7::/64
2a02:26f7:cb02:52d8::/64
2a02:26f7:cb02:52d9::/64
2a02:26f7:cb02::/54
2a02:26f7:cb03:4000::/64
2a02:26f7:cb04:4000::/64
2a02:26f7:cb04:400::/56
2a02:26f7:cb04:500::/58
2a02:26f7:cb04:5280::/64
2a02:26f7:cb04:5281::/64
2a02:26f7:cb04:5282::/64
2a02:26f7:cb04:5283::/64
2a02:26f7:cb04:5284::/64
2a02:26f7:cb04:5285::/64
2a02:26f7:cb04:5286::/64
2a02:26f7:cb04:5287::/64
2a02:26f7:cb04:5288::/64
2a02:26f7:cb04:5289::/64
2a02:26f7:cb04:528a::/64
2a02:26f7:cb04:528b::/64
2a02:26f7:cb04:528c::/64
2a02:26f7:cb04:528d::/64
2a02:26f7:cb04:528e::/64
2a02:26f7:cb04:528f::/64
2a02:26f7:cb04:5290::/64
2a02:26f7:cb04:5291::/64
2a02:26f7:cb04:5292::/64
2a02:26f7:cb04:5293::/64
2a02:26f7:cb04:5294::/64
2a02:26f7:cb04:5295::/64
2a02:26f7:cb04:5296::/64
2a02:26f7:cb04:5297::/64
2a02:26f7:cb04:5298::/64
2a02:26f7:cb04:5299::/64
2a02:26f7:cb04:529a::/64
2a02:26f7:cb04:529b::/64
2a02:26f7:cb04:529c::/64
2a02:26f7:cb04:529d::/64
2a02:26f7:cb04:529e::/64
2a02:26f7:cb04:529f::/64
2a02:26f7:cb04:52a0::/64
2a02:26f7:cb04:52a1::/64
2a02:26f7:cb04:52a2::/64
2a02:26f7:cb04:52a3::/64
2a02:26f7:cb04:52a4::/64
2a02:26f7:cb04:52a5::/64
2a02:26f7:cb04:52a6::/64
2a02:26f7:cb04:52a7::/64
2a02:26f7:cb04:52a8::/64
2a02:26f7:cb04:52a9::/64
2a02:26f7:cb04:52aa::/64
2a02:26f7:cb04:52ab::/64
2a02:26f7:cb04:52ac::/64
2a02:26f7:cb04:52ad::/64
2a02:26f7:cb04:52ae::/64
2a02:26f7:cb04:52af::/64
2a02:26f7:cb04:52b0::/64
2a02:26f7:cb04:52b1::/64
2a02:26f7:cb04:52b2::/64
2a02:26f7:cb04:52b3::/64
2a02:26f7:cb04:52b4::/64
2a02:26f7:cb04:52b5::/64
2a02:26f7:cb04:52b6::/64
2a02:26f7:cb04:52b7::/64
2a02:26f7:cb04:52b8::/64
2a02:26f7:cb04:52b9::/64
2a02:26f7:cb04:52ba::/64
2a02:26f7:cb04:52bb::/64
2a02:26f7:cb04:52bc::/64
2a02:26f7:cb04:52bd::/64
2a02:26f7:cb04:52be::/64
2a02:26f7:cb04:52bf::/64
2a02:26f7:cb04:52c0::/64
2a02:26f7:cb04:52c1::/64
2a02:26f7:cb04:52c2::/64
2a02:26f7:cb04:52c3::/64
2a02:26f7:cb04:52c4::/64
2a02:26f7:cb04:52c5::/64
2a02:26f7:cb04:52c6::/64
2a02:26f7:cb04:52c7::/64
2a02:26f7:cb04:52c8::/64
2a02:26f7:cb04:52c9::/64
2a02:26f7:cb04:52ca::/64
2a02:26f7:cb04:52cb::/64
2a02:26f7:cb04:52cc::/64
2a02:26f7:cb04:52cd::/64
2a02:26f7:cb04:52ce::/64
2a02:26f7:cb04:52cf::/64
2a02:26f7:cb04:52d0::/64
2a02:26f7:cb04:52d1::/64
2a02:26f7:cb04:52d2::/64
2a02:26f7:cb04:52d3::/64
2a02:26f7:cb04:52d4::/64
2a02:26f7:cb04:52d5::/64
2a02:26f7:cb04:52d6::/64
2a02:26f7:cb04:52d7::/64
2a02:26f7:cb04:52d8::/64
2a02:26f7:cb04:52d9::/64
2a02:26f7:cb04::/54
2a02:26f7:cb05:4000::/64
2a02:26f7:cb0c:4000::/64
2a02:26f7:cb0c:400::/56
2a02:26f7:cb0c:500::/58
2a02:26f7:cb0c:5280::/64
2a02:26f7:cb0c:5281::/64
2a02:26f7:cb0c:5282::/64
2a02:26f7:cb0c:5283::/64
2a02:26f7:cb0c:5284::/64
2a02:26f7:cb0c:5285::/64
2a02:26f7:cb0c:5286::/64
2a02:26f7:cb0c:5287::/64
2a02:26f7:cb0c:5288::/64
2a02:26f7:cb0c:5289::/64
2a02:26f7:cb0c:528a::/64
2a02:26f7:cb0c:528b::/64
2a02:26f7:cb0c:528c::/64
2a02:26f7:cb0c:528d::/64
2a02:26f7:cb0c:528e::/64
2a02:26f7:cb0c:528f::/64
2a02:26f7:cb0c:5290::/64
2a02:26f7:cb0c:5291::/64
2a02:26f7:cb0c:5292::/64
2a02:26f7:cb0c:5293::/64
2a02:26f7:cb0c:5294::/64
2a02:26f7:cb0c:5295::/64
2a02:26f7:cb0c:5296::/64
2a02:26f7:cb0c:5297::/64
2a02:26f7:cb0c:5298::/64
2a02:26f7:cb0c:5299::/64
2a02:26f7:cb0c:529a::/64
2a02:26f7:cb0c:529b::/64
2a02:26f7:cb0c:529c::/64
2a02:26f7:cb0c:529d::/64
2a02:26f7:cb0c:529e::/64
2a02:26f7:cb0c:529f::/64
2a02:26f7:cb0c:52a0::/64
2a02:26f7:cb0c:52a1::/64
2a02:26f7:cb0c:52a2::/64
2a02:26f7:cb0c:52a3::/64
2a02:26f7:cb0c:52a4::/64
2a02:26f7:cb0c:52a5::/64
2a02:26f7:cb0c:52a6::/64
2a02:26f7:cb0c:52a7::/64
2a02:26f7:cb0c:52a8::/64
2a02:26f7:cb0c:52a9::/64
2a02:26f7:cb0c:52aa::/64
2a02:26f7:cb0c:52ab::/64
2a02:26f7:cb0c:52ac::/64
2a02:26f7:cb0c:52ad::/64
2a02:26f7:cb0c:52ae::/64
2a02:26f7:cb0c:52af::/64
2a02:26f7:cb0c:52b0::/64
2a02:26f7:cb0c:52b1::/64
2a02:26f7:cb0c:52b2::/64
2a02:26f7:cb0c:52b3::/64
2a02:26f7:cb0c:52b4::/64
2a02:26f7:cb0c:52b5::/64
2a02:26f7:cb0c:52b6::/64
2a02:26f7:cb0c:52b7::/64
2a02:26f7:cb0c:52b8::/64
2a02:26f7:cb0c:52b9::/64
2a02:26f7:cb0c:52ba::/64
2a02:26f7:cb0c:52bb::/64
2a02:26f7:cb0c:52bc::/64
2a02:26f7:cb0c:52bd::/64
2a02:26f7:cb0c:52be::/64
2a02:26f7:cb0c:52bf::/64
2a02:26f7:cb0c:52c0::/64
2a02:26f7:cb0c:52c1::/64
2a02:26f7:cb0c:52c2::/64
2a02:26f7:cb0c:52c3::/64
2a02:26f7:cb0c:52c4::/64
2a02:26f7:cb0c:52c5::/64
2a02:26f7:cb0c:52c6::/64
2a02:26f7:cb0c:52c7::/64
2a02:26f7:cb0c:52c8::/64
2a02:26f7:cb0c:52c9::/64
2a02:26f7:cb0c:52ca::/64
2a02:26f7:cb0c:52cb::/64
2a02:26f7:cb0c:52cc::/64
2a02:26f7:cb0c:52cd::/64
2a02:26f7:cb0c:52ce::/64
2a02:26f7:cb0c:52cf::/64
2a02:26f7:cb0c:52d0::/64
2a02:26f7:cb0c:52d1::/64
2a02:26f7:cb0c:52d2::/64
2a02:26f7:cb0c:52d3::/64
2a02:26f7:cb0c:52d4::/64
2a02:26f7:cb0c:52d5::/64
2a02:26f7:cb0c:52d6::/64
2a02:26f7:cb0c:52d7::/64
2a02:26f7:cb0c:52d8::/64
2a02:26f7:cb0c:52d9::/64
2a02:26f7:cb0c::/54
2a02:26f7:cb0d:4000::/64
2a02:26f7:cb10:4000::/64
2a02:26f7:cb10:400::/56
2a02:26f7:cb10:500::/58
2a02:26f7:cb10:5280::/64
2a02:26f7:cb10:5281::/64
2a02:26f7:cb10:5282::/64
2a02:26f7:cb10:5283::/64
2a02:26f7:cb10:5284::/64
2a02:26f7:cb10:5285::/64
2a02:26f7:cb10:5286::/64
2a02:26f7:cb10:5287::/64
2a02:26f7:cb10:5288::/64
2a02:26f7:cb10:5289::/64
2a02:26f7:cb10:528a::/64
2a02:26f7:cb10:528b::/64
2a02:26f7:cb10:528c::/64
2a02:26f7:cb10:528d::/64
2a02:26f7:cb10:528e::/64
2a02:26f7:cb10:528f::/64
2a02:26f7:cb10:5290::/64
2a02:26f7:cb10:5291::/64
2a02:26f7:cb10:5292::/64
2a02:26f7:cb10:5293::/64
2a02:26f7:cb10:5294::/64
2a02:26f7:cb10:5295::/64
2a02:26f7:cb10:5296::/64
2a02:26f7:cb10:5297::/64
2a02:26f7:cb10:5298::/64
2a02:26f7:cb10:5299::/64
2a02:26f7:cb10:529a::/64
2a02:26f7:cb10:529b::/64
2a02:26f7:cb10:529c::/64
2a02:26f7:cb10:529d::/64
2a02:26f7:cb10:529e::/64
2a02:26f7:cb10:529f::/64
2a02:26f7:cb10:52a0::/64
2a02:26f7:cb10:52a1::/64
2a02:26f7:cb10:52a2::/64
2a02:26f7:cb10:52a3::/64
2a02:26f7:cb10:52a4::/64
2a02:26f7:cb10:52a5::/64
2a02:26f7:cb10:52a6::/64
2a02:26f7:cb10:52a7::/64
2a02:26f7:cb10:52a8::/64
2a02:26f7:cb10:52a9::/64
2a02:26f7:cb10:52aa::/64
2a02:26f7:cb10:52ab::/64
2a02:26f7:cb10:52ac::/64
2a02:26f7:cb10:52ad::/64
2a02:26f7:cb10:52ae::/64
2a02:26f7:cb10:52af::/64
2a02:26f7:cb10:52b0::/64
2a02:26f7:cb10:52b1::/64
2a02:26f7:cb10:52b2::/64
2a02:26f7:cb10:52b3::/64
2a02:26f7:cb10:52b4::/64
2a02:26f7:cb10:52b5::/64
2a02:26f7:cb10:52b6::/64
2a02:26f7:cb10:52b7::/64
2a02:26f7:cb10:52b8::/64
2a02:26f7:cb10:52b9::/64
2a02:26f7:cb10:52ba::/64
2a02:26f7:cb10:52bb::/64
2a02:26f7:cb10:52bc::/64
2a02:26f7:cb10:52bd::/64
2a02:26f7:cb10:52be::/64
2a02:26f7:cb10:52bf::/64
2a02:26f7:cb10:52c0::/64
2a02:26f7:cb10:52c1::/64
2a02:26f7:cb10:52c2::/64
2a02:26f7:cb10:52c3::/64
2a02:26f7:cb10:52c4::/64
2a02:26f7:cb10:52c5::/64
2a02:26f7:cb10:52c6::/64
2a02:26f7:cb10:52c7::/64
2a02:26f7:cb10:52c8::/64
2a02:26f7:cb10:52c9::/64
2a02:26f7:cb10:52ca::/64
2a02:26f7:cb10:52cb::/64
2a02:26f7:cb10:52cc::/64
2a02:26f7:cb10:52cd::/64
2a02:26f7:cb10:52ce::/64
2a02:26f7:cb10:52cf::/64
2a02:26f7:cb10:52d0::/64
2a02:26f7:cb10:52d1::/64
2a02:26f7:cb10:52d2::/64
2a02:26f7:cb10:52d3::/64
2a02:26f7:cb10:52d4::/64
2a02:26f7:cb10:52d5::/64
2a02:26f7:cb10:52d6::/64
2a02:26f7:cb10:52d7::/64
2a02:26f7:cb10:52d8::/64
2a02:26f7:cb10:52d9::/64
2a02:26f7:cb10::/54
2a02:26f7:cb11:4000::/64
2a02:26f7:ed00:4000::/64
2a02:26f7:ed00:cbc0::/64
2a02:26f7:ed00:cbc1::/64
2a02:26f7:ed01:4000::/64
2a02:26f7:ed02:4000::/64
2a02:26f7:ed02:cbc0::/64
2a02:26f7:ed02:cbc1::/64
2a02:26f7:ed03:4000::/64
2a02:26f7:ed04:4000::/64
2a02:26f7:ed04:cbc0::/64
2a02:26f7:ed04:cbc1::/64
2a02:26f7:ed05:4000::/64
2a02:26f7:ed0c:4000::/64
2a02:26f7:ed0c:cbc0::/64
2a02:26f7:ed0c:cbc1::/64
2a02:26f7:ed0d:4000::/64
2a04:4e41:1303:5000::/52
2a04:4e41:1303:6000::/52
2a04:4e41:1303:7000::/52
2a04:4e41:1324::/48
2a04:4e41:1603:6000::/52
2a04:4e41:1603:7000::/52
2a04:4e41:1603:8000::/52
2a04:4e41:161a::/48
2a04:4e41:4100:8000::/52
2a04:4e41:4100:9000::/52
2a04:4e41:4100:a000::/52
2a04:4e41:4112::/48
2a04:4e41:7403:6000::/52
2a04:4e41:7403:7000::/52
2a04:4e41:7403:8000::/52
2a04:4e41:741a::/48
2a04:4e41:7800:8000::/52
2a04:4e41:7800:9000::/52
2a04:4e41:7800:a000::/52
2a04:4e41:7812::/48
2a09:bac2:4fe8::/45
2a09:bac2:4ff0::/45
2a09:bac2:4ff8::/45
2a09:bac2:5000::/45
2a09:bac2:5008::/45
2a09:bac2:5010::/45
2a09:bac3:4fe8::/45
2a09:bac3:4ff0::/45
2a09:bac3:4ff8::/45
2a09:bac3:5000::/45
2a09:bac3:5008::/45
2a09:bac3:5010::/45
@@ -1,25 +0,0 @@
<?php
// ─────────────────────────────────────────────────────────────────────────────
// A mesma bolha, em forma de ligação de rodapé — para o site público
//
// include __DIR__ . '/../shared/platform-tools/feedback_footer_link.php';
//
// Põe-se DENTRO do <footer>, na linha onde a ligação deve aparecer (e não antes
// do </body>, como a bolha do backoffice: aqui o sítio no HTML é o sítio no
// ecrã).
//
// Existe como ficheiro próprio, e não como uma variável a definir no site,
// porque a escolha entre bolha e ligação é uma decisão deste módulo e não de
// cada rodapé que o chama: no canto inferior direito do site público já mora o
// botão do WhatsApp, e duas bolhas empilhadas no mesmo canto são duas coisas a
// disputar o mesmo clique — sendo que a que interessa ao visitante é a outra.
// Assim o site continua a ser uma linha, igual à do backoffice, e o dia em que
// esta decisão mudar muda aqui e em toda a frota de uma vez.
//
// Tudo o resto — HTML, CSS, JavaScript, permissões, envio — está no
// feedback_widget.php e no functions_feedback.php.
// ─────────────────────────────────────────────────────────────────────────────
$GLOBALS['platform_feedback_mode'] = 'footer';
require __DIR__ . '/feedback_widget.php';
-479
View File
@@ -1,479 +0,0 @@
<?php
// ─────────────────────────────────────────────────────────────────────────────
// A bolha de feedback — incluída pelo rodapé de cada site
//
// include __DIR__ . '/../shared/platform-tools/feedback_widget.php';
//
// Põe-se no rodapé do layout (includes/footer.php, e o rodapé do site público
// onde exista), imediatamente antes do </body>. Aparece só a quem é
// administrador — para os restantes, e para visitantes, não sai daqui uma
// única linha de HTML.
//
// Não depende de nada: nem do Bootstrap (JS ou CSS), nem de ícones, nem de
// jQuery. É um ficheiro incluído no fim de páginas que já são pesadas, e a
// alternativa — assumir que o Bootstrap está carregado — partia-se no primeiro
// site da frota que o não tivesse. O ícone é SVG inline pela mesma razão.
//
// O z-index (1035) é deliberadamente ABAIXO do backdrop dos modais do
// Bootstrap (1050): com um modal aberto a bolha desaparece por baixo dele, em
// vez de ficar a flutuar por cima de um diálogo que pediu a atenção toda.
//
// ─── Dois modos ──────────────────────────────────────────────────────────────
//
// $GLOBALS['platform_feedback_mode'] = 'footer'; // antes do include
//
// 'bubble' (por omissão) é a bolha a flutuar no canto — para o backoffice, onde
// o canto está livre e quem trabalha quer o atalho sempre à vista.
//
// 'footer' é uma ligação dentro do rodapé, sem nada a flutuar — para o site
// público, onde o canto inferior direito JÁ ESTÁ OCUPADO: o dance tem lá o
// botão do WhatsApp (e uma barra fixa em telemóvel). Duas bolhas empilhadas no
// mesmo canto são duas coisas a disputar o mesmo clique, e a que interessa ao
// visitante é a outra. O painel continua a abrir fixo no canto, por cima de
// tudo isso — está aberto por um instante e tem botão de fechar.
//
// Ver functions_feedback.php para o que é enviado, e api/feedback.php para
// quem o recebe.
// ─────────────────────────────────────────────────────────────────────────────
require_once __DIR__ . '/functions_feedback.php';
require_once __DIR__ . '/functions_csrf.php';
(static function (): void {
// Uma vez por resposta. O rodapé do backoffice e o do site público podem
// acabar ambos incluídos na mesma página, e dois formulários com os mesmos
// ids não é meio widget a mais: é um widget partido.
if (!empty($GLOBALS['__platform_feedback_rendered'])) {
return;
}
$user = platform_feedback_user();
if (!$user || !platform_feedback_is_admin($user)) {
return;
}
$ligado = hub_ticket_ready();
// Sem emparelhamento não há para onde enviar. Ao administrador não se
// mostra nada — não é ele que resolve isto, e um botão que falha é pior do
// que botão nenhum. Ao sysadmin mostra-se, porque é ele que o liga: um
// canal de relatos que desaparece em silêncio quando a chave roda perde
// exactamente os relatos que mais interessavam.
if (!$ligado && !platform_feedback_is_sysadmin($user)) {
return;
}
$GLOBALS['__platform_feedback_rendered'] = true;
$h = static fn(string $s): string => htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
$cats = platform_feedback_categories();
// Caminho absoluto, e não base_url(): o site público da fotografia não
// carrega o config/functions.php, e ali base_url() não existe (ver
// ASSETS.md). O docroot é a raiz do domínio em toda a frota.
$endpoint = '/api/feedback.php';
// A página onde o sysadmin liga isto. Cada site tem a sua árvore de vistas,
// daí poder ser sobreposta pelo layout antes do include.
// Num site com router (platform_connect(['router' => true])) é o endereço
// dele; o stub antigo só reencaminha.
$hubPage = (string) ($GLOBALS['platform_feedback_hub_url']
?? (function_exists('platform_router_enabled') && platform_router_enabled()
? platform_route_url('hub_integration')
: '/views/sysadmin/hub_integration.php'));
$inline = ($GLOBALS['platform_feedback_mode'] ?? 'bubble') === 'footer';
?>
<div class="ptfb<?= $inline ? ' ptfb--inline' : '' ?>" id="ptfb" data-endpoint="<?= $h($endpoint) ?>">
<div class="ptfb-panel" id="ptfb-panel" role="dialog" aria-labelledby="ptfb-title" hidden>
<div class="ptfb-head">
<div>
<p class="ptfb-title" id="ptfb-title">Reportar à andmore</p>
<p class="ptfb-sub">Alguma coisa errada na plataforma? Conta aqui.</p>
</div>
<button type="button" class="ptfb-x" id="ptfb-close" aria-label="Fechar">&times;</button>
</div>
<?php if (!$ligado): ?>
<div class="ptfb-body">
<p class="ptfb-warn">
Esta plataforma não está ligada ao balcão de apoio — nada seria enviado.
Só tu vês esta bolha.
</p>
<p class="ptfb-warn-more">
Liga-a em <a href="<?= $h($hubPage) ?>">Integração com o hub</a>: falta o
endereço do hub, a chave de emparelhamento, ou ambos.
</p>
</div>
<?php else: ?>
<form class="ptfb-body" id="ptfb-form" novalidate>
<input type="hidden" name="_pt_csrf" value="<?= $h(platform_csrf_token()) ?>">
<input type="hidden" name="page_url" id="ptfb-page">
<label class="ptfb-label" for="ptfb-cat">Tipo</label>
<select class="ptfb-input" name="category" id="ptfb-cat">
<?php foreach ($cats as $key => $label): ?>
<option value="<?= $h((string) $key) ?>"><?= $h((string) $label) ?></option>
<?php endforeach; ?>
</select>
<label class="ptfb-label" for="ptfb-subject">Assunto</label>
<input type="text" class="ptfb-input" name="title" id="ptfb-subject"
maxlength="150" placeholder="Numa linha, o que se passa">
<label class="ptfb-label" for="ptfb-desc">O que aconteceu</label>
<textarea class="ptfb-input ptfb-area" name="description" id="ptfb-desc" rows="5"
maxlength="5000"
placeholder="O que estavas a fazer, o que esperavas, e o que aconteceu."></textarea>
<label class="ptfb-check">
<input type="checkbox" name="blocking" value="1" id="ptfb-block">
<span>Está a impedir-me de trabalhar</span>
</label>
<p class="ptfb-note">
Vai junto o contexto: a página onde estás, a versão da plataforma, a tua
conta e o browser. Não precisas de escrever nada disso.
</p>
<button type="submit" class="ptfb-send" id="ptfb-send">Enviar</button>
<p class="ptfb-msg" id="ptfb-msg" role="status" hidden></p>
</form>
<?php endif; ?>
</div>
<?php if ($inline): ?>
<button type="button" class="ptfb-linkbtn" id="ptfb-open"
aria-expanded="false" aria-controls="ptfb-panel">
<svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true" focusable="false">
<path fill="currentColor" d="M20 2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h4v4l5-4h7a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2z"/>
</svg>
Reportar um problema
</button>
<?php else: ?>
<button type="button" class="ptfb-bubble" id="ptfb-open"
aria-expanded="false" aria-controls="ptfb-panel"
title="Reportar à andmore">
<svg viewBox="0 0 24 24" width="26" height="26" aria-hidden="true" focusable="false">
<path fill="currentColor" d="M20 2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h4v4l5-4h7a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2z"/>
</svg>
<span class="ptfb-sr">Reportar à andmore</span>
</button>
<?php endif; ?>
</div>
<style>
.ptfb {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 1035; /* ver a nota no topo do ficheiro */
display: flex;
flex-direction: column;
align-items: flex-end;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
line-height: 1.45;
}
.ptfb-sr {
position: absolute; width: 1px; height: 1px;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap;
}
.ptfb-bubble {
display: flex; align-items: center; justify-content: center;
width: 52px; height: 52px;
border: none;
border-radius: 50%;
background: #1f2937;
color: #fff;
box-shadow: 0 6px 20px rgba(0, 0, 0, .28);
cursor: pointer;
transition: transform .15s ease, background-color .15s ease;
}
.ptfb-bubble:hover { background: #111827; transform: translateY(-2px); }
.ptfb-bubble:focus-visible { outline: 3px solid #60a5fa; outline-offset: 2px; }
.ptfb.is-open .ptfb-bubble { background: #111827; }
.ptfb-panel {
width: 340px;
max-width: calc(100vw - 36px);
/* Num portátil com o browser a meia altura o painel passava do ecrã e o
botão de enviar ficava fora de alcance. */
max-height: calc(100vh - 36px);
overflow-y: auto;
margin-bottom: 12px;
background: #fff;
color: #1f2937;
border: 1px solid rgba(0, 0, 0, .12);
border-radius: 14px;
box-shadow: 0 18px 48px rgba(0, 0, 0, .28);
overflow: hidden;
text-align: left;
}
.ptfb-panel[hidden] { display: none; }
.ptfb-head {
display: flex; align-items: flex-start; gap: .5rem;
padding: .85rem 1rem;
background: #1f2937;
color: #fff;
}
.ptfb-title { margin: 0; font-size: .95rem; font-weight: 600; }
.ptfb-sub { margin: .1rem 0 0; font-size: .78rem; opacity: .75; }
.ptfb-x {
margin-left: auto;
background: none; border: none; color: #fff;
font-size: 1.4rem; line-height: 1;
padding: 0 .1rem; cursor: pointer; opacity: .8;
}
.ptfb-x:hover { opacity: 1; }
.ptfb-body { padding: .9rem 1rem 1rem; }
.ptfb-label {
display: block;
margin: .6rem 0 .25rem;
font-size: .72rem; font-weight: 600;
letter-spacing: .04em; text-transform: uppercase;
color: #6b7280;
}
.ptfb-label:first-of-type { margin-top: 0; }
.ptfb-input {
display: block; width: 100%;
box-sizing: border-box;
padding: .5rem .6rem;
font: inherit; font-size: .88rem;
color: #1f2937;
background: #fff;
border: 1px solid #d1d5db;
border-radius: 8px;
}
.ptfb-input:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, .18);
}
.ptfb-area { resize: vertical; min-height: 92px; }
.ptfb-check {
display: flex; align-items: center; gap: .45rem;
margin: .7rem 0 0;
font-size: .83rem;
cursor: pointer;
}
.ptfb-note {
margin: .7rem 0 .85rem;
font-size: .74rem;
color: #6b7280;
}
.ptfb-send {
width: 100%;
padding: .6rem 1rem;
font: inherit; font-size: .88rem; font-weight: 600;
color: #fff; background: #2563eb;
border: none; border-radius: 8px;
cursor: pointer;
}
.ptfb-send:hover:not(:disabled) { background: #1d4ed8; }
.ptfb-send:disabled { opacity: .6; cursor: default; }
.ptfb-msg {
margin: .7rem 0 0;
padding: .55rem .7rem;
font-size: .82rem;
border-radius: 8px;
}
.ptfb-msg[hidden] { display: none; }
.ptfb-msg.is-ok { background: #ecfdf5; color: #065f46; border: 1px solid #a7f3d0; }
.ptfb-msg.is-bad { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }
.ptfb-msg a { color: inherit; }
.ptfb-warn { margin: 0; font-size: .85rem; }
.ptfb-warn-more { margin: .6rem 0 0; font-size: .8rem; color: #6b7280; }
@media (max-width: 480px) {
.ptfb { right: 12px; bottom: 12px; left: 12px; align-items: stretch; }
.ptfb-panel { width: 100%; max-width: none; }
.ptfb-bubble { margin-left: auto; }
}
@media (prefers-reduced-motion: reduce) {
.ptfb-bubble { transition: none; }
}
/* ─── Modo rodapé ───────────────────────────────────────────────────────────
Nada a flutuar: fica a ligação na linha onde o include foi posto, a herdar
a cor e o tipo de letra do rodapé que a recebe — o da fotografia é escuro, o
do dance é claro, e um botão com cores próprias destoava num dos dois. O
painel continua fixo no canto, e vem depois destas regras no ficheiro
precisamente para ganhar às de cima. */
.ptfb--inline {
position: static;
display: inline-flex;
align-items: center;
z-index: auto;
font-family: inherit;
}
.ptfb-linkbtn {
display: inline-flex; align-items: center; gap: .4rem;
margin: 0; padding: 0;
border: none; background: none;
color: inherit; font: inherit; font-size: .85em;
line-height: inherit;
opacity: .75;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 3px;
}
.ptfb-linkbtn:hover { opacity: 1; }
.ptfb-linkbtn:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; }
.ptfb-linkbtn svg { flex: 0 0 auto; }
/* Cor medida, não herdada — ver a nota no JS. Só entram quando o script
conseguiu medir o fundo; se não conseguiu, fica o `color: inherit` de cima. */
.ptfb-on-dark .ptfb-linkbtn { color: rgba(255, 255, 255, .62); opacity: 1; }
.ptfb-on-dark .ptfb-linkbtn:hover { color: #fff; }
.ptfb-on-light .ptfb-linkbtn { color: rgba(0, 0, 0, .66); opacity: 1; }
.ptfb-on-light .ptfb-linkbtn:hover { color: #000; }
.ptfb--inline .ptfb-panel {
position: fixed;
right: 18px;
bottom: 18px;
margin-bottom: 0;
z-index: 1035;
/* O `font-family: inherit` acima é para a LIGAÇÃO se fundir no rodapé; o
painel é um formulário e volta ao tipo de letra do sistema. Sem isto, o
rodapé da fotografia (Georgia) punha um formulário inteiro em serifada. */
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
line-height: 1.45;
}
@media (max-width: 480px) {
.ptfb--inline { left: auto; right: auto; bottom: auto; }
.ptfb--inline .ptfb-panel { left: 12px; right: 12px; bottom: 12px; width: auto; }
}
</style>
<script>
(function () {
var root = document.getElementById('ptfb');
if (!root) return;
var openBtn = document.getElementById('ptfb-open');
var panel = document.getElementById('ptfb-panel');
var closeBtn = document.getElementById('ptfb-close');
var form = document.getElementById('ptfb-form');
/* ─── A cor da ligação do rodapé é medida, não herdada ──────────────────
`color: inherit` assume que o rodapé define uma cor legível para si
próprio. Há rodapés que não definem nenhuma e colorem os filhos um a um:
o do dance tem `background: var(--text)` (#1a1a1a) e cor herdada #1a1a1a,
o que dava uma ligação invisível — contraste 1:1.
Em vez de adivinhar a paleta de cada site da frota, procura-se o primeiro
antepassado com fundo opaco e escolhe-se claro ou escuro pela luminância.
Não se encontrando nenhum, não se toca em nada e fica o herdado. */
function tuneContrast() {
if (!root.classList.contains('ptfb--inline')) return;
var el = root, rgb = null;
while (el && el !== document.documentElement && !rgb) {
var m = getComputedStyle(el).backgroundColor.match(/rgba?\(([\d.,\s]+)\)/);
if (m) {
var p = m[1].split(',').map(parseFloat);
if (p.length < 4 || p[3] > 0.5) rgb = p;
}
el = el.parentElement;
}
if (!rgb) return;
// Luminância aproximada (sem correcção gama): chega de sobra para decidir
// entre texto claro e texto escuro.
var lum = (0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]) / 255;
root.classList.remove('ptfb-on-dark', 'ptfb-on-light');
root.classList.add(lum < 0.5 ? 'ptfb-on-dark' : 'ptfb-on-light');
}
tuneContrast();
// Outra vez com tudo carregado: um site que sirva a folha de estilos no fim
// do body ainda não a tinha aplicada na primeira medição.
window.addEventListener('load', tuneContrast);
function open() {
panel.hidden = false;
root.classList.add('is-open');
openBtn.setAttribute('aria-expanded', 'true');
var first = panel.querySelector('select, input[type=text], a');
if (first) first.focus();
}
function close() {
panel.hidden = true;
root.classList.remove('is-open');
openBtn.setAttribute('aria-expanded', 'false');
openBtn.focus();
}
openBtn.addEventListener('click', function () { panel.hidden ? open() : close(); });
closeBtn.addEventListener('click', close);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && !panel.hidden) close();
});
if (!form) return; // painel de aviso: não há formulário para submeter
var msg = document.getElementById('ptfb-msg');
var send = document.getElementById('ptfb-send');
// O URL da página é lido aqui e não no servidor: o rodapé pode ser incluído
// por uma página que não é a que o browser mostra na barra de endereço.
document.getElementById('ptfb-page').value = window.location.href;
// Texto por textContent e a ligação como elemento: a referência e o URL vêm
// de outro serviço, e nada do que vem de fora entra por innerHTML.
function say(text, ok, link) {
msg.className = 'ptfb-msg ' + (ok ? 'is-ok' : 'is-bad');
msg.textContent = text;
if (link && /^https?:\/\//i.test(link)) {
var a = document.createElement('a');
a.href = link;
a.target = '_blank';
a.rel = 'noopener';
a.textContent = 'Acompanhar';
msg.appendChild(document.createTextNode(' '));
msg.appendChild(a);
}
msg.hidden = false;
}
form.addEventListener('submit', function (e) {
e.preventDefault();
send.disabled = true;
send.textContent = 'A enviar…';
msg.hidden = true;
fetch(root.dataset.endpoint, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Accept': 'application/json' },
body: new FormData(form)
})
.then(function (r) {
return r.json().catch(function () {
return { ok: false, error: 'O servidor respondeu de forma inesperada (HTTP ' + r.status + ').' };
});
})
.then(function (res) {
if (!res.ok) {
say(res.error || 'Não foi possível enviar.', false);
return;
}
say('Enviado. Ficou como ' + (res.ref || '—') + '.', true, res.track_url);
document.getElementById('ptfb-subject').value = '';
document.getElementById('ptfb-desc').value = '';
document.getElementById('ptfb-block').checked = false;
})
.catch(function () {
say('Sem ligação ao servidor. Tenta outra vez daqui a bocado.', false);
})
.finally(function () {
send.disabled = false;
send.textContent = 'Enviar';
});
});
})();
</script>
<?php
})();
-226
View File
@@ -1,226 +0,0 @@
<?php
// ─── Remote-ops agent (shared) ───────────────────────────────────────────────
//
// Server-to-server endpoint the id.andmore.pt fleet control plane drives to read
// status and (later phases) pull git / apply migrations / take backups remotely.
// No session, no CSRF — authenticated purely by HMAC over the shared secret.
//
// The hub signs each request with this platform's HUB_OPS_SECRET (.env; generated
// on id.andmore.pt → Plataformas → <app> → "Gerar segredo de operações").
// Canonical signing string binds the FULL request (this channel mutates, unlike
// the read-only content pull which signs only date.path):
//
// signing = method "\n" path "\n" sha256(body) "\n" date
// X-Hub-Date : ISO-8601 UTC timestamp (±5 min tolerance)
// X-Hub-Token : hash_hmac('sha256', signing, HUB_OPS_SECRET)
//
// Everything here is best-effort and guarded with function_exists(), so a host
// that wires only some of the platform-tools helpers (e.g. surf, no backups)
// still answers a partial, well-formed status.
function agent_json(array $data, int $code = 200): void {
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
/**
* Verify the request HMAC. On success returns the raw request body (already read
* from php://input, so callers must use the returned string, not re-read it).
* On any failure emits 401/503 and exits.
*/
function agent_verify_or_die(string $secret): string {
if ($secret === '') {
agent_json(['error' => 'HUB_OPS_SECRET não configurado nesta plataforma.'], 503);
}
$method = (string) ($_SERVER['REQUEST_METHOD'] ?? 'GET');
$date = (string) ($_SERVER['HTTP_X_HUB_DATE'] ?? '');
$token = (string) ($_SERVER['HTTP_X_HUB_TOKEN'] ?? '');
$path = (string) (parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?? '');
$body = (string) file_get_contents('php://input');
$ts = $date !== '' ? strtotime($date) : false;
if ($token === '' || $ts === false || abs(time() - $ts) > 300) {
agent_json(['error' => 'Assinatura ausente ou expirada.'], 401);
}
$signing = $method . "\n" . $path . "\n" . hash('sha256', $body) . "\n" . $date;
$expected = hash_hmac('sha256', $signing, $secret);
if (!hash_equals($expected, $token)) {
agent_json(['error' => 'Assinatura inválida.'], 401);
}
return $body;
}
/**
* Git facts read OFFLINE (no network) — `behind` reflects the last-known remote
* tracking ref. Refresh it with the git.fetch action before trusting it.
* Returns null when git is unavailable.
*/
function agent_git_info(string $repo_root): ?array {
if (!function_exists('git_run')) return null;
$branch = git_run(['rev-parse', '--abbrev-ref', 'HEAD'], $repo_root);
$head = git_run(['log', '-1', '--format=%h|%cd|%s', '--date=short'], $repo_root);
$dirty = git_run(['status', '--porcelain'], $repo_root);
$count = git_run(['rev-list', '--left-right', '--count', 'HEAD...@{u}'], $repo_root);
$ahead = $behind = null;
if ($count['ok'] && preg_match('/^(\d+)\s+(\d+)/', $count['output'], $m)) {
$ahead = (int) $m[1]; // local commits not pushed
$behind = (int) $m[2]; // remote commits not pulled → "atrasado"
}
return [
'branch' => $branch['ok'] ? trim($branch['output']) : null,
'head' => $head['ok'] ? trim($head['output']) : null,
'dirty' => $dirty['ok'] ? (trim($dirty['output']) !== '') : null,
'ahead' => $ahead,
'behind' => $behind,
];
}
/**
* Assemble the platform health snapshot. Each section is independently guarded:
* a missing helper or a failing query yields a null field, never a blank reply.
*/
function agent_status(PDO $pdo, string $repo_root): array {
$out = ['ok' => true, 'checked_at' => gmdate('c'), 'php_version' => PHP_VERSION];
$backup_dir = function_exists('backup_dir') ? backup_dir() : ($repo_root . '/backups');
$free = @disk_free_space($backup_dir);
$out['disk_free_bytes'] = ($free !== false) ? (int) $free : null;
$git = agent_git_info($repo_root);
if ($git !== null) $out['git'] = $git;
// ── Migrations pending ───────────────────────────────────────────────────
if (function_exists('migrations_status')) {
try {
$pending = [];
foreach (migrations_status($pdo) as $r) {
if (($r['applied_at'] ?? null) === null) {
$pending[] = $r['filename'];
}
}
$out['migrations_pending'] = count($pending);
$out['migrations_pending_files'] = $pending;
} catch (Throwable $e) {
$out['migrations_pending'] = null;
}
}
// ── Schema drift (committed schema.sql vs a fresh build) ─────────────────
if (function_exists('schema_build') && function_exists('schema_dump_path')) {
try {
$live = schema_build($pdo);
$file = @file_get_contents(schema_dump_path());
$out['schema_drift'] = ($file === false) ? null : (trim($live) !== trim($file));
} catch (Throwable $e) {
$out['schema_drift'] = null;
}
}
// ── Last backup ──────────────────────────────────────────────────────────
if (function_exists('backup_list')) {
try {
$list = backup_list();
$out['backup_count'] = count($list);
$out['last_backup'] = $list ? [
'filename' => $list[0]['filename'],
'type' => $list[0]['type'],
'created_at' => gmdate('c', $list[0]['created_at']),
'size_human' => $list[0]['size_human'],
] : null;
} catch (Throwable $e) {
$out['last_backup'] = null;
}
}
return $out;
}
/**
* Verify + dispatch a remote-ops request. The calling stub bootstraps the host
* its own way (config/ stack vs surf's src/ stack), opens $pdo, sets $repo_root
* and reads HUB_OPS_SECRET, then hands off here. Keeping the dispatch out of
* api/agent.php lets a non-config/ host (surf) reuse the exact same action set.
*
* Loads the sibling helpers it needs (git/migrations/schema/backup) from the
* submodule root, so the stub doesn't have to. Never returns — every branch
* ends in agent_json(), which exits.
*/
function agent_dispatch(PDO $pdo, string $repo_root, string $secret): void {
require_once __DIR__ . '/functions_git.php';
require_once __DIR__ . '/functions_migrations.php';
require_once __DIR__ . '/functions_schema.php';
if (is_file(__DIR__ . '/functions_backup.php')) {
require_once __DIR__ . '/functions_backup.php';
}
$body = agent_verify_or_die($secret);
@session_write_close(); // server-to-server; release the session lock past auth
$req = json_decode($body, true);
$action = is_array($req) ? (string) ($req['action'] ?? '') : (string) ($_GET['action'] ?? '');
switch ($action) {
case 'status':
agent_json(agent_status($pdo, $repo_root));
// no break — agent_json() exits
case 'git.fetch':
$res = git_run(['fetch', '--all', '--prune'], $repo_root);
agent_json([
'ok' => $res['ok'],
'output' => $res['output'] !== '' ? $res['output'] : '(sem alterações remotas)',
'git' => agent_git_info($repo_root),
]);
case 'git.pull':
$pull = git_run(['pull', '--ff-only'], $repo_root);
$ok = $pull['ok'];
$output = $pull['output'] !== '' ? $pull['output'] : '(sem saída)';
if ($ok) {
$sub = git_run(['submodule', 'update', '--init', '--recursive'], $repo_root);
if (trim($sub['output']) !== '') $output .= "\n\n[submodule update]\n" . $sub['output'];
if (function_exists('opcache_reset')) @opcache_reset();
if (function_exists('settings_cache_clear')) settings_cache_clear();
$output .= "\n\n(OPcache + settings cache invalidados)";
}
agent_json(['ok' => $ok, 'output' => $output, 'git' => agent_git_info($repo_root)]);
case 'migrations.list':
agent_json(['ok' => true, 'migrations' => migrations_status($pdo)]);
case 'migrations.apply':
// migrations_apply_file() enforces the allowlist: only an existing
// database/migration_*.sql in this repo (no raw SQL — locked decision).
$file = is_array($req) ? (string) ($req['filename'] ?? '') : '';
$res = migrations_apply_file($pdo, $file, null);
if (!empty($res['ok']) && function_exists('settings_cache_clear')) settings_cache_clear();
agent_json(['ok' => !empty($res['ok'])] + $res);
case 'migrations.apply_pending':
$applied = [];
$error = null;
foreach (migrations_status($pdo) as $row) {
if ($row['applied_at']) continue;
$r = migrations_apply_file($pdo, $row['filename'], null);
if (!empty($r['ok'])) {
$applied[] = $row['filename'];
} else {
$error = $row['filename'] . ': ' . ($r['error'] ?? 'erro');
break; // stop on first failure — manual intervention needed
}
}
if ($applied && function_exists('settings_cache_clear')) settings_cache_clear();
agent_json(['ok' => $error === null, 'applied' => $applied, 'error' => $error]);
// Fase 3: 'backup.trigger', 'backup.list', 'backup.fetch' (signed download URL)
default:
agent_json(['error' => 'Ação desconhecida: ' . $action], 400);
}
}
@@ -1,274 +0,0 @@
<?php
require_once __DIR__ . '/functions_settings.php';
// ─── Anti-spam de formulários públicos (partilhado) ─────────────────────────
//
// Sem captcha e sem serviço externo — o objectivo é travar bots automáticos,
// não pessoas.
//
// Nasceu na fotografia (pedidos de orçamento), foi refinado no hub (tickets) e
// vive agora aqui, porque a terceira cópia seria a que ninguém se lembraria de
// corrigir. O que era específico do hub — o limite de 24 h contado sobre a
// tabela `tickets` — passou a receber a tabela e as colunas como argumentos.
//
// O que NÃO vem daqui: a escalada de bloqueio de IP, que vive no
// functions_ip_ban.php. São coisas diferentes e de propósito: isto decide se
// esta submissão passa, aquilo lembra-se de quem falha sempre.
//
// Cinco camadas independentes, aplicadas por esta ordem:
// 1. honeypot — campo escondido que humanos deixam vazio
// 2. time-trap assinado — rápido demais ou velho demais == bot
// 3. blocklist — email/domínio/IP geridos pelo staff
// 4. heurística de texto — links a mais / BBCode / HTML
// 5. rate limit deslizante 24 h por email e por IP
//
// Cada site pode redefinir os limites antes de incluir este ficheiro: as
// constantes só são declaradas se ainda não existirem.
if (!defined('ANTISPAM_HONEYPOT')) define('ANTISPAM_HONEYPOT', 'website');
if (!defined('ANTISPAM_MIN_SECONDS')) define('ANTISPAM_MIN_SECONDS', 4);
if (!defined('ANTISPAM_MAX_SECONDS')) define('ANTISPAM_MAX_SECONDS', 7200);
if (!defined('ANTISPAM_MAX_PER_EMAIL_24H')) define('ANTISPAM_MAX_PER_EMAIL_24H', 3);
if (!defined('ANTISPAM_MAX_PER_IP_24H')) define('ANTISPAM_MAX_PER_IP_24H', 6);
/**
* Chave HMAC do time-trap. Prefere um segredo só do servidor; cai para o
* APP_BASE_URL para o formulário nunca deixar de funcionar num install novo.
*/
function antispam_secret(): string
{
foreach (['ANTISPAM_SECRET', 'APP_SECRET_KEY', 'CRON_TOKEN'] as $key) {
$v = (string) env($key, '');
if ($v !== '') {
return $v;
}
}
$base = (string) env('APP_BASE_URL', '');
return $base !== '' ? $base : 'andmore-antispam';
}
/** "<timestamp>.<hmac>" para embeber num campo escondido no render. */
function antispam_token(): string
{
$ts = (string) time();
return $ts . '.' . hash_hmac('sha256', $ts, antispam_secret());
}
/** Os dois campos escondidos. Colar em qualquer formulário público. */
function antispam_fields(): string
{
$hp = htmlspecialchars(ANTISPAM_HONEYPOT, ENT_QUOTES);
$token = htmlspecialchars(antispam_token(), ENT_QUOTES);
// Escondido fora do ecrã (não display:none, que alguns bots ignoram) e fora
// da ordem de tabulação/autofill, para nenhum utilizador real lá chegar.
return '<div style="position:absolute;left:-9999px;top:-9999px;" aria-hidden="true">'
. '<label>Não preencher<input type="text" name="' . $hp . '"'
. ' tabindex="-1" autocomplete="off" value=""></label></div>'
. '<input type="hidden" name="antispam_token" value="' . $token . '">';
}
/**
* Verifica honeypot + time-trap. Em caso de falha, $reason fica com
* 'honeypot' | 'toofast' | 'stale' | 'badtoken' para registo.
*/
/**
* @param int $minSeconds Submetido mais depressa do que isto == bot.
* @param int $maxSeconds Idade máxima do carimbo; 0 desliga o limite superior.
*
* Os dois são parâmetros e não constantes porque o formulário certo depende do
* ecrã. Num pedido de orçamento, 4 s de mínimo e 2 h de validade são acertados.
* Num LOGIN não são: um gestor de passwords preenche e submete num instante
* (seria recusado), e uma página de login deixada aberta num separador durante
* a tarde ficaria "velha" (também recusada) — em ambos os casos recusaríamos
* exactamente a pessoa certa.
*/
function antispam_check(
array $post,
?string &$reason = null,
int $minSeconds = ANTISPAM_MIN_SECONDS,
int $maxSeconds = ANTISPAM_MAX_SECONDS
): bool {
if (trim((string) ($post[ANTISPAM_HONEYPOT] ?? '')) !== '') {
$reason = 'honeypot';
return false;
}
$raw = (string) ($post['antispam_token'] ?? '');
$dot = strrpos($raw, '.');
if ($dot === false) {
$reason = 'badtoken';
return false;
}
$ts = substr($raw, 0, $dot);
$sig = substr($raw, $dot + 1);
if (!ctype_digit($ts) || !hash_equals(hash_hmac('sha256', $ts, antispam_secret()), $sig)) {
$reason = 'badtoken';
return false;
}
$age = time() - (int) $ts;
if ($age < $minSeconds) { $reason = 'toofast'; return false; }
if ($maxSeconds > 0 && $age > $maxSeconds) { $reason = 'stale'; return false; }
return true;
}
/**
* Blocklist gerida pelo staff (`system_settings.antispam_blocklist`, uma
* entrada por linha). Formas aceites:
* alguem@exemplo.com email exacto (sem distinguir maiúsculas)
* @exemplo.com qualquer endereço desse domínio
* 203.0.113.7 IP exacto
* 203.0.113. prefixo de IP (ponto final = prefixo)
* Linhas iniciadas por # são comentários.
*/
function antispam_blocked(string $email, string $ip): bool
{
// setting() é do stack config/; a silver não o tem. O primitivo do
// platform-tools serve os dois.
$raw = function_exists('setting')
? (string) setting('antispam_blocklist', '')
: (isset($GLOBALS['pdo']) ? (string) platform_settings_get($GLOBALS['pdo'], 'antispam_blocklist', '') : '');
if (trim($raw) === '') {
return false;
}
$email = mb_strtolower(trim($email));
foreach (preg_split('/\R+/', $raw) as $line) {
$entry = mb_strtolower(trim($line));
if ($entry === '' || $entry[0] === '#') {
continue;
}
if ($entry[0] === '@') {
if ($email !== '' && str_ends_with($email, $entry)) return true;
} elseif (str_contains($entry, '@')) {
if ($email === $entry) return true;
} elseif (str_ends_with($entry, '.')) {
if ($ip !== '' && str_starts_with($ip, $entry)) return true;
} else {
if ($ip !== '' && $ip === $entry) return true;
}
}
return false;
}
/**
* Heurística de texto suspeito. Um pedido de suporte real pode trazer UM link
* (o URL da página com problema); dois ou mais, ou marcação BBCode/HTML, é
* quase sempre payload de bot.
*/
function antispam_message_suspect(?string ...$texts): bool
{
$joined = implode("\n", array_map(fn($t) => (string) $t, $texts));
if ($joined === '') {
return false;
}
if (stripos($joined, '[url') !== false || stripos($joined, '<a href') !== false) {
return true;
}
return preg_match_all('#https?://|\bwww\.#i', $joined) >= 2;
}
/**
* Limite deslizante de 24 h sobre a tabela de submissões, por email e por IP.
*
* A tabela e as colunas vêm por argumento: no hub são os `tickets`, na
* fotografia os pedidos de orçamento. Era a única parte deste módulo que não
* era genérica.
*
* Os nomes de tabela e coluna são interpolados (não há placeholders para
* identificadores em SQL), por isso passam por uma validação estrita — só
* letras, dígitos e underscore. Vêm sempre do código do site, nunca de um
* pedido, mas a validação torna isso impossível de contornar por engano.
*/
function antispam_submission_throttled(
PDO $pdo,
string $table,
string $emailColumn,
string $ipColumn,
string $email,
string $ip,
?string &$reason = null,
?int $maxPerEmail = null,
?int $maxPerIp = null
): bool {
foreach ([$table, $emailColumn, $ipColumn] as $ident) {
if (!preg_match('/^[A-Za-z0-9_]+$/', $ident)) {
error_log('[antispam] identificador inválido: ' . $ident);
return false; // falha aberto: não é o antispam que derruba o formulário
}
}
$maxPerEmail ??= ANTISPAM_MAX_PER_EMAIL_24H;
$maxPerIp ??= ANTISPAM_MAX_PER_IP_24H;
$s = $pdo->prepare(
"SELECT COUNT(*) FROM `{$table}`
WHERE `{$emailColumn}` = ? AND created_at > DATE_SUB(NOW(), INTERVAL 1 DAY)"
);
$s->execute([mb_strtolower($email)]);
if ((int) $s->fetchColumn() >= $maxPerEmail) {
$reason = 'email_rate';
return true;
}
if ($ip !== '') {
$s = $pdo->prepare(
"SELECT COUNT(*) FROM `{$table}`
WHERE `{$ipColumn}` = ? AND created_at > DATE_SUB(NOW(), INTERVAL 1 DAY)"
);
$s->execute([$ip]);
if ((int) $s->fetchColumn() >= $maxPerIp) {
$reason = 'ip_rate';
return true;
}
}
return false;
}
/**
* Corre as cinco camadas por ordem e devolve o motivo da recusa, ou null.
*
* $submissions diz onde contar o limite de 24 h: ['tabela', 'coluna_email',
* 'coluna_ip']. A null, a camada 5 não corre — um site sem tabela de
* submissões usa na mesma as outras quatro.
*/
function antispam_screen(PDO $pdo, array $post, string $email, string $ip,
?array $submissions = null, string ...$texts): ?string
{
$reason = null;
if (!antispam_check($post, $reason)) return $reason;
if (antispam_blocked($email, $ip)) return 'blocklist';
if (antispam_message_suspect(...$texts)) return 'links';
if ($submissions !== null
&& antispam_submission_throttled($pdo, $submissions[0], $submissions[1], $submissions[2],
$email, $ip, $reason)) {
return $reason;
}
return null;
}
/**
* Regista uma tentativa descartada, para o staff ver o que está a ser filtrado.
*
* É também o ponto único onde a infracção conta para o bloqueio de IP: todas as
* recusas do antispam passam por aqui, portanto pendurar a escalada neste sítio
* cobre o formulário de suporte e tudo o que venha a usá-lo, sem ter de andar a
* lembrar de o acrescentar em cada um.
*/
function antispam_log_drop(PDO $pdo, string $reason, string $email, string $ip, string $subject = ''): void
{
try {
$pdo->prepare(
'INSERT INTO antispam_drops (reason, submitter_email, ip_address, subject)
VALUES (?, ?, ?, ?)'
)->execute([$reason, mb_substr($email, 0, 190), $ip, mb_substr($subject, 0, 190)]);
} catch (Throwable $e) {
error_log('antispam_log_drop falhou: ' . $e->getMessage());
}
// O IP explícito e não o do pedido: quem chama isto já o resolveu, e nos
// casos em que a submissão vem de outro caminho é esse que vale.
if (function_exists('platform_ip_record_offence')) {
platform_ip_record_offence($pdo, 'antispam.' . mb_substr($reason, 0, 28), $subject !== '' ? $subject : $email, $ip);
}
}
@@ -1,502 +0,0 @@
<?php
// ─── Anti-spam de formulários públicos: o motor de pontuação (partilhado) ────
//
// Nasceu no dance, a defender os formulários de contacto e de aula
// experimental — que são o funil de inscrições da academia — e vive agora aqui.
// A auditoria de 2026-09-07 encontrou CINCO implementações de anti-spam na
// frota, e a do dance era a única com pontuação de conteúdo. Também era, das
// que existiam, a que corria sem a validação de identificadores SQL que o
// functions_antispam.php ao lado já tinha: a cópia mais rica era a cópia sem a
// guarda. É o argumento inteiro contra implementações paralelas.
//
// ─── A relação com functions_antispam.php ───────────────────────────────────
//
// São dois módulos e não um por decisão, não por esquecimento. O outro
// (honeypot + blocklist + limite deslizante de 24 h por email e IP) está em
// produção no hub e tem outra API; fundi-los às cegas mudava o comportamento de
// um site a que ninguém pediu alterações. Este trata do que aquele não faz:
// **pontuar o conteúdo** de um formulário de angariação, onde recusar de mais
// custa inscrições e recusar de menos custa uma caixa de correio.
//
// Convergir os dois é trabalho para quando houver quem teste o hub. Até lá,
// quem escolhe é o site: este para formulários de angariação com texto livre,
// aquele para formulários de suporte com identidade conhecida.
//
// ─── O desenho, que se mantém do dance ──────────────────────────────────────
//
// Nada aqui devolve um erro a quem escreve. Uma submissão recusada recebe a
// mesma página de obrigado que uma válida — um bot não fica a saber o que o
// apanhou — e fica gravada, para que um falso positivo seja recuperável em vez
// de desaparecer. Um falso positivo é uma lead verdadeira.
//
// As camadas, da mais barata para a mais cara:
//
// 1. honeypot — campo escondido; preenchido é um bot.
// 2. carimbo assinado — `form_ts` + HMAC. Sem assinatura, o carimbo era um
// número que qualquer script punha à mão.
// 3. prova de JavaScript — um campo que só o browser preenche. NÃO bloqueia
// sozinho: quem desliga o JS continua a enviar.
// 4. limite por IP — inclui as tentativas já bloqueadas, senão bastava
// insistir para o contador nunca subir.
// 5. conteúdo — pontuação. Uma mensagem legítima pode trazer um
// link ("vi no vosso instagram"), por isso um link
// sozinho não chega ao limite e dois chegam.
//
// ─── Ligar num site ─────────────────────────────────────────────────────────
//
// require_once __DIR__ . '/../shared/platform-tools/functions_antispam_forms.php';
// platform_antispam_config([
// 'key_domain' => 'dsa-antispam|', // NÃO mudar num site a correr: ver abaixo
// 'log_table' => 'blocked_submissions',
// 'keywords' => [...], // vocabulário que não aparece no vosso negócio
// 'native' => '/\b(ola|gostaria|aulas?)\b/u',
// ]);
//
// `key_domain` entra no HMAC do carimbo. Mudá-lo num site a correr invalida os
// formulários que estiverem abertos nesse momento, e essas submissões são
// recusadas em silêncio — leads perdidas por uma questão de arrumação. Por isso
// é configurável e não fixo: cada site mantém o domínio com que já assina.
require_once __DIR__ . '/functions_settings.php';
require_once __DIR__ . '/functions_client_ip.php';
/**
* Configuração do módulo. Chamar com um array para definir; sem argumentos, lê.
* Os valores por omissão são conservadores de propósito — sem `keywords`, a
* camada de vocabulário simplesmente não pontua.
*/
function platform_antispam_config(?array $set = null): array
{
static $cfg = [
'key_domain' => 'antispam|',
'log_table' => 'blocked_submissions',
'min_seconds' => 4, // ninguém preenche um formulário em menos
'max_seconds' => 21600, // 6 h — separador aberto de manhã ainda serve
'score_block' => 5,
'ip_limit' => 5,
'ip_window' => 60, // minutos
'keywords' => [],
'native' => '', // regex das marcas da língua local (ver looks_foreign)
'setting_key' => 'antispam_key',
];
if ($set !== null) {
$cfg = array_merge($cfg, $set);
}
return $cfg;
}
/**
* Um identificador SQL que podemos interpolar?
*
* Nomes de tabela não podem ir por placeholder, e estas funções recebem-nos de
* quem chama. **Falha aberto**: um identificador inválido é um erro de
* programação do site, e não é o anti-spam que há-de derrubar o formulário por
* causa dele — regista-se e deixa-se passar.
*/
function platform_antispam_ident_ok(string $ident): bool
{
if (preg_match('/^[A-Za-z0-9_]+$/', $ident)) {
return true;
}
error_log('[antispam] identificador SQL inválido, ignorado: ' . $ident);
return false;
}
/**
* Chave dos HMAC do carimbo.
*
* Tem de ser **a mesma entre pedidos**: o formulário é assinado num pedido e
* verificado noutro, e uma chave que mudasse a cada pedido recusava toda a
* gente com "carimbo inválido". Daí os três degraus, todos estáveis:
*
* 1. APP_SECRET_KEY / BACKUP_TOKEN do ambiente (o caso normal);
* 2. uma chave aleatória gravada em system_settings, quando não há a primeira;
* 3. derivada da configuração do install, para instalações sem chave e sem a
* tabela de settings. Não é um segredo tão bom como os outros, mas é
* constante — e uma defesa que tranca todos os formulários é pior do que
* uma assinatura mais fraca.
*
* A mesma escada vive agora em platform_derived_key() (functions_settings.php),
* que é o que os módulos novos usam — o captcha, por exemplo. Esta cópia fica
* porque separa os domínios de outra maneira (ver o comentário lá dentro sobre
* a chave do ambiente) e porque trocá-la num site a correr invalida os
* formulários abertos nesse instante. Quem a converter faz uma janela que
* aceite as duas assinaturas antes de tirar a antiga.
*/
function platform_antispam_key(?PDO $pdo = null): string
{
static $key = null;
if ($key !== null) {
return $key;
}
$cfg = platform_antispam_config();
// Em bruto, e NÃO derivada com o domínio, quando vem do ambiente.
//
// Separar domínios seria melhor prática — a mesma chave assina carimbos e
// cifra backups. Mas a chave do HMAC não se muda num site a correr: os
// formulários abertos nesse instante passam a ter carimbo inválido e são
// recusados em silêncio, ou seja, leads perdidas por uma questão de
// arrumação. Fica como está, de propósito, e quem quiser trocar faz uma
// janela que aceite as duas assinaturas antes de tirar a antiga.
$fromEnv = platform_secret_key();
if ($fromEnv !== null) {
return $key = $fromEnv;
}
$stored = '';
if ($pdo instanceof PDO) {
$stored = (string) platform_settings_get($pdo, $cfg['setting_key'], '');
if ($stored === '') {
$fresh = bin2hex(random_bytes(32));
try {
// Nunca cifrada: platform_settings_is_secret('antispam_key') é
// falso de propósito. Cifrá-la aqui e lê-la noutro sítio sem
// decifrar dava uma chave diferente a cada leitura, ou seja
// todos os formulários recusados.
platform_settings_set($pdo, $cfg['setting_key'], $fresh, null, false);
$stored = $fresh;
} catch (Throwable $e) {
error_log('[antispam] chave não persistida (' . $e->getMessage() . '); a derivar da configuração');
}
}
}
if ($stored === '') {
$env = function (string $k): string {
return function_exists('env') ? (string) env($k, '') : (string) (getenv($k) ?: '');
};
$stored = 'derived|' . $env('DB_NAME') . '|' . $env('DB_USER') . '|' . $env('DB_PASS')
. '|' . (defined('APP_BASE_URL') ? APP_BASE_URL : '');
}
return $key = hash('sha256', $cfg['key_domain'] . $stored, true);
}
function platform_antispam_sign(string $form, int $ts, ?PDO $pdo = null): string
{
return hash_hmac('sha256', $form . '|' . $ts, platform_antispam_key($pdo));
}
/**
* Campos-armadilha de um formulário público. `$form` entra na assinatura, para
* que um carimbo colhido no formulário de contacto não sirva no de inscrição.
*
* O <script> sai uma vez por página (dois formulários na mesma página partilham
* o mesmo) e preenche `form_js` com o início da assinatura — um cliente que só
* leia o HTML e reenvie os campos manda-o vazio, porque no HTML está vazio.
*/
function platform_antispam_trap_field(string $form = 'form', ?PDO $pdo = null): string
{
static $scriptPrinted = false;
$esc = fn(string $s): string => htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
$ts = time();
$sig = platform_antispam_sign($form, $ts, $pdo);
$html = '<input type="text" name="website" value="" tabindex="-1" autocomplete="off"'
. ' style="position:absolute;left:-9999px;top:-9999px;width:1px;height:1px;overflow:hidden;" aria-hidden="true">'
. '<input type="hidden" name="form_ts" value="' . $ts . '">'
. '<input type="hidden" name="form_sig" value="' . $esc($sig) . '">'
. '<input type="hidden" name="form_js" value="" data-js-check="' . $esc(substr($sig, 0, 12)) . '">';
if (!$scriptPrinted) {
$scriptPrinted = true;
// Corre já (o formulário que o imprime está acima) e outra vez quando a
// página acaba de carregar: o script sai uma vez só, junto ao PRIMEIRO
// formulário, e um segundo mais abaixo na mesma página ainda não existia
// quando ele correu — ficava sem a prova de JS (silver, 2026-09-23).
$html .= '<script>(function(){function f(){document.querySelectorAll(\'input[data-js-check]\').forEach('
. 'function(i){i.value=i.dataset.jsCheck;});}f();'
. 'document.addEventListener(\'DOMContentLoaded\',f);})();</script>';
}
return $html;
}
/**
* Recusa directa: honeypot, carimbo em falta/forjado, ou depressa/velho de mais.
* Devolve o motivo (para o registo) ou null quando passa.
*/
function platform_antispam_trap_reason(string $form, ?PDO $pdo = null): ?string
{
$cfg = platform_antispam_config();
if (trim((string) ($_POST['website'] ?? '')) !== '') {
return 'honeypot';
}
$ts = (int) ($_POST['form_ts'] ?? 0);
$sig = (string) ($_POST['form_sig'] ?? '');
if ($ts <= 0 || $sig === '' || !hash_equals(platform_antispam_sign($form, $ts, $pdo), $sig)) {
return 'carimbo inválido';
}
$elapsed = time() - $ts;
if ($elapsed < $cfg['min_seconds']) {
return 'submetido em ' . max(0, $elapsed) . 's';
}
if ($elapsed > $cfg['max_seconds']) {
return 'formulário expirado';
}
return null;
}
/**
* Texto comprido, em inglês corrente, sem uma única marca da língua do site.
*
* Vale pontos e não uma recusa: um estrangeiro a perguntar por aulas escreve
* exactamente assim, e esse tem de passar. A regex da língua local vem da
* configuração — sem ela, esta camada não pontua, porque um módulo partilhado
* não pode adivinhar em que língua escrevem os clientes de cada site.
*/
function platform_antispam_looks_foreign(string $text): bool
{
$cfg = platform_antispam_config();
if ($cfg['native'] === '' || mb_strlen($text) < 60) {
return false;
}
$lower = mb_strtolower($text, 'UTF-8');
if (preg_match($cfg['native'], $lower)) {
return false;
}
$en = preg_match_all(
'/\b(the|your|and|are|with|for|our|we|you|this|that|they|before|across|using|each|from|have|will|would|can|about|more|their)\b/u',
$lower
);
return $en >= 4;
}
/** O campo que só o browser preenche foi preenchido? */
function platform_antispam_has_js_proof(): bool
{
$sig = (string) ($_POST['form_sig'] ?? '');
$js = (string) ($_POST['form_js'] ?? '');
return $sig !== '' && $js !== '' && hash_equals(substr($sig, 0, 12), $js);
}
/**
* Pontuação do conteúdo. `$fields['name']` é tratado com mais severidade do que
* o texto livre: um link no nome não tem leitura inocente.
*
* @param array{name?:string, text?:string, modality?:string} $fields
* @param string[] $allowedChoices Lista fechada de um <select>; vazia = não verifica
* @return array{0:int, 1:string[]}
*/
function platform_antispam_content_score(array $fields, array $allowedChoices = []): array
{
$cfg = platform_antispam_config();
$name = trim((string) ($fields['name'] ?? ''));
$text = trim((string) ($fields['text'] ?? ''));
$all = $name . "\n" . $text;
$lower = mb_strtolower($all, 'UTF-8');
$score = 0;
$reasons = [];
$urlPattern = '~(https?://|www\.|\[url|<a\s)~i';
if (preg_match($urlPattern, $name)) {
$score += 5;
$reasons[] = 'link no nome';
}
$urlCount = preg_match_all($urlPattern, $text);
if ($urlCount >= 2) {
$score += 5;
$reasons[] = $urlCount . ' links na mensagem';
} elseif ($urlCount === 1) {
$score += 3;
$reasons[] = 'link na mensagem';
}
// Domínio escrito de propósito ao lado do filtro — "brnd .li/delist",
// "exemplo (dot) com". Quem escreve assim sabe que há um filtro a evitar, o
// que por si só diz o que a mensagem é. Vale 3 e não 5 porque apanha por
// engano um ponto mal espaçado ("a minha filha .Com sete anos").
if (preg_match('~\b[a-z0-9][a-z0-9-]+\s+\.\s?(com|net|org|io|co|li|ru|xyz|info|shop|link|site|online|biz)\b~i', $all)
|| preg_match('~\(\s*dot\s*\)|\bdot\s+(com|net|org)\b|\bhxxps?\b~i', $all)) {
$score += 3;
$reasons[] = 'domínio disfarçado';
}
if (preg_match('/[\p{Cyrillic}\p{Han}\p{Hiragana}\p{Katakana}\p{Hangul}\p{Arabic}\p{Hebrew}\p{Thai}]/u', $all)) {
$score += 5;
$reasons[] = 'escrita não-latina';
}
$hits = [];
foreach ($cfg['keywords'] as $word) {
if (str_contains($lower, $word)) {
$hits[] = $word;
}
}
if ($hits) {
$score += min(6, 3 * count($hits));
$reasons[] = 'vocabulário: ' . implode(', ', array_slice($hits, 0, 3));
}
// Dois pontos, e não três, por causa de um caso concreto: um estrangeiro a
// perguntar por aulas, com o JavaScript desligado, somava logo o limite.
if (platform_antispam_looks_foreign($text)) {
$score += 2;
$reasons[] = 'texto em língua estrangeira';
}
if (preg_match('~<\s*(a|script|iframe|img|div)\b~i', $all) || str_contains($lower, '[/url]')) {
$score += 3;
$reasons[] = 'html na mensagem';
}
$choice = trim((string) ($fields['modality'] ?? $fields['choice'] ?? ''));
if ($choice !== '' && $allowedChoices && !in_array($choice, $allowedChoices, true)) {
$score += 3;
$reasons[] = 'opção inexistente';
}
if (!platform_antispam_has_js_proof()) {
$score += 2;
$reasons[] = 'sem javascript';
}
return [$score, $reasons];
}
/**
* Quantas submissões deste IP na última janela, contando as bloqueadas — senão
* quem fosse recusado voltava a começar do zero a cada tentativa.
*/
function platform_antispam_ip_count(PDO $pdo, string $form, string $table, string $ip): int
{
if ($ip === '') {
return 0;
}
$cfg = platform_antispam_config();
$count = 0;
if (platform_antispam_ident_ok($table)) {
try {
$stmt = $pdo->prepare(
"SELECT COUNT(*) FROM `{$table}` WHERE ip = ? AND created_at >= DATE_SUB(NOW(), INTERVAL ? MINUTE)"
);
$stmt->execute([$ip, $cfg['ip_window']]);
$count += (int) $stmt->fetchColumn();
} catch (PDOException) { /* tabela ainda não migrada — não trancar por isso */ }
}
$log = $cfg['log_table'];
if (platform_antispam_ident_ok($log)) {
try {
$stmt = $pdo->prepare(
"SELECT COUNT(*) FROM `{$log}`
WHERE ip = ? AND form = ? AND created_at >= DATE_SUB(NOW(), INTERVAL ? MINUTE)"
);
$stmt->execute([$ip, $form, $cfg['ip_window']]);
$count += (int) $stmt->fetchColumn();
} catch (PDOException) { /* idem */ }
}
return $count;
}
/**
* A verificação completa de um formulário público. Devolve null quando pode
* seguir, ou o motivo quando não — já registado.
*
* Chamar depois da verificação de CSRF. Quem chama responde sempre da mesma
* maneira (a página de obrigado), passe ou não.
*/
function platform_antispam_check(
PDO $pdo,
string $form,
string $table,
array $fields,
array $allowedChoices = []
): ?string {
// O IP verdadeiro, não o REMOTE_ADDR: atrás de um proxy o REMOTE_ADDR é
// sempre o mesmo, e o limite por IP passava a contar toda a gente no mesmo
// balde — cinco visitantes diferentes trancavam o formulário a todos, e um
// bot sozinho nunca chegava ao limite dele.
// platform_client_ip(), o nome canónico de functions_client_ip.php — e não
// auth_client_ip(), que é apenas um alias criado dentro do
// functions_auth.php. Um módulo partilhado não pode contar com que o site
// tenha carregado a autenticação: no dance tinha, e por isso o alias
// funcionava aqui sem ninguém reparar na dependência que faltava.
$ip = platform_client_ip() ?? '';
// O portão de país corre primeiro: é mais barato que as outras verificações
// e não depende de token nenhum. Desligado, devolve null.
if (function_exists('geo_form_block_reason')) {
$geoReason = geo_form_block_reason($ip ?: null);
if ($geoReason !== null) {
platform_antispam_log($pdo, $form, $geoReason, 0, $ip);
return $geoReason;
}
}
$cfg = platform_antispam_config();
$reason = platform_antispam_trap_reason($form, $pdo);
$score = 0;
if ($reason === null && platform_antispam_ip_count($pdo, $form, $table, $ip) >= $cfg['ip_limit']) {
$reason = 'limite de ' . $cfg['ip_limit'] . ' envios/hora deste IP';
}
if ($reason === null) {
[$score, $reasons] = platform_antispam_content_score($fields, $allowedChoices);
if ($score >= $cfg['score_block']) {
$reason = implode('; ', $reasons);
}
}
if ($reason === null) {
return null;
}
platform_antispam_log($pdo, $form, $reason, $score, $ip);
return $reason;
}
/**
* Grava a tentativa recusada. O payload é o $_POST inteiro menos o token CSRF —
* é o que permite ler um falso positivo e responder à pessoa à mesma.
*/
function platform_antispam_log(PDO $pdo, string $form, string $reason, int $score, string $ip): void
{
$cfg = platform_antispam_config();
$log = $cfg['log_table'];
if (!platform_antispam_ident_ok($log)) {
return;
}
$payload = $_POST;
unset($payload['csrf_token'], $payload['form_sig'], $payload['form_js'], $payload['form_ts']);
try {
$pdo->prepare(
"INSERT INTO `{$log}` (form, reason, score, ip, user_agent, payload)
VALUES (?, ?, ?, ?, ?, ?)"
)->execute([
$form,
mb_substr($reason, 0, 190),
$score,
$ip,
mb_substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 250),
mb_substr((string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 0, 4000),
]);
// Cada recusa conta para o bloqueio de IP. É isto que dá memória às
// defesas: sozinho, este contador esquece tudo à hora seguinte, e um
// bot que volte todos os dias é sempre um visitante novo.
if (function_exists('platform_ip_record_offence')) {
platform_ip_record_offence($pdo, 'antispam:' . $form, mb_substr($reason, 0, 120), $ip ?: null);
}
} catch (PDOException $e) {
// Tabela por migrar, ou disco cheio: o bloqueio mantém-se, só não fica
// registado. Recusar por não se conseguir escrever o registo seria
// trocar spam por leads perdidas.
error_log('[antispam] registo falhou: ' . $e->getMessage());
}
}
-348
View File
@@ -1,348 +0,0 @@
<?php
// ─── Registo de auditoria (partilhado) ──────────────────────────────────────
//
// Quem fez o quê, a quê, quando e de onde — numa tabela `audit_log` de cada
// site, escrita por uma função só:
//
// platform_audit($pdo, 'user.delete', [
// 'target_type' => 'user',
// 'target_id' => 42,
// 'target_label' => 'Maria Silva',
// 'summary' => 'Enviada para a reciclagem',
// 'changes' => platform_audit_diff($antes, $depois),
// ]);
//
// Porque existe: a frota tinha TRÊS registos incompatíveis (hub, dance, quack,
// cada um com as suas colunas e a sua função) e cinco sites sem nenhum. O
// mesmo argumento do anti-spam e dos segredos — cópias não ficam iguais,
// ficam parecidas.
//
// ─── As tabelas que já existiam ─────────────────────────────────────────────
//
// Não se renomeia nada. O escritor LÊ as colunas da tabela do site
// (information_schema, uma vez por pedido) e escreve cada campo canónico na
// coluna que o site declarou para ele — `platform_connect(['audit' => ['map' =>
// ['actor_id' => 'user_id', ...]]])`, como o `schema` da autenticação. Um campo
// sem coluna é simplesmente omitido; uma coluna NOT NULL sem valor por omissão
// recebe um vazio (a do quack, `summary`, rebentava o INSERT); os textos são
// cortados ao tamanho da coluna. As migrações de cada site acrescentam as
// colunas que faltam, e daí em diante nada se perde.
//
// Um site sem tabela recebe-a no primeiro uso, já na forma canónica
// (platform_audit_ensure_table()).
//
// ─── As regras ──────────────────────────────────────────────────────────────
//
// - **Nunca parte a ação.** Uma falha a escrever regista-se no error_log e o
// pedido segue: perde-se a linha, não a operação.
// - **Segredos nunca entram.** Qualquer chave de `changes` com cara de segredo
// (password, token, secret, key, hash, cookie, csrf) é substituída por
// «(oculto)», a qualquer profundidade.
// - **O autor vem da sessão**, em qualquer das formas da frota
// (current_user(), $_SESSION['user'], ou as chaves soltas do quack), e o IP é
// o verdadeiro (platform_client_ip()). Quem chama pode passar 'actor' quando
// sabe melhor — o hub a agir por ops, o webhook do deploy, o PIN do dance.
require_once __DIR__ . '/functions_client_ip.php';
/** Os campos canónicos, e a definição de cada um numa tabela nova. */
function platform_audit_columns(): array
{
return [
'actor_id' => 'BIGINT UNSIGNED NULL',
'actor_name' => 'VARCHAR(150) NULL',
'actor_role' => 'VARCHAR(30) NULL',
'action' => "VARCHAR(80) NOT NULL DEFAULT ''",
'target_type' => "VARCHAR(60) NOT NULL DEFAULT ''",
'target_id' => 'VARCHAR(64) NULL',
'target_label' => 'VARCHAR(250) NULL',
'summary' => 'VARCHAR(1000) NULL',
'changes' => 'LONGTEXT NULL',
'ip' => 'VARCHAR(45) NULL',
'user_agent' => 'VARCHAR(255) NULL',
];
}
/** Nome da tabela e mapa de colunas deste site (platform_connect 'audit'). */
function platform_audit_config(): array
{
$cfg = function_exists('platform_config') ? (array) (platform_config()['audit'] ?? []) : [];
return [
'table' => (string) ($cfg['table'] ?? 'audit_log'),
'map' => (array) ($cfg['map'] ?? []),
// Dias que uma linha vive; 0 = para sempre.
'keep_days' => (int) ($cfg['keep_days'] ?? 730),
];
}
function platform_audit_ensure_table(PDO $pdo, string $table): void
{
if (!preg_match('/^[A-Za-z0-9_]+$/', $table)) return;
$cols = '';
foreach (platform_audit_columns() as $name => $def) $cols .= " `$name` $def,\n";
$pdo->exec(
"CREATE TABLE IF NOT EXISTS `$table` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
$cols `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_audit_created` (`created_at`),
KEY `idx_audit_actor` (`actor_id`, `created_at`),
KEY `idx_audit_action` (`action`, `created_at`),
KEY `idx_audit_target` (`target_type`, `target_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
}
/**
* As colunas reais da tabela: [nome => ['type' => …, 'max' => int|null,
* 'required' => bool]]. Uma consulta por pedido. Cria a tabela quando não há.
*/
function platform_audit_table_columns(PDO $pdo): array
{
static $cache = [];
$table = platform_audit_config()['table'];
if (isset($cache[$table])) return $cache[$table];
$read = function () use ($pdo, $table): array {
$st = $pdo->prepare(
'SELECT COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, IS_NULLABLE, COLUMN_DEFAULT, EXTRA
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?'
);
$st->execute([$table]);
$out = [];
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $c) {
$out[$c['COLUMN_NAME']] = [
'type' => strtolower((string) $c['DATA_TYPE']),
'max' => $c['CHARACTER_MAXIMUM_LENGTH'] !== null ? (int) $c['CHARACTER_MAXIMUM_LENGTH'] : null,
'required' => $c['IS_NULLABLE'] === 'NO' && $c['COLUMN_DEFAULT'] === null
&& !str_contains((string) $c['EXTRA'], 'auto_increment'),
'not_null' => $c['IS_NULLABLE'] === 'NO',
];
}
return $out;
};
$cols = $read();
if (!$cols) {
platform_audit_ensure_table($pdo, $table);
$cols = $read();
}
return $cache[$table] = $cols;
}
/** A coluna do site para um campo canónico (o mapa, ou o próprio nome). */
function platform_audit_column(string $field): string
{
return (string) (platform_audit_config()['map'][$field] ?? $field);
}
/**
* Quem está a agir, a partir da sessão, em qualquer das formas da frota:
* current_user(), $_SESSION['user'], ou as chaves soltas user_id/username/role.
*/
function platform_audit_actor(): ?array
{
$u = null;
if (function_exists('current_user')) {
try { $u = current_user(); } catch (Throwable) { $u = null; }
}
if (!is_array($u) && session_status() === PHP_SESSION_ACTIVE && is_array($_SESSION['user'] ?? null)) {
$u = $_SESSION['user'];
}
if (is_array($u) && !empty($u['id'])) {
return [
'id' => (int) $u['id'],
'name' => (string) ($u['full_name'] ?? $u['name'] ?? $u['display_name'] ?? $u['username'] ?? $u['email'] ?? ''),
'role' => (string) ($u['role'] ?? ''),
];
}
if (session_status() === PHP_SESSION_ACTIVE && !empty($_SESSION['user_id'])) {
return [
'id' => (int) $_SESSION['user_id'],
'name' => (string) ($_SESSION['full_name'] ?? $_SESSION['username'] ?? ''),
'role' => (string) ($_SESSION['role'] ?? ''),
];
}
return null;
}
/** Tira os segredos de uma estrutura antes de ela ir para o registo. */
function platform_audit_redact(mixed $v, string $key = ''): mixed
{
if ($key !== '' && preg_match('/pass|token|secret|api_?key|ops_?key|hash|cookie|csrf|validator|pairing_code/i', $key)) {
// Oculta os valores mas mantém a forma: um {from, to} continua a ser um
// {from, to}, e a página continua a mostrá-lo como alteração.
if (is_array($v)) return array_map(fn($x) => ($x === null || $x === '') ? $x : '(oculto)', $v);
return ($v === null || $v === '') ? $v : '(oculto)';
}
if (is_array($v)) {
$out = [];
foreach ($v as $k => $x) $out[$k] = platform_audit_redact($x, is_string($k) ? $k : $key);
return $out;
}
return $v;
}
/**
* O que mudou entre duas versões de um registo: [campo => ['from' => …, 'to' => …]].
* Compara como texto, para um '1' da base de dados e um 1 do formulário não
* contarem como mudança. $ignore: campos a não comparar (updated_at, …).
*/
function platform_audit_diff(array $before, array $after, array $ignore = ['updated_at', 'created_at']): array
{
$out = [];
foreach (array_unique(array_merge(array_keys($before), array_keys($after))) as $k) {
if (in_array($k, $ignore, true)) continue;
$a = $before[$k] ?? null;
$b = $after[$k] ?? null;
if ((string) (is_scalar($a) || $a === null ? $a : json_encode($a)) === (string) (is_scalar($b) || $b === null ? $b : json_encode($b))) continue;
$out[$k] = ['from' => $a, 'to' => $b];
}
return $out;
}
/** Os campos de um registo novo, na forma do diff (a partir de nada). Vazios ficam de fora. */
function platform_audit_created(array $fields): array
{
$out = [];
foreach ($fields as $k => $v) {
if ($v === null || $v === '') continue;
$out[$k] = ['from' => null, 'to' => $v];
}
return $out;
}
/**
* Regista uma ação. Nunca lança.
*
* $d: target_type, target_id, target_label, summary, changes (array), e
* opcionalmente actor (['id','name','role'], ou false para «sistema»).
*/
function platform_audit(?PDO $pdo, string $action, array $d = []): void
{
if (!$pdo instanceof PDO) {
$pdo = function_exists('platform_pdo') ? platform_pdo() : null;
if (!$pdo instanceof PDO) return;
}
try {
$actor = array_key_exists('actor', $d) ? ($d['actor'] ?: null) : platform_audit_actor();
$changes = $d['changes'] ?? null;
if (is_array($changes)) {
$changes = $changes ? json_encode(platform_audit_redact($changes), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null;
}
$values = [
'actor_id' => $actor['id'] ?? null,
'actor_name' => $actor['name'] ?? null,
'actor_role' => $actor['role'] ?? null,
'action' => $action,
'target_type' => (string) ($d['target_type'] ?? ''),
'target_id' => isset($d['target_id']) ? (string) $d['target_id'] : null,
'target_label' => $d['target_label'] ?? null,
'summary' => $d['summary'] ?? null,
'changes' => $changes,
'ip' => platform_client_ip(),
'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? (string) $_SERVER['HTTP_USER_AGENT'] : null,
];
$cols = platform_audit_table_columns($pdo);
if (!$cols) return;
$insert = [];
foreach ($values as $field => $v) {
$col = platform_audit_column($field);
if (!isset($cols[$col])) continue; // o site não tem coluna para isto
$c = $cols[$col];
if ($v !== null && in_array($c['type'], ['int', 'bigint', 'smallint', 'mediumint', 'tinyint'], true)) {
$v = is_numeric($v) ? (int) $v : null; // um id que não é número não cabe numa coluna INT
}
if (is_string($v) && $c['max'] !== null && mb_strlen($v) > $c['max']) {
$v = mb_substr($v, 0, $c['max']);
}
if ($v === null && $c['not_null']) {
// NOT NULL: com valor por omissão, omite-se e vale o dela; sem ele, vazio.
if (!$c['required']) continue;
$v = '';
}
$insert[$col] = $v;
}
// Colunas obrigatórias do site que nada preencheu (NOT NULL sem valor por omissão).
foreach ($cols as $col => $c) {
if ($c['required'] && !array_key_exists($col, $insert) && $col !== 'id') $insert[$col] = '';
}
$table = platform_audit_config()['table'];
$names = implode(', ', array_map(fn($c) => "`$c`", array_keys($insert)));
$marks = implode(', ', array_fill(0, count($insert), '?'));
$pdo->prepare("INSERT INTO `$table` ($names) VALUES ($marks)")->execute(array_values($insert));
platform_audit_maybe_prune($pdo);
} catch (Throwable $e) {
error_log('[audit] não registado (' . $action . '): ' . $e->getMessage());
}
}
/** Apaga as linhas mais velhas do que keep_days — no máximo uma vez por dia e por pedido. */
function platform_audit_maybe_prune(PDO $pdo): void
{
static $done = false;
if ($done) return;
$done = true;
$keep = platform_audit_config()['keep_days'];
if ($keep <= 0 || random_int(1, 200) !== 1) return; // barato: ~1 em 200 escritas
try {
$table = platform_audit_config()['table'];
$col = 'created_at';
$pdo->prepare("DELETE FROM `$table` WHERE `$col` < DATE_SUB(NOW(), INTERVAL ? DAY) LIMIT 5000")->execute([$keep]);
} catch (Throwable $e) {
error_log('[audit] poda falhou: ' . $e->getMessage());
}
}
// ─── Leitura (a página audit.php) ───────────────────────────────────────────
/**
* Linhas do registo com os nomes canónicos, seja qual for a forma da tabela do
* site. Filtros: actor (texto), action (prefixo), target_type, target_id,
* from/to (Y-m-d), q (texto livre).
*/
function platform_audit_query(PDO $pdo, array $f = [], int $limit = 100, int $offset = 0): array
{
$cols = platform_audit_table_columns($pdo);
$table = platform_audit_config()['table'];
$sel = ['`id`', '`created_at`'];
$has = [];
foreach (array_keys(platform_audit_columns()) as $field) {
$col = platform_audit_column($field);
if (isset($cols[$col])) { $sel[] = "`$col` AS `$field`"; $has[$field] = "`$col`"; }
else { $sel[] = "NULL AS `$field`"; }
}
$where = []; $params = [];
if (($f['actor'] ?? '') !== '' && isset($has['actor_name'])) { $where[] = "{$has['actor_name']} LIKE ?"; $params[] = '%' . $f['actor'] . '%'; }
if (($f['action'] ?? '') !== '') { $where[] = "{$has['action']} LIKE ?"; $params[] = $f['action'] . '%'; }
if (($f['target_type'] ?? '') !== '' && isset($has['target_type'])) { $where[] = "{$has['target_type']} = ?"; $params[] = $f['target_type']; }
if (($f['target_id'] ?? '') !== '' && isset($has['target_id'])) { $where[] = "{$has['target_id']} = ?"; $params[] = $f['target_id']; }
if (($f['from'] ?? '') !== '') { $where[] = '`created_at` >= ?'; $params[] = $f['from'] . ' 00:00:00'; }
if (($f['to'] ?? '') !== '') { $where[] = '`created_at` <= ?'; $params[] = $f['to'] . ' 23:59:59'; }
if (($f['q'] ?? '') !== '') {
$like = [];
foreach (['target_label', 'summary', 'changes'] as $field) if (isset($has[$field])) $like[] = "{$has[$field]} LIKE ?";
if ($like) { $where[] = '(' . implode(' OR ', $like) . ')'; foreach ($like as $_) $params[] = '%' . $f['q'] . '%'; }
}
$sql = 'SELECT ' . implode(', ', $sel) . " FROM `$table`"
. ($where ? ' WHERE ' . implode(' AND ', $where) : '')
. ' ORDER BY `created_at` DESC, `id` DESC LIMIT ' . max(1, min(1000, $limit)) . ' OFFSET ' . max(0, $offset);
$st = $pdo->prepare($sql);
$st->execute($params);
return $st->fetchAll(PDO::FETCH_ASSOC);
}
/** Os valores distintos de um campo, para os filtros (ações, tipos). */
function platform_audit_distinct(PDO $pdo, string $field, int $limit = 200): array
{
$cols = platform_audit_table_columns($pdo);
$col = platform_audit_column($field);
if (!isset($cols[$col])) return [];
$table = platform_audit_config()['table'];
return $pdo->query("SELECT DISTINCT `$col` FROM `$table` WHERE `$col` IS NOT NULL AND `$col` <> '' ORDER BY `$col` LIMIT " . (int) $limit)
->fetchAll(PDO::FETCH_COLUMN);
}
-421
View File
@@ -1,421 +0,0 @@
<?php
// ─── Shared authentication: sessions, passwords, roles, guards ───────────────
//
// One implementation for every platform. Each site had grown its own: dance had
// no session hardening at all and no login throttle, hub2 and fotografia each
// solved both separately, and silver used camelCase guards returning void while
// the others returned the user array. The mechanics below have no business
// logic in them, so there is no reason for four versions.
//
// What stays per-site: the users table itself. The columns genuinely differ
// (fotografia carries CalDAV credentials, silver guardian_id), so this file
// adapts to the local schema through auth_schema() instead of demanding one
// shape. Roles are the same idea -- a common core plus whatever the app needs.
//
// Typical wiring, in the site's bootstrap, after the PDO is open:
//
// require_once __DIR__ . '/../shared/platform-tools/functions_auth.php';
// auth_schema(['password' => 'password_hash', 'name' => 'name']);
// auth_roles_register(['teacher' => 40, 'student' => 20, 'parent' => 20]);
// auth_session_start(['name' => 'dsa_session']);
require_once __DIR__ . '/functions_settings.php';
require_once __DIR__ . '/functions_client_ip.php';
require_once __DIR__ . '/functions_security_events.php';
require_once __DIR__ . '/functions_connector.php';
// ─── Schema map ─────────────────────────────────────────────────────────────
//
// Column names diverged before this file existed -- silver stores the hash in
// `password` and the name in `full_name`, everyone else uses `password_hash`
// and `name`. Renaming columns across four live sites is a migration; adapting
// is a function call. Sites converging on the standard names can simply not
// call auth_schema() at all.
// The defaults live in functions_connector.php, which the conformance check
// reads too — one list of standard names, not two that drift apart.
function auth_schema(?array $map = null): array {
static $schema = null;
$schema ??= platform_auth_schema_defaults();
if ($map !== null) $schema = array_merge($schema, $map);
return $schema;
}
/** Builds the "is this account usable" SQL fragment for the local schema. */
function auth_active_sql(string $alias = ''): string {
$s = auth_schema();
$p = $alias !== '' ? "`{$alias}`." : '';
$out = [];
if ($s['active'] !== null) $out[] = "{$p}`{$s['active']}` = 1";
if ($s['deleted_at'] !== null) $out[] = "{$p}`{$s['deleted_at']}` IS NULL";
return $out ? implode(' AND ', $out) : '1=1';
}
// ─── Roles ──────────────────────────────────────────────────────────────────
//
// Three roles are common to every platform and mean the same thing everywhere.
// Everything else is the app's own vocabulary -- dance has teacher/student/
// parent, fotografia staff/client, silver student -- so a site registers its
// own and they sit in the same ranking as the core ones.
//
// The rank is what makes "at least an admin" expressible without hardcoding a
// list. Higher is more privileged; equal ranks are peers, not substitutes.
const AUTH_ROLE_SYSADMIN = 'sysadmin';
const AUTH_ROLE_ADMIN = 'admin';
const AUTH_ROLE_USER = 'user';
function auth_roles_register(?array $roles = null): array {
static $registry = [
AUTH_ROLE_SYSADMIN => 100,
AUTH_ROLE_ADMIN => 80,
AUTH_ROLE_USER => 10,
];
if ($roles !== null) {
foreach ($roles as $name => $rank) {
$registry[(string) $name] = (int) $rank;
}
arsort($registry);
}
return $registry;
}
function auth_roles_all(): array { return array_keys(auth_roles_register()); }
function auth_role_exists(string $role): bool { return isset(auth_roles_register()[$role]); }
/** Unknown roles rank 0 -- below everything, so a typo cannot grant access. */
function auth_role_rank(string $role): int {
return auth_roles_register()[$role] ?? 0;
}
// ─── Session ────────────────────────────────────────────────────────────────
/**
* Start a hardened session. Safe to call more than once.
*
* dance called a bare session_start() with no cookie parameters at all -- no
* httponly, no samesite, no strict mode -- which is exactly the kind of gap
* that appears when four sites each solve the same problem separately.
*/
function auth_session_start(array $opts = []): void {
if (session_status() === PHP_SESSION_ACTIVE) return;
$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
// Strict mode makes PHP refuse a session id it did not issue, which is what
// closes session fixation. It has to be set before the session starts.
ini_set('session.use_strict_mode', '1');
ini_set('session.use_only_cookies', '1');
session_set_cookie_params([
'lifetime' => (int) ($opts['lifetime'] ?? 0),
'path' => (string) ($opts['path'] ?? '/'),
'domain' => (string) ($opts['domain'] ?? ''),
'secure' => (bool) ($opts['secure'] ?? $https),
'httponly' => true,
'samesite' => (string) ($opts['samesite'] ?? 'Lax'),
]);
if (!empty($opts['name'])) session_name((string) $opts['name']);
session_start();
$idle = (int) ($opts['idle_seconds'] ?? 0);
if ($idle > 0) {
$last = (int) ($_SESSION['_auth_seen'] ?? 0);
if ($last > 0 && (time() - $last) > $idle) {
auth_logout(false);
session_start();
}
$_SESSION['_auth_seen'] = time();
}
}
/** Call on every privilege change -- login, logout, role change. */
function auth_session_regenerate(): void {
if (session_status() === PHP_SESSION_ACTIVE) session_regenerate_id(true);
}
// ─── Passwords ──────────────────────────────────────────────────────────────
//
// PASSWORD_DEFAULT, deliberately, so the estate follows PHP forward. dance
// pinned PASSWORD_BCRYPT, which means the day DEFAULT moves on, three sites
// migrate and one silently does not.
function auth_password_hash(string $plain): string {
return password_hash($plain, PASSWORD_DEFAULT);
}
/**
* Verify a password and transparently upgrade the stored hash.
*
* password_needs_rehash() appeared exactly twice across the whole estate, so
* hashes never actually improved. Login is the only moment the plaintext is
* available, so it is the only moment a rehash can happen.
*/
function auth_password_verify(PDO $pdo, array $user, string $plain): bool {
$s = auth_schema();
$hash = (string) ($user[$s['password']] ?? '');
if ($hash === '' || !password_verify($plain, $hash)) return false;
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
try {
$pdo->prepare("UPDATE `{$s['table']}` SET `{$s['password']}` = ? WHERE `{$s['id']}` = ?")
->execute([auth_password_hash($plain), $user[$s['id']]]);
} catch (Throwable $e) {
error_log('[auth] rehash failed: ' . $e->getMessage()); // never block a valid login
}
}
return true;
}
// ─── Login throttle ─────────────────────────────────────────────────────────
function auth_throttle_ensure_table(PDO $pdo): void {
$pdo->exec(
"CREATE TABLE IF NOT EXISTS `auth_attempts` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`identifier` VARCHAR(190) NOT NULL,
`ip` VARCHAR(45) NULL,
`attempted_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`success` TINYINT(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_identifier_time` (`identifier`, `attempted_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
}
/**
* Does this install's auth_attempts carry a `success` column?
*
* silver and fotografia already had the table, and theirs logs successful
* logins alongside failed ones. Counting every row in the window would then
* throttle a user for signing in successfully, so the count has to exclude
* them. Detected once rather than assumed, because hub2 has no such table and
* a fresh one created here starts out with the column too.
*/
function auth_throttle_has_success_column(PDO $pdo): bool {
static $has = null;
if ($has !== null) return $has;
try {
$st = $pdo->query("SHOW COLUMNS FROM `auth_attempts` LIKE 'success'");
$has = $st->fetch(PDO::FETCH_ASSOC) !== false;
} catch (Throwable $e) {
$has = false;
}
return $has;
}
/**
* Returns ['blocked'=>bool, 'remaining'=>int, 'retry_after'=>int].
*
* Throttles on identifier+IP together: on the identifier alone an attacker can
* lock a known user out of their own account, and on IP alone a NAT'd office
* locks itself out.
*/
function auth_throttle_check(PDO $pdo, string $identifier, int $max = 5, int $windowSeconds = 900): array {
auth_throttle_ensure_table($pdo);
$ip = auth_client_ip();
try {
$onlyFailures = auth_throttle_has_success_column($pdo) ? ' AND success = 0' : '';
$st = $pdo->prepare(
"SELECT COUNT(*) c, COALESCE(MAX(UNIX_TIMESTAMP(attempted_at)), 0) last
FROM auth_attempts
WHERE identifier = ? AND (ip = ? OR ? IS NULL)
AND attempted_at > (NOW() - INTERVAL ? SECOND)" . $onlyFailures
);
$st->execute([mb_strtolower($identifier), $ip, $ip, $windowSeconds]);
$row = $st->fetch(PDO::FETCH_ASSOC) ?: ['c' => 0, 'last' => 0];
} catch (Throwable $e) {
return ['blocked' => false, 'remaining' => $max, 'retry_after' => 0]; // never lock everyone out on a DB hiccup
}
$count = (int) $row['c'];
$blocked = $count >= $max;
if ($blocked) {
platform_security_event($pdo, 'login_throttled', hub_security_mask_identifier($identifier), $ip);
}
return [
'blocked' => $blocked,
'remaining' => max(0, $max - $count),
'retry_after' => $blocked ? max(0, ((int) $row['last'] + $windowSeconds) - time()) : 0,
];
}
function auth_throttle_hit(PDO $pdo, string $identifier): void {
auth_throttle_ensure_table($pdo);
try {
// ip is NOT NULL on the tables silver and fotografia already had, so a
// null from an odd SAPI would abort the insert and silently disable the
// throttle. Empty string records "unknown" without failing.
$pdo->prepare("INSERT INTO auth_attempts (identifier, ip) VALUES (?, ?)")
->execute([mb_strtolower($identifier), auth_client_ip() ?? '']);
} catch (Throwable $e) {
error_log('[auth] throttle record failed: ' . $e->getMessage());
}
platform_security_event($pdo, 'login_failed', hub_security_mask_identifier($identifier));
}
function auth_throttle_clear(PDO $pdo, string $identifier): void {
try {
$pdo->prepare("DELETE FROM auth_attempts WHERE identifier = ?")
->execute([mb_strtolower($identifier)]);
} catch (Throwable $e) { /* nothing worth failing a login over */ }
}
/**
* Client IP, honouring X-Forwarded-For only when TRUSTED_PROXY says to.
*
* Every site sits behind NPM, so REMOTE_ADDR is always the proxy. Trusting the
* header unconditionally would let a caller forge their own throttle identity.
*/
function auth_client_ip(): ?string {
// A regra mudou-se para functions_client_ip.php, para o antispam dos sites
// que declaram o seu próprio auth_login() lhe poder chegar sem incluir este
// ficheiro. Aqui fica só o nome que o resto do módulo já chama.
return platform_client_ip();
}
// ─── Login / logout ─────────────────────────────────────────────────────────
/**
* Look up an active account by its login identifier and verify the password.
*
* The identifier is the `email` column unless the site overrode `login` in
* auth_schema() -- quack signs in with `username`.
*
* Returns the user row, or null. Deliberately does not distinguish "no such
* account" from "wrong password" to the caller, so a login form cannot be used
* to enumerate which identifiers have accounts.
*/
function auth_attempt(PDO $pdo, string $identifier, string $password): ?array {
$s = auth_schema();
$col = $s['login'] ?: $s['email'];
$st = $pdo->prepare(
"SELECT * FROM `{$s['table']}` WHERE `{$col}` = ? AND " . auth_active_sql() . " LIMIT 1"
);
$st->execute([mb_strtolower(trim($identifier))]);
$user = $st->fetch(PDO::FETCH_ASSOC) ?: null;
if ($user === null) {
// Spend roughly the time a real verify would, so response timing does
// not reveal whether the address exists.
password_verify($password, '$2y$12$usesomesillystringfoeXiOjbGRRjfxSTvlOKz.OKB0PfSUcnu');
return null;
}
return auth_password_verify($pdo, $user, $password) ? $user : null;
}
/** Establishes the session for a verified user. */
function auth_login(PDO $pdo, array $user): void {
$s = auth_schema();
auth_session_regenerate();
$_SESSION['user_id'] = (int) $user[$s['id']];
$_SESSION['user'] = [
'id' => (int) $user[$s['id']],
'email' => (string) ($user[$s['email']] ?? ''),
'name' => (string) ($user[$s['name']] ?? ''),
'role' => (string) ($user[$s['role']] ?? AUTH_ROLE_USER),
];
$_SESSION['_auth_seen'] = time();
if (function_exists('platform_audit')) {
platform_audit($pdo, 'auth.login', ['target_type' => 'user', 'target_id' => (int) $user[$s['id']],
'target_label' => (string) ($user[$s['email']] ?? '')]);
}
$sets = []; $params = [];
if ($s['last_login']) { $sets[] = "`{$s['last_login']}` = NOW()"; }
if ($s['last_ip']) { $sets[] = "`{$s['last_ip']}` = ?"; $params[] = auth_client_ip(); }
if ($sets) {
$params[] = $user[$s['id']];
try {
$pdo->prepare("UPDATE `{$s['table']}` SET " . implode(', ', $sets) . " WHERE `{$s['id']}` = ?")
->execute($params);
} catch (Throwable $e) {
error_log('[auth] last-login stamp failed: ' . $e->getMessage());
}
}
}
function auth_logout(bool $destroy = true): void {
// Antes de limpar a sessão: depois já não se sabe quem saiu.
if (function_exists('platform_audit') && !empty($_SESSION['user']['id'])) {
platform_audit(null, 'auth.logout', ['target_type' => 'user', 'target_id' => (int) $_SESSION['user']['id']]);
}
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000,
$p['path'], $p['domain'], $p['secure'], $p['httponly']);
}
if ($destroy && session_status() === PHP_SESSION_ACTIVE) session_destroy();
}
// ─── Guards ─────────────────────────────────────────────────────────────────
function auth_user(): ?array { return $_SESSION['user'] ?? null; }
function auth_id(): int { return (int) ($_SESSION['user']['id'] ?? 0); }
function auth_check(): bool { return auth_id() > 0; }
function auth_role(): string { return (string) ($_SESSION['user']['role'] ?? ''); }
/** Exact role match. Use auth_at_least() for "this role or better". */
function auth_is(string $role): bool { return auth_role() === $role; }
/** True when the current role ranks at or above $role. */
function auth_at_least(string $role): bool {
return auth_check() && auth_role_rank(auth_role()) >= auth_role_rank($role);
}
function auth_is_admin(): bool { return auth_at_least(AUTH_ROLE_ADMIN); }
function auth_is_sysadmin(): bool { return auth_is(AUTH_ROLE_SYSADMIN); }
/** Any one of the listed roles, exactly. For app roles that are peers. */
function auth_is_any(array $roles): bool {
return auth_check() && in_array(auth_role(), $roles, true);
}
/**
* Guards redirect rather than returning, and take the destination from the
* caller because every site names its login page differently.
*/
function auth_require_login(string $loginUrl): array {
if (!auth_check()) {
$_SESSION['auth_intended'] = $_SERVER['REQUEST_URI'] ?? null;
header('Location: ' . $loginUrl);
exit;
}
return auth_user();
}
function auth_require_at_least(string $role, string $loginUrl, ?string $denyUrl = null): array {
$user = auth_require_login($loginUrl);
if (!auth_at_least($role)) {
header('Location: ' . ($denyUrl ?: $loginUrl));
exit;
}
return $user;
}
function auth_require_any(array $roles, string $loginUrl, ?string $denyUrl = null): array {
$user = auth_require_login($loginUrl);
if (!auth_is_any($roles)) {
header('Location: ' . ($denyUrl ?: $loginUrl));
exit;
}
return $user;
}
/**
* Where to send the user after login: back where they were headed, if that
* destination is local. An open redirect here would turn the login page into a
* phishing hop, so anything absolute is discarded.
*/
function auth_intended_url(string $fallback): string {
$raw = $_SESSION['auth_intended'] ?? null;
unset($_SESSION['auth_intended']);
if (!is_string($raw) || $raw === '') return $fallback;
if (str_starts_with($raw, '//') || preg_match('#^[a-z][a-z0-9+.-]*:#i', $raw)) return $fallback;
return str_starts_with($raw, '/') ? $raw : $fallback;
}
@@ -1,640 +0,0 @@
<?php
// ─── Ciclo de vida de uma atualização ────────────────────────────────────────
//
// Everything that happens around an update, shared by the two ways of starting
// one (the "Atualizar" button and the deploy webhook): the pre-update backup
// gate, the restore point that makes an update reversible, and the webhook
// itself. Kept together because they are one sequence, not three features.
//
// Turns a git push into a deploy: Gitea POSTs here, the platform pulls itself,
// applies the migrations that arrived with the code, and drops the caches. The
// same sequence the "Atualizar plataforma" button runs, minus the human.
//
// Authentication is the webhook secret, HMAC-SHA256 over the RAW body. Both
// header spellings are accepted, so the endpoint works unchanged behind Gitea
// (X-Gitea-Signature) and GitHub (X-Hub-Signature-256):
//
// signature = hash_hmac('sha256', <raw body>, AUTO_UPDATE_SECRET)
//
// The secret lives in .env, not in system_settings: it is a credential you need
// in order to *repair* a site, so it must not depend on the database being
// readable. The on/off switch is a setting, because that is runtime policy an
// operator changes from the backoffice.
//
// Three properties matter more than convenience here, because nobody is
// watching when it runs:
//
// * It never discards work. git_safe_update() fast-forwards, tolerates
// mode-bit and submodule drift, and STOPS on real local commits or edits.
// An unattended `reset --hard` is how you lose the hotfix someone applied
// on the server at 2 a.m.
// * One at a time. A push burst would otherwise start several pulls in the
// same working tree; the lock makes the extras no-ops.
// * It always leaves a trace. Every run — refused, skipped or applied — is
// recorded in settings, which is what the platform page and the dashboard
// strip read.
//
// Public API:
// auto_update_enabled(PDO) / auto_update_migrations_enabled(PDO)
// auto_update_secret(): string
// auto_update_verify(string $body, string $secret): bool
// auto_update_payload_branch(string $body): ?string
// auto_update_run(PDO, string $repo_root, ?string $ref): array
// auto_update_last(PDO): array
// update_backup_settings(PDO): array
// platform_pre_update_backup(PDO, string $repo_root): array
// platform_respond_and_continue(int $code, array $data): void
// update_restore_point(PDO): ?array
// update_restore_point_store(PDO, string $repo_root, string $sha, array, array): void
// update_restore_point_apply(PDO, string $repo_root, bool $discard_local): array
// update_restore_point_clear(PDO): void
// platform_status_summary(PDO, string $repo_root): array
// platform_status_strip(PDO, string $repo_root, ?string $url): void
require_once __DIR__ . '/functions_git.php';
require_once __DIR__ . '/functions_migrations.php';
require_once __DIR__ . '/functions_settings.php';
require_once __DIR__ . '/functions_changelog.php';
function auto_update_secret(): string {
if (function_exists('env')) {
$v = (string) env('AUTO_UPDATE_SECRET', '');
if ($v !== '') return $v;
}
$v = getenv('AUTO_UPDATE_SECRET');
return $v !== false ? (string) $v : '';
}
function auto_update_enabled(PDO $pdo): bool {
return platform_settings_get($pdo, 'auto_update_enabled', '0') === '1';
}
/** Whether a deploy also applies the migrations that came with it. */
function auto_update_migrations_enabled(PDO $pdo): bool {
return platform_settings_get($pdo, 'auto_update_migrations', '1') === '1';
}
/**
* Constant-time check of the webhook signature against the raw body.
*
* Gitea sends the bare hex digest in X-Gitea-Signature; GitHub (and newer
* Gitea) send "sha256=<hex>" in X-Hub-Signature-256. Either satisfies us.
*/
function auto_update_verify(string $body, string $secret): bool {
if ($secret === '') return false;
$expected = hash_hmac('sha256', $body, $secret);
$offered = [];
if (!empty($_SERVER['HTTP_X_GITEA_SIGNATURE'])) {
$offered[] = trim((string) $_SERVER['HTTP_X_GITEA_SIGNATURE']);
}
if (!empty($_SERVER['HTTP_X_HUB_SIGNATURE_256'])) {
$sig = trim((string) $_SERVER['HTTP_X_HUB_SIGNATURE_256']);
$offered[] = str_starts_with($sig, 'sha256=') ? substr($sig, 7) : $sig;
}
foreach ($offered as $sig) {
if (hash_equals($expected, strtolower($sig))) return true;
}
return false;
}
/** Branch name from a push payload's `ref`, or null when it isn't a branch push. */
function auto_update_payload_branch(string $body): ?string {
$data = json_decode($body, true);
$ref = is_array($data) ? (string) ($data['ref'] ?? '') : '';
if ($ref === '' || !str_starts_with($ref, 'refs/heads/')) return null;
$branch = substr($ref, 11);
return $branch !== '' ? $branch : null;
}
/** Path of the per-repo lock file. Kept in the temp dir: always writable. */
function auto_update_lock_path(string $repo_root): string {
return sys_get_temp_dir() . '/auto_update_' . substr(sha1($repo_root), 0, 12) . '.lock';
}
/** Which backups to take before an update touches anything. */
function update_backup_settings(PDO $pdo): array {
return [
'db' => platform_settings_get($pdo, 'update_backup_db', '0') === '1',
'files' => platform_settings_get($pdo, 'update_backup_files', '0') === '1',
];
}
/**
* Load the host's backup wiring, if it has any.
*
* Order matters. The host wrapper (config/functions_backup.php) is tried FIRST
* because it defines BACKUP_TRIGGER_URL_PREFIX before including the shared
* file — on a public/ docroot, shared/ is not reachable over HTTP, and loading
* the shared file directly would bake in a trigger URL that 404s.
*/
function platform_backup_bootstrap(string $repo_root): bool {
if (function_exists('backup_trigger')) return true;
foreach ([$repo_root . '/config/functions_backup.php', __DIR__ . '/functions_backup.php'] as $f) {
if (is_file($f)) {
require_once $f;
if (function_exists('backup_trigger')) return true;
}
}
return false;
}
/** Filenames currently in the backup folder, for one type. */
function _platform_backup_names(string $type): array {
clearstatcache();
$names = [];
foreach (backup_list() as $b) {
if ($b['type'] === $type) $names[$b['filename']] = true;
}
return $names;
}
/**
* Take the configured backups and WAIT for them to land, before the update is
* allowed to touch anything.
*
* Waiting is the whole point. backup_trigger() is fire-and-forget — it starts a
* worker in a separate request and returns in milliseconds. Pulling straight
* after it would race the dump: migrations could apply halfway through it, and
* the "restore point" would be a snapshot of neither the old schema nor the new
* one. libzip writes the archive under a temp name and renames it on close, so
* the final filename appearing is a real completion signal, not a partial file.
*
* A backup that fails or times out ABORTS the update. A toggle that promises a
* restore point and then quietly proceeds without one is worse than no toggle.
*
* Returns ['ok' => bool, 'log' => string[], 'created' => string[]].
*/
function platform_pre_update_backup(PDO $pdo, string $repo_root, int $waitSeconds = 900): array {
$want = update_backup_settings($pdo);
$types = [];
if ($want['db']) $types[] = 'db';
if ($want['files']) $types[] = 'files';
if (!$types) return ['ok' => true, 'log' => [], 'created' => []];
$log = ['── Backup antes de atualizar ──'];
if (!platform_backup_bootstrap($repo_root)) {
$log[] = 'FALHA: esta plataforma não tem os helpers de backup instalados.';
return ['ok' => false, 'log' => $log, 'created' => []];
}
// The wait can outlive any sane max_execution_time — a files archive of a
// photo site is measured in gigabytes.
@set_time_limit(0);
$created = [];
foreach ($types as $type) {
$label = backup_type_label($type);
$before = _platform_backup_names($type);
if (!backup_trigger($type)) {
$log[] = "FALHA: não foi possível arrancar o backup de {$label}.";
return ['ok' => false, 'log' => $log, 'created' => $created];
}
$deadline = time() + $waitSeconds;
$new = null;
while (time() < $deadline) {
sleep(2);
foreach (array_keys(_platform_backup_names($type)) as $name) {
if (!isset($before[$name])) { $new = $name; break 2; }
}
}
if ($new === null) {
$log[] = "FALHA: o backup de {$label} não ficou pronto em {$waitSeconds}s."
. ' Atualização cancelada — nada foi alterado.';
return ['ok' => false, 'log' => $log, 'created' => $created];
}
$created[] = $new;
$log[] = "OK {$label}: {$new}";
}
return ['ok' => true, 'log' => $log, 'created' => $created];
}
// ─── Ponto de restauro ───────────────────────────────────────────────────────
//
// "Atualizei, testei, correu mal, quero voltar atrás." Every update that
// actually moves HEAD records where it came from; the button then walks the
// branch back to that commit.
//
// `reset --hard` and not `revert`: the pulled commits still exist on the remote
// and in this repo's object store, so moving the branch pointer back loses
// nothing and leaves the working tree in exactly the state it had before the
// update — which is the state that was known to work. A `revert` would instead
// invent new commits that then have to be pushed, and would leave this server
// diverged from every other one.
//
// What it does NOT undo is the database. Migrations that ran during the update
// stay applied, and old code against a new schema can be worse than new code
// against it. So the restore point remembers which migrations ran and which
// backup was taken just before, and the UI says so plainly instead of implying
// a clean rewind.
/** HEAD's full SHA, or '' when it cannot be read. */
function update_head_sha(string $repo_root): string {
if (!function_exists('git_run')) return '';
$r = git_run(['rev-parse', 'HEAD'], $repo_root);
$s = $r['ok'] ? trim($r['output']) : '';
return preg_match('/^[0-9a-f]{40}$/', $s) ? $s : '';
}
/**
* Remember the commit an update started from.
*
* Only ever called when HEAD actually moved: recording a restore point for an
* update that changed nothing would offer a button that does nothing.
*/
function update_restore_point_store(
PDO $pdo, string $repo_root, string $sha,
array $migrations = [], array $backups = []
): void {
if (!preg_match('/^[0-9a-f]{40}$/', $sha)) return;
$info = git_run(['log', '-1', '--format=%s', $sha], $repo_root);
$when = git_run(['log', '-1', '--format=%cd', '--date=format:%Y-%m-%d %H:%M', $sha], $repo_root);
$br = git_run(['rev-parse', '--abbrev-ref', 'HEAD'], $repo_root);
try {
platform_settings_set($pdo, 'update_restore_sha', $sha);
platform_settings_set($pdo, 'update_restore_subject', $info['ok'] ? trim($info['output']) : '');
platform_settings_set($pdo, 'update_restore_commit_at', $when['ok'] ? trim($when['output']) : '');
platform_settings_set($pdo, 'update_restore_branch', $br['ok'] ? trim($br['output']) : '');
platform_settings_set($pdo, 'update_restore_at', gmdate('Y-m-d H:i:s'));
platform_settings_set($pdo, 'update_restore_migrations', implode(',', $migrations));
platform_settings_set($pdo, 'update_restore_backups', implode(',', $backups));
} catch (Throwable $e) {
error_log('[restore_point] ' . $e->getMessage());
}
}
/** The current restore point, or null when there is nothing to go back to. */
function update_restore_point(PDO $pdo): ?array {
$sha = (string) platform_settings_get($pdo, 'update_restore_sha', '');
if (!preg_match('/^[0-9a-f]{40}$/', $sha)) return null;
$mig = array_values(array_filter(explode(',', (string) platform_settings_get($pdo, 'update_restore_migrations', ''))));
$bk = array_values(array_filter(explode(',', (string) platform_settings_get($pdo, 'update_restore_backups', ''))));
return [
'sha' => $sha,
'short' => substr($sha, 0, 8),
'subject' => (string) platform_settings_get($pdo, 'update_restore_subject', ''),
'commit_at' => (string) platform_settings_get($pdo, 'update_restore_commit_at', ''),
'branch' => (string) platform_settings_get($pdo, 'update_restore_branch', ''),
'updated_at' => (string) platform_settings_get($pdo, 'update_restore_at', ''),
'migrations' => $mig,
'backups' => $bk,
];
}
function update_restore_point_clear(PDO $pdo): void {
foreach (['sha', 'subject', 'commit_at', 'branch', 'at', 'migrations', 'backups'] as $k) {
try { platform_settings_set($pdo, 'update_restore_' . $k, ''); } catch (Throwable $e) {}
}
}
/**
* Walk the branch back to the recorded commit.
*
* The SHA comes from our own settings row and is re-validated as a real commit
* in this repository — a reset target is never read from the request, so a
* forged POST cannot point this at anything.
*
* Refuses while there are uncommitted content changes unless $discard_local is
* explicitly set: `reset --hard` would silently destroy them, and somebody
* debugging a bad deploy is exactly the person likely to have edited a file on
* the server.
*/
function update_restore_point_apply(PDO $pdo, string $repo_root, bool $discard_local = false): array {
$rp = update_restore_point($pdo);
if ($rp === null) {
return ['ok' => false, 'output' => 'Não há ponto de restauro registado.'];
}
$exists = git_run(['cat-file', '-e', $rp['sha'] . '^{commit}'], $repo_root);
if (!$exists['ok']) {
return ['ok' => false, 'output' =>
"O commit {$rp['short']} já não existe neste repositório — não é possível voltar atrás por aqui."];
}
$log = [];
$state = git_local_state($repo_root);
if ($state['content_dirty'] && !$discard_local) {
return ['ok' => false, 'output' => implode("\n", [
'── Recusado: há alterações locais por comitar ──',
$state['dirty_files'],
'',
'Um reset --hard apagava-as. Comita, faz stash, ou marca',
'"Confirmo descartar alterações locais" para avançar mesmo assim.',
])];
}
$undo = git_run(['rev-list', '--count', $rp['sha'] . '..HEAD'], $repo_root);
$n = $undo['ok'] ? (int) trim($undo['output']) : 0;
$log[] = "── Voltar a {$rp['short']} — " . ($rp['subject'] ?: '(sem assunto)') . ' ──';
if ($n > 0) $log[] = "A desfazer {$n} commit(s) locais. Continuam no remote; isto só recua este servidor.";
$reset = git_run(['reset', '--hard', $rp['sha']], $repo_root);
$log[] = trim($reset['output']) !== '' ? $reset['output'] : '(reset --hard concluído)';
if (!$reset['ok']) {
return ['ok' => false, 'output' => implode("\n", $log)];
}
$log[] = '';
$log[] = '── Submódulos ──';
foreach (git_sync_submodules($repo_root)['log'] as $l) $log[] = $l;
if (function_exists('opcache_reset')) @opcache_reset();
if (function_exists('settings_cache_clear')) settings_cache_clear();
platform_settings_cache_clear();
$log[] = '';
$log[] = '(OPcache + caches de definições invalidados)';
if ($rp['migrations']) {
$log[] = '';
$log[] = '── ATENÇÃO: a base de dados NÃO recuou ──';
$log[] = 'Estas migrations correram nesta atualização e continuam aplicadas:';
foreach ($rp['migrations'] as $m) $log[] = ' · ' . $m;
if ($rp['backups']) {
$log[] = '';
$log[] = 'Backup tirado imediatamente antes da atualização:';
foreach ($rp['backups'] as $b) $log[] = ' · ' . $b;
$log[] = 'Restaura-o pela página Restaurar base de dados se o esquema novo for o problema.';
}
}
// Consumed: the branch is back where it was, so there is nothing left to
// undo. The next real update records a fresh one.
update_restore_point_clear($pdo);
try {
platform_settings_set($pdo, 'update_reverted_at', gmdate('Y-m-d H:i:s'));
platform_settings_set($pdo, 'update_reverted_to', $rp['short']);
} catch (Throwable $e) {}
return ['ok' => true, 'output' => implode("\n", $log), 'undone' => $n];
}
/**
* Send the response, then keep working after the client has gone.
*
* Gitea gives a webhook delivery 5 seconds by default. A git pull alone beats
* that, and a pre-update backup of a media library beats it by minutes — so
* holding the connection until the work finished would paint every successful
* deploy red in Gitea's delivery list. Answer immediately, work afterwards, and
* let the platform page and the dashboard strip carry the outcome.
*/
function platform_respond_and_continue(int $code, array $data): void {
ignore_user_abort(true);
@set_time_limit(0);
$body = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
header('Content-Length: ' . strlen($body));
header('Connection: close');
echo $body;
while (ob_get_level() > 0) @ob_end_flush();
@flush();
// PHP-FPM hands the connection back here; on mod_php it is the
// Content-Length + Connection: close above that releases the client.
if (function_exists('fastcgi_finish_request')) @fastcgi_finish_request();
}
/**
* Pull, migrate, invalidate. Returns:
* ['ok' => bool, 'status' => 'updated'|'busy'|'failed', 'summary' => string,
* 'output' => string, 'migrations' => string[]]
*
* $ref is the branch the push was for, or null for a manual run.
*/
function auto_update_run(PDO $pdo, string $repo_root, ?string $ref = null): array {
$lockFile = @fopen(auto_update_lock_path($repo_root), 'c');
if (!$lockFile || !flock($lockFile, LOCK_EX | LOCK_NB)) {
if ($lockFile) fclose($lockFile);
// Not an error: a push burst means several deliveries for one state, and
// the run already in flight is picking up the same commits.
return [
'ok' => true, 'status' => 'busy', 'migrations' => [],
'summary' => 'Já havia uma atualização a decorrer — este pedido foi ignorado.',
'output' => '',
];
}
@set_time_limit(300);
$log = [];
$log[] = '── Auto-update ' . gmdate('Y-m-d H:i:s') . ' UTC'
. ($ref !== null ? ' · push para ' . $ref : ' · execução manual') . ' ──';
// First, and blocking: the restore point has to exist before anything moves.
$bk = platform_pre_update_backup($pdo, $repo_root);
foreach ($bk['log'] as $l) $log[] = $l;
if (!$bk['ok']) {
flock($lockFile, LOCK_UN);
fclose($lockFile);
$out = ['ok' => false, 'status' => 'failed', 'migrations' => [],
'summary' => 'Backup pré-atualização falhou — nada foi atualizado.',
'output' => implode("\n", $log)];
auto_update_record($pdo, $out, $ref);
return $out;
}
if ($bk['log']) $log[] = '';
$before_sha = update_head_sha($repo_root);
$res = git_safe_update($repo_root);
$log[] = $res['output'] !== '' ? $res['output'] : '(sem saída)';
if (!$res['ok']) {
flock($lockFile, LOCK_UN);
fclose($lockFile);
$out = ['ok' => false, 'status' => 'failed', 'migrations' => [],
'summary' => 'Atualização interrompida — há trabalho local por resolver no servidor.',
'output' => implode("\n", $log)];
auto_update_record($pdo, $out, $ref);
return $out;
}
// Migrations arrive in the same commit as the code that needs them, so a
// deploy that pulls one without the other leaves the site broken between
// the two. Applied in filename order; the first failure stops the rest.
$applied = [];
if (auto_update_migrations_enabled($pdo)) {
$log[] = '';
$log[] = '── Migrations pendentes ──';
$error = null;
foreach (migrations_status($pdo) as $row) {
if ($row['applied_at']) continue;
$r = migrations_apply_file($pdo, $row['filename'], null);
if (!empty($r['ok'])) {
$applied[] = $row['filename'];
$log[] = 'OK ' . $row['filename'] . ' (' . $r['applied'] . '/' . $r['total'] . ' statements)';
} else {
$error = $row['filename'] . ': ' . ($r['error'] ?? 'erro desconhecido');
$log[] = 'FALHA ' . $error;
break;
}
}
if (!$applied && $error === null) $log[] = '(nenhuma pendente)';
if ($error !== null) {
flock($lockFile, LOCK_UN);
fclose($lockFile);
$out = ['ok' => false, 'status' => 'failed', 'migrations' => $applied,
'summary' => 'Código atualizado, mas uma migration falhou: ' . $error,
'output' => implode("\n", $log)];
auto_update_record($pdo, $out, $ref);
return $out;
}
} else {
$log[] = '';
$log[] = '(migrations automáticas desligadas — aplicar à mão)';
}
if (function_exists('opcache_reset')) @opcache_reset();
if (function_exists('settings_cache_clear')) settings_cache_clear();
platform_settings_cache_clear();
$log[] = '';
$log[] = '(OPcache + caches de definições invalidados)';
flock($lockFile, LOCK_UN);
fclose($lockFile);
// Só se HEAD mexeu mesmo. Um deploy que não trouxe nada não deve deixar um
// botão "reverter" que não reverteria coisa nenhuma — e não apaga o ponto
// de restauro da última atualização que trouxe.
$after_sha = update_head_sha($repo_root);
if ($before_sha !== '' && $after_sha !== '' && $before_sha !== $after_sha) {
update_restore_point_store($pdo, $repo_root, $before_sha, $applied, $bk['created']);
$log[] = 'Ponto de restauro: ' . substr($before_sha, 0, 8);
}
$summary = 'Plataforma atualizada'
. ($applied ? ' · ' . count($applied) . ' migration(s) aplicada(s)' : '')
. '.';
$out = ['ok' => true, 'status' => 'updated', 'migrations' => $applied,
'summary' => $summary, 'output' => implode("\n", $log)];
auto_update_record($pdo, $out, $ref);
return $out;
}
/** Persist the outcome so the UI can show it without re-running anything. */
function auto_update_record(PDO $pdo, array $result, ?string $ref): void {
if (function_exists('platform_audit')) {
// Sem sessão (webhook do Gitea) o autor é a própria atualização automática.
platform_audit($pdo, 'platform.auto_update', [
'actor' => platform_audit_actor() ?? ['id' => null, 'name' => 'atualização automática', 'role' => 'webhook'],
'target_type' => 'platform',
'summary' => (string) ($result['summary'] ?? '') . (empty($result['ok']) ? ' — falhou' : ''),
'changes' => ['status' => $result['status'] ?? null, 'ref' => $ref],
]);
}
try {
platform_settings_set($pdo, 'auto_update_last_at', gmdate('Y-m-d H:i:s'));
platform_settings_set($pdo, 'auto_update_last_ok', !empty($result['ok']) ? '1' : '0');
platform_settings_set($pdo, 'auto_update_last_status', (string) ($result['status'] ?? ''));
platform_settings_set($pdo, 'auto_update_last_summary', (string) ($result['summary'] ?? ''));
platform_settings_set($pdo, 'auto_update_last_ref', (string) ($ref ?? ''));
// Bounded: this is a settings row, not a log file.
platform_settings_set($pdo, 'auto_update_last_output',
mb_substr((string) ($result['output'] ?? ''), -8000));
} catch (Throwable $e) {
error_log('[auto_update] ' . $e->getMessage());
}
}
/** Last recorded run, with sane defaults when there has never been one. */
function auto_update_last(PDO $pdo): array {
$at = (string) platform_settings_get($pdo, 'auto_update_last_at', '');
return [
'at' => $at !== '' ? $at : null,
'ok' => platform_settings_get($pdo, 'auto_update_last_ok', '') === '1',
'status' => (string) platform_settings_get($pdo, 'auto_update_last_status', ''),
'summary' => (string) platform_settings_get($pdo, 'auto_update_last_summary', ''),
'ref' => (string) platform_settings_get($pdo, 'auto_update_last_ref', ''),
'output' => (string) platform_settings_get($pdo, 'auto_update_last_output', ''),
];
}
/**
* Everything the "at a glance" strip needs, and nothing that costs a process.
*
* Deliberately git-free: this runs on a dashboard that loads on every visit,
* and a `git fetch` there would put a network round-trip in front of the page.
* Whether the remote has moved is a question for the platform page.
*/
function platform_status_summary(PDO $pdo, string $repo_root): array {
$pending = 0;
try {
foreach (migrations_status($pdo) as $r) {
if ($r['applied_at'] === null) $pending++;
}
} catch (Throwable $e) {
$pending = null; // table unreachable — say nothing rather than "0"
}
return [
'version' => changelog_latest()['version'],
'migrations_pending' => $pending,
'auto_update' => auto_update_last($pdo),
'auto_update_on' => auto_update_enabled($pdo),
];
}
/**
* One discreet line of platform state, for a backoffice dashboard.
*
* A muted strip, not a card: this is ambient information for whoever
* administers the site, and it must not compete with the day's work on that
* page. It raises its voice only when something is actually pending.
*/
function platform_status_strip(PDO $pdo, string $repo_root, ?string $url = null): void {
$s = platform_status_summary($pdo, $repo_root);
$url = $url ?? platform_admin_url('platform_update.php');
$au = $s['auto_update'];
$e = fn($v) => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
?>
<div class="d-flex align-items-center flex-wrap gap-2 small text-muted mb-3">
<i class="bi bi-hdd-network"></i>
<?php if ($s['version'] !== null): ?>
<span><?= $e($s['version']) ?></span>
<?php endif; ?>
<?php if (!empty($s['migrations_pending'])): ?>
<span class="text-secondary">·</span>
<a href="<?= $e($url) ?>" class="text-decoration-none">
<span class="badge text-bg-warning text-dark"><?= (int) $s['migrations_pending'] ?></span>
migration(s) por aplicar
</a>
<?php endif; ?>
<?php if ($s['auto_update_on']): ?>
<span class="text-secondary">·</span>
<span<?= $au['summary'] !== '' ? ' title="' . $e($au['summary']) . '"' : '' ?>>
<i class="bi bi-arrow-repeat"></i>
auto-update
<?php if ($au['at'] !== null): ?>
<?php if (!$au['ok']): ?><span class="text-danger fw-semibold">falhou</span> <?php endif; ?>
<?= $e(date('d/m H:i', strtotime($au['at'] . ' UTC'))) ?>
<?php else: ?>
ligado
<?php endif; ?>
</span>
<?php elseif ($au['at'] !== null && !$au['ok']): ?>
<span class="text-secondary">·</span>
<span class="text-danger">a última atualização automática falhou</span>
<?php endif; ?>
<a href="<?= $e($url) ?>" class="ms-auto text-decoration-none">Plataforma <i class="bi bi-chevron-right"></i></a>
</div>
<?php
}
-372
View File
@@ -1,372 +0,0 @@
<?php
// ─── Backup helpers (shared) ─────────────────────────────────────────────────
//
// Path assumptions (when this file lives at <host>/shared/platform-tools/):
// backup_dir() → <host>/backups/ (where ZIPs land + are listed)
// trigger_url($t) → base_url('shared/platform-tools/backups/<script>.php?token=…')
// Override the URL prefix by defining BACKUP_TRIGGER_URL_PREFIX
// before this file loads.
//
// Public API:
// backup_dir(): string
// backup_filename_is_safe(string): bool
// backup_human_size(int): string
// backup_list(): array
// backup_trigger(string $type): bool ($type in {files, db})
// backup_type_label(string): string
// backup_type_badge(string): string
// backup_retention_policy(): array
// backup_retention_summary(?array $policy = null): string
// backup_created_at(string $filename, int $fallback): int
// backup_gfs_keep(array $items, array $policy, int $now): array
// backup_prune(bool $dryRun = false): array
function backup_dir(): string {
$dir = dirname(__DIR__, 2) . '/backups';
// The directory must exist on the host before we can call realpath().
if (!is_dir($dir)) @mkdir($dir, 0775, true);
$dir = realpath($dir) ?: $dir;
backup_dir_protect($dir);
return $dir;
}
/**
* Deny direct HTTP access to the backups folder. The ZIPs contain sensitive
* data (full DB dump, user uploads) and are only meant to leave the server
* through api/backup_download.php (auth'd + attachment header). Idempotent —
* writes the .htaccess once and leaves an existing one alone.
*/
function backup_dir_protect(string $dir): void {
$ht = $dir . '/.htaccess';
if (is_file($ht)) return;
@file_put_contents($ht,
"# Gerado por platform-tools. Os backups contêm dados sensíveis (BD completa,\n"
. "# uploads); só podem sair do servidor via api/backup_download.php.\n"
. "<IfModule mod_authz_core.c>\n"
. " Require all denied\n"
. "</IfModule>\n"
. "<IfModule !mod_authz_core.c>\n"
. " Order allow,deny\n"
. " Deny from all\n"
. "</IfModule>\n"
);
}
function backup_filename_is_safe(string $filename): bool {
// Two generations coexist: the legacy minute-granularity name and the
// current one with seconds + random suffix (unguessable, no same-minute
// overwrite). Both must stay listable/downloadable.
return (bool) preg_match(
'/^(db_)?backup_\d{2}_\d{2}_\d{4}_\d{2}_\d{2}(?:_\d{2}_[0-9a-f]{6})?\.zip$/',
$filename
);
}
function backup_human_size(int $bytes): string {
// GB matters: um site com galerias produz arquivos de uploads na casa dos
// milhares de MB, e "4812.9 MB" obriga a dividir de cabeça para perceber a
// ordem de grandeza — que é exactamente o que se está a tentar ler.
if ($bytes >= 1_073_741_824) {
return round($bytes / 1_073_741_824, 1) . ' GB';
}
if ($bytes >= 1_048_576) {
return round($bytes / 1_048_576, 1) . ' MB';
}
return round($bytes / 1_024, 1) . ' KB';
}
function backup_list(): array {
$dir = backup_dir();
$files = glob($dir . '/*.zip') ?: [];
$items = [];
foreach ($files as $path) {
$name = basename($path);
if (!backup_filename_is_safe($name)) {
continue;
}
$type = str_starts_with($name, 'db_backup_') ? 'db' : 'files';
$bytes = filesize($path);
$items[] = [
'filename' => $name,
'size_bytes' => $bytes,
'size_human' => backup_human_size($bytes),
'created_at' => filemtime($path),
'type' => $type,
];
}
usort($items, fn($a, $b) => $b['created_at'] - $a['created_at']);
return $items;
}
function backup_trigger(string $type): bool {
if (!in_array($type, ['files', 'db'], true)) {
return false;
}
if (!function_exists('curl_init')) {
error_log('backup_trigger: cURL não disponível');
return false;
}
if (!defined('BACKUP_TOKEN')) {
require_once __DIR__ . '/backups/config.php';
}
$script = $type === 'db' ? 'db_backup.php' : 'backup_maker.php';
// Where the self-call lands. Default assumes shared/ is inside the document
// root. Under a public/ docroot it is not, so hosts point this at a thin
// wrapper instead. Constant wins (fotografia defines it in
// config/functions_backup.php); .env covers hosts with no such file.
$prefix = defined('BACKUP_TRIGGER_URL_PREFIX')
? BACKUP_TRIGGER_URL_PREFIX
: (function_exists('env')
? ((string) (env('BACKUP_TRIGGER_URL_PREFIX') ?: 'shared/platform-tools/backups/'))
: 'shared/platform-tools/backups/');
// base_url() when the host has one; APP_BASE_URL from .env otherwise, so a
// host without the helper still works.
$path = ltrim($prefix . $script, '/');
if (function_exists('base_url')) {
$url = base_url($path);
} else {
$base = function_exists('env') ? (string) env('APP_BASE_URL', '') : (string) getenv('APP_BASE_URL');
if ($base === '') {
error_log('backup_trigger: no base URL available (no base_url(), no APP_BASE_URL).');
return false;
}
$url = rtrim($base, '/') . '/' . $path;
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
// Fire-and-forget; the worker keeps running via ignore_user_abort + set_time_limit(0).
CURLOPT_TIMEOUT => 5,
// Self-call to the host's own base_url — hosts frequently sit behind
// self-signed/internal certs, hence no peer verification here.
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => false,
CURLOPT_FOLLOWLOCATION => true,
// Token no corpo do POST, não no URL — fora dos access logs.
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query(['token' => BACKUP_TOKEN]),
// Um redirect (http→https, www) não pode transformar o POST em GET
// e deixar cair o corpo — o token iria com ele.
CURLOPT_POSTREDIR => 7, // CURL_REDIR_POST_ALL
]);
curl_exec($ch);
$errno = curl_errno($ch);
curl_close($ch);
// errno 28 = CURLE_OPERATION_TIMEDOUT — expected when backup takes longer than 5 s.
$ok = $errno === 0 || $errno === 28;
error_log("backup_trigger: type={$type} curl_errno={$errno} ok=" . ($ok ? 'yes' : 'no'));
return $ok;
}
// ─── Retenção avô-pai-filho ─────────────────────────────────────────────────
//
// Antes: tudo o que tivesse mais de BACKUP_KEEP_DAYS dias era apagado, e tudo o
// que tivesse menos ficava — incluindo cinco cópias do mesmo dia (a da noite, a
// de antes de atualizar, as do hub, as de segurança de um restauro). Num site
// com galerias são gigabytes por cópia.
//
// Agora, por tipo (db e ficheiros contam em separado):
// • tudo das últimas 24 h — são os pontos de restauro de uma atualização;
// • a mais recente de cada um dos últimos BACKUP_KEEP_DAYS dias com cópia;
// • a mais recente de cada uma das últimas BACKUP_KEEP_WEEKS semanas;
// • a mais recente de cada um dos últimos BACKUP_KEEP_MONTHS meses;
// • e a mais recente de todas, sempre.
//
// Cada escalão começa onde o anterior acaba: as semanais são semanas mais
// antigas do que a diária mais antiga, e as mensais meses mais antigos do que a
// semanal mais antiga. Sem isto a «mensal» era a cópia mais recente do mês
// corrente — que já é a diária de hoje — e BACKUP_KEEP_MONTHS=1 não guardava
// nada. Com 7/4/1 cobre-se cerca de dois meses.
//
// «Dias com cópia», e não «dias de calendário»: se as cópias pararem durante um
// mês, a poda não pode ficar sem nada para guardar — é exactamente quando as
// antigas são as únicas que há.
function backup_retention_policy(): array {
$read = static function (string $key, int $default): int {
if (defined($key)) return max(0, (int) constant($key));
$v = function_exists('env') ? env($key, null) : (getenv($key) !== false ? getenv($key) : null);
return ($v === null || $v === '') ? $default : max(0, (int) $v);
};
return [
'recent_hours' => 24,
'days' => $read('BACKUP_KEEP_DAYS', 7),
'weeks' => $read('BACKUP_KEEP_WEEKS', 4),
'months' => $read('BACKUP_KEEP_MONTHS', 1),
];
}
/**
* A data da cópia pelo nome do ficheiro, que não muda quando o ficheiro é
* copiado ou restaurado de outro disco — ao contrário do mtime.
*/
function backup_created_at(string $filename, int $fallback): int {
if (preg_match('/_(\d{2})_(\d{2})_(\d{4})_(\d{2})_(\d{2})(?:_(\d{2}))?(?:_[0-9a-f]{6})?\.zip$/', $filename, $m)) {
$ts = mktime((int) $m[4], (int) $m[5], (int) ($m[6] ?? 0), (int) $m[2], (int) $m[1], (int) $m[3]);
if ($ts !== false) return $ts;
}
return $fallback;
}
/**
* Que cópias ficam. Função pura: recebe a lista (filename, type, created_at) e
* devolve [filename => motivo]. O que não vier na resposta é para apagar.
*/
function backup_gfs_keep(array $items, array $policy, int $now): array {
$tiers = [
'days' => ['Y-m-d', 'diária'],
'weeks' => ['o-\WW', 'semanal'],
'months' => ['Y-m', 'mensal'],
];
$byType = [];
foreach ($items as $it) $byType[$it['type']][] = $it;
$keep = [];
foreach ($byType as $list) {
usort($list, fn($a, $b) => $b['created_at'] <=> $a['created_at']);
$keep[$list[0]['filename']] = 'mais recente';
foreach ($list as $it) {
if ($now - (int) $it['created_at'] < $policy['recent_hours'] * 3600) {
$keep[$it['filename']] ??= 'últimas ' . $policy['recent_hours'] . ' h';
}
}
// $floor: a cópia mais antiga guardada pelo escalão anterior. O escalão
// seguinte só olha para o que é mais antigo, e salta o balde dela — esse
// período já está representado.
$floor = null;
foreach ($tiers as $tier => [$fmt, $label]) {
$skip = $floor === null ? null : date($fmt, $floor);
$buckets = [];
$oldest = null;
// Da mais recente para a mais antiga: a primeira que cai num balde é
// a mais recente desse dia/semana/mês.
foreach ($list as $it) {
$t = (int) $it['created_at'];
if ($floor !== null && $t >= $floor) continue;
if (count($buckets) >= $policy[$tier]) break;
$key = date($fmt, $t);
if ($key === $skip || isset($buckets[$key])) continue;
$buckets[$key] = true;
$keep[$it['filename']] ??= $label;
$oldest = $t;
}
if ($oldest !== null) $floor = $oldest;
}
}
return $keep;
}
/**
* Aplica a retenção à pasta de cópias. Com $dryRun não apaga nada — diz o que
* apagaria. Devolve ['policy', 'kept' => [[filename, reason]], 'deleted', 'errors'].
*/
function backup_prune(bool $dryRun = false): array {
$dir = backup_dir();
$policy = backup_retention_policy();
$now = time();
$items = [];
foreach (backup_list() as $b) {
$b['created_at'] = backup_created_at($b['filename'], (int) $b['created_at']);
$items[] = $b;
}
$keep = backup_gfs_keep($items, $policy, $now);
$out = ['policy' => $policy, 'kept' => [], 'deleted' => [], 'errors' => []];
foreach ($items as $b) {
if (isset($keep[$b['filename']])) {
$out['kept'][] = ['filename' => $b['filename'], 'reason' => $keep[$b['filename']]];
} elseif ($dryRun || @unlink($dir . '/' . $b['filename'])) {
$out['deleted'][] = $b['filename'];
} else {
$out['errors'][] = $b['filename'];
}
}
// .sql de dumps interrompidos antes de irem para o ZIP: texto em claro que
// não aparece na lista. Um dia chega para não apanhar um dump a decorrer.
foreach (glob($dir . '/db_backup_*.sql') ?: [] as $f) {
if (filemtime($f) < $now - 86400) {
if ($dryRun || @unlink($f)) $out['deleted'][] = basename($f);
else $out['errors'][] = basename($f);
}
}
return $out;
}
/** Linha legível da política, para a página e para o log dos workers. */
function backup_retention_summary(?array $policy = null): string {
$p = $policy ?? backup_retention_policy();
return sprintf('tudo das últimas %d h, e a mais recente de cada um dos últimos %d dias, %d semanas e %d meses',
$p['recent_hours'], $p['days'], $p['weeks'], $p['months']);
}
function backup_type_label(string $type): string {
return match($type) {
'db' => 'Base de dados',
'files' => 'Uploads',
default => 'Desconhecido',
};
}
function backup_type_badge(string $type): string {
return match($type) {
'db' => 'info',
'files' => 'secondary',
default => 'dark',
};
}
// ─── Encriptação dos artefactos ─────────────────────────────────────────────
//
// Um dump de base de dados em claro no disco é o ficheiro mais sensível que uma
// destas plataformas produz: leva a tabela users com os hashes, os contactos, e
// no caso do dance os contratos assinados -- que incluem dados de menores.
//
// A chave é derivada da APP_SECRET_KEY, que só existe no .env e nunca entra num
// dump. Isso tem uma consequência que é preciso dizer alto: **perder a
// APP_SECRET_KEY passa a significar perder todos os backups encriptados com
// ela.** Guarde-a onde guarda as credenciais do servidor, não só no .env.
//
// Sem chave, o backup sai em claro e diz-se no log. Recusar-se a fazer backup
// por falta de chave seria pior do que um backup desprotegido: a alternativa a
// um ficheiro legível não é um ficheiro seguro, é ficheiro nenhum.
function platform_backup_key(): ?string
{
foreach (['APP_SECRET_KEY', 'BACKUP_TOKEN'] as $k) {
$v = defined($k) ? (string) constant($k) : '';
if ($v === '' && function_exists('env')) {
$v = (string) env($k, '');
}
if ($v === '') {
$v = (string) (getenv($k) ?: '');
}
if ($v !== '' && $v !== 'replace_me') {
// Separada por domínio: a mesma APP_SECRET_KEY cifra as settings, e
// a mesma chave para dois fins nunca é boa ideia.
return hash('sha256', 'am-backup|' . $v);
}
}
return null;
}
/** Há chave para encriptar? A página de backups usa isto para avisar. */
function platform_backup_encryption_available(): bool
{
return platform_backup_key() !== null
&& class_exists('ZipArchive')
&& method_exists('ZipArchive', 'setEncryptionName')
&& defined('ZipArchive::EM_AES_256');
}
Loaded 100 of 1889 files, more files were not shown because too many files have changed in this diff. Show more