Compare commits
126
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8683658fa | ||
|
|
22edadf0d7 | ||
|
|
2279970768 | ||
|
|
e804c52b4e | ||
|
|
6ac5213624 | ||
|
|
6abc486630 | ||
|
|
725f5b9c26 | ||
|
|
785d1f6ceb | ||
|
|
0bc14f5f72 | ||
|
|
e7ab661f4c | ||
|
|
4b54a777b7 | ||
|
|
2e8b6d20f6 | ||
|
|
c2b1f98e96 | ||
|
|
04df7f8997 | ||
|
|
f72292fc02 | ||
|
|
bfbd04a2d3 | ||
|
|
4f44bc1609 | ||
|
|
8fe73eb5d6 | ||
|
|
49adbd0ae1 | ||
|
|
1eb2a61031 | ||
|
|
cc677af92d | ||
|
|
7fa2da73dc | ||
|
|
14dbabcad5 | ||
|
|
bab6eb87bf | ||
|
|
3ffcf0fe38 | ||
|
|
9c28be314b | ||
|
|
d445ba64b9 | ||
|
|
a85bec6851 | ||
|
|
965a123834 | ||
|
|
68ac78097a | ||
|
|
3d30f09bb3 | ||
|
|
938347779c | ||
|
|
b2b736734e | ||
|
|
a2219ebd63 | ||
|
|
9e3eee227b | ||
|
|
72cf3dad98 | ||
|
|
e5f4fd8fc9 | ||
|
|
18676ad6d0 | ||
|
|
781a3a324e | ||
|
|
18556ca8b9 | ||
|
|
3d2a696162 | ||
|
|
6c14e644b1 | ||
|
|
99c28f04b4 | ||
|
|
4b6329f529 | ||
|
|
356554db42 | ||
|
|
b884749a4e | ||
|
|
f9ec3644c8 | ||
|
|
da90c5c681 | ||
|
|
9017c849f6 | ||
|
|
83398eb7f5 | ||
|
|
9588f902c6 | ||
|
|
8e889adeb9 | ||
|
|
f28b2b8482 | ||
|
|
4b99991c11 | ||
|
|
db28427c1e | ||
|
|
d40f6d8b2d | ||
|
|
348e7d6848 | ||
|
|
e52709b926 | ||
|
|
7bf2a8542e | ||
|
|
acac3a191a | ||
|
|
251644c393 | ||
|
|
2a4596e7e3 | ||
|
|
9a42c91471 | ||
|
|
2a66f74275 | ||
|
|
a94287e3d6 | ||
|
|
b103aeae58 | ||
|
|
8ede31ce3a | ||
|
|
58d5199883 | ||
|
|
32f8d7a613 | ||
|
|
4b2823d1e5 | ||
|
|
716eec9f6d | ||
|
|
aa55d44406 | ||
|
|
b73c23bf55 | ||
|
|
4ed2dd3709 | ||
|
|
5f64ae57db | ||
|
|
4c847267ea | ||
|
|
8edc37e43a | ||
|
|
7dc47f79cc | ||
|
|
63adaf19ce | ||
|
|
4446224c19 | ||
|
|
79a0047f29 | ||
|
|
f2a9c5181b | ||
|
|
4bf177289a | ||
|
|
b1e88781b8 | ||
|
|
4222b8f2e0 | ||
|
|
b5bbbd3b5f | ||
|
|
7e2798d0b7 | ||
|
|
65182eecd6 | ||
|
|
5478952171 | ||
|
|
3f2c505c17 | ||
|
|
116817e2de | ||
|
|
33158381bb | ||
|
|
6d7b6456a9 | ||
|
|
c5c4a818f2 | ||
|
|
8a0c2272a4 | ||
|
|
231b8eb699 | ||
|
|
7482dc98f2 | ||
|
|
4ddefa6dae | ||
|
|
30e85b2438 | ||
|
|
1e2068bea2 | ||
|
|
b837def5a0 | ||
|
|
33b7f19d15 | ||
|
|
b8c0422318 | ||
|
|
22dc14c8c8 | ||
|
|
595a37fd8b | ||
|
|
a42b6d33c0 | ||
|
|
6faad68059 | ||
|
|
90efdc4bc1 | ||
|
|
11b2906488 | ||
|
|
8300f2c421 | ||
|
|
43e0fc7be6 | ||
|
|
fa87aabb87 | ||
|
|
ccc644ac0d | ||
|
|
927cc1967d | ||
|
|
df1aad8769 | ||
|
|
7037077b48 | ||
|
|
ff1d0fdc07 | ||
|
|
300655b92a | ||
|
|
ee30e6f116 | ||
|
|
0ea8eb1cde | ||
|
|
32a5c24ba2 | ||
|
|
a9cda24de3 | ||
|
|
0ecc38774a | ||
|
|
35415429e5 | ||
|
|
f066eec7c7 | ||
|
|
5cf0f14ad2 |
No files matched your search
+14
-3
@@ -20,6 +20,10 @@ uploads/**/.trash/
|
||||
# (config/functions_privacy.php) para o browser não as pedir à Google.
|
||||
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
|
||||
# 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 é
|
||||
@@ -57,11 +61,18 @@ Thumbs.db
|
||||
.claude/worktrees/
|
||||
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
|
||||
# ─── Dependências ───────────────────────────────────────────────────────────
|
||||
# vendor/ ESTÁ versionado de propósito: não há Composer nesta app e o
|
||||
# PHPMailer foi copiado à mão (vendor/PHPMailer). Quando houver composer.json,
|
||||
# acrescentar aqui "vendor/" e retirá-lo do índice.
|
||||
# vendor/ é output do composer install, exceto vendor/PHPMailer: o
|
||||
# platform-tools partilhado (functions_mail.php) já espera esse caminho por
|
||||
# require_once, igual em toda a frota — composer install criaria uma segunda
|
||||
# árvore paralela (vendor/phpmailer/phpmailer/) que nada usa por si, mas fica
|
||||
# para o botão "Atualizar dependências" do backoffice e para composer audit.
|
||||
# composer.lock FICA versionado (fixa o que um deploy instala).
|
||||
/vendor/*
|
||||
!/vendor/PHPMailer/
|
||||
!/vendor/PHPMailer/**
|
||||
node_modules/
|
||||
|
||||
# ─── Lixo de editores / temporários ─────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
[submodule "shared/platform-tools"]
|
||||
path = shared/platform-tools
|
||||
url = ../platform-tools.git
|
||||
branch = main
|
||||
@@ -1,6 +1,11 @@
|
||||
# Disable directory listing
|
||||
Options -Indexes
|
||||
|
||||
# JPEG XL uploads (gym header/logo): older mime.types don't know the extension.
|
||||
<IfModule mod_mime.c>
|
||||
AddType image/jxl .jxl
|
||||
</IfModule>
|
||||
|
||||
# Block direct HTTP access to sensitive files
|
||||
<FilesMatch "^\.(env|env\..*|git.*|htaccess|htpasswd)$">
|
||||
Require all denied
|
||||
@@ -16,6 +21,10 @@ Options -Indexes
|
||||
RewriteEngine On
|
||||
RewriteRule "(^|/)\.(?!well-known)" - [F,L]
|
||||
|
||||
# Server-side trees: never reachable by URL (the repo root is the docroot here, and
|
||||
# the dev tree serves it from /GitTea/repos/fitme3070.pt/).
|
||||
RewriteRule ^(config|includes|database|logs|backups|templates|vendor)(/|$) - [F,L]
|
||||
|
||||
# A raiz web é o repositório inteiro, por isso fecha-se o que não é página:
|
||||
# - parciais (_form.php e afins) só se incluem, nunca se pedem;
|
||||
# - PHP soltos do platform-tools: só api/ e backups/ (auto-chamada com token)
|
||||
@@ -26,15 +35,18 @@ RewriteRule "^shared/platform-tools/[^/]+\.php$" - [F,L]
|
||||
RewriteRule "\.md$" - [F,L]
|
||||
|
||||
# ── Headers de segurança ─────────────────────────────────────────────────────
|
||||
# A CSP não restringe scripts ainda (o site tem scripts inline e incorporações
|
||||
# da Google); fecha o que não parte nada: quem pode meter o site num iframe,
|
||||
# <base>, plugins e para onde os formulários podem enviar.
|
||||
# script-src: 'self' (todo o JS próprio, incl. Bootstrap/jQuery/Summernote
|
||||
# vendorizados desde 25/09) + 'unsafe-inline' (o site tem vários <script>
|
||||
# inline — remover isso é reescrever cada um para nonce/hash, não cabe aqui)
|
||||
# + cdn.jsdelivr.net (html2canvas em views/admin/horario/instagram.php,
|
||||
# bootstrap-icons na página de erro). Já bloqueia o que importa: um
|
||||
# <script src="https://outro-dominio/..."> injetado não carrega.
|
||||
<IfModule mod_headers.c>
|
||||
Header always set X-Content-Type-Options "nosniff"
|
||||
Header always set Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Header always set X-Frame-Options "SAMEORIGIN"
|
||||
Header always set Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()"
|
||||
Header always set Content-Security-Policy "frame-ancestors 'self'; base-uri 'self'; object-src 'none'; form-action 'self' https://forms.zohopublic.eu"
|
||||
Header always set Content-Security-Policy "frame-ancestors 'self'; base-uri 'self'; object-src 'none'; form-action 'self' https://forms.zohopublic.eu; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net"
|
||||
</IfModule>
|
||||
|
||||
# ── Canonical host: force HTTPS + strip www ───────────────────────────────────
|
||||
@@ -51,6 +63,9 @@ RewriteRule ^site/assets/ - [L]
|
||||
RewriteRule ^assets/ - [L]
|
||||
|
||||
# ── Friendly URLs ─────────────────────────────────────────────────────────────
|
||||
# DEV ONLY: dev_login.php 404s sozinho se o platform-tools não tiver functions_dev.php.
|
||||
RewriteCond %{REQUEST_FILENAME}.php -f
|
||||
RewriteRule ^dev_login/?$ dev_login.php [L]
|
||||
RewriteRule ^home/?$ site/index.php [L]
|
||||
RewriteRule ^home/equipa/?$ site/equipa.php [L,QSA]
|
||||
RewriteRule ^home/modalidades/?$ site/modalidades.php [L,QSA]
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -0,0 +1 @@
|
||||
<?php require __DIR__ . '/../shared/platform-tools/api/router.php';
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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 || {}));
|
||||
}
|
||||
@@ -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 +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
@@ -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
@@ -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});
|
||||
@@ -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&<(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});
|
||||
@@ -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
@@ -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
@@ -1,19 +1,13 @@
|
||||
<?php
|
||||
// Auth guards — require functions.php (and session_start) before this file.
|
||||
// Auth guards — require functions.php (and the shared session, started by
|
||||
// platform_connect() in config/db.php) before this file.
|
||||
//
|
||||
// This install owns its passwords: they live in `users.password_hash` and
|
||||
// views/auth/login.php is a real form. There is no identity provider in front
|
||||
// of the app.
|
||||
|
||||
function require_login(): array {
|
||||
if (empty($_SESSION['user_id'])) {
|
||||
$return = $_SERVER['REQUEST_URI'] ?? '';
|
||||
$qs = $return ? ('?return_to=' . urlencode($return)) : '';
|
||||
header('Location: ' . base_url('views/auth/login.php' . $qs));
|
||||
exit;
|
||||
}
|
||||
return $_SESSION['user'];
|
||||
}
|
||||
// Auditoria 2026-09-24, ponto 1.3: estas funções deixaram de ter lógica
|
||||
// própria de sessão/roles — delegam em
|
||||
// shared/platform-tools/functions_auth.php (auth_check/auth_user/
|
||||
// auth_at_least/auth_is/...), ligado em config/db.php ('auth' => 'shared').
|
||||
// Os nomes e assinaturas mantêm-se exactamente iguais porque há ~30
|
||||
// ficheiros em views/ a chamá-las; só o que está por baixo mudou.
|
||||
|
||||
// ─── Role ladder ─────────────────────────────────────────────────────────────
|
||||
// sysadmin — super-admin (backups, platform update, app_base_url).
|
||||
@@ -24,6 +18,12 @@ function require_login(): array {
|
||||
// client — portal-only.
|
||||
// is_admin()/require_admin() = sysadmin OR admin. is_staff()/require_staff()
|
||||
// also includes the plain `staff` role.
|
||||
//
|
||||
// A hierarquia é agora expressa por posto numérico (auth_roles_register() em
|
||||
// config/db.php: sysadmin=100, admin=80, staff=40, client=10) em vez dos
|
||||
// antigos in_array() — auth_at_least('admin') dá exactamente o mesmo
|
||||
// resultado que in_array($role, ['sysadmin','admin']) dava, porque staff(40)
|
||||
// e client(10) ficam sempre abaixo de admin(80).
|
||||
|
||||
// Resolves the dashboard path for a given role. Used by index.php, the auth
|
||||
// pages, and require_admin()'s bounce fallback so all four agree on routing.
|
||||
@@ -36,12 +36,27 @@ function post_login_destination(string $role): string {
|
||||
};
|
||||
}
|
||||
|
||||
function require_login(): array {
|
||||
// O «manter sessão iniciada» já corre em config/db.php
|
||||
// (platform_connect(['remember' => true, ...]) chama auth_remember_resume()
|
||||
// logo no arranque, antes de qualquer view), por isso não há aqui nada
|
||||
// equivalente ao antigo remember_resume() manual — se havia um cookie
|
||||
// válido, $_SESSION['user'] já está preenchido a esta altura.
|
||||
if (!auth_check()) {
|
||||
$return = $_SERVER['REQUEST_URI'] ?? '';
|
||||
$qs = $return ? ('?return_to=' . urlencode($return)) : '';
|
||||
header('Location: ' . base_url('views/auth/login.php' . $qs));
|
||||
exit;
|
||||
}
|
||||
return auth_user();
|
||||
}
|
||||
|
||||
// Accepts sysadmin + admin only. Plain staff and clients are bounced to the
|
||||
// dashboard appropriate for their role (avoids redirect loops when an admin-
|
||||
// only page bounces a staff user).
|
||||
function require_admin(): array {
|
||||
$user = require_login();
|
||||
if (!in_array($user['role'], ['sysadmin', 'admin'])) {
|
||||
if (!auth_at_least('admin')) {
|
||||
header('Location: ' . base_url(post_login_destination($user['role'] ?? '')));
|
||||
exit;
|
||||
}
|
||||
@@ -52,7 +67,7 @@ function require_admin(): array {
|
||||
// Clients are redirected to the public index.
|
||||
function require_staff(): array {
|
||||
$user = require_login();
|
||||
if (!in_array($user['role'], ['sysadmin', 'admin', 'staff'])) {
|
||||
if (!auth_at_least('staff')) {
|
||||
header('Location: ' . base_url('index.php'));
|
||||
exit;
|
||||
}
|
||||
@@ -65,7 +80,7 @@ function require_staff(): array {
|
||||
// the role-appropriate sessions list.
|
||||
function require_session_ownership(array $session): array {
|
||||
$user = require_staff();
|
||||
if (in_array($user['role'], ['sysadmin', 'admin'], true)) return $user;
|
||||
if (auth_at_least('admin')) return $user;
|
||||
if ((int) ($session['photographer_id'] ?? 0) !== (int) $user['id']) {
|
||||
header('Location: ' . base_url('views/sessions/index.php'));
|
||||
exit;
|
||||
@@ -77,7 +92,7 @@ function require_session_ownership(array $session): array {
|
||||
// Plain admins are sent back to their dashboard.
|
||||
function require_sysadmin(): array {
|
||||
$user = require_login();
|
||||
if ($user['role'] !== 'sysadmin') {
|
||||
if (!auth_is('sysadmin')) {
|
||||
header('Location: ' . base_url(post_login_destination($user['role'] ?? '')));
|
||||
exit;
|
||||
}
|
||||
@@ -85,34 +100,34 @@ function require_sysadmin(): array {
|
||||
}
|
||||
|
||||
function current_user(): ?array {
|
||||
return $_SESSION['user'] ?? null;
|
||||
return auth_user();
|
||||
}
|
||||
|
||||
// True for sysadmin + admin (NOT plain staff).
|
||||
function is_admin(): bool {
|
||||
return in_array(current_user()['role'] ?? '', ['sysadmin', 'admin']);
|
||||
return auth_at_least('admin');
|
||||
}
|
||||
|
||||
// True for any backoffice user (sysadmin + admin + staff). Use this to show
|
||||
// a UI element that any non-client backoffice user should see.
|
||||
function is_staff(): bool {
|
||||
return in_array(current_user()['role'] ?? '', ['sysadmin', 'admin', 'staff']);
|
||||
return auth_at_least('staff');
|
||||
}
|
||||
|
||||
function is_sysadmin(): bool {
|
||||
return (current_user()['role'] ?? '') === 'sysadmin';
|
||||
return auth_is('sysadmin');
|
||||
}
|
||||
|
||||
// True for plain admins (not sysadmin, not staff). Useful for "hide from
|
||||
// sysadmin" edge cases; most code wants is_admin() or is_sysadmin().
|
||||
function is_admin_role(): bool {
|
||||
return (current_user()['role'] ?? '') === 'admin';
|
||||
return auth_is('admin');
|
||||
}
|
||||
|
||||
// True for plain staff only — the assignment-only role. Used to scope queries
|
||||
// to "photographer_id = me" instead of returning all sessions.
|
||||
function is_staff_role(): bool {
|
||||
return (current_user()['role'] ?? '') === 'staff';
|
||||
return auth_is('staff');
|
||||
}
|
||||
|
||||
// Back-compat shim: every is_designer() caller now means "is plain admin".
|
||||
@@ -120,21 +135,22 @@ function is_designer(): bool {
|
||||
return is_admin_role();
|
||||
}
|
||||
|
||||
// Refreshes the session's user fields (name/email/role) without a full
|
||||
// login — used after a profile edit or an admin-driven role change, where a
|
||||
// full auth_login() (rehash checks, last_login stamp, audit "auth.login")
|
||||
// would be the wrong event to log. Still rotates the session id: any
|
||||
// privilege/credential change should get a fresh one.
|
||||
function auth_set_session(array $user): void {
|
||||
// Rotate the session id on every privilege change (login, registration,
|
||||
// password reset). Defeats session fixation: an attacker who pre-seeded
|
||||
// FIT_SESS now ends up holding a dead id while the real user gets a fresh
|
||||
// one. The CSRF token is rotated alongside the session id.
|
||||
auth_session_regenerate();
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
session_regenerate_id(true);
|
||||
unset($_SESSION['csrf_token']);
|
||||
}
|
||||
$_SESSION['user_id'] = $user['id'];
|
||||
$_SESSION['user_id'] = (int) $user['id'];
|
||||
$_SESSION['user'] = [
|
||||
'id' => $user['id'],
|
||||
'name' => $user['name'],
|
||||
'email' => $user['email'],
|
||||
'role' => $user['role'],
|
||||
'id' => (int) $user['id'],
|
||||
'name' => $user['name'],
|
||||
'email' => $user['email'],
|
||||
'role' => $user['role'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
+100
-21
@@ -15,7 +15,7 @@ env_load(__DIR__ . '/../.env');
|
||||
function _bootstrap_fail(string $logMsg): void {
|
||||
@mkdir(__DIR__ . '/../logs', 0775, true);
|
||||
@error_log('[' . date('c') . '] ' . $logMsg . "\n", 3, __DIR__ . '/../logs/db_error.log');
|
||||
error_log('[fotografia bootstrap] ' . $logMsg);
|
||||
error_log('[fitme bootstrap] ' . $logMsg);
|
||||
http_response_code(500);
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
$tpl = __DIR__ . '/../templates/error_500.php';
|
||||
@@ -40,16 +40,19 @@ if (getenv('DB_PASS') === false) {
|
||||
_bootstrap_fail('required env var DB_PASS is not set (empty string is allowed)');
|
||||
}
|
||||
|
||||
$host = env('DB_HOST', '127.0.0.1');
|
||||
$port = (int) env('DB_PORT', 3306);
|
||||
$db = env('DB_NAME');
|
||||
$user = env('DB_USER');
|
||||
$pass = env('DB_PASS');
|
||||
$host = env('DB_HOST', '127.0.0.1');
|
||||
$port = (int) env('DB_PORT', 3306);
|
||||
$db = env('DB_NAME');
|
||||
// Not $user: that name leaks into the router's flat include scope (router.php
|
||||
// requires this file at top level, no function wrapper) and would shadow the
|
||||
// real logged-in user array that includes/header.php expects.
|
||||
$dbUser = env('DB_USER');
|
||||
$pass = env('DB_PASS');
|
||||
|
||||
$dsn = "mysql:host=$host;port=$port;dbname=$db;charset=utf8mb4";
|
||||
|
||||
try {
|
||||
$pdo = new PDO($dsn, $user, $pass, [
|
||||
$pdo = new PDO($dsn, $dbUser, $pass, [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
// Real server-side prepares (not driver emulation): a tighter defence
|
||||
// against injection than emulated string-interpolation. Safe here — the
|
||||
@@ -63,19 +66,95 @@ try {
|
||||
_bootstrap_fail('PDO connect failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Distinct session cookie per app so installs that share a host (e.g.
|
||||
// development.andmore.pt/id.andmore.pt/ and /fitme3070.pt/) don't overwrite
|
||||
// each other's PHPSESSID. Must run before session_start().
|
||||
// ─── Ligação ao platform-tools ───────────────────────────────────────────────
|
||||
//
|
||||
// Cookie e modo estrito definidos aqui, no código, para valerem em qualquer
|
||||
// servidor: antes o cookie saía só com path=/ (sem Secure, HttpOnly nem
|
||||
// SameSite) e o PHP aceitava IDs de sessão inventados pelo cliente.
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
ini_set('session.use_strict_mode', '1');
|
||||
ini_set('session.use_only_cookies', '1');
|
||||
ini_set('session.cookie_httponly', '1');
|
||||
ini_set('session.cookie_samesite', 'Lax');
|
||||
if (request_is_https()) ini_set('session.cookie_secure', '1');
|
||||
session_name('FIT_SESS');
|
||||
session_start();
|
||||
// Auditoria 2026-09-24, ponto 1.3: a app passou a usar a autenticação
|
||||
// partilhada (`shared/platform-tools/functions_auth.php`) em vez da sua
|
||||
// própria — login, sessão, roles e guardas (config/auth.php,
|
||||
// config/functions_user.php) agora chamam auth_attempt()/auth_login()/
|
||||
// auth_at_least()/etc. por baixo, com os mesmos nomes de função de sempre
|
||||
// para os ~30 ficheiros que os chamam não mudarem.
|
||||
//
|
||||
// O cookie de sessão (nome, Secure/HttpOnly/SameSite, modo estrito) passa a
|
||||
// ser inteiramente gerido por auth_session_start() através da opção
|
||||
// 'session' abaixo — o bloco que estava aqui antes (ini_set + session_start
|
||||
// manuais) foi removido para não haver dois sítios a configurar a mesma
|
||||
// sessão.
|
||||
//
|
||||
// `users` já tem exactamente as colunas por omissão do auth_schema()
|
||||
// (id, email, name, password_hash, role, is_active, last_login_at,
|
||||
// last_login_ip — confirmado via information_schema em produção), por isso
|
||||
// não é preciso mapear nada em 'schema'.
|
||||
//
|
||||
// auth_attempts (o throttle da APP, config/functions_throttle.php — ip+scope,
|
||||
// cobre login/registo/recuperação) continua a ser o único throttle de login
|
||||
// usado; o throttle do módulo partilhado (auth_throttle_check/hit, que exige
|
||||
// `identifier`) não é chamado, para não duplicar a defesa do mesmo
|
||||
// formulário com duas contagens diferentes. A tabela foi tornada conforme na
|
||||
// mesma (database/migration_20260925_shared_auth_attempts.sql) para o
|
||||
// relatório de saúde abaixo ('conformance' => 'report') não acusar erro e
|
||||
// para deixar a porta aberta a usar o throttle partilhado no futuro. Ver
|
||||
// views/auth/login.php para a nota completa.
|
||||
require_once dirname(__DIR__) . '/shared/platform-tools/functions_connector.php';
|
||||
// Pacotes de ícones partilhados (platform?tool=icons, pt_icon_assets()). Só se o
|
||||
// submódulo já os tem; sem ele os layouts caem nas cópias locais (fit_icon_assets()).
|
||||
// Flaticon NÃO está registado: o site usa PNGs (uploads/modalidades/icons), não a fonte.
|
||||
$iconsFile = dirname(__DIR__) . '/shared/platform-tools/functions_icons.php';
|
||||
if (is_file($iconsFile)) {
|
||||
require_once $iconsFile;
|
||||
}
|
||||
$iconsOpt = is_file($iconsFile)
|
||||
? ['icons' => ['packs' => ['bootstrap-icons', 'fontawesome'], 'default' => 'bootstrap-icons']]
|
||||
: [];
|
||||
platform_connect($pdo, $iconsOpt + [
|
||||
'auth' => 'shared',
|
||||
'roles' => [
|
||||
// Preserva a hierarquia que config/auth.php já tinha (sysadmin >
|
||||
// admin > staff > client, por in_array(), não por posto numérico):
|
||||
// staff acima de client, e os dois abaixo de admin(80), para
|
||||
// require_staff()/is_staff() continuarem a excluir clientes e
|
||||
// require_admin()/is_admin() continuarem a excluir staff e clientes.
|
||||
// sysadmin(100)/admin(80)/user(10) já vêm registados por omissão.
|
||||
'staff' => 40,
|
||||
'client' => 10,
|
||||
],
|
||||
'session' => [
|
||||
'name' => 'FIT_SESS',
|
||||
'samesite' => 'Lax',
|
||||
// secure/httponly ficam a cargo de auth_session_start(): secure
|
||||
// segue o mesmo critério de HTTPS que o bloco antigo usava,
|
||||
// httponly está sempre ligado.
|
||||
],
|
||||
'remember' => true, // «Manter sessão iniciada» — agora auth_remember_*
|
||||
// do platform-tools; ver views/auth/login.php.
|
||||
// Brand feed público (api/brand.php): nome, logos e cores para o hub.
|
||||
// Cores = tokens :root de site/assets/css/style.css. logo_light = logo de
|
||||
// cor clara (fundos escuros), logo_dark = logo escuro (fundos claros).
|
||||
// name: omitido de propósito (APP_NAME ainda não existe aqui; o default é hub_app_name()).
|
||||
'brand' => [
|
||||
'logo_light' => fn() => site_logo_rel('logo_light_filename', 'site/assets/img/logos/logo_crossfit_white.svg'),
|
||||
'logo_dark' => fn() => site_logo_rel('logo_dark_filename', 'site/assets/img/logos/logo_crossfit.svg'),
|
||||
'colors' => ['primary' => '#c2d500', 'secondary' => '#0b0b0d', 'accent' => '#a6b600'],
|
||||
],
|
||||
'guard' => 'require_sysadmin',
|
||||
'router' => true,
|
||||
'conformance' => 'report', // nunca recusa — só reporta no health endpoint
|
||||
|
||||
// A Comunicações desta app já cobre SMTP (config + testes); o Estado SMTP
|
||||
// partilhado seria um segundo editor da mesma configuração.
|
||||
'replaced_tools' => ['smtp_status' => 'views/sysadmin/comms.php'],
|
||||
'hide_tools' => ['smtp_status'],
|
||||
'sysadmin_tools' => [[
|
||||
'title' => 'Comunicações',
|
||||
'description' => 'Configuração SMTP, fila de mensagens e testes de envio.',
|
||||
'icon' => 'bi-broadcast',
|
||||
'url' => 'views/sysadmin/comms.php',
|
||||
'color' => 'info',
|
||||
], [
|
||||
'title' => 'Fila de mensagens',
|
||||
'description' => 'O que saiu, o que falhou e porquê.',
|
||||
'icon' => 'bi-envelope-paper',
|
||||
'url' => 'views/sysadmin/messages.php',
|
||||
'color' => 'secondary',
|
||||
]],
|
||||
]);
|
||||
@@ -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
@@ -15,8 +15,11 @@ require_once __DIR__ . '/functions_secrets.php'; // setting_or_env()/setting_s
|
||||
require_once __DIR__ . '/functions_db.php'; // generic DB helpers
|
||||
require_once __DIR__ . '/functions_audit.php'; // registo de auditoria (platform-tools) + audit()
|
||||
require_once __DIR__ . '/functions_trash.php'; // lixeira: soft delete + eliminação só após 30 dias
|
||||
require_once __DIR__ . '/functions_user.php'; // auth_login/register, password reset, user creators
|
||||
require_once __DIR__ . '/functions_user.php'; // register/reset/user creators (auth itself: platform-tools)
|
||||
require_once __DIR__ . '/auth.php'; // require_login/admin/staff/sysadmin(), role helpers
|
||||
// «Manter sessão iniciada»: shared/platform-tools/functions_remember.php,
|
||||
// ligado por platform_connect(['remember' => true]) em config/db.php — já não
|
||||
// há um functions_remember.php local (auditoria 1.3).
|
||||
require_once __DIR__ . '/functions_throttle.php'; // per-IP attempt throttle for the auth endpoints
|
||||
require_once __DIR__ . '/functions_migrations.php';// platform_update.php migrations runner
|
||||
|
||||
@@ -24,6 +27,7 @@ require_once __DIR__ . '/functions_migrations.php';// platform_update.php migrat
|
||||
require_once __DIR__ . '/functions_queue.php'; // message_queue + enqueue_*/flush_message
|
||||
require_once __DIR__ . '/functions_csrf.php';
|
||||
require_once __DIR__ . '/functions_html.php'; // allowlist HTML sanitiser (clean_richtext/clean_map_embed)
|
||||
require_once __DIR__ . '/functions_icons.php'; // fit_icon_assets(): pacotes de ícones partilhados + recurso local
|
||||
require_once __DIR__ . '/ci_compat.php'; // CI4 view-helper shim (script_tag/link_tag/view/esc/session)
|
||||
require_once __DIR__ . '/functions_gyms.php'; // multi-gym: gyms, URL folders, admin gym switcher
|
||||
require_once __DIR__ . '/functions_site.php'; // public-site data (site_info/offer/team/modalidades/horario)
|
||||
@@ -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_memberships.php'; // mensalidades + pedidos de inscrição
|
||||
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_privacy.php'; // mapa e fotos da Google só a pedido / servidos localmente
|
||||
require_once __DIR__ . '/functions_forms.php'; // anti-spam + bloqueio de IP dos formulários públicos
|
||||
require_once __DIR__ . '/functions_rgpd.php'; // pedidos de acesso/apagamento (art.º 15/17)
|
||||
|
||||
// ─── DB-backed settings → legacy constants ───────────────────────────────────
|
||||
// Resolved once from the settings cache so call sites read plain constants.
|
||||
@@ -97,6 +103,45 @@ function format_date_pt(string $date): string {
|
||||
return (new DateTime($date))->format('d/m/Y');
|
||||
}
|
||||
|
||||
// ─── CSV export (backoffice listings) ────────────────────────────────────────
|
||||
// Excel-friendly CSV (UTF-8 BOM + ';'). Every export is recorded in the audit
|
||||
// log — RGPD requires personal-data exports to be traceable.
|
||||
function csv_export(string $type, string $filename, string $summary, array $header, array $rows): void {
|
||||
audit('export.' . $type, [
|
||||
'target_type' => $type,
|
||||
'target_label' => 'CSV (' . count($rows) . ' registos)',
|
||||
'summary' => $summary,
|
||||
]);
|
||||
header('Content-Type: text/csv; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename="' . $filename . '-' . date('Y-m-d') . '.csv"');
|
||||
$out = fopen('php://output', 'w');
|
||||
fwrite($out, "\xEF\xBB\xBF");
|
||||
fputcsv($out, $header, ';', '"', '');
|
||||
foreach ($rows as $row) {
|
||||
fputcsv($out, $row, ';', '"', '');
|
||||
}
|
||||
fclose($out);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ─── Logo path (platform convention + static fallback) ───────────────────────
|
||||
// Setting <key> holds a file name. Convention: uploads/public/logo/<file>; the
|
||||
// settings page still stores uploads in assets/img/logo/ (legacy), so that is
|
||||
// accepted too. Empty setting / missing file -> $fallback (site-relative, no
|
||||
// leading slash), or '' when that file is missing as well.
|
||||
function site_logo_rel(string $settingKey, string $fallback): string {
|
||||
$file = basename((string) setting($settingKey, ''));
|
||||
$root = dirname(__DIR__) . '/';
|
||||
if ($file !== '') {
|
||||
foreach (['uploads/public/logo/', 'assets/img/logo/'] as $dir) {
|
||||
if (is_file($root . $dir . $file)) {
|
||||
return $dir . rawurlencode($file);
|
||||
}
|
||||
}
|
||||
}
|
||||
return is_file($root . $fallback) ? $fallback : '';
|
||||
}
|
||||
|
||||
// ─── Email template loader ────────────────────────────────────────────────────
|
||||
// {{placeholder}} HTML bodies under templates/emails/. Built locally, delivered
|
||||
// by id.andmore.pt via the enqueue_email()/flush_message() façade.
|
||||
@@ -107,9 +152,9 @@ function load_email_template(string $template, array $vars): string {
|
||||
}
|
||||
$html = file_get_contents($path);
|
||||
$brand = htmlspecialchars(APP_NAME, ENT_QUOTES, 'UTF-8');
|
||||
$logo = (string) setting('logo_light_filename', '');
|
||||
$logoTag = $logo !== ''
|
||||
? '<img src="' . htmlspecialchars(base_url('assets/img/logo/' . rawurlencode($logo)), ENT_QUOTES, 'UTF-8')
|
||||
$rel = site_logo_rel('logo_light_filename', 'site/assets/img/logos/logo_crossfit_white.svg');
|
||||
$logoTag = $rel !== ''
|
||||
? '<img src="' . htmlspecialchars(base_url($rel), ENT_QUOTES, 'UTF-8')
|
||||
. '" alt="' . $brand . '" style="max-height:60px;border:0;">'
|
||||
: '<p style="margin:0;color:#fff;font-size:22px;font-weight:bold;letter-spacing:1px;">' . $brand . '</p>';
|
||||
$html = str_replace('{{logo_html}}', $logoTag, $html);
|
||||
|
||||
@@ -38,6 +38,36 @@ function db_get_active_admins(PDO $pdo): array {
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
// Every user, in role order — the listing at views/user/index.php.
|
||||
function db_users_list(PDO $pdo): array {
|
||||
return $pdo->query(
|
||||
"SELECT id, name, email, phone, role, is_active, password_hash, last_login_at, last_login_ua
|
||||
FROM users ORDER BY FIELD(role,'sysadmin','admin','staff','client'), name"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
// Dashboard widget counts (views/admin/dashboard.php).
|
||||
function db_users_count_clients(PDO $pdo): int {
|
||||
return (int) $pdo->query("SELECT COUNT(*) FROM users WHERE role = 'client'")->fetchColumn();
|
||||
}
|
||||
|
||||
function db_users_count_team(PDO $pdo): int {
|
||||
return (int) $pdo->query("SELECT COUNT(*) FROM users WHERE role IN ('admin','staff')")->fetchColumn();
|
||||
}
|
||||
|
||||
// Profile self-edit (views/user/profile.php) — the contact-details half of
|
||||
// the form; the password half already goes through auth_set_password().
|
||||
function db_user_update_profile(PDO $pdo, int $userId, array $data): void {
|
||||
$pdo->prepare(
|
||||
"UPDATE users SET phone=?, address=?, city=?, postal_code=?,
|
||||
email_opt_out=?, sms_opt_out=?, marketing_consent=?
|
||||
WHERE id=?"
|
||||
)->execute([
|
||||
$data['phone'], $data['address'], $data['city'], $data['postal_code'],
|
||||
$data['email_opt_out'], $data['sms_opt_out'], $data['marketing_consent'], $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
// PT-PT label for the role ladder.
|
||||
function role_label(string $role): string {
|
||||
return [
|
||||
|
||||
@@ -15,6 +15,7 @@ function events_defaults(): array {
|
||||
'location' => '', 'event_starts_at' => null, 'event_ends_at' => null,
|
||||
'publish_from' => '', 'publish_until' => '', 'is_active' => 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
|
||||
(slug, title, subtitle, summary, description, hero_image, cor, location,
|
||||
event_starts_at, event_ends_at, publish_from, publish_until,
|
||||
is_active, cta_label, cta_url, show_lead_form)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
is_active, cta_label, cta_url, show_lead_form, gym_id)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
");
|
||||
$stmt->execute([
|
||||
$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_until']),
|
||||
(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();
|
||||
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 = ?,
|
||||
hero_image = ?, cor = ?, location = ?,
|
||||
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 = ?
|
||||
");
|
||||
$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_until']),
|
||||
(int) $d['is_active'], $d['cta_label'], $d['cta_url'], (int) $d['show_lead_form'],
|
||||
$d['gym_id'] ? (int) $d['gym_id'] : null,
|
||||
$id,
|
||||
]);
|
||||
$__r = $stmt->rowCount() > 0;
|
||||
@@ -225,6 +228,7 @@ function events_image_remove(?string $filename): void {
|
||||
if ($filename === '' || strncmp($filename, 'upl_', 4) !== 0) return;
|
||||
$path = events_image_dir() . $filename;
|
||||
if (is_file($path)) @unlink($path);
|
||||
img_fallback_follow($path);
|
||||
}
|
||||
|
||||
// ─── Leads ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -8,6 +8,12 @@ function db_gallery_list(PDO $pdo): array {
|
||||
return $pdo->query("SELECT * FROM galleries WHERE deleted_at IS NULL ORDER BY name")->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
// id => name pairs for a <select> (gyms create/edit "gallery" picker).
|
||||
function db_galleries_options(PDO $pdo): array {
|
||||
return $pdo->query("SELECT id, name FROM galleries WHERE deleted_at IS NULL ORDER BY id")
|
||||
->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
}
|
||||
|
||||
function db_gallery_get(PDO $pdo, int $id): ?array {
|
||||
$stmt = $pdo->prepare("SELECT * FROM galleries WHERE id = ? LIMIT 1");
|
||||
$stmt->execute([$id]);
|
||||
@@ -44,23 +50,34 @@ function db_gallery_images(PDO $pdo, int $gallery_id): array {
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
function db_gallery_image_upload(PDO $pdo, int $gallery_id, array $file): ?string {
|
||||
if ($file['error'] !== UPLOAD_ERR_OK) return null;
|
||||
// Limite por ficheiro: o menor entre 16 MB e o upload_max_filesize do PHP.
|
||||
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).
|
||||
$ext = upload_image_ext($file, ['jpg', 'jpeg', 'png', 'gif', 'webp']);
|
||||
if ($ext === null) return null;
|
||||
$ext = upload_image_ext($file, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'jxl']);
|
||||
if ($ext === null) { $error = 'formato não suportado'; return null; }
|
||||
|
||||
$upload_dir = __DIR__ . '/../uploads/gallery/' . $gallery_id . '/';
|
||||
if (!is_dir($upload_dir)) @mkdir($upload_dir, 0775, true);
|
||||
|
||||
$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);
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
|
||||
+121
-18
@@ -19,6 +19,7 @@ function gym_sections(): array {
|
||||
return [
|
||||
'oferta' => 'A Nossa Oferta',
|
||||
'galeria' => 'Galeria (instalações)',
|
||||
'comunidade' => 'Comunidade (Instagram)',
|
||||
'modalidades' => 'Modalidades',
|
||||
'equipa' => 'Equipa',
|
||||
'horario' => 'Horário de aulas',
|
||||
@@ -41,7 +42,7 @@ function gym_defaults(): array {
|
||||
'slug' => '', 'name' => '', 'locality' => '', 'postal_code' => '', 'is_published' => 0,
|
||||
'is_default' => 0, 'sections' => null, 'hero_title' => '', 'hero_subtitle' => '',
|
||||
'hero_image' => '', 'seo_title' => '', 'seo_description' => '', 'gallery_id' => null,
|
||||
'google_place_id' => '', 'has_crossfit' => 0, 'item_order' => 0,
|
||||
'google_place_id' => '', 'has_crossfit' => 0, 'brand_color' => '', 'logo' => '', 'item_order' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -103,6 +104,32 @@ function gym_section_on(array $gym, string $key): bool {
|
||||
return !is_array($list) || in_array($key, $list, true);
|
||||
}
|
||||
|
||||
// Brand colour of a gym as :root overrides for the public site. Derives the
|
||||
// hover shade, the text-on-light ink and the text colour that stays readable on
|
||||
// the colour (dark on light brands, white on dark ones). '' = stylesheet default.
|
||||
function gym_brand_vars(array $gym): string {
|
||||
$hex = strtoupper((string) ($gym['brand_color'] ?? ''));
|
||||
if (!preg_match('/^#[0-9A-F]{6}$/', $hex)) return '';
|
||||
[$r, $g, $b] = [hexdec(substr($hex, 1, 2)), hexdec(substr($hex, 3, 2)), hexdec(substr($hex, 5, 2))];
|
||||
$shade = fn(float $k) => sprintf('#%02X%02X%02X', (int) round($r * $k), (int) round($g * $k), (int) round($b * $k));
|
||||
$lum = (0.2126 * $r + 0.7152 * $g + 0.0722 * $b) / 255;
|
||||
return '--fitme-accent:' . $hex . ';--fitme-accent-rgb:' . "$r, $g, $b" . ';--fitme-accent-dark:' . $shade(.86)
|
||||
. ';--fitme-accent-ink:' . $shade($lum > .5 ? .5 : .85) . ';--fitme-on-accent:' . ($lum > .5 ? '#0B0B0D' : '#FFFFFF');
|
||||
}
|
||||
|
||||
// The gym's logo (relative path). The main gym falls back to the site's own
|
||||
// FitMe 3070 logo, so its navbar button has one before anything is uploaded.
|
||||
function gym_logo(array $gym): string {
|
||||
$logo = (string) ($gym['logo'] ?? '');
|
||||
if ($logo === '' && !empty($gym['is_default'])) $logo = 'site/assets/img/logos/logo_black.svg';
|
||||
return $logo;
|
||||
}
|
||||
|
||||
function gym_brand_css(array $gym): string {
|
||||
$v = gym_brand_vars($gym);
|
||||
return $v === '' ? '' : ':root{' . $v . '}';
|
||||
}
|
||||
|
||||
function gym_url(array $gym, string $path = ''): string {
|
||||
return base_url(rawurlencode((string) $gym['slug']) . '/' . ltrim($path, '/'));
|
||||
}
|
||||
@@ -159,8 +186,22 @@ function gyms_from_post(PDO $pdo, array $post, array &$errors, ?int $id = null):
|
||||
'gallery_id' => (int) ($post['gallery_id'] ?? 0) ?: null,
|
||||
'google_place_id' => mb_substr(trim((string) ($post['google_place_id'] ?? '')), 0, 120),
|
||||
'has_crossfit' => !empty($post['has_crossfit']) ? 1 : 0,
|
||||
'logo' => mb_substr(trim((string) ($post['logo'] ?? '')), 0, 255),
|
||||
'brand_color' => strtoupper(trim((string) ($post['brand_color'] ?? ''))),
|
||||
'item_order' => (int) ($post['item_order'] ?? 0),
|
||||
];
|
||||
// Header image + logo come from file inputs (a fresh upload wins, "remover"
|
||||
// clears it, otherwise the current value posted in the hidden field stays).
|
||||
foreach (['hero' => 'hero_image', 'logo' => 'logo'] as $kind => $field) {
|
||||
if (!empty($post[$kind . '_remove'])) $data[$field] = '';
|
||||
$file = $_FILES[$kind . '_file'] ?? null;
|
||||
if ($file && ($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) {
|
||||
$err = null;
|
||||
$saved = gyms_save_image($file, $kind, $err);
|
||||
if ($saved !== null) $data[$field] = $saved;
|
||||
else $errors[] = ($kind === 'hero' ? 'Imagem do header: ' : 'Logótipo: ') . ($err ?? 'ficheiro inválido.');
|
||||
}
|
||||
}
|
||||
$on = array_values(array_intersect(array_keys(gym_sections()), (array) ($post['sections'] ?? [])));
|
||||
$data['sections'] = count($on) === count(gym_sections()) ? null : json_encode($on);
|
||||
|
||||
@@ -173,25 +214,42 @@ function gyms_from_post(PDO $pdo, array $post, array &$errors, ?int $id = null):
|
||||
$other = db_gyms_by_slug($pdo, $slug);
|
||||
if ($other && (int) $other['id'] !== (int) $id) $errors[] = 'Já existe um ginásio com essa pasta da URL.';
|
||||
}
|
||||
if ($data['logo'] !== '' && !preg_match('~^(site/assets/img|uploads)/[A-Za-z0-9_./ -]+\.(svg|png|webp|jpe?g|jxl)$~i', $data['logo'])) {
|
||||
$errors[] = 'Logótipo: imagem inválida.';
|
||||
}
|
||||
if ($data['brand_color'] !== '' && !preg_match('/^#[0-9A-F]{6}$/', $data['brand_color'])) {
|
||||
$errors[] = 'Cor da marca: formato #RRGGBB (ex.: #C2D500).';
|
||||
}
|
||||
if ($data['is_default'] && !$data['is_published']) $errors[] = 'O ginásio principal tem de estar publicado.';
|
||||
if ($data['hero_image'] !== '' && !preg_match('~^(site/assets/img|uploads)/[A-Za-z0-9_./ -]+\.(jpe?g|png|webp)$~i', $data['hero_image'])) {
|
||||
$errors[] = 'Imagem do header: caminho de uma imagem do site (ex.: site/assets/img/home.jpg).';
|
||||
if ($data['hero_image'] !== '' && !preg_match('~^(site/assets/img|uploads)/[A-Za-z0-9_./ -]+\.(jpe?g|png|webp|jxl)$~i', $data['hero_image'])) {
|
||||
$errors[] = 'Imagem do header: imagem inválida.';
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
function _gyms_params(array $d): array {
|
||||
return [$d['slug'], $d['name'], $d['locality'], $d['postal_code'], $d['is_published'], $d['is_default'],
|
||||
$d['sections'], $d['hero_title'], $d['hero_subtitle'], $d['hero_image'], $d['seo_title'],
|
||||
$d['seo_description'], $d['gallery_id'], $d['google_place_id'], $d['has_crossfit'], $d['item_order']];
|
||||
// Columns written by create/update, in order. brand_color and logo were added by
|
||||
// migration_20261013_gym_brand_color.sql: an install that hasn't run it yet keeps
|
||||
// working (they are just not saved) instead of failing on an unknown column.
|
||||
function _gyms_cols(PDO $pdo, array $d): array {
|
||||
$cols = [
|
||||
'slug' => $d['slug'], 'name' => $d['name'], 'locality' => $d['locality'], 'postal_code' => $d['postal_code'],
|
||||
'is_published' => $d['is_published'], 'is_default' => $d['is_default'], 'sections' => $d['sections'],
|
||||
'hero_title' => $d['hero_title'], 'hero_subtitle' => $d['hero_subtitle'], 'hero_image' => $d['hero_image'],
|
||||
'seo_title' => $d['seo_title'], 'seo_description' => $d['seo_description'], 'gallery_id' => $d['gallery_id'],
|
||||
'google_place_id' => $d['google_place_id'], 'has_crossfit' => $d['has_crossfit'],
|
||||
];
|
||||
foreach (['brand_color', 'logo'] as $opt) {
|
||||
if (_gyms_has_col($pdo, 'gyms', $opt)) $cols[$opt] = $d[$opt] ?? '';
|
||||
}
|
||||
$cols['item_order'] = $d['item_order'];
|
||||
return $cols;
|
||||
}
|
||||
|
||||
function db_gyms_create(PDO $pdo, array $d): int {
|
||||
$cols = _gyms_cols($pdo, $d);
|
||||
$pdo->beginTransaction();
|
||||
$pdo->prepare("INSERT INTO gyms (slug, name, locality, postal_code, is_published, is_default, sections,
|
||||
hero_title, hero_subtitle, hero_image, seo_title, seo_description, gallery_id,
|
||||
google_place_id, has_crossfit, item_order, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, NOW(), NOW())")->execute(_gyms_params($d));
|
||||
$pdo->prepare('INSERT INTO gyms (' . implode(', ', array_keys($cols)) . ', created_at, updated_at) VALUES ('
|
||||
. implode(',', array_fill(0, count($cols), '?')) . ', NOW(), NOW())')->execute(array_values($cols));
|
||||
$id = (int) $pdo->lastInsertId();
|
||||
if ($d['is_default']) $pdo->prepare("UPDATE gyms SET is_default = 0 WHERE id <> ?")->execute([$id]);
|
||||
// Its own contacts row (id = gym id), starting from the gym's name.
|
||||
@@ -206,14 +264,10 @@ function db_gyms_create(PDO $pdo, array $d): int {
|
||||
|
||||
function db_gyms_update(PDO $pdo, int $id, array $d): bool {
|
||||
$__before = audit_row($pdo, 'gyms', 'id', (int) $id);
|
||||
$cols = _gyms_cols($pdo, $d);
|
||||
$pdo->beginTransaction();
|
||||
$params = _gyms_params($d); $params[] = $id;
|
||||
$st = $pdo->prepare("UPDATE gyms SET slug = ?, name = ?, locality = ?, postal_code = ?, is_published = ?,
|
||||
is_default = ?, sections = ?, hero_title = ?, hero_subtitle = ?, hero_image = ?,
|
||||
seo_title = ?, seo_description = ?, gallery_id = ?, google_place_id = ?,
|
||||
has_crossfit = ?, item_order = ?, updated_at = NOW()
|
||||
WHERE id = ?");
|
||||
$st->execute($params);
|
||||
$st = $pdo->prepare('UPDATE gyms SET ' . implode(' = ?, ', array_keys($cols)) . ' = ?, updated_at = NOW() WHERE id = ?');
|
||||
$st->execute(array_merge(array_values($cols), [$id]));
|
||||
$changed = $st->rowCount() > 0;
|
||||
if ($d['is_default']) $pdo->prepare("UPDATE gyms SET is_default = 0 WHERE id <> ?")->execute([$id]);
|
||||
$pdo->commit();
|
||||
@@ -222,6 +276,55 @@ function db_gyms_update(PDO $pdo, int $id, array $d): bool {
|
||||
return $__r;
|
||||
}
|
||||
|
||||
// ─── Header image / logo uploads ──────────────────────────────────────────────
|
||||
// Files live in uploads/gyms/ (uploads/.htaccess blocks script execution) and the
|
||||
// DB keeps the site-relative path, which is what the public pages already read.
|
||||
function gyms_upload_dir(): string {
|
||||
return dirname(__DIR__) . '/uploads/gyms/';
|
||||
}
|
||||
|
||||
// Validates by content (upload_image_ext, SVGs through svg_is_safe), stores the
|
||||
// file under a random name and returns "uploads/gyms/<name>", or null with $error.
|
||||
function gyms_save_image(array $file, string $kind, ?string &$error = null): ?string {
|
||||
if (($file['error'] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) {
|
||||
$error = ($file['error'] === UPLOAD_ERR_INI_SIZE || $file['error'] === UPLOAD_ERR_FORM_SIZE)
|
||||
? 'o ficheiro é demasiado grande.' : 'o envio falhou.';
|
||||
return null;
|
||||
}
|
||||
if (($file['size'] ?? 0) > 6 * 1024 * 1024) { $error = 'máximo 6 MB.'; return null; }
|
||||
$allowed = $kind === 'hero' ? ['jpg', 'jpeg', 'png', 'webp', 'jxl'] : ['svg', 'png', 'webp', 'jpg', 'jpeg', 'jxl'];
|
||||
$ext = upload_image_ext($file, $allowed);
|
||||
if ($ext === null) {
|
||||
$error = $kind === 'hero' ? 'usa JPG, PNG, WebP ou JPEG XL.' : 'usa SVG, PNG, WebP, JPG ou JPEG XL (SVG sem scripts).';
|
||||
return null;
|
||||
}
|
||||
$dir = gyms_upload_dir();
|
||||
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) { $error = 'não foi possível criar a pasta de envio.'; return null; }
|
||||
$name = $kind . '_' . bin2hex(random_bytes(6)) . '.' . $ext;
|
||||
if (!move_uploaded_file($file['tmp_name'], $dir . $name)) { $error = 'não foi possível guardar o ficheiro.'; return null; }
|
||||
if ($ext !== 'svg' && function_exists('img_optimize')) {
|
||||
img_optimize($dir . $name, $kind === 'hero' ? IMG_MAX_GALLERY : 600);
|
||||
}
|
||||
return 'uploads/gyms/' . $name;
|
||||
}
|
||||
|
||||
// Deletes an uploaded image that is no longer referenced (never a bundled one).
|
||||
function gyms_remove_image(?string $rel): void {
|
||||
$rel = (string) $rel;
|
||||
if (!preg_match('~^uploads/gyms/[A-Za-z0-9_.-]+$~', $rel)) return;
|
||||
$path = gyms_upload_dir() . basename($rel);
|
||||
if (is_file($path)) @unlink($path);
|
||||
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).
|
||||
function db_gyms_delete(PDO $pdo, int $id): bool {
|
||||
$st = $pdo->prepare("UPDATE gyms SET deleted_at = NOW(), is_published = 0
|
||||
|
||||
@@ -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
@@ -39,6 +39,15 @@ function upload_image_ext(array $file, array $allowed): ?string {
|
||||
}
|
||||
$raster = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp'];
|
||||
|
||||
// JPEG XL: o getimagesize()/GD não o conhecem e o finfo só nas versões
|
||||
// recentes, por isso vê-se pela assinatura (codestream FF 0A ou contentor
|
||||
// ISOBMFF "JXL "). Só entra onde quem chama pôs 'jxl' em $allowed; o
|
||||
// img_optimize() salta-o (não há GD para JXL).
|
||||
if (in_array('jxl', $allowed, true)) {
|
||||
$head = (string) @file_get_contents($tmp, false, null, 0, 12);
|
||||
if (str_starts_with($head, "\xFF\x0A") || $head === "\x00\x00\x00\x0CJXL \x0D\x0A\x87\x0A") return 'jxl';
|
||||
}
|
||||
|
||||
if (isset($raster[$mime]) || $mime === '') {
|
||||
$info = @getimagesize($tmp);
|
||||
$ext = $info ? ($raster[$info['mime']] ?? null) : null;
|
||||
@@ -80,6 +89,7 @@ function img_optimize(string $path, int $max_dim): array {
|
||||
|
||||
if (!is_file($path)) return $fail('ficheiro não encontrado');
|
||||
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);
|
||||
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;
|
||||
foreach (scandir($dir) ?: [] as $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));
|
||||
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);
|
||||
$stats[$r['status']]++;
|
||||
$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';
|
||||
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;
|
||||
}
|
||||
@@ -43,6 +43,11 @@ function info_from_post(array $post, array &$errors): array {
|
||||
|
||||
function db_info_save(PDO $pdo, array $data): bool {
|
||||
$cols = array_keys(info_fields());
|
||||
foreach ($cols as $c) {
|
||||
if (!preg_match('/^[A-Za-z0-9_]+$/', $c)) {
|
||||
throw new InvalidArgumentException("Nome de coluna inválido: $c");
|
||||
}
|
||||
}
|
||||
// One row per gym (id = gyms.id): the gym being edited in the backoffice.
|
||||
$gid = admin_gym_id();
|
||||
$__before = audit_row($pdo, 'fitme3070_info', 'id', (int) $gid);
|
||||
|
||||
@@ -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'])));
|
||||
}
|
||||
@@ -9,22 +9,8 @@
|
||||
// e o browser pede-a ao próprio site.
|
||||
|
||||
function map_click_to_load(string $iframeHtml, string $mapsLink = ''): string {
|
||||
if (trim($iframeHtml) === '') return '';
|
||||
static $n = 0;
|
||||
$id = 'map-consent-' . (++$n);
|
||||
$esc = fn(string $s): string => htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
|
||||
$link = $mapsLink !== ''
|
||||
? '<a href="' . $esc($mapsLink) . '" target="_blank" rel="noopener" class="map-consent-link">Abrir no Google Maps</a>'
|
||||
: '';
|
||||
return '<div class="map-consent" id="' . $id . '" style="position:absolute;inset:0;display:flex;flex-direction:column;'
|
||||
. 'align-items:center;justify-content:center;gap:.6rem;padding:1rem;text-align:center;color:#f5f5f5;">'
|
||||
. '<i class="fas fa-map-marker-alt" aria-hidden="true" style="font-size:1.8rem;color:#C2D500"></i>'
|
||||
. '<button type="button" class="site-btn" style="min-height:44px" '
|
||||
. 'onclick="var w=document.getElementById(\'' . $id . '\');var t=w.nextElementSibling;'
|
||||
. 'w.insertAdjacentHTML(\'afterend\',t.innerHTML);w.remove();">Mostrar mapa</button>'
|
||||
. '<small style="max-width:32em;opacity:.8">O mapa é do Google Maps: ao abri-lo, a Google recebe o seu endereço IP e pode guardar cookies.</small>'
|
||||
. $link
|
||||
. '</div><template>' . $iframeHtml . '</template>';
|
||||
// O mapa aparece logo com a página; o nome ficou por compatibilidade.
|
||||
return trim($iframeHtml);
|
||||
}
|
||||
|
||||
function review_avatar_url(array $r): string {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -29,14 +29,38 @@ function site_info(PDO $pdo, ?int $gym_id = null): array {
|
||||
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
|
||||
// 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 {
|
||||
$gym = $gym ?? current_gym();
|
||||
$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) {
|
||||
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 = [];
|
||||
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, '/'));
|
||||
}
|
||||
|
||||
// Same value/$base_dir resolution as site_img_url(), but to a filesystem path
|
||||
// (for getimagesize()) instead of a URL. Empty string for a remote http(s)
|
||||
// image or anything that doesn't resolve to a local file.
|
||||
function site_img_path($value, $base_dir): string {
|
||||
$value = (string) $value;
|
||||
if ($value === '' || substr($value, 0, 4) === 'http') return '';
|
||||
$root = dirname(__DIR__);
|
||||
$rel = (substr($value, 0, 8) === 'uploads/')
|
||||
? $value
|
||||
: rtrim((string) $base_dir, '/') . '/' . ltrim($value, '/');
|
||||
return $root . '/' . $rel;
|
||||
}
|
||||
|
||||
// width="" height="" attribute string for an uploaded image, read from the
|
||||
// file itself (uploads aren't resized to one fixed size — see
|
||||
// config/functions_images.php — so this can't be hardcoded). Empty string
|
||||
// when the file is missing/unreadable, so the <img> just renders without the
|
||||
// attributes rather than with bogus 0x0 ones.
|
||||
function site_img_dims_attr($value, $base_dir): string {
|
||||
$path = site_img_path($value, $base_dir);
|
||||
if ($path === '') return '';
|
||||
return svg_img_dims_attr($path);
|
||||
}
|
||||
|
||||
// width="" height="" for any local raster or SVG file. getimagesize() covers
|
||||
// raster; for SVG (which it doesn't decode) this reads the width/height
|
||||
// attributes and falls back to the viewBox aspect ratio when only that is
|
||||
// set (e.g. the site logos). Empty string when the file is missing or has no
|
||||
// size info to go on.
|
||||
function svg_img_dims_attr(string $path): string {
|
||||
if (!is_file($path)) return '';
|
||||
if (strtolower(pathinfo($path, PATHINFO_EXTENSION)) !== 'svg') {
|
||||
$info = @getimagesize($path);
|
||||
return $info ? 'width="' . (int) $info[0] . '" height="' . (int) $info[1] . '"' : '';
|
||||
}
|
||||
$svg = (string) @file_get_contents($path, false, null, 0, 4096);
|
||||
if (preg_match('/\bwidth="([\d.]+)"/', $svg, $mw) && preg_match('/\bheight="([\d.]+)"/', $svg, $mh)) {
|
||||
return 'width="' . (int) round((float) $mw[1]) . '" height="' . (int) round((float) $mh[1]) . '"';
|
||||
}
|
||||
if (preg_match('/\bviewBox="[\d.\-]+\s+[\d.\-]+\s+([\d.]+)\s+([\d.]+)"/', $svg, $mv)) {
|
||||
return 'width="' . (int) round((float) $mv[1]) . '" height="' . (int) round((float) $mv[2]) . '"';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// Schedule joined to modalidades (name/colour/icon), optionally filtered by
|
||||
// local (e.g. 'crossfit'). Mirrors Horario_model::getAll().
|
||||
function site_horario(PDO $pdo, string $local = '', ?int $gym_id = null): array {
|
||||
@@ -194,5 +263,7 @@ function modalidade_icon_html(?string $icon): string {
|
||||
return $svg;
|
||||
}
|
||||
}
|
||||
return '<img loading="lazy" draggable="false" src="' . htmlspecialchars(site_img_url($icon, 'uploads/modalidades/icons')) . '" alt="">';
|
||||
$dims = site_img_dims_attr($icon, 'uploads/modalidades/icons');
|
||||
return '<img loading="lazy" decoding="async" draggable="false" ' . $dims
|
||||
. ' src="' . htmlspecialchars(site_img_url($icon, 'uploads/modalidades/icons')) . '" alt="">';
|
||||
}
|
||||
@@ -269,9 +269,12 @@ function _trash_image_file(PDO $pdo, int $imageId, string $action): void {
|
||||
if ($action === 'delete' && is_file($live)) {
|
||||
if (!is_dir($dir . '.trash')) @mkdir($dir . '.trash', 0775, true);
|
||||
@rename($live, $trash);
|
||||
img_fallback_follow($live, $trash); // foto.jxl.webp goes with foto.jxl
|
||||
} elseif ($action === 'restore' && is_file($trash)) {
|
||||
@rename($trash, $live);
|
||||
img_fallback_follow($trash, $live);
|
||||
} elseif ($action === 'purge') {
|
||||
if (is_file($trash)) @unlink($trash);
|
||||
img_fallback_follow($trash);
|
||||
}
|
||||
}
|
||||
+35
-47
@@ -1,8 +1,11 @@
|
||||
<?php
|
||||
// ─── Users: lookup, authentication, registration, password reset ─────────────
|
||||
// This install owns its identity. Passwords live in `users.password_hash`;
|
||||
// views/auth/login.php is a real form and require_login() (config/auth.php)
|
||||
// bounces unauthenticated visitors there. Nothing is delegated to a hub.
|
||||
// ─── Users: lookup, registration, password reset ─────────────────────────────
|
||||
// This install owns its identity — passwords live in `users.password_hash` —
|
||||
// but auditoria 2026-09-24 ponto 1.3 moved credential verification and
|
||||
// session establishment to shared/platform-tools/functions_auth.php
|
||||
// (auth_attempt()/auth_login(), wired in config/db.php). What is left here is
|
||||
// fitme-specific: registration, backoffice user creation, password reset
|
||||
// tokens and their emails — none of which has a shared equivalent.
|
||||
//
|
||||
// parse_user_agent() lives in config/functions.php; db_get_user() /
|
||||
// db_get_all_staff() / db_get_active_admins() live in config/functions_db.php
|
||||
@@ -26,46 +29,28 @@ function admin_exists(PDO $pdo): bool {
|
||||
}
|
||||
|
||||
// ─── Authentication ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// Credential verification is now auth_attempt($pdo, $email, $password) from
|
||||
// shared/platform-tools/functions_auth.php — same account-enumeration timing
|
||||
// defence and transparent rehash, one implementation instead of two. Login
|
||||
// itself (session + audit "auth.login" + last_login_at/ip stamp) is
|
||||
// auth_login($pdo, $user) from the same file. See views/auth/login.php.
|
||||
//
|
||||
// last_login_ua has no shared equivalent (functions_auth.php only tracks
|
||||
// last_login/last_ip) and is still fitme's own — stamped separately, right
|
||||
// after auth_login(), by fit_stamp_login_ua() below.
|
||||
|
||||
// Hash bcrypt (custo 12) de uma password aleatória descartada — só serve para
|
||||
// igualar o tempo de resposta quando a conta não existe.
|
||||
const AUTH_DUMMY_HASH = '$2y$12$luPhZpP46FkyufPCM1d8Gu4lPNS3A/I0gSvoBiNEL3lZAauMkuzH2';
|
||||
|
||||
// Verifies credentials and stamps the login trail. Returns the user row on
|
||||
// success, null on bad email / bad password / inactive account / no password
|
||||
// set. Callers must not leak which of the four it was.
|
||||
function auth_login(PDO $pdo, string $email, string $password): ?array {
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT * FROM users WHERE email = ? AND is_active = 1 AND password_hash IS NOT NULL LIMIT 1"
|
||||
);
|
||||
$stmt->execute([strtolower(trim($email))]);
|
||||
$user = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$user) {
|
||||
// Sem conta, verifica na mesma contra um hash de referência: um bcrypt
|
||||
// de custo 12 demora dezenas de ms, e responder mais depressa quando o
|
||||
// email não existe diria a quem tenta quais emails têm conta.
|
||||
password_verify($password, AUTH_DUMMY_HASH);
|
||||
return null;
|
||||
}
|
||||
if (!password_verify($password, $user['password_hash'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Transparently upgrade hashes when PHP's default algorithm/cost moves on.
|
||||
if (password_needs_rehash($user['password_hash'], PASSWORD_DEFAULT)) {
|
||||
$pdo->prepare("UPDATE users SET password_hash = ? WHERE id = ?")
|
||||
->execute([password_hash($password, PASSWORD_DEFAULT), $user['id']]);
|
||||
}
|
||||
|
||||
$ip = client_ip();
|
||||
// Stamps the user-agent of the browser that just logged in. Best-effort:
|
||||
// shown in views/user/index.php ("last seen from …") but never worth failing
|
||||
// a login over.
|
||||
function fit_stamp_login_ua(PDO $pdo, int $userId): void {
|
||||
$ua = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 512) ?: null;
|
||||
$pdo->prepare("UPDATE users SET last_login_at = NOW(), last_login_ip = ?, last_login_ua = ? WHERE id = ?")
|
||||
->execute([$ip, $ua, $user['id']]);
|
||||
$user['last_login_at'] = date('Y-m-d H:i:s');
|
||||
$user['last_login_ip'] = $ip;
|
||||
$user['last_login_ua'] = $ua;
|
||||
return $user;
|
||||
try {
|
||||
$pdo->prepare("UPDATE users SET last_login_ua = ? WHERE id = ?")
|
||||
->execute([$ua, $userId]);
|
||||
} catch (Throwable $e) {
|
||||
error_log('[auth] last_login_ua stamp failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Writes a new password hash. Shared by the reset flow, the profile page and
|
||||
@@ -74,7 +59,10 @@ function auth_login(PDO $pdo, string $email, string $password): ?array {
|
||||
// take the account back.
|
||||
function auth_set_password(PDO $pdo, int $userId, string $password): void {
|
||||
$pdo->prepare("UPDATE users SET password_hash = ? WHERE id = ?")
|
||||
->execute([password_hash($password, PASSWORD_DEFAULT), $userId]);
|
||||
->execute([auth_password_hash($password), $userId]);
|
||||
// Uma password nova invalida qualquer «manter sessão iniciada» antigo —
|
||||
// sem isto, um cookie roubado antes da mudança continuava a entrar depois.
|
||||
auth_remember_forget_user($pdo, $userId);
|
||||
$self = (int) ($_SESSION['user_id'] ?? 0) === $userId;
|
||||
audit($self ? 'auth.password_change' : 'user.password_set', [
|
||||
'target_type' => 'user', 'target_id' => $userId,
|
||||
@@ -120,7 +108,7 @@ function auth_register_client(PDO $pdo, array $data): array {
|
||||
}
|
||||
$pdo->prepare(
|
||||
"UPDATE users SET name = ?, phone = COALESCE(?, phone), password_hash = ?, is_active = 1 WHERE id = ?"
|
||||
)->execute([$name, $phone, password_hash($data['password'], PASSWORD_DEFAULT), $row['id']]);
|
||||
)->execute([$name, $phone, auth_password_hash($data['password']), $row['id']]);
|
||||
audit('user.register', ['target_type' => 'user', 'target_id' => (int) $row['id'], 'target_label' => $name,
|
||||
'summary' => 'Registo que reclamou uma conta de cliente sem palavra-passe', 'actor' => null]);
|
||||
return ['id' => (int) $row['id']];
|
||||
@@ -136,7 +124,7 @@ function auth_register_client(PDO $pdo, array $data): array {
|
||||
$name,
|
||||
$email,
|
||||
$phone,
|
||||
password_hash($data['password'], PASSWORD_DEFAULT),
|
||||
auth_password_hash($data['password']),
|
||||
!empty($data['marketing_consent']) ? 1 : 0,
|
||||
]);
|
||||
} catch (PDOException $e) {
|
||||
@@ -161,7 +149,7 @@ function auth_create_sysadmin(PDO $pdo, string $name, string $email, string $pas
|
||||
$stmt->execute([
|
||||
$name,
|
||||
strtolower(trim($email)),
|
||||
password_hash($password, PASSWORD_DEFAULT),
|
||||
auth_password_hash($password),
|
||||
]);
|
||||
$id = (int) $pdo->lastInsertId();
|
||||
audit('user.create', ['target_type' => 'user', 'target_id' => $id, 'target_label' => $name,
|
||||
@@ -190,7 +178,7 @@ function _create_backoffice_user(PDO $pdo, string $role, string $name, string $e
|
||||
$name,
|
||||
strtolower(trim($email)),
|
||||
$phone ?: null,
|
||||
password_hash($password, PASSWORD_DEFAULT),
|
||||
auth_password_hash($password),
|
||||
$role,
|
||||
]);
|
||||
} catch (PDOException $e) {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
-- ─── created_at nas tabelas que ainda não têm ────────────────────────────────
|
||||
-- Auditoria 2026-09-24, ponto 3.1.5: fitme3070_info, galleries, horario,
|
||||
-- modalidades, system_settings, website_offer e work_hours não registam
|
||||
-- quando uma linha foi criada. Sem isso, uma fila de definições antigas ou
|
||||
-- uma modalidade que já lá estava no dia 1 é indistinguível de uma criada
|
||||
-- ontem.
|
||||
--
|
||||
-- Sem created_by de propósito: estas sete são o CRUD de um só admin (a
|
||||
-- audit_log, ligada em 2026-09-25, já regista quem criou/mudou cada linha —
|
||||
-- ver config/functions_audit.php). Uma coluna created_by aqui seria uma
|
||||
-- segunda fonte da mesma resposta, sem valor a mais.
|
||||
--
|
||||
-- DEFAULT CURRENT_TIMESTAMP em vez de NULL: as linhas já existentes ficam com
|
||||
-- a data da migração (a única data honesta disponível), e nenhum INSERT
|
||||
-- existente que não mencione created_at parte.
|
||||
--
|
||||
-- Safe to re-run (IF NOT EXISTS é extensão do MariaDB).
|
||||
|
||||
ALTER TABLE `fitme3070_info` ADD COLUMN IF NOT EXISTS `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP AFTER `id`;
|
||||
ALTER TABLE `galleries` ADD COLUMN IF NOT EXISTS `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP AFTER `name`;
|
||||
ALTER TABLE `horario` ADD COLUMN IF NOT EXISTS `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP AFTER `local`;
|
||||
ALTER TABLE `modalidades` ADD COLUMN IF NOT EXISTS `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP AFTER `gym_id`;
|
||||
ALTER TABLE `system_settings` ADD COLUMN IF NOT EXISTS `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP AFTER `setting_value`;
|
||||
ALTER TABLE `website_offer` ADD COLUMN IF NOT EXISTS `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP AFTER `gym_id`;
|
||||
ALTER TABLE `work_hours` ADD COLUMN IF NOT EXISTS `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP AFTER `gym_id`;
|
||||
@@ -0,0 +1,27 @@
|
||||
-- ─── «Manter sessão iniciada» ────────────────────────────────────────────────
|
||||
-- Auditoria 2026-09-24, "Sem «Manter sessão iniciada»" (Médio). A sessão do
|
||||
-- PHP morre com o browser e o garbage collector apaga-a do servidor bem antes
|
||||
-- disso — esticar o cookie da sessão não chega, é preciso estado próprio.
|
||||
--
|
||||
-- Selector/validator (o mesmo desenho do platform-tools functions_remember.php,
|
||||
-- implementado localmente porque esta app tem a sua própria autenticação — ver
|
||||
-- config/functions_remember.php): selector em claro e UNIQUE (é o índice da
|
||||
-- consulta), só o hash SHA-256 do validator fica guardado. Quem levar um dump
|
||||
-- desta tabela não consegue forjar um cookie a partir dela.
|
||||
--
|
||||
-- Safe to re-run.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `remember_tokens` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INT UNSIGNED NOT NULL,
|
||||
`selector` CHAR(32) NOT NULL,
|
||||
`validator_hash` CHAR(64) NOT NULL,
|
||||
`expires_at` DATETIME NOT NULL,
|
||||
`last_ip` VARCHAR(45) NOT NULL DEFAULT '',
|
||||
`user_agent` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `idx_remember_selector` (`selector`),
|
||||
KEY `idx_remember_user` (`user_id`),
|
||||
KEY `idx_remember_expires` (`expires_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,20 @@
|
||||
-- RGPD (art.º 15.º/17.º): pedidos de acesso e de apagamento de dados pessoais.
|
||||
-- O visitante submete um pedido na Política de Privacidade; fica aqui (o email
|
||||
-- está desligado, por isso o pedido é guardado, como as leads). O backoffice
|
||||
-- processa: acesso = exportar os dados; apagamento = anonimizar.
|
||||
CREATE TABLE IF NOT EXISTS `rgpd_requests` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`gym_id` INT NOT NULL DEFAULT 0,
|
||||
`email` VARCHAR(190) NOT NULL,
|
||||
`request_type` ENUM('acesso','apagamento') NOT NULL,
|
||||
`message` TEXT NULL,
|
||||
`status` ENUM('novo','concluido') NOT NULL DEFAULT 'novo',
|
||||
`consent` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`processed_at` DATETIME NULL DEFAULT NULL,
|
||||
`processed_by` INT UNSIGNED NULL DEFAULT NULL,
|
||||
`deleted_at` DATETIME NULL DEFAULT NULL,
|
||||
`deleted_by` INT UNSIGNED NULL DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_rgpd_requests_status` (`status`, `created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,34 @@
|
||||
-- ─── auth_attempts: torna-a conforme com o throttle do platform-tools ────────
|
||||
-- Auditoria 2026-09-24, ponto 1.3: a app passa a usar o auth partilhado
|
||||
-- (shared/platform-tools/functions_auth.php) em vez do seu próprio login/
|
||||
-- sessão/roles. Ver config/db.php ('auth' => 'shared').
|
||||
--
|
||||
-- Esta tabela já existia (migration_20260913_local_auth.sql) com o formato do
|
||||
-- throttle PRÓPRIO da app (config/functions_throttle.php: `ip` + `scope`,
|
||||
-- cobre login/registo/recuperação). O throttle partilhado
|
||||
-- (auth_throttle_check/hit em functions_auth.php) espera `identifier` + `ip`
|
||||
-- e falharia (INSERT INTO auth_attempts (identifier, ip) ...) com um erro de
|
||||
-- SQL — silenciosamente engolido pelo try/catch do throttle partilhado, que
|
||||
-- por desenho falha aberto e deixaria de bloquear ninguém.
|
||||
--
|
||||
-- Decisão: a app CONTINUA a usar só o seu próprio throttle (login+registo+
|
||||
-- recuperação, dois níveis IP+conta) — não passa a chamar
|
||||
-- auth_throttle_check()/auth_throttle_hit() em lado nenhum, para não duplicar
|
||||
-- a defesa contra o mesmo formulário com duas contagens diferentes. Esta
|
||||
-- migração só torna a TABELA conforme com o que o platform_conformance()
|
||||
-- (config/db.php, 'conformance' => 'report') verifica, para o relatório de
|
||||
-- saúde ficar limpo e para deixar a porta aberta a usar o throttle partilhado
|
||||
-- no futuro sem precisar de outra migração.
|
||||
--
|
||||
-- `identifier` e `success` são aditivos, com omissão — invisíveis para o
|
||||
-- INSERT/SELECT do throttle próprio, que continua a nomear só `ip`+`scope`.
|
||||
-- `scope` passa a ter omissão (era NOT NULL sem omissão) porque é a única
|
||||
-- coluna que o INSERT do throttle partilhado não preenche; sem omissão esse
|
||||
-- INSERT falhava em modo estrito.
|
||||
--
|
||||
-- Safe to re-run (IF NOT EXISTS é extensão do MariaDB).
|
||||
|
||||
ALTER TABLE `auth_attempts` ADD COLUMN IF NOT EXISTS `identifier` VARCHAR(190) NOT NULL DEFAULT '' AFTER `id`;
|
||||
ALTER TABLE `auth_attempts` ADD COLUMN IF NOT EXISTS `success` TINYINT(1) NOT NULL DEFAULT 0 AFTER `attempted_at`;
|
||||
ALTER TABLE `auth_attempts` MODIFY COLUMN `scope` VARCHAR(20) NOT NULL DEFAULT '';
|
||||
ALTER TABLE `auth_attempts` ADD INDEX IF NOT EXISTS `idx_identifier_time` (`identifier`, `attempted_at`);
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Cor de marca por ginásio (hex #RRGGBB). Vazio = verde FitMe por defeito.
|
||||
-- Ginásio 1 = verde; o ginásio 2 usa vermelho (definir em Gestão → Ginásios).
|
||||
-- Safe to re-run.
|
||||
ALTER TABLE `gyms` ADD COLUMN IF NOT EXISTS `brand_color` VARCHAR(7) NOT NULL DEFAULT '' AFTER `has_crossfit`;
|
||||
UPDATE `gyms` SET `brand_color` = '#C2D500' WHERE `id` = 1 AND `brand_color` = '';
|
||||
|
||||
-- Logótipo do ginásio (caminho relativo, ex.: site/assets/img/logos/ginasio.svg).
|
||||
ALTER TABLE `gyms` ADD COLUMN IF NOT EXISTS `logo` VARCHAR(255) NOT NULL DEFAULT '' AFTER `brand_color`;
|
||||
@@ -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`;
|
||||
@@ -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));
|
||||
},
|
||||
]);
|
||||
@@ -4,5 +4,6 @@
|
||||
</main>
|
||||
<script src="<?= base_url('assets/vendor/bootstrap5/js/bootstrap.bundle.min.js') ?>"></script>
|
||||
<?= script_tag('assets/js/password-toggle.js') ?>
|
||||
<?php include __DIR__ . '/dev_toolbar.php'; // DEV ONLY: sem saída fora de dev ?>
|
||||
</body>
|
||||
</html>
|
||||
@@ -29,7 +29,8 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070';
|
||||
})();
|
||||
</script>
|
||||
<link href="<?= base_url('assets/vendor/bootstrap5/css/bootstrap.min.css') ?>" rel="stylesheet">
|
||||
<link href="<?= base_url('assets/vendor/bootstrap-icons/bootstrap-icons.css') ?>" rel="stylesheet">
|
||||
<link href="<?= htmlspecialchars(_asset_versioned('assets/css/bs-brand.css'), ENT_QUOTES) ?>" rel="stylesheet">
|
||||
<?= fit_icon_assets(['bootstrap-icons'], ['assets/vendor/bootstrap-icons/bootstrap-icons.css']) ?>
|
||||
<style>
|
||||
:root {
|
||||
--bo-accent: #C2D500;
|
||||
@@ -65,6 +66,7 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070';
|
||||
/* 16px inputs stop iOS from auto-zooming on focus */
|
||||
@media (max-width: 575.98px) { .form-control { font-size: 16px; } .auth-card { padding: 1.5rem; } }
|
||||
</style>
|
||||
<?php include __DIR__ . '/env_ribbon.php'; // moldura de aviso fora de produção ?>
|
||||
</head>
|
||||
<body>
|
||||
<main class="auth-card">
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
@@ -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
@@ -78,23 +78,93 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
mq.addEventListener('change', function () { if (pref === 'auto') apply('auto'); });
|
||||
})();
|
||||
|
||||
// Mobile off-canvas sidebar: hamburger toggles .sidebar.open + the backdrop;
|
||||
// tapping the backdrop, a nav link, Escape, or resizing to desktop closes it.
|
||||
// Barra lateral. Em ecrã grande o hamburger alterna entre completa e mini (so
|
||||
// o texto das categorias, lembrado em localStorage); em telemovel abre/fecha a
|
||||
// gaveta por cima do conteudo. Um so manipulador para os dois botoes. O CSS
|
||||
// decide o que cada estado mostra; o estado inicial ja veio do <head>.
|
||||
(function () {
|
||||
var sb = document.getElementById('sidebar');
|
||||
var tg = document.getElementById('menuToggle');
|
||||
var bd = document.getElementById('sidebarBackdrop');
|
||||
if (!sb || !tg) return;
|
||||
var mq = window.matchMedia('(max-width: 991.98px)');
|
||||
function open() { sb.classList.add('open'); if (bd) bd.classList.add('show'); document.body.style.overflow = 'hidden'; tg.setAttribute('aria-expanded', 'true'); }
|
||||
function close() { sb.classList.remove('open'); if (bd) bd.classList.remove('show'); document.body.style.overflow = ''; tg.setAttribute('aria-expanded', 'false'); }
|
||||
tg.addEventListener('click', function () { sb.classList.contains('open') ? close() : open(); });
|
||||
if (bd) bd.addEventListener('click', close);
|
||||
sb.querySelectorAll('.nav-link').forEach(function (a) {
|
||||
a.addEventListener('click', function () { if (mq.matches) close(); });
|
||||
var toggles = document.querySelectorAll('[data-sidebar-toggle]');
|
||||
var root = document.documentElement;
|
||||
var nav = sb && sb.querySelector('.bo-sidebar__nav');
|
||||
if (!sb || !toggles.length) return;
|
||||
var mq = window.matchMedia('(min-width: 992px)');
|
||||
function store(k, v) { try { localStorage.setItem(k, v); } catch (e) {} }
|
||||
|
||||
function sync() {
|
||||
var desktop = mq.matches;
|
||||
var open = desktop ? !root.classList.contains('bo-mini') : sb.classList.contains('open');
|
||||
toggles.forEach(function (t) {
|
||||
t.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
t.setAttribute('aria-label', open ? (desktop ? 'Reduzir menu' : 'Fechar menu') : (desktop ? 'Expandir menu' : 'Abrir menu'));
|
||||
});
|
||||
if (bd) bd.classList.toggle('show', !desktop && open);
|
||||
document.body.style.overflow = (!desktop && open) ? 'hidden' : '';
|
||||
}
|
||||
function setDrawer(open) { sb.classList.toggle('open', open); sync(); }
|
||||
|
||||
toggles.forEach(function (t) {
|
||||
t.addEventListener('click', function () {
|
||||
if (mq.matches) {
|
||||
store('bo-sidebar-mini', root.classList.toggle('bo-mini') ? '1' : '0');
|
||||
sync();
|
||||
} else {
|
||||
setDrawer(!sb.classList.contains('open'));
|
||||
}
|
||||
});
|
||||
});
|
||||
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
|
||||
window.addEventListener('resize', function () { if (!mq.matches) close(); });
|
||||
if (bd) bd.addEventListener('click', function () { setDrawer(false); });
|
||||
sb.querySelectorAll('.bo-nav-item').forEach(function (a) {
|
||||
a.addEventListener('click', function () { if (!mq.matches) setDrawer(false); });
|
||||
});
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape' && sb.classList.contains('open')) {
|
||||
setDrawer(false);
|
||||
var o = document.querySelector('.menu-toggle');
|
||||
if (o && o.offsetParent) o.focus();
|
||||
}
|
||||
});
|
||||
if (mq.addEventListener) mq.addEventListener('change', function () { setDrawer(false); });
|
||||
sync();
|
||||
|
||||
// Grupos recolhiveis. O estado inicial veio do <head> (atributo no <html>);
|
||||
// aqui so se liga o clique e se mantem atributo, armazenamento e aria alinhados.
|
||||
var saved = {};
|
||||
try { saved = JSON.parse(localStorage.getItem('bo-nav-groups') || '{}'); } catch (e) {}
|
||||
function groupOpen(g) {
|
||||
if (g.classList.contains('has-active')) return !g.classList.contains('is-closed');
|
||||
return !saved[g.getAttribute('data-group')];
|
||||
}
|
||||
function paint(g) {
|
||||
g.querySelector('[data-group-toggle]').setAttribute('aria-expanded', groupOpen(g) ? 'true' : 'false');
|
||||
}
|
||||
var groups = sb.querySelectorAll('.bo-nav-group[data-group]');
|
||||
groups.forEach(function (g) {
|
||||
g.querySelector('[data-group-toggle]').addEventListener('click', function () {
|
||||
var key = g.getAttribute('data-group');
|
||||
var willClose = groupOpen(g);
|
||||
if (g.classList.contains('has-active')) g.classList.toggle('is-closed', willClose);
|
||||
if (willClose) saved[key] = true; else delete saved[key];
|
||||
store('bo-nav-groups', JSON.stringify(saved));
|
||||
root.setAttribute('data-bo-groups', Object.keys(saved).join(' '));
|
||||
groups.forEach(paint);
|
||||
});
|
||||
});
|
||||
groups.forEach(paint);
|
||||
|
||||
// A lista rola sozinha: lembrar a posicao entre paginas e mostrar o item ativo.
|
||||
if (nav) {
|
||||
try { nav.scrollTop = +sessionStorage.getItem('bo-nav-scroll') || 0; } catch (e) {}
|
||||
var cur = nav.querySelector('[aria-current="page"]');
|
||||
if (cur) {
|
||||
var r = cur.getBoundingClientRect(), n = nav.getBoundingClientRect();
|
||||
if (r.top < n.top || r.bottom > n.bottom) cur.scrollIntoView({ block: 'center' });
|
||||
}
|
||||
addEventListener('pagehide', function () {
|
||||
try { sessionStorage.setItem('bo-nav-scroll', nav.scrollTop); } catch (e) {}
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
@@ -105,5 +175,6 @@ main:after {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
</style>
|
||||
<?php include __DIR__ . '/dev_toolbar.php'; ?>
|
||||
</body>
|
||||
</html>
|
||||
+119
-194
@@ -25,203 +25,111 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070';
|
||||
document.documentElement.setAttribute('data-theme-pref', pref);
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// Estado da barra lateral antes da primeira pintura (senão cada página salta
|
||||
// de 264 para 80px): modo mini no <html> e grupos fechados em data-bo-groups.
|
||||
try{var d=document.documentElement;if(localStorage.getItem('bo-sidebar-mini')==='1')d.classList.add('bo-mini');
|
||||
var g=JSON.parse(localStorage.getItem('bo-nav-groups')||'{}');d.setAttribute('data-bo-groups',Object.keys(g).filter(function(k){return g[k]}).join(' '));}catch(e){}
|
||||
</script>
|
||||
<link href="<?= base_url('assets/vendor/bootstrap5/css/bootstrap.min.css') ?>" rel="stylesheet">
|
||||
<link href="<?= base_url('assets/vendor/bootstrap-icons/bootstrap-icons.css') ?>" rel="stylesheet">
|
||||
<link href="<?= htmlspecialchars(_asset_versioned('assets/css/bs-brand.css'), ENT_QUOTES) ?>" rel="stylesheet">
|
||||
<?= fit_icon_assets(['bootstrap-icons'], ['assets/vendor/bootstrap-icons/bootstrap-icons.css']) ?>
|
||||
<link href="<?= base_url('assets/vendor/summernote/summernote-bs5.min.css') ?>" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bo-accent: #C2D500;
|
||||
--bo-accent-rgb: 194,213,0;
|
||||
--bo-dark: #0a0a0a;
|
||||
--bo-dark-2: #151515;
|
||||
--bo-surface: #eef0f4;
|
||||
}
|
||||
[data-bs-theme="dark"] { --bo-surface: #121416; }
|
||||
body { background: var(--bo-surface); font-size: .925rem; }
|
||||
.sidebar { position: fixed; top: 0; bottom: 0; left: 0; z-index: 100;
|
||||
padding: 68px 0 0; width: 240px; background: var(--bo-dark); overflow-y: auto; }
|
||||
.sidebar .nav-link { color: rgba(255,255,255,.55); padding: .55rem 1.25rem; font-size: .875rem;
|
||||
border-left: 3px solid transparent; transition: all .2s; }
|
||||
.sidebar .nav-link:hover { color: #fff; background: rgba(255,255,255,.06); border-left-color: rgba(255,255,255,.2); }
|
||||
.sidebar .nav-link.active { color: #fff; background: rgba(var(--bo-accent-rgb),.12); border-left-color: var(--bo-accent); }
|
||||
.sidebar .nav-link i { width: 20px; margin-right: .5rem; text-align: center; }
|
||||
.sidebar .sidebar-brand { position: fixed; top: 0; left: 0; width: 240px; padding: 1rem 1.25rem;
|
||||
color: #fff; font-weight: 700; font-size: 1.1rem; background: var(--bo-dark);
|
||||
z-index: 101; letter-spacing: .5px; border-bottom: 1px solid rgba(255,255,255,.06); }
|
||||
.sidebar .sidebar-brand i { color: var(--bo-accent); }
|
||||
.sidebar .sidebar-section { color: rgba(255,255,255,.3); font-size: .65rem; text-transform: uppercase;
|
||||
letter-spacing: 1.5px; padding: 1.25rem 1.25rem .35rem; }
|
||||
main { margin-left: 240px; padding-top: 68px; }
|
||||
.topbar { position: fixed; top: 0; left: 240px; right: 0; z-index: 99; height: 56px;
|
||||
background: var(--bs-body-bg); border-bottom: 1px solid var(--bs-border-color); padding: 0 1.5rem;
|
||||
display: flex; align-items: center; justify-content: space-between; }
|
||||
.topbar .user-dropdown { display: flex; align-items: center; gap: .5rem; margin-left: auto; }
|
||||
.badge-role-sysadmin { background: var(--bo-accent); color: var(--bo-dark); }
|
||||
.badge-role-admin { background: var(--bo-accent); color: var(--bo-dark); opacity: .85; }
|
||||
.badge-role-staff { background: #6f42c1; }
|
||||
.badge-role-client { background: #adb5bd; color: #212529; }
|
||||
.btn-outline-primary { color: var(--bo-accent); border-color: var(--bo-accent); }
|
||||
.btn-outline-primary:hover { background: var(--bo-accent); border-color: var(--bo-accent); color: var(--bo-dark); }
|
||||
.btn-primary { background: var(--bo-accent); border-color: var(--bo-accent); color: var(--bo-dark); }
|
||||
.btn-primary:hover { background: #a8ba00; border-color: #a8ba00; color: var(--bo-dark); }
|
||||
.card { border: none; box-shadow: 0 1px 3px rgba(0,0,0,.04); }
|
||||
[data-bs-theme="dark"] .table-light { --bs-table-bg: var(--bs-tertiary-bg); --bs-table-color: var(--bs-body-color); }
|
||||
.theme-toggle { min-width: 44px; min-height: 44px; }
|
||||
a { color: var(--bs-emphasis-color); }
|
||||
/* Summernote (WYSIWYG) — align with the backoffice look */
|
||||
.note-editor.note-frame { border-color: #ced4da; border-radius: .375rem; }
|
||||
.note-editor .note-editing-area .note-editable { font-size: .95rem; }
|
||||
.note-editor.note-frame.note-focused { border-color: var(--bo-accent); box-shadow: 0 0 0 .2rem rgba(var(--bo-accent-rgb), .25); }
|
||||
.note-toolbar { background: var(--bs-tertiary-bg); }
|
||||
.note-btn.btn-primary { background: var(--bo-accent); border-color: var(--bo-accent); color: var(--bo-dark); }
|
||||
/* ── Mobile: off-canvas sidebar + hamburger ──────────────────────────── */
|
||||
.sidebar { transition: transform .25s ease; }
|
||||
.menu-toggle { display: none; width: 44px; height: 44px; padding: 0; border: 1px solid var(--bs-border-color);
|
||||
background: var(--bs-body-bg); color: var(--bs-body-color); border-radius: 8px; font-size: 1.35rem;
|
||||
line-height: 1; align-items: center; justify-content: center; }
|
||||
.sidebar-backdrop { display: none; position: fixed; inset: 0; z-index: 1090; background: rgba(0,0,0,.45); }
|
||||
.sidebar-backdrop.show { display: block; }
|
||||
@media (max-width: 991.98px) {
|
||||
.sidebar { transform: translateX(-100%); width: 264px; z-index: 1100;
|
||||
box-shadow: 2px 0 18px rgba(0,0,0,.3); }
|
||||
.sidebar .sidebar-brand { width: 264px; z-index: 1101; }
|
||||
.sidebar.open { transform: translateX(0); }
|
||||
main { margin-left: 0; }
|
||||
.topbar { left: 0; padding: 0 .75rem; }
|
||||
.menu-toggle { display: inline-flex; }
|
||||
/* Wide tables scroll inside their card instead of overflowing the page */
|
||||
main .card > .card-body { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||
}
|
||||
@media (max-width: 575.98px) {
|
||||
.topbar .user-dropdown .badge,
|
||||
.topbar .user-dropdown .u-name { display: none; }
|
||||
/* Page-header rows (title + action buttons) stack instead of overflowing */
|
||||
main .d-flex.justify-content-between.align-items-center { flex-wrap: wrap; gap: .5rem; }
|
||||
/* 16px inputs stop iOS from auto-zooming on focus */
|
||||
.form-control, .form-select { font-size: 16px; }
|
||||
/* Comfortable content gutters on phones */
|
||||
.p-3 { padding: .85rem !important; }
|
||||
}
|
||||
</style>
|
||||
<link href="<?= htmlspecialchars(_asset_versioned('assets/css/backoffice.css'), ENT_QUOTES) ?>" rel="stylesheet">
|
||||
<?php include __DIR__ . '/env_ribbon.php'; // moldura de aviso fora de produção ?>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="sidebar" id="sidebar">
|
||||
<div class="sidebar-brand"><i class="bi bi-lightning-charge-fill me-2"></i><?= htmlspecialchars($app_name) ?></div>
|
||||
<div class="sidebar-section">Principal</div>
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link<?= ($_SERVER['SCRIPT_NAME'] ?? '') === '/views/admin/dashboard.php' ? ' active' : '' ?>"
|
||||
href="<?= base_url('views/admin/dashboard.php') ?>"><i class="bi bi-speedometer2"></i>Painel</a>
|
||||
</li>
|
||||
<?php if (is_staff()): ?>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="<?= base_url('/') ?>" target="_blank" rel="noopener"><i class="bi bi-box-arrow-up-right"></i>Site público</a>
|
||||
</li>
|
||||
<?php require_once __DIR__ . '/nav_helpers.php'; $dash = bo_dashboard_path(); ?>
|
||||
<aside class="sidebar" id="sidebar" aria-label="Menu principal">
|
||||
<div class="bo-sidebar__head">
|
||||
<button type="button" class="bo-sidebar__toggle" data-sidebar-toggle
|
||||
aria-label="Reduzir menu" aria-expanded="true" aria-controls="sidebar">
|
||||
<i class="bi bi-list" aria-hidden="true"></i>
|
||||
</button>
|
||||
<a class="bo-sidebar__brand" href="<?= base_url($dash) ?>"><i class="bi bi-lightning-charge me-2" aria-hidden="true"></i><?= htmlspecialchars($app_name) ?></a>
|
||||
<?php // Único sítio que abre o site público (nova aba). ?>
|
||||
<a class="bo-sidebar__site" href="<?= base_url('/') ?>" target="_blank" rel="noopener"
|
||||
title="Ver site público" aria-label="Ver site público"><i class="bi bi-box-arrow-up-right" aria-hidden="true"></i></a>
|
||||
</div>
|
||||
|
||||
<div class="dropdown bo-sidebar__user">
|
||||
<button class="bo-user-menu dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false"
|
||||
title="<?= htmlspecialchars($user['name']) ?>">
|
||||
<span class="bo-user-menu__avatar" aria-hidden="true"><?= htmlspecialchars(bo_initials((string) $user['name'])) ?></span>
|
||||
<span class="bo-user-menu__text">
|
||||
<span class="bo-user-menu__name"><?= htmlspecialchars($user['name']) ?></span>
|
||||
<span class="badge rounded-pill badge-role-<?= htmlspecialchars($user['role']) ?>"><?= htmlspecialchars(role_label($user['role'])) ?></span>
|
||||
</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li class="px-3 pt-2 pb-1"><div class="fw-semibold small lh-sm"><?= htmlspecialchars($user['name']) ?></div></li>
|
||||
<li><hr class="dropdown-divider my-1"></li>
|
||||
<li><a class="dropdown-item" href="<?= base_url('views/user/profile.php') ?>"><i class="bi bi-person me-2" aria-hidden="true"></i>Perfil</a></li>
|
||||
<li><a class="dropdown-item text-danger" href="<?= base_url('views/auth/logout.php') ?>"><i class="bi bi-box-arrow-right me-2" aria-hidden="true"></i>Terminar sessão</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<nav class="bo-sidebar__nav" aria-label="Navegação">
|
||||
<?= bo_nav_link($dash, 'bi-speedometer2', 'Painel', ['mod' => 'primary']) ?>
|
||||
|
||||
<?php if (is_admin()): ?>
|
||||
<?php bo_nav_group_start('gestao', 'Gestão', 'bi-briefcase'); ?>
|
||||
<?php bo_nav_group_start('site', 'Site', 'bi-window', true); ?>
|
||||
<?= bo_nav_link('views/admin/gyms/index.php', 'bi-buildings', 'Ginásios', ['mod' => 'sub2', 'match' => 'views/admin/gyms/']) ?>
|
||||
<?= bo_nav_link('views/admin/modalidades/index.php', 'bi-grid-3x3-gap', 'Modalidades', ['mod' => 'sub2', 'match' => 'views/admin/modalidades/']) ?>
|
||||
<?= bo_nav_link('views/admin/galerias/index.php', 'bi-images', 'Galerias', ['mod' => 'sub2', 'match' => 'views/admin/galerias/']) ?>
|
||||
<?= bo_nav_link('views/admin/events/index.php', 'bi-megaphone', 'Eventos', ['mod' => 'sub2', 'match' => 'views/admin/events/']) ?>
|
||||
<?= bo_nav_link('views/admin/info/edit.php', 'bi-telephone', 'Contactos', ['mod' => 'sub2', 'match' => 'views/admin/info/']) ?>
|
||||
<?= bo_nav_link('views/admin/reviews/index.php', 'bi-star', 'Reviews Google', ['mod' => 'sub2', 'match' => 'views/admin/reviews/']) ?>
|
||||
<?= 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; ?>
|
||||
</ul>
|
||||
|
||||
<?php if (is_admin()): ?>
|
||||
<div class="sidebar-section">Gestão</div>
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/gyms/') !== false) ? ' active' : '' ?>"
|
||||
href="<?= base_url('views/admin/gyms/index.php') ?>"><i class="bi bi-buildings-fill"></i>Ginásios</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/team/') !== false) ? ' active' : '' ?>"
|
||||
href="<?= base_url('views/admin/team/index.php') ?>"><i class="bi bi-people-fill"></i>Equipa</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/modalidades/') !== false) ? ' active' : '' ?>"
|
||||
href="<?= base_url('views/admin/modalidades/index.php') ?>"><i class="bi bi-grid-3x3-gap-fill"></i>Modalidades</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/galerias/') !== false) ? ' active' : '' ?>"
|
||||
href="<?= base_url('views/admin/galerias/index.php') ?>"><i class="bi bi-images"></i>Galerias</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/events/') !== false) ? ' active' : '' ?>"
|
||||
href="<?= base_url('views/admin/events/index.php') ?>"><i class="bi bi-megaphone-fill"></i>Eventos</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/work_hours/') !== false) ? ' active' : '' ?>"
|
||||
href="<?= base_url('views/admin/work_hours/index.php') ?>"><i class="bi bi-clock-fill"></i>Horário de Funcionamento</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/horario/') !== false) ? ' active' : '' ?>"
|
||||
href="<?= base_url('views/admin/horario/index.php') ?>"><i class="bi bi-calendar-week-fill"></i>Horário de Aulas</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/memberships/') !== false) ? ' active' : '' ?>"
|
||||
href="<?= base_url('views/admin/memberships/index.php') ?>"><i class="bi bi-credit-card-2-front-fill"></i>Mensalidades</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/trials/') !== false) ? ' active' : '' ?>"
|
||||
href="<?= base_url('views/admin/trials/index.php') ?>"><i class="bi bi-person-check-fill"></i>Aulas experimentais<?php
|
||||
$__tn = function_exists('db_trial_leads_count_new') ? db_trial_leads_count_new($pdo, admin_gym_id()) : 0;
|
||||
if ($__tn): ?> <span class="badge bg-danger ms-1"><?= (int) $__tn ?></span><?php endif; ?></a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/reviews/') !== false) ? ' active' : '' ?>"
|
||||
href="<?= base_url('views/admin/reviews/index.php') ?>"><i class="bi bi-star-fill"></i>Reviews Google</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/info/') !== false) ? ' active' : '' ?>"
|
||||
href="<?= base_url('views/admin/info/edit.php') ?>"><i class="bi bi-telephone-fill"></i>Contactos</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/trash/') !== false) ? ' active' : '' ?>"
|
||||
href="<?= base_url('views/admin/trash/index.php') ?>"><i class="bi bi-trash3-fill"></i>Lixeira</a>
|
||||
</li>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (is_admin()): ?>
|
||||
<div class="sidebar-section">Utilizadores</div>
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link<?= ($_SERVER['SCRIPT_NAME'] ?? '') === '/views/user/index.php' ? ' active' : '' ?>"
|
||||
href="<?= base_url('views/user/index.php') ?>"><i class="bi bi-person-badge"></i>Utilizadores</a>
|
||||
</li>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (is_sysadmin()): ?>
|
||||
<div class="sidebar-section">Sistema</div>
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link<?= ($_SERVER['SCRIPT_NAME'] ?? '') === '/views/sysadmin/system_settings.php' ? ' active' : '' ?>"
|
||||
href="<?= base_url('views/sysadmin/system_settings.php') ?>"><i class="bi bi-sliders"></i>Definições</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link<?= ($_SERVER['SCRIPT_NAME'] ?? '') === '/views/sysadmin/backups.php' ? ' active' : '' ?>"
|
||||
href="<?= base_url('views/sysadmin/backups.php') ?>"><i class="bi bi-database-fill-gear"></i>Backups</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link<?= ($_SERVER['SCRIPT_NAME'] ?? '') === '/views/sysadmin/messages.php' ? ' active' : '' ?>"
|
||||
href="<?= base_url('views/sysadmin/messages.php') ?>"><i class="bi bi-envelope-paper"></i>Mensagens</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link<?= ($_SERVER['SCRIPT_NAME'] ?? '') === '/views/sysadmin/audit.php' ? ' active' : '' ?>"
|
||||
href="<?= base_url('views/sysadmin/audit.php') ?>"><i class="bi bi-journal-text"></i>Auditoria</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link<?= ($_SERVER['SCRIPT_NAME'] ?? '') === '/views/sysadmin/comms.php' ? ' active' : '' ?>"
|
||||
href="<?= base_url('views/sysadmin/comms.php') ?>"><i class="bi bi-send"></i>Comunicações</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link<?= ($_SERVER['SCRIPT_NAME'] ?? '') === '/views/sysadmin/platform_update.php' ? ' active' : '' ?>"
|
||||
href="<?= base_url('views/sysadmin/platform_update.php') ?>"><i class="bi bi-arrow-repeat"></i>Actualizações</a>
|
||||
</li>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</nav>
|
||||
<?php if (is_sysadmin()): ?>
|
||||
<?php bo_nav_group_start('sistema', 'Sistema', 'bi-gear'); ?>
|
||||
<?= bo_nav_link('views/sysadmin/system_settings.php', 'bi-sliders', 'Definições', ['mod' => 'sub']) ?>
|
||||
<?= bo_nav_link('views/sysadmin/messages.php', 'bi-envelope-paper', 'Mensagens', ['mod' => 'sub']) ?>
|
||||
<?= bo_nav_link('views/sysadmin/comms.php', 'bi-send', 'Comunicações', ['mod' => 'sub']) ?>
|
||||
<?php // Backups/Auditoria/IP bans/Geo/Origem das visitas/Atualizações/Hub: um só
|
||||
// link para o índice partilhado do platform-tools (como a fotografia/design/
|
||||
// dance fazem), em vez de um por ferramenta — uma ferramenta nova no
|
||||
// platform-tools passa a aparecer sozinha, sem precisar de link novo aqui. ?>
|
||||
<?= bo_nav_link('platform.php?tool=index', 'bi-cpu', 'Sistema', ['mod' => 'sub',
|
||||
'active' => (($_GET['tool'] ?? 'index') === 'index' && str_contains($_SERVER['REQUEST_URI'] ?? '', 'platform.php'))]) ?>
|
||||
<?php if (function_exists('pt_icon_packs')): // pacotes de ícones só existem no submódulo novo ?>
|
||||
<?= bo_nav_link('platform.php?tool=icons', 'bi-grid-3x3-gap', 'Pacotes de ícones', ['mod' => 'sub',
|
||||
'active' => (($_GET['tool'] ?? '') === 'icons')]) ?>
|
||||
<?php endif; ?>
|
||||
<?php bo_nav_group_end(); ?>
|
||||
<?php endif; ?>
|
||||
</nav>
|
||||
</aside>
|
||||
<div class="sidebar-backdrop" id="sidebarBackdrop" aria-hidden="true"></div>
|
||||
|
||||
<main>
|
||||
<div class="topbar">
|
||||
<button type="button" class="menu-toggle" id="menuToggle" aria-label="Abrir menu" aria-controls="sidebar">
|
||||
<i class="bi bi-list"></i>
|
||||
<?php // Só em telemóvel: abre a gaveta (a barra está fora do ecrã). ?>
|
||||
<button type="button" class="menu-toggle" data-sidebar-toggle aria-label="Abrir menu" aria-expanded="false" aria-controls="sidebar">
|
||||
<i class="bi bi-list" aria-hidden="true"></i>
|
||||
</button>
|
||||
<?php
|
||||
// Which gym the content pages edit (functions_gyms.php). Switching goes back
|
||||
@@ -244,7 +152,7 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070';
|
||||
<noscript><button class="btn btn-sm btn-outline-secondary">Mudar</button></noscript>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<div class="user-dropdown">
|
||||
<div class="bo-topbar__tools">
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm btn-outline-secondary theme-toggle" type="button" data-bs-toggle="dropdown"
|
||||
aria-expanded="false" aria-label="Tema: automático" id="themeToggle">
|
||||
@@ -256,14 +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>
|
||||
</ul>
|
||||
</div>
|
||||
<span class="badge rounded-pill badge-role-<?= htmlspecialchars($user['role']) ?>">
|
||||
<?= htmlspecialchars(role_label($user['role'])) ?>
|
||||
</span>
|
||||
<span class="u-name"><?= htmlspecialchars($user['name']) ?></span>
|
||||
<a href="<?= base_url('views/user/profile.php') ?>" class="btn btn-sm btn-outline-secondary"><i class="bi bi-person"></i></a>
|
||||
<a href="<?= base_url('views/auth/logout.php') ?>" class="btn btn-sm btn-outline-danger"><i class="bi bi-box-arrow-right"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
// Breadcrumbs: a page sets $breadcrumbs = [['label'=>'Equipa','url'=>base_url('views/admin/team/index.php')], ['label'=>'Editar']]
|
||||
// (last item has no 'url' — it's the current page) for a full trail; with
|
||||
// nothing set, this falls back to Painel > $page_title, which is right for
|
||||
// every top-level listing page and wrong for nothing.
|
||||
$breadcrumbs = $breadcrumbs ?? (
|
||||
$page_title !== 'Painel'
|
||||
? [['label' => 'Painel', 'url' => base_url('views/admin/dashboard.php')], ['label' => $page_title]]
|
||||
: []
|
||||
);
|
||||
if ($breadcrumbs): ?>
|
||||
<nav aria-label="breadcrumb" class="px-3 px-md-4 pt-3">
|
||||
<ol class="breadcrumb mb-0 small">
|
||||
<?php foreach ($breadcrumbs as $__i => $__bc): ?>
|
||||
<?php if (!empty($__bc['url']) && $__i < count($breadcrumbs) - 1): ?>
|
||||
<li class="breadcrumb-item"><a href="<?= htmlspecialchars($__bc['url']) ?>"><?= htmlspecialchars($__bc['label']) ?></a></li>
|
||||
<?php else: ?>
|
||||
<li class="breadcrumb-item active" aria-current="page"><?= htmlspecialchars($__bc['label']) ?></li>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</ol>
|
||||
</nav>
|
||||
<?php endif; ?>
|
||||
<div class="p-3 p-md-4">
|
||||
<?php
|
||||
// Show flash messages if any
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)">& 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>
|
||||
@@ -0,0 +1 @@
|
||||
<?php require __DIR__ . '/shared/platform-tools/router.php';
|
||||
@@ -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'],
|
||||
]));
|
||||
Submodule
+1
Submodule shared/platform-tools added at 1c51c84dae.
@@ -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.*
|
||||
@@ -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.
|
||||
@@ -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
@@ -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.
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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'] ?? ''),
|
||||
]);
|
||||
@@ -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() : [],
|
||||
]);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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'),
|
||||
]);
|
||||
@@ -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;
|
||||
@@ -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])) ?>">← 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 →</a><?php else: ?><span></span><?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php if (is_file($layout_footer)) include $layout_footer; ?>
|
||||
@@ -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();
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
],
|
||||
];
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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';
|
||||
@@ -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">×</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
|
||||
})();
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user