133 Commits
Author SHA1 Message Date
andre c461396654 Merge pull request 'style(contactos): info and hours beside the form' (#6) from beta into main 2026-10-27 14:02:58 +00:00
andre 2089f92750 style(contactos): info and hours in two columns beside the form
Drops the duplicate «Envia-nos uma mensagem» heading (the app form has its
own) and gives the narrower form the right half.
2026-10-27 13:55:13 +00:00
andre dcef3cda45 Merge pull request 'feat(contactos): embed the FitMe app contact form' (#5) from beta into main 2026-10-27 13:53:08 +00:00
andre f018fa582d feat(contactos): embed the FitMe app contact form
Replaces the Zoho form in the contacts section with the app.fitme3070.pt
site-contacto iframe (passes page URL and UTM params, auto-height).
2026-10-27 13:47:35 +00:00
andre 18d037ac16 Merge pull request 'fix(gallery): lightbox click and mouse drag' (#4) from beta into main 2026-10-26 20:41:18 +00:00
andre f297e128fd fix(gallery): open lightbox on click and keep mouse drag alive
Pointer capture on pointerdown redirected the click to the strip, so photos
never opened; capture now starts only after a 5px drag. Native link drag is
blocked so it no longer cancels the mouse drag.
2026-10-26 20:40:17 +00:00
andre b22033e1bd Merge pull request 'release: promote beta to main (gyms, brand colours, JPEG XL, SEO per gym)' (#3) from beta into main
Merge pull request #3: promote beta to main
2026-10-26 19:47:48 +00:00
andre a8683658fa chore: merge main into beta (keep beta's dev toolbar) 2026-10-26 19:46:55 +00:00
andre 22edadf0d7 chore: stop tracking assistant notes 2026-10-26 16:55:12 +00:00
andre 2279970768 feat(footer): add Powered by andmore.pt credit 2026-10-26 16:52:20 +00:00
andre e804c52b4e feat(site): Comunidade section with Instagram posts the gym was tagged in
Per-gym Graph API /tags fetch with file cache and local thumbnails, section after the gallery, navbar link and admin page (Gestão > Site).
2026-10-26 14:19:43 +00:00
andre 6ac5213624 feat(galerias): upload several images at once, clear errors and JPEG XL placeholders 2026-10-26 13:48:02 +00:00
andre 6abc486630 feat(site): YourFit brand glyphs as modalidade icons, light plate for the other-gym logo button 2026-10-26 12:55:36 +00:00
andre 725f5b9c26 feat(site): per-gym SEO texts, event gym selector, hero/navbar polish
- Generated SEO titles/descriptions per gym; gym seo fields override each on its own
- Keywords, author, og:site_name, favicon and JSON-LD logo follow the gym
- CrossFit page 404s before output for gyms without a box
- Events: «Mostrar em» gym selector; event page uses its gym's brand
- Hero event button text colour from the event colour (--ev-on)
- Navbar bottom line stops at the other-gym head (--switch-w)
- Diagonal hero blur mask; remove the trial link
2026-10-26 12:15:37 +00:00
andre 785d1f6ceb style(hero): event card border in its button colour, no glow on hero text, lighter subtitle 2026-10-26 12:06:15 +00:00
andre 0bc14f5f72 style(navbar): match head slash in the colour of the gym it links to 2026-10-26 12:03:30 +00:00
andre e7ab661f4c feat(navbar): match head shows the other gym's brand, centred logo on CrossFit, mobile band
The match head is painted in the other gym's colours with a strip in this site's accent; below 992px it is a slashed band at the bottom of the open menu (menu height from the real header height). The CrossFit page gets the centred logo crossfade, the hero logo has a fixed 52px height, and the hero h1 shadow is softer with the frosted blur at .8.
2026-10-26 12:00:56 +00:00
andre 4b54a777b7 style(navbar): logo crossfade, match-head end, full-screen mobile menu, brand slash template 2026-10-26 11:53:44 +00:00
andre 2e8b6d20f6 style(navbar): smaller centred logo, hero clears the bar, liquid-glass backdrop 2026-10-26 11:34:11 +00:00
andre c2b1f98e96 docs: libjxl-tools in the production Dockerfile, pending rebuild 2026-10-26 11:30:35 +00:00
andre 04df7f8997 style(site): truly centred navbar, smoother logo docking, grain on logos and buttons, slashed title underlines 2026-10-26 11:27:28 +00:00
andre f72292fc02 docs: centred home logo, nav underline and hero event card 2026-10-26 11:24:15 +00:00
andre bfbd04a2d3 feat(site): centred home logo that docks on scroll, slashed nav underline, square defocused hero event 2026-10-26 11:23:28 +00:00
andre 4f44bc1609 feat(images): JPEG XL optimizer with webp/jpg fallback and <picture> serving
img_optimize() decodes .jxl with djxl or Imagick, resizes with GD, re-encodes
with cjxl and writes foto.jxl.webp beside it; skips cleanly without a decoder.
Gallery, hero, navbar logos and JSON-LD serve the JXL with the fallback, and
trash/removal move the fallback with its .jxl.
2026-10-26 11:18:13 +00:00
andre 8fe73eb5d6 style(navbar): dark outline instead of white halo on gym logo buttons 2026-10-26 10:15:22 +00:00
andre 49adbd0ae1 feat(site): navbar links only sections with content, Galeria link, brand-tinted gallery background 2026-10-26 10:14:04 +00:00
andre 1eb2a61031 fix(admin): empty DataTables listings no longer error; their message becomes emptyTable
docs: JPEG XL optimizer plan and image-input rule
2026-10-26 10:13:29 +00:00
andre cc677af92d style(contactos): opening hours stacked by day, one line per period, centred 2026-10-26 10:01:57 +00:00
andre 7fa2da73dc feat(galerias): accept JPEG XL photos and list every uploaded format 2026-10-26 10:00:18 +00:00
andre 14dbabcad5 style(site): logo gym buttons without face or glow, gallery off scroll hover, Mensalidades brand glow 2026-10-26 09:59:51 +00:00
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 4ed2dd3709 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:16:14 +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
andre 4c847267ea Merge pull request 'Promote beta: local-only dev setup and notes' (#2) from beta into main 2026-10-09 21:58:41 +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
andre 7dc47f79cc Merge pull request 'Promote beta' (#1) from beta into main 2026-10-09 21:19:50 +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
1904 changed files with 5714 additions and 166981 deletions

No files matched your search

+14 -3
View File
@@ -20,6 +20,10 @@ uploads/**/.trash/
# (config/functions_privacy.php) para o browser não as pedir à Google. # (config/functions_privacy.php) para o browser não as pedir à Google.
uploads/reviews/ uploads/reviews/
# Miniaturas das publicações do Instagram da secção «Comunidade»
# (config/functions_instagram.php), descarregadas a cada atualização.
uploads/instagram/
# Nota: as fotos da equipa, galerias e ícones em uploads/ ainda estão # Nota: as fotos da equipa, galerias e ícones em uploads/ ainda estão
# versionados (vieram assim do site antigo). Em produção uploads/ é um volume # versionados (vieram assim do site antigo). Em produção uploads/ é um volume
# (/srv/data/fitme.andmore.pt/uploads) e é esse a fonte; o que está no git é # (/srv/data/fitme.andmore.pt/uploads) e é esse a fonte; o que está no git é
@@ -57,11 +61,18 @@ Thumbs.db
.claude/worktrees/ .claude/worktrees/
.claude/ .claude/
CLAUDE.md
# ─── Dependências ─────────────────────────────────────────────────────────── # ─── Dependências ───────────────────────────────────────────────────────────
# vendor/ ESTÁ versionado de propósito: não há Composer nesta app e o # vendor/ é output do composer install, exceto vendor/PHPMailer: o
# PHPMailer foi copiado à mão (vendor/PHPMailer). Quando houver composer.json, # platform-tools partilhado (functions_mail.php) já espera esse caminho por
# acrescentar aqui "vendor/" e retirá-lo do índice. # 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/ node_modules/
# ─── Lixo de editores / temporários ───────────────────────────────────────── # ─── 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 # Disable directory listing
Options -Indexes 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 # Block direct HTTP access to sensitive files
<FilesMatch "^\.(env|env\..*|git.*|htaccess|htpasswd)$"> <FilesMatch "^\.(env|env\..*|git.*|htaccess|htpasswd)$">
Require all denied Require all denied
@@ -16,6 +21,10 @@ Options -Indexes
RewriteEngine On RewriteEngine On
RewriteRule "(^|/)\.(?!well-known)" - [F,L] 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: # 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; # - parciais (_form.php e afins) só se incluem, nunca se pedem;
# - PHP soltos do platform-tools: só api/ e backups/ (auto-chamada com token) # - 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] RewriteRule "\.md$" - [F,L]
# ── Headers de segurança ───────────────────────────────────────────────────── # ── Headers de segurança ─────────────────────────────────────────────────────
# A CSP não restringe scripts ainda (o site tem scripts inline e incorporações # script-src: 'self' (todo o JS próprio, incl. Bootstrap/jQuery/Summernote
# da Google); fecha o que não parte nada: quem pode meter o site num iframe, # vendorizados desde 25/09) + 'unsafe-inline' (o site tem vários <script>
# <base>, plugins e para onde os formulários podem enviar. # 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> <IfModule mod_headers.c>
Header always set X-Content-Type-Options "nosniff" Header always set X-Content-Type-Options "nosniff"
Header always set Referrer-Policy "strict-origin-when-cross-origin" Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set X-Frame-Options "SAMEORIGIN" Header always set X-Frame-Options "SAMEORIGIN"
Header always set Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" 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> </IfModule>
# ── Canonical host: force HTTPS + strip www ─────────────────────────────────── # ── Canonical host: force HTTPS + strip www ───────────────────────────────────
@@ -51,6 +63,9 @@ RewriteRule ^site/assets/ - [L]
RewriteRule ^assets/ - [L] RewriteRule ^assets/ - [L]
# ── Friendly URLs ───────────────────────────────────────────────────────────── # ── 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/?$ site/index.php [L]
RewriteRule ^home/equipa/?$ site/equipa.php [L,QSA] RewriteRule ^home/equipa/?$ site/equipa.php [L,QSA]
RewriteRule ^home/modalidades/?$ site/modalidades.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.
+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; }
+69
View File
@@ -0,0 +1,69 @@
// 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;
// Listagem vazia: as páginas desenham uma única linha <td colspan> com a
// mensagem, que a DataTables rejeita (nº de colunas errado → erro). Tira-se
// a linha e a mensagem dela (com links) passa a ser o emptyTable.
var language = DATATABLES_PT_PT;
var $rows = $table.find('tbody > tr');
var $only = $rows.length === 1 ? $rows.children('td[colspan]') : jQuery();
if ($only.length === 1 && $rows.children().length === 1) {
language = jQuery.extend({}, DATATABLES_PT_PT, { emptyTable: $only.html() });
$rows.remove();
}
return $table.DataTable(jQuery.extend(true, {
language: language,
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 <?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 // Auditoria 2026-09-24, ponto 1.3: estas funções deixaram de ter lógica
// views/auth/login.php is a real form. There is no identity provider in front // própria de sessão/roles — delegam em
// of the app. // shared/platform-tools/functions_auth.php (auth_check/auth_user/
// auth_at_least/auth_is/...), ligado em config/db.php ('auth' => 'shared').
function require_login(): array { // Os nomes e assinaturas mantêm-se exactamente iguais porque há ~30
if (empty($_SESSION['user_id'])) { // ficheiros em views/ a chamá-las; só o que está por baixo mudou.
$return = $_SERVER['REQUEST_URI'] ?? '';
$qs = $return ? ('?return_to=' . urlencode($return)) : '';
header('Location: ' . base_url('views/auth/login.php' . $qs));
exit;
}
return $_SESSION['user'];
}
// ─── Role ladder ───────────────────────────────────────────────────────────── // ─── Role ladder ─────────────────────────────────────────────────────────────
// sysadmin — super-admin (backups, platform update, app_base_url). // sysadmin — super-admin (backups, platform update, app_base_url).
@@ -24,6 +18,12 @@ function require_login(): array {
// client — portal-only. // client — portal-only.
// is_admin()/require_admin() = sysadmin OR admin. is_staff()/require_staff() // is_admin()/require_admin() = sysadmin OR admin. is_staff()/require_staff()
// also includes the plain `staff` role. // 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 // 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. // 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 // Accepts sysadmin + admin only. Plain staff and clients are bounced to the
// dashboard appropriate for their role (avoids redirect loops when an admin- // dashboard appropriate for their role (avoids redirect loops when an admin-
// only page bounces a staff user). // only page bounces a staff user).
function require_admin(): array { function require_admin(): array {
$user = require_login(); $user = require_login();
if (!in_array($user['role'], ['sysadmin', 'admin'])) { if (!auth_at_least('admin')) {
header('Location: ' . base_url(post_login_destination($user['role'] ?? ''))); header('Location: ' . base_url(post_login_destination($user['role'] ?? '')));
exit; exit;
} }
@@ -52,7 +67,7 @@ function require_admin(): array {
// Clients are redirected to the public index. // Clients are redirected to the public index.
function require_staff(): array { function require_staff(): array {
$user = require_login(); $user = require_login();
if (!in_array($user['role'], ['sysadmin', 'admin', 'staff'])) { if (!auth_at_least('staff')) {
header('Location: ' . base_url('index.php')); header('Location: ' . base_url('index.php'));
exit; exit;
} }
@@ -65,7 +80,7 @@ function require_staff(): array {
// the role-appropriate sessions list. // the role-appropriate sessions list.
function require_session_ownership(array $session): array { function require_session_ownership(array $session): array {
$user = require_staff(); $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']) { if ((int) ($session['photographer_id'] ?? 0) !== (int) $user['id']) {
header('Location: ' . base_url('views/sessions/index.php')); header('Location: ' . base_url('views/sessions/index.php'));
exit; exit;
@@ -77,7 +92,7 @@ function require_session_ownership(array $session): array {
// Plain admins are sent back to their dashboard. // Plain admins are sent back to their dashboard.
function require_sysadmin(): array { function require_sysadmin(): array {
$user = require_login(); $user = require_login();
if ($user['role'] !== 'sysadmin') { if (!auth_is('sysadmin')) {
header('Location: ' . base_url(post_login_destination($user['role'] ?? ''))); header('Location: ' . base_url(post_login_destination($user['role'] ?? '')));
exit; exit;
} }
@@ -85,34 +100,34 @@ function require_sysadmin(): array {
} }
function current_user(): ?array { function current_user(): ?array {
return $_SESSION['user'] ?? null; return auth_user();
} }
// True for sysadmin + admin (NOT plain staff). // True for sysadmin + admin (NOT plain staff).
function is_admin(): bool { 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 // True for any backoffice user (sysadmin + admin + staff). Use this to show
// a UI element that any non-client backoffice user should see. // a UI element that any non-client backoffice user should see.
function is_staff(): bool { function is_staff(): bool {
return in_array(current_user()['role'] ?? '', ['sysadmin', 'admin', 'staff']); return auth_at_least('staff');
} }
function is_sysadmin(): bool { 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 // True for plain admins (not sysadmin, not staff). Useful for "hide from
// sysadmin" edge cases; most code wants is_admin() or is_sysadmin(). // sysadmin" edge cases; most code wants is_admin() or is_sysadmin().
function is_admin_role(): bool { 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 // True for plain staff only — the assignment-only role. Used to scope queries
// to "photographer_id = me" instead of returning all sessions. // to "photographer_id = me" instead of returning all sessions.
function is_staff_role(): bool { 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". // Back-compat shim: every is_designer() caller now means "is plain admin".
@@ -120,21 +135,22 @@ function is_designer(): bool {
return is_admin_role(); 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 { function auth_set_session(array $user): void {
// Rotate the session id on every privilege change (login, registration, auth_session_regenerate();
// 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.
if (session_status() === PHP_SESSION_ACTIVE) { if (session_status() === PHP_SESSION_ACTIVE) {
session_regenerate_id(true);
unset($_SESSION['csrf_token']); unset($_SESSION['csrf_token']);
} }
$_SESSION['user_id'] = $user['id']; $_SESSION['user_id'] = (int) $user['id'];
$_SESSION['user'] = [ $_SESSION['user'] = [
'id' => $user['id'], 'id' => (int) $user['id'],
'name' => $user['name'], 'name' => $user['name'],
'email' => $user['email'], 'email' => $user['email'],
'role' => $user['role'], 'role' => $user['role'],
]; ];
} }
+100 -21
View File
@@ -15,7 +15,7 @@ env_load(__DIR__ . '/../.env');
function _bootstrap_fail(string $logMsg): void { function _bootstrap_fail(string $logMsg): void {
@mkdir(__DIR__ . '/../logs', 0775, true); @mkdir(__DIR__ . '/../logs', 0775, true);
@error_log('[' . date('c') . '] ' . $logMsg . "\n", 3, __DIR__ . '/../logs/db_error.log'); @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); http_response_code(500);
header('Content-Type: text/html; charset=utf-8'); header('Content-Type: text/html; charset=utf-8');
$tpl = __DIR__ . '/../templates/error_500.php'; $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)'); _bootstrap_fail('required env var DB_PASS is not set (empty string is allowed)');
} }
$host = env('DB_HOST', '127.0.0.1'); $host = env('DB_HOST', '127.0.0.1');
$port = (int) env('DB_PORT', 3306); $port = (int) env('DB_PORT', 3306);
$db = env('DB_NAME'); $db = env('DB_NAME');
$user = env('DB_USER'); // Not $user: that name leaks into the router's flat include scope (router.php
$pass = env('DB_PASS'); // 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"; $dsn = "mysql:host=$host;port=$port;dbname=$db;charset=utf8mb4";
try { try {
$pdo = new PDO($dsn, $user, $pass, [ $pdo = new PDO($dsn, $dbUser, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
// Real server-side prepares (not driver emulation): a tighter defence // Real server-side prepares (not driver emulation): a tighter defence
// against injection than emulated string-interpolation. Safe here — the // against injection than emulated string-interpolation. Safe here — the
@@ -63,19 +66,95 @@ try {
_bootstrap_fail('PDO connect failed: ' . $e->getMessage()); _bootstrap_fail('PDO connect failed: ' . $e->getMessage());
} }
// Distinct session cookie per app so installs that share a host (e.g. // ─── Ligação ao platform-tools ───────────────────────────────────────────────
// development.andmore.pt/id.andmore.pt/ and /fitme3070.pt/) don't overwrite
// each other's PHPSESSID. Must run before session_start().
// //
// Cookie e modo estrito definidos aqui, no código, para valerem em qualquer // Auditoria 2026-09-24, ponto 1.3: a app passou a usar a autenticação
// servidor: antes o cookie saía só com path=/ (sem Secure, HttpOnly nem // partilhada (`shared/platform-tools/functions_auth.php`) em vez da sua
// SameSite) e o PHP aceitava IDs de sessão inventados pelo cliente. // própria — login, sessão, roles e guardas (config/auth.php,
if (session_status() === PHP_SESSION_NONE) { // config/functions_user.php) agora chamam auth_attempt()/auth_login()/
ini_set('session.use_strict_mode', '1'); // auth_at_least()/etc. por baixo, com os mesmos nomes de função de sempre
ini_set('session.use_only_cookies', '1'); // para os ~30 ficheiros que os chamam não mudarem.
ini_set('session.cookie_httponly', '1'); //
ini_set('session.cookie_samesite', 'Lax'); // O cookie de sessão (nome, Secure/HttpOnly/SameSite, modo estrito) passa a
if (request_is_https()) ini_set('session.cookie_secure', '1'); // ser inteiramente gerido por auth_session_start() através da opção
session_name('FIT_SESS'); // 'session' abaixo — o bloco que estava aqui antes (ini_set + session_start
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; }
}
+49 -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_db.php'; // generic DB helpers
require_once __DIR__ . '/functions_audit.php'; // registo de auditoria (platform-tools) + audit() 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_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 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_throttle.php'; // per-IP attempt throttle for the auth endpoints
require_once __DIR__ . '/functions_migrations.php';// platform_update.php migrations runner 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_queue.php'; // message_queue + enqueue_*/flush_message
require_once __DIR__ . '/functions_csrf.php'; require_once __DIR__ . '/functions_csrf.php';
require_once __DIR__ . '/functions_html.php'; // allowlist HTML sanitiser (clean_richtext/clean_map_embed) 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__ . '/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_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) require_once __DIR__ . '/functions_site.php'; // public-site data (site_info/offer/team/modalidades/horario)
@@ -37,9 +41,11 @@ require_once __DIR__ . '/functions_events.php'; // time-limited events +
require_once __DIR__ . '/functions_info.php'; // contactos (fitme3070_info) editor require_once __DIR__ . '/functions_info.php'; // contactos (fitme3070_info) editor
require_once __DIR__ . '/functions_memberships.php'; // mensalidades + pedidos de inscrição require_once __DIR__ . '/functions_memberships.php'; // mensalidades + pedidos de inscrição
require_once __DIR__ . '/functions_reviews.php'; // reviews Google (Places API) + cache require_once __DIR__ . '/functions_reviews.php'; // reviews Google (Places API) + cache
require_once __DIR__ . '/functions_instagram.php'; // «Comunidade»: posts do Instagram onde o ginásio é identificado
require_once __DIR__ . '/functions_trials.php'; // pedidos de aula experimental 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_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_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 ─────────────────────────────────── // ─── DB-backed settings → legacy constants ───────────────────────────────────
// Resolved once from the settings cache so call sites read plain constants. // Resolved once from the settings cache so call sites read plain constants.
@@ -97,6 +103,45 @@ function format_date_pt(string $date): string {
return (new DateTime($date))->format('d/m/Y'); 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 ──────────────────────────────────────────────────── // ─── Email template loader ────────────────────────────────────────────────────
// {{placeholder}} HTML bodies under templates/emails/. Built locally, delivered // {{placeholder}} HTML bodies under templates/emails/. Built locally, delivered
// by id.andmore.pt via the enqueue_email()/flush_message() façade. // by id.andmore.pt via the enqueue_email()/flush_message() façade.
@@ -107,9 +152,9 @@ function load_email_template(string $template, array $vars): string {
} }
$html = file_get_contents($path); $html = file_get_contents($path);
$brand = htmlspecialchars(APP_NAME, ENT_QUOTES, 'UTF-8'); $brand = htmlspecialchars(APP_NAME, ENT_QUOTES, 'UTF-8');
$logo = (string) setting('logo_light_filename', ''); $rel = site_logo_rel('logo_light_filename', 'site/assets/img/logos/logo_crossfit_white.svg');
$logoTag = $logo !== '' $logoTag = $rel !== ''
? '<img src="' . htmlspecialchars(base_url('assets/img/logo/' . rawurlencode($logo)), ENT_QUOTES, 'UTF-8') ? '<img src="' . htmlspecialchars(base_url($rel), ENT_QUOTES, 'UTF-8')
. '" alt="' . $brand . '" style="max-height:60px;border:0;">' . '" 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>'; : '<p style="margin:0;color:#fff;font-size:22px;font-weight:bold;letter-spacing:1px;">' . $brand . '</p>';
$html = str_replace('{{logo_html}}', $logoTag, $html); $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); )->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. // PT-PT label for the role ladder.
function role_label(string $role): string { function role_label(string $role): string {
return [ return [
+7 -3
View File
@@ -15,6 +15,7 @@ function events_defaults(): array {
'location' => '', 'event_starts_at' => null, 'event_ends_at' => null, 'location' => '', 'event_starts_at' => null, 'event_ends_at' => null,
'publish_from' => '', 'publish_until' => '', 'is_active' => 1, 'publish_from' => '', 'publish_until' => '', 'is_active' => 1,
'cta_label' => '', 'cta_url' => '', 'show_lead_form' => 1, 'cta_label' => '', 'cta_url' => '', 'show_lead_form' => 1,
'gym_id' => null, // NULL = shown on every gym's homepage
]; ];
} }
@@ -117,8 +118,8 @@ function db_events_create(PDO $pdo, array $data): int {
INSERT INTO events INSERT INTO events
(slug, title, subtitle, summary, description, hero_image, cor, location, (slug, title, subtitle, summary, description, hero_image, cor, location,
event_starts_at, event_ends_at, publish_from, publish_until, event_starts_at, event_ends_at, publish_from, publish_until,
is_active, cta_label, cta_url, show_lead_form) is_active, cta_label, cta_url, show_lead_form, gym_id)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
"); ");
$stmt->execute([ $stmt->execute([
$d['slug'], $d['title'], $d['subtitle'], $d['summary'], $d['description'], $d['slug'], $d['title'], $d['subtitle'], $d['summary'], $d['description'],
@@ -127,6 +128,7 @@ function db_events_create(PDO $pdo, array $data): int {
str_replace('T', ' ', (string) $d['publish_from']), str_replace('T', ' ', (string) $d['publish_from']),
str_replace('T', ' ', (string) $d['publish_until']), str_replace('T', ' ', (string) $d['publish_until']),
(int) $d['is_active'], $d['cta_label'], $d['cta_url'], (int) $d['show_lead_form'], (int) $d['is_active'], $d['cta_label'], $d['cta_url'], (int) $d['show_lead_form'],
$d['gym_id'] ? (int) $d['gym_id'] : null,
]); ]);
$__id = (int) $pdo->lastInsertId(); $__id = (int) $pdo->lastInsertId();
audit_change($pdo, 'event', 'events', 'e_id', (int) $__id, 'create', null, 'title'); audit_change($pdo, 'event', 'events', 'e_id', (int) $__id, 'create', null, 'title');
@@ -141,7 +143,7 @@ function db_events_update(PDO $pdo, int $id, array $data): bool {
slug = ?, title = ?, subtitle = ?, summary = ?, description = ?, slug = ?, title = ?, subtitle = ?, summary = ?, description = ?,
hero_image = ?, cor = ?, location = ?, hero_image = ?, cor = ?, location = ?,
event_starts_at = ?, event_ends_at = ?, publish_from = ?, publish_until = ?, event_starts_at = ?, event_ends_at = ?, publish_from = ?, publish_until = ?,
is_active = ?, cta_label = ?, cta_url = ?, show_lead_form = ? is_active = ?, cta_label = ?, cta_url = ?, show_lead_form = ?, gym_id = ?
WHERE e_id = ? WHERE e_id = ?
"); ");
$stmt->execute([ $stmt->execute([
@@ -151,6 +153,7 @@ function db_events_update(PDO $pdo, int $id, array $data): bool {
str_replace('T', ' ', (string) $d['publish_from']), str_replace('T', ' ', (string) $d['publish_from']),
str_replace('T', ' ', (string) $d['publish_until']), str_replace('T', ' ', (string) $d['publish_until']),
(int) $d['is_active'], $d['cta_label'], $d['cta_url'], (int) $d['show_lead_form'], (int) $d['is_active'], $d['cta_label'], $d['cta_url'], (int) $d['show_lead_form'],
$d['gym_id'] ? (int) $d['gym_id'] : null,
$id, $id,
]); ]);
$__r = $stmt->rowCount() > 0; $__r = $stmt->rowCount() > 0;
@@ -225,6 +228,7 @@ function events_image_remove(?string $filename): void {
if ($filename === '' || strncmp($filename, 'upl_', 4) !== 0) return; if ($filename === '' || strncmp($filename, 'upl_', 4) !== 0) return;
$path = events_image_dir() . $filename; $path = events_image_dir() . $filename;
if (is_file($path)) @unlink($path); if (is_file($path)) @unlink($path);
img_fallback_follow($path);
} }
// ─── Leads ───────────────────────────────────────────────────────────────────── // ─── Leads ─────────────────────────────────────────────────────────────────────
+24 -7
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); 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 { function db_gallery_get(PDO $pdo, int $id): ?array {
$stmt = $pdo->prepare("SELECT * FROM galleries WHERE id = ? LIMIT 1"); $stmt = $pdo->prepare("SELECT * FROM galleries WHERE id = ? LIMIT 1");
$stmt->execute([$id]); $stmt->execute([$id]);
@@ -44,23 +50,34 @@ function db_gallery_images(PDO $pdo, int $gallery_id): array {
return $stmt->fetchAll(PDO::FETCH_ASSOC); return $stmt->fetchAll(PDO::FETCH_ASSOC);
} }
function db_gallery_image_upload(PDO $pdo, int $gallery_id, array $file): ?string { // Limite por ficheiro: o menor entre 16 MB e o upload_max_filesize do PHP.
if ($file['error'] !== UPLOAD_ERR_OK) return null; function gallery_upload_max_bytes(): int {
$ini = trim((string) ini_get('upload_max_filesize'));
$n = (int) $ini;
$n *= match (strtolower(substr($ini, -1))) { 'g' => 1 << 30, 'm' => 1 << 20, 'k' => 1 << 10, default => 1 };
return $n > 0 ? min($n, 16 * 1024 * 1024) : 16 * 1024 * 1024;
}
if (($file['size'] ?? 0) > 16 * 1024 * 1024) return null; // $error recebe o motivo (para a mensagem ao utilizador) quando devolve null.
function db_gallery_image_upload(PDO $pdo, int $gallery_id, array $file, ?string &$error = null): ?string {
$max_mb = round(gallery_upload_max_bytes() / 1048576);
if ($file['error'] === UPLOAD_ERR_INI_SIZE || $file['error'] === UPLOAD_ERR_FORM_SIZE) { $error = "maior que {$max_mb} MB"; return null; }
if ($file['error'] !== UPLOAD_ERR_OK) { $error = 'o envio falhou'; return null; }
if (($file['size'] ?? 0) > gallery_upload_max_bytes()) { $error = "maior que {$max_mb} MB"; return null; }
// Tipo decidido pelo conteúdo, não pelo nome (config/functions_images.php). // Tipo decidido pelo conteúdo, não pelo nome (config/functions_images.php).
$ext = upload_image_ext($file, ['jpg', 'jpeg', 'png', 'gif', 'webp']); $ext = upload_image_ext($file, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'jxl']);
if ($ext === null) return null; if ($ext === null) { $error = 'formato não suportado'; return null; }
$upload_dir = __DIR__ . '/../uploads/gallery/' . $gallery_id . '/'; $upload_dir = __DIR__ . '/../uploads/gallery/' . $gallery_id . '/';
if (!is_dir($upload_dir)) @mkdir($upload_dir, 0775, true); if (!is_dir($upload_dir)) @mkdir($upload_dir, 0775, true);
$filename = bin2hex(random_bytes(16)) . '.' . $ext; $filename = bin2hex(random_bytes(16)) . '.' . $ext;
if (!move_uploaded_file($file['tmp_name'], $upload_dir . $filename)) return null; if (!move_uploaded_file($file['tmp_name'], $upload_dir . $filename)) { $error = 'não foi possível gravar'; return null; }
img_optimize($upload_dir . $filename, IMG_MAX_GALLERY); img_optimize($upload_dir . $filename, IMG_MAX_GALLERY);
$stmt = $pdo->prepare("INSERT INTO gallery (i_file_name, i_file_type, i_project_id) VALUES (?, ?, ?)"); $stmt = $pdo->prepare("INSERT INTO gallery (i_file_name, i_file_type, i_project_id) VALUES (?, ?, ?)");
$stmt->execute([$filename, $file['type'] ?? 'image/jpeg', $gallery_id]); $stmt->execute([$filename, $ext === 'jxl' ? 'image/jxl' : ($file['type'] ?? 'image/jpeg'), $gallery_id]);
return $filename; return $filename;
} }
+121 -18
View File
@@ -19,6 +19,7 @@ function gym_sections(): array {
return [ return [
'oferta' => 'A Nossa Oferta', 'oferta' => 'A Nossa Oferta',
'galeria' => 'Galeria (instalações)', 'galeria' => 'Galeria (instalações)',
'comunidade' => 'Comunidade (Instagram)',
'modalidades' => 'Modalidades', 'modalidades' => 'Modalidades',
'equipa' => 'Equipa', 'equipa' => 'Equipa',
'horario' => 'Horário de aulas', 'horario' => 'Horário de aulas',
@@ -41,7 +42,7 @@ function gym_defaults(): array {
'slug' => '', 'name' => '', 'locality' => '', 'postal_code' => '', 'is_published' => 0, 'slug' => '', 'name' => '', 'locality' => '', 'postal_code' => '', 'is_published' => 0,
'is_default' => 0, 'sections' => null, 'hero_title' => '', 'hero_subtitle' => '', 'is_default' => 0, 'sections' => null, 'hero_title' => '', 'hero_subtitle' => '',
'hero_image' => '', 'seo_title' => '', 'seo_description' => '', 'gallery_id' => null, '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 +104,32 @@ function gym_section_on(array $gym, string $key): bool {
return !is_array($list) || in_array($key, $list, true); 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 { function gym_url(array $gym, string $path = ''): string {
return base_url(rawurlencode((string) $gym['slug']) . '/' . ltrim($path, '/')); return base_url(rawurlencode((string) $gym['slug']) . '/' . ltrim($path, '/'));
} }
@@ -159,8 +186,22 @@ function gyms_from_post(PDO $pdo, array $post, array &$errors, ?int $id = null):
'gallery_id' => (int) ($post['gallery_id'] ?? 0) ?: null, 'gallery_id' => (int) ($post['gallery_id'] ?? 0) ?: null,
'google_place_id' => mb_substr(trim((string) ($post['google_place_id'] ?? '')), 0, 120), 'google_place_id' => mb_substr(trim((string) ($post['google_place_id'] ?? '')), 0, 120),
'has_crossfit' => !empty($post['has_crossfit']) ? 1 : 0, '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), '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'] ?? []))); $on = array_values(array_intersect(array_keys(gym_sections()), (array) ($post['sections'] ?? [])));
$data['sections'] = count($on) === count(gym_sections()) ? null : json_encode($on); $data['sections'] = count($on) === count(gym_sections()) ? null : json_encode($on);
@@ -173,25 +214,42 @@ function gyms_from_post(PDO $pdo, array $post, array &$errors, ?int $id = null):
$other = db_gyms_by_slug($pdo, $slug); $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 ($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['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'])) { 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: caminho de uma imagem do site (ex.: site/assets/img/home.jpg).'; $errors[] = 'Imagem do header: imagem inválida.';
} }
return $data; return $data;
} }
function _gyms_params(array $d): array { // Columns written by create/update, in order. brand_color and logo were added by
return [$d['slug'], $d['name'], $d['locality'], $d['postal_code'], $d['is_published'], $d['is_default'], // migration_20261013_gym_brand_color.sql: an install that hasn't run it yet keeps
$d['sections'], $d['hero_title'], $d['hero_subtitle'], $d['hero_image'], $d['seo_title'], // working (they are just not saved) instead of failing on an unknown column.
$d['seo_description'], $d['gallery_id'], $d['google_place_id'], $d['has_crossfit'], $d['item_order']]; 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 { function db_gyms_create(PDO $pdo, array $d): int {
$cols = _gyms_cols($pdo, $d);
$pdo->beginTransaction(); $pdo->beginTransaction();
$pdo->prepare("INSERT INTO gyms (slug, name, locality, postal_code, is_published, is_default, sections, $pdo->prepare('INSERT INTO gyms (' . implode(', ', array_keys($cols)) . ', created_at, updated_at) VALUES ('
hero_title, hero_subtitle, hero_image, seo_title, seo_description, gallery_id, . implode(',', array_fill(0, count($cols), '?')) . ', NOW(), NOW())')->execute(array_values($cols));
google_place_id, has_crossfit, item_order, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, NOW(), NOW())")->execute(_gyms_params($d));
$id = (int) $pdo->lastInsertId(); $id = (int) $pdo->lastInsertId();
if ($d['is_default']) $pdo->prepare("UPDATE gyms SET is_default = 0 WHERE id <> ?")->execute([$id]); 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. // Its own contacts row (id = gym id), starting from the gym's name.
@@ -206,14 +264,10 @@ function db_gyms_create(PDO $pdo, array $d): int {
function db_gyms_update(PDO $pdo, int $id, array $d): bool { function db_gyms_update(PDO $pdo, int $id, array $d): bool {
$__before = audit_row($pdo, 'gyms', 'id', (int) $id); $__before = audit_row($pdo, 'gyms', 'id', (int) $id);
$cols = _gyms_cols($pdo, $d);
$pdo->beginTransaction(); $pdo->beginTransaction();
$params = _gyms_params($d); $params[] = $id; $st = $pdo->prepare('UPDATE gyms SET ' . implode(' = ?, ', array_keys($cols)) . ' = ?, updated_at = NOW() WHERE id = ?');
$st = $pdo->prepare("UPDATE gyms SET slug = ?, name = ?, locality = ?, postal_code = ?, is_published = ?, $st->execute(array_merge(array_values($cols), [$id]));
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);
$changed = $st->rowCount() > 0; $changed = $st->rowCount() > 0;
if ($d['is_default']) $pdo->prepare("UPDATE gyms SET is_default = 0 WHERE id <> ?")->execute([$id]); if ($d['is_default']) $pdo->prepare("UPDATE gyms SET is_default = 0 WHERE id <> ?")->execute([$id]);
$pdo->commit(); $pdo->commit();
@@ -222,6 +276,55 @@ function db_gyms_update(PDO $pdo, int $id, array $d): bool {
return $__r; 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);
img_fallback_follow($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). // Soft delete. The default gym can't go (the root URL needs somewhere to land).
function db_gyms_delete(PDO $pdo, int $id): bool { function db_gyms_delete(PDO $pdo, int $id): bool {
$st = $pdo->prepare("UPDATE gyms SET deleted_at = NOW(), is_published = 0 $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;
}
+235 -2
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']; $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 === '') { if (isset($raster[$mime]) || $mime === '') {
$info = @getimagesize($tmp); $info = @getimagesize($tmp);
$ext = $info ? ($raster[$info['mime']] ?? null) : null; $ext = $info ? ($raster[$info['mime']] ?? null) : null;
@@ -80,6 +89,7 @@ function img_optimize(string $path, int $max_dim): array {
if (!is_file($path)) return $fail('ficheiro não encontrado'); if (!is_file($path)) return $fail('ficheiro não encontrado');
if (!extension_loaded('gd')) return $fail('extensão GD indisponível'); if (!extension_loaded('gd')) return $fail('extensão GD indisponível');
if (strtolower(pathinfo($path, PATHINFO_EXTENSION)) === 'jxl') return img_jxl_optimize($path, $max_dim);
$info = @getimagesize($path); $info = @getimagesize($path);
if (!$info) return $skip('formato não suportado'); // SVG e outros não-raster if (!$info) return $skip('formato não suportado'); // SVG e outros não-raster
@@ -177,9 +187,9 @@ function img_optimize_dir(string $dir, int $max_dim): array {
if (!is_dir($dir)) return $stats; if (!is_dir($dir)) return $stats;
foreach (scandir($dir) ?: [] as $file) { foreach (scandir($dir) ?: [] as $file) {
$path = rtrim($dir, '/\\') . DIRECTORY_SEPARATOR . $file; $path = rtrim($dir, '/\\') . DIRECTORY_SEPARATOR . $file;
if (!is_file($path)) continue; if (!is_file($path) || img_is_jxl_fallback($file)) continue;
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION)); $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
if (!in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp'], true)) continue; if (!in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'jxl'], true)) continue;
$r = img_optimize($path, $max_dim); $r = img_optimize($path, $max_dim);
$stats[$r['status']]++; $stats[$r['status']]++;
$stats['saved'] += $r['saved']; $stats['saved'] += $r['saved'];
@@ -205,3 +215,226 @@ function img_human_bytes(int $bytes): string {
if ($bytes >= 1024) return number_format($bytes / 1024, 0) . ' KB'; if ($bytes >= 1024) return number_format($bytes / 1024, 0) . ' KB';
return $bytes . ' B'; return $bytes . ' B';
} }
// ─── JPEG XL ─────────────────────────────────────────────────────────────────
// O GD não lê JXL. Descodifica-se com os binários do libjxl (djxl/cjxl, pacote
// Debian libjxl-tools) ou com o Imagick se tiver o formato JXL; sem nenhum, o
// .jxl fica como foi enviado (status "skipped") e o upload não falha.
// Ao lado de cada foto.jxl grava-se foto.jxl.webp (ou .jpg sem WebP no GD):
// é a versão para os browsers sem JXL, servida por img_tag()/img_css_bg() e
// escondida das listagens (img_list_dir). Redimensiona-se a partir da versão
// descodificada e, havendo encoder, o .jxl é regravado ao novo tamanho.
const IMG_JXL_DISTANCE = '1.0'; // cjxl -d: 1.0 = visualmente sem perdas
// Backends on this server: ['imagick' => bool, 'djxl' => path|'', 'cjxl' => path|''].
function img_jxl_tools(): array {
static $tools = null;
if ($tools !== null) return $tools;
$imagick = class_exists('Imagick') && in_array('JXL', \Imagick::queryFormats('JXL'), true);
$find = static function (string $bin): string {
if (!function_exists('proc_open')) return '';
$dirs = array_merge(explode(PATH_SEPARATOR, (string) getenv('PATH')), ['/usr/bin', '/usr/local/bin']);
foreach (array_unique(array_filter($dirs)) as $d) {
$p = rtrim($d, '/\\') . DIRECTORY_SEPARATOR . $bin;
if (@is_file($p) && @is_executable($p)) return $p;
}
return '';
};
return $tools = ['imagick' => $imagick, 'djxl' => $find('djxl'), 'cjxl' => $find('cjxl')];
}
function img_jxl_can_decode(): bool {
$t = img_jxl_tools();
return $t['imagick'] || $t['djxl'] !== '';
}
// Runs a binary with an argument list (no shell). True when it exits 0 and wrote $out.
function _img_run(string $bin, array $args, string $out): bool {
$proc = @proc_open(array_merge([$bin], $args), [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
if (!is_resource($proc)) return false;
stream_get_contents($pipes[1]); stream_get_contents($pipes[2]);
fclose($pipes[1]); fclose($pipes[2]);
clearstatcache(true, $out);
return proc_close($proc) === 0 && is_file($out) && filesize($out) > 0;
}
function img_jxl_decode(string $jxl, string $png): bool {
$t = img_jxl_tools();
if ($t['djxl'] !== '' && _img_run($t['djxl'], [$jxl, $png], $png)) return true;
if ($t['imagick']) {
try {
$im = new \Imagick($jxl);
$im->setImageFormat('PNG');
return $im->writeImage($png) && is_file($png);
} catch (\Throwable $e) { return false; }
}
return false;
}
function img_jxl_encode(string $png, string $jxl): bool {
$t = img_jxl_tools();
if ($t['cjxl'] !== '' && _img_run($t['cjxl'], [$png, $jxl, '-d', IMG_JXL_DISTANCE, '-e', '7'], $jxl)) return true;
if ($t['imagick']) {
try {
$im = new \Imagick($png);
$im->setImageFormat('JXL');
$im->setImageCompressionQuality(90);
return $im->writeImage($jxl) && is_file($jxl);
} catch (\Throwable $e) { return false; }
}
return false;
}
// "foto.jxl.webp" / "foto.jxl.jpg": the fallback copies, never listed as photos.
function img_is_jxl_fallback(string $name): bool {
return (bool) preg_match('/\.jxl\.(webp|jpe?g|png)$/i', $name);
}
// Absolute path of the fallback beside $jxl_path, or '' when there is none.
function img_jxl_fallback(string $jxl_path): string {
foreach (['webp', 'jpg', 'png'] as $e) {
if (is_file($jxl_path . '.' . $e)) return $jxl_path . '.' . $e;
}
return '';
}
// Moves (or, with $to = '', deletes) the fallback that goes with an image file.
// Callers that move/delete an upload call this too, so the pair stays together.
function img_fallback_follow(string $from, string $to = ''): void {
if (!preg_match('/\.jxl$/i', $from)) return;
foreach (['webp', 'jpg', 'png'] as $e) {
if (!is_file($from . '.' . $e)) continue;
if ($to === '') @unlink($from . '.' . $e);
else @rename($from . '.' . $e, $to . '.' . $e);
}
}
function img_jxl_optimize(string $path, int $max_dim): array {
$skip = fn(string $why) => ['status' => 'skipped', 'saved' => 0, 'reason' => $why];
$fail = fn(string $why) => ['status' => 'failed', 'saved' => 0, 'reason' => $why];
// Fallback newer than the .jxl and within the cap: nothing to do (idempotent).
$old = img_jxl_fallback($path);
if ($old !== '' && filemtime($old) >= filemtime($path)) {
$d = @getimagesize($old);
if ($d && $d[0] <= $max_dim && $d[1] <= $max_dim) return $skip('já dentro do limite');
}
if (!img_jxl_can_decode()) return $skip('JPEG XL sem descodificador no servidor (libjxl-tools)');
$dec = $path . '.dec.png';
if (!img_jxl_decode($path, $dec)) { @unlink($dec); return $fail('JPEG XL ilegível'); }
$info = @getimagesize($dec);
if (!$info) { @unlink($dec); return $fail('JPEG XL ilegível'); }
[$w, $h] = $info;
if ($w * $h > 4_000_000) @ini_set('memory_limit', '512M');
$src = @imagecreatefrompng($dec);
if (!$src) { @unlink($dec); return $fail('JPEG XL ilegível'); }
$scale = min(1, $max_dim / max($w, $h, 1));
$nw = max(1, (int) round($w * $scale));
$nh = max(1, (int) round($h * $scale));
if ($scale < 1) {
$dst = imagecreatetruecolor($nw, $nh);
imagealphablending($dst, false);
imagesavealpha($dst, true);
imagefilledrectangle($dst, 0, 0, $nw, $nh, imagecolorallocatealpha($dst, 0, 0, 0, 127));
imagecopyresampled($dst, $src, 0, 0, 0, 0, $nw, $nh, $w, $h);
imagedestroy($src);
$src = $dst;
}
// Fallback: WebP keeps transparency; without WebP in GD, JPEG.
img_fallback_follow($path);
if (function_exists('imagewebp')) {
imagesavealpha($src, true);
$ok = imagewebp($src, $path . '.webp', IMG_WEBP_QUALITY);
} else {
imageinterlace($src, true);
$ok = imagejpeg($src, $path . '.jpg', IMG_JPEG_QUALITY);
}
// Smaller JXL: re-encode from the resized pixels when there is an encoder.
$saved = 0;
if ($ok && $scale < 1 && imagepng($src, $dec)) {
$tmp = $path . '.opt.tmp';
$before = (int) filesize($path);
if (img_jxl_encode($dec, $tmp) && filesize($tmp) < $before) {
if (!@rename($tmp, $path)) { @unlink($path); @rename($tmp, $path); }
clearstatcache(true, $path);
$saved = max(0, $before - (int) filesize($path));
}
@unlink($tmp);
}
imagedestroy($src);
@unlink($dec);
if (!$ok) return $fail('falha ao gravar a versão alternativa');
// The fallback must not look older than the .jxl (idempotence check above).
@touch(img_jxl_fallback($path));
return ['status' => 'resized', 'saved' => $saved,
'reason' => ($scale < 1 ? "{$w}×{$h} → {$nw}×{$nh}, " : '') . 'versão alternativa criada'];
}
// ─── Servir imagens (JPEG XL com alternativa) ────────────────────────────────
// $rel é o caminho relativo à raiz do projeto (o que a BD guarda / o glob dá).
function _img_abs(string $rel): string {
return dirname(__DIR__) . '/' . ltrim($rel, '/');
}
// Fallback of a .jxl as a project-relative path, or '' (not JXL / no fallback).
function img_fallback_rel(string $rel): string {
if (!preg_match('/\.jxl$/i', $rel)) return '';
$fb = img_jxl_fallback(_img_abs($rel));
return $fb === '' ? '' : $rel . substr($fb, -strlen(strrchr($fb, '.')));
}
// Where only one image fits (lightbox, og:image, preload, e-mail): the
// fallback of a .jxl when there is one, else the file itself.
function img_compat_rel(string $rel): string {
return img_fallback_rel($rel) ?: $rel;
}
// <img> for $rel; a .jxl with a fallback becomes <picture> with the JXL source
// first. $attrs: extra <img> attributes (trusted names, values escaped here);
// width/height come from the file (or its fallback) unless given.
function img_tag(string $rel, array $attrs = []): string {
$fb = img_fallback_rel($rel);
$src = $fb ?: $rel;
if (!isset($attrs['width'])) {
$d = @getimagesize(_img_abs($src));
if ($d) { $attrs['width'] = $d[0]; $attrs['height'] = $d[1]; }
}
$a = '';
foreach ($attrs as $k => $v) {
if ($v === null || $v === false) continue;
$a .= ' ' . $k . ($v === true ? '' : '="' . htmlspecialchars((string) $v) . '"');
}
$img = '<img src="' . htmlspecialchars(base_url($src)) . '"' . $a . '>';
if ($fb === '') return $img;
return '<picture><source type="image/jxl" srcset="' . htmlspecialchars(base_url($rel)) . '">' . $img . '</picture>';
}
// CSS for a style="" attribute: url(fallback) first, then image-set() with the
// JXL, so browsers without image-set()/JXL keep the fallback.
function img_css_bg(string $rel): string {
$q = static fn(string $r) => "url('" . str_replace(["'", '\\'], ['%27', '%5C'], base_url($r)) . "')";
$fb = img_fallback_rel($rel);
if ($fb === '') return 'background-image:' . $q($rel);
$type = str_ends_with(strtolower($fb), '.webp') ? 'image/webp' : 'image/jpeg';
return 'background-image:' . $q($fb) . ';background-image:image-set(' . $q($rel) . ' type("image/jxl"), ' . $q($fb) . ' type("' . $type . '"))';
}
// Photos directly inside a project-relative folder ("uploads/gallery/3/"),
// sorted, without the .jxl fallbacks.
function img_list_dir(string $dir_rel): array {
$dir_rel = rtrim($dir_rel, '/') . '/';
$out = [];
foreach (glob(_img_abs($dir_rel) . '*') ?: [] as $p) {
$name = basename($p);
if (!is_file($p) || img_is_jxl_fallback($name) || !preg_match('/\.(jpe?g|png|gif|webp|jxl)$/i', $name)) continue;
$out[] = $dir_rel . $name;
}
sort($out);
return $out;
}
+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 { function db_info_save(PDO $pdo, array $data): bool {
$cols = array_keys(info_fields()); $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. // One row per gym (id = gyms.id): the gym being edited in the backoffice.
$gid = admin_gym_id(); $gid = admin_gym_id();
$__before = audit_row($pdo, 'fitme3070_info', 'id', (int) $gid); $__before = audit_row($pdo, 'fitme3070_info', 'id', (int) $gid);
+188
View File
@@ -0,0 +1,188 @@
<?php
// «Comunidade»: the latest Instagram posts where the gym was tagged, via the
// Instagram Graph API (GET /{ig-user-id}/tags). Per gym, no migration: the
// account id and token live in system_settings as instagram_user_id_<gid> /
// instagram_token_<gid> (token encrypted, or INSTAGRAM_TOKEN_<gid> in .env).
// Same cache pattern as functions_reviews.php: config/cache/instagram_<gid>.cache.php,
// lazy refresh under a non-blocking lock, old copy kept on error. Thumbnails
// are downloaded at refresh time to uploads/instagram/<gid>/ (Instagram CDN
// URLs expire), so visitors never wait on Instagram.
const INSTAGRAM_GRAPH = 'https://graph.facebook.com/v21.0/';
function instagram_use_gym(array $gym): void {
$GLOBALS['_instagram_gym'] = $gym;
}
function instagram_gym(): array {
return $GLOBALS['_instagram_gym'] ?? current_gym();
}
function _instagram_gid(): int {
return (int) (instagram_gym()['id'] ?? 1);
}
function instagram_config(): array {
$gid = _instagram_gid();
return [
'user_id' => preg_replace('/\D+/', '', (string) setting('instagram_user_id_' . $gid, '')),
'token' => setting_secret('instagram_token_' . $gid, 'INSTAGRAM_TOKEN_' . $gid),
'max' => max(3, min(24, (int) setting('instagram_max', 8))),
'cache_hours' => max(1, (int) setting('instagram_cache_hours', 6)),
];
}
function instagram_configured(): bool {
$c = instagram_config();
return $c['user_id'] !== '' && $c['token'] !== '';
}
function _instagram_cache_path(): string {
return __DIR__ . '/cache/instagram_' . _instagram_gid() . '.cache.php';
}
function _instagram_media_dir(): string {
return dirname(__DIR__) . '/uploads/instagram/' . _instagram_gid() . '/';
}
function instagram_cache_read(): ?array {
$path = _instagram_cache_path();
if (!is_file($path)) return null;
$data = @include $path;
return is_array($data) ? $data : null;
}
function _instagram_cache_write(array $data): void {
$path = _instagram_cache_path();
if (!is_dir(dirname($path))) @mkdir(dirname($path), 0775, true);
$tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp';
if (@file_put_contents($tmp, "<?php\nreturn " . var_export($data, true) . ";\n", LOCK_EX) === false) return;
@rename($tmp, $path);
if (function_exists('opcache_invalidate')) @opcache_invalidate($path, true);
}
function instagram_cache_clear(): void {
$path = _instagram_cache_path();
if (is_file($path)) @unlink($path);
}
// GET against the Graph API. Returns [decoded|null, error].
function _instagram_request(string $url): array {
if (!function_exists('curl_init')) return [null, 'cURL indisponível nesta instalação.'];
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_TIMEOUT => 6,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$raw = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$cerr = curl_error($ch);
curl_close($ch);
if ($raw === false) return [null, 'Sem ligação ao Instagram: ' . $cerr];
$json = json_decode((string) $raw, true);
if ($status !== 200) {
$msg = is_array($json) ? (string) ($json['error']['message'] ?? '') : '';
return [$json, 'Instagram respondeu ' . $status . ($msg !== '' ? ': ' . $msg : '')];
}
return [is_array($json) ? $json : null, is_array($json) ? '' : 'Resposta inválida do Instagram.'];
}
// Saves one thumbnail locally; returns the uploads-relative path or ''.
function _instagram_save_thumb(string $src, string $id): string {
if (!preg_match('#^https://[a-z0-9.-]+\.(cdninstagram\.com|fbcdn\.net)/#i', $src)) return '';
$dir = _instagram_media_dir();
$rel = 'uploads/instagram/' . _instagram_gid() . '/' . $id . '.jpg';
if (is_file($dir . $id . '.jpg')) return $rel;
$ch = curl_init($src);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 2,
CURLOPT_TIMEOUT => 5, CURLOPT_CONNECTTIMEOUT => 3, CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
]);
$bin = curl_exec($ch);
curl_close($ch);
$info = (is_string($bin) && $bin !== '' && strlen($bin) < 4 * 1024 * 1024) ? @getimagesizefromstring($bin) : false;
if (!$info || $info['mime'] !== 'image/jpeg') return '';
if (!is_dir($dir)) @mkdir($dir, 0775, true);
return @file_put_contents($dir . $id . '.jpg', $bin) ? $rel : '';
}
// Fetch the tagged media now and rewrite the cache. Returns the cache array.
function instagram_refresh(): array {
$c = instagram_config();
$old = instagram_cache_read() ?? [];
if ($c['user_id'] === '' || $c['token'] === '') {
return $old + ['error' => 'Falta o ID da conta ou o token.'];
}
// Ask for a few extra: private posts are never returned, and some come without a picture.
$url = INSTAGRAM_GRAPH . $c['user_id'] . '/tags?' . http_build_query([
'fields' => 'id,caption,media_type,media_url,thumbnail_url,permalink,timestamp,username',
'limit' => min(50, $c['max'] + 6),
'access_token' => $c['token'],
]);
[$json, $err] = _instagram_request($url);
if ($err !== '') {
// Keep serving the previous copy; retry after an hour, not a full window.
$data = $old;
$data['error'] = $err;
$data['error_at'] = time();
$data['fetched_at'] = time() - max(0, $c['cache_hours'] * 3600 - 3600);
_instagram_cache_write($data);
error_log('[instagram] ' . $err);
return $data;
}
$posts = [];
foreach ((array) ($json['data'] ?? []) as $m) {
if (count($posts) >= $c['max']) break;
$id = preg_replace('/\D+/', '', (string) ($m['id'] ?? ''));
if ($id === '' || empty($m['permalink'])) continue;
// Videos/reels: the cover (thumbnail_url); photos and albums: media_url.
$src = (string) (($m['media_type'] ?? '') === 'VIDEO' ? ($m['thumbnail_url'] ?? '') : ($m['media_url'] ?? ''));
$img = $src !== '' ? _instagram_save_thumb($src, $id) : '';
if ($img === '') continue;
$posts[] = [
'id' => $id,
'image' => $img,
'type' => (string) ($m['media_type'] ?? ''),
'permalink' => (string) $m['permalink'],
'username' => (string) ($m['username'] ?? ''),
'caption' => (string) ($m['caption'] ?? ''),
'timestamp' => (string) ($m['timestamp'] ?? ''),
];
}
// Drop thumbnails of posts that are no longer shown.
$keep = array_flip(array_column($posts, 'id'));
foreach (glob(_instagram_media_dir() . '*.jpg') ?: [] as $f) {
if (!isset($keep[basename($f, '.jpg')])) @unlink($f);
}
$data = ['fetched_at' => time(), 'posts' => $posts, 'error' => ''];
_instagram_cache_write($data);
return $data;
}
// Cached data, refreshed when older than the cache window (one refresh at a time).
function instagram_get(): array {
$c = instagram_config();
$cache = instagram_cache_read();
$fresh = $cache && (time() - (int) ($cache['fetched_at'] ?? 0)) < $c['cache_hours'] * 3600;
if ($fresh || !instagram_configured()) return $cache ?? [];
$lock = @fopen(_instagram_cache_path() . '.lock', 'c');
if ($lock && flock($lock, LOCK_EX | LOCK_NB)) {
$cache = instagram_refresh();
flock($lock, LOCK_UN);
}
if ($lock) fclose($lock);
return $cache ?? [];
}
// Posts the homepage shows (only those whose thumbnail is on disk), or [] to hide the section.
function site_instagram(?array $gym = null): array {
if ($gym !== null) instagram_use_gym($gym);
try { $data = instagram_get(); } catch (Throwable $e) { error_log('[instagram] ' . $e->getMessage()); return []; }
$root = dirname(__DIR__) . '/';
return array_values(array_filter((array) ($data['posts'] ?? []), fn($p) => is_file($root . $p['image'])));
}
+2 -16
View File
@@ -9,22 +9,8 @@
// e o browser pede-a ao próprio site. // e o browser pede-a ao próprio site.
function map_click_to_load(string $iframeHtml, string $mapsLink = ''): string { function map_click_to_load(string $iframeHtml, string $mapsLink = ''): string {
if (trim($iframeHtml) === '') return ''; // O mapa aparece logo com a página; o nome ficou por compatibilidade.
static $n = 0; return trim($iframeHtml);
$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>';
} }
function review_avatar_url(array $r): string { 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]);
}
+75 -4
View File
@@ -29,14 +29,38 @@ function site_info(PDO $pdo, ?int $gym_id = null): array {
return $row; return $row;
} }
/** Whether the home page renders section $sec for $gym (same data checks as site/index.php). */
function site_section_has_content(array $gym, string $sec): bool {
$pdo = $GLOBALS['pdo'] ?? null;
$gid = (int) $gym['id'];
if (!$pdo && $sec !== 'galeria' && $sec !== 'comunidade') return true;
return match ($sec) {
'modalidades' => (bool) site_modalidades($pdo, $gid),
'equipa' => (bool) site_team_active($pdo, $gid),
'horario' => (bool) site_horario($pdo, '', $gid),
'mensalidades' => (bool) site_memberships($pdo, $gid),
'galeria' => !empty($gym['gallery_id']) && img_list_dir('uploads/gallery/' . (int) $gym['gallery_id']),
'comunidade' => (bool) site_instagram($gym),
default => true,
};
}
/** Team members shown on the site (status «ativo»). */
function site_team_active(PDO $pdo, ?int $gym_id = null): array {
return array_values(array_filter(site_team($pdo, $gym_id), fn($d) => ($d['status'] ?? '') === 'ativo'));
}
// Top navigation (label => absolute URL): anchors on the gym's own page, only // Top navigation (label => absolute URL): anchors on the gym's own page, only
// for the sections that gym shows, plus its CrossFit page when it has one. // for the sections that gym shows and has content for, plus its CrossFit page when it has one.
function site_navbar(?array $gym = null): array { function site_navbar(?array $gym = null): array {
$gym = $gym ?? current_gym(); $gym = $gym ?? current_gym();
$items = ['Home' => ['#home', null]]; $items = ['Home' => ['#home', null]];
foreach (['Modalidades' => 'modalidades', 'Equipa' => 'equipa', 'Horário' => 'horario', foreach (['Galeria' => 'galeria', 'Comunidade' => 'comunidade', 'Modalidades' => 'modalidades', 'Equipa' => 'equipa', 'Horário' => 'horario',
'Mensalidades' => 'mensalidades', 'Contactos' => 'contactos'] as $label => $sec) { 'Mensalidades' => 'mensalidades', 'Contactos' => 'contactos'] as $label => $sec) {
if (gym_section_on($gym, $sec)) $items[$label] = ['#' . $sec, $sec]; if (!gym_section_on($gym, $sec)) continue;
// A section with nothing to show isn't rendered, so no link to it either.
if (!site_section_has_content($gym, $sec)) continue;
$items[$label] = ['#' . $sec, $sec];
} }
$nav = []; $nav = [];
foreach ($items as $label => [$hash]) $nav[$label] = gym_url($gym, $hash); foreach ($items as $label => [$hash]) $nav[$label] = gym_url($gym, $hash);
@@ -104,6 +128,51 @@ function site_img_url($value, $base_dir): string {
return base_url(rtrim((string) $base_dir, '/') . '/' . ltrim($value, '/')); 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 // Schedule joined to modalidades (name/colour/icon), optionally filtered by
// local (e.g. 'crossfit'). Mirrors Horario_model::getAll(). // local (e.g. 'crossfit'). Mirrors Horario_model::getAll().
function site_horario(PDO $pdo, string $local = '', ?int $gym_id = null): array { function site_horario(PDO $pdo, string $local = '', ?int $gym_id = null): array {
@@ -194,5 +263,7 @@ function modalidade_icon_html(?string $icon): string {
return $svg; 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="">';
} }
+3
View File
@@ -269,9 +269,12 @@ function _trash_image_file(PDO $pdo, int $imageId, string $action): void {
if ($action === 'delete' && is_file($live)) { if ($action === 'delete' && is_file($live)) {
if (!is_dir($dir . '.trash')) @mkdir($dir . '.trash', 0775, true); if (!is_dir($dir . '.trash')) @mkdir($dir . '.trash', 0775, true);
@rename($live, $trash); @rename($live, $trash);
img_fallback_follow($live, $trash); // foto.jxl.webp goes with foto.jxl
} elseif ($action === 'restore' && is_file($trash)) { } elseif ($action === 'restore' && is_file($trash)) {
@rename($trash, $live); @rename($trash, $live);
img_fallback_follow($trash, $live);
} elseif ($action === 'purge') { } elseif ($action === 'purge') {
if (is_file($trash)) @unlink($trash); if (is_file($trash)) @unlink($trash);
img_fallback_follow($trash);
} }
} }
+35 -47
View File
@@ -1,8 +1,11 @@
<?php <?php
// ─── Users: lookup, authentication, registration, password reset ───────────── // ─── Users: lookup, registration, password reset ─────────────────────────────
// This install owns its identity. Passwords live in `users.password_hash`; // 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) // but auditoria 2026-09-24 ponto 1.3 moved credential verification and
// bounces unauthenticated visitors there. Nothing is delegated to a hub. // 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() / // 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 // 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 ────────────────────────────────────────────────────────── // ─── 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 // Stamps the user-agent of the browser that just logged in. Best-effort:
// igualar o tempo de resposta quando a conta não existe. // shown in views/user/index.php ("last seen from …") but never worth failing
const AUTH_DUMMY_HASH = '$2y$12$luPhZpP46FkyufPCM1d8Gu4lPNS3A/I0gSvoBiNEL3lZAauMkuzH2'; // a login over.
function fit_stamp_login_ua(PDO $pdo, int $userId): void {
// 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();
$ua = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 512) ?: null; $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 = ?") try {
->execute([$ip, $ua, $user['id']]); $pdo->prepare("UPDATE users SET last_login_ua = ? WHERE id = ?")
$user['last_login_at'] = date('Y-m-d H:i:s'); ->execute([$ua, $userId]);
$user['last_login_ip'] = $ip; } catch (Throwable $e) {
$user['last_login_ua'] = $ua; error_log('[auth] last_login_ua stamp failed: ' . $e->getMessage());
return $user; }
} }
// Writes a new password hash. Shared by the reset flow, the profile page and // 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. // take the account back.
function auth_set_password(PDO $pdo, int $userId, string $password): void { function auth_set_password(PDO $pdo, int $userId, string $password): void {
$pdo->prepare("UPDATE users SET password_hash = ? WHERE id = ?") $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; $self = (int) ($_SESSION['user_id'] ?? 0) === $userId;
audit($self ? 'auth.password_change' : 'user.password_set', [ audit($self ? 'auth.password_change' : 'user.password_set', [
'target_type' => 'user', 'target_id' => $userId, 'target_type' => 'user', 'target_id' => $userId,
@@ -120,7 +108,7 @@ function auth_register_client(PDO $pdo, array $data): array {
} }
$pdo->prepare( $pdo->prepare(
"UPDATE users SET name = ?, phone = COALESCE(?, phone), password_hash = ?, is_active = 1 WHERE id = ?" "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, 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]); 'summary' => 'Registo que reclamou uma conta de cliente sem palavra-passe', 'actor' => null]);
return ['id' => (int) $row['id']]; return ['id' => (int) $row['id']];
@@ -136,7 +124,7 @@ function auth_register_client(PDO $pdo, array $data): array {
$name, $name,
$email, $email,
$phone, $phone,
password_hash($data['password'], PASSWORD_DEFAULT), auth_password_hash($data['password']),
!empty($data['marketing_consent']) ? 1 : 0, !empty($data['marketing_consent']) ? 1 : 0,
]); ]);
} catch (PDOException $e) { } catch (PDOException $e) {
@@ -161,7 +149,7 @@ function auth_create_sysadmin(PDO $pdo, string $name, string $email, string $pas
$stmt->execute([ $stmt->execute([
$name, $name,
strtolower(trim($email)), strtolower(trim($email)),
password_hash($password, PASSWORD_DEFAULT), auth_password_hash($password),
]); ]);
$id = (int) $pdo->lastInsertId(); $id = (int) $pdo->lastInsertId();
audit('user.create', ['target_type' => 'user', 'target_id' => $id, 'target_label' => $name, 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, $name,
strtolower(trim($email)), strtolower(trim($email)),
$phone ?: null, $phone ?: null,
password_hash($password, PASSWORD_DEFAULT), auth_password_hash($password),
$role, $role,
]); ]);
} catch (PDOException $e) { } 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> </main>
<script src="<?= base_url('assets/vendor/bootstrap5/js/bootstrap.bundle.min.js') ?>"></script> <script src="<?= base_url('assets/vendor/bootstrap5/js/bootstrap.bundle.min.js') ?>"></script>
<?= script_tag('assets/js/password-toggle.js') ?> <?= script_tag('assets/js/password-toggle.js') ?>
<?php include __DIR__ . '/dev_toolbar.php'; // DEV ONLY: sem saída fora de dev ?>
</body> </body>
</html> </html>
+3 -1
View File
@@ -29,7 +29,8 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070';
})(); })();
</script> </script>
<link href="<?= base_url('assets/vendor/bootstrap5/css/bootstrap.min.css') ?>" rel="stylesheet"> <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> <style>
:root { :root {
--bo-accent: #C2D500; --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 */ /* 16px inputs stop iOS from auto-zooming on focus */
@media (max-width: 575.98px) { .form-control { font-size: 16px; } .auth-card { padding: 1.5rem; } } @media (max-width: 575.98px) { .form-control { font-size: 16px; } .auth-card { padding: 1.5rem; } }
</style> </style>
<?php include __DIR__ . '/env_ribbon.php'; // moldura de aviso fora de produção ?>
</head> </head>
<body> <body>
<main class="auth-card"> <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'); }); mq.addEventListener('change', function () { if (pref === 'auto') apply('auto'); });
})(); })();
// Mobile off-canvas sidebar: hamburger toggles .sidebar.open + the backdrop; // Barra lateral. Em ecrã grande o hamburger alterna entre completa e mini (so
// tapping the backdrop, a nav link, Escape, or resizing to desktop closes it. // 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 () { (function () {
var sb = document.getElementById('sidebar'); var sb = document.getElementById('sidebar');
var tg = document.getElementById('menuToggle');
var bd = document.getElementById('sidebarBackdrop'); var bd = document.getElementById('sidebarBackdrop');
if (!sb || !tg) return; var toggles = document.querySelectorAll('[data-sidebar-toggle]');
var mq = window.matchMedia('(max-width: 991.98px)'); var root = document.documentElement;
function open() { sb.classList.add('open'); if (bd) bd.classList.add('show'); document.body.style.overflow = 'hidden'; tg.setAttribute('aria-expanded', 'true'); } var nav = sb && sb.querySelector('.bo-sidebar__nav');
function close() { sb.classList.remove('open'); if (bd) bd.classList.remove('show'); document.body.style.overflow = ''; tg.setAttribute('aria-expanded', 'false'); } if (!sb || !toggles.length) return;
tg.addEventListener('click', function () { sb.classList.contains('open') ? close() : open(); }); var mq = window.matchMedia('(min-width: 992px)');
if (bd) bd.addEventListener('click', close); function store(k, v) { try { localStorage.setItem(k, v); } catch (e) {} }
sb.querySelectorAll('.nav-link').forEach(function (a) {
a.addEventListener('click', function () { if (mq.matches) close(); }); 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(); }); if (bd) bd.addEventListener('click', function () { setDrawer(false); });
window.addEventListener('resize', function () { if (!mq.matches) close(); }); 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> </script>
<style> <style>
@@ -105,5 +175,6 @@ main:after {
margin-top: 2rem; margin-top: 2rem;
} }
</style> </style>
<?php include __DIR__ . '/dev_toolbar.php'; ?>
</body> </body>
</html> </html>
+119 -194
View File
@@ -25,203 +25,111 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070';
document.documentElement.setAttribute('data-theme-pref', pref); document.documentElement.setAttribute('data-theme-pref', pref);
})(); })();
</script> </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/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"> <link href="<?= base_url('assets/vendor/summernote/summernote-bs5.min.css') ?>" rel="stylesheet">
<style> <link href="<?= htmlspecialchars(_asset_versioned('assets/css/backoffice.css'), ENT_QUOTES) ?>" rel="stylesheet">
:root { <?php include __DIR__ . '/env_ribbon.php'; // moldura de aviso fora de produção ?>
--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>
</head> </head>
<body> <body>
<nav class="sidebar" id="sidebar"> <?php require_once __DIR__ . '/nav_helpers.php'; $dash = bo_dashboard_path(); ?>
<div class="sidebar-brand"><i class="bi bi-lightning-charge-fill me-2"></i><?= htmlspecialchars($app_name) ?></div> <aside class="sidebar" id="sidebar" aria-label="Menu principal">
<div class="sidebar-section">Principal</div> <div class="bo-sidebar__head">
<ul class="nav flex-column"> <button type="button" class="bo-sidebar__toggle" data-sidebar-toggle
<li class="nav-item"> aria-label="Reduzir menu" aria-expanded="true" aria-controls="sidebar">
<a class="nav-link<?= ($_SERVER['SCRIPT_NAME'] ?? '') === '/views/admin/dashboard.php' ? ' active' : '' ?>" <i class="bi bi-list" aria-hidden="true"></i>
href="<?= base_url('views/admin/dashboard.php') ?>"><i class="bi bi-speedometer2"></i>Painel</a> </button>
</li> <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 if (is_staff()): ?> <?php // Único sítio que abre o site público (nova aba). ?>
<li class="nav-item"> <a class="bo-sidebar__site" href="<?= base_url('/') ?>" target="_blank" rel="noopener"
<a class="nav-link" href="<?= base_url('/') ?>" target="_blank" rel="noopener"><i class="bi bi-box-arrow-up-right"></i>Site público</a> title="Ver site público" aria-label="Ver site público"><i class="bi bi-box-arrow-up-right" aria-hidden="true"></i></a>
</li> </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/']) ?>
<?= bo_nav_link('views/admin/instagram/index.php', 'bi-instagram', 'Comunidade (Instagram)', ['mod' => 'sub2', 'match' => 'views/admin/instagram/']) ?>
<?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; ?> <?php endif; ?>
</ul>
<?php if (is_admin()): ?> <?php if (is_sysadmin()): ?>
<div class="sidebar-section">Gestão</div> <?php bo_nav_group_start('sistema', 'Sistema', 'bi-gear'); ?>
<ul class="nav flex-column"> <?= bo_nav_link('views/sysadmin/system_settings.php', 'bi-sliders', 'Definições', ['mod' => 'sub']) ?>
<li class="nav-item"> <?= bo_nav_link('views/sysadmin/messages.php', 'bi-envelope-paper', 'Mensagens', ['mod' => 'sub']) ?>
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/gyms/') !== false) ? ' active' : '' ?>" <?= bo_nav_link('views/sysadmin/comms.php', 'bi-send', 'Comunicações', ['mod' => 'sub']) ?>
href="<?= base_url('views/admin/gyms/index.php') ?>"><i class="bi bi-buildings-fill"></i>Ginásios</a> <?php // Backups/Auditoria/IP bans/Geo/Origem das visitas/Atualizações/Hub: um só
</li> // link para o índice partilhado do platform-tools (como a fotografia/design/
<li class="nav-item"> // dance fazem), em vez de um por ferramenta — uma ferramenta nova no
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/team/') !== false) ? ' active' : '' ?>" // platform-tools passa a aparecer sozinha, sem precisar de link novo aqui. ?>
href="<?= base_url('views/admin/team/index.php') ?>"><i class="bi bi-people-fill"></i>Equipa</a> <?= bo_nav_link('platform.php?tool=index', 'bi-cpu', 'Sistema', ['mod' => 'sub',
</li> 'active' => (($_GET['tool'] ?? 'index') === 'index' && str_contains($_SERVER['REQUEST_URI'] ?? '', 'platform.php'))]) ?>
<li class="nav-item"> <?php if (function_exists('pt_icon_packs')): // pacotes de ícones só existem no submódulo novo ?>
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/modalidades/') !== false) ? ' active' : '' ?>" <?= bo_nav_link('platform.php?tool=icons', 'bi-grid-3x3-gap', 'Pacotes de ícones', ['mod' => 'sub',
href="<?= base_url('views/admin/modalidades/index.php') ?>"><i class="bi bi-grid-3x3-gap-fill"></i>Modalidades</a> 'active' => (($_GET['tool'] ?? '') === 'icons')]) ?>
</li> <?php endif; ?>
<li class="nav-item"> <?php bo_nav_group_end(); ?>
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/galerias/') !== false) ? ' active' : '' ?>" <?php endif; ?>
href="<?= base_url('views/admin/galerias/index.php') ?>"><i class="bi bi-images"></i>Galerias</a> </nav>
</li> </aside>
<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>
<div class="sidebar-backdrop" id="sidebarBackdrop" aria-hidden="true"></div> <div class="sidebar-backdrop" id="sidebarBackdrop" aria-hidden="true"></div>
<main> <main>
<div class="topbar"> <div class="topbar">
<button type="button" class="menu-toggle" id="menuToggle" aria-label="Abrir menu" aria-controls="sidebar"> <?php // Só em telemóvel: abre a gaveta (a barra está fora do ecrã). ?>
<i class="bi bi-list"></i> <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> </button>
<?php <?php
// Which gym the content pages edit (functions_gyms.php). Switching goes back // Which gym the content pages edit (functions_gyms.php). Switching goes back
@@ -244,7 +152,7 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070';
<noscript><button class="btn btn-sm btn-outline-secondary">Mudar</button></noscript> <noscript><button class="btn btn-sm btn-outline-secondary">Mudar</button></noscript>
</form> </form>
<?php endif; ?> <?php endif; ?>
<div class="user-dropdown"> <div class="bo-topbar__tools">
<div class="dropdown"> <div class="dropdown">
<button class="btn btn-sm btn-outline-secondary theme-toggle" type="button" data-bs-toggle="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"> aria-expanded="false" aria-label="Tema: automático" id="themeToggle">
@@ -256,14 +164,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> <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> </ul>
</div> </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>
</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"> <div class="p-3 p-md-4">
<?php <?php
// Show flash messages if any // 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;
}
Loaded 100 of 1904 files, more files were not shown because too many files have changed in this diff. Show more