feat(platform-tools): volta a ser submódulo, agora com branch fixo

Estava vendorizado como cópia simples desde 24/09 (VENDORED.md) depois
de um submódulo anterior ter partido a produção: .gitmodules sem
branch fazia "Atualizar submódulo" cair para refs/remotes/origin/HEAD,
uma ref que um clone só-servidor nunca cria, e a URL era http:// (523
na origem). Corrigidos os dois: branch = main fixo e URL https (via
../platform-tools.git, relativa como a fotografia usa). A terceira
razão do VENDORED.md — "o site é deployado por upload" — não se aplica
ao deploy.sh actual (é git pull), por isso deixou de justificar ficar
vendorizado depois de corrigir as outras duas.

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
andreandClaude Sonnet 5 committed 2026-09-25 12:30:26 +01:00
1 parent 35415429e5
commit 0ecc38774a
84 files changed
+38 -22767

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
[submodule "shared/platform-tools"]
path = shared/platform-tools
url = ../platform-tools.git
branch = main
+33
View File
@@ -0,0 +1,33 @@
# 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.
-36
View File
@@ -1,36 +0,0 @@
# OS
.DS_Store
Thumbs.db
# Editor / IDE
.vscode/
.idea/
*.swp
*.swo
# Secrets
.env
.env.local
.env.*.local
# Logs
*.log
# AI assistant files — not part of the project
CLAUDE.md
AGENTS.md
.claude/
GEMINI.md
.gemini/
.cursorrules
.cursorignore
.aider*
AIDER.md
COPILOT*.md
# Índice de países da analítica: ~5 MB, gerado por bin/geo-build-index.php.
# Não é versionado -- um binário deste tamanho refrescado mensalmente inchava o
# histórico. Sem ele a analítica regista o país como desconhecido, que é um
# degradar aceitável; as listas do BLOQUEIO por país (pt.v4/pt.v6) essas ficam
# versionadas, porque aí dados em falta seriam uma decisão de segurança.
data/geo/country-index.*
-80
View File
@@ -1,80 +0,0 @@
# Assets de front-end na frota andmore
Convenção fixada a 2026-09-01, quando os oito sites deixaram de carregar
JavaScript e CSS de CDNs.
## A regra
**Nada de front-end sai para a rede em produção.** Tudo é servido do próprio
domínio, está no git, e tem a versão no nome da pasta.
public/vendor/<biblioteca>-<versão>/… (o hub usa public/assets/vendor/,
que é onde já tinha o Bootstrap)
Porquê, e não é gosto por arrumação:
- um CDN em baixo leva o back-office com ele — e o CSS do Bootstrap a faltar
não é «sem estilo», é uma página ilegível;
- um CDN comprometido corre código de terceiros **na sessão de quem está
autenticado como administrador**, e nenhuma destas tags tinha SRI;
- e uma versão que vem de `@1` ou `@5.3` muda debaixo dos pés sem ninguém
decidir nada.
## De onde vem cada coisa
**Composer, sempre que o pacote exista.** Instala-se com `composer require` e
copia-se o ficheiro de distribuição para `public/vendor/`:
twbs/bootstrap → bootstrap-<versão>/{css,js}/
twbs/bootstrap-icons → bootstrap-icons-<versão>/ (+ fonts/)
components/jquery → jquery-<versão>/jquery.min.js
**Vendorizado à mão**, quando não há pacote. Descarrega-se uma vez, commita-se,
e deixa-se um `ORIGEM.md` ao lado com o URL de origem e o comando para
actualizar. É o caso do DataTables, Chart.js, Cropper, Flatpickr, SortableJS,
html2canvas, qrcodejs, signature_pad, html5-qrcode, Font Awesome, jszip e
pdfmake.
## DataTables: build combinado, e porque não vem do composer
O único pacote no packagist é `datatables/datatables`, **congelado na 1.10.21
(2020)**, que usa `$.isArray` e `$.trim` — ambas removidas na jQuery 4. Ir pelo
composer prendia a frota à jQuery 3 para sempre.
Usa-se o build combinado do construtor oficial, que é **um ficheiro** com o
core, os Buttons, o Responsive e a integração Bootstrap 5:
https://cdn.datatables.net/v/bs5/dt-2.3.4/b-3.2.6/r-3.0.6/datatables.min.js
https://cdn.datatables.net/v/bs5/dt-2.3.4/b-3.2.6/r-3.0.6/datatables.min.css
Substituiu até cinco `<script>` por site. Os botões de exportação (CSV, Excel,
imprimir) já vêm lá dentro; só o `jszip` e o `pdfmake` ficam à parte, e só onde
se exporta.
## A armadilha, que já custou duas homes
**`base_url()` não existe em todo o lado.** Na fotografia e na mstudio, o site
público (`public/index.php`, `public/site/includes/head.php`) não carrega o
`config/functions.php`. Um `<?= base_url('vendor/…') ?>` ali dá fatal e a
página passa a servir o ecrã de erro.
**Usa sempre o caminho absoluto `/vendor/…`.** O docroot é a raiz do domínio em
todos os sites, portanto é equivalente, e funciona haja ou não bootstrap.
## Não usar
- `is_file($local) ? $local : $cdn` — não é uma rede de segurança. É a promessa
de que, no dia em que um ficheiro faltar, a página vai buscar código a um
terceiro sem ninguém dar por isso.
- `<link rel="preconnect">` para CDNs — não há para onde ligar.
- Cópias sem versão no nome (`assets/js/dataTables.min.js`). Ninguém sabe qual
é, e a próxima pessoa não tem como descobrir sem comparar bytes.
- **jQuery slim.** Não traz `$.ajax`, e portanto nenhum DataTables em
server-side processing funciona com ela. A dance esteve assim.
## Estado a 2026-09-01
Todos os sites: **jQuery 4.0.0** e **DataTables 2.3.4**. Zero CDN.
Continua a sair para a rede, por decisão à parte: o **Google Fonts**, em dance,
fotografia, mstudio, silver, quack e design.
-274
View File
@@ -1,274 +0,0 @@
# How the andmore platforms are built
Seven sites — **dance**, **design**, **fotografia**, **hub2**, **mstudio**,
**quack**, **silver** — share one VPS, one MariaDB, one reverse proxy, and this
submodule. They are separate products with separate databases, but they are
built the same way on purpose, so that knowing one means knowing all seven.
Two of them are worth naming as special cases, because they are the shape the
conventions have to survive: **mstudio** is a second instance of fotografia's
platform under different branding, and **quack** is a classroom game that grew
up outside this submodule and was brought in later. Where this document says
"every site", those two are included — that is the point of them.
This document records the reasoning behind that shape. It is descriptive, not
aspirational: everything here is a decision already visible in the code. When a
choice below stops being true, change the code or change this file — a
convention nobody follows is worse than no convention.
---
## 1. Plain PHP, no framework, no build step
PHP 8.4 with PDO, server-side rendering, Bootstrap 5. Composer is used for
autoloading and real libraries; there is no bundler, no transpiler, no
`node_modules` in production.
The reason is maintenance horizon. These are small products that must keep
working for years with long idle stretches between changes. A build step is a
second thing that can rot — toolchain versions, lockfiles, a CI runner — and it
rots silently while the site still appears fine. Editing a `.php` file and
reloading is a workflow that cannot break.
The cost is real and accepted: no type checking beyond PHP's own, no component
model, more repetition in templates. That trade is worth it at this size and
would not be at ten times the size.
## 2. Comments explain why, never what
The code is unusually densely commented, and almost none of it restates the
line below. A comment earns its place by recording something the reader cannot
recover from the code: a constraint, a rejected alternative, a bug that a
previous version had.
```php
// MariaDB does not read cgroup limits -- without this it sizes its buffer
// pool against all 7.8GB of host RAM and then gets OOM-killed by mem_limit.
- --innodb-buffer-pool-size=1G
```
That comment is worth more than the line it sits above, because the line looks
arbitrary without it and someone will eventually "clean it up".
Where a decision is a knowing compromise, it is labelled as one. The site
containers mount their own source read-write so the backoffice updater can run
`git pull`, and the compose file says so under an **ACCEPTED RISK** heading
naming the exact exposure. A documented risk is a decision; an undocumented one
is an accident waiting to be discovered by someone else.
## 3. Shared code lives here, once
Anything two platforms both need moves into `platform-tools` and is consumed as
a git submodule. Edit one file, every platform gets it.
The failure mode this exists to prevent is genuine and has already happened:
three sites independently grew their own AES-256-GCM secret helpers with three
different key derivations, all writing the same `enc:v1:` prefix, all mutually
unreadable, and nothing surfaced the divergence until something tried to read
across sites. Copies do not stay identical. They stay *similar*, which is worse,
because similar code invites the assumption that it is the same.
The corollary: **a site must not reimplement something this submodule provides.**
If the shared version is wrong for one site, fix it here with a parameter, or
say plainly in the site's own docs why it deviates.
**The site declares; the shared code does not guess.** Each site's
`config/db.php` makes one `platform_connect()` call that states its PDO, base
URL, environment, column map, roles, session and sysadmin guard. Before it,
every tool probed for variants — a global `$pdo` or `App\Database`, a
`base_url()` or a `getBaseUrl()` — and each new tool had to remember them all.
The same call runs `platform_conformance()`: the database is compared with what
the shared code expects, and a missing column that would switch a defence off
without an error fails loudly in development and turns the hub card yellow in
production (§7).
**New tools reach every site without a file on the site.** The shared pages are
served by one front controller (`public/platform.php?tool=…`) and the shared
endpoints by another (`public/api/platform.php?endpoint=…`), both from closed
route tables here. A per-page stub on each of seven sites was seven chances to
forget one.
Submodules have one sharp edge worth stating: `git pull` in a site advances the
submodule *pointer* but leaves its working tree on the old commit, silently and
with no error. Every deploy script therefore runs `git submodule update
--init --recursive` as a separate, mandatory step.
## 4. Configuration has two homes, and a rule for choosing
`.env` for anything needed to reach or decrypt the database, or to repair the
site when it is broken. The `system_settings` table for anything an operator
should be able to change without shell access.
The test: *can the system read this after a cold start with an empty database,
and would I still need it if the app were broken?*
This is why `DB_*` and `APP_SECRET_KEY` can never move to the database — the
first is needed to reach it, the second to decrypt it. And it is why
`GIT_HTTP_TOKEN` and `HUB_OPS_SECRET` stay in `.env` by choice rather than
necessity: they are the credentials used to *repair* a platform, and a repair
channel must not depend on the thing it repairs.
Full precedence rules and the secret-encryption contract are in
[README.md](README.md#settings-and-secrets).
## 5. Secrets are encrypted at rest, because backups exist
The nightly job keeps many copies of each database. A plaintext SMTP password
would therefore sit in every one of them, and the backups live on disk next to
everything else. So secret-valued settings are encrypted, and the key lives only
in `.env` — never in the database, never in git. A leaked dump alone is not
enough to recover credentials.
Each platform keeps its **own** `APP_SECRET_KEY`. A secret encrypted on hub2
deliberately does not decrypt on dance: one compromised dump must not unlock the
estate.
Which keys count as secret is decided centrally, by name, in
`platform_settings_is_secret()` — never by the calling page. The previous
arrangement had each site keeping its own list, and a credential added later on
a different screen was simply missed.
## 6. Authentication is mechanism, not policy
Sessions, password hashing, login throttling, guards and password recovery live
in `functions_auth.php` and `functions_password_reset.php`. None of it contains
business logic, so there was never a reason for one version per site — and the
separate versions had exactly the drift you would expect. dance ran a bare
`session_start()` with no HttpOnly, no SameSite and no strict mode, never
rotated the session id on login, kept the password hash in `$_SESSION`, and
accepted unlimited login attempts. The other three had each solved some of that,
separately.
Two things stay per-site, deliberately:
- **The users table.** The columns genuinely differ — fotografia's users carry
CalDAV credentials, silver's carry a guardian. The shared code adapts through
the `schema` given to `platform_connect()` (`auth_schema()` underneath),
declaring only the columns that differ, rather than demanding one shape, because renaming a column
with 147 references is a migration and adapting is a function call.
The same map covers *what you sign in with*. Six sites match on `email`;
quack matches on `username` and keeps `email` as the address recovery mails
to, so `auth_schema()` carries a `login` key separate from `email`. It
defaults to null, meaning "same as email" — a site that never thought about
it gets the behaviour it already had.
- **The roles.** `sysadmin`, `admin` and `user` are common and mean the same
everywhere. Past that the vocabulary is the product's: teacher, student,
parent, staff, client, formador, aluno. A site registers its own with a rank,
so
`auth_at_least('admin')` works without anyone hardcoding a list, and an
unrecognised role ranks zero — a typo can never grant access.
Password recovery goes out over the same SMTP transport as everything else, and
stores only the SHA-256 of its token: a leaked database dump must not be usable
to reset anyone's password.
## 7. Fail loudly; silence must mean healthy
The most expensive bug in this estate's history was not a crash. dance's nightly
backup ran every night, printed `DB backup FAILED`, exited `0`, and touched its
success heartbeat. Both monitoring signals — an empty error log and a fresh
heartbeat — reported health while zero backups existed. It took four
independent faults to produce that, and every one of them was individually
survivable; what made it invisible was the exit code.
A related habit from the same incident: dance ran `error_reporting(0)` in
production, which silences the log as well as the screen. Suppress *display*,
never *logging* — the log is the only thing that can explain a production
failure after the fact.
Two rules come from that:
- **An operation that failed must exit non-zero.** Printing the failure is not
reporting it; nothing downstream reads stdout.
- **Health signals must be positive, not the absence of a negative.** An empty
log is ambiguous between "everything succeeded" and "it never ran". Cron jobs
therefore touch a heartbeat file *only* on real success, and the check is the
heartbeat's freshness rather than the log's emptiness.
The same instinct applies in code: a missing configuration value should be a
loud error, not a quiet default that happens to be empty.
## 8. Expose the minimum
Only the reverse proxy publishes ports. MariaDB publishes none and is reachable
only on an internal Docker network. Each site has its own database user with
access to its own schema and nothing else.
Where a control surface must exist, it is bound to loopback and reached over an
SSH tunnel rather than firewalled — because Docker writes iptables rules
directly and a `ufw` rule would not actually apply. That distinction is written
into the compose file, since the safe-looking alternative is the one people
reach for.
Every container runs with `no-new-privileges`, a memory limit and a pid cap,
sized to its workload rather than copied between sites.
Application code sits outside the document root: the web root is `public/`, and
`shared/`, `config/` and `src/` are not reachable over HTTP. Anything that
genuinely must be callable goes through the two front controllers or a thin,
explicit stub in `public/api/` — and the front controllers only dispatch names
from a closed list; they never include a path taken from the request.
## 9. Migrations are append-only
A migration that has been applied is never edited. If it seeded a value that
later turns out wrong, a new migration corrects it. The applied set is tracked
in `applied_migrations`, and the schema is exported deterministically to
`database/schema.sql` so that a git diff on that file is a real changelog.
Editing an applied migration produces a database that matches no known state —
recoverable on the machine where it happened, and nowhere else.
**The filename is the order.** The runner globs `database/migration_*.sql` and
sorts by name, so the numeric prefix is not decoration — it is the only thing
saying what runs when. quack's were originally named after what they did, and
alphabetically `migration_multi_deck` (which creates `decks`) sorted *after*
`migration_anti_hoarding` (which updates it). Thirteen of thirty-five failed
on a fresh install, and nobody noticed for months because the only database
that mattered had been built incrementally, in the order the files were
written. A migration set that cannot rebuild the database from scratch is not
a migration set; it is a changelog that happens to be executable.
## 10. The page works without JavaScript
Pages render complete from the server. JavaScript filters, copies, reorders and
warns — it does not fetch the content. Without it a page degrades to *more*
information rather than less: a filter simply stays off and everything shows.
**Vendoring is the goal, not yet the practice.** hub2 serves every front-end
asset from itself and says so in its own code. quack does the same by a
different route — composer `post-install-cmd` downloads Bootstrap, jQuery,
DataTables and A-Frame into `public/assets/vendor/` at deploy time, and each
`<script>` falls back to the CDN only when the local file is absent, so the
fallback is visible in the page rather than assumed. dance, fotografia and
silver still load Bootstrap and Bootstrap Icons from `cdn.jsdelivr.net` — 12,
11 and 8 files respectively. So this is a direction, not yet a description:
new pages should vendor, and the remaining CDN links are debt worth paying
down, because a third party host is a runtime dependency the container
isolation cannot protect.
## 11. Portuguese for people, English for machines
User-facing text is European Portuguese (pt-PT). Code identifiers, commit
subjects and the infrastructure documentation are English. In-code comments are
written in whichever language the surrounding file already uses — consistency
within a file matters more than uniformity across the estate.
## 12. Data lives outside the checkout
Uploads, logs, caches and backups are bind-mounted from `/srv/data/<host>/` and
never live inside the git working tree. The checkout should be disposable: a
`git pull`, a rebuild, or a fresh clone must never risk user data, and `git
status` on a production site should be quiet enough that real drift stands out.
---
## When these conflict
They will. The ordering that has held so far:
1. **Do not lose data.** Backups and migrations win over convenience.
2. **Do not hide failure.** A loud error beats a graceful degradation nobody sees.
3. **Do not duplicate.** One implementation, parameterised, beats two that agree today.
4. **Do not surprise.** A boring, obvious solution beats a clever one, because the reader six months from now is the constraint.
File diff suppressed because it is too large. Load diff
-49
View File
@@ -1,49 +0,0 @@
# platform-tools is vendored here, not a submodule
This directory used to be a git submodule pointing at
`git.andmore.pt/andre/platform-tools`. On the `main` branch it is a plain
directory of tracked files.
**Frozen at:** `12f26fd1cba8ce3229c598ac2f2e9a788cd7cf75` (`fix(client_ip): X-Real-IP / último X-Forwarded-For, nunca o primeiro`)
— refreshed on 2026-09-25 from `ea3616a`, 27 commits later. It is the fleet
pin (`f761d99`, audit module) plus `a8c681b` (login form pieces, remember
options) and the client-IP fix found in this app's audit.
History: `ea3616a` (2026-09-13, same pin as fotografia at the time) →
`12f26fd` (2026-09-25).
## Why
The submodule was the single biggest source of deployment failures on this
install:
- `.gitmodules` named no `branch`, so "Atualizar submódulo" fell back to
`refs/remotes/origin/HEAD`, a ref a plain server-side clone never creates.
It failed with `Unable to find refs/remotes/origin/HEAD revision`.
- Its URL used `http://`, which the origin answers with 523.
- The site is deployed by uploading files. A submodule is invisible to that:
the rest of the tree moved forward while `shared/platform-tools` stayed on an
old commit, and `config/functions_secrets.php` then died on a missing
`compat_secrets.php` — taking every page, public ones included, down with it.
A plain directory deploys like every other file. There is no second repository
to be out of step with the first.
## The trade-off
Fixes to platform-tools no longer arrive by bumping a pin. To take an update:
1. Clone or pull `platform-tools` somewhere separate.
2. Check out the commit you want.
3. Copy its files over this directory (delete removed ones).
4. Update the SHA above, and commit here with that SHA in the message.
Keep recording the upstream SHA. It is the only thing that makes "which version
is this?" answerable.
## Note on line endings
`social/engine.js`, `social/functions_publications.php`, `social/page.js` and
`social/publications.css` are stored upstream with CRLF. They were normalised to
LF when vendored. The content is otherwise byte-identical; diff with
`tr -d '\r'` to compare against upstream.
-21
View File
@@ -1,21 +0,0 @@
<?php
// Self-bootstrapping remote-ops endpoint for the id.andmore.pt fleet plane —
// config/ stack (fitme3070, fotografia, dsa, moremio, tickets).
//
// Assumed location: <host>/shared/platform-tools/api/agent.php
// dirname(__DIR__, 3) === <host>
// The per-platform stub at <host>/api/agent.php is a one-line require of this file.
//
// The action set lives in functions_agent.php, so a host that needs its own
// stub (surf sets MIGRATIONS_DIR first) still shares it.
$repo_root = dirname(__DIR__, 3);
require_once $repo_root . '/config/db.php'; // opens $pdo, starts the session
require_once $repo_root . '/config/functions.php'; // env(), helpers, migrations stub
require_once __DIR__ . '/../functions_agent.php';
$secret = (string) (function_exists('env')
? (env('HUB_OPS_SECRET', '') ?: '')
: (getenv('HUB_OPS_SECRET') ?: ''));
agent_dispatch($pdo, $repo_root, $secret);
-88
View File
@@ -1,88 +0,0 @@
<?php
// Self-bootstrapping deploy webhook. Point a Gitea (or GitHub) push webhook at
// <host>/api/auto_update.php and the platform updates itself on every push to
// the branch it is running.
//
// Assumed location: <host>/shared/platform-tools/api/auto_update.php
// dirname(__DIR__, 3) === <host>
// The per-platform stub at <host>/api/auto_update.php is a one-line require.
//
// Answers JSON always, and with a status code the webhook UI can be read from:
// 202 accepted (work continues after the reply) / disabled
// 200 skipped — push was for another branch
// 401 bad signature · 405 wrong method · 503 no secret configured
//
// A refusal is deliberately a 4xx/5xx: Gitea shows the delivery in red and
// keeps the response body, which is the only place a rejected delivery would
// ever be noticed before someone opens the platform page.
//
// Everything that can be answered instantly IS answered instantly, and only the
// slow part runs after the reply. Gitea allows a webhook delivery 5 seconds by
// default; a git pull alone exceeds that and a pre-update backup exceeds it by
// minutes, so a synchronous reply would mark every successful deploy as failed.
// The outcome is recorded in auto_update_last_* — the platform page and the
// dashboard strip are where you read it.
$repo_root = dirname(__DIR__, 3);
require_once $repo_root . '/config/db.php'; // opens $pdo, starts the session
require_once $repo_root . '/config/functions.php'; // env(), helpers
require_once __DIR__ . '/../functions_auto_update.php';
header('Content-Type: application/json; charset=utf-8');
$reply = function (int $code, array $data): never {
http_response_code($code);
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
};
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$reply(405, ['ok' => false, 'error' => 'Só POST.']);
}
// Read the body ONCE and verify against those exact bytes — re-reading
// php://input after the fact can come back empty and would silently turn the
// signature check into a no-op.
$body = (string) file_get_contents('php://input');
$secret = auto_update_secret();
if ($secret === '') {
$reply(503, ['ok' => false, 'error' => 'AUTO_UPDATE_SECRET não está definido no .env desta plataforma.']);
}
if (!auto_update_verify($body, $secret)) {
$reply(401, ['ok' => false, 'error' => 'Assinatura ausente ou inválida.']);
}
// Signature first, switch second: an unauthenticated caller learns nothing
// about this platform, not even whether the feature is on.
if (!auto_update_enabled($pdo)) {
$reply(202, ['ok' => true, 'status' => 'disabled',
'message' => 'Auto-update desligado nesta plataforma.']);
}
// A push to a branch this server is not running is not our business — the
// webhook fires for every branch, and pulling one you are not on does nothing
// useful at best.
$ref = auto_update_payload_branch($body);
if ($ref !== null && function_exists('git_run')) {
$cur = git_run(['rev-parse', '--abbrev-ref', 'HEAD'], $repo_root);
$cur = $cur['ok'] ? trim($cur['output']) : '';
if ($cur !== '' && $cur !== $ref) {
$reply(200, ['ok' => true, 'status' => 'skipped',
'message' => "Push para {$ref}; este servidor está em {$cur}."]);
}
}
// Server-to-server past this point: drop the session lock so the pull (which
// can take a while) does not hold up the operator's own browsing.
@session_write_close();
// Answer Gitea now; do the slow work with the connection already closed.
platform_respond_and_continue(202, [
'ok' => true,
'status' => 'accepted',
'message' => 'Atualização iniciada. O resultado fica registado na plataforma.',
'ref' => $ref,
]);
auto_update_run($pdo, $repo_root, $ref);
@@ -1,43 +0,0 @@
<?php
// Self-bootstrapping download handler. Assumed location:
// <host>/shared/platform-tools/api/backup_download.php
$repo_root = dirname(__DIR__, 3);
require_once $repo_root . '/config/db.php';
require_once $repo_root . '/config/functions.php';
require_once $repo_root . '/config/functions_backup.php';
require_sysadmin();
$filename = $_GET['file'] ?? '';
if (!backup_filename_is_safe($filename)) {
http_response_code(400);
exit('Ficheiro inválido.');
}
$dir = backup_dir();
$filepath = $dir . DIRECTORY_SEPARATOR . $filename;
$real = realpath($filepath);
if ($real === false || !str_starts_with($real, $dir . DIRECTORY_SEPARATOR)) {
http_response_code(403);
exit('Acesso negado.');
}
if (!is_file($real)) {
http_response_code(404);
exit('Ficheiro não encontrado.');
}
$size = filesize($real);
header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Content-Length: ' . $size);
header('Cache-Control: no-store, no-cache, must-revalidate');
header('Pragma: no-cache');
if (ob_get_level()) ob_end_clean();
readfile($real);
exit;
@@ -1,55 +0,0 @@
<?php
// Self-bootstrapping POST handler. Assumed location:
// <host>/shared/platform-tools/api/backup_trigger.php
// dirname(__DIR__, 3) === <host>
$repo_root = dirname(__DIR__, 3);
require_once $repo_root . '/config/db.php';
require_once $repo_root . '/config/functions.php';
require_once $repo_root . '/config/functions_backup.php';
require_once __DIR__ . '/../functions_csrf.php';
require_once __DIR__ . '/../functions_settings.php'; // platform_admin_url()
require_sysadmin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: ' . platform_admin_url('backups.php'));
exit;
}
if (!platform_csrf_ok()) {
$_SESSION['backup_flash'] = [
'type' => 'danger',
'message' => 'Pedido rejeitado (token CSRF ausente ou inválido). Recarrega a página e repete.',
];
header('Location: ' . platform_admin_url('backups.php'));
exit;
}
$type = $_POST['type'] ?? '';
if (!in_array($type, ['files', 'db'], true)) {
$_SESSION['backup_flash'] = [
'type' => 'danger',
'message' => 'Tipo de backup inválido.',
];
header('Location: ' . platform_admin_url('backups.php'));
exit;
}
$count_before = count(backup_list());
$ok = backup_trigger($type);
$_SESSION['backup_flash'] = $ok
? [
'type' => 'success',
'message' => 'Backup iniciado. A lista atualiza automaticamente quando o ficheiro aparecer.',
// Lets the backups page poll until a new file shows up.
'await_count' => $count_before,
]
: [
'type' => 'danger',
'message' => 'Falha ao iniciar o backup. Verifique os logs do servidor.',
];
header('Location: ' . platform_admin_url('backups.php'));
exit;
-69
View File
@@ -1,69 +0,0 @@
<?php
// ─── Endpoint da bolha de feedback ───────────────────────────────────────────
//
// POST multipart/form-data (o que um <form> envia), resposta JSON:
// {"ok":true,"ref":"T-1234","track_url":"https://…"}
// {"ok":false,"error":"…"}
//
// Ao contrário dos outros endpoints desta pasta, este NÃO se autentica por
// chave: quem chama é o browser de um administrador com sessão aberta neste
// site. Precisa por isso da sessão e dos helpers de autenticação do site — e
// obtém-nos do mesmo hub_bootstrap.php que os outros, que abre o config/db.php
// do site e deixa em cima da mesa a sessão aberta, o $pdo e o functions_hub.
//
// Assim o stub de cada site é uma linha, igual à dos restantes endpoints.
//
// Sendo autenticado por cookie, é atacável por CSRF: um site qualquer podia
// levar o browser de um administrador a abrir tickets em nome dele. Daí o
// token do platform_csrf_ok(). Não substitui a verificação do papel — CSRF e
// permissão respondem a perguntas diferentes.
declare(strict_types=1);
require_once __DIR__ . '/../hub_bootstrap.php';
require_once __DIR__ . '/../functions_feedback.php';
require_once __DIR__ . '/../functions_csrf.php';
header('Content-Type: application/json; charset=utf-8');
// Nada disto deve ficar em cache nem ser partilhado entre sessões.
header('Cache-Control: no-store');
function feedback_out(int $status, array $payload): never
{
http_response_code($status);
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
feedback_out(405, ['ok' => false, 'error' => 'Usa POST.']);
}
$user = platform_feedback_user();
if (!$user || !platform_feedback_is_admin($user)) {
// 403 e não 401: quem chega aqui sem sessão não tem como a obter a partir
// deste endereço, e um 401 mandaria o browser pedir credenciais.
feedback_out(403, ['ok' => false, 'error' => 'Sessão terminada ou sem permissão. Recarrega a página.']);
}
if (!platform_csrf_ok()) {
feedback_out(403, ['ok' => false, 'error' => 'Pedido rejeitado (token expirado). Recarrega a página e tenta outra vez.']);
}
$res = platform_feedback_submit([
'title' => (string) ($_POST['title'] ?? ''),
'description' => (string) ($_POST['description'] ?? ''),
'category' => (string) ($_POST['category'] ?? 'problema'),
'blocking' => !empty($_POST['blocking']),
'page_url' => (string) ($_POST['page_url'] ?? ''),
], $user);
// 200 mesmo quando o hub recusa: o erro já vem escrito para a pessoa ler, e é
// o corpo que a bolha mostra. Um 4xx aqui só faria o fetch tratar como avaria
// de rede uma coisa que tem explicação.
feedback_out(200, [
'ok' => (bool) $res['ok'],
'error' => (string) ($res['error'] ?? ''),
'ref' => (string) ($res['ref'] ?? ''),
'track_url' => (string) ($res['track_url'] ?? ''),
]);
-180
View File
@@ -1,180 +0,0 @@
<?php
// Estado desta plataforma, para o monitor do hub. Só leitura: nenhum parâmetro
// muda comportamento e nenhum dado de negócio sai daqui. As ações vivem noutro
// endpoint (api/hub_ops.php) e com outra chave — ver ../functions_hub.php.
//
// Formato: docs/CONTRATO_AGENTE_SAUDE.md do hub. `git` e `backup` são entradas
// DENTRO de `checks`; um check em falta aparece como «não reportado» e um check
// a `false` pinta o cartão de amarelo.
//
// **`ok` de topo não é o AND dos checks.** Segundo o contrato, `ok:false`
// significa «responde, mas está inutilizável» — e é isso que o hub mostra como
// «Em baixo». Uma mensagem falhada na fila ou uma cópia antiga não põem um site
// em baixo: são checks a false, que já dão o aviso amarelo. Aqui `ok` segue a
// base de dados, que é o que decide se a plataforma consegue sequer servir.
//
// Cada verificação corre isolada: uma que rebente reporta-se como falha, em vez
// de derrubar a resposta inteira — que apareceria no hub como app inacessível,
// que é uma coisa diferente e mais alarmante.
//
// O stub de cada site (public/api/health.php) é uma linha a incluir isto.
declare(strict_types=1);
require_once __DIR__ . '/../hub_bootstrap.php';
hub_key_verify_or_die();
$health_check = static function (callable $fn): array {
try {
return $fn();
} catch (Throwable $e) {
return ['ok' => false, 'detail' => 'erro no check: ' . $e->getMessage()];
}
};
$root = $repo_root;
$checks = [];
// ─── Plataformas estáticas ───────────────────────────────────────────────────
//
// O apex (andmore.pt) não tem base de dados — ver a nota no hub_bootstrap.php.
// Com ela caem quatro checks: base de dados, SMTP, fila e migrações. Nenhum se
// pode medir, e o contrato diz que um check em falta aparece como «não
// reportado», que é exactamente a verdade.
//
// O que não se faz é deixá-los cair calados: sem uma palavra, o cartão no hub
// fica com quatro linhas em branco e ninguém sabe se aquilo é uma montra
// estática ou uma app com o arranque partido. O check `storage` diz qual é.
if (!$hub_has_db_layer) {
$checks['storage'] = ['ok' => true, 'detail' => 'montra estática — sem base de dados'];
}
if ($pdo instanceof PDO) $checks['database'] = $health_check(function () use ($pdo) {
$version = (string) $pdo->query('SELECT VERSION()')->fetchColumn();
$tables = (int) $pdo->query('SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE()')->fetchColumn();
return ['ok' => true, 'detail' => $version . ', ' . $tables . ' tabelas'];
});
// SMTP: abre a ligação e fecha. NÃO envia nada — isto corre de poucos em poucos
// minutos e um envio por sondagem seria spam a si próprio.
if ($pdo instanceof PDO) $checks['smtp'] = $health_check(function () use ($pdo) {
// O SMTP da frota é configurado no backoffice, não no .env (as chaves do
// .env são placeholders em branco de propósito). Ler só o .env dava «sem
// SMTP configurado» em sites que o têm a funcionar há meses — daí o
// platform_smtp_config(), que é o resolvedor canónico da frota.
$cfg = platform_smtp_config($pdo);
$host = trim((string) $cfg['host']);
if ($host === '') return ['ok' => true, 'detail' => 'sem SMTP configurado'];
$port = (int) ($cfg['port'] ?: 587);
$e1 = 0; $e2 = '';
$sock = @fsockopen($host, $port, $e1, $e2, 5);
if (!$sock) return ['ok' => false, 'detail' => "$host:$port — $e2"];
$greeting = trim((string) fgets($sock, 512));
fclose($sock);
return ['ok' => str_starts_with($greeting, '220'), 'detail' => "$host:$port — $greeting"];
});
// Fila de envios. Só as plataformas que a têm reportam: o contrato diz que um
// check ausente aparece como «não reportado», que é a verdade, em vez de um
// `ok` que não mediu nada.
$queue = !($pdo instanceof PDO) ? [] : $health_check(function () use ($pdo) {
if (!$pdo->query("SHOW TABLES LIKE 'message_queue'")->fetch()) return [];
$rows = $pdo->query("SELECT status, COUNT(*) c FROM message_queue GROUP BY status")->fetchAll(PDO::FETCH_KEY_PAIR);
$failed = (int) ($rows['failed'] ?? 0);
$pending = (int) ($rows['pending'] ?? 0);
return ['ok' => $failed === 0, 'detail' => $pending . ' por enviar, ' . $failed . ' falhadas',
'pending' => $pending, 'failed' => $failed];
});
if ($queue) $checks['queue'] = $queue;
// Migrações por aplicar — o sinal de que um `git pull` ficou a meio e a app
// está a correr contra um esquema antigo.
if ($pdo instanceof PDO) $checks['migrations'] = $health_check(function () use ($pdo) {
hub_load_migration_helpers();
// Não medir não é estar bem: sem runner isto diz-se por medir, e o hub
// mostra-o como aviso em vez de um «tudo ok» que esconde migrações à espera.
if (!function_exists('migrations_status')) {
return ['ok' => false, 'detail' => 'runner indisponível — por medir', 'measured' => false];
}
$pending = 0;
foreach (migrations_status($pdo) as $m) if (empty($m['applied_at'])) $pending++;
return ['ok' => $pending === 0, 'detail' => $pending . ' por aplicar', 'pending' => $pending];
});
// Espaço em disco: uma cópia de segurança que não cabe é uma cópia que não há.
$checks['disk'] = $health_check(function () use ($root) {
$free = @disk_free_space($root);
$total = @disk_total_space($root);
if ($free === false) return ['ok' => true, 'detail' => 'indisponível'];
$used = ($total && $total > 0) ? (int) round(100 * ($total - $free) / $total) : null;
return ['ok' => $free > 512 * 1024 * 1024,
'detail' => round($free / 1073741824, 2) . ' GB livres',
'used_percent' => $used, 'free_bytes' => (int) $free];
});
// Git e cópias entram como checks — não como chaves de topo. Ambos devolvem []
// quando não se aplicam (num cPanel sem repositório, por exemplo), e o contrato
// diz que um check ausente é «não reportado», não uma falha.
$git = $health_check(fn() => hub_git_check($root));
if ($git) $checks['git'] = $git;
// Numa montra estática não se pergunta por cópias: não há base de dados a
// despejar e o código está todo no git, que o check acima já reporta. Sem esta
// guarda o backup_list() -- que existe sempre, porque vem do platform-tools --
// respondia «nenhuma cópia encontrada» e punha o cartão a amarelo para sempre,
// por uma cópia que não faria sentido nenhum existir.
$backup = $hub_has_db_layer ? $health_check(fn() => hub_backup_check()) : [];
if ($backup) $checks['backup'] = $backup;
// Envio de eventos de segurança ao hub. [] numa plataforma por emparelhar ou
// sem base de dados — não reportado, como os outros.
$security = !($pdo instanceof PDO) ? [] : $health_check(function () use ($pdo) {
require_once __DIR__ . '/../functions_security_events.php';
return hub_security_check($pdo);
});
if ($security) $checks['security'] = $security;
// A base de dados tem a forma que o código partilhado espera? Reporta as avarias
// silenciosas (uma coluna em falta que desliga o throttle sem erro nenhum) —
// ver functions_connector.php. Só leitura, uma consulta ao information_schema.
if ($pdo instanceof PDO) $checks['conformance'] = $health_check(function () use ($pdo) {
require_once __DIR__ . '/../functions_connector.php';
return platform_conformance_check($pdo);
});
hub_json([
// Só a base de dados: ver a nota no topo sobre o que significa `ok`. Numa
// plataforma estática não há base de dados que decida nada -- o que torna a
// plataforma inutilizável é não responder, e nesse caso não se chega aqui.
'ok' => $hub_has_db_layer ? !empty($checks['database']['ok']) : true,
'app' => hub_app_name(),
'version' => hub_version($root, $git),
'env' => hub_env('APP_ENV', 'prod'),
'php_version' => PHP_VERSION,
'generated_at' => date('c'),
'checks' => $checks,
// Extra fora do contrato — o hub guarda tudo em payload_json. Diz onde
// ficam as ações e quais são, para não ter de manter uma lista por
// plataforma. Repara que exigem OUTRA chave.
//
// Com as operações desligadas não se anuncia endpoint nem lista de ações.
// O apex nem sequer tem o stub do hub_ops.php — anunciar seis ações e um
// caminho que devolve 404 é pior do que não anunciar nada: quem lê a
// resposta fica a achar que a plataforma sabe fazer coisas que não sabe.
'ops' => hub_ops_key() === '' ? [
'endpoint' => null,
'actions' => [],
'enabled' => false,
'auth' => 'X-Hub-Key: <chave de operações>',
] : [
'endpoint' => hub_base_url('api/hub_ops.php'),
'actions' => hub_ops_actions(),
'enabled' => true,
'auth' => 'X-Hub-Key: <chave de operações>',
],
// As páginas de sistema deste site, para o hub ligar para a página local
// (o recurso quando o hub não chega). Só com router: ver functions_routes.php.
'tools' => function_exists('platform_routes_announced') ? platform_routes_announced() : [],
// E os endpoints pelo router da API — o caminho para endpoints novos.
'api' => function_exists('platform_api_routes_announced') ? platform_api_routes_announced() : [],
]);
-226
View File
@@ -1,226 +0,0 @@
<?php
// Ações remotas do hub sobre esta plataforma: cópias de segurança, atualização
// e migrações. Chave DIFERENTE da do api/health.php, e POST — o que muda alguma
// coisa não entra por GET, para não ser disparado por um link ou um prefetch.
//
// POST JSON: {"action": "...", ...}
//
// **A atualização e as cópias são independentes de propósito.** Num alojamento
// cPanel o `git pull` falha ou nem existe (sem shell, sem git, sem permissões
// de escrita no tree) — e isso não pode impedir o hub de mandar fazer uma cópia
// da base de dados, que é a operação que realmente protege o cliente. Por isso
// `git.pull` devolve `ok:false` com a explicação em vez de rebentar, e as ações
// de cópia não passam por git nenhum.
//
// O stub de cada site (public/api/hub_ops.php) é uma linha a incluir isto.
declare(strict_types=1);
require_once __DIR__ . '/../hub_bootstrap.php';
hub_ops_verify_or_die();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
hub_json(['ok' => false, 'error' => 'usa POST'], 405);
}
$raw = (string) file_get_contents('php://input');
$req = json_decode($raw, true);
$action = is_array($req) ? (string) ($req['action'] ?? '') : (string) ($_POST['action'] ?? '');
$root = $repo_root;
// Numa montra estática não há esquema a migrar nem base de dados a despejar
// (ver a nota do hub_bootstrap.php). Recusa-se aqui, com a razão escrita, em
// vez de deixar as ações chamarem métodos sobre um $pdo a null — um erro de PHP
// apareceria no hub como plataforma avariada, que é uma coisa diferente.
if (!$pdo instanceof PDO
&& in_array($action, ['migrations.list', 'migrations.apply_pending', 'update.run', 'update.status',
'ip_bans.list', 'ip_bans.ban', 'ip_bans.unban', 'tracking.summary'], true)) {
hub_json(['ok' => false, 'error' => 'plataforma sem base de dados — não há migrações a aplicar'], 400);
}
// Auditoria: o que o hub mandou fazer aqui fica escrito, com o hub como autor.
// Só as ações que mudam alguma coisa; as leituras (list, status) não.
if ($pdo instanceof PDO && function_exists('platform_audit')
&& in_array($action, ['backup.trigger', 'update.run', 'git.pull', 'migrations.apply_pending', 'ip_bans.ban', 'ip_bans.unban'], true)) {
$params = is_array($req) ? $req : $_POST; unset($params['action']);
platform_audit($pdo, 'hub.' . $action, ['actor' => ['id' => null, 'name' => 'hub', 'role' => 'hub'],
'target_type' => 'platform', 'changes' => $params ?: null]);
}
switch ($action) {
// ── Cópias de segurança ──────────────────────────────────────────────────
case 'backup.list':
hub_load_backup_helpers();
hub_json(['ok' => true, 'backups' => function_exists('backup_list') ? backup_list() : [],
'summary' => hub_backup_summary()]);
case 'backup.trigger':
$type = is_array($req) ? (string) ($req['type'] ?? 'db') : 'db';
if (!in_array($type, ['db', 'files'], true)) {
hub_json(['ok' => false, 'error' => 'tipo inválido (db|files)'], 400);
}
hub_load_backup_helpers();
if (!function_exists('backup_trigger')) {
hub_json(['ok' => false, 'error' => 'backups não disponíveis nesta plataforma'], 503);
}
$ok = backup_trigger($type);
// O backup_trigger() é «dispara e esquece»: o ficheiro pode ainda estar
// a ser escrito quando isto responde. O resumo devolvido é o de ANTES,
// e é o hub que volta a perguntar — dizer o contrário seria mentir
// sobre uma cópia que ainda não existe.
hub_json([
'ok' => $ok,
'type' => $type,
'started' => $ok,
'note' => $ok ? 'cópia iniciada; volta a consultar daqui a instantes' : 'não foi possível iniciar a cópia',
'summary' => hub_backup_summary(),
], $ok ? 200 : 500);
// ── Atualização ──────────────────────────────────────────────────────────
case 'git.status':
case 'git.pull':
require_once __DIR__ . '/../functions_git.php';
if (!function_exists('git_run') || !is_dir($root . '/.git')) {
hub_json(['ok' => false, 'error' => 'esta instalação não é um repositório git (típico em cPanel) — as cópias continuam a funcionar'], 503);
}
if ($action === 'git.status') {
$branch = git_run(['rev-parse', '--abbrev-ref', 'HEAD'], $root);
$head = git_run(['log', '-1', '--format=%h|%cd|%s', '--date=short'], $root);
$dirty = git_run(['status', '--porcelain'], $root);
hub_json(['ok' => true,
'branch' => $branch['ok'] ? trim($branch['output']) : null,
'head' => $head['ok'] ? trim($head['output']) : null,
'dirty' => $dirty['ok'] ? (trim($dirty['output']) !== '') : null,
]);
}
$pull = git_run(['pull', '--ff-only'], $root);
$output = $pull['output'] !== '' ? $pull['output'] : '(sem saída)';
if ($pull['ok']) {
// O ff-only recusa-se a fundir; um repositório divergente falha aqui
// em vez de inventar um merge que ninguém pediu. Os submódulos vão
// pelo git_sync_submodules(), que respeita os que seguem o branch.
$sub = git_sync_submodules($root);
$output .= "\n\n[submodules]\n" . implode("\n", $sub['log']);
if (function_exists('opcache_reset')) @opcache_reset();
if (function_exists('settings_cache_clear')) settings_cache_clear();
if (function_exists('platform_settings_cache_clear')) platform_settings_cache_clear();
$output .= "\n\n(OPcache + cache de definições invalidados)";
}
hub_json(['ok' => $pull['ok'], 'output' => $output]);
// ── Atualização completa ─────────────────────────────────────────────────
// O mesmo caminho do webhook de deploy (auto_update_run): cópia antes de
// atualizar se estiver ligada, pull que pára em trabalho local, migrações
// pendentes e caches. É o que o botão do hub deve fazer — o git.pull acima
// fica para quem quer só o código.
//
// Responde já e trabalha depois de fechar a ligação: a cópia prévia pode
// demorar minutos e o hub só espera MONITOR_OPS_TIMEOUT_SECONDS. O
// resultado lê-se a seguir com update.status.
case 'update.run':
require_once __DIR__ . '/../functions_auto_update.php';
hub_load_backup_helpers();
if (!function_exists('git_run') || !is_dir($root . '/.git')) {
hub_json(['ok' => false, 'error' => 'esta instalação não é um repositório git (típico em cPanel) — as cópias continuam a funcionar'], 503);
}
@session_write_close();
platform_respond_and_continue(202, [
'ok' => true,
'status' => 'accepted',
'note' => 'Atualização iniciada. O resultado fica em update.status daqui a instantes.',
]);
auto_update_run($pdo, $root, null);
exit;
case 'update.status':
require_once __DIR__ . '/../functions_auto_update.php';
hub_json(['ok' => true, 'last' => auto_update_last($pdo)]);
// ── Bloqueio de IP ───────────────────────────────────────────────────────
// A mesma lista e as mesmas acções da página ip_bans.php deste site, para o
// hub mostrar numa aba. Um banimento feito daqui fica com source='hub' e,
// como qualquer outro, segue para a frota.
case 'ip_bans.list':
require_once __DIR__ . '/../functions_ip_ban.php';
hub_json(['ok' => true, 'bans' => platform_ip_ban_list($pdo, 200),
'offenders' => platform_ip_offenders($pdo, 7, 30)]);
case 'ip_bans.ban':
case 'ip_bans.unban':
require_once __DIR__ . '/../functions_ip_ban.php';
$ip = trim((string) (is_array($req) ? ($req['ip'] ?? '') : ''));
if (!filter_var($ip, FILTER_VALIDATE_IP)) {
hub_json(['ok' => false, 'error' => 'endereço IP inválido'], 400);
}
if ($action === 'ip_bans.unban') {
hub_json(['ok' => platform_ip_unban($pdo, $ip), 'ip' => $ip]);
}
if (platform_ip_ban_exempt($ip)) {
hub_json(['ok' => false, 'error' => 'endereços internos não podem ser banidos'], 400);
}
// `minutes: null` é permanente; ausente é um dia.
$minutes = array_key_exists('minutes', $req) && $req['minutes'] === null
? null : max(1, (int) ($req['minutes'] ?? 1440));
$reason = trim((string) ($req['reason'] ?? '')) ?: 'banido a partir do hub';
$ok = platform_ip_ban($pdo, $ip, $reason, $minutes, 'hub', null);
hub_json(['ok' => $ok, 'ip' => $ip, 'minutes' => $minutes], $ok ? 200 : 500);
// ── Origem das visitas ───────────────────────────────────────────────────
// Só agregados — origens, formulários, países, dispositivos. Nenhuma visita
// individual sai daqui, nem o hash do IP.
case 'tracking.summary':
require_once __DIR__ . '/../functions_tracking.php';
$days = (int) (is_array($req) ? ($req['days'] ?? 30) : 30);
if (!in_array($days, [7, 30, 90, 365], true)) $days = 30;
platform_tracking_ensure_tables($pdo);
hub_json([
'ok' => true,
'days' => $days,
'sources' => array_slice(platform_tracking_report($pdo, $days), 0, 25),
'forms' => platform_tracking_conversions_by_form($pdo, $days),
'countries' => platform_tracking_breakdown($pdo, 'country', $days, 10),
'devices' => platform_tracking_breakdown($pdo, 'device_type', $days, 6),
]);
// ── Migrações ────────────────────────────────────────────────────────────
// Um pull sem as migrações aplicadas deixa a app a correr contra um esquema
// antigo, portanto o hub tem de conseguir fazer as duas coisas.
//
// Numa plataforma estática não há esquema nenhum a migrar (ver a nota do
// hub_bootstrap.php). Diz-se assim, em vez de rebentar com um erro de PHP a
// chamar métodos sobre null — que o hub mostraria como app avariada.
case 'migrations.list':
hub_load_migration_helpers();
if (!function_exists('migrations_status')) hub_json(['ok' => false, 'error' => 'runner indisponível'], 503);
hub_json(['ok' => true, 'migrations' => migrations_status($pdo)]);
case 'migrations.apply_pending':
hub_load_migration_helpers();
if (!function_exists('migrations_status') || !function_exists('migrations_apply_file')) {
hub_json(['ok' => false, 'error' => 'runner indisponível'], 503);
}
$applied = [];
$error = null;
foreach (migrations_status($pdo) as $row) {
if (!empty($row['applied_at'])) continue;
$r = migrations_apply_file($pdo, $row['filename'], null);
if (!empty($r['ok'])) {
$applied[] = $row['filename'];
} else {
// Pára à primeira falha: a seguinte pode depender desta, e
// continuar a aplicar às cegas é como um esquema fica a meio.
$error = $row['filename'] . ': ' . ($r['error'] ?? 'erro');
break;
}
}
if ($applied && function_exists('settings_cache_clear')) settings_cache_clear();
hub_json(['ok' => $error === null, 'applied' => $applied, 'error' => $error]);
default:
hub_json(['ok' => false, 'error' => 'ação desconhecida: ' . $action,
'actions' => hub_ops_actions()], 400);
}
-47
View File
@@ -1,47 +0,0 @@
<?php
// Emparelhamento: o hub chama aqui quando o André aprova esta plataforma na
// Frota. É a ÚNICA etapa em que as chaves saem daqui, e saem numa ligação que o
// hub iniciou — não numa que esta plataforma abriu para um URL que lhe deram.
//
// Autenticação: o código de emparelhamento que esta plataforma gerou e enviou
// no anúncio. Uso único, com validade. Não há chave de API nesta etapa, porque
// é precisamente ela que está a ser entregue.
//
// O stub de cada site (public/api/hub_pair.php) é uma linha a incluir isto.
declare(strict_types=1);
require_once __DIR__ . '/../hub_bootstrap.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
hub_json(['ok' => false, 'error' => 'usa POST'], 405);
}
$req = json_decode((string) file_get_contents('php://input'), true);
$code = is_array($req) ? (string) ($req['pairing_code'] ?? '') : '';
$ops = is_array($req) && !empty($req['with_ops']);
if ($code === '') hub_json(['ok' => false, 'error' => 'código em falta'], 400);
// O emparelhamento automático guarda o estado e as chaves no system_settings,
// portanto precisa mesmo de base de dados. Uma montra estática (ver a nota do
// hub_bootstrap.php) inscreve-se à mão: chaves no .env do stack, linha na
// tabela `apps` do hub. Diz-se isso, em vez de rebentar sobre um $pdo a null.
if (!$pdo instanceof PDO) {
hub_json(['ok' => false,
'error' => 'plataforma sem base de dados — inscreve-se à mão, com as chaves no .env'], 400);
}
$res = hub_pair_complete($pdo, $code, $ops);
if (empty($res['ok'])) {
// 403 e não 404: o pedido chegou ao sítio certo, o que falha é a permissão.
hub_json(['ok' => false, 'error' => $res['error']], 403);
}
hub_json([
'ok' => true,
'app' => hub_app_name(),
'api_key' => $res['api_key'],
'ops_key' => $res['ops_key'], // null quando o hub não pediu operações
'health' => hub_base_url('api/health.php'),
'ops' => hub_base_url('api/hub_ops.php'),
]);
-36
View File
@@ -1,36 +0,0 @@
<?php
// ─── Front controller dos endpoints partilhados ─────────────────────────────
//
// Um só stub por site para toda a API partilhada:
//
// <?php require __DIR__ . '/../../shared/platform-tools/api/router.php'; // public/api/platform.php
//
// e cada endpoint responde em api/platform.php?endpoint=<nome> (ou
// api/platform?endpoint=… com 'clean_urls'). A lista é fechada
// (platform_api_routes()), e cada endpoint continua a autenticar-se a si
// próprio: este ficheiro só carrega o arranque do site e despacha.
//
// Um site com implementação própria de um endpoint declara-a no
// platform_connect(): 'api_files' => ['backup_trigger' => <caminho absoluto>]
// (a silver tem guardas com outros nomes). O 'migrations_dir' do site chega ao
// agente, como chega às páginas.
declare(strict_types=1);
require_once __DIR__ . '/../hub_bootstrap.php'; // $repo_root, $pdo, o arranque do site
require_once __DIR__ . '/../functions_routes.php';
$endpoint = (string) ($_GET['endpoint'] ?? '');
$file = platform_api_routes()[$endpoint] ?? null;
if ($file === null) {
hub_json(['ok' => false, 'error' => 'endpoint desconhecido'], 404);
}
$cfg = function_exists('platform_config') ? platform_config() : [];
if (!empty($cfg['migrations_dir'])) {
$GLOBALS['MIGRATIONS_DIR'] = (string) $cfg['migrations_dir'];
}
$target = (string) ($cfg['api_files'][$endpoint] ?? (dirname(__DIR__) . '/' . $file));
unset($cfg, $file);
require $target;
-154
View File
@@ -1,154 +0,0 @@
<?php
// ─── Registo de auditoria (página partilhada) ───────────────────────────────
//
// /platform?tool=audit. Lê o `audit_log` do site pela forma canónica
// (platform_audit_query(), functions_audit.php), seja qual for a forma da
// tabela, e mostra quem fez o quê, a quê e quando, com o antes e o depois de
// cada alteração. Só leitura. Exporta o filtro em CSV.
if (!isset($pdo) || !$pdo instanceof PDO) {
http_response_code(500);
exit('audit.php: sem PDO — o arranque do site (config/db.php) tem de abrir a ligação primeiro.');
}
require_once __DIR__ . '/functions_audit.php';
$filters = [
'actor' => trim((string) ($_GET['actor'] ?? '')),
'action' => trim((string) ($_GET['action'] ?? '')),
'target_type' => trim((string) ($_GET['target_type'] ?? '')),
'target_id' => trim((string) ($_GET['target_id'] ?? '')),
'q' => trim((string) ($_GET['q'] ?? '')),
'from' => preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) ($_GET['from'] ?? '')) ? (string) $_GET['from'] : '',
'to' => preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) ($_GET['to'] ?? '')) ? (string) $_GET['to'] : '',
];
$perPage = 50;
$page = max(1, (int) ($_GET['p'] ?? 1));
// Exportação: o filtro inteiro (até 5000 linhas), em CSV que o Excel abre em pt.
if (($_GET['export'] ?? '') === 'csv') {
$rows = platform_audit_query($pdo, $filters, 5000);
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="auditoria-' . date('Y-m-d') . '.csv"');
$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF");
fputcsv($out, ['Quando', 'Quem', 'Papel', 'Ação', 'Tipo', 'Id', 'Alvo', 'Resumo', 'Alterações', 'IP'], ';');
foreach ($rows as $r) {
fputcsv($out, [$r['created_at'], $r['actor_name'], $r['actor_role'], $r['action'], $r['target_type'],
$r['target_id'], $r['target_label'], $r['summary'], $r['changes'], $r['ip']], ';');
}
fclose($out);
exit;
}
$rows = platform_audit_query($pdo, $filters, $perPage + 1, ($page - 1) * $perPage);
$hasNext = count($rows) > $perPage;
$rows = array_slice($rows, 0, $perPage);
$actions = platform_audit_distinct($pdo, 'action');
$types = platform_audit_distinct($pdo, 'target_type');
$selfUrl = strtok((string) ($_SERVER['REQUEST_URI'] ?? ''), '?') ?: '';
$link = function (array $q) use ($selfUrl, $filters): string {
$base = isset($_GET['tool']) ? ['tool' => (string) $_GET['tool']] : [];
return $selfUrl . '?' . http_build_query($base + array_filter($q + $filters, fn($v) => $v !== '' && $v !== null));
};
/** Uma alteração em HTML: tabela campo / antes / depois, ou o texto tal como veio. */
$renderChanges = function (?string $json): string {
if ($json === null || $json === '') return '';
$data = json_decode($json, true);
if (!is_array($data)) return '<pre class="small mb-0">' . htmlspecialchars($json) . '</pre>';
$fmt = fn($v) => $v === null ? '<span class="text-muted">—</span>'
: htmlspecialchars(is_scalar($v) ? (string) $v : json_encode($v, JSON_UNESCAPED_UNICODE));
$isDiff = $data && array_reduce($data, fn($ok, $v) => $ok && is_array($v) && (array_key_exists('from', $v) || array_key_exists('to', $v)), true);
if (!$isDiff) return '<pre class="small mb-0" style="white-space:pre-wrap">' . htmlspecialchars(json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)) . '</pre>';
$h = '<table class="table table-sm mb-0 small"><thead><tr><th>Campo</th><th>Antes</th><th>Depois</th></tr></thead><tbody>';
foreach ($data as $k => $v) {
$h .= '<tr><td>' . htmlspecialchars((string) $k) . '</td><td class="text-danger">' . $fmt($v['from'] ?? null) . '</td><td class="text-success">' . $fmt($v['to'] ?? null) . '</td></tr>';
}
return $h . '</tbody></table>';
};
$layout_header = $platform_layout_header ?? ($repo_root . '/includes/header.php');
$layout_navbar = $platform_layout_navbar ?? ($repo_root . '/includes/navbar.php');
$layout_footer = $platform_layout_footer ?? ($repo_root . '/includes/footer.php');
$dashboard_url = $platform_dashboard_url ?? (function_exists('platform_admin_url') ? platform_admin_url('index.php') : 'index.php');
$pageTitle = 'Registo de auditoria';
if (is_file($layout_header)) include $layout_header;
if (is_file($layout_navbar)) include $layout_navbar;
?>
<div class="container py-4" style="max-width:1200px;">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
<div>
<h2 class="mb-1"><i class="bi bi-journal-text me-2"></i>Registo de auditoria</h2>
<p class="text-muted small mb-0">Quem fez o quê, a quê e quando. Os segredos nunca entram no registo.</p>
</div>
<div class="d-flex gap-2">
<a class="btn btn-sm btn-outline-secondary" href="<?= htmlspecialchars($link(['export' => 'csv'])) ?>"><i class="bi bi-download me-1"></i>Exportar CSV</a>
<a class="btn btn-sm btn-outline-secondary" href="<?= htmlspecialchars($dashboard_url) ?>">Voltar</a>
</div>
</div>
<form method="get" class="card card-body mb-3">
<?php if (isset($_GET['tool'])): ?><input type="hidden" name="tool" value="<?= htmlspecialchars((string) $_GET['tool']) ?>"><?php endif; ?>
<div class="row g-2 align-items-end">
<div class="col-md-2"><label class="form-label small mb-1">Quem</label>
<input class="form-control form-control-sm" name="actor" value="<?= htmlspecialchars($filters['actor']) ?>" placeholder="nome"></div>
<div class="col-md-2"><label class="form-label small mb-1">Ação</label>
<input class="form-control form-control-sm" name="action" list="audit-actions" value="<?= htmlspecialchars($filters['action']) ?>" placeholder="ex.: user.">
<datalist id="audit-actions"><?php foreach ($actions as $a): ?><option value="<?= htmlspecialchars((string) $a) ?>"><?php endforeach; ?></datalist></div>
<div class="col-md-2"><label class="form-label small mb-1">Tipo</label>
<select class="form-select form-select-sm" name="target_type"><option value="">Todos</option>
<?php foreach ($types as $t): ?><option value="<?= htmlspecialchars((string) $t) ?>" <?= $filters['target_type'] === (string) $t ? 'selected' : '' ?>><?= htmlspecialchars((string) $t) ?></option><?php endforeach; ?>
</select></div>
<div class="col-md-2"><label class="form-label small mb-1">Texto</label>
<input class="form-control form-control-sm" name="q" value="<?= htmlspecialchars($filters['q']) ?>" placeholder="alvo, resumo…"></div>
<div class="col-6 col-md-1"><label class="form-label small mb-1">De</label>
<input type="date" class="form-control form-control-sm" name="from" value="<?= htmlspecialchars($filters['from']) ?>"></div>
<div class="col-6 col-md-1"><label class="form-label small mb-1">Até</label>
<input type="date" class="form-control form-control-sm" name="to" value="<?= htmlspecialchars($filters['to']) ?>"></div>
<div class="col-md-2 d-flex gap-2">
<button class="btn btn-sm btn-primary flex-grow-1">Filtrar</button>
<a class="btn btn-sm btn-outline-secondary" href="<?= htmlspecialchars($link(['actor' => '', 'action' => '', 'target_type' => '', 'target_id' => '', 'q' => '', 'from' => '', 'to' => ''])) ?>">Limpar</a>
</div>
</div>
</form>
<div class="card">
<div class="table-responsive">
<table class="table table-sm table-hover align-middle mb-0">
<thead><tr><th>Quando</th><th>Quem</th><th>Ação</th><th>Alvo</th><th>Resumo</th><th>IP</th></tr></thead>
<tbody>
<?php if (!$rows): ?><tr><td colspan="6" class="text-muted p-4 text-center">Nada no registo com este filtro.</td></tr><?php endif; ?>
<?php foreach ($rows as $r): $ch = $renderChanges($r['changes']); ?>
<tr>
<td class="small text-nowrap"><?= htmlspecialchars(date('d/m/Y H:i:s', strtotime((string) $r['created_at']))) ?></td>
<td class="small"><?= $r['actor_name'] !== null && $r['actor_name'] !== '' ? htmlspecialchars((string) $r['actor_name']) : '<span class="text-muted">sistema</span>' ?>
<?php if (!empty($r['actor_role'])): ?><div class="text-muted"><?= htmlspecialchars((string) $r['actor_role']) ?></div><?php endif; ?></td>
<td class="small"><a href="<?= htmlspecialchars($link(['action' => (string) $r['action'], 'p' => null])) ?>"><code><?= htmlspecialchars((string) $r['action']) ?></code></a></td>
<td class="small">
<?php if (!empty($r['target_type'])): ?><a class="text-muted" href="<?= htmlspecialchars($link(['target_type' => (string) $r['target_type'], 'target_id' => (string) ($r['target_id'] ?? ''), 'p' => null])) ?>"><?= htmlspecialchars((string) $r['target_type']) ?><?= $r['target_id'] !== null && $r['target_id'] !== '' ? ' #' . htmlspecialchars((string) $r['target_id']) : '' ?></a><?php endif; ?>
<?php if (!empty($r['target_label'])): ?><div><?= htmlspecialchars((string) $r['target_label']) ?></div><?php endif; ?>
</td>
<td class="small">
<?= htmlspecialchars((string) ($r['summary'] ?? '')) ?>
<?php if ($ch !== ''): ?>
<details class="mt-1"><summary class="text-muted" style="cursor:pointer">alterações</summary><?= $ch ?></details>
<?php endif; ?>
</td>
<td class="small text-muted"><?= htmlspecialchars((string) ($r['ip'] ?? '')) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php if ($page > 1 || $hasNext): ?>
<div class="card-footer d-flex justify-content-between">
<?php if ($page > 1): ?><a class="btn btn-sm btn-outline-secondary" href="<?= htmlspecialchars($link(['p' => $page - 1])) ?>">&larr; Mais recentes</a><?php else: ?><span></span><?php endif; ?>
<span class="small text-muted align-self-center">página <?= $page ?></span>
<?php if ($hasNext): ?><a class="btn btn-sm btn-outline-secondary" href="<?= htmlspecialchars($link(['p' => $page + 1])) ?>">Mais antigas &rarr;</a><?php else: ?><span></span><?php endif; ?>
</div>
<?php endif; ?>
</div>
</div>
<?php if (is_file($layout_footer)) include $layout_footer; ?>
-438
View File
@@ -1,438 +0,0 @@
<?php
// ─── Shared backups view ─────────────────────────────────────────────────────
//
// Bootstrap contract — the stub at <host>/views/sysadmin/backups.php must:
// 1. Load $pdo (via $repo_root/config/db.php + functions.php)
// 2. Run the sysadmin auth guard
// 3. Set $repo_root
// Optional override variables (same as platform_update.php):
// $platform_layout_header / $platform_layout_navbar / $platform_layout_footer
// $platform_dashboard_url
require_once __DIR__ . '/functions_backup.php';
require_once __DIR__ . '/functions_csrf.php';
// Load BACKUP_KEEP_DAYS (and BACKUP_TOKEN) from the worker config if not yet defined.
if (!defined('BACKUP_KEEP_DAYS')) {
require_once __DIR__ . '/backups/config.php';
}
if (!isset($repo_root) || !is_string($repo_root)) {
$repo_root = realpath(dirname(__DIR__, 2)) ?: dirname(__DIR__, 2);
}
$repo_root = rtrim($repo_root, '/');
// ── Apagar backup (POST à própria página, PRG) ──────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delete_backup') {
if (!platform_csrf_ok()) {
$_SESSION['backup_flash'] = [
'type' => 'danger',
'message' => 'Pedido rejeitado (token CSRF ausente ou inválido).',
];
} else {
$fn = (string) ($_POST['filename'] ?? '');
$dir = backup_dir();
$real = realpath($dir . DIRECTORY_SEPARATOR . $fn);
if (!backup_filename_is_safe($fn) || $real === false
|| !str_starts_with($real, $dir . DIRECTORY_SEPARATOR)) {
$_SESSION['backup_flash'] = ['type' => 'danger', 'message' => 'Ficheiro inválido.'];
} elseif (@unlink($real)) {
if (function_exists('platform_audit')) platform_audit($pdo ?? null, 'backup.delete', ['target_type' => 'backup', 'target_label' => $fn]);
$_SESSION['backup_flash'] = ['type' => 'success', 'message' => "Backup {$fn} apagado."];
} else {
$_SESSION['backup_flash'] = ['type' => 'danger', 'message' => "Não foi possível apagar {$fn} (permissões?)."];
}
}
header('Location: ' . $_SERVER['REQUEST_URI'], true, 303);
exit;
}
// ── Restaurar backup (POST à própria página, PRG) ───────────────────────────
// Destrutivo: substitui o estado actual. Exige CSRF + confirmação escrita, e
// restore_db()/restore_files() tiram sempre um backup de segurança antes de
// escrever, cujo nome é devolvido na flash para servir de "desfazer".
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'restore_backup') {
require_once __DIR__ . '/functions_restore.php';
if (!platform_csrf_ok()) {
$_SESSION['backup_flash'] = [
'type' => 'danger',
'message' => 'Pedido rejeitado (token CSRF ausente ou inválido).',
];
} elseif (trim((string) ($_POST['confirm'] ?? '')) !== 'RESTAURAR') {
// Guarda contra o clique por reflexo: um confirm() do browser é
// demasiado fácil de despachar sem ler.
$_SESSION['backup_flash'] = [
'type' => 'danger',
'message' => 'Confirmação incorrecta — restauro cancelado. Nada foi alterado.',
];
} else {
$fn = (string) ($_POST['filename'] ?? '');
$purge = !empty($_POST['purge']);
@set_time_limit(0);
ignore_user_abort(true);
$info = restore_inspect($fn);
if (!$info['ok']) {
$_SESSION['backup_flash'] = [
'type' => 'danger',
'message' => 'Restauro recusado: ' . (string) $info['error'],
];
} elseif ($info['type'] === 'db' && !isset($pdo)) {
$_SESSION['backup_flash'] = [
'type' => 'danger',
'message' => 'Restauro da base de dados indisponível: o arranque do site (config/db.php) não abriu a ligação à base de dados.',
];
} else {
$r = ($info['type'] === 'db')
? restore_db($pdo, $fn)
: restore_files($fn, ['purge' => $purge]);
if (!empty($r['ok'])) {
if ($info['type'] === 'db') {
$detail = sprintf(
'%d statements aplicados. Tabelas: %s → %s.',
(int) $r['statements'],
$r['tables_before'] ?? '?',
$r['tables_after'] ?? '?'
);
if (!empty($r['dropped_extra'])) {
$detail .= ' Tabelas removidas por não existirem no backup: '
. implode(', ', $r['dropped_extra']) . '.';
}
} else {
$detail = sprintf(
'%d ficheiro(s) restaurado(s)%s.',
(int) $r['extracted'],
!empty($r['purged']) ? ', ' . (int) $r['purged'] . ' apagado(s)' : ''
);
}
// A flash é renderizada com htmlspecialchars(), por isso é texto
// simples: markup aqui apareceria literalmente ao utilizador, e
// escapar aqui daria escape duplo.
$undo = !empty($r['safety_backup'])
? ' Estado anterior guardado em ' . (string) $r['safety_backup']
. ' — restaura esse ficheiro para desfazer.'
: '';
$_SESSION['backup_flash'] = [
'type' => 'success',
'message' => 'Restaurado de ' . $fn . '. ' . $detail . $undo,
];
} else {
$safe = !empty($r['safety_backup'])
? ' O backup de segurança ' . (string) $r['safety_backup']
. ' foi criado antes da falha — usa-o para voltar atrás.'
: '';
$_SESSION['backup_flash'] = [
'type' => 'danger',
'message' => 'Restauro falhou: ' . (string) ($r['error'] ?? 'erro desconhecido') . $safe,
];
}
}
}
header('Location: ' . $_SERVER['REQUEST_URI'], true, 303);
exit;
}
$flash = $_SESSION['backup_flash'] ?? null;
unset($_SESSION['backup_flash']);
$backups = backup_list();
// ── Auto-refresh após "backup iniciado" ─────────────────────────────────────
// O trigger devolve o nº de backups antes do disparo; enquanto a contagem não
// subir, a página recarrega sozinha (limite de tentativas para não ficar em
// loop se o worker falhar).
$await_count = null;
$await_tries = 0;
if (is_array($flash) && isset($flash['await_count'])) {
$await_count = (int) $flash['await_count'];
} elseif (isset($_GET['pt_await'])) {
$await_count = (int) $_GET['pt_await'];
$await_tries = max(0, (int) ($_GET['pt_tries'] ?? 0));
}
$awaiting = $await_count !== null && count($backups) <= $await_count && $await_tries < 20;
$layout_header = $platform_layout_header ?? ($repo_root . '/includes/header.php');
$layout_navbar = $platform_layout_navbar ?? ($repo_root . '/includes/navbar.php');
$layout_footer = $platform_layout_footer ?? ($repo_root . '/includes/footer.php');
$base_url_fn = function_exists('base_url') ? 'base_url' : null;
$dashboard_url = $platform_dashboard_url
?? (function_exists('base_url') ? base_url('views/admin/dashboard.php') : 'dashboard.php');
$pageTitle = 'Backups';
if (is_file($layout_header)) include $layout_header;
if (is_file($layout_navbar)) include $layout_navbar;
?>
<div class="container py-4" style="max-width:1000px;">
<?php
// O estado da encriptação tem de estar à vista. Um backup em claro parece
// igual a um encriptado na listagem, e a diferença só se descobre no dia em
// que o ficheiro sai do servidor.
$bkEnc = function_exists('platform_backup_encryption_available') && platform_backup_encryption_available();
?>
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
<div>
<h2 class="mb-1"><i class="bi bi-archive me-2"></i>Backups</h2>
<p class="text-muted small mb-0">Gerir cópias de segurança dos uploads e da base de dados. O código reconstitui-se a partir do git — só a pasta <code>uploads/</code> tem informação externa.</p>
<p class="small mb-0 mt-1">
<?php if ($bkEnc): ?>
<span class="badge text-bg-success"><i class="bi bi-lock-fill me-1"></i>AES-256</span>
<span class="text-muted">Os dumps novos saem encriptados. A chave deriva da <code>APP_SECRET_KEY</code> —
<strong>perdê-la é perder os backups</strong>; guarde-a fora do servidor.</span>
<?php else: ?>
<span class="badge text-bg-warning text-dark"><i class="bi bi-unlock-fill me-1"></i>Sem encriptação</span>
<span class="text-muted">Sem <code>APP_SECRET_KEY</code> no <code>.env</code>, os dumps saem em claro —
e levam a tabela de utilizadores, os contactos e os contratos assinados.</span>
<?php endif; ?>
</p>
</div>
<div class="d-flex gap-2 flex-wrap align-items-center">
<a href="<?= htmlspecialchars($dashboard_url) ?>" class="btn btn-outline-secondary btn-sm">Voltar</a>
<form method="post" action="<?= htmlspecialchars($base_url_fn ? base_url('api/backup_trigger.php') : 'api/backup_trigger.php') ?>">
<?= platform_csrf_field() ?><input type="hidden" name="type" value="files">
<button type="submit" class="btn btn-outline-secondary">
<i class="bi bi-archive me-1"></i>Backup de uploads
</button>
</form>
<form method="post" action="<?= htmlspecialchars($base_url_fn ? base_url('api/backup_trigger.php') : 'api/backup_trigger.php') ?>">
<?= platform_csrf_field() ?><input type="hidden" name="type" value="db">
<button type="submit" class="btn btn-outline-primary">
<i class="bi bi-database-down me-1"></i>Backup da base de dados
</button>
</form>
</div>
</div>
<?php if (is_array($flash) && !empty($flash['type'])): ?>
<?php $flash_type = (string) $flash['type']; ?>
<div class="alert alert-<?= htmlspecialchars($flash_type) ?> alert-dismissible fade show d-flex align-items-center gap-2" role="alert">
<i class="bi bi-<?= $flash_type === 'success' ? 'check-circle' : 'exclamation-triangle' ?> flex-shrink-0"></i>
<div><?= htmlspecialchars((string) ($flash['message'] ?? '')) ?></div>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
<?php endif; ?>
<?php if ($awaiting): ?>
<div class="alert alert-info d-flex align-items-center gap-2" role="status">
<span class="spinner-border spinner-border-sm flex-shrink-0"></span>
<div>A aguardar o novo backup… a lista atualiza automaticamente
<span class="text-muted">(tentativa <?= $await_tries + 1 ?>/20)</span></div>
</div>
<?php elseif ($await_count !== null && $await_tries >= 20): ?>
<div class="alert alert-warning" role="alert">
O backup ainda não apareceu ao fim de <?= $await_tries ?> verificações — confirma os logs do servidor.
</div>
<?php endif; ?>
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="fw-semibold">Cópias de segurança disponíveis</span>
<span class="badge text-bg-secondary"><?= count($backups) ?></span>
</div>
<?php if (empty($backups)): ?>
<div class="card-body text-center text-muted py-5">
<i class="bi bi-inbox fs-2 d-block mb-2 opacity-50"></i>
Nenhuma cópia de segurança encontrada.
</div>
<?php else: ?>
<div class="table-responsive">
<table class="table table-hover mb-0 small align-middle">
<thead class="table-dark">
<tr>
<th>Ficheiro</th>
<th>Tipo</th>
<th>Tamanho</th>
<th>Data de criação</th>
<th class="text-end">Ações</th>
</tr>
</thead>
<tbody>
<?php foreach ($backups as $b): ?>
<tr>
<td class="font-monospace text-break"><?= htmlspecialchars($b['filename']) ?></td>
<td>
<span class="badge text-bg-<?= backup_type_badge($b['type']) ?>">
<?= backup_type_label($b['type']) ?>
</span>
</td>
<td class="text-nowrap"><?= htmlspecialchars($b['size_human']) ?></td>
<td class="text-muted text-nowrap"><?= date('d/m/Y H:i', $b['created_at']) ?></td>
<td class="text-end">
<a href="<?= htmlspecialchars(($base_url_fn ? base_url('api/backup_download.php') : 'api/backup_download.php')) ?>?file=<?= urlencode($b['filename']) ?>"
class="btn btn-sm btn-outline-secondary"
title="Transferir" aria-label="Transferir <?= htmlspecialchars($b['filename']) ?>">
<i class="bi bi-download"></i>
</a>
<button type="button"
class="btn btn-sm btn-outline-warning js-restore"
data-filename="<?= htmlspecialchars($b['filename']) ?>"
data-type="<?= htmlspecialchars($b['type']) ?>"
data-label="<?= htmlspecialchars(backup_type_label($b['type'])) ?>"
data-created="<?= htmlspecialchars(date('d/m/Y H:i', $b['created_at'])) ?>"
title="Restaurar" aria-label="Restaurar <?= htmlspecialchars($b['filename']) ?>">
<i class="bi bi-arrow-counterclockwise"></i>
</button>
<form method="POST" class="d-inline m-0"
onsubmit="return confirm('Apagar <?= htmlspecialchars(addslashes($b['filename'])) ?>? Esta ação não pode ser desfeita.');">
<?= platform_csrf_field() ?><input type="hidden" name="action" value="delete_backup">
<input type="hidden" name="filename" value="<?= htmlspecialchars($b['filename']) ?>">
<button type="submit" class="btn btn-sm btn-outline-danger"
title="Apagar" aria-label="Apagar <?= htmlspecialchars($b['filename']) ?>">
<i class="bi bi-trash"></i>
</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<div class="card-footer text-muted small">
<i class="bi bi-info-circle me-1"></i>
Retenção automática, por tipo: <?= htmlspecialchars(backup_retention_summary()) ?>.
O resto é apagado no fim de cada backup.
Os ficheiros estão armazenados na pasta <code>backups/</code> no servidor.
</div>
</div>
</div>
<!-- ── Modal de restauro ───────────────────────────────────────────────────
Um único modal para todas as linhas, preenchido a partir dos data-* do
botão clicado. Um confirm() do browser não chega aqui: é preciso mostrar
o que vai ser substituído, oferecer o purge só para ficheiros, e exigir
uma confirmação escrita antes de activar o botão. -->
<div class="modal fade" id="ptRestoreModal" tabindex="-1" aria-labelledby="ptRestoreLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<form method="POST" class="modal-content">
<?= platform_csrf_field() ?>
<input type="hidden" name="action" value="restore_backup">
<input type="hidden" name="filename" id="ptRestoreFilename" value="">
<div class="modal-header">
<h5 class="modal-title" id="ptRestoreLabel">
<i class="bi bi-arrow-counterclockwise me-2"></i>Restaurar backup
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Fechar"></button>
</div>
<div class="modal-body">
<p class="mb-2">Vai restaurar:</p>
<p class="font-monospace small text-break mb-1" id="ptRestoreName"></p>
<p class="text-muted small mb-3">
<span class="badge text-bg-secondary" id="ptRestoreType"></span>
<span id="ptRestoreCreated" class="ms-2"></span>
</p>
<div class="alert alert-warning small mb-3" role="alert">
<strong>Isto substitui o estado actual.</strong>
<span id="ptRestoreScopeDb" class="d-none">
Toda a base de dados é substituída pelo conteúdo do backup. Tabelas que
não existam no backup são removidas.
</span>
<span id="ptRestoreScopeFiles" class="d-none">
A pasta de uploads é reposta a partir do backup.
</span>
<br>
É tirado automaticamente um backup do estado actual antes de escrever —
o nome aparece no fim para poderes desfazer.
</div>
<div class="form-check mb-3 d-none" id="ptRestorePurgeWrap">
<input class="form-check-input" type="checkbox" name="purge" value="1" id="ptRestorePurge">
<label class="form-check-label small" for="ptRestorePurge">
Apagar ficheiros que não existam no backup (espelho exacto).
<span class="text-danger">Desligado por omissão: um backup parcial apagaria uploads em uso.</span>
</label>
</div>
<label for="ptRestoreConfirm" class="form-label small mb-1">
Escreve <code>RESTAURAR</code> para confirmar:
</label>
<input type="text" class="form-control" name="confirm" id="ptRestoreConfirm"
autocomplete="off" placeholder="RESTAURAR">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancelar</button>
<button type="submit" class="btn btn-warning" id="ptRestoreSubmit" disabled>
<i class="bi bi-arrow-counterclockwise me-1"></i>Restaurar
</button>
</div>
</form>
</div>
</div>
<script>
(function () {
var modalEl = document.getElementById('ptRestoreModal');
if (!modalEl || typeof bootstrap === 'undefined') return;
var modal = new bootstrap.Modal(modalEl);
var confirmInput = document.getElementById('ptRestoreConfirm');
var submitBtn = document.getElementById('ptRestoreSubmit');
document.querySelectorAll('.js-restore').forEach(function (btn) {
btn.addEventListener('click', function () {
var isDb = btn.dataset.type === 'db';
document.getElementById('ptRestoreFilename').value = btn.dataset.filename;
document.getElementById('ptRestoreName').textContent = btn.dataset.filename;
document.getElementById('ptRestoreType').textContent = btn.dataset.label || btn.dataset.type;
document.getElementById('ptRestoreCreated').textContent = btn.dataset.created || '';
document.getElementById('ptRestoreScopeDb').classList.toggle('d-none', !isDb);
document.getElementById('ptRestoreScopeFiles').classList.toggle('d-none', isDb);
// purge só faz sentido para ficheiros; garante que não vai marcado
// de uma abertura anterior.
document.getElementById('ptRestorePurgeWrap').classList.toggle('d-none', isDb);
document.getElementById('ptRestorePurge').checked = false;
confirmInput.value = '';
submitBtn.disabled = true;
modal.show();
});
});
confirmInput.addEventListener('input', function () {
submitBtn.disabled = confirmInput.value.trim() !== 'RESTAURAR';
});
})();
</script>
<script>
// Anti-duplo-clique: bloqueia reenvios e mostra spinner no botão submetido.
document.addEventListener('submit', function (e) {
var form = e.target;
if (e.defaultPrevented || !(form instanceof HTMLFormElement)) return;
if (form.dataset.ptSubmitted) { e.preventDefault(); return; }
form.dataset.ptSubmitted = '1';
form.querySelectorAll('button[type="submit"]').forEach(function (btn) {
btn.disabled = true;
btn.insertAdjacentHTML('afterbegin',
'<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>');
});
});
<?php if ($awaiting): ?>
// Recarrega até o novo backup aparecer (contagem no servidor decide).
setTimeout(function () {
var url = new URL(window.location.href);
url.searchParams.set('pt_await', '<?= (int) $await_count ?>');
url.searchParams.set('pt_tries', '<?= (int) ($await_tries + 1) ?>');
window.location.replace(url.toString());
}, 8000);
<?php else: ?>
// Poll terminado (ou nunca ativo): limpa os parâmetros de acompanhamento.
if (window.location.search.indexOf('pt_await') !== -1) {
var cleanUrl = new URL(window.location.href);
cleanUrl.searchParams.delete('pt_await');
cleanUrl.searchParams.delete('pt_tries');
window.history.replaceState(null, '', cleanUrl.toString());
}
<?php endif; ?>
</script>
<?php if (is_file($layout_footer)) include $layout_footer; ?>
@@ -1,185 +0,0 @@
<?php
/**
* Backup dos ficheiros do site — APENAS a pasta de uploads.
*
* Tudo o resto no host é reconstituível a partir do git (código) ou do backup
* da base de dados; os uploads são a única pasta com informação externa. Os
* caminhos dentro do ZIP ficam relativos à raiz do host (uploads/...), por
* isso um restore é extrair o ZIP na raiz.
*
* Assumed location: <host>/shared/platform-tools/backups/backup_maker.php
* → $sourceDir = <host>/<BACKUP_FILES_DIR> (default: uploads; ver config.php)
* → $backupDir = <host>/backups (where the ZIP lands)
*
* Via CLI : php shared/platform-tools/backups/backup_maker.php
* Via HTTP: GET shared/platform-tools/backups/backup_maker.php?token=SEU_TOKEN
*/
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/../functions_backup.php';
// --- Autenticação -----------------------------------------------------------
if (php_sapi_name() !== 'cli') {
$token = $_GET['token'] ?? $_POST['token'] ?? '';
if (!hash_equals(BACKUP_TOKEN, $token)) {
http_response_code(403);
die(json_encode(['status' => 'error', 'message' => 'Acesso negado.']));
}
ignore_user_abort(true);
set_time_limit(0);
}
// --- Configuração -----------------------------------------------------------
$siteRoot = dirname(__DIR__, 3); // <host>
$backupDir = $siteRoot . '/backups'; // <host>/backups
if (!is_dir($backupDir)) @mkdir($backupDir, 0775, true);
$siteRoot = realpath($siteRoot) ?: $siteRoot;
$backupDir = realpath($backupDir) ?: $backupDir;
backup_dir_protect($backupDir);
// BACKUP_FILES_DIR='' is an explicit opt-out, not a misconfiguration: some
// hosts keep no local user content at all (surf serves media from Nextcloud
// over WebDAV), so there is genuinely nothing to archive. Report and exit 0
// rather than die() -- a scheduled run must not be marked failed for it.
if (trim(BACKUP_FILES_DIR) === '') {
echo "Backup de ficheiros desactivado (BACKUP_FILES_DIR vazio) — este host não guarda ficheiros localmente.\n";
return;
}
// BACKUP_FILES_DIR aceita uma LISTA separada por vírgulas, não só uma pasta.
//
// Era uma única pasta, e isso perdia dados em silêncio: o design guarda os
// modelos 3D em public/threed/ e o resto em public/uploads/, por isso um
// backup "de ficheiros" trazia metade do conteúdo do utilizador e ninguém
// reparava até tentar restaurar. Uma pasta continua a ser válido -- a maioria
// dos sites tem só uma.
$requestedDirs = array_values(array_filter(array_map(
fn($d) => trim($d, " \t/"),
explode(',', BACKUP_FILES_DIR)
), fn($d) => $d !== ''));
$sourceDirs = []; // rótulo relativo => caminho absoluto
foreach ($requestedDirs as $rel) {
$abs = realpath($siteRoot . '/' . $rel) ?: '';
if ($abs === '' || !is_dir($abs)) {
// Falhar alto: uma pasta configurada que não existe é quase sempre um
// erro de configuração, e continuar em silêncio produz exactamente o
// backup incompleto que isto existe para evitar.
die("Erro: pasta de uploads não encontrada: $siteRoot/$rel"
. " (verifica BACKUP_FILES_DIR no .env).\n");
}
$sourceDirs[$rel] = $abs;
}
if (!$sourceDirs) {
die("Erro: BACKUP_FILES_DIR não resolveu nenhuma pasta válida.\n");
}
// Seconds + random suffix: no same-minute overwrite, and the download URL
// can't be guessed even if the folder deny rule is missing.
$filename = 'backup_' . date('d_m_Y_H_i_s') . '_' . bin2hex(random_bytes(3)) . '.zip';
$outputPath = "$backupDir/$filename";
echo "Raiz do site : $siteRoot\n";
echo "Pastas origem: " . implode(', ', array_keys($sourceDirs)) . "\n";
echo "Pasta backup : $backupDir\n";
echo "Destino ZIP : $outputPath\n\n";
if (!class_exists('ZipArchive')) {
die("Erro: extensão ZipArchive não disponível.\n");
}
if (!is_writable($backupDir)) {
die("Erro: sem permissão de escrita em: $backupDir\n");
}
// --- Criar ZIP --------------------------------------------------------------
$zip = new ZipArchive();
$result = $zip->open($outputPath, ZipArchive::CREATE | ZipArchive::OVERWRITE);
if ($result !== true) {
die("Erro ao criar ZIP (código: $result)\n");
}
// Só a pasta de uploads é percorrida — código, vendor/ e afins são
// reconstituíveis a partir do git e ficam de fora por construção. Dentro dos
// uploads NADA é excluído por nome/extensão: tudo ali é conteúdo externo de
// utilizadores (um .zip carregado por um utilizador é dado legítimo). Única
// exclusão: a própria pasta backups/, caso algum host a tenha aninhado aqui.
$skipped = 0;
$added = 0;
$errors = 0;
foreach ($sourceDirs as $relRoot => $sourceDir) {
$filter = new RecursiveCallbackFilterIterator(
new RecursiveDirectoryIterator($sourceDir, RecursiveDirectoryIterator::SKIP_DOTS),
function ($file) use ($backupDir, &$skipped): bool {
$realPath = $file->getRealPath();
if ($realPath === false) { $skipped++; return false; }
if ($realPath === $backupDir || str_starts_with($realPath, $backupDir . DIRECTORY_SEPARATOR)) {
$skipped++;
return false;
}
return true;
}
);
$iterator = new RecursiveIteratorIterator($filter, RecursiveIteratorIterator::SELF_FIRST);
// Entrada raiz explícita: o restore extrai na raiz do host, e um backup de
// uma pasta de uploads vazia continua a ser um ZIP válido.
$zip->addEmptyDir($relRoot);
foreach ($iterator as $file) {
$realPath = $file->getRealPath();
// Entradas prefixadas com uploads/... para o ZIP extraído na raiz do host
// repor os ficheiros no sítio certo. Ancorado em $sourceDir (não em
// $siteRoot) para funcionar mesmo quando uploads é um symlink para fora
// da raiz do site.
$relativePath = $relRoot . '/'
. str_replace(DIRECTORY_SEPARATOR, '/', substr($realPath, strlen($sourceDir) + 1));
if ($file->isDir()) {
$zip->addEmptyDir($relativePath);
} else {
if (!is_readable($realPath)) {
echo "AVISO: sem permissão de leitura — $realPath\n";
$errors++;
continue;
}
// addFile() streams no close() — não carrega o ficheiro em memória.
if (!$zip->addFile($realPath, $relativePath)) {
echo "AVISO: falha ao adicionar — $realPath\n";
$errors++;
continue;
}
$added++;
}
}
} // fim do foreach por pasta configurada
echo "Âmbito: " . implode('/, ', array_keys($sourceDirs)) . "/ (o código reconstitui-se a partir do git)\n";
if (!$zip->close()) {
die("Erro: falha ao finalizar o ficheiro ZIP.\n");
}
clearstatcache(true, $outputPath);
if (!file_exists($outputPath)) {
die("Erro: o ficheiro ZIP não foi criado.\n");
}
// --- Resultado --------------------------------------------------------------
$bytes = filesize($outputPath);
$size = $bytes >= 1024 * 1024
? round($bytes / 1024 / 1024, 2) . ' MB'
: round($bytes / 1024, 1) . ' KB';
echo "Backup concluído!\n";
echo "Ficheiro : $outputPath\n";
echo "Tamanho : $size\n";
echo "Ficheiros: $added adicionados, $skipped ignorados" . ($errors ? ", $errors erros" : '') . "\n";
backup_prune_report();
-82
View File
@@ -1,82 +0,0 @@
<?php
/**
* Configuração partilhada pelos scripts de backup.
* Assumed location: <host>/shared/platform-tools/backups/config.php
*
* Valores vêm do .env na raiz do projecto (<host>/.env, gitignored). Os
* fallbacks abaixo só são usados se o .env estiver ausente — em produção,
* mete sempre o .env preenchido.
*/
$host_root = dirname(__DIR__, 3);
// Three host patterns coexist:
// - fotografia-style : config/env.php exposes env_load(), needs an explicit call
// - dsa/moremio-style: config/env.php auto-loads .env via an IIFE; require is enough
// - surf-style : no config/env.php -- config/db.php loads .env with
// vlucas/phpdotenv, and the workers run without it, so
// they read .env themselves (the fallback below).
// Requiring config/env.php unconditionally fataled on the third layout.
if (is_file($host_root . '/config/env.php')) {
require_once $host_root . '/config/env.php';
if (function_exists('env_load')) {
env_load($host_root . '/.env');
}
}
// Fallback reader for hosts with no config/env.php, and for CLI invocations
// that run before any framework bootstrap: parse .env directly rather than
// assuming something else already did.
if (!function_exists('env')) {
function env(string $key, $default = null) {
$v = getenv($key);
if ($v === false && array_key_exists($key, $_ENV)) $v = $_ENV[$key];
if ($v !== false && $v !== null) return $v;
static $parsed = null;
if ($parsed === null) {
$parsed = [];
$f = dirname(__DIR__, 3) . '/.env';
if (is_file($f) && is_readable($f)) {
foreach (file($f, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#' || !str_contains($line, '=')) continue;
[$k, $val] = array_pad(explode('=', $line, 2), 2, '');
$parsed[trim($k)] = trim(trim($val), " \"'");
}
}
}
return array_key_exists($key, $parsed) ? $parsed[$key] : $default;
}
}
define('BACKUP_TOKEN', env('BACKUP_TOKEN', 'replace_me'));
// Retenção avô-pai-filho — ver backup_gfs_keep() em functions_backup.php.
define('BACKUP_KEEP_DAYS', (int) env('BACKUP_KEEP_DAYS', 7));
define('BACKUP_KEEP_WEEKS', (int) env('BACKUP_KEEP_WEEKS', 4));
define('BACKUP_KEEP_MONTHS', (int) env('BACKUP_KEEP_MONTHS', 1));
// Pasta (relativa à raiz do host) que o backup de ficheiros arquiva. Só os
// uploads têm informação externa — todo o resto é reconstituível a partir do
// git — por isso o default é 'uploads'. Overridável no .env se um host
// guardar os uploads noutro sítio.
define('BACKUP_FILES_DIR', trim((string) env('BACKUP_FILES_DIR', 'uploads'), '/'));
// Credenciais da base de dados — sem fallbacks reais: se o .env faltar, a
// ligação falha alto em vez de tentar um host/utilizador "mágico".
define('DB_HOST', env('DB_HOST', 'localhost'));
define('DB_PORT', (int) env('DB_PORT', 3306));
define('DB_NAME', env('DB_NAME', ''));
define('DB_USER', env('DB_USER', ''));
define('DB_PASS', env('DB_PASS', ''));
define('DB_CHARSET', 'utf8mb4');
/** Poda avô-pai-filho no fim de um worker (ver backup_prune()), com o relatório no output. */
function backup_prune_report(): void
{
$r = backup_prune();
foreach ($r['deleted'] as $f) echo "Apagado (retenção): $f\n";
foreach ($r['errors'] as $f) echo "ERRO a apagar: $f\n";
echo 'Rotação: ' . count($r['deleted']) . ' apagado(s), ' . count($r['kept']) . ' mantido(s) — '
. backup_retention_summary($r['policy']) . ".\n";
}
-187
View File
@@ -1,187 +0,0 @@
<?php
/**
* Backup da base de dados — gera um dump SQL completo em ZIP.
*
* Assumed location: <host>/shared/platform-tools/backups/db_backup.php
* → $backupDir = <host>/backups (where the ZIP lands)
*
* Via CLI : php shared/platform-tools/backups/db_backup.php
* Via HTTP: GET shared/platform-tools/backups/db_backup.php?token=SEU_TOKEN
*/
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/../functions_backup.php';
// --- Autenticação -----------------------------------------------------------
if (php_sapi_name() !== 'cli') {
$token = $_GET['token'] ?? $_POST['token'] ?? '';
if (!hash_equals(BACKUP_TOKEN, $token)) {
http_response_code(403);
die(json_encode(['status' => 'error', 'message' => 'Acesso negado.']));
}
ignore_user_abort(true);
set_time_limit(0);
}
// --- Configuração -----------------------------------------------------------
$siteRoot = dirname(__DIR__, 3);
$backupDir = $siteRoot . '/backups';
if (!is_dir($backupDir)) @mkdir($backupDir, 0775, true);
$backupDir = realpath($backupDir) ?: $backupDir;
backup_dir_protect($backupDir);
// Sem ZipArchive não há backup: um .sql em claro na pasta backups/ nunca pode
// ser o artefacto final (não aparece na UI, não roda, e é texto simples).
if (!class_exists('ZipArchive')) {
die("Erro: extensão ZipArchive não disponível — backup cancelado.\n");
}
// Seconds + random suffix: no same-minute overwrite, unguessable URL.
$sqlFile = 'db_backup_' . date('d_m_Y_H_i_s') . '_' . bin2hex(random_bytes(3)) . '.sql';
$zipFile = str_replace('.sql', '.zip', $sqlFile);
$sqlPath = $backupDir . '/' . $sqlFile;
$zipPath = $backupDir . '/' . $zipFile;
echo "Base de dados: " . DB_NAME . "\n";
echo "Destino ZIP : $zipPath\n\n";
if (!is_writable($backupDir)) {
die("Erro: sem permissão de escrita em: $backupDir\n");
}
// --- Ligação PDO ------------------------------------------------------------
try {
$dsn = sprintf('mysql:host=%s;port=%d;dbname=%s;charset=%s', DB_HOST, DB_PORT, DB_NAME, DB_CHARSET);
$pdo = new PDO($dsn, DB_USER, DB_PASS, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
} catch (PDOException $e) {
die("Erro de ligação à base de dados: " . $e->getMessage() . "\n");
}
// --- Gerar dump SQL (streaming) ----------------------------------------------
// Escreve o dump diretamente para o .sql à medida que lê — nunca constrói a
// base de dados inteira numa string PHP. As leituras de dados usam queries
// não-bufferizadas (uma de cada vez, cursor fechado antes da query seguinte),
// por isso o pico de memória é ~1 batch de 500 linhas, independentemente do
// tamanho das tabelas.
function dumpToFile(PDO $pdo, string $sqlPath): void
{
$fh = fopen($sqlPath, 'wb');
if ($fh === false) {
die("Erro: não foi possível escrever o ficheiro SQL.\n");
}
fwrite($fh, "-- Backup: " . DB_NAME . "\n");
fwrite($fh, "-- Data : " . date('Y-m-d H:i:s') . "\n\n");
fwrite($fh, "SET NAMES utf8mb4;\n");
fwrite($fh, "SET FOREIGN_KEY_CHECKS = 0;\n\n");
$tables = $pdo->query("SHOW FULL TABLES WHERE Table_type = 'BASE TABLE'")->fetchAll(PDO::FETCH_COLUMN);
foreach ($tables as $table) {
$quoted = '`' . str_replace('`', '``', (string) $table) . '`';
// Estrutura
$create = $pdo->query("SHOW CREATE TABLE $quoted")->fetch();
fwrite($fh, "DROP TABLE IF EXISTS $quoted;\n");
fwrite($fh, $create['Create Table'] . ";\n\n");
// Dados — linha a linha, INSERTs em blocos de 500.
$pdo->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false);
$stmt = $pdo->query("SELECT * FROM $quoted");
$columns = null;
$values = [];
$flush = function () use ($fh, $quoted, &$columns, &$values) {
if (!$values) return;
fwrite($fh, "INSERT INTO $quoted ($columns) VALUES\n" . implode(",\n", $values) . ";\n");
$values = [];
};
while (($row = $stmt->fetch(PDO::FETCH_ASSOC)) !== false) {
if ($columns === null) {
$columns = '`' . implode('`, `', array_keys($row)) . '`';
}
$escaped = array_map(
fn($v) => $v === null ? 'NULL' : $pdo->quote((string) $v),
array_values($row)
);
$values[] = '(' . implode(', ', $escaped) . ')';
if (count($values) >= 500) $flush();
}
$flush();
$stmt->closeCursor();
$pdo->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, true);
fwrite($fh, "\n");
}
fwrite($fh, "SET FOREIGN_KEY_CHECKS = 1;\n");
fclose($fh);
}
echo "A gerar dump SQL...\n";
dumpToFile($pdo, $sqlPath);
// --- Empacotar em ZIP -------------------------------------------------------
$zip = new ZipArchive();
$result = $zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE);
if ($result !== true) {
@unlink($sqlPath);
die("Erro ao criar ZIP (código: $result)\n");
}
// addFile() streams no close() — o .sql não passa pela memória.
$zip->addFile($sqlPath, $sqlFile);
// Encriptação AES-256 quando há chave. Um dump em claro no disco leva a tabela
// users com os hashes, os contactos, e os contratos assinados -- que no dance
// incluem dados de menores.
//
// Sem chave sai em claro, e diz-se. Recusar o backup por falta de chave seria
// pior: a alternativa a um ficheiro legível não é um ficheiro seguro, é
// ficheiro nenhum.
require_once dirname(__DIR__) . '/functions_backup.php';
$bkKey = platform_backup_key();
if ($bkKey !== null && method_exists($zip, 'setEncryptionName') && defined('ZipArchive::EM_AES_256')) {
$zip->setPassword($bkKey);
if ($zip->setEncryptionName($sqlFile, ZipArchive::EM_AES_256)) {
echo "Encriptação: AES-256\n";
} else {
echo "AVISO: a encriptação falhou — o backup vai em claro.\n";
error_log('[backup] setEncryptionName falhou; artefacto em claro');
}
} else {
echo "AVISO: sem APP_SECRET_KEY — o backup vai em claro.\n";
error_log('[backup] sem chave de encriptação; artefacto em claro');
}
if (!$zip->close()) {
@unlink($sqlPath);
die("Erro: falha ao finalizar o ficheiro ZIP.\n");
}
// Remover .sql temporário
@unlink($sqlPath);
clearstatcache(true, $zipPath);
if (!file_exists($zipPath)) {
die("Erro: o ficheiro ZIP não foi criado.\n");
}
// --- Resultado --------------------------------------------------------------
$bytes = filesize($zipPath);
$size = $bytes >= 1024 * 1024
? round($bytes / 1024 / 1024, 2) . ' MB'
: round($bytes / 1024, 1) . ' KB';
echo "Backup da BD concluído!\n";
echo "Ficheiro : $zipPath\n";
echo "Tamanho : $size\n";
// Só se chega aqui com a cópia nova no disco: a poda nunca corre depois de uma
// cópia falhada, que é quando as antigas são as únicas que há.
backup_prune_report();
@@ -1,126 +0,0 @@
<?php
/**
* Restaura um backup produzido por db_backup.php ou backup_maker.php.
*
* Assumed location: <host>/shared/platform-tools/backups/restore_runner.php
*
* Via CLI :
* php shared/platform-tools/backups/restore_runner.php <ficheiro.zip> [opções]
* Opções: --dry-run apenas valida e mostra o que faria
* --no-safety não tira backup de segurança (NÃO recomendado)
* --purge (só ficheiros) apaga o que não estiver no artefacto
* --list lista os backups disponíveis e sai
*
* Via HTTP: POST restore_runner.php com token=<BACKUP_TOKEN>&file=<ficheiro.zip>
* GET é recusado de propósito: restaurar não é uma operação idempotente de
* leitura e não deve ser possível dispará-la com um link.
*
* TODO RESTAURO É DESTRUTIVO. Por omissão tira sempre um backup do estado
* actual primeiro e devolve o nome desse ficheiro no relatório.
*/
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/../functions_restore.php';
$IS_CLI = (php_sapi_name() === 'cli');
// --- Autenticação -----------------------------------------------------------
if (!$IS_CLI) {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
header('Content-Type: application/json');
die(json_encode(['ok' => false, 'error' => 'Use POST.']));
}
$token = $_POST['token'] ?? '';
if (BACKUP_TOKEN === '' || BACKUP_TOKEN === 'replace_me' || !hash_equals(BACKUP_TOKEN, (string) $token)) {
http_response_code(403);
header('Content-Type: application/json');
die(json_encode(['ok' => false, 'error' => 'Acesso negado.']));
}
ignore_user_abort(true);
set_time_limit(0);
}
// --- Argumentos -------------------------------------------------------------
if ($IS_CLI) {
$args = array_slice($argv, 1);
$flags = array_values(array_filter($args, fn($a) => str_starts_with($a, '--')));
$files = array_values(array_filter($args, fn($a) => !str_starts_with($a, '--')));
$file = $files[0] ?? '';
$dryRun = in_array('--dry-run', $flags, true);
$safety = !in_array('--no-safety', $flags, true);
$purge = in_array('--purge', $flags, true);
$list = in_array('--list', $flags, true);
} else {
$file = (string) ($_POST['file'] ?? '');
$dryRun = !empty($_POST['dry_run']);
$safety = ($_POST['safety'] ?? '1') !== '0';
$purge = !empty($_POST['purge']);
$list = !empty($_POST['list']);
}
function out(array $data, bool $cli): void {
if (!$cli) { header('Content-Type: application/json'); echo json_encode($data, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE); return; }
foreach ($data as $k => $v) {
if (is_array($v)) $v = $v ? implode(', ', array_map('strval', $v)) : '(nenhum)';
elseif (is_bool($v)) $v = $v ? 'sim' : 'não';
elseif ($v === null) $v = '-';
printf(" %-16s %s\n", $k, $v);
}
}
if ($list) {
$rows = backup_list();
if ($IS_CLI) {
echo "Backups disponíveis em " . backup_dir() . ":\n";
foreach ($rows as $r) printf(" %-46s %-6s %s\n", $r['filename'], $r['type'], $r['size_human']);
if (!$rows) echo " (nenhum)\n";
} else { header('Content-Type: application/json'); echo json_encode(['ok'=>true,'backups'=>$rows]); }
exit;
}
if ($file === '') {
out(['ok' => false, 'error' => 'Indique o ficheiro a restaurar (ou --list).'], $IS_CLI);
exit($IS_CLI ? 1 : 0);
}
// --- Validação (sempre, mesmo fora do dry-run) ------------------------------
$info = restore_inspect($file);
if ($IS_CLI) {
echo "Artefacto : {$file}\n";
echo "Tipo : " . ($info['type'] ?? '?') . "\n";
echo "Tamanho : " . ($info['size_human'] ?? '?') . "\n";
echo "Entradas : " . ($info['entries'] ?? 0) . "\n\n";
}
if (!$info['ok']) { out(['ok' => false, 'error' => $info['error']], $IS_CLI); exit($IS_CLI ? 1 : 0); }
if ($dryRun) {
out(['ok' => true, 'dry_run' => true, 'type' => $info['type'],
'would_restore' => $info['type'] === 'db'
? 'base de dados ' . DB_NAME
: (defined('BACKUP_FILES_DIR') ? BACKUP_FILES_DIR : 'uploads'),
'safety_backup' => $safety ? 'sim (antes de escrever)' : 'NÃO',
'purge' => $purge], $IS_CLI);
exit;
}
// --- Execução ---------------------------------------------------------------
if ($info['type'] === 'db') {
try {
$dsn = 'mysql:host=' . DB_HOST . ';port=' . DB_PORT . ';dbname=' . DB_NAME . ';charset=' . DB_CHARSET;
$pdo = new PDO($dsn, DB_USER, DB_PASS, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => true,
]);
} catch (PDOException $e) {
out(['ok' => false, 'error' => 'Ligação à BD falhou: ' . $e->getMessage()], $IS_CLI);
exit($IS_CLI ? 1 : 0);
}
$report = restore_db($pdo, $file, ['safety' => $safety]);
} else {
$report = restore_files($file, ['safety' => $safety, 'purge' => $purge]);
}
out($report, $IS_CLI);
exit($IS_CLI ? ($report['ok'] ? 0 : 1) : 0);
@@ -1,30 +0,0 @@
<?php
// Aplica a retenção avô-pai-filho à pasta backups/ sem fazer uma cópia nova.
//
// php shared/platform-tools/bin/backup-prune.php [--dry-run]
// 30 4 * * * php /var/www/html/shared/platform-tools/bin/backup-prune.php
//
// Os workers já podam no fim de cada cópia. Isto serve para um site cujas cópias
// pararam (a poda nunca corre depois de uma cópia falhada), para ver o que uma
// mudança de BACKUP_KEEP_* apagaria antes de a fazer (--dry-run), e para podar
// depois de a mudar.
declare(strict_types=1);
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit;
}
require_once dirname(__DIR__) . '/backups/config.php';
require_once dirname(__DIR__) . '/functions_backup.php';
$dryRun = in_array('--dry-run', $argv, true);
$r = backup_prune($dryRun);
echo 'Política: ' . backup_retention_summary($r['policy']) . "\n";
foreach ($r['kept'] as $k) printf(" mantém %s (%s)\n", $k['filename'], $k['reason']);
foreach ($r['deleted'] as $f) printf(" %s %s\n", $dryRun ? 'apagaria' : 'apagou ', $f);
foreach ($r['errors'] as $f) printf(" ERRO %s\n", $f);
exit($r['errors'] ? 1 : 0);
@@ -1,77 +0,0 @@
<?php
// Encrypt any secret-valued row in system_settings that is still plaintext.
//
// docker exec -u www-data <container> php \
// /var/www/html/shared/platform-tools/bin/encrypt_secrets.php [--dry-run]
//
// Idempotent -- rows already carrying the enc:v1: prefix are left alone -- so it
// is safe to call from deploy.sh on every deploy. Exits non-zero if a row that
// should be encrypted could not be, so a deploy notices.
//
// Every site opens $pdo in its config/db.php; that is the whole bootstrap.
declare(strict_types=1);
$repoRoot = dirname(__DIR__, 3);
$dryRun = in_array('--dry-run', $argv, true);
$pdo = null;
if (is_file($repoRoot . '/config/db.php')) {
require_once $repoRoot . '/config/db.php'; // defines $pdo
if (is_file($repoRoot . '/config/functions.php')) {
require_once $repoRoot . '/config/functions.php';
}
}
if (!$pdo instanceof PDO) {
fwrite(STDERR, "encrypt_secrets: could not open a PDO connection for {$repoRoot}\n");
exit(2);
}
// A bootstrap with a hardcoded fallback schema would connect somewhere
// harmless-looking when the environment did not reach it, and "encrypt"
// nothing, reporting success. Refuse unless the live connection is the schema
// DB_NAME actually names.
$expected = (string) ($_ENV['DB_NAME'] ?? getenv('DB_NAME') ?: '');
$actual = (string) $pdo->query('SELECT DATABASE()')->fetchColumn();
if ($expected !== '' && $actual !== $expected) {
fwrite(STDERR, "encrypt_secrets: connected to '{$actual}' but DB_NAME is '{$expected}' -- refusing to run.\n");
exit(2);
}
require_once dirname(__DIR__) . '/functions_settings.php';
if (!platform_settings_secret_available()) {
fwrite(STDERR, "encrypt_secrets: no APP_SECRET_KEY (or BACKUP_TOKEN) configured -- refusing to run.\n");
exit(3);
}
if ($dryRun) {
// Report without writing: list secret-named rows still in plaintext.
$rows = $pdo->query("SELECT setting_key, setting_value FROM system_settings")->fetchAll(PDO::FETCH_ASSOC);
$pending = [];
foreach ($rows as $row) {
$k = (string) $row['setting_key'];
$v = (string) $row['setting_value'];
if (platform_settings_is_secret($k) && $v !== '' && !str_starts_with($v, PLATFORM_SECRET_PREFIX)) {
$pending[] = $k;
}
}
echo $pending
? "would encrypt: " . implode(', ', $pending) . "\n"
: "nothing to encrypt -- all secret rows are empty or already encrypted\n";
exit(0);
}
$report = platform_settings_encrypt_existing($pdo);
foreach ($report['encrypted'] as $k) echo "encrypted: {$k}\n";
foreach ($report['skipped'] as $k) echo "skipped: {$k}\n";
foreach ($report['failed'] as $k) fwrite(STDERR, "FAILED: {$k}\n");
printf(
"%d encrypted, %d skipped, %d failed\n",
count($report['encrypted']), count($report['skipped']), count($report['failed'])
);
exit($report['failed'] ? 1 : 0);
@@ -1,169 +0,0 @@
#!/usr/bin/env php
<?php
// Compila o índice de país por endereço, para a analítica de visitas.
//
// php bin/geo-build-index.php
//
// O bloqueio por país (functions_geo.php) só precisa das listas dos países
// permitidos, que são pequenas e ficam versionadas. Isto é outra coisa: para
// dizer DE ONDE veio uma visita é preciso o mundo inteiro -- 240 países e
// ~263 mil ranges. Em texto são 4,7 MB e a compilar dá ~2,6 MB.
//
// Por isso o índice NÃO vai para o repositório: um binário desse tamanho
// refrescado todos os meses inchava o histórico sem nada em troca. Gera-se aqui
// e, se faltar, a analítica regista o país como desconhecido -- que é um
// degradar aceitável, ao contrário do bloqueio, onde dados em falta seriam uma
// decisão de segurança.
//
// Formato, escolhido para se poder pesquisar sem carregar nada para memória:
// registos de largura fixa, ordenados, e busca binária por fseek(). São
// centenas de milhares de ranges — lê-los todos a cada visita custaria mais do
// que a própria página.
//
// .v4 → 10 bytes: início(4, big-endian) + fim(4) + código do país(2)
// .v6 → 34 bytes: início(16) + fim(16) + código do país(2)
//
// Os bytes vêm de inet_pton(), por isso a ordem lexicográfica é a ordem
// numérica e o strcmp() serve de comparador nas duas famílias.
const V4_URL = 'https://www.ipdeny.com/ipblocks/data/countries/all-zones.tar.gz';
const V6_URL = 'https://www.ipdeny.com/ipv6/ipaddresses/blocks/ipv6-all-zones.tar.gz';
$dataDir = dirname(__DIR__) . '/data/geo';
@mkdir($dataDir, 0775, true);
require_once dirname(__DIR__) . '/functions_geo.php'; // geo_cidr_to_range()
$tmp = sys_get_temp_dir() . '/geo-index-' . getmypid();
@mkdir($tmp, 0700, true);
register_shutdown_function(static function () use ($tmp) {
foreach (glob($tmp . '/*') ?: [] as $f) { @unlink($f); }
@rmdir($tmp);
});
$status = 0;
foreach ([['v4', V4_URL, 4], ['v6', V6_URL, 16]] as [$fam, $url, $addrLen]) {
fwrite(STDERR, "$fam: a descarregar...\n");
$tar = "$tmp/$fam.tar.gz";
if (!download($url, $tar)) {
fwrite(STDERR, " falhou o download -- o índice anterior mantém-se\n");
$status = 1;
continue;
}
$work = "$tmp/$fam";
@mkdir($work, 0700, true);
exec('tar xzf ' . escapeshellarg($tar) . ' -C ' . escapeshellarg($work) . ' 2>&1', $out, $code);
if ($code !== 0) {
fwrite(STDERR, " o arquivo não abriu -- ignorado\n");
$status = 1;
continue;
}
// Nem toda a zona do ipdeny é um país. O zz.zone é um apanha-tudo de blocos
// /8 inteiros (1.0.0.0/8, 8.0.0.0/8…) que se sobrepõem aos países reais, e
// o eu.zone é um agregado continental. Deixá-los entrar fazia o 8.8.8.8 sair
// como "ZZ" em vez de US, porque a busca binária cai no range que apanhar --
// e com sobreposições não há garantia nenhuma de ser o mais específico.
// Fora estes dois, sobram 2 sobreposições em 262 mil.
$notCountries = ['ZZ', 'EU', 'AP'];
$ranges = [];
foreach (glob("$work/*.zone") ?: [] as $file) {
// ipdeny nomeia cada ficheiro pelo código ISO: pt.zone, br.zone…
$cc = strtoupper(basename($file, '.zone'));
if (!preg_match('/^[A-Z]{2}$/', $cc) || in_array($cc, $notCountries, true)) {
continue;
}
foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') {
continue;
}
$r = geo_cidr_to_range($line);
// Um ficheiro v4 não pode contaminar o índice v6 nem o contrário:
// os registos são de largura fixa e um intruso desalinhava tudo a
// partir dali, o que a busca binária não tem como detectar.
if ($r === null || strlen($r[0]) !== $addrLen) {
continue;
}
$ranges[] = [$r[0], $r[1], $cc];
}
}
if (count($ranges) < 1000) {
fwrite(STDERR, ' só ' . count($ranges) . " ranges — recusado, fica o índice anterior\n");
$status = 1;
continue;
}
usort($ranges, static fn($a, $b) => strcmp($a[0], $b[0]));
// As poucas sobreposições que restam são descartadas, ficando a primeira.
// A busca binária pressupõe ranges disjuntos: com sobreposições devolveria
// um resultado que depende de onde a bissecção calhou aterrar, o que é pior
// do que não responder -- daria países diferentes para o mesmo endereço
// consoante o tamanho do índice.
$clean = [];
$lastEnd = null;
$dropped = 0;
foreach ($ranges as $r) {
if ($lastEnd !== null && strcmp($r[0], $lastEnd) <= 0) {
$dropped++;
continue;
}
$clean[] = $r;
$lastEnd = $r[1];
}
$ranges = $clean;
if ($dropped > 0) {
fwrite(STDERR, " $dropped ranges sobrepostos descartados\n");
}
// Escrita para ficheiro temporário e rename: o rename é atómico, por isso
// um processo a ler nunca apanha o índice a meio de ser escrito.
$outFile = "$dataDir/country-index.$fam";
$tmpOut = $outFile . '.tmp';
$fh = fopen($tmpOut, 'wb');
if ($fh === false) {
fwrite(STDERR, " não consegui escrever em $tmpOut\n");
$status = 1;
continue;
}
foreach ($ranges as [$start, $end, $cc]) {
fwrite($fh, $start . $end . $cc);
}
fclose($fh);
@chmod($tmpOut, 0664);
rename($tmpOut, $outFile);
printf(
" %s: %d ranges, %.1f MB\n",
$fam,
count($ranges),
filesize($outFile) / 1048576
);
}
exit($status);
function download(string $url, string $dest): bool
{
$fh = fopen($dest, 'wb');
if ($fh === false) {
return false;
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_FILE => $fh,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 180,
CURLOPT_FAILONERROR => true,
]);
$ok = curl_exec($ch) !== false;
curl_close($ch);
fclose($fh);
return $ok && filesize($dest) > 1024;
}
@@ -1,100 +0,0 @@
#!/usr/bin/env bash
#
# Refresh the iCloud Private Relay egress ranges that functions_geo.php reads.
#
# geo-relay-update.sh pt # one or more ISO country codes
# geo-relay-update.sh # falls back to $GEO_ALLOW_COUNTRIES
#
# Why this exists: Private Relay sends Safari traffic out through Apple's egress
# addresses, which are registered where Apple registered them and not where the
# person is. Against the ipdeny lists a subscriber in Aveiro looks foreign, gets
# refused, and never hears back. Apple publishes which country each egress range
# serves, so the gate can ask the right question about it.
#
# It writes relay-<cc>.v4 / relay-<cc>.v6 — same format as the ipdeny lists, read
# by the same code. **Only the countries you ask for**, and that is the point:
# the published list is 42k IPv4 ranges and 246k IPv6 (855 KB and 6.7 MB), while
# Portugal is 611 of them. The whole file would be a 6.7 MB commit every week and
# a 6.7 MB parse on every request, to answer a question about one country.
#
# Keeping it per-country is also what stops this becoming a Relay whitelist: a
# range serving France is simply never written, so a French relay user is
# refused by the normal path exactly like any other foreign address.
#
# Apple adds ranges as it adds capacity, so weekly is reasonable:
#
# 23 4 * * 1 /srv/sites/<site>/shared/platform-tools/bin/geo-relay-update.sh pt
#
# The files are committed, same as the ipdeny data and for the same reason: a
# deploy on a box that cannot reach Apple still has working data — as current as
# the last commit.
set -euo pipefail
DATA_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/data/geo"
URL="${ICLOUD_RELAY_URL:-https://mask-api.icloud.com/egress-ip-ranges.csv}"
# Apple serves relay capacity for a country from at least a few dozen ranges.
# Fewer than this means the country code was wrong, the format changed, or the
# download was truncated — and overwriting a good file with it would quietly
# turn every relay visitor back into a foreigner.
MIN_ROWS=10
countries=("$@")
if [ ${#countries[@]} -eq 0 ]; then
read -r -a countries <<< "${GEO_ALLOW_COUNTRIES//[,;]/ }"
fi
if [ ${#countries[@]} -eq 0 ]; then
echo "uso: $(basename "$0") <cc> [cc...] (ou defina GEO_ALLOW_COUNTRIES)" >&2
exit 2
fi
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
echo "iCloud Private Relay: a descarregar $URL"
if ! curl -fsS --max-time 60 "$URL" -o "$tmp/raw.csv"; then
echo "FALHOU o download — os ficheiros anteriores mantêm-se." >&2
exit 1
fi
failed=0
for cc in "${countries[@]}"; do
cc="$(echo "$cc" | tr '[:upper:]' '[:lower:]')"
[ -n "$cc" ] || continue
up="$(echo "$cc" | tr '[:lower:]' '[:upper:]')"
# O formato da Apple é: cidr,countryCode,region,city, — só as duas
# primeiras colunas interessam, e uma linha que não seja exactamente isso é
# descartada em vez de adivinhada: uma mudança de formato tem de aparecer
# como "poucas gamas" e parar o script, não como um ficheiro meio lido.
awk -F',' -v want="$up" '
$1 ~ /\// && toupper($2) == want {
if ($1 ~ /:/) { print $1 > v6 } else { print $1 > v4 }
}
' v4="$tmp/$cc.v4" v6="$tmp/$cc.v6" "$tmp/raw.csv" || true
touch "$tmp/$cc.v4" "$tmp/$cc.v6"
rows4=$(wc -l < "$tmp/$cc.v4" | tr -d ' ')
rows6=$(wc -l < "$tmp/$cc.v6" | tr -d ' ')
total=$((rows4 + rows6))
if [ "$total" -lt "$MIN_ROWS" ]; then
echo "FALHOU $up: só $total gamas (mínimo $MIN_ROWS). Ficheiros anteriores mantidos." >&2
failed=1
continue
fi
# Ordenados no disco para um git diff entre duas actualizações se conseguir
# ler. O módulo volta a ordená-los ao carregar, porque esta ordem é textual
# e a procura precisa da ordem dos bytes empacotados.
sort -u "$tmp/$cc.v4" -o "$tmp/$cc.v4"
sort -u "$tmp/$cc.v6" -o "$tmp/$cc.v6"
mkdir -p "$DATA_DIR"
mv "$tmp/$cc.v4" "$DATA_DIR/relay-$cc.v4"
mv "$tmp/$cc.v6" "$DATA_DIR/relay-$cc.v6"
echo "OK $up: $rows4 gamas IPv4, $rows6 IPv6 → $DATA_DIR/relay-$cc.{v4,v6}"
done
exit "$failed"
-95
View File
@@ -1,95 +0,0 @@
#!/usr/bin/env bash
#
# Refresh the per-country CIDR lists that functions_geo.php reads.
#
# geo-update.sh pt # one or more ISO country codes
# geo-update.sh # falls back to $GEO_ALLOW_COUNTRIES
#
# Source is ipdeny.com, which publishes one aggregated zone file per country
# from the RIR allocation data. Allocations move slowly, so monthly is plenty:
#
# 17 4 3 * * /srv/sites/<site>/shared/platform-tools/bin/geo-update.sh pt
#
# The files are committed to the repo. This script only refreshes them, so a
# deploy on a box that cannot reach ipdeny still has working data — it is just
# as current as the last commit. That is the whole reason for committing them.
#
# Nothing is overwritten unless the download passed every check: a truncated
# file, an HTML error page served with 200, or a suspiciously short list would
# otherwise turn into "no visitor is in Portugal" — which, with GEO_FAIL_CLOSED,
# locks the country out of its own site.
set -euo pipefail
DATA_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/data/geo"
V4_URL="https://www.ipdeny.com/ipblocks/data/countries"
V6_URL="https://www.ipdeny.com/ipv6/ipaddresses/blocks"
# A real country zone has hundreds of ranges. Anything under this is a bad
# download, not a small country -- the smallest national allocations still run
# to dozens of blocks, and we would rather keep yesterday's file.
MIN_V4_LINES=20
MIN_V6_LINES=1
countries=("$@")
if [ ${#countries[@]} -eq 0 ]; then
read -r -a countries <<< "${GEO_ALLOW_COUNTRIES//[,;]/ }"
fi
if [ ${#countries[@]} -eq 0 ]; then
echo "uso: $(basename "$0") <cc> [cc...] (ou defina GEO_ALLOW_COUNTRIES)" >&2
exit 2
fi
mkdir -p "$DATA_DIR"
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
status=0
fetch_zone() {
local cc="$1" family="$2" url="$3" min="$4"
local out="$DATA_DIR/${cc}.${family}"
local work="$tmp/${cc}.${family}"
if ! curl -fsS --max-time 60 -o "$work" "$url"; then
echo " ${cc}.${family}: download falhou -- fica o ficheiro anterior" >&2
return 1
fi
# Every line must be a bare CIDR. An HTML error page fails here, which is
# the check that actually matters: those arrive with a 200.
if grep -qvE '^[0-9a-fA-F:.]+/[0-9]{1,3}$' "$work"; then
echo " ${cc}.${family}: conteúdo não é uma lista de CIDR -- ignorado" >&2
return 1
fi
local n
n=$(wc -l < "$work")
if [ "$n" -lt "$min" ]; then
echo " ${cc}.${family}: só $n linhas (mínimo $min) -- ignorado" >&2
return 1
fi
sort -V -o "$work" "$work"
if [ -f "$out" ] && cmp -s "$work" "$out"; then
echo " ${cc}.${family}: sem alterações ($n)"
return 0
fi
mv "$work" "$out"
echo " ${cc}.${family}: actualizado ($n ranges)"
}
for cc in "${countries[@]}"; do
cc="$(echo "$cc" | tr '[:upper:]' '[:lower:]')"
[[ "$cc" =~ ^[a-z]{2}$ ]] || { echo " '$cc' não é um código ISO -- ignorado" >&2; status=1; continue; }
echo "$cc:"
fetch_zone "$cc" v4 "$V4_URL/${cc}.zone" "$MIN_V4_LINES" || status=1
# IPv6 is published separately and some countries have no file at all; a
# missing v6 list is not an error, the v4 list still gates those visitors.
fetch_zone "$cc" v6 "$V6_URL/${cc}.zone" "$MIN_V6_LINES" || true
done
exit $status
@@ -1,29 +0,0 @@
<?php
// Envia ao hub os eventos de segurança em espera e aplica os banimentos da frota.
//
// */5 * * * * php /var/www/html/shared/platform-tools/bin/hub-security-sync.php
//
// Sem isto a sincronização acontece na mesma, no fim dos pedidos normais — mas
// um site sem visitas nunca chegaria a receber os banimentos dos outros, e uma
// caixa de saída cheia depois de o hub ter estado em baixo esvazia-se aqui de uma
// vez em vez de um lote por visita.
declare(strict_types=1);
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit;
}
// O mesmo arranque dos endpoints do hub: resolve os dois stacks da frota.
require dirname(__DIR__) . '/hub_bootstrap.php';
require_once dirname(__DIR__) . '/functions_security_events.php';
if (!$pdo instanceof PDO) {
echo json_encode(['ok' => true, 'skipped' => 'plataforma sem base de dados']), "\n";
exit(0);
}
$res = hub_security_sync($pdo, 50);
echo json_encode($res, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), "\n";
exit(!empty($res['ok']) ? 0 : 1);
-59
View File
@@ -1,59 +0,0 @@
<?php
// ─── Short secret-helper names, backed by the shared implementation ──────────
//
// fotografia and hub2 each grew their own config/functions_secrets.php with
// secret_encrypt()/secret_decrypt() in it. Same enc:v1: wire format, but not
// the same key: fotografia derived it domain-separated ('am-secret|' . key)
// while hub2 used a plain sha256 of APP_SECRET_KEY. A value written by one was
// unreadable by the other, and dance and silver had no implementation at all.
//
// There is now one implementation, in functions_settings.php. This file only
// re-exports it under the short names those call sites already use, so a site
// keeps calling secret_encrypt() and gets the estate-wide behaviour.
//
// A site includes this instead of defining its own:
// require_once __DIR__ . '/../shared/platform-tools/compat_secrets.php';
require_once __DIR__ . '/functions_settings.php';
if (!defined('SECRET_PREFIX')) {
define('SECRET_PREFIX', PLATFORM_SECRET_PREFIX);
}
if (!function_exists('secret_encrypt')) {
function secret_encrypt(string $plain): string {
return platform_secret_encrypt($plain);
}
}
if (!function_exists('secret_decrypt')) {
function secret_decrypt(?string $stored): string {
return platform_secret_decrypt($stored);
}
}
if (!function_exists('secret_is_encrypted')) {
function secret_is_encrypted(string $stored): bool {
return str_starts_with($stored, PLATFORM_SECRET_PREFIX);
}
}
if (!function_exists('secret_mask')) {
function secret_mask(string $plain, string $key = ''): string {
return platform_secret_mask($plain, $key);
}
}
// Both spellings existed: fotografia called it secrets_available(), hub2
// secrets_key_available(). Keep both so neither site's call sites need editing.
if (!function_exists('secrets_available')) {
function secrets_available(): bool {
return platform_settings_secret_available();
}
}
if (!function_exists('secrets_key_available')) {
function secrets_key_available(): bool {
return platform_settings_secret_available();
}
}
-100
View File
@@ -1,100 +0,0 @@
<?php
// ─── Países para a página de bloqueio por país ──────────────────────────────
//
// Nomes em português europeu, agrupados pela ordem por que alguém os procura
// num site desta frota — e não por ordem alfabética do código, que é a ordem
// de quem escreve a lista e não a de quem a lê.
//
// **Não é a ISO 3166 inteira**, de propósito. São 249 códigos, e uma página com
// 249 caixas é a mesma coisa que um campo de texto: ninguém encontra lá nada.
// Estão aqui os que um negócio português tem alguma hipótese de querer — a
// Europa toda, os países de língua portuguesa, os destinos de emigração e as
// meia dúzia de economias grandes. Para o resto, a página tem um campo de
// escape onde se escreve o código à mão, e um código que já esteja gravado
// aparece sempre, mesmo que não conste desta lista.
//
// Um país que falte aqui não é um país bloqueado: isto é a lista do que se
// consegue escolher com o rato, não a lista do que o módulo sabe tratar.
return [
'Mais usados' => [
'PT' => 'Portugal',
'ES' => 'Espanha',
'FR' => 'França',
'GB' => 'Reino Unido',
'CH' => 'Suíça',
'LU' => 'Luxemburgo',
'DE' => 'Alemanha',
'BR' => 'Brasil',
],
'Resto da Europa' => [
'AD' => 'Andorra',
'AL' => 'Albânia',
'AT' => 'Áustria',
'BA' => 'Bósnia e Herzegovina',
'BE' => 'Bélgica',
'BG' => 'Bulgária',
'BY' => 'Bielorrússia',
'CY' => 'Chipre',
'CZ' => 'Chéquia',
'DK' => 'Dinamarca',
'EE' => 'Estónia',
'FI' => 'Finlândia',
'GI' => 'Gibraltar',
'GR' => 'Grécia',
'HR' => 'Croácia',
'HU' => 'Hungria',
'IE' => 'Irlanda',
'IS' => 'Islândia',
'IT' => 'Itália',
'LI' => 'Listenstaine',
'LT' => 'Lituânia',
'LV' => 'Letónia',
'MC' => 'Mónaco',
'MD' => 'Moldávia',
'ME' => 'Montenegro',
'MK' => 'Macedónia do Norte',
'MT' => 'Malta',
'NL' => 'Países Baixos',
'NO' => 'Noruega',
'PL' => 'Polónia',
'RO' => 'Roménia',
'RS' => 'Sérvia',
'RU' => 'Rússia',
'SE' => 'Suécia',
'SI' => 'Eslovénia',
'SK' => 'Eslováquia',
'SM' => 'São Marinho',
'UA' => 'Ucrânia',
'VA' => 'Vaticano',
],
'Língua portuguesa' => [
'AO' => 'Angola',
'CV' => 'Cabo Verde',
'GQ' => 'Guiné Equatorial',
'GW' => 'Guiné-Bissau',
'MO' => 'Macau',
'MZ' => 'Moçambique',
'ST' => 'São Tomé e Príncipe',
'TL' => 'Timor-Leste',
],
'Fora da Europa' => [
'AE' => 'Emirados Árabes Unidos',
'AR' => 'Argentina',
'AU' => 'Austrália',
'CA' => 'Canadá',
'CL' => 'Chile',
'CN' => 'China',
'CO' => 'Colômbia',
'IL' => 'Israel',
'IN' => 'Índia',
'JP' => 'Japão',
'MA' => 'Marrocos',
'MX' => 'México',
'NZ' => 'Nova Zelândia',
'TR' => 'Turquia',
'US' => 'Estados Unidos',
'VE' => 'Venezuela',
'ZA' => 'África do Sul',
],
];
-449
View File
@@ -1,449 +0,0 @@
2.80.0.0/14
5.43.0.0/18
5.158.0.0/18
5.183.96.0/24
5.183.97.0/24
5.183.98.0/23
5.206.224.0/21
5.249.0.0/17
5.253.128.0/22
5.253.180.0/22
31.22.128.0/17
31.25.8.0/23
37.28.192.0/18
37.189.0.0/16
45.92.68.0/23
45.135.172.0/22
45.154.64.0/22
45.156.128.0/22
46.50.0.0/17
46.189.128.0/17
62.28.0.0/16
62.48.128.0/18
62.48.192.0/18
62.169.64.0/18
62.233.41.0/24
62.249.0.0/19
66.33.88.0/24
77.54.0.0/16
77.91.200.0/21
77.95.168.0/24
78.29.128.0/18
78.108.208.0/22
78.130.0.0/17
78.137.192.0/18
79.168.0.0/15
80.172.0.0/16
80.243.80.0/20
80.244.10.0/24
81.20.240.0/20
81.84.0.0/16
81.90.48.0/20
81.193.0.0/16
82.102.4.0/24
82.102.7.0/24
82.154.0.0/15
83.132.0.0/16
83.144.128.0/18
83.174.0.0/18
83.223.160.0/19
83.223.224.0/19
83.240.128.0/17
84.23.192.0/19
84.90.0.0/15
84.252.88.0/22
85.8.226.0/24
85.138.0.0/15
85.240.0.0/13
87.103.0.0/17
87.196.0.0/16
88.157.0.0/16
88.210.64.0/18
88.214.128.0/18
89.26.128.0/17
89.31.224.0/21
89.109.64.0/18
89.114.0.0/15
89.152.0.0/14
89.180.0.0/15
89.214.0.0/16
91.90.170.0/24
91.194.170.0/23
91.194.182.0/23
91.198.47.0/24
91.198.90.0/24
91.198.182.0/24
91.199.116.0/24
91.205.112.0/22
91.209.16.0/24
91.212.59.0/24
91.213.65.0/24
91.216.117.0/24
91.220.190.0/24
91.227.100.0/24
91.230.40.0/24
91.231.216.0/23
91.241.3.0/24
92.118.109.0/24
92.243.81.0/24
92.250.0.0/17
93.94.56.0/21
93.102.0.0/16
93.108.0.0/16
94.46.12.0/22
94.46.16.0/20
94.46.128.0/21
94.46.141.0/24
94.46.142.0/24
94.46.160.0/20
94.46.176.0/21
94.46.251.0/24
94.60.0.0/14
94.126.144.0/21
94.126.168.0/21
94.132.0.0/15
94.231.208.0/22
95.69.0.0/17
95.92.0.0/14
95.136.0.0/17
95.172.160.0/19
103.117.124.0/22
103.243.124.0/22
104.244.8.0/22
109.48.0.0/14
109.71.40.0/21
109.105.208.0/21
109.109.160.0/22
128.65.224.0/19
130.185.80.0/21
139.83.0.0/16
144.64.0.0/16
146.19.95.0/24
146.19.249.0/24
146.193.0.0/16
147.78.200.0/22
148.63.0.0/16
148.69.0.0/16
148.71.0.0/16
149.90.0.0/16
150.237.126.0/23
158.162.0.0/16
159.255.48.0/20
161.230.0.0/16
168.182.128.0/17
176.61.144.0/21
176.78.0.0/15
176.111.104.0/21
176.119.193.0/24
176.121.10.0/24
176.121.255.0/24
176.124.252.0/24
176.221.32.0/21
178.166.0.0/17
185.0.25.0/24
185.1.116.0/24
185.2.84.0/22
185.11.164.0/22
185.12.116.0/22
185.15.20.0/22
185.17.228.0/22
185.31.156.0/22
185.32.36.0/22
185.32.188.0/22
185.36.101.0/24
185.37.208.0/22
185.38.140.0/22
185.38.173.0/24
185.39.208.0/22
185.41.116.0/22
185.42.162.0/24
185.51.92.0/22
185.58.80.0/22
185.63.180.0/22
185.72.152.0/22
185.76.4.0/22
185.79.224.0/22
185.80.76.0/22
185.83.20.0/22
185.83.212.0/23
185.83.215.0/24
185.83.248.0/22
185.88.16.0/22
185.89.192.0/22
185.90.56.0/22
185.92.96.0/22
185.95.220.0/22
185.96.172.0/22
185.98.248.0/22
185.99.232.0/22
185.101.176.0/22
185.105.8.0/24
185.112.251.0/24
185.113.140.0/22
185.113.156.0/22
185.117.225.0/24
185.118.112.0/22
185.124.124.0/22
185.126.88.0/22
185.133.76.0/22
185.133.136.0/22
185.136.52.0/22
185.142.131.0/24
185.146.92.0/22
185.160.115.0/24
185.162.180.0/23
185.162.182.0/24
185.162.183.0/24
185.165.104.0/22
185.166.144.0/24
185.169.96.0/22
185.170.67.0/24
185.175.91.0/24
185.175.184.0/22
185.180.140.0/22
185.204.111.0/24
185.208.203.0/24
185.210.156.0/24
185.214.32.0/22
185.218.12.0/22
185.219.129.0/24
185.219.131.0/24
185.222.8.0/22
185.222.200.0/22
185.224.164.0/22
185.226.196.0/22
185.229.60.0/22
185.231.233.0/24
185.236.228.0/22
185.240.248.0/22
185.248.35.0/24
188.37.0.0/16
188.80.0.0/14
188.93.192.0/21
188.93.224.0/21
188.93.232.0/21
188.125.192.0/19
188.140.0.0/17
188.214.159.0/24
188.214.192.0/24
188.250.0.0/15
192.12.232.0/24
192.26.231.0/24
192.26.236.0/24
192.26.239.0/24
192.35.246.0/24
192.67.76.0/24
192.68.186.0/24
192.68.209.0/24
192.68.216.0/24
192.68.221.0/24
192.68.224.0/24
192.76.242.0/24
192.80.20.0/24
192.80.21.0/24
192.82.127.0/24
192.82.214.0/24
192.84.13.0/24
192.84.15.0/24
192.84.62.0/24
192.86.138.0/24
192.88.17.0/24
192.88.123.0/24
192.88.250.0/23
192.88.252.0/23
192.88.254.0/24
192.91.177.0/24
192.92.133.0/24
192.92.135.0/24
192.92.142.0/24
192.92.143.0/24
192.92.144.0/24
192.92.145.0/24
192.92.146.0/24
192.92.147.0/24
192.92.148.0/24
192.92.149.0/24
192.92.150.0/24
192.92.151.0/24
192.92.152.0/24
192.92.153.0/24
192.94.24.0/24
192.101.118.0/24
192.103.147.0/24
192.104.35.0/24
192.104.36.0/24
192.104.37.0/24
192.104.48.0/24
192.107.122.0/24
192.111.101.0/24
192.112.30.0/24
192.112.31.0/24
192.112.32.0/24
192.112.45.0/24
192.122.238.0/23
192.122.240.0/23
192.122.242.0/24
192.132.53.0/24
192.132.55.0/24
192.133.15.0/24
192.133.54.0/24
192.133.55.0/24
192.133.108.0/24
192.135.129.0/24
192.135.187.0/24
192.135.219.0/24
192.136.31.0/24
192.136.49.0/24
192.136.52.0/24
192.138.86.0/24
192.138.204.0/24
192.147.76.0/24
192.147.77.0/24
192.147.78.0/24
192.147.155.0/24
192.148.103.0/24
192.150.192.0/24
192.150.193.0/24
192.152.47.0/24
192.152.174.0/24
192.153.13.0/24
192.156.210.0/24
192.160.245.0/24
192.160.246.0/23
192.160.248.0/23
192.160.251.0/24
192.162.17.0/24
192.188.10.0/24
192.190.174.0/24
192.195.195.0/24
192.199.16.0/20
192.207.196.0/24
193.16.103.0/24
193.17.0.0/24
193.25.164.0/23
193.27.11.0/24
193.36.190.0/24
193.37.153.0/24
193.39.195.0/24
193.43.40.0/24
193.47.185.0/24
193.53.22.0/24
193.58.118.0/24
193.104.52.0/24
193.107.13.0/24
193.109.93.0/24
193.109.184.0/21
193.111.42.0/24
193.126.0.0/16
193.136.0.0/16
193.137.0.0/16
193.163.151.0/24
193.164.0.0/24
193.176.0.0/24
193.192.4.0/22
193.192.8.0/22
193.200.216.0/24
193.203.106.0/23
193.218.156.0/22
193.227.238.0/23
193.236.0.0/19
193.236.32.0/19
193.236.64.0/19
193.236.96.0/20
193.236.112.0/21
193.236.120.0/22
193.236.124.0/22
193.236.128.0/20
193.236.144.0/21
193.236.152.0/21
193.236.160.0/20
193.236.176.0/23
193.236.178.0/23
193.236.180.0/22
193.236.184.0/21
193.236.192.0/18
193.242.124.0/24
194.6.229.0/24
194.8.30.0/24
194.11.23.0/24
194.38.128.0/19
194.39.84.0/24
194.39.124.0/22
194.62.41.0/24
194.62.240.0/22
194.65.0.0/19
194.65.32.0/19
194.65.64.0/18
194.65.128.0/17
194.79.64.0/19
194.107.127.0/24
194.110.76.0/24
194.117.0.0/19
194.117.32.0/22
194.117.36.0/22
194.117.40.0/21
194.117.48.0/23
194.117.75.0/24
194.117.93.0/24
194.125.242.0/23
194.140.195.0/24
194.140.232.0/24
194.140.238.0/24
194.145.121.0/24
194.153.132.0/24
194.169.156.0/24
194.169.215.0/24
194.210.0.0/16
195.8.0.0/19
195.8.58.0/23
195.22.0.0/19
195.23.0.0/16
195.35.66.0/24
195.47.200.0/24
195.54.168.0/23
195.60.80.224/27
195.66.127.0/24
195.72.136.0/22
195.85.221.0/24
195.88.104.0/23
195.138.0.0/19
195.170.168.0/24
195.184.240.0/22
195.200.252.0/23
195.225.180.0/22
195.234.47.0/24
195.234.134.0/24
195.245.128.0/18
195.245.197.0/24
195.245.234.0/24
195.246.238.0/23
198.56.128.0/17
203.171.64.0/19
212.0.160.0/19
212.13.32.0/19
212.18.160.0/19
212.52.23.0/24
212.55.128.0/19
212.55.160.0/20
212.55.176.0/21
212.104.208.0/23
212.113.160.0/19
213.13.0.0/16
213.22.0.0/16
213.30.0.0/17
213.58.0.0/16
213.63.0.0/17
213.63.128.0/17
213.134.26.0/24
213.138.224.0/19
213.141.0.0/19
213.146.192.0/19
213.190.192.0/19
213.205.64.0/19
213.228.128.0/19
213.228.160.0/19
217.28.143.0/24
217.65.73.0/24
217.70.64.0/20
217.129.0.0/16
217.168.112.0/21
-120
View File
@@ -1,120 +0,0 @@
2a0a:5b00::/29
2a0a:36c0::/29
2a0a:9000::/29
2a0a:e3c0::/29
2a0b:94c0::/32
2a0b:8400::/29
2a0b:c1c0::/29
2a0b:e640::/29
2a0b:e740::/29
2a0c:3840::/29
2a0c:9740::/29
2a0c:b800::/29
2a0c:c2c0::/29
2a0d:3b80::/29
2a0d:82c0::/29
2a0d:94c0::/29
2a0d:2340::/29
2a0d:7200::/29
2a0d:e300::/29
2a0d:f600::/29
2a0e:1f40::/29
2a0e:5f00::/29
2a0e:48c0::/29
2a0e:bc00::/29
2a0e:ec00::/30
2a0e:ec04::/31
2a0f:7700::/29
2a0f:8900::/29
2a0f:c440::/29
2a0f:c700::/29
2a0f:d300::/29
2a00:1c60::/32
2a00:1650::/29
2a00:bc20::/32
2a00:c560::/29
2a00:d180::/29
2a00:fa00::/32
2a01:8::/29
2a01:10::/29
2a01:b020::/32
2a02:2a60::/29
2a02:8f0::/32
2a02:26c8::/32
2a02:27e0::/32
2a02:138::/32
2a02:740::/32
2a02:818::/29
2a02:870::/29
2a03:3f60::/32
2a03:8bc0::/32
2a03:9c80::/32
2a03:73c0::/32
2a03:84e0::/32
2a03:6420::/32
2a03:da0::/32
2a03::/32
2a04:6d80::/29
2a04:53c0::/29
2a04:8940::/29
2a04:b040::/29
2a04:e040::/29
2a05:9f00::/29
2a05:7540::/29
2a05:7800::/29
2a05:9640::/29
2a05:a040::/29
2a05:c800::/29
2a05:df40::/29
2a06:2000::/29
2a06:cdc0::/29
2a06:e640::/29
2a07:4f40::/29
2a09:5b00::/29
2a09:58c0::/32
2a09:2840::/29
2a09:5640::/32
2a09:be41::/32
2a09:be42::/31
2a09:be44::/30
2a10:1f80::/29
2a10:3c0::/29
2a10:7b40::/29
2a10:5000::/29
2a10:a4c0::/29
2a10:b280::/29
2a10:e440::/32
2a10:ee40::/29
2a11:9ec0::/29
2a11:6740::/29
2a11:bd80::/30
2a11:c00::/29
2a11:e740::/32
2a12:26c0::/29
2a12:49c0::/29
2a12:c1c0::/29
2a13:3fc0::/29
2a13:67c0::/29
2a13:580::/29
2a13:de80::/29
2a13:f2c0::/29
2a14:3840::/29
2a14:da80::/29
2001:4cc0::/32
2001:7f8:a::/48
2001:7f8:c7::/48
2001:7f8:f9::/48
2001:8a0::/29
2001:15d8::/29
2001:35c0::/29
2001:67c:1b40::/48
2001:67c:2d44::/48
2001:67c:13ec::/48
2001:67c:c0c::/48
2001:678:12f0::/48
2001:678:1094::/48
2001:690::/29
2001:818::/29
2001:1588::/29
2001:a40::/29
2001:b18::/32
@@ -1,76 +0,0 @@
104.28.136.119/32
104.28.136.120/32
104.28.138.54/32
104.28.138.55/32
104.28.139.148/32
104.28.139.149/32
104.28.143.15/32
104.28.146.45/32
104.28.146.46/32
104.28.146.47/32
104.28.146.48/32
104.28.146.49/32
104.28.34.141/32
104.28.34.142/32
104.28.34.143/32
104.28.34.144/32
104.28.42.78/32
104.28.42.79/32
104.28.53.18/32
104.28.53.19/32
104.28.86.43/32
104.28.86.44/32
104.28.87.85/32
104.28.87.86/32
104.28.87.87/32
104.28.87.88/32
104.28.88.103/32
104.28.88.104/32
104.28.88.105/32
104.28.88.106/32
104.28.96.131/32
104.28.96.132/32
140.248.40.172/31
140.248.40.174/31
140.248.40.176/31
140.248.40.178/31
146.75.168.156/31
146.75.168.158/31
146.75.168.160/31
146.75.168.162/31
146.75.182.52/31
146.75.182.54/31
146.75.182.56/31
146.75.182.58/31
146.75.185.156/31
146.75.185.158/31
146.75.185.160/31
146.75.185.162/31
146.75.210.52/31
146.75.210.54/31
146.75.210.56/31
146.75.210.58/31
172.225.158.178/31
172.225.158.180/30
172.225.158.216/31
172.225.159.64/28
172.225.161.144/28
172.225.162.192/28
172.225.163.62/31
172.225.163.80/30
172.225.163.84/31
172.225.178.176/28
172.225.178.192/28
172.225.179.50/31
172.225.179.52/30
172.225.179.64/31
172.225.192.208/28
172.225.193.58/31
172.225.193.60/30
172.225.193.64/31
172.225.212.0/28
172.225.212.16/30
172.225.212.20/31
172.225.212.22/31
172.225.212.240/28
172.225.243.64/28
-535
View File
@@ -1,535 +0,0 @@
2606:54c0:4fe8::/45
2606:54c0:4ff0::/45
2606:54c0:4ff8::/45
2606:54c0:5000::/45
2606:54c0:5008::/45
2606:54c0:5010::/45
2606:54c3:0:1152::/64
2606:54c3:0:132a::/64
2606:54c3:0:16a0::/64
2606:54c3:0:1992::/64
2606:54c3:0:857::/64
2606:54c3:0:ac1::/64
2a02:26f7:cb00:4000::/64
2a02:26f7:cb00:400::/56
2a02:26f7:cb00:500::/58
2a02:26f7:cb00:5280::/64
2a02:26f7:cb00:5281::/64
2a02:26f7:cb00:5282::/64
2a02:26f7:cb00:5283::/64
2a02:26f7:cb00:5284::/64
2a02:26f7:cb00:5285::/64
2a02:26f7:cb00:5286::/64
2a02:26f7:cb00:5287::/64
2a02:26f7:cb00:5288::/64
2a02:26f7:cb00:5289::/64
2a02:26f7:cb00:528a::/64
2a02:26f7:cb00:528b::/64
2a02:26f7:cb00:528c::/64
2a02:26f7:cb00:528d::/64
2a02:26f7:cb00:528e::/64
2a02:26f7:cb00:528f::/64
2a02:26f7:cb00:5290::/64
2a02:26f7:cb00:5291::/64
2a02:26f7:cb00:5292::/64
2a02:26f7:cb00:5293::/64
2a02:26f7:cb00:5294::/64
2a02:26f7:cb00:5295::/64
2a02:26f7:cb00:5296::/64
2a02:26f7:cb00:5297::/64
2a02:26f7:cb00:5298::/64
2a02:26f7:cb00:5299::/64
2a02:26f7:cb00:529a::/64
2a02:26f7:cb00:529b::/64
2a02:26f7:cb00:529c::/64
2a02:26f7:cb00:529d::/64
2a02:26f7:cb00:529e::/64
2a02:26f7:cb00:529f::/64
2a02:26f7:cb00:52a0::/64
2a02:26f7:cb00:52a1::/64
2a02:26f7:cb00:52a2::/64
2a02:26f7:cb00:52a3::/64
2a02:26f7:cb00:52a4::/64
2a02:26f7:cb00:52a5::/64
2a02:26f7:cb00:52a6::/64
2a02:26f7:cb00:52a7::/64
2a02:26f7:cb00:52a8::/64
2a02:26f7:cb00:52a9::/64
2a02:26f7:cb00:52aa::/64
2a02:26f7:cb00:52ab::/64
2a02:26f7:cb00:52ac::/64
2a02:26f7:cb00:52ad::/64
2a02:26f7:cb00:52ae::/64
2a02:26f7:cb00:52af::/64
2a02:26f7:cb00:52b0::/64
2a02:26f7:cb00:52b1::/64
2a02:26f7:cb00:52b2::/64
2a02:26f7:cb00:52b3::/64
2a02:26f7:cb00:52b4::/64
2a02:26f7:cb00:52b5::/64
2a02:26f7:cb00:52b6::/64
2a02:26f7:cb00:52b7::/64
2a02:26f7:cb00:52b8::/64
2a02:26f7:cb00:52b9::/64
2a02:26f7:cb00:52ba::/64
2a02:26f7:cb00:52bb::/64
2a02:26f7:cb00:52bc::/64
2a02:26f7:cb00:52bd::/64
2a02:26f7:cb00:52be::/64
2a02:26f7:cb00:52bf::/64
2a02:26f7:cb00:52c0::/64
2a02:26f7:cb00:52c1::/64
2a02:26f7:cb00:52c2::/64
2a02:26f7:cb00:52c3::/64
2a02:26f7:cb00:52c4::/64
2a02:26f7:cb00:52c5::/64
2a02:26f7:cb00:52c6::/64
2a02:26f7:cb00:52c7::/64
2a02:26f7:cb00:52c8::/64
2a02:26f7:cb00:52c9::/64
2a02:26f7:cb00:52ca::/64
2a02:26f7:cb00:52cb::/64
2a02:26f7:cb00:52cc::/64
2a02:26f7:cb00:52cd::/64
2a02:26f7:cb00:52ce::/64
2a02:26f7:cb00:52cf::/64
2a02:26f7:cb00:52d0::/64
2a02:26f7:cb00:52d1::/64
2a02:26f7:cb00:52d2::/64
2a02:26f7:cb00:52d3::/64
2a02:26f7:cb00:52d4::/64
2a02:26f7:cb00:52d5::/64
2a02:26f7:cb00:52d6::/64
2a02:26f7:cb00:52d7::/64
2a02:26f7:cb00:52d8::/64
2a02:26f7:cb00:52d9::/64
2a02:26f7:cb00::/54
2a02:26f7:cb01:4000::/64
2a02:26f7:cb02:4000::/64
2a02:26f7:cb02:400::/56
2a02:26f7:cb02:500::/58
2a02:26f7:cb02:5280::/64
2a02:26f7:cb02:5281::/64
2a02:26f7:cb02:5282::/64
2a02:26f7:cb02:5283::/64
2a02:26f7:cb02:5284::/64
2a02:26f7:cb02:5285::/64
2a02:26f7:cb02:5286::/64
2a02:26f7:cb02:5287::/64
2a02:26f7:cb02:5288::/64
2a02:26f7:cb02:5289::/64
2a02:26f7:cb02:528a::/64
2a02:26f7:cb02:528b::/64
2a02:26f7:cb02:528c::/64
2a02:26f7:cb02:528d::/64
2a02:26f7:cb02:528e::/64
2a02:26f7:cb02:528f::/64
2a02:26f7:cb02:5290::/64
2a02:26f7:cb02:5291::/64
2a02:26f7:cb02:5292::/64
2a02:26f7:cb02:5293::/64
2a02:26f7:cb02:5294::/64
2a02:26f7:cb02:5295::/64
2a02:26f7:cb02:5296::/64
2a02:26f7:cb02:5297::/64
2a02:26f7:cb02:5298::/64
2a02:26f7:cb02:5299::/64
2a02:26f7:cb02:529a::/64
2a02:26f7:cb02:529b::/64
2a02:26f7:cb02:529c::/64
2a02:26f7:cb02:529d::/64
2a02:26f7:cb02:529e::/64
2a02:26f7:cb02:529f::/64
2a02:26f7:cb02:52a0::/64
2a02:26f7:cb02:52a1::/64
2a02:26f7:cb02:52a2::/64
2a02:26f7:cb02:52a3::/64
2a02:26f7:cb02:52a4::/64
2a02:26f7:cb02:52a5::/64
2a02:26f7:cb02:52a6::/64
2a02:26f7:cb02:52a7::/64
2a02:26f7:cb02:52a8::/64
2a02:26f7:cb02:52a9::/64
2a02:26f7:cb02:52aa::/64
2a02:26f7:cb02:52ab::/64
2a02:26f7:cb02:52ac::/64
2a02:26f7:cb02:52ad::/64
2a02:26f7:cb02:52ae::/64
2a02:26f7:cb02:52af::/64
2a02:26f7:cb02:52b0::/64
2a02:26f7:cb02:52b1::/64
2a02:26f7:cb02:52b2::/64
2a02:26f7:cb02:52b3::/64
2a02:26f7:cb02:52b4::/64
2a02:26f7:cb02:52b5::/64
2a02:26f7:cb02:52b6::/64
2a02:26f7:cb02:52b7::/64
2a02:26f7:cb02:52b8::/64
2a02:26f7:cb02:52b9::/64
2a02:26f7:cb02:52ba::/64
2a02:26f7:cb02:52bb::/64
2a02:26f7:cb02:52bc::/64
2a02:26f7:cb02:52bd::/64
2a02:26f7:cb02:52be::/64
2a02:26f7:cb02:52bf::/64
2a02:26f7:cb02:52c0::/64
2a02:26f7:cb02:52c1::/64
2a02:26f7:cb02:52c2::/64
2a02:26f7:cb02:52c3::/64
2a02:26f7:cb02:52c4::/64
2a02:26f7:cb02:52c5::/64
2a02:26f7:cb02:52c6::/64
2a02:26f7:cb02:52c7::/64
2a02:26f7:cb02:52c8::/64
2a02:26f7:cb02:52c9::/64
2a02:26f7:cb02:52ca::/64
2a02:26f7:cb02:52cb::/64
2a02:26f7:cb02:52cc::/64
2a02:26f7:cb02:52cd::/64
2a02:26f7:cb02:52ce::/64
2a02:26f7:cb02:52cf::/64
2a02:26f7:cb02:52d0::/64
2a02:26f7:cb02:52d1::/64
2a02:26f7:cb02:52d2::/64
2a02:26f7:cb02:52d3::/64
2a02:26f7:cb02:52d4::/64
2a02:26f7:cb02:52d5::/64
2a02:26f7:cb02:52d6::/64
2a02:26f7:cb02:52d7::/64
2a02:26f7:cb02:52d8::/64
2a02:26f7:cb02:52d9::/64
2a02:26f7:cb02::/54
2a02:26f7:cb03:4000::/64
2a02:26f7:cb04:4000::/64
2a02:26f7:cb04:400::/56
2a02:26f7:cb04:500::/58
2a02:26f7:cb04:5280::/64
2a02:26f7:cb04:5281::/64
2a02:26f7:cb04:5282::/64
2a02:26f7:cb04:5283::/64
2a02:26f7:cb04:5284::/64
2a02:26f7:cb04:5285::/64
2a02:26f7:cb04:5286::/64
2a02:26f7:cb04:5287::/64
2a02:26f7:cb04:5288::/64
2a02:26f7:cb04:5289::/64
2a02:26f7:cb04:528a::/64
2a02:26f7:cb04:528b::/64
2a02:26f7:cb04:528c::/64
2a02:26f7:cb04:528d::/64
2a02:26f7:cb04:528e::/64
2a02:26f7:cb04:528f::/64
2a02:26f7:cb04:5290::/64
2a02:26f7:cb04:5291::/64
2a02:26f7:cb04:5292::/64
2a02:26f7:cb04:5293::/64
2a02:26f7:cb04:5294::/64
2a02:26f7:cb04:5295::/64
2a02:26f7:cb04:5296::/64
2a02:26f7:cb04:5297::/64
2a02:26f7:cb04:5298::/64
2a02:26f7:cb04:5299::/64
2a02:26f7:cb04:529a::/64
2a02:26f7:cb04:529b::/64
2a02:26f7:cb04:529c::/64
2a02:26f7:cb04:529d::/64
2a02:26f7:cb04:529e::/64
2a02:26f7:cb04:529f::/64
2a02:26f7:cb04:52a0::/64
2a02:26f7:cb04:52a1::/64
2a02:26f7:cb04:52a2::/64
2a02:26f7:cb04:52a3::/64
2a02:26f7:cb04:52a4::/64
2a02:26f7:cb04:52a5::/64
2a02:26f7:cb04:52a6::/64
2a02:26f7:cb04:52a7::/64
2a02:26f7:cb04:52a8::/64
2a02:26f7:cb04:52a9::/64
2a02:26f7:cb04:52aa::/64
2a02:26f7:cb04:52ab::/64
2a02:26f7:cb04:52ac::/64
2a02:26f7:cb04:52ad::/64
2a02:26f7:cb04:52ae::/64
2a02:26f7:cb04:52af::/64
2a02:26f7:cb04:52b0::/64
2a02:26f7:cb04:52b1::/64
2a02:26f7:cb04:52b2::/64
2a02:26f7:cb04:52b3::/64
2a02:26f7:cb04:52b4::/64
2a02:26f7:cb04:52b5::/64
2a02:26f7:cb04:52b6::/64
2a02:26f7:cb04:52b7::/64
2a02:26f7:cb04:52b8::/64
2a02:26f7:cb04:52b9::/64
2a02:26f7:cb04:52ba::/64
2a02:26f7:cb04:52bb::/64
2a02:26f7:cb04:52bc::/64
2a02:26f7:cb04:52bd::/64
2a02:26f7:cb04:52be::/64
2a02:26f7:cb04:52bf::/64
2a02:26f7:cb04:52c0::/64
2a02:26f7:cb04:52c1::/64
2a02:26f7:cb04:52c2::/64
2a02:26f7:cb04:52c3::/64
2a02:26f7:cb04:52c4::/64
2a02:26f7:cb04:52c5::/64
2a02:26f7:cb04:52c6::/64
2a02:26f7:cb04:52c7::/64
2a02:26f7:cb04:52c8::/64
2a02:26f7:cb04:52c9::/64
2a02:26f7:cb04:52ca::/64
2a02:26f7:cb04:52cb::/64
2a02:26f7:cb04:52cc::/64
2a02:26f7:cb04:52cd::/64
2a02:26f7:cb04:52ce::/64
2a02:26f7:cb04:52cf::/64
2a02:26f7:cb04:52d0::/64
2a02:26f7:cb04:52d1::/64
2a02:26f7:cb04:52d2::/64
2a02:26f7:cb04:52d3::/64
2a02:26f7:cb04:52d4::/64
2a02:26f7:cb04:52d5::/64
2a02:26f7:cb04:52d6::/64
2a02:26f7:cb04:52d7::/64
2a02:26f7:cb04:52d8::/64
2a02:26f7:cb04:52d9::/64
2a02:26f7:cb04::/54
2a02:26f7:cb05:4000::/64
2a02:26f7:cb0c:4000::/64
2a02:26f7:cb0c:400::/56
2a02:26f7:cb0c:500::/58
2a02:26f7:cb0c:5280::/64
2a02:26f7:cb0c:5281::/64
2a02:26f7:cb0c:5282::/64
2a02:26f7:cb0c:5283::/64
2a02:26f7:cb0c:5284::/64
2a02:26f7:cb0c:5285::/64
2a02:26f7:cb0c:5286::/64
2a02:26f7:cb0c:5287::/64
2a02:26f7:cb0c:5288::/64
2a02:26f7:cb0c:5289::/64
2a02:26f7:cb0c:528a::/64
2a02:26f7:cb0c:528b::/64
2a02:26f7:cb0c:528c::/64
2a02:26f7:cb0c:528d::/64
2a02:26f7:cb0c:528e::/64
2a02:26f7:cb0c:528f::/64
2a02:26f7:cb0c:5290::/64
2a02:26f7:cb0c:5291::/64
2a02:26f7:cb0c:5292::/64
2a02:26f7:cb0c:5293::/64
2a02:26f7:cb0c:5294::/64
2a02:26f7:cb0c:5295::/64
2a02:26f7:cb0c:5296::/64
2a02:26f7:cb0c:5297::/64
2a02:26f7:cb0c:5298::/64
2a02:26f7:cb0c:5299::/64
2a02:26f7:cb0c:529a::/64
2a02:26f7:cb0c:529b::/64
2a02:26f7:cb0c:529c::/64
2a02:26f7:cb0c:529d::/64
2a02:26f7:cb0c:529e::/64
2a02:26f7:cb0c:529f::/64
2a02:26f7:cb0c:52a0::/64
2a02:26f7:cb0c:52a1::/64
2a02:26f7:cb0c:52a2::/64
2a02:26f7:cb0c:52a3::/64
2a02:26f7:cb0c:52a4::/64
2a02:26f7:cb0c:52a5::/64
2a02:26f7:cb0c:52a6::/64
2a02:26f7:cb0c:52a7::/64
2a02:26f7:cb0c:52a8::/64
2a02:26f7:cb0c:52a9::/64
2a02:26f7:cb0c:52aa::/64
2a02:26f7:cb0c:52ab::/64
2a02:26f7:cb0c:52ac::/64
2a02:26f7:cb0c:52ad::/64
2a02:26f7:cb0c:52ae::/64
2a02:26f7:cb0c:52af::/64
2a02:26f7:cb0c:52b0::/64
2a02:26f7:cb0c:52b1::/64
2a02:26f7:cb0c:52b2::/64
2a02:26f7:cb0c:52b3::/64
2a02:26f7:cb0c:52b4::/64
2a02:26f7:cb0c:52b5::/64
2a02:26f7:cb0c:52b6::/64
2a02:26f7:cb0c:52b7::/64
2a02:26f7:cb0c:52b8::/64
2a02:26f7:cb0c:52b9::/64
2a02:26f7:cb0c:52ba::/64
2a02:26f7:cb0c:52bb::/64
2a02:26f7:cb0c:52bc::/64
2a02:26f7:cb0c:52bd::/64
2a02:26f7:cb0c:52be::/64
2a02:26f7:cb0c:52bf::/64
2a02:26f7:cb0c:52c0::/64
2a02:26f7:cb0c:52c1::/64
2a02:26f7:cb0c:52c2::/64
2a02:26f7:cb0c:52c3::/64
2a02:26f7:cb0c:52c4::/64
2a02:26f7:cb0c:52c5::/64
2a02:26f7:cb0c:52c6::/64
2a02:26f7:cb0c:52c7::/64
2a02:26f7:cb0c:52c8::/64
2a02:26f7:cb0c:52c9::/64
2a02:26f7:cb0c:52ca::/64
2a02:26f7:cb0c:52cb::/64
2a02:26f7:cb0c:52cc::/64
2a02:26f7:cb0c:52cd::/64
2a02:26f7:cb0c:52ce::/64
2a02:26f7:cb0c:52cf::/64
2a02:26f7:cb0c:52d0::/64
2a02:26f7:cb0c:52d1::/64
2a02:26f7:cb0c:52d2::/64
2a02:26f7:cb0c:52d3::/64
2a02:26f7:cb0c:52d4::/64
2a02:26f7:cb0c:52d5::/64
2a02:26f7:cb0c:52d6::/64
2a02:26f7:cb0c:52d7::/64
2a02:26f7:cb0c:52d8::/64
2a02:26f7:cb0c:52d9::/64
2a02:26f7:cb0c::/54
2a02:26f7:cb0d:4000::/64
2a02:26f7:cb10:4000::/64
2a02:26f7:cb10:400::/56
2a02:26f7:cb10:500::/58
2a02:26f7:cb10:5280::/64
2a02:26f7:cb10:5281::/64
2a02:26f7:cb10:5282::/64
2a02:26f7:cb10:5283::/64
2a02:26f7:cb10:5284::/64
2a02:26f7:cb10:5285::/64
2a02:26f7:cb10:5286::/64
2a02:26f7:cb10:5287::/64
2a02:26f7:cb10:5288::/64
2a02:26f7:cb10:5289::/64
2a02:26f7:cb10:528a::/64
2a02:26f7:cb10:528b::/64
2a02:26f7:cb10:528c::/64
2a02:26f7:cb10:528d::/64
2a02:26f7:cb10:528e::/64
2a02:26f7:cb10:528f::/64
2a02:26f7:cb10:5290::/64
2a02:26f7:cb10:5291::/64
2a02:26f7:cb10:5292::/64
2a02:26f7:cb10:5293::/64
2a02:26f7:cb10:5294::/64
2a02:26f7:cb10:5295::/64
2a02:26f7:cb10:5296::/64
2a02:26f7:cb10:5297::/64
2a02:26f7:cb10:5298::/64
2a02:26f7:cb10:5299::/64
2a02:26f7:cb10:529a::/64
2a02:26f7:cb10:529b::/64
2a02:26f7:cb10:529c::/64
2a02:26f7:cb10:529d::/64
2a02:26f7:cb10:529e::/64
2a02:26f7:cb10:529f::/64
2a02:26f7:cb10:52a0::/64
2a02:26f7:cb10:52a1::/64
2a02:26f7:cb10:52a2::/64
2a02:26f7:cb10:52a3::/64
2a02:26f7:cb10:52a4::/64
2a02:26f7:cb10:52a5::/64
2a02:26f7:cb10:52a6::/64
2a02:26f7:cb10:52a7::/64
2a02:26f7:cb10:52a8::/64
2a02:26f7:cb10:52a9::/64
2a02:26f7:cb10:52aa::/64
2a02:26f7:cb10:52ab::/64
2a02:26f7:cb10:52ac::/64
2a02:26f7:cb10:52ad::/64
2a02:26f7:cb10:52ae::/64
2a02:26f7:cb10:52af::/64
2a02:26f7:cb10:52b0::/64
2a02:26f7:cb10:52b1::/64
2a02:26f7:cb10:52b2::/64
2a02:26f7:cb10:52b3::/64
2a02:26f7:cb10:52b4::/64
2a02:26f7:cb10:52b5::/64
2a02:26f7:cb10:52b6::/64
2a02:26f7:cb10:52b7::/64
2a02:26f7:cb10:52b8::/64
2a02:26f7:cb10:52b9::/64
2a02:26f7:cb10:52ba::/64
2a02:26f7:cb10:52bb::/64
2a02:26f7:cb10:52bc::/64
2a02:26f7:cb10:52bd::/64
2a02:26f7:cb10:52be::/64
2a02:26f7:cb10:52bf::/64
2a02:26f7:cb10:52c0::/64
2a02:26f7:cb10:52c1::/64
2a02:26f7:cb10:52c2::/64
2a02:26f7:cb10:52c3::/64
2a02:26f7:cb10:52c4::/64
2a02:26f7:cb10:52c5::/64
2a02:26f7:cb10:52c6::/64
2a02:26f7:cb10:52c7::/64
2a02:26f7:cb10:52c8::/64
2a02:26f7:cb10:52c9::/64
2a02:26f7:cb10:52ca::/64
2a02:26f7:cb10:52cb::/64
2a02:26f7:cb10:52cc::/64
2a02:26f7:cb10:52cd::/64
2a02:26f7:cb10:52ce::/64
2a02:26f7:cb10:52cf::/64
2a02:26f7:cb10:52d0::/64
2a02:26f7:cb10:52d1::/64
2a02:26f7:cb10:52d2::/64
2a02:26f7:cb10:52d3::/64
2a02:26f7:cb10:52d4::/64
2a02:26f7:cb10:52d5::/64
2a02:26f7:cb10:52d6::/64
2a02:26f7:cb10:52d7::/64
2a02:26f7:cb10:52d8::/64
2a02:26f7:cb10:52d9::/64
2a02:26f7:cb10::/54
2a02:26f7:cb11:4000::/64
2a02:26f7:ed00:4000::/64
2a02:26f7:ed00:cbc0::/64
2a02:26f7:ed00:cbc1::/64
2a02:26f7:ed01:4000::/64
2a02:26f7:ed02:4000::/64
2a02:26f7:ed02:cbc0::/64
2a02:26f7:ed02:cbc1::/64
2a02:26f7:ed03:4000::/64
2a02:26f7:ed04:4000::/64
2a02:26f7:ed04:cbc0::/64
2a02:26f7:ed04:cbc1::/64
2a02:26f7:ed05:4000::/64
2a02:26f7:ed0c:4000::/64
2a02:26f7:ed0c:cbc0::/64
2a02:26f7:ed0c:cbc1::/64
2a02:26f7:ed0d:4000::/64
2a04:4e41:1303:5000::/52
2a04:4e41:1303:6000::/52
2a04:4e41:1303:7000::/52
2a04:4e41:1324::/48
2a04:4e41:1603:6000::/52
2a04:4e41:1603:7000::/52
2a04:4e41:1603:8000::/52
2a04:4e41:161a::/48
2a04:4e41:4100:8000::/52
2a04:4e41:4100:9000::/52
2a04:4e41:4100:a000::/52
2a04:4e41:4112::/48
2a04:4e41:7403:6000::/52
2a04:4e41:7403:7000::/52
2a04:4e41:7403:8000::/52
2a04:4e41:741a::/48
2a04:4e41:7800:8000::/52
2a04:4e41:7800:9000::/52
2a04:4e41:7800:a000::/52
2a04:4e41:7812::/48
2a09:bac2:4fe8::/45
2a09:bac2:4ff0::/45
2a09:bac2:4ff8::/45
2a09:bac2:5000::/45
2a09:bac2:5008::/45
2a09:bac2:5010::/45
2a09:bac3:4fe8::/45
2a09:bac3:4ff0::/45
2a09:bac3:4ff8::/45
2a09:bac3:5000::/45
2a09:bac3:5008::/45
2a09:bac3:5010::/45
@@ -1,25 +0,0 @@
<?php
// ─────────────────────────────────────────────────────────────────────────────
// A mesma bolha, em forma de ligação de rodapé — para o site público
//
// include __DIR__ . '/../shared/platform-tools/feedback_footer_link.php';
//
// Põe-se DENTRO do <footer>, na linha onde a ligação deve aparecer (e não antes
// do </body>, como a bolha do backoffice: aqui o sítio no HTML é o sítio no
// ecrã).
//
// Existe como ficheiro próprio, e não como uma variável a definir no site,
// porque a escolha entre bolha e ligação é uma decisão deste módulo e não de
// cada rodapé que o chama: no canto inferior direito do site público já mora o
// botão do WhatsApp, e duas bolhas empilhadas no mesmo canto são duas coisas a
// disputar o mesmo clique — sendo que a que interessa ao visitante é a outra.
// Assim o site continua a ser uma linha, igual à do backoffice, e o dia em que
// esta decisão mudar muda aqui e em toda a frota de uma vez.
//
// Tudo o resto — HTML, CSS, JavaScript, permissões, envio — está no
// feedback_widget.php e no functions_feedback.php.
// ─────────────────────────────────────────────────────────────────────────────
$GLOBALS['platform_feedback_mode'] = 'footer';
require __DIR__ . '/feedback_widget.php';
-479
View File
@@ -1,479 +0,0 @@
<?php
// ─────────────────────────────────────────────────────────────────────────────
// A bolha de feedback — incluída pelo rodapé de cada site
//
// include __DIR__ . '/../shared/platform-tools/feedback_widget.php';
//
// Põe-se no rodapé do layout (includes/footer.php, e o rodapé do site público
// onde exista), imediatamente antes do </body>. Aparece só a quem é
// administrador — para os restantes, e para visitantes, não sai daqui uma
// única linha de HTML.
//
// Não depende de nada: nem do Bootstrap (JS ou CSS), nem de ícones, nem de
// jQuery. É um ficheiro incluído no fim de páginas que já são pesadas, e a
// alternativa — assumir que o Bootstrap está carregado — partia-se no primeiro
// site da frota que o não tivesse. O ícone é SVG inline pela mesma razão.
//
// O z-index (1035) é deliberadamente ABAIXO do backdrop dos modais do
// Bootstrap (1050): com um modal aberto a bolha desaparece por baixo dele, em
// vez de ficar a flutuar por cima de um diálogo que pediu a atenção toda.
//
// ─── Dois modos ──────────────────────────────────────────────────────────────
//
// $GLOBALS['platform_feedback_mode'] = 'footer'; // antes do include
//
// 'bubble' (por omissão) é a bolha a flutuar no canto — para o backoffice, onde
// o canto está livre e quem trabalha quer o atalho sempre à vista.
//
// 'footer' é uma ligação dentro do rodapé, sem nada a flutuar — para o site
// público, onde o canto inferior direito JÁ ESTÁ OCUPADO: o dance tem lá o
// botão do WhatsApp (e uma barra fixa em telemóvel). Duas bolhas empilhadas no
// mesmo canto são duas coisas a disputar o mesmo clique, e a que interessa ao
// visitante é a outra. O painel continua a abrir fixo no canto, por cima de
// tudo isso — está aberto por um instante e tem botão de fechar.
//
// Ver functions_feedback.php para o que é enviado, e api/feedback.php para
// quem o recebe.
// ─────────────────────────────────────────────────────────────────────────────
require_once __DIR__ . '/functions_feedback.php';
require_once __DIR__ . '/functions_csrf.php';
(static function (): void {
// Uma vez por resposta. O rodapé do backoffice e o do site público podem
// acabar ambos incluídos na mesma página, e dois formulários com os mesmos
// ids não é meio widget a mais: é um widget partido.
if (!empty($GLOBALS['__platform_feedback_rendered'])) {
return;
}
$user = platform_feedback_user();
if (!$user || !platform_feedback_is_admin($user)) {
return;
}
$ligado = hub_ticket_ready();
// Sem emparelhamento não há para onde enviar. Ao administrador não se
// mostra nada — não é ele que resolve isto, e um botão que falha é pior do
// que botão nenhum. Ao sysadmin mostra-se, porque é ele que o liga: um
// canal de relatos que desaparece em silêncio quando a chave roda perde
// exactamente os relatos que mais interessavam.
if (!$ligado && !platform_feedback_is_sysadmin($user)) {
return;
}
$GLOBALS['__platform_feedback_rendered'] = true;
$h = static fn(string $s): string => htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
$cats = platform_feedback_categories();
// Caminho absoluto, e não base_url(): o site público da fotografia não
// carrega o config/functions.php, e ali base_url() não existe (ver
// ASSETS.md). O docroot é a raiz do domínio em toda a frota.
$endpoint = '/api/feedback.php';
// A página onde o sysadmin liga isto. Cada site tem a sua árvore de vistas,
// daí poder ser sobreposta pelo layout antes do include.
// Num site com router (platform_connect(['router' => true])) é o endereço
// dele; o stub antigo só reencaminha.
$hubPage = (string) ($GLOBALS['platform_feedback_hub_url']
?? (function_exists('platform_router_enabled') && platform_router_enabled()
? platform_route_url('hub_integration')
: '/views/sysadmin/hub_integration.php'));
$inline = ($GLOBALS['platform_feedback_mode'] ?? 'bubble') === 'footer';
?>
<div class="ptfb<?= $inline ? ' ptfb--inline' : '' ?>" id="ptfb" data-endpoint="<?= $h($endpoint) ?>">
<div class="ptfb-panel" id="ptfb-panel" role="dialog" aria-labelledby="ptfb-title" hidden>
<div class="ptfb-head">
<div>
<p class="ptfb-title" id="ptfb-title">Reportar à andmore</p>
<p class="ptfb-sub">Alguma coisa errada na plataforma? Conta aqui.</p>
</div>
<button type="button" class="ptfb-x" id="ptfb-close" aria-label="Fechar">&times;</button>
</div>
<?php if (!$ligado): ?>
<div class="ptfb-body">
<p class="ptfb-warn">
Esta plataforma não está ligada ao balcão de apoio — nada seria enviado.
Só tu vês esta bolha.
</p>
<p class="ptfb-warn-more">
Liga-a em <a href="<?= $h($hubPage) ?>">Integração com o hub</a>: falta o
endereço do hub, a chave de emparelhamento, ou ambos.
</p>
</div>
<?php else: ?>
<form class="ptfb-body" id="ptfb-form" novalidate>
<input type="hidden" name="_pt_csrf" value="<?= $h(platform_csrf_token()) ?>">
<input type="hidden" name="page_url" id="ptfb-page">
<label class="ptfb-label" for="ptfb-cat">Tipo</label>
<select class="ptfb-input" name="category" id="ptfb-cat">
<?php foreach ($cats as $key => $label): ?>
<option value="<?= $h((string) $key) ?>"><?= $h((string) $label) ?></option>
<?php endforeach; ?>
</select>
<label class="ptfb-label" for="ptfb-subject">Assunto</label>
<input type="text" class="ptfb-input" name="title" id="ptfb-subject"
maxlength="150" placeholder="Numa linha, o que se passa">
<label class="ptfb-label" for="ptfb-desc">O que aconteceu</label>
<textarea class="ptfb-input ptfb-area" name="description" id="ptfb-desc" rows="5"
maxlength="5000"
placeholder="O que estavas a fazer, o que esperavas, e o que aconteceu."></textarea>
<label class="ptfb-check">
<input type="checkbox" name="blocking" value="1" id="ptfb-block">
<span>Está a impedir-me de trabalhar</span>
</label>
<p class="ptfb-note">
Vai junto o contexto: a página onde estás, a versão da plataforma, a tua
conta e o browser. Não precisas de escrever nada disso.
</p>
<button type="submit" class="ptfb-send" id="ptfb-send">Enviar</button>
<p class="ptfb-msg" id="ptfb-msg" role="status" hidden></p>
</form>
<?php endif; ?>
</div>
<?php if ($inline): ?>
<button type="button" class="ptfb-linkbtn" id="ptfb-open"
aria-expanded="false" aria-controls="ptfb-panel">
<svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true" focusable="false">
<path fill="currentColor" d="M20 2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h4v4l5-4h7a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2z"/>
</svg>
Reportar um problema
</button>
<?php else: ?>
<button type="button" class="ptfb-bubble" id="ptfb-open"
aria-expanded="false" aria-controls="ptfb-panel"
title="Reportar à andmore">
<svg viewBox="0 0 24 24" width="26" height="26" aria-hidden="true" focusable="false">
<path fill="currentColor" d="M20 2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h4v4l5-4h7a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2z"/>
</svg>
<span class="ptfb-sr">Reportar à andmore</span>
</button>
<?php endif; ?>
</div>
<style>
.ptfb {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 1035; /* ver a nota no topo do ficheiro */
display: flex;
flex-direction: column;
align-items: flex-end;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
line-height: 1.45;
}
.ptfb-sr {
position: absolute; width: 1px; height: 1px;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap;
}
.ptfb-bubble {
display: flex; align-items: center; justify-content: center;
width: 52px; height: 52px;
border: none;
border-radius: 50%;
background: #1f2937;
color: #fff;
box-shadow: 0 6px 20px rgba(0, 0, 0, .28);
cursor: pointer;
transition: transform .15s ease, background-color .15s ease;
}
.ptfb-bubble:hover { background: #111827; transform: translateY(-2px); }
.ptfb-bubble:focus-visible { outline: 3px solid #60a5fa; outline-offset: 2px; }
.ptfb.is-open .ptfb-bubble { background: #111827; }
.ptfb-panel {
width: 340px;
max-width: calc(100vw - 36px);
/* Num portátil com o browser a meia altura o painel passava do ecrã e o
botão de enviar ficava fora de alcance. */
max-height: calc(100vh - 36px);
overflow-y: auto;
margin-bottom: 12px;
background: #fff;
color: #1f2937;
border: 1px solid rgba(0, 0, 0, .12);
border-radius: 14px;
box-shadow: 0 18px 48px rgba(0, 0, 0, .28);
overflow: hidden;
text-align: left;
}
.ptfb-panel[hidden] { display: none; }
.ptfb-head {
display: flex; align-items: flex-start; gap: .5rem;
padding: .85rem 1rem;
background: #1f2937;
color: #fff;
}
.ptfb-title { margin: 0; font-size: .95rem; font-weight: 600; }
.ptfb-sub { margin: .1rem 0 0; font-size: .78rem; opacity: .75; }
.ptfb-x {
margin-left: auto;
background: none; border: none; color: #fff;
font-size: 1.4rem; line-height: 1;
padding: 0 .1rem; cursor: pointer; opacity: .8;
}
.ptfb-x:hover { opacity: 1; }
.ptfb-body { padding: .9rem 1rem 1rem; }
.ptfb-label {
display: block;
margin: .6rem 0 .25rem;
font-size: .72rem; font-weight: 600;
letter-spacing: .04em; text-transform: uppercase;
color: #6b7280;
}
.ptfb-label:first-of-type { margin-top: 0; }
.ptfb-input {
display: block; width: 100%;
box-sizing: border-box;
padding: .5rem .6rem;
font: inherit; font-size: .88rem;
color: #1f2937;
background: #fff;
border: 1px solid #d1d5db;
border-radius: 8px;
}
.ptfb-input:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, .18);
}
.ptfb-area { resize: vertical; min-height: 92px; }
.ptfb-check {
display: flex; align-items: center; gap: .45rem;
margin: .7rem 0 0;
font-size: .83rem;
cursor: pointer;
}
.ptfb-note {
margin: .7rem 0 .85rem;
font-size: .74rem;
color: #6b7280;
}
.ptfb-send {
width: 100%;
padding: .6rem 1rem;
font: inherit; font-size: .88rem; font-weight: 600;
color: #fff; background: #2563eb;
border: none; border-radius: 8px;
cursor: pointer;
}
.ptfb-send:hover:not(:disabled) { background: #1d4ed8; }
.ptfb-send:disabled { opacity: .6; cursor: default; }
.ptfb-msg {
margin: .7rem 0 0;
padding: .55rem .7rem;
font-size: .82rem;
border-radius: 8px;
}
.ptfb-msg[hidden] { display: none; }
.ptfb-msg.is-ok { background: #ecfdf5; color: #065f46; border: 1px solid #a7f3d0; }
.ptfb-msg.is-bad { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }
.ptfb-msg a { color: inherit; }
.ptfb-warn { margin: 0; font-size: .85rem; }
.ptfb-warn-more { margin: .6rem 0 0; font-size: .8rem; color: #6b7280; }
@media (max-width: 480px) {
.ptfb { right: 12px; bottom: 12px; left: 12px; align-items: stretch; }
.ptfb-panel { width: 100%; max-width: none; }
.ptfb-bubble { margin-left: auto; }
}
@media (prefers-reduced-motion: reduce) {
.ptfb-bubble { transition: none; }
}
/* ─── Modo rodapé ───────────────────────────────────────────────────────────
Nada a flutuar: fica a ligação na linha onde o include foi posto, a herdar
a cor e o tipo de letra do rodapé que a recebe — o da fotografia é escuro, o
do dance é claro, e um botão com cores próprias destoava num dos dois. O
painel continua fixo no canto, e vem depois destas regras no ficheiro
precisamente para ganhar às de cima. */
.ptfb--inline {
position: static;
display: inline-flex;
align-items: center;
z-index: auto;
font-family: inherit;
}
.ptfb-linkbtn {
display: inline-flex; align-items: center; gap: .4rem;
margin: 0; padding: 0;
border: none; background: none;
color: inherit; font: inherit; font-size: .85em;
line-height: inherit;
opacity: .75;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 3px;
}
.ptfb-linkbtn:hover { opacity: 1; }
.ptfb-linkbtn:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; }
.ptfb-linkbtn svg { flex: 0 0 auto; }
/* Cor medida, não herdada — ver a nota no JS. Só entram quando o script
conseguiu medir o fundo; se não conseguiu, fica o `color: inherit` de cima. */
.ptfb-on-dark .ptfb-linkbtn { color: rgba(255, 255, 255, .62); opacity: 1; }
.ptfb-on-dark .ptfb-linkbtn:hover { color: #fff; }
.ptfb-on-light .ptfb-linkbtn { color: rgba(0, 0, 0, .66); opacity: 1; }
.ptfb-on-light .ptfb-linkbtn:hover { color: #000; }
.ptfb--inline .ptfb-panel {
position: fixed;
right: 18px;
bottom: 18px;
margin-bottom: 0;
z-index: 1035;
/* O `font-family: inherit` acima é para a LIGAÇÃO se fundir no rodapé; o
painel é um formulário e volta ao tipo de letra do sistema. Sem isto, o
rodapé da fotografia (Georgia) punha um formulário inteiro em serifada. */
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
line-height: 1.45;
}
@media (max-width: 480px) {
.ptfb--inline { left: auto; right: auto; bottom: auto; }
.ptfb--inline .ptfb-panel { left: 12px; right: 12px; bottom: 12px; width: auto; }
}
</style>
<script>
(function () {
var root = document.getElementById('ptfb');
if (!root) return;
var openBtn = document.getElementById('ptfb-open');
var panel = document.getElementById('ptfb-panel');
var closeBtn = document.getElementById('ptfb-close');
var form = document.getElementById('ptfb-form');
/* ─── A cor da ligação do rodapé é medida, não herdada ──────────────────
`color: inherit` assume que o rodapé define uma cor legível para si
próprio. Há rodapés que não definem nenhuma e colorem os filhos um a um:
o do dance tem `background: var(--text)` (#1a1a1a) e cor herdada #1a1a1a,
o que dava uma ligação invisível — contraste 1:1.
Em vez de adivinhar a paleta de cada site da frota, procura-se o primeiro
antepassado com fundo opaco e escolhe-se claro ou escuro pela luminância.
Não se encontrando nenhum, não se toca em nada e fica o herdado. */
function tuneContrast() {
if (!root.classList.contains('ptfb--inline')) return;
var el = root, rgb = null;
while (el && el !== document.documentElement && !rgb) {
var m = getComputedStyle(el).backgroundColor.match(/rgba?\(([\d.,\s]+)\)/);
if (m) {
var p = m[1].split(',').map(parseFloat);
if (p.length < 4 || p[3] > 0.5) rgb = p;
}
el = el.parentElement;
}
if (!rgb) return;
// Luminância aproximada (sem correcção gama): chega de sobra para decidir
// entre texto claro e texto escuro.
var lum = (0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]) / 255;
root.classList.remove('ptfb-on-dark', 'ptfb-on-light');
root.classList.add(lum < 0.5 ? 'ptfb-on-dark' : 'ptfb-on-light');
}
tuneContrast();
// Outra vez com tudo carregado: um site que sirva a folha de estilos no fim
// do body ainda não a tinha aplicada na primeira medição.
window.addEventListener('load', tuneContrast);
function open() {
panel.hidden = false;
root.classList.add('is-open');
openBtn.setAttribute('aria-expanded', 'true');
var first = panel.querySelector('select, input[type=text], a');
if (first) first.focus();
}
function close() {
panel.hidden = true;
root.classList.remove('is-open');
openBtn.setAttribute('aria-expanded', 'false');
openBtn.focus();
}
openBtn.addEventListener('click', function () { panel.hidden ? open() : close(); });
closeBtn.addEventListener('click', close);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && !panel.hidden) close();
});
if (!form) return; // painel de aviso: não há formulário para submeter
var msg = document.getElementById('ptfb-msg');
var send = document.getElementById('ptfb-send');
// O URL da página é lido aqui e não no servidor: o rodapé pode ser incluído
// por uma página que não é a que o browser mostra na barra de endereço.
document.getElementById('ptfb-page').value = window.location.href;
// Texto por textContent e a ligação como elemento: a referência e o URL vêm
// de outro serviço, e nada do que vem de fora entra por innerHTML.
function say(text, ok, link) {
msg.className = 'ptfb-msg ' + (ok ? 'is-ok' : 'is-bad');
msg.textContent = text;
if (link && /^https?:\/\//i.test(link)) {
var a = document.createElement('a');
a.href = link;
a.target = '_blank';
a.rel = 'noopener';
a.textContent = 'Acompanhar';
msg.appendChild(document.createTextNode(' '));
msg.appendChild(a);
}
msg.hidden = false;
}
form.addEventListener('submit', function (e) {
e.preventDefault();
send.disabled = true;
send.textContent = 'A enviar…';
msg.hidden = true;
fetch(root.dataset.endpoint, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Accept': 'application/json' },
body: new FormData(form)
})
.then(function (r) {
return r.json().catch(function () {
return { ok: false, error: 'O servidor respondeu de forma inesperada (HTTP ' + r.status + ').' };
});
})
.then(function (res) {
if (!res.ok) {
say(res.error || 'Não foi possível enviar.', false);
return;
}
say('Enviado. Ficou como ' + (res.ref || '—') + '.', true, res.track_url);
document.getElementById('ptfb-subject').value = '';
document.getElementById('ptfb-desc').value = '';
document.getElementById('ptfb-block').checked = false;
})
.catch(function () {
say('Sem ligação ao servidor. Tenta outra vez daqui a bocado.', false);
})
.finally(function () {
send.disabled = false;
send.textContent = 'Enviar';
});
});
})();
</script>
<?php
})();
-226
View File
@@ -1,226 +0,0 @@
<?php
// ─── Remote-ops agent (shared) ───────────────────────────────────────────────
//
// Server-to-server endpoint the id.andmore.pt fleet control plane drives to read
// status and (later phases) pull git / apply migrations / take backups remotely.
// No session, no CSRF — authenticated purely by HMAC over the shared secret.
//
// The hub signs each request with this platform's HUB_OPS_SECRET (.env; generated
// on id.andmore.pt → Plataformas → <app> → "Gerar segredo de operações").
// Canonical signing string binds the FULL request (this channel mutates, unlike
// the read-only content pull which signs only date.path):
//
// signing = method "\n" path "\n" sha256(body) "\n" date
// X-Hub-Date : ISO-8601 UTC timestamp (±5 min tolerance)
// X-Hub-Token : hash_hmac('sha256', signing, HUB_OPS_SECRET)
//
// Everything here is best-effort and guarded with function_exists(), so a host
// that wires only some of the platform-tools helpers (e.g. surf, no backups)
// still answers a partial, well-formed status.
function agent_json(array $data, int $code = 200): void {
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
/**
* Verify the request HMAC. On success returns the raw request body (already read
* from php://input, so callers must use the returned string, not re-read it).
* On any failure emits 401/503 and exits.
*/
function agent_verify_or_die(string $secret): string {
if ($secret === '') {
agent_json(['error' => 'HUB_OPS_SECRET não configurado nesta plataforma.'], 503);
}
$method = (string) ($_SERVER['REQUEST_METHOD'] ?? 'GET');
$date = (string) ($_SERVER['HTTP_X_HUB_DATE'] ?? '');
$token = (string) ($_SERVER['HTTP_X_HUB_TOKEN'] ?? '');
$path = (string) (parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?? '');
$body = (string) file_get_contents('php://input');
$ts = $date !== '' ? strtotime($date) : false;
if ($token === '' || $ts === false || abs(time() - $ts) > 300) {
agent_json(['error' => 'Assinatura ausente ou expirada.'], 401);
}
$signing = $method . "\n" . $path . "\n" . hash('sha256', $body) . "\n" . $date;
$expected = hash_hmac('sha256', $signing, $secret);
if (!hash_equals($expected, $token)) {
agent_json(['error' => 'Assinatura inválida.'], 401);
}
return $body;
}
/**
* Git facts read OFFLINE (no network) — `behind` reflects the last-known remote
* tracking ref. Refresh it with the git.fetch action before trusting it.
* Returns null when git is unavailable.
*/
function agent_git_info(string $repo_root): ?array {
if (!function_exists('git_run')) return null;
$branch = git_run(['rev-parse', '--abbrev-ref', 'HEAD'], $repo_root);
$head = git_run(['log', '-1', '--format=%h|%cd|%s', '--date=short'], $repo_root);
$dirty = git_run(['status', '--porcelain'], $repo_root);
$count = git_run(['rev-list', '--left-right', '--count', 'HEAD...@{u}'], $repo_root);
$ahead = $behind = null;
if ($count['ok'] && preg_match('/^(\d+)\s+(\d+)/', $count['output'], $m)) {
$ahead = (int) $m[1]; // local commits not pushed
$behind = (int) $m[2]; // remote commits not pulled → "atrasado"
}
return [
'branch' => $branch['ok'] ? trim($branch['output']) : null,
'head' => $head['ok'] ? trim($head['output']) : null,
'dirty' => $dirty['ok'] ? (trim($dirty['output']) !== '') : null,
'ahead' => $ahead,
'behind' => $behind,
];
}
/**
* Assemble the platform health snapshot. Each section is independently guarded:
* a missing helper or a failing query yields a null field, never a blank reply.
*/
function agent_status(PDO $pdo, string $repo_root): array {
$out = ['ok' => true, 'checked_at' => gmdate('c'), 'php_version' => PHP_VERSION];
$backup_dir = function_exists('backup_dir') ? backup_dir() : ($repo_root . '/backups');
$free = @disk_free_space($backup_dir);
$out['disk_free_bytes'] = ($free !== false) ? (int) $free : null;
$git = agent_git_info($repo_root);
if ($git !== null) $out['git'] = $git;
// ── Migrations pending ───────────────────────────────────────────────────
if (function_exists('migrations_status')) {
try {
$pending = [];
foreach (migrations_status($pdo) as $r) {
if (($r['applied_at'] ?? null) === null) {
$pending[] = $r['filename'];
}
}
$out['migrations_pending'] = count($pending);
$out['migrations_pending_files'] = $pending;
} catch (Throwable $e) {
$out['migrations_pending'] = null;
}
}
// ── Schema drift (committed schema.sql vs a fresh build) ─────────────────
if (function_exists('schema_build') && function_exists('schema_dump_path')) {
try {
$live = schema_build($pdo);
$file = @file_get_contents(schema_dump_path());
$out['schema_drift'] = ($file === false) ? null : (trim($live) !== trim($file));
} catch (Throwable $e) {
$out['schema_drift'] = null;
}
}
// ── Last backup ──────────────────────────────────────────────────────────
if (function_exists('backup_list')) {
try {
$list = backup_list();
$out['backup_count'] = count($list);
$out['last_backup'] = $list ? [
'filename' => $list[0]['filename'],
'type' => $list[0]['type'],
'created_at' => gmdate('c', $list[0]['created_at']),
'size_human' => $list[0]['size_human'],
] : null;
} catch (Throwable $e) {
$out['last_backup'] = null;
}
}
return $out;
}
/**
* Verify + dispatch a remote-ops request. The calling stub bootstraps the host
* its own way (config/ stack vs surf's src/ stack), opens $pdo, sets $repo_root
* and reads HUB_OPS_SECRET, then hands off here. Keeping the dispatch out of
* api/agent.php lets a non-config/ host (surf) reuse the exact same action set.
*
* Loads the sibling helpers it needs (git/migrations/schema/backup) from the
* submodule root, so the stub doesn't have to. Never returns — every branch
* ends in agent_json(), which exits.
*/
function agent_dispatch(PDO $pdo, string $repo_root, string $secret): void {
require_once __DIR__ . '/functions_git.php';
require_once __DIR__ . '/functions_migrations.php';
require_once __DIR__ . '/functions_schema.php';
if (is_file(__DIR__ . '/functions_backup.php')) {
require_once __DIR__ . '/functions_backup.php';
}
$body = agent_verify_or_die($secret);
@session_write_close(); // server-to-server; release the session lock past auth
$req = json_decode($body, true);
$action = is_array($req) ? (string) ($req['action'] ?? '') : (string) ($_GET['action'] ?? '');
switch ($action) {
case 'status':
agent_json(agent_status($pdo, $repo_root));
// no break — agent_json() exits
case 'git.fetch':
$res = git_run(['fetch', '--all', '--prune'], $repo_root);
agent_json([
'ok' => $res['ok'],
'output' => $res['output'] !== '' ? $res['output'] : '(sem alterações remotas)',
'git' => agent_git_info($repo_root),
]);
case 'git.pull':
$pull = git_run(['pull', '--ff-only'], $repo_root);
$ok = $pull['ok'];
$output = $pull['output'] !== '' ? $pull['output'] : '(sem saída)';
if ($ok) {
$sub = git_run(['submodule', 'update', '--init', '--recursive'], $repo_root);
if (trim($sub['output']) !== '') $output .= "\n\n[submodule update]\n" . $sub['output'];
if (function_exists('opcache_reset')) @opcache_reset();
if (function_exists('settings_cache_clear')) settings_cache_clear();
$output .= "\n\n(OPcache + settings cache invalidados)";
}
agent_json(['ok' => $ok, 'output' => $output, 'git' => agent_git_info($repo_root)]);
case 'migrations.list':
agent_json(['ok' => true, 'migrations' => migrations_status($pdo)]);
case 'migrations.apply':
// migrations_apply_file() enforces the allowlist: only an existing
// database/migration_*.sql in this repo (no raw SQL — locked decision).
$file = is_array($req) ? (string) ($req['filename'] ?? '') : '';
$res = migrations_apply_file($pdo, $file, null);
if (!empty($res['ok']) && function_exists('settings_cache_clear')) settings_cache_clear();
agent_json(['ok' => !empty($res['ok'])] + $res);
case 'migrations.apply_pending':
$applied = [];
$error = null;
foreach (migrations_status($pdo) as $row) {
if ($row['applied_at']) continue;
$r = migrations_apply_file($pdo, $row['filename'], null);
if (!empty($r['ok'])) {
$applied[] = $row['filename'];
} else {
$error = $row['filename'] . ': ' . ($r['error'] ?? 'erro');
break; // stop on first failure — manual intervention needed
}
}
if ($applied && function_exists('settings_cache_clear')) settings_cache_clear();
agent_json(['ok' => $error === null, 'applied' => $applied, 'error' => $error]);
// Fase 3: 'backup.trigger', 'backup.list', 'backup.fetch' (signed download URL)
default:
agent_json(['error' => 'Ação desconhecida: ' . $action], 400);
}
}
@@ -1,274 +0,0 @@
<?php
require_once __DIR__ . '/functions_settings.php';
// ─── Anti-spam de formulários públicos (partilhado) ─────────────────────────
//
// Sem captcha e sem serviço externo — o objectivo é travar bots automáticos,
// não pessoas.
//
// Nasceu na fotografia (pedidos de orçamento), foi refinado no hub (tickets) e
// vive agora aqui, porque a terceira cópia seria a que ninguém se lembraria de
// corrigir. O que era específico do hub — o limite de 24 h contado sobre a
// tabela `tickets` — passou a receber a tabela e as colunas como argumentos.
//
// O que NÃO vem daqui: a escalada de bloqueio de IP, que vive no
// functions_ip_ban.php. São coisas diferentes e de propósito: isto decide se
// esta submissão passa, aquilo lembra-se de quem falha sempre.
//
// Cinco camadas independentes, aplicadas por esta ordem:
// 1. honeypot — campo escondido que humanos deixam vazio
// 2. time-trap assinado — rápido demais ou velho demais == bot
// 3. blocklist — email/domínio/IP geridos pelo staff
// 4. heurística de texto — links a mais / BBCode / HTML
// 5. rate limit deslizante 24 h por email e por IP
//
// Cada site pode redefinir os limites antes de incluir este ficheiro: as
// constantes só são declaradas se ainda não existirem.
if (!defined('ANTISPAM_HONEYPOT')) define('ANTISPAM_HONEYPOT', 'website');
if (!defined('ANTISPAM_MIN_SECONDS')) define('ANTISPAM_MIN_SECONDS', 4);
if (!defined('ANTISPAM_MAX_SECONDS')) define('ANTISPAM_MAX_SECONDS', 7200);
if (!defined('ANTISPAM_MAX_PER_EMAIL_24H')) define('ANTISPAM_MAX_PER_EMAIL_24H', 3);
if (!defined('ANTISPAM_MAX_PER_IP_24H')) define('ANTISPAM_MAX_PER_IP_24H', 6);
/**
* Chave HMAC do time-trap. Prefere um segredo só do servidor; cai para o
* APP_BASE_URL para o formulário nunca deixar de funcionar num install novo.
*/
function antispam_secret(): string
{
foreach (['ANTISPAM_SECRET', 'APP_SECRET_KEY', 'CRON_TOKEN'] as $key) {
$v = (string) env($key, '');
if ($v !== '') {
return $v;
}
}
$base = (string) env('APP_BASE_URL', '');
return $base !== '' ? $base : 'andmore-antispam';
}
/** "<timestamp>.<hmac>" para embeber num campo escondido no render. */
function antispam_token(): string
{
$ts = (string) time();
return $ts . '.' . hash_hmac('sha256', $ts, antispam_secret());
}
/** Os dois campos escondidos. Colar em qualquer formulário público. */
function antispam_fields(): string
{
$hp = htmlspecialchars(ANTISPAM_HONEYPOT, ENT_QUOTES);
$token = htmlspecialchars(antispam_token(), ENT_QUOTES);
// Escondido fora do ecrã (não display:none, que alguns bots ignoram) e fora
// da ordem de tabulação/autofill, para nenhum utilizador real lá chegar.
return '<div style="position:absolute;left:-9999px;top:-9999px;" aria-hidden="true">'
. '<label>Não preencher<input type="text" name="' . $hp . '"'
. ' tabindex="-1" autocomplete="off" value=""></label></div>'
. '<input type="hidden" name="antispam_token" value="' . $token . '">';
}
/**
* Verifica honeypot + time-trap. Em caso de falha, $reason fica com
* 'honeypot' | 'toofast' | 'stale' | 'badtoken' para registo.
*/
/**
* @param int $minSeconds Submetido mais depressa do que isto == bot.
* @param int $maxSeconds Idade máxima do carimbo; 0 desliga o limite superior.
*
* Os dois são parâmetros e não constantes porque o formulário certo depende do
* ecrã. Num pedido de orçamento, 4 s de mínimo e 2 h de validade são acertados.
* Num LOGIN não são: um gestor de passwords preenche e submete num instante
* (seria recusado), e uma página de login deixada aberta num separador durante
* a tarde ficaria "velha" (também recusada) — em ambos os casos recusaríamos
* exactamente a pessoa certa.
*/
function antispam_check(
array $post,
?string &$reason = null,
int $minSeconds = ANTISPAM_MIN_SECONDS,
int $maxSeconds = ANTISPAM_MAX_SECONDS
): bool {
if (trim((string) ($post[ANTISPAM_HONEYPOT] ?? '')) !== '') {
$reason = 'honeypot';
return false;
}
$raw = (string) ($post['antispam_token'] ?? '');
$dot = strrpos($raw, '.');
if ($dot === false) {
$reason = 'badtoken';
return false;
}
$ts = substr($raw, 0, $dot);
$sig = substr($raw, $dot + 1);
if (!ctype_digit($ts) || !hash_equals(hash_hmac('sha256', $ts, antispam_secret()), $sig)) {
$reason = 'badtoken';
return false;
}
$age = time() - (int) $ts;
if ($age < $minSeconds) { $reason = 'toofast'; return false; }
if ($maxSeconds > 0 && $age > $maxSeconds) { $reason = 'stale'; return false; }
return true;
}
/**
* Blocklist gerida pelo staff (`system_settings.antispam_blocklist`, uma
* entrada por linha). Formas aceites:
* alguem@exemplo.com email exacto (sem distinguir maiúsculas)
* @exemplo.com qualquer endereço desse domínio
* 203.0.113.7 IP exacto
* 203.0.113. prefixo de IP (ponto final = prefixo)
* Linhas iniciadas por # são comentários.
*/
function antispam_blocked(string $email, string $ip): bool
{
// setting() é do stack config/; a silver não o tem. O primitivo do
// platform-tools serve os dois.
$raw = function_exists('setting')
? (string) setting('antispam_blocklist', '')
: (isset($GLOBALS['pdo']) ? (string) platform_settings_get($GLOBALS['pdo'], 'antispam_blocklist', '') : '');
if (trim($raw) === '') {
return false;
}
$email = mb_strtolower(trim($email));
foreach (preg_split('/\R+/', $raw) as $line) {
$entry = mb_strtolower(trim($line));
if ($entry === '' || $entry[0] === '#') {
continue;
}
if ($entry[0] === '@') {
if ($email !== '' && str_ends_with($email, $entry)) return true;
} elseif (str_contains($entry, '@')) {
if ($email === $entry) return true;
} elseif (str_ends_with($entry, '.')) {
if ($ip !== '' && str_starts_with($ip, $entry)) return true;
} else {
if ($ip !== '' && $ip === $entry) return true;
}
}
return false;
}
/**
* Heurística de texto suspeito. Um pedido de suporte real pode trazer UM link
* (o URL da página com problema); dois ou mais, ou marcação BBCode/HTML, é
* quase sempre payload de bot.
*/
function antispam_message_suspect(?string ...$texts): bool
{
$joined = implode("\n", array_map(fn($t) => (string) $t, $texts));
if ($joined === '') {
return false;
}
if (stripos($joined, '[url') !== false || stripos($joined, '<a href') !== false) {
return true;
}
return preg_match_all('#https?://|\bwww\.#i', $joined) >= 2;
}
/**
* Limite deslizante de 24 h sobre a tabela de submissões, por email e por IP.
*
* A tabela e as colunas vêm por argumento: no hub são os `tickets`, na
* fotografia os pedidos de orçamento. Era a única parte deste módulo que não
* era genérica.
*
* Os nomes de tabela e coluna são interpolados (não há placeholders para
* identificadores em SQL), por isso passam por uma validação estrita — só
* letras, dígitos e underscore. Vêm sempre do código do site, nunca de um
* pedido, mas a validação torna isso impossível de contornar por engano.
*/
function antispam_submission_throttled(
PDO $pdo,
string $table,
string $emailColumn,
string $ipColumn,
string $email,
string $ip,
?string &$reason = null,
?int $maxPerEmail = null,
?int $maxPerIp = null
): bool {
foreach ([$table, $emailColumn, $ipColumn] as $ident) {
if (!preg_match('/^[A-Za-z0-9_]+$/', $ident)) {
error_log('[antispam] identificador inválido: ' . $ident);
return false; // falha aberto: não é o antispam que derruba o formulário
}
}
$maxPerEmail ??= ANTISPAM_MAX_PER_EMAIL_24H;
$maxPerIp ??= ANTISPAM_MAX_PER_IP_24H;
$s = $pdo->prepare(
"SELECT COUNT(*) FROM `{$table}`
WHERE `{$emailColumn}` = ? AND created_at > DATE_SUB(NOW(), INTERVAL 1 DAY)"
);
$s->execute([mb_strtolower($email)]);
if ((int) $s->fetchColumn() >= $maxPerEmail) {
$reason = 'email_rate';
return true;
}
if ($ip !== '') {
$s = $pdo->prepare(
"SELECT COUNT(*) FROM `{$table}`
WHERE `{$ipColumn}` = ? AND created_at > DATE_SUB(NOW(), INTERVAL 1 DAY)"
);
$s->execute([$ip]);
if ((int) $s->fetchColumn() >= $maxPerIp) {
$reason = 'ip_rate';
return true;
}
}
return false;
}
/**
* Corre as cinco camadas por ordem e devolve o motivo da recusa, ou null.
*
* $submissions diz onde contar o limite de 24 h: ['tabela', 'coluna_email',
* 'coluna_ip']. A null, a camada 5 não corre — um site sem tabela de
* submissões usa na mesma as outras quatro.
*/
function antispam_screen(PDO $pdo, array $post, string $email, string $ip,
?array $submissions = null, string ...$texts): ?string
{
$reason = null;
if (!antispam_check($post, $reason)) return $reason;
if (antispam_blocked($email, $ip)) return 'blocklist';
if (antispam_message_suspect(...$texts)) return 'links';
if ($submissions !== null
&& antispam_submission_throttled($pdo, $submissions[0], $submissions[1], $submissions[2],
$email, $ip, $reason)) {
return $reason;
}
return null;
}
/**
* Regista uma tentativa descartada, para o staff ver o que está a ser filtrado.
*
* É também o ponto único onde a infracção conta para o bloqueio de IP: todas as
* recusas do antispam passam por aqui, portanto pendurar a escalada neste sítio
* cobre o formulário de suporte e tudo o que venha a usá-lo, sem ter de andar a
* lembrar de o acrescentar em cada um.
*/
function antispam_log_drop(PDO $pdo, string $reason, string $email, string $ip, string $subject = ''): void
{
try {
$pdo->prepare(
'INSERT INTO antispam_drops (reason, submitter_email, ip_address, subject)
VALUES (?, ?, ?, ?)'
)->execute([$reason, mb_substr($email, 0, 190), $ip, mb_substr($subject, 0, 190)]);
} catch (Throwable $e) {
error_log('antispam_log_drop falhou: ' . $e->getMessage());
}
// O IP explícito e não o do pedido: quem chama isto já o resolveu, e nos
// casos em que a submissão vem de outro caminho é esse que vale.
if (function_exists('platform_ip_record_offence')) {
platform_ip_record_offence($pdo, 'antispam.' . mb_substr($reason, 0, 28), $subject !== '' ? $subject : $email, $ip);
}
}
@@ -1,502 +0,0 @@
<?php
// ─── Anti-spam de formulários públicos: o motor de pontuação (partilhado) ────
//
// Nasceu no dance, a defender os formulários de contacto e de aula
// experimental — que são o funil de inscrições da academia — e vive agora aqui.
// A auditoria de 2026-09-07 encontrou CINCO implementações de anti-spam na
// frota, e a do dance era a única com pontuação de conteúdo. Também era, das
// que existiam, a que corria sem a validação de identificadores SQL que o
// functions_antispam.php ao lado já tinha: a cópia mais rica era a cópia sem a
// guarda. É o argumento inteiro contra implementações paralelas.
//
// ─── A relação com functions_antispam.php ───────────────────────────────────
//
// São dois módulos e não um por decisão, não por esquecimento. O outro
// (honeypot + blocklist + limite deslizante de 24 h por email e IP) está em
// produção no hub e tem outra API; fundi-los às cegas mudava o comportamento de
// um site a que ninguém pediu alterações. Este trata do que aquele não faz:
// **pontuar o conteúdo** de um formulário de angariação, onde recusar de mais
// custa inscrições e recusar de menos custa uma caixa de correio.
//
// Convergir os dois é trabalho para quando houver quem teste o hub. Até lá,
// quem escolhe é o site: este para formulários de angariação com texto livre,
// aquele para formulários de suporte com identidade conhecida.
//
// ─── O desenho, que se mantém do dance ──────────────────────────────────────
//
// Nada aqui devolve um erro a quem escreve. Uma submissão recusada recebe a
// mesma página de obrigado que uma válida — um bot não fica a saber o que o
// apanhou — e fica gravada, para que um falso positivo seja recuperável em vez
// de desaparecer. Um falso positivo é uma lead verdadeira.
//
// As camadas, da mais barata para a mais cara:
//
// 1. honeypot — campo escondido; preenchido é um bot.
// 2. carimbo assinado — `form_ts` + HMAC. Sem assinatura, o carimbo era um
// número que qualquer script punha à mão.
// 3. prova de JavaScript — um campo que só o browser preenche. NÃO bloqueia
// sozinho: quem desliga o JS continua a enviar.
// 4. limite por IP — inclui as tentativas já bloqueadas, senão bastava
// insistir para o contador nunca subir.
// 5. conteúdo — pontuação. Uma mensagem legítima pode trazer um
// link ("vi no vosso instagram"), por isso um link
// sozinho não chega ao limite e dois chegam.
//
// ─── Ligar num site ─────────────────────────────────────────────────────────
//
// require_once __DIR__ . '/../shared/platform-tools/functions_antispam_forms.php';
// platform_antispam_config([
// 'key_domain' => 'dsa-antispam|', // NÃO mudar num site a correr: ver abaixo
// 'log_table' => 'blocked_submissions',
// 'keywords' => [...], // vocabulário que não aparece no vosso negócio
// 'native' => '/\b(ola|gostaria|aulas?)\b/u',
// ]);
//
// `key_domain` entra no HMAC do carimbo. Mudá-lo num site a correr invalida os
// formulários que estiverem abertos nesse momento, e essas submissões são
// recusadas em silêncio — leads perdidas por uma questão de arrumação. Por isso
// é configurável e não fixo: cada site mantém o domínio com que já assina.
require_once __DIR__ . '/functions_settings.php';
require_once __DIR__ . '/functions_client_ip.php';
/**
* Configuração do módulo. Chamar com um array para definir; sem argumentos, lê.
* Os valores por omissão são conservadores de propósito — sem `keywords`, a
* camada de vocabulário simplesmente não pontua.
*/
function platform_antispam_config(?array $set = null): array
{
static $cfg = [
'key_domain' => 'antispam|',
'log_table' => 'blocked_submissions',
'min_seconds' => 4, // ninguém preenche um formulário em menos
'max_seconds' => 21600, // 6 h — separador aberto de manhã ainda serve
'score_block' => 5,
'ip_limit' => 5,
'ip_window' => 60, // minutos
'keywords' => [],
'native' => '', // regex das marcas da língua local (ver looks_foreign)
'setting_key' => 'antispam_key',
];
if ($set !== null) {
$cfg = array_merge($cfg, $set);
}
return $cfg;
}
/**
* Um identificador SQL que podemos interpolar?
*
* Nomes de tabela não podem ir por placeholder, e estas funções recebem-nos de
* quem chama. **Falha aberto**: um identificador inválido é um erro de
* programação do site, e não é o anti-spam que há-de derrubar o formulário por
* causa dele — regista-se e deixa-se passar.
*/
function platform_antispam_ident_ok(string $ident): bool
{
if (preg_match('/^[A-Za-z0-9_]+$/', $ident)) {
return true;
}
error_log('[antispam] identificador SQL inválido, ignorado: ' . $ident);
return false;
}
/**
* Chave dos HMAC do carimbo.
*
* Tem de ser **a mesma entre pedidos**: o formulário é assinado num pedido e
* verificado noutro, e uma chave que mudasse a cada pedido recusava toda a
* gente com "carimbo inválido". Daí os três degraus, todos estáveis:
*
* 1. APP_SECRET_KEY / BACKUP_TOKEN do ambiente (o caso normal);
* 2. uma chave aleatória gravada em system_settings, quando não há a primeira;
* 3. derivada da configuração do install, para instalações sem chave e sem a
* tabela de settings. Não é um segredo tão bom como os outros, mas é
* constante — e uma defesa que tranca todos os formulários é pior do que
* uma assinatura mais fraca.
*
* A mesma escada vive agora em platform_derived_key() (functions_settings.php),
* que é o que os módulos novos usam — o captcha, por exemplo. Esta cópia fica
* porque separa os domínios de outra maneira (ver o comentário lá dentro sobre
* a chave do ambiente) e porque trocá-la num site a correr invalida os
* formulários abertos nesse instante. Quem a converter faz uma janela que
* aceite as duas assinaturas antes de tirar a antiga.
*/
function platform_antispam_key(?PDO $pdo = null): string
{
static $key = null;
if ($key !== null) {
return $key;
}
$cfg = platform_antispam_config();
// Em bruto, e NÃO derivada com o domínio, quando vem do ambiente.
//
// Separar domínios seria melhor prática — a mesma chave assina carimbos e
// cifra backups. Mas a chave do HMAC não se muda num site a correr: os
// formulários abertos nesse instante passam a ter carimbo inválido e são
// recusados em silêncio, ou seja, leads perdidas por uma questão de
// arrumação. Fica como está, de propósito, e quem quiser trocar faz uma
// janela que aceite as duas assinaturas antes de tirar a antiga.
$fromEnv = platform_secret_key();
if ($fromEnv !== null) {
return $key = $fromEnv;
}
$stored = '';
if ($pdo instanceof PDO) {
$stored = (string) platform_settings_get($pdo, $cfg['setting_key'], '');
if ($stored === '') {
$fresh = bin2hex(random_bytes(32));
try {
// Nunca cifrada: platform_settings_is_secret('antispam_key') é
// falso de propósito. Cifrá-la aqui e lê-la noutro sítio sem
// decifrar dava uma chave diferente a cada leitura, ou seja
// todos os formulários recusados.
platform_settings_set($pdo, $cfg['setting_key'], $fresh, null, false);
$stored = $fresh;
} catch (Throwable $e) {
error_log('[antispam] chave não persistida (' . $e->getMessage() . '); a derivar da configuração');
}
}
}
if ($stored === '') {
$env = function (string $k): string {
return function_exists('env') ? (string) env($k, '') : (string) (getenv($k) ?: '');
};
$stored = 'derived|' . $env('DB_NAME') . '|' . $env('DB_USER') . '|' . $env('DB_PASS')
. '|' . (defined('APP_BASE_URL') ? APP_BASE_URL : '');
}
return $key = hash('sha256', $cfg['key_domain'] . $stored, true);
}
function platform_antispam_sign(string $form, int $ts, ?PDO $pdo = null): string
{
return hash_hmac('sha256', $form . '|' . $ts, platform_antispam_key($pdo));
}
/**
* Campos-armadilha de um formulário público. `$form` entra na assinatura, para
* que um carimbo colhido no formulário de contacto não sirva no de inscrição.
*
* O <script> sai uma vez por página (dois formulários na mesma página partilham
* o mesmo) e preenche `form_js` com o início da assinatura — um cliente que só
* leia o HTML e reenvie os campos manda-o vazio, porque no HTML está vazio.
*/
function platform_antispam_trap_field(string $form = 'form', ?PDO $pdo = null): string
{
static $scriptPrinted = false;
$esc = fn(string $s): string => htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
$ts = time();
$sig = platform_antispam_sign($form, $ts, $pdo);
$html = '<input type="text" name="website" value="" tabindex="-1" autocomplete="off"'
. ' style="position:absolute;left:-9999px;top:-9999px;width:1px;height:1px;overflow:hidden;" aria-hidden="true">'
. '<input type="hidden" name="form_ts" value="' . $ts . '">'
. '<input type="hidden" name="form_sig" value="' . $esc($sig) . '">'
. '<input type="hidden" name="form_js" value="" data-js-check="' . $esc(substr($sig, 0, 12)) . '">';
if (!$scriptPrinted) {
$scriptPrinted = true;
// Corre já (o formulário que o imprime está acima) e outra vez quando a
// página acaba de carregar: o script sai uma vez só, junto ao PRIMEIRO
// formulário, e um segundo mais abaixo na mesma página ainda não existia
// quando ele correu — ficava sem a prova de JS (silver, 2026-09-23).
$html .= '<script>(function(){function f(){document.querySelectorAll(\'input[data-js-check]\').forEach('
. 'function(i){i.value=i.dataset.jsCheck;});}f();'
. 'document.addEventListener(\'DOMContentLoaded\',f);})();</script>';
}
return $html;
}
/**
* Recusa directa: honeypot, carimbo em falta/forjado, ou depressa/velho de mais.
* Devolve o motivo (para o registo) ou null quando passa.
*/
function platform_antispam_trap_reason(string $form, ?PDO $pdo = null): ?string
{
$cfg = platform_antispam_config();
if (trim((string) ($_POST['website'] ?? '')) !== '') {
return 'honeypot';
}
$ts = (int) ($_POST['form_ts'] ?? 0);
$sig = (string) ($_POST['form_sig'] ?? '');
if ($ts <= 0 || $sig === '' || !hash_equals(platform_antispam_sign($form, $ts, $pdo), $sig)) {
return 'carimbo inválido';
}
$elapsed = time() - $ts;
if ($elapsed < $cfg['min_seconds']) {
return 'submetido em ' . max(0, $elapsed) . 's';
}
if ($elapsed > $cfg['max_seconds']) {
return 'formulário expirado';
}
return null;
}
/**
* Texto comprido, em inglês corrente, sem uma única marca da língua do site.
*
* Vale pontos e não uma recusa: um estrangeiro a perguntar por aulas escreve
* exactamente assim, e esse tem de passar. A regex da língua local vem da
* configuração — sem ela, esta camada não pontua, porque um módulo partilhado
* não pode adivinhar em que língua escrevem os clientes de cada site.
*/
function platform_antispam_looks_foreign(string $text): bool
{
$cfg = platform_antispam_config();
if ($cfg['native'] === '' || mb_strlen($text) < 60) {
return false;
}
$lower = mb_strtolower($text, 'UTF-8');
if (preg_match($cfg['native'], $lower)) {
return false;
}
$en = preg_match_all(
'/\b(the|your|and|are|with|for|our|we|you|this|that|they|before|across|using|each|from|have|will|would|can|about|more|their)\b/u',
$lower
);
return $en >= 4;
}
/** O campo que só o browser preenche foi preenchido? */
function platform_antispam_has_js_proof(): bool
{
$sig = (string) ($_POST['form_sig'] ?? '');
$js = (string) ($_POST['form_js'] ?? '');
return $sig !== '' && $js !== '' && hash_equals(substr($sig, 0, 12), $js);
}
/**
* Pontuação do conteúdo. `$fields['name']` é tratado com mais severidade do que
* o texto livre: um link no nome não tem leitura inocente.
*
* @param array{name?:string, text?:string, modality?:string} $fields
* @param string[] $allowedChoices Lista fechada de um <select>; vazia = não verifica
* @return array{0:int, 1:string[]}
*/
function platform_antispam_content_score(array $fields, array $allowedChoices = []): array
{
$cfg = platform_antispam_config();
$name = trim((string) ($fields['name'] ?? ''));
$text = trim((string) ($fields['text'] ?? ''));
$all = $name . "\n" . $text;
$lower = mb_strtolower($all, 'UTF-8');
$score = 0;
$reasons = [];
$urlPattern = '~(https?://|www\.|\[url|<a\s)~i';
if (preg_match($urlPattern, $name)) {
$score += 5;
$reasons[] = 'link no nome';
}
$urlCount = preg_match_all($urlPattern, $text);
if ($urlCount >= 2) {
$score += 5;
$reasons[] = $urlCount . ' links na mensagem';
} elseif ($urlCount === 1) {
$score += 3;
$reasons[] = 'link na mensagem';
}
// Domínio escrito de propósito ao lado do filtro — "brnd .li/delist",
// "exemplo (dot) com". Quem escreve assim sabe que há um filtro a evitar, o
// que por si só diz o que a mensagem é. Vale 3 e não 5 porque apanha por
// engano um ponto mal espaçado ("a minha filha .Com sete anos").
if (preg_match('~\b[a-z0-9][a-z0-9-]+\s+\.\s?(com|net|org|io|co|li|ru|xyz|info|shop|link|site|online|biz)\b~i', $all)
|| preg_match('~\(\s*dot\s*\)|\bdot\s+(com|net|org)\b|\bhxxps?\b~i', $all)) {
$score += 3;
$reasons[] = 'domínio disfarçado';
}
if (preg_match('/[\p{Cyrillic}\p{Han}\p{Hiragana}\p{Katakana}\p{Hangul}\p{Arabic}\p{Hebrew}\p{Thai}]/u', $all)) {
$score += 5;
$reasons[] = 'escrita não-latina';
}
$hits = [];
foreach ($cfg['keywords'] as $word) {
if (str_contains($lower, $word)) {
$hits[] = $word;
}
}
if ($hits) {
$score += min(6, 3 * count($hits));
$reasons[] = 'vocabulário: ' . implode(', ', array_slice($hits, 0, 3));
}
// Dois pontos, e não três, por causa de um caso concreto: um estrangeiro a
// perguntar por aulas, com o JavaScript desligado, somava logo o limite.
if (platform_antispam_looks_foreign($text)) {
$score += 2;
$reasons[] = 'texto em língua estrangeira';
}
if (preg_match('~<\s*(a|script|iframe|img|div)\b~i', $all) || str_contains($lower, '[/url]')) {
$score += 3;
$reasons[] = 'html na mensagem';
}
$choice = trim((string) ($fields['modality'] ?? $fields['choice'] ?? ''));
if ($choice !== '' && $allowedChoices && !in_array($choice, $allowedChoices, true)) {
$score += 3;
$reasons[] = 'opção inexistente';
}
if (!platform_antispam_has_js_proof()) {
$score += 2;
$reasons[] = 'sem javascript';
}
return [$score, $reasons];
}
/**
* Quantas submissões deste IP na última janela, contando as bloqueadas — senão
* quem fosse recusado voltava a começar do zero a cada tentativa.
*/
function platform_antispam_ip_count(PDO $pdo, string $form, string $table, string $ip): int
{
if ($ip === '') {
return 0;
}
$cfg = platform_antispam_config();
$count = 0;
if (platform_antispam_ident_ok($table)) {
try {
$stmt = $pdo->prepare(
"SELECT COUNT(*) FROM `{$table}` WHERE ip = ? AND created_at >= DATE_SUB(NOW(), INTERVAL ? MINUTE)"
);
$stmt->execute([$ip, $cfg['ip_window']]);
$count += (int) $stmt->fetchColumn();
} catch (PDOException) { /* tabela ainda não migrada — não trancar por isso */ }
}
$log = $cfg['log_table'];
if (platform_antispam_ident_ok($log)) {
try {
$stmt = $pdo->prepare(
"SELECT COUNT(*) FROM `{$log}`
WHERE ip = ? AND form = ? AND created_at >= DATE_SUB(NOW(), INTERVAL ? MINUTE)"
);
$stmt->execute([$ip, $form, $cfg['ip_window']]);
$count += (int) $stmt->fetchColumn();
} catch (PDOException) { /* idem */ }
}
return $count;
}
/**
* A verificação completa de um formulário público. Devolve null quando pode
* seguir, ou o motivo quando não — já registado.
*
* Chamar depois da verificação de CSRF. Quem chama responde sempre da mesma
* maneira (a página de obrigado), passe ou não.
*/
function platform_antispam_check(
PDO $pdo,
string $form,
string $table,
array $fields,
array $allowedChoices = []
): ?string {
// O IP verdadeiro, não o REMOTE_ADDR: atrás de um proxy o REMOTE_ADDR é
// sempre o mesmo, e o limite por IP passava a contar toda a gente no mesmo
// balde — cinco visitantes diferentes trancavam o formulário a todos, e um
// bot sozinho nunca chegava ao limite dele.
// platform_client_ip(), o nome canónico de functions_client_ip.php — e não
// auth_client_ip(), que é apenas um alias criado dentro do
// functions_auth.php. Um módulo partilhado não pode contar com que o site
// tenha carregado a autenticação: no dance tinha, e por isso o alias
// funcionava aqui sem ninguém reparar na dependência que faltava.
$ip = platform_client_ip() ?? '';
// O portão de país corre primeiro: é mais barato que as outras verificações
// e não depende de token nenhum. Desligado, devolve null.
if (function_exists('geo_form_block_reason')) {
$geoReason = geo_form_block_reason($ip ?: null);
if ($geoReason !== null) {
platform_antispam_log($pdo, $form, $geoReason, 0, $ip);
return $geoReason;
}
}
$cfg = platform_antispam_config();
$reason = platform_antispam_trap_reason($form, $pdo);
$score = 0;
if ($reason === null && platform_antispam_ip_count($pdo, $form, $table, $ip) >= $cfg['ip_limit']) {
$reason = 'limite de ' . $cfg['ip_limit'] . ' envios/hora deste IP';
}
if ($reason === null) {
[$score, $reasons] = platform_antispam_content_score($fields, $allowedChoices);
if ($score >= $cfg['score_block']) {
$reason = implode('; ', $reasons);
}
}
if ($reason === null) {
return null;
}
platform_antispam_log($pdo, $form, $reason, $score, $ip);
return $reason;
}
/**
* Grava a tentativa recusada. O payload é o $_POST inteiro menos o token CSRF —
* é o que permite ler um falso positivo e responder à pessoa à mesma.
*/
function platform_antispam_log(PDO $pdo, string $form, string $reason, int $score, string $ip): void
{
$cfg = platform_antispam_config();
$log = $cfg['log_table'];
if (!platform_antispam_ident_ok($log)) {
return;
}
$payload = $_POST;
unset($payload['csrf_token'], $payload['form_sig'], $payload['form_js'], $payload['form_ts']);
try {
$pdo->prepare(
"INSERT INTO `{$log}` (form, reason, score, ip, user_agent, payload)
VALUES (?, ?, ?, ?, ?, ?)"
)->execute([
$form,
mb_substr($reason, 0, 190),
$score,
$ip,
mb_substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 250),
mb_substr((string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 0, 4000),
]);
// Cada recusa conta para o bloqueio de IP. É isto que dá memória às
// defesas: sozinho, este contador esquece tudo à hora seguinte, e um
// bot que volte todos os dias é sempre um visitante novo.
if (function_exists('platform_ip_record_offence')) {
platform_ip_record_offence($pdo, 'antispam:' . $form, mb_substr($reason, 0, 120), $ip ?: null);
}
} catch (PDOException $e) {
// Tabela por migrar, ou disco cheio: o bloqueio mantém-se, só não fica
// registado. Recusar por não se conseguir escrever o registo seria
// trocar spam por leads perdidas.
error_log('[antispam] registo falhou: ' . $e->getMessage());
}
}
-348
View File
@@ -1,348 +0,0 @@
<?php
// ─── Registo de auditoria (partilhado) ──────────────────────────────────────
//
// Quem fez o quê, a quê, quando e de onde — numa tabela `audit_log` de cada
// site, escrita por uma função só:
//
// platform_audit($pdo, 'user.delete', [
// 'target_type' => 'user',
// 'target_id' => 42,
// 'target_label' => 'Maria Silva',
// 'summary' => 'Enviada para a reciclagem',
// 'changes' => platform_audit_diff($antes, $depois),
// ]);
//
// Porque existe: a frota tinha TRÊS registos incompatíveis (hub, dance, quack,
// cada um com as suas colunas e a sua função) e cinco sites sem nenhum. O
// mesmo argumento do anti-spam e dos segredos — cópias não ficam iguais,
// ficam parecidas.
//
// ─── As tabelas que já existiam ─────────────────────────────────────────────
//
// Não se renomeia nada. O escritor LÊ as colunas da tabela do site
// (information_schema, uma vez por pedido) e escreve cada campo canónico na
// coluna que o site declarou para ele — `platform_connect(['audit' => ['map' =>
// ['actor_id' => 'user_id', ...]]])`, como o `schema` da autenticação. Um campo
// sem coluna é simplesmente omitido; uma coluna NOT NULL sem valor por omissão
// recebe um vazio (a do quack, `summary`, rebentava o INSERT); os textos são
// cortados ao tamanho da coluna. As migrações de cada site acrescentam as
// colunas que faltam, e daí em diante nada se perde.
//
// Um site sem tabela recebe-a no primeiro uso, já na forma canónica
// (platform_audit_ensure_table()).
//
// ─── As regras ──────────────────────────────────────────────────────────────
//
// - **Nunca parte a ação.** Uma falha a escrever regista-se no error_log e o
// pedido segue: perde-se a linha, não a operação.
// - **Segredos nunca entram.** Qualquer chave de `changes` com cara de segredo
// (password, token, secret, key, hash, cookie, csrf) é substituída por
// «(oculto)», a qualquer profundidade.
// - **O autor vem da sessão**, em qualquer das formas da frota
// (current_user(), $_SESSION['user'], ou as chaves soltas do quack), e o IP é
// o verdadeiro (platform_client_ip()). Quem chama pode passar 'actor' quando
// sabe melhor — o hub a agir por ops, o webhook do deploy, o PIN do dance.
require_once __DIR__ . '/functions_client_ip.php';
/** Os campos canónicos, e a definição de cada um numa tabela nova. */
function platform_audit_columns(): array
{
return [
'actor_id' => 'BIGINT UNSIGNED NULL',
'actor_name' => 'VARCHAR(150) NULL',
'actor_role' => 'VARCHAR(30) NULL',
'action' => "VARCHAR(80) NOT NULL DEFAULT ''",
'target_type' => "VARCHAR(60) NOT NULL DEFAULT ''",
'target_id' => 'VARCHAR(64) NULL',
'target_label' => 'VARCHAR(250) NULL',
'summary' => 'VARCHAR(1000) NULL',
'changes' => 'LONGTEXT NULL',
'ip' => 'VARCHAR(45) NULL',
'user_agent' => 'VARCHAR(255) NULL',
];
}
/** Nome da tabela e mapa de colunas deste site (platform_connect 'audit'). */
function platform_audit_config(): array
{
$cfg = function_exists('platform_config') ? (array) (platform_config()['audit'] ?? []) : [];
return [
'table' => (string) ($cfg['table'] ?? 'audit_log'),
'map' => (array) ($cfg['map'] ?? []),
// Dias que uma linha vive; 0 = para sempre.
'keep_days' => (int) ($cfg['keep_days'] ?? 730),
];
}
function platform_audit_ensure_table(PDO $pdo, string $table): void
{
if (!preg_match('/^[A-Za-z0-9_]+$/', $table)) return;
$cols = '';
foreach (platform_audit_columns() as $name => $def) $cols .= " `$name` $def,\n";
$pdo->exec(
"CREATE TABLE IF NOT EXISTS `$table` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
$cols `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_audit_created` (`created_at`),
KEY `idx_audit_actor` (`actor_id`, `created_at`),
KEY `idx_audit_action` (`action`, `created_at`),
KEY `idx_audit_target` (`target_type`, `target_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
}
/**
* As colunas reais da tabela: [nome => ['type' => …, 'max' => int|null,
* 'required' => bool]]. Uma consulta por pedido. Cria a tabela quando não há.
*/
function platform_audit_table_columns(PDO $pdo): array
{
static $cache = [];
$table = platform_audit_config()['table'];
if (isset($cache[$table])) return $cache[$table];
$read = function () use ($pdo, $table): array {
$st = $pdo->prepare(
'SELECT COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, IS_NULLABLE, COLUMN_DEFAULT, EXTRA
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?'
);
$st->execute([$table]);
$out = [];
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $c) {
$out[$c['COLUMN_NAME']] = [
'type' => strtolower((string) $c['DATA_TYPE']),
'max' => $c['CHARACTER_MAXIMUM_LENGTH'] !== null ? (int) $c['CHARACTER_MAXIMUM_LENGTH'] : null,
'required' => $c['IS_NULLABLE'] === 'NO' && $c['COLUMN_DEFAULT'] === null
&& !str_contains((string) $c['EXTRA'], 'auto_increment'),
'not_null' => $c['IS_NULLABLE'] === 'NO',
];
}
return $out;
};
$cols = $read();
if (!$cols) {
platform_audit_ensure_table($pdo, $table);
$cols = $read();
}
return $cache[$table] = $cols;
}
/** A coluna do site para um campo canónico (o mapa, ou o próprio nome). */
function platform_audit_column(string $field): string
{
return (string) (platform_audit_config()['map'][$field] ?? $field);
}
/**
* Quem está a agir, a partir da sessão, em qualquer das formas da frota:
* current_user(), $_SESSION['user'], ou as chaves soltas user_id/username/role.
*/
function platform_audit_actor(): ?array
{
$u = null;
if (function_exists('current_user')) {
try { $u = current_user(); } catch (Throwable) { $u = null; }
}
if (!is_array($u) && session_status() === PHP_SESSION_ACTIVE && is_array($_SESSION['user'] ?? null)) {
$u = $_SESSION['user'];
}
if (is_array($u) && !empty($u['id'])) {
return [
'id' => (int) $u['id'],
'name' => (string) ($u['full_name'] ?? $u['name'] ?? $u['display_name'] ?? $u['username'] ?? $u['email'] ?? ''),
'role' => (string) ($u['role'] ?? ''),
];
}
if (session_status() === PHP_SESSION_ACTIVE && !empty($_SESSION['user_id'])) {
return [
'id' => (int) $_SESSION['user_id'],
'name' => (string) ($_SESSION['full_name'] ?? $_SESSION['username'] ?? ''),
'role' => (string) ($_SESSION['role'] ?? ''),
];
}
return null;
}
/** Tira os segredos de uma estrutura antes de ela ir para o registo. */
function platform_audit_redact(mixed $v, string $key = ''): mixed
{
if ($key !== '' && preg_match('/pass|token|secret|api_?key|ops_?key|hash|cookie|csrf|validator|pairing_code/i', $key)) {
// Oculta os valores mas mantém a forma: um {from, to} continua a ser um
// {from, to}, e a página continua a mostrá-lo como alteração.
if (is_array($v)) return array_map(fn($x) => ($x === null || $x === '') ? $x : '(oculto)', $v);
return ($v === null || $v === '') ? $v : '(oculto)';
}
if (is_array($v)) {
$out = [];
foreach ($v as $k => $x) $out[$k] = platform_audit_redact($x, is_string($k) ? $k : $key);
return $out;
}
return $v;
}
/**
* O que mudou entre duas versões de um registo: [campo => ['from' => …, 'to' => …]].
* Compara como texto, para um '1' da base de dados e um 1 do formulário não
* contarem como mudança. $ignore: campos a não comparar (updated_at, …).
*/
function platform_audit_diff(array $before, array $after, array $ignore = ['updated_at', 'created_at']): array
{
$out = [];
foreach (array_unique(array_merge(array_keys($before), array_keys($after))) as $k) {
if (in_array($k, $ignore, true)) continue;
$a = $before[$k] ?? null;
$b = $after[$k] ?? null;
if ((string) (is_scalar($a) || $a === null ? $a : json_encode($a)) === (string) (is_scalar($b) || $b === null ? $b : json_encode($b))) continue;
$out[$k] = ['from' => $a, 'to' => $b];
}
return $out;
}
/** Os campos de um registo novo, na forma do diff (a partir de nada). Vazios ficam de fora. */
function platform_audit_created(array $fields): array
{
$out = [];
foreach ($fields as $k => $v) {
if ($v === null || $v === '') continue;
$out[$k] = ['from' => null, 'to' => $v];
}
return $out;
}
/**
* Regista uma ação. Nunca lança.
*
* $d: target_type, target_id, target_label, summary, changes (array), e
* opcionalmente actor (['id','name','role'], ou false para «sistema»).
*/
function platform_audit(?PDO $pdo, string $action, array $d = []): void
{
if (!$pdo instanceof PDO) {
$pdo = function_exists('platform_pdo') ? platform_pdo() : null;
if (!$pdo instanceof PDO) return;
}
try {
$actor = array_key_exists('actor', $d) ? ($d['actor'] ?: null) : platform_audit_actor();
$changes = $d['changes'] ?? null;
if (is_array($changes)) {
$changes = $changes ? json_encode(platform_audit_redact($changes), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null;
}
$values = [
'actor_id' => $actor['id'] ?? null,
'actor_name' => $actor['name'] ?? null,
'actor_role' => $actor['role'] ?? null,
'action' => $action,
'target_type' => (string) ($d['target_type'] ?? ''),
'target_id' => isset($d['target_id']) ? (string) $d['target_id'] : null,
'target_label' => $d['target_label'] ?? null,
'summary' => $d['summary'] ?? null,
'changes' => $changes,
'ip' => platform_client_ip(),
'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? (string) $_SERVER['HTTP_USER_AGENT'] : null,
];
$cols = platform_audit_table_columns($pdo);
if (!$cols) return;
$insert = [];
foreach ($values as $field => $v) {
$col = platform_audit_column($field);
if (!isset($cols[$col])) continue; // o site não tem coluna para isto
$c = $cols[$col];
if ($v !== null && in_array($c['type'], ['int', 'bigint', 'smallint', 'mediumint', 'tinyint'], true)) {
$v = is_numeric($v) ? (int) $v : null; // um id que não é número não cabe numa coluna INT
}
if (is_string($v) && $c['max'] !== null && mb_strlen($v) > $c['max']) {
$v = mb_substr($v, 0, $c['max']);
}
if ($v === null && $c['not_null']) {
// NOT NULL: com valor por omissão, omite-se e vale o dela; sem ele, vazio.
if (!$c['required']) continue;
$v = '';
}
$insert[$col] = $v;
}
// Colunas obrigatórias do site que nada preencheu (NOT NULL sem valor por omissão).
foreach ($cols as $col => $c) {
if ($c['required'] && !array_key_exists($col, $insert) && $col !== 'id') $insert[$col] = '';
}
$table = platform_audit_config()['table'];
$names = implode(', ', array_map(fn($c) => "`$c`", array_keys($insert)));
$marks = implode(', ', array_fill(0, count($insert), '?'));
$pdo->prepare("INSERT INTO `$table` ($names) VALUES ($marks)")->execute(array_values($insert));
platform_audit_maybe_prune($pdo);
} catch (Throwable $e) {
error_log('[audit] não registado (' . $action . '): ' . $e->getMessage());
}
}
/** Apaga as linhas mais velhas do que keep_days — no máximo uma vez por dia e por pedido. */
function platform_audit_maybe_prune(PDO $pdo): void
{
static $done = false;
if ($done) return;
$done = true;
$keep = platform_audit_config()['keep_days'];
if ($keep <= 0 || random_int(1, 200) !== 1) return; // barato: ~1 em 200 escritas
try {
$table = platform_audit_config()['table'];
$col = 'created_at';
$pdo->prepare("DELETE FROM `$table` WHERE `$col` < DATE_SUB(NOW(), INTERVAL ? DAY) LIMIT 5000")->execute([$keep]);
} catch (Throwable $e) {
error_log('[audit] poda falhou: ' . $e->getMessage());
}
}
// ─── Leitura (a página audit.php) ───────────────────────────────────────────
/**
* Linhas do registo com os nomes canónicos, seja qual for a forma da tabela do
* site. Filtros: actor (texto), action (prefixo), target_type, target_id,
* from/to (Y-m-d), q (texto livre).
*/
function platform_audit_query(PDO $pdo, array $f = [], int $limit = 100, int $offset = 0): array
{
$cols = platform_audit_table_columns($pdo);
$table = platform_audit_config()['table'];
$sel = ['`id`', '`created_at`'];
$has = [];
foreach (array_keys(platform_audit_columns()) as $field) {
$col = platform_audit_column($field);
if (isset($cols[$col])) { $sel[] = "`$col` AS `$field`"; $has[$field] = "`$col`"; }
else { $sel[] = "NULL AS `$field`"; }
}
$where = []; $params = [];
if (($f['actor'] ?? '') !== '' && isset($has['actor_name'])) { $where[] = "{$has['actor_name']} LIKE ?"; $params[] = '%' . $f['actor'] . '%'; }
if (($f['action'] ?? '') !== '') { $where[] = "{$has['action']} LIKE ?"; $params[] = $f['action'] . '%'; }
if (($f['target_type'] ?? '') !== '' && isset($has['target_type'])) { $where[] = "{$has['target_type']} = ?"; $params[] = $f['target_type']; }
if (($f['target_id'] ?? '') !== '' && isset($has['target_id'])) { $where[] = "{$has['target_id']} = ?"; $params[] = $f['target_id']; }
if (($f['from'] ?? '') !== '') { $where[] = '`created_at` >= ?'; $params[] = $f['from'] . ' 00:00:00'; }
if (($f['to'] ?? '') !== '') { $where[] = '`created_at` <= ?'; $params[] = $f['to'] . ' 23:59:59'; }
if (($f['q'] ?? '') !== '') {
$like = [];
foreach (['target_label', 'summary', 'changes'] as $field) if (isset($has[$field])) $like[] = "{$has[$field]} LIKE ?";
if ($like) { $where[] = '(' . implode(' OR ', $like) . ')'; foreach ($like as $_) $params[] = '%' . $f['q'] . '%'; }
}
$sql = 'SELECT ' . implode(', ', $sel) . " FROM `$table`"
. ($where ? ' WHERE ' . implode(' AND ', $where) : '')
. ' ORDER BY `created_at` DESC, `id` DESC LIMIT ' . max(1, min(1000, $limit)) . ' OFFSET ' . max(0, $offset);
$st = $pdo->prepare($sql);
$st->execute($params);
return $st->fetchAll(PDO::FETCH_ASSOC);
}
/** Os valores distintos de um campo, para os filtros (ações, tipos). */
function platform_audit_distinct(PDO $pdo, string $field, int $limit = 200): array
{
$cols = platform_audit_table_columns($pdo);
$col = platform_audit_column($field);
if (!isset($cols[$col])) return [];
$table = platform_audit_config()['table'];
return $pdo->query("SELECT DISTINCT `$col` FROM `$table` WHERE `$col` IS NOT NULL AND `$col` <> '' ORDER BY `$col` LIMIT " . (int) $limit)
->fetchAll(PDO::FETCH_COLUMN);
}
-421
View File
@@ -1,421 +0,0 @@
<?php
// ─── Shared authentication: sessions, passwords, roles, guards ───────────────
//
// One implementation for every platform. Each site had grown its own: dance had
// no session hardening at all and no login throttle, hub2 and fotografia each
// solved both separately, and silver used camelCase guards returning void while
// the others returned the user array. The mechanics below have no business
// logic in them, so there is no reason for four versions.
//
// What stays per-site: the users table itself. The columns genuinely differ
// (fotografia carries CalDAV credentials, silver guardian_id), so this file
// adapts to the local schema through auth_schema() instead of demanding one
// shape. Roles are the same idea -- a common core plus whatever the app needs.
//
// Typical wiring, in the site's bootstrap, after the PDO is open:
//
// require_once __DIR__ . '/../shared/platform-tools/functions_auth.php';
// auth_schema(['password' => 'password_hash', 'name' => 'name']);
// auth_roles_register(['teacher' => 40, 'student' => 20, 'parent' => 20]);
// auth_session_start(['name' => 'dsa_session']);
require_once __DIR__ . '/functions_settings.php';
require_once __DIR__ . '/functions_client_ip.php';
require_once __DIR__ . '/functions_security_events.php';
require_once __DIR__ . '/functions_connector.php';
// ─── Schema map ─────────────────────────────────────────────────────────────
//
// Column names diverged before this file existed -- silver stores the hash in
// `password` and the name in `full_name`, everyone else uses `password_hash`
// and `name`. Renaming columns across four live sites is a migration; adapting
// is a function call. Sites converging on the standard names can simply not
// call auth_schema() at all.
// The defaults live in functions_connector.php, which the conformance check
// reads too — one list of standard names, not two that drift apart.
function auth_schema(?array $map = null): array {
static $schema = null;
$schema ??= platform_auth_schema_defaults();
if ($map !== null) $schema = array_merge($schema, $map);
return $schema;
}
/** Builds the "is this account usable" SQL fragment for the local schema. */
function auth_active_sql(string $alias = ''): string {
$s = auth_schema();
$p = $alias !== '' ? "`{$alias}`." : '';
$out = [];
if ($s['active'] !== null) $out[] = "{$p}`{$s['active']}` = 1";
if ($s['deleted_at'] !== null) $out[] = "{$p}`{$s['deleted_at']}` IS NULL";
return $out ? implode(' AND ', $out) : '1=1';
}
// ─── Roles ──────────────────────────────────────────────────────────────────
//
// Three roles are common to every platform and mean the same thing everywhere.
// Everything else is the app's own vocabulary -- dance has teacher/student/
// parent, fotografia staff/client, silver student -- so a site registers its
// own and they sit in the same ranking as the core ones.
//
// The rank is what makes "at least an admin" expressible without hardcoding a
// list. Higher is more privileged; equal ranks are peers, not substitutes.
const AUTH_ROLE_SYSADMIN = 'sysadmin';
const AUTH_ROLE_ADMIN = 'admin';
const AUTH_ROLE_USER = 'user';
function auth_roles_register(?array $roles = null): array {
static $registry = [
AUTH_ROLE_SYSADMIN => 100,
AUTH_ROLE_ADMIN => 80,
AUTH_ROLE_USER => 10,
];
if ($roles !== null) {
foreach ($roles as $name => $rank) {
$registry[(string) $name] = (int) $rank;
}
arsort($registry);
}
return $registry;
}
function auth_roles_all(): array { return array_keys(auth_roles_register()); }
function auth_role_exists(string $role): bool { return isset(auth_roles_register()[$role]); }
/** Unknown roles rank 0 -- below everything, so a typo cannot grant access. */
function auth_role_rank(string $role): int {
return auth_roles_register()[$role] ?? 0;
}
// ─── Session ────────────────────────────────────────────────────────────────
/**
* Start a hardened session. Safe to call more than once.
*
* dance called a bare session_start() with no cookie parameters at all -- no
* httponly, no samesite, no strict mode -- which is exactly the kind of gap
* that appears when four sites each solve the same problem separately.
*/
function auth_session_start(array $opts = []): void {
if (session_status() === PHP_SESSION_ACTIVE) return;
$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
// Strict mode makes PHP refuse a session id it did not issue, which is what
// closes session fixation. It has to be set before the session starts.
ini_set('session.use_strict_mode', '1');
ini_set('session.use_only_cookies', '1');
session_set_cookie_params([
'lifetime' => (int) ($opts['lifetime'] ?? 0),
'path' => (string) ($opts['path'] ?? '/'),
'domain' => (string) ($opts['domain'] ?? ''),
'secure' => (bool) ($opts['secure'] ?? $https),
'httponly' => true,
'samesite' => (string) ($opts['samesite'] ?? 'Lax'),
]);
if (!empty($opts['name'])) session_name((string) $opts['name']);
session_start();
$idle = (int) ($opts['idle_seconds'] ?? 0);
if ($idle > 0) {
$last = (int) ($_SESSION['_auth_seen'] ?? 0);
if ($last > 0 && (time() - $last) > $idle) {
auth_logout(false);
session_start();
}
$_SESSION['_auth_seen'] = time();
}
}
/** Call on every privilege change -- login, logout, role change. */
function auth_session_regenerate(): void {
if (session_status() === PHP_SESSION_ACTIVE) session_regenerate_id(true);
}
// ─── Passwords ──────────────────────────────────────────────────────────────
//
// PASSWORD_DEFAULT, deliberately, so the estate follows PHP forward. dance
// pinned PASSWORD_BCRYPT, which means the day DEFAULT moves on, three sites
// migrate and one silently does not.
function auth_password_hash(string $plain): string {
return password_hash($plain, PASSWORD_DEFAULT);
}
/**
* Verify a password and transparently upgrade the stored hash.
*
* password_needs_rehash() appeared exactly twice across the whole estate, so
* hashes never actually improved. Login is the only moment the plaintext is
* available, so it is the only moment a rehash can happen.
*/
function auth_password_verify(PDO $pdo, array $user, string $plain): bool {
$s = auth_schema();
$hash = (string) ($user[$s['password']] ?? '');
if ($hash === '' || !password_verify($plain, $hash)) return false;
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
try {
$pdo->prepare("UPDATE `{$s['table']}` SET `{$s['password']}` = ? WHERE `{$s['id']}` = ?")
->execute([auth_password_hash($plain), $user[$s['id']]]);
} catch (Throwable $e) {
error_log('[auth] rehash failed: ' . $e->getMessage()); // never block a valid login
}
}
return true;
}
// ─── Login throttle ─────────────────────────────────────────────────────────
function auth_throttle_ensure_table(PDO $pdo): void {
$pdo->exec(
"CREATE TABLE IF NOT EXISTS `auth_attempts` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`identifier` VARCHAR(190) NOT NULL,
`ip` VARCHAR(45) NULL,
`attempted_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`success` TINYINT(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_identifier_time` (`identifier`, `attempted_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
}
/**
* Does this install's auth_attempts carry a `success` column?
*
* silver and fotografia already had the table, and theirs logs successful
* logins alongside failed ones. Counting every row in the window would then
* throttle a user for signing in successfully, so the count has to exclude
* them. Detected once rather than assumed, because hub2 has no such table and
* a fresh one created here starts out with the column too.
*/
function auth_throttle_has_success_column(PDO $pdo): bool {
static $has = null;
if ($has !== null) return $has;
try {
$st = $pdo->query("SHOW COLUMNS FROM `auth_attempts` LIKE 'success'");
$has = $st->fetch(PDO::FETCH_ASSOC) !== false;
} catch (Throwable $e) {
$has = false;
}
return $has;
}
/**
* Returns ['blocked'=>bool, 'remaining'=>int, 'retry_after'=>int].
*
* Throttles on identifier+IP together: on the identifier alone an attacker can
* lock a known user out of their own account, and on IP alone a NAT'd office
* locks itself out.
*/
function auth_throttle_check(PDO $pdo, string $identifier, int $max = 5, int $windowSeconds = 900): array {
auth_throttle_ensure_table($pdo);
$ip = auth_client_ip();
try {
$onlyFailures = auth_throttle_has_success_column($pdo) ? ' AND success = 0' : '';
$st = $pdo->prepare(
"SELECT COUNT(*) c, COALESCE(MAX(UNIX_TIMESTAMP(attempted_at)), 0) last
FROM auth_attempts
WHERE identifier = ? AND (ip = ? OR ? IS NULL)
AND attempted_at > (NOW() - INTERVAL ? SECOND)" . $onlyFailures
);
$st->execute([mb_strtolower($identifier), $ip, $ip, $windowSeconds]);
$row = $st->fetch(PDO::FETCH_ASSOC) ?: ['c' => 0, 'last' => 0];
} catch (Throwable $e) {
return ['blocked' => false, 'remaining' => $max, 'retry_after' => 0]; // never lock everyone out on a DB hiccup
}
$count = (int) $row['c'];
$blocked = $count >= $max;
if ($blocked) {
platform_security_event($pdo, 'login_throttled', hub_security_mask_identifier($identifier), $ip);
}
return [
'blocked' => $blocked,
'remaining' => max(0, $max - $count),
'retry_after' => $blocked ? max(0, ((int) $row['last'] + $windowSeconds) - time()) : 0,
];
}
function auth_throttle_hit(PDO $pdo, string $identifier): void {
auth_throttle_ensure_table($pdo);
try {
// ip is NOT NULL on the tables silver and fotografia already had, so a
// null from an odd SAPI would abort the insert and silently disable the
// throttle. Empty string records "unknown" without failing.
$pdo->prepare("INSERT INTO auth_attempts (identifier, ip) VALUES (?, ?)")
->execute([mb_strtolower($identifier), auth_client_ip() ?? '']);
} catch (Throwable $e) {
error_log('[auth] throttle record failed: ' . $e->getMessage());
}
platform_security_event($pdo, 'login_failed', hub_security_mask_identifier($identifier));
}
function auth_throttle_clear(PDO $pdo, string $identifier): void {
try {
$pdo->prepare("DELETE FROM auth_attempts WHERE identifier = ?")
->execute([mb_strtolower($identifier)]);
} catch (Throwable $e) { /* nothing worth failing a login over */ }
}
/**
* Client IP, honouring X-Forwarded-For only when TRUSTED_PROXY says to.
*
* Every site sits behind NPM, so REMOTE_ADDR is always the proxy. Trusting the
* header unconditionally would let a caller forge their own throttle identity.
*/
function auth_client_ip(): ?string {
// A regra mudou-se para functions_client_ip.php, para o antispam dos sites
// que declaram o seu próprio auth_login() lhe poder chegar sem incluir este
// ficheiro. Aqui fica só o nome que o resto do módulo já chama.
return platform_client_ip();
}
// ─── Login / logout ─────────────────────────────────────────────────────────
/**
* Look up an active account by its login identifier and verify the password.
*
* The identifier is the `email` column unless the site overrode `login` in
* auth_schema() -- quack signs in with `username`.
*
* Returns the user row, or null. Deliberately does not distinguish "no such
* account" from "wrong password" to the caller, so a login form cannot be used
* to enumerate which identifiers have accounts.
*/
function auth_attempt(PDO $pdo, string $identifier, string $password): ?array {
$s = auth_schema();
$col = $s['login'] ?: $s['email'];
$st = $pdo->prepare(
"SELECT * FROM `{$s['table']}` WHERE `{$col}` = ? AND " . auth_active_sql() . " LIMIT 1"
);
$st->execute([mb_strtolower(trim($identifier))]);
$user = $st->fetch(PDO::FETCH_ASSOC) ?: null;
if ($user === null) {
// Spend roughly the time a real verify would, so response timing does
// not reveal whether the address exists.
password_verify($password, '$2y$12$usesomesillystringfoeXiOjbGRRjfxSTvlOKz.OKB0PfSUcnu');
return null;
}
return auth_password_verify($pdo, $user, $password) ? $user : null;
}
/** Establishes the session for a verified user. */
function auth_login(PDO $pdo, array $user): void {
$s = auth_schema();
auth_session_regenerate();
$_SESSION['user_id'] = (int) $user[$s['id']];
$_SESSION['user'] = [
'id' => (int) $user[$s['id']],
'email' => (string) ($user[$s['email']] ?? ''),
'name' => (string) ($user[$s['name']] ?? ''),
'role' => (string) ($user[$s['role']] ?? AUTH_ROLE_USER),
];
$_SESSION['_auth_seen'] = time();
if (function_exists('platform_audit')) {
platform_audit($pdo, 'auth.login', ['target_type' => 'user', 'target_id' => (int) $user[$s['id']],
'target_label' => (string) ($user[$s['email']] ?? '')]);
}
$sets = []; $params = [];
if ($s['last_login']) { $sets[] = "`{$s['last_login']}` = NOW()"; }
if ($s['last_ip']) { $sets[] = "`{$s['last_ip']}` = ?"; $params[] = auth_client_ip(); }
if ($sets) {
$params[] = $user[$s['id']];
try {
$pdo->prepare("UPDATE `{$s['table']}` SET " . implode(', ', $sets) . " WHERE `{$s['id']}` = ?")
->execute($params);
} catch (Throwable $e) {
error_log('[auth] last-login stamp failed: ' . $e->getMessage());
}
}
}
function auth_logout(bool $destroy = true): void {
// Antes de limpar a sessão: depois já não se sabe quem saiu.
if (function_exists('platform_audit') && !empty($_SESSION['user']['id'])) {
platform_audit(null, 'auth.logout', ['target_type' => 'user', 'target_id' => (int) $_SESSION['user']['id']]);
}
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000,
$p['path'], $p['domain'], $p['secure'], $p['httponly']);
}
if ($destroy && session_status() === PHP_SESSION_ACTIVE) session_destroy();
}
// ─── Guards ─────────────────────────────────────────────────────────────────
function auth_user(): ?array { return $_SESSION['user'] ?? null; }
function auth_id(): int { return (int) ($_SESSION['user']['id'] ?? 0); }
function auth_check(): bool { return auth_id() > 0; }
function auth_role(): string { return (string) ($_SESSION['user']['role'] ?? ''); }
/** Exact role match. Use auth_at_least() for "this role or better". */
function auth_is(string $role): bool { return auth_role() === $role; }
/** True when the current role ranks at or above $role. */
function auth_at_least(string $role): bool {
return auth_check() && auth_role_rank(auth_role()) >= auth_role_rank($role);
}
function auth_is_admin(): bool { return auth_at_least(AUTH_ROLE_ADMIN); }
function auth_is_sysadmin(): bool { return auth_is(AUTH_ROLE_SYSADMIN); }
/** Any one of the listed roles, exactly. For app roles that are peers. */
function auth_is_any(array $roles): bool {
return auth_check() && in_array(auth_role(), $roles, true);
}
/**
* Guards redirect rather than returning, and take the destination from the
* caller because every site names its login page differently.
*/
function auth_require_login(string $loginUrl): array {
if (!auth_check()) {
$_SESSION['auth_intended'] = $_SERVER['REQUEST_URI'] ?? null;
header('Location: ' . $loginUrl);
exit;
}
return auth_user();
}
function auth_require_at_least(string $role, string $loginUrl, ?string $denyUrl = null): array {
$user = auth_require_login($loginUrl);
if (!auth_at_least($role)) {
header('Location: ' . ($denyUrl ?: $loginUrl));
exit;
}
return $user;
}
function auth_require_any(array $roles, string $loginUrl, ?string $denyUrl = null): array {
$user = auth_require_login($loginUrl);
if (!auth_is_any($roles)) {
header('Location: ' . ($denyUrl ?: $loginUrl));
exit;
}
return $user;
}
/**
* Where to send the user after login: back where they were headed, if that
* destination is local. An open redirect here would turn the login page into a
* phishing hop, so anything absolute is discarded.
*/
function auth_intended_url(string $fallback): string {
$raw = $_SESSION['auth_intended'] ?? null;
unset($_SESSION['auth_intended']);
if (!is_string($raw) || $raw === '') return $fallback;
if (str_starts_with($raw, '//') || preg_match('#^[a-z][a-z0-9+.-]*:#i', $raw)) return $fallback;
return str_starts_with($raw, '/') ? $raw : $fallback;
}
@@ -1,640 +0,0 @@
<?php
// ─── Ciclo de vida de uma atualização ────────────────────────────────────────
//
// Everything that happens around an update, shared by the two ways of starting
// one (the "Atualizar" button and the deploy webhook): the pre-update backup
// gate, the restore point that makes an update reversible, and the webhook
// itself. Kept together because they are one sequence, not three features.
//
// Turns a git push into a deploy: Gitea POSTs here, the platform pulls itself,
// applies the migrations that arrived with the code, and drops the caches. The
// same sequence the "Atualizar plataforma" button runs, minus the human.
//
// Authentication is the webhook secret, HMAC-SHA256 over the RAW body. Both
// header spellings are accepted, so the endpoint works unchanged behind Gitea
// (X-Gitea-Signature) and GitHub (X-Hub-Signature-256):
//
// signature = hash_hmac('sha256', <raw body>, AUTO_UPDATE_SECRET)
//
// The secret lives in .env, not in system_settings: it is a credential you need
// in order to *repair* a site, so it must not depend on the database being
// readable. The on/off switch is a setting, because that is runtime policy an
// operator changes from the backoffice.
//
// Three properties matter more than convenience here, because nobody is
// watching when it runs:
//
// * It never discards work. git_safe_update() fast-forwards, tolerates
// mode-bit and submodule drift, and STOPS on real local commits or edits.
// An unattended `reset --hard` is how you lose the hotfix someone applied
// on the server at 2 a.m.
// * One at a time. A push burst would otherwise start several pulls in the
// same working tree; the lock makes the extras no-ops.
// * It always leaves a trace. Every run — refused, skipped or applied — is
// recorded in settings, which is what the platform page and the dashboard
// strip read.
//
// Public API:
// auto_update_enabled(PDO) / auto_update_migrations_enabled(PDO)
// auto_update_secret(): string
// auto_update_verify(string $body, string $secret): bool
// auto_update_payload_branch(string $body): ?string
// auto_update_run(PDO, string $repo_root, ?string $ref): array
// auto_update_last(PDO): array
// update_backup_settings(PDO): array
// platform_pre_update_backup(PDO, string $repo_root): array
// platform_respond_and_continue(int $code, array $data): void
// update_restore_point(PDO): ?array
// update_restore_point_store(PDO, string $repo_root, string $sha, array, array): void
// update_restore_point_apply(PDO, string $repo_root, bool $discard_local): array
// update_restore_point_clear(PDO): void
// platform_status_summary(PDO, string $repo_root): array
// platform_status_strip(PDO, string $repo_root, ?string $url): void
require_once __DIR__ . '/functions_git.php';
require_once __DIR__ . '/functions_migrations.php';
require_once __DIR__ . '/functions_settings.php';
require_once __DIR__ . '/functions_changelog.php';
function auto_update_secret(): string {
if (function_exists('env')) {
$v = (string) env('AUTO_UPDATE_SECRET', '');
if ($v !== '') return $v;
}
$v = getenv('AUTO_UPDATE_SECRET');
return $v !== false ? (string) $v : '';
}
function auto_update_enabled(PDO $pdo): bool {
return platform_settings_get($pdo, 'auto_update_enabled', '0') === '1';
}
/** Whether a deploy also applies the migrations that came with it. */
function auto_update_migrations_enabled(PDO $pdo): bool {
return platform_settings_get($pdo, 'auto_update_migrations', '1') === '1';
}
/**
* Constant-time check of the webhook signature against the raw body.
*
* Gitea sends the bare hex digest in X-Gitea-Signature; GitHub (and newer
* Gitea) send "sha256=<hex>" in X-Hub-Signature-256. Either satisfies us.
*/
function auto_update_verify(string $body, string $secret): bool {
if ($secret === '') return false;
$expected = hash_hmac('sha256', $body, $secret);
$offered = [];
if (!empty($_SERVER['HTTP_X_GITEA_SIGNATURE'])) {
$offered[] = trim((string) $_SERVER['HTTP_X_GITEA_SIGNATURE']);
}
if (!empty($_SERVER['HTTP_X_HUB_SIGNATURE_256'])) {
$sig = trim((string) $_SERVER['HTTP_X_HUB_SIGNATURE_256']);
$offered[] = str_starts_with($sig, 'sha256=') ? substr($sig, 7) : $sig;
}
foreach ($offered as $sig) {
if (hash_equals($expected, strtolower($sig))) return true;
}
return false;
}
/** Branch name from a push payload's `ref`, or null when it isn't a branch push. */
function auto_update_payload_branch(string $body): ?string {
$data = json_decode($body, true);
$ref = is_array($data) ? (string) ($data['ref'] ?? '') : '';
if ($ref === '' || !str_starts_with($ref, 'refs/heads/')) return null;
$branch = substr($ref, 11);
return $branch !== '' ? $branch : null;
}
/** Path of the per-repo lock file. Kept in the temp dir: always writable. */
function auto_update_lock_path(string $repo_root): string {
return sys_get_temp_dir() . '/auto_update_' . substr(sha1($repo_root), 0, 12) . '.lock';
}
/** Which backups to take before an update touches anything. */
function update_backup_settings(PDO $pdo): array {
return [
'db' => platform_settings_get($pdo, 'update_backup_db', '0') === '1',
'files' => platform_settings_get($pdo, 'update_backup_files', '0') === '1',
];
}
/**
* Load the host's backup wiring, if it has any.
*
* Order matters. The host wrapper (config/functions_backup.php) is tried FIRST
* because it defines BACKUP_TRIGGER_URL_PREFIX before including the shared
* file — on a public/ docroot, shared/ is not reachable over HTTP, and loading
* the shared file directly would bake in a trigger URL that 404s.
*/
function platform_backup_bootstrap(string $repo_root): bool {
if (function_exists('backup_trigger')) return true;
foreach ([$repo_root . '/config/functions_backup.php', __DIR__ . '/functions_backup.php'] as $f) {
if (is_file($f)) {
require_once $f;
if (function_exists('backup_trigger')) return true;
}
}
return false;
}
/** Filenames currently in the backup folder, for one type. */
function _platform_backup_names(string $type): array {
clearstatcache();
$names = [];
foreach (backup_list() as $b) {
if ($b['type'] === $type) $names[$b['filename']] = true;
}
return $names;
}
/**
* Take the configured backups and WAIT for them to land, before the update is
* allowed to touch anything.
*
* Waiting is the whole point. backup_trigger() is fire-and-forget — it starts a
* worker in a separate request and returns in milliseconds. Pulling straight
* after it would race the dump: migrations could apply halfway through it, and
* the "restore point" would be a snapshot of neither the old schema nor the new
* one. libzip writes the archive under a temp name and renames it on close, so
* the final filename appearing is a real completion signal, not a partial file.
*
* A backup that fails or times out ABORTS the update. A toggle that promises a
* restore point and then quietly proceeds without one is worse than no toggle.
*
* Returns ['ok' => bool, 'log' => string[], 'created' => string[]].
*/
function platform_pre_update_backup(PDO $pdo, string $repo_root, int $waitSeconds = 900): array {
$want = update_backup_settings($pdo);
$types = [];
if ($want['db']) $types[] = 'db';
if ($want['files']) $types[] = 'files';
if (!$types) return ['ok' => true, 'log' => [], 'created' => []];
$log = ['── Backup antes de atualizar ──'];
if (!platform_backup_bootstrap($repo_root)) {
$log[] = 'FALHA: esta plataforma não tem os helpers de backup instalados.';
return ['ok' => false, 'log' => $log, 'created' => []];
}
// The wait can outlive any sane max_execution_time — a files archive of a
// photo site is measured in gigabytes.
@set_time_limit(0);
$created = [];
foreach ($types as $type) {
$label = backup_type_label($type);
$before = _platform_backup_names($type);
if (!backup_trigger($type)) {
$log[] = "FALHA: não foi possível arrancar o backup de {$label}.";
return ['ok' => false, 'log' => $log, 'created' => $created];
}
$deadline = time() + $waitSeconds;
$new = null;
while (time() < $deadline) {
sleep(2);
foreach (array_keys(_platform_backup_names($type)) as $name) {
if (!isset($before[$name])) { $new = $name; break 2; }
}
}
if ($new === null) {
$log[] = "FALHA: o backup de {$label} não ficou pronto em {$waitSeconds}s."
. ' Atualização cancelada — nada foi alterado.';
return ['ok' => false, 'log' => $log, 'created' => $created];
}
$created[] = $new;
$log[] = "OK {$label}: {$new}";
}
return ['ok' => true, 'log' => $log, 'created' => $created];
}
// ─── Ponto de restauro ───────────────────────────────────────────────────────
//
// "Atualizei, testei, correu mal, quero voltar atrás." Every update that
// actually moves HEAD records where it came from; the button then walks the
// branch back to that commit.
//
// `reset --hard` and not `revert`: the pulled commits still exist on the remote
// and in this repo's object store, so moving the branch pointer back loses
// nothing and leaves the working tree in exactly the state it had before the
// update — which is the state that was known to work. A `revert` would instead
// invent new commits that then have to be pushed, and would leave this server
// diverged from every other one.
//
// What it does NOT undo is the database. Migrations that ran during the update
// stay applied, and old code against a new schema can be worse than new code
// against it. So the restore point remembers which migrations ran and which
// backup was taken just before, and the UI says so plainly instead of implying
// a clean rewind.
/** HEAD's full SHA, or '' when it cannot be read. */
function update_head_sha(string $repo_root): string {
if (!function_exists('git_run')) return '';
$r = git_run(['rev-parse', 'HEAD'], $repo_root);
$s = $r['ok'] ? trim($r['output']) : '';
return preg_match('/^[0-9a-f]{40}$/', $s) ? $s : '';
}
/**
* Remember the commit an update started from.
*
* Only ever called when HEAD actually moved: recording a restore point for an
* update that changed nothing would offer a button that does nothing.
*/
function update_restore_point_store(
PDO $pdo, string $repo_root, string $sha,
array $migrations = [], array $backups = []
): void {
if (!preg_match('/^[0-9a-f]{40}$/', $sha)) return;
$info = git_run(['log', '-1', '--format=%s', $sha], $repo_root);
$when = git_run(['log', '-1', '--format=%cd', '--date=format:%Y-%m-%d %H:%M', $sha], $repo_root);
$br = git_run(['rev-parse', '--abbrev-ref', 'HEAD'], $repo_root);
try {
platform_settings_set($pdo, 'update_restore_sha', $sha);
platform_settings_set($pdo, 'update_restore_subject', $info['ok'] ? trim($info['output']) : '');
platform_settings_set($pdo, 'update_restore_commit_at', $when['ok'] ? trim($when['output']) : '');
platform_settings_set($pdo, 'update_restore_branch', $br['ok'] ? trim($br['output']) : '');
platform_settings_set($pdo, 'update_restore_at', gmdate('Y-m-d H:i:s'));
platform_settings_set($pdo, 'update_restore_migrations', implode(',', $migrations));
platform_settings_set($pdo, 'update_restore_backups', implode(',', $backups));
} catch (Throwable $e) {
error_log('[restore_point] ' . $e->getMessage());
}
}
/** The current restore point, or null when there is nothing to go back to. */
function update_restore_point(PDO $pdo): ?array {
$sha = (string) platform_settings_get($pdo, 'update_restore_sha', '');
if (!preg_match('/^[0-9a-f]{40}$/', $sha)) return null;
$mig = array_values(array_filter(explode(',', (string) platform_settings_get($pdo, 'update_restore_migrations', ''))));
$bk = array_values(array_filter(explode(',', (string) platform_settings_get($pdo, 'update_restore_backups', ''))));
return [
'sha' => $sha,
'short' => substr($sha, 0, 8),
'subject' => (string) platform_settings_get($pdo, 'update_restore_subject', ''),
'commit_at' => (string) platform_settings_get($pdo, 'update_restore_commit_at', ''),
'branch' => (string) platform_settings_get($pdo, 'update_restore_branch', ''),
'updated_at' => (string) platform_settings_get($pdo, 'update_restore_at', ''),
'migrations' => $mig,
'backups' => $bk,
];
}
function update_restore_point_clear(PDO $pdo): void {
foreach (['sha', 'subject', 'commit_at', 'branch', 'at', 'migrations', 'backups'] as $k) {
try { platform_settings_set($pdo, 'update_restore_' . $k, ''); } catch (Throwable $e) {}
}
}
/**
* Walk the branch back to the recorded commit.
*
* The SHA comes from our own settings row and is re-validated as a real commit
* in this repository — a reset target is never read from the request, so a
* forged POST cannot point this at anything.
*
* Refuses while there are uncommitted content changes unless $discard_local is
* explicitly set: `reset --hard` would silently destroy them, and somebody
* debugging a bad deploy is exactly the person likely to have edited a file on
* the server.
*/
function update_restore_point_apply(PDO $pdo, string $repo_root, bool $discard_local = false): array {
$rp = update_restore_point($pdo);
if ($rp === null) {
return ['ok' => false, 'output' => 'Não há ponto de restauro registado.'];
}
$exists = git_run(['cat-file', '-e', $rp['sha'] . '^{commit}'], $repo_root);
if (!$exists['ok']) {
return ['ok' => false, 'output' =>
"O commit {$rp['short']} já não existe neste repositório — não é possível voltar atrás por aqui."];
}
$log = [];
$state = git_local_state($repo_root);
if ($state['content_dirty'] && !$discard_local) {
return ['ok' => false, 'output' => implode("\n", [
'── Recusado: há alterações locais por comitar ──',
$state['dirty_files'],
'',
'Um reset --hard apagava-as. Comita, faz stash, ou marca',
'"Confirmo descartar alterações locais" para avançar mesmo assim.',
])];
}
$undo = git_run(['rev-list', '--count', $rp['sha'] . '..HEAD'], $repo_root);
$n = $undo['ok'] ? (int) trim($undo['output']) : 0;
$log[] = "── Voltar a {$rp['short']} — " . ($rp['subject'] ?: '(sem assunto)') . ' ──';
if ($n > 0) $log[] = "A desfazer {$n} commit(s) locais. Continuam no remote; isto só recua este servidor.";
$reset = git_run(['reset', '--hard', $rp['sha']], $repo_root);
$log[] = trim($reset['output']) !== '' ? $reset['output'] : '(reset --hard concluído)';
if (!$reset['ok']) {
return ['ok' => false, 'output' => implode("\n", $log)];
}
$log[] = '';
$log[] = '── Submódulos ──';
foreach (git_sync_submodules($repo_root)['log'] as $l) $log[] = $l;
if (function_exists('opcache_reset')) @opcache_reset();
if (function_exists('settings_cache_clear')) settings_cache_clear();
platform_settings_cache_clear();
$log[] = '';
$log[] = '(OPcache + caches de definições invalidados)';
if ($rp['migrations']) {
$log[] = '';
$log[] = '── ATENÇÃO: a base de dados NÃO recuou ──';
$log[] = 'Estas migrations correram nesta atualização e continuam aplicadas:';
foreach ($rp['migrations'] as $m) $log[] = ' · ' . $m;
if ($rp['backups']) {
$log[] = '';
$log[] = 'Backup tirado imediatamente antes da atualização:';
foreach ($rp['backups'] as $b) $log[] = ' · ' . $b;
$log[] = 'Restaura-o pela página Restaurar base de dados se o esquema novo for o problema.';
}
}
// Consumed: the branch is back where it was, so there is nothing left to
// undo. The next real update records a fresh one.
update_restore_point_clear($pdo);
try {
platform_settings_set($pdo, 'update_reverted_at', gmdate('Y-m-d H:i:s'));
platform_settings_set($pdo, 'update_reverted_to', $rp['short']);
} catch (Throwable $e) {}
return ['ok' => true, 'output' => implode("\n", $log), 'undone' => $n];
}
/**
* Send the response, then keep working after the client has gone.
*
* Gitea gives a webhook delivery 5 seconds by default. A git pull alone beats
* that, and a pre-update backup of a media library beats it by minutes — so
* holding the connection until the work finished would paint every successful
* deploy red in Gitea's delivery list. Answer immediately, work afterwards, and
* let the platform page and the dashboard strip carry the outcome.
*/
function platform_respond_and_continue(int $code, array $data): void {
ignore_user_abort(true);
@set_time_limit(0);
$body = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
header('Content-Length: ' . strlen($body));
header('Connection: close');
echo $body;
while (ob_get_level() > 0) @ob_end_flush();
@flush();
// PHP-FPM hands the connection back here; on mod_php it is the
// Content-Length + Connection: close above that releases the client.
if (function_exists('fastcgi_finish_request')) @fastcgi_finish_request();
}
/**
* Pull, migrate, invalidate. Returns:
* ['ok' => bool, 'status' => 'updated'|'busy'|'failed', 'summary' => string,
* 'output' => string, 'migrations' => string[]]
*
* $ref is the branch the push was for, or null for a manual run.
*/
function auto_update_run(PDO $pdo, string $repo_root, ?string $ref = null): array {
$lockFile = @fopen(auto_update_lock_path($repo_root), 'c');
if (!$lockFile || !flock($lockFile, LOCK_EX | LOCK_NB)) {
if ($lockFile) fclose($lockFile);
// Not an error: a push burst means several deliveries for one state, and
// the run already in flight is picking up the same commits.
return [
'ok' => true, 'status' => 'busy', 'migrations' => [],
'summary' => 'Já havia uma atualização a decorrer — este pedido foi ignorado.',
'output' => '',
];
}
@set_time_limit(300);
$log = [];
$log[] = '── Auto-update ' . gmdate('Y-m-d H:i:s') . ' UTC'
. ($ref !== null ? ' · push para ' . $ref : ' · execução manual') . ' ──';
// First, and blocking: the restore point has to exist before anything moves.
$bk = platform_pre_update_backup($pdo, $repo_root);
foreach ($bk['log'] as $l) $log[] = $l;
if (!$bk['ok']) {
flock($lockFile, LOCK_UN);
fclose($lockFile);
$out = ['ok' => false, 'status' => 'failed', 'migrations' => [],
'summary' => 'Backup pré-atualização falhou — nada foi atualizado.',
'output' => implode("\n", $log)];
auto_update_record($pdo, $out, $ref);
return $out;
}
if ($bk['log']) $log[] = '';
$before_sha = update_head_sha($repo_root);
$res = git_safe_update($repo_root);
$log[] = $res['output'] !== '' ? $res['output'] : '(sem saída)';
if (!$res['ok']) {
flock($lockFile, LOCK_UN);
fclose($lockFile);
$out = ['ok' => false, 'status' => 'failed', 'migrations' => [],
'summary' => 'Atualização interrompida — há trabalho local por resolver no servidor.',
'output' => implode("\n", $log)];
auto_update_record($pdo, $out, $ref);
return $out;
}
// Migrations arrive in the same commit as the code that needs them, so a
// deploy that pulls one without the other leaves the site broken between
// the two. Applied in filename order; the first failure stops the rest.
$applied = [];
if (auto_update_migrations_enabled($pdo)) {
$log[] = '';
$log[] = '── Migrations pendentes ──';
$error = null;
foreach (migrations_status($pdo) as $row) {
if ($row['applied_at']) continue;
$r = migrations_apply_file($pdo, $row['filename'], null);
if (!empty($r['ok'])) {
$applied[] = $row['filename'];
$log[] = 'OK ' . $row['filename'] . ' (' . $r['applied'] . '/' . $r['total'] . ' statements)';
} else {
$error = $row['filename'] . ': ' . ($r['error'] ?? 'erro desconhecido');
$log[] = 'FALHA ' . $error;
break;
}
}
if (!$applied && $error === null) $log[] = '(nenhuma pendente)';
if ($error !== null) {
flock($lockFile, LOCK_UN);
fclose($lockFile);
$out = ['ok' => false, 'status' => 'failed', 'migrations' => $applied,
'summary' => 'Código atualizado, mas uma migration falhou: ' . $error,
'output' => implode("\n", $log)];
auto_update_record($pdo, $out, $ref);
return $out;
}
} else {
$log[] = '';
$log[] = '(migrations automáticas desligadas — aplicar à mão)';
}
if (function_exists('opcache_reset')) @opcache_reset();
if (function_exists('settings_cache_clear')) settings_cache_clear();
platform_settings_cache_clear();
$log[] = '';
$log[] = '(OPcache + caches de definições invalidados)';
flock($lockFile, LOCK_UN);
fclose($lockFile);
// Só se HEAD mexeu mesmo. Um deploy que não trouxe nada não deve deixar um
// botão "reverter" que não reverteria coisa nenhuma — e não apaga o ponto
// de restauro da última atualização que trouxe.
$after_sha = update_head_sha($repo_root);
if ($before_sha !== '' && $after_sha !== '' && $before_sha !== $after_sha) {
update_restore_point_store($pdo, $repo_root, $before_sha, $applied, $bk['created']);
$log[] = 'Ponto de restauro: ' . substr($before_sha, 0, 8);
}
$summary = 'Plataforma atualizada'
. ($applied ? ' · ' . count($applied) . ' migration(s) aplicada(s)' : '')
. '.';
$out = ['ok' => true, 'status' => 'updated', 'migrations' => $applied,
'summary' => $summary, 'output' => implode("\n", $log)];
auto_update_record($pdo, $out, $ref);
return $out;
}
/** Persist the outcome so the UI can show it without re-running anything. */
function auto_update_record(PDO $pdo, array $result, ?string $ref): void {
if (function_exists('platform_audit')) {
// Sem sessão (webhook do Gitea) o autor é a própria atualização automática.
platform_audit($pdo, 'platform.auto_update', [
'actor' => platform_audit_actor() ?? ['id' => null, 'name' => 'atualização automática', 'role' => 'webhook'],
'target_type' => 'platform',
'summary' => (string) ($result['summary'] ?? '') . (empty($result['ok']) ? ' — falhou' : ''),
'changes' => ['status' => $result['status'] ?? null, 'ref' => $ref],
]);
}
try {
platform_settings_set($pdo, 'auto_update_last_at', gmdate('Y-m-d H:i:s'));
platform_settings_set($pdo, 'auto_update_last_ok', !empty($result['ok']) ? '1' : '0');
platform_settings_set($pdo, 'auto_update_last_status', (string) ($result['status'] ?? ''));
platform_settings_set($pdo, 'auto_update_last_summary', (string) ($result['summary'] ?? ''));
platform_settings_set($pdo, 'auto_update_last_ref', (string) ($ref ?? ''));
// Bounded: this is a settings row, not a log file.
platform_settings_set($pdo, 'auto_update_last_output',
mb_substr((string) ($result['output'] ?? ''), -8000));
} catch (Throwable $e) {
error_log('[auto_update] ' . $e->getMessage());
}
}
/** Last recorded run, with sane defaults when there has never been one. */
function auto_update_last(PDO $pdo): array {
$at = (string) platform_settings_get($pdo, 'auto_update_last_at', '');
return [
'at' => $at !== '' ? $at : null,
'ok' => platform_settings_get($pdo, 'auto_update_last_ok', '') === '1',
'status' => (string) platform_settings_get($pdo, 'auto_update_last_status', ''),
'summary' => (string) platform_settings_get($pdo, 'auto_update_last_summary', ''),
'ref' => (string) platform_settings_get($pdo, 'auto_update_last_ref', ''),
'output' => (string) platform_settings_get($pdo, 'auto_update_last_output', ''),
];
}
/**
* Everything the "at a glance" strip needs, and nothing that costs a process.
*
* Deliberately git-free: this runs on a dashboard that loads on every visit,
* and a `git fetch` there would put a network round-trip in front of the page.
* Whether the remote has moved is a question for the platform page.
*/
function platform_status_summary(PDO $pdo, string $repo_root): array {
$pending = 0;
try {
foreach (migrations_status($pdo) as $r) {
if ($r['applied_at'] === null) $pending++;
}
} catch (Throwable $e) {
$pending = null; // table unreachable — say nothing rather than "0"
}
return [
'version' => changelog_latest()['version'],
'migrations_pending' => $pending,
'auto_update' => auto_update_last($pdo),
'auto_update_on' => auto_update_enabled($pdo),
];
}
/**
* One discreet line of platform state, for a backoffice dashboard.
*
* A muted strip, not a card: this is ambient information for whoever
* administers the site, and it must not compete with the day's work on that
* page. It raises its voice only when something is actually pending.
*/
function platform_status_strip(PDO $pdo, string $repo_root, ?string $url = null): void {
$s = platform_status_summary($pdo, $repo_root);
$url = $url ?? platform_admin_url('platform_update.php');
$au = $s['auto_update'];
$e = fn($v) => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
?>
<div class="d-flex align-items-center flex-wrap gap-2 small text-muted mb-3">
<i class="bi bi-hdd-network"></i>
<?php if ($s['version'] !== null): ?>
<span><?= $e($s['version']) ?></span>
<?php endif; ?>
<?php if (!empty($s['migrations_pending'])): ?>
<span class="text-secondary">·</span>
<a href="<?= $e($url) ?>" class="text-decoration-none">
<span class="badge text-bg-warning text-dark"><?= (int) $s['migrations_pending'] ?></span>
migration(s) por aplicar
</a>
<?php endif; ?>
<?php if ($s['auto_update_on']): ?>
<span class="text-secondary">·</span>
<span<?= $au['summary'] !== '' ? ' title="' . $e($au['summary']) . '"' : '' ?>>
<i class="bi bi-arrow-repeat"></i>
auto-update
<?php if ($au['at'] !== null): ?>
<?php if (!$au['ok']): ?><span class="text-danger fw-semibold">falhou</span> <?php endif; ?>
<?= $e(date('d/m H:i', strtotime($au['at'] . ' UTC'))) ?>
<?php else: ?>
ligado
<?php endif; ?>
</span>
<?php elseif ($au['at'] !== null && !$au['ok']): ?>
<span class="text-secondary">·</span>
<span class="text-danger">a última atualização automática falhou</span>
<?php endif; ?>
<a href="<?= $e($url) ?>" class="ms-auto text-decoration-none">Plataforma <i class="bi bi-chevron-right"></i></a>
</div>
<?php
}
-372
View File
@@ -1,372 +0,0 @@
<?php
// ─── Backup helpers (shared) ─────────────────────────────────────────────────
//
// Path assumptions (when this file lives at <host>/shared/platform-tools/):
// backup_dir() → <host>/backups/ (where ZIPs land + are listed)
// trigger_url($t) → base_url('shared/platform-tools/backups/<script>.php?token=…')
// Override the URL prefix by defining BACKUP_TRIGGER_URL_PREFIX
// before this file loads.
//
// Public API:
// backup_dir(): string
// backup_filename_is_safe(string): bool
// backup_human_size(int): string
// backup_list(): array
// backup_trigger(string $type): bool ($type in {files, db})
// backup_type_label(string): string
// backup_type_badge(string): string
// backup_retention_policy(): array
// backup_retention_summary(?array $policy = null): string
// backup_created_at(string $filename, int $fallback): int
// backup_gfs_keep(array $items, array $policy, int $now): array
// backup_prune(bool $dryRun = false): array
function backup_dir(): string {
$dir = dirname(__DIR__, 2) . '/backups';
// The directory must exist on the host before we can call realpath().
if (!is_dir($dir)) @mkdir($dir, 0775, true);
$dir = realpath($dir) ?: $dir;
backup_dir_protect($dir);
return $dir;
}
/**
* Deny direct HTTP access to the backups folder. The ZIPs contain sensitive
* data (full DB dump, user uploads) and are only meant to leave the server
* through api/backup_download.php (auth'd + attachment header). Idempotent —
* writes the .htaccess once and leaves an existing one alone.
*/
function backup_dir_protect(string $dir): void {
$ht = $dir . '/.htaccess';
if (is_file($ht)) return;
@file_put_contents($ht,
"# Gerado por platform-tools. Os backups contêm dados sensíveis (BD completa,\n"
. "# uploads); só podem sair do servidor via api/backup_download.php.\n"
. "<IfModule mod_authz_core.c>\n"
. " Require all denied\n"
. "</IfModule>\n"
. "<IfModule !mod_authz_core.c>\n"
. " Order allow,deny\n"
. " Deny from all\n"
. "</IfModule>\n"
);
}
function backup_filename_is_safe(string $filename): bool {
// Two generations coexist: the legacy minute-granularity name and the
// current one with seconds + random suffix (unguessable, no same-minute
// overwrite). Both must stay listable/downloadable.
return (bool) preg_match(
'/^(db_)?backup_\d{2}_\d{2}_\d{4}_\d{2}_\d{2}(?:_\d{2}_[0-9a-f]{6})?\.zip$/',
$filename
);
}
function backup_human_size(int $bytes): string {
// GB matters: um site com galerias produz arquivos de uploads na casa dos
// milhares de MB, e "4812.9 MB" obriga a dividir de cabeça para perceber a
// ordem de grandeza — que é exactamente o que se está a tentar ler.
if ($bytes >= 1_073_741_824) {
return round($bytes / 1_073_741_824, 1) . ' GB';
}
if ($bytes >= 1_048_576) {
return round($bytes / 1_048_576, 1) . ' MB';
}
return round($bytes / 1_024, 1) . ' KB';
}
function backup_list(): array {
$dir = backup_dir();
$files = glob($dir . '/*.zip') ?: [];
$items = [];
foreach ($files as $path) {
$name = basename($path);
if (!backup_filename_is_safe($name)) {
continue;
}
$type = str_starts_with($name, 'db_backup_') ? 'db' : 'files';
$bytes = filesize($path);
$items[] = [
'filename' => $name,
'size_bytes' => $bytes,
'size_human' => backup_human_size($bytes),
'created_at' => filemtime($path),
'type' => $type,
];
}
usort($items, fn($a, $b) => $b['created_at'] - $a['created_at']);
return $items;
}
function backup_trigger(string $type): bool {
if (!in_array($type, ['files', 'db'], true)) {
return false;
}
if (!function_exists('curl_init')) {
error_log('backup_trigger: cURL não disponível');
return false;
}
if (!defined('BACKUP_TOKEN')) {
require_once __DIR__ . '/backups/config.php';
}
$script = $type === 'db' ? 'db_backup.php' : 'backup_maker.php';
// Where the self-call lands. Default assumes shared/ is inside the document
// root. Under a public/ docroot it is not, so hosts point this at a thin
// wrapper instead. Constant wins (fotografia defines it in
// config/functions_backup.php); .env covers hosts with no such file.
$prefix = defined('BACKUP_TRIGGER_URL_PREFIX')
? BACKUP_TRIGGER_URL_PREFIX
: (function_exists('env')
? ((string) (env('BACKUP_TRIGGER_URL_PREFIX') ?: 'shared/platform-tools/backups/'))
: 'shared/platform-tools/backups/');
// base_url() when the host has one; APP_BASE_URL from .env otherwise, so a
// host without the helper still works.
$path = ltrim($prefix . $script, '/');
if (function_exists('base_url')) {
$url = base_url($path);
} else {
$base = function_exists('env') ? (string) env('APP_BASE_URL', '') : (string) getenv('APP_BASE_URL');
if ($base === '') {
error_log('backup_trigger: no base URL available (no base_url(), no APP_BASE_URL).');
return false;
}
$url = rtrim($base, '/') . '/' . $path;
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
// Fire-and-forget; the worker keeps running via ignore_user_abort + set_time_limit(0).
CURLOPT_TIMEOUT => 5,
// Self-call to the host's own base_url — hosts frequently sit behind
// self-signed/internal certs, hence no peer verification here.
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => false,
CURLOPT_FOLLOWLOCATION => true,
// Token no corpo do POST, não no URL — fora dos access logs.
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query(['token' => BACKUP_TOKEN]),
// Um redirect (http→https, www) não pode transformar o POST em GET
// e deixar cair o corpo — o token iria com ele.
CURLOPT_POSTREDIR => 7, // CURL_REDIR_POST_ALL
]);
curl_exec($ch);
$errno = curl_errno($ch);
curl_close($ch);
// errno 28 = CURLE_OPERATION_TIMEDOUT — expected when backup takes longer than 5 s.
$ok = $errno === 0 || $errno === 28;
error_log("backup_trigger: type={$type} curl_errno={$errno} ok=" . ($ok ? 'yes' : 'no'));
return $ok;
}
// ─── Retenção avô-pai-filho ─────────────────────────────────────────────────
//
// Antes: tudo o que tivesse mais de BACKUP_KEEP_DAYS dias era apagado, e tudo o
// que tivesse menos ficava — incluindo cinco cópias do mesmo dia (a da noite, a
// de antes de atualizar, as do hub, as de segurança de um restauro). Num site
// com galerias são gigabytes por cópia.
//
// Agora, por tipo (db e ficheiros contam em separado):
// • tudo das últimas 24 h — são os pontos de restauro de uma atualização;
// • a mais recente de cada um dos últimos BACKUP_KEEP_DAYS dias com cópia;
// • a mais recente de cada uma das últimas BACKUP_KEEP_WEEKS semanas;
// • a mais recente de cada um dos últimos BACKUP_KEEP_MONTHS meses;
// • e a mais recente de todas, sempre.
//
// Cada escalão começa onde o anterior acaba: as semanais são semanas mais
// antigas do que a diária mais antiga, e as mensais meses mais antigos do que a
// semanal mais antiga. Sem isto a «mensal» era a cópia mais recente do mês
// corrente — que já é a diária de hoje — e BACKUP_KEEP_MONTHS=1 não guardava
// nada. Com 7/4/1 cobre-se cerca de dois meses.
//
// «Dias com cópia», e não «dias de calendário»: se as cópias pararem durante um
// mês, a poda não pode ficar sem nada para guardar — é exactamente quando as
// antigas são as únicas que há.
function backup_retention_policy(): array {
$read = static function (string $key, int $default): int {
if (defined($key)) return max(0, (int) constant($key));
$v = function_exists('env') ? env($key, null) : (getenv($key) !== false ? getenv($key) : null);
return ($v === null || $v === '') ? $default : max(0, (int) $v);
};
return [
'recent_hours' => 24,
'days' => $read('BACKUP_KEEP_DAYS', 7),
'weeks' => $read('BACKUP_KEEP_WEEKS', 4),
'months' => $read('BACKUP_KEEP_MONTHS', 1),
];
}
/**
* A data da cópia pelo nome do ficheiro, que não muda quando o ficheiro é
* copiado ou restaurado de outro disco — ao contrário do mtime.
*/
function backup_created_at(string $filename, int $fallback): int {
if (preg_match('/_(\d{2})_(\d{2})_(\d{4})_(\d{2})_(\d{2})(?:_(\d{2}))?(?:_[0-9a-f]{6})?\.zip$/', $filename, $m)) {
$ts = mktime((int) $m[4], (int) $m[5], (int) ($m[6] ?? 0), (int) $m[2], (int) $m[1], (int) $m[3]);
if ($ts !== false) return $ts;
}
return $fallback;
}
/**
* Que cópias ficam. Função pura: recebe a lista (filename, type, created_at) e
* devolve [filename => motivo]. O que não vier na resposta é para apagar.
*/
function backup_gfs_keep(array $items, array $policy, int $now): array {
$tiers = [
'days' => ['Y-m-d', 'diária'],
'weeks' => ['o-\WW', 'semanal'],
'months' => ['Y-m', 'mensal'],
];
$byType = [];
foreach ($items as $it) $byType[$it['type']][] = $it;
$keep = [];
foreach ($byType as $list) {
usort($list, fn($a, $b) => $b['created_at'] <=> $a['created_at']);
$keep[$list[0]['filename']] = 'mais recente';
foreach ($list as $it) {
if ($now - (int) $it['created_at'] < $policy['recent_hours'] * 3600) {
$keep[$it['filename']] ??= 'últimas ' . $policy['recent_hours'] . ' h';
}
}
// $floor: a cópia mais antiga guardada pelo escalão anterior. O escalão
// seguinte só olha para o que é mais antigo, e salta o balde dela — esse
// período já está representado.
$floor = null;
foreach ($tiers as $tier => [$fmt, $label]) {
$skip = $floor === null ? null : date($fmt, $floor);
$buckets = [];
$oldest = null;
// Da mais recente para a mais antiga: a primeira que cai num balde é
// a mais recente desse dia/semana/mês.
foreach ($list as $it) {
$t = (int) $it['created_at'];
if ($floor !== null && $t >= $floor) continue;
if (count($buckets) >= $policy[$tier]) break;
$key = date($fmt, $t);
if ($key === $skip || isset($buckets[$key])) continue;
$buckets[$key] = true;
$keep[$it['filename']] ??= $label;
$oldest = $t;
}
if ($oldest !== null) $floor = $oldest;
}
}
return $keep;
}
/**
* Aplica a retenção à pasta de cópias. Com $dryRun não apaga nada — diz o que
* apagaria. Devolve ['policy', 'kept' => [[filename, reason]], 'deleted', 'errors'].
*/
function backup_prune(bool $dryRun = false): array {
$dir = backup_dir();
$policy = backup_retention_policy();
$now = time();
$items = [];
foreach (backup_list() as $b) {
$b['created_at'] = backup_created_at($b['filename'], (int) $b['created_at']);
$items[] = $b;
}
$keep = backup_gfs_keep($items, $policy, $now);
$out = ['policy' => $policy, 'kept' => [], 'deleted' => [], 'errors' => []];
foreach ($items as $b) {
if (isset($keep[$b['filename']])) {
$out['kept'][] = ['filename' => $b['filename'], 'reason' => $keep[$b['filename']]];
} elseif ($dryRun || @unlink($dir . '/' . $b['filename'])) {
$out['deleted'][] = $b['filename'];
} else {
$out['errors'][] = $b['filename'];
}
}
// .sql de dumps interrompidos antes de irem para o ZIP: texto em claro que
// não aparece na lista. Um dia chega para não apanhar um dump a decorrer.
foreach (glob($dir . '/db_backup_*.sql') ?: [] as $f) {
if (filemtime($f) < $now - 86400) {
if ($dryRun || @unlink($f)) $out['deleted'][] = basename($f);
else $out['errors'][] = basename($f);
}
}
return $out;
}
/** Linha legível da política, para a página e para o log dos workers. */
function backup_retention_summary(?array $policy = null): string {
$p = $policy ?? backup_retention_policy();
return sprintf('tudo das últimas %d h, e a mais recente de cada um dos últimos %d dias, %d semanas e %d meses',
$p['recent_hours'], $p['days'], $p['weeks'], $p['months']);
}
function backup_type_label(string $type): string {
return match($type) {
'db' => 'Base de dados',
'files' => 'Uploads',
default => 'Desconhecido',
};
}
function backup_type_badge(string $type): string {
return match($type) {
'db' => 'info',
'files' => 'secondary',
default => 'dark',
};
}
// ─── Encriptação dos artefactos ─────────────────────────────────────────────
//
// Um dump de base de dados em claro no disco é o ficheiro mais sensível que uma
// destas plataformas produz: leva a tabela users com os hashes, os contactos, e
// no caso do dance os contratos assinados -- que incluem dados de menores.
//
// A chave é derivada da APP_SECRET_KEY, que só existe no .env e nunca entra num
// dump. Isso tem uma consequência que é preciso dizer alto: **perder a
// APP_SECRET_KEY passa a significar perder todos os backups encriptados com
// ela.** Guarde-a onde guarda as credenciais do servidor, não só no .env.
//
// Sem chave, o backup sai em claro e diz-se no log. Recusar-se a fazer backup
// por falta de chave seria pior do que um backup desprotegido: a alternativa a
// um ficheiro legível não é um ficheiro seguro, é ficheiro nenhum.
function platform_backup_key(): ?string
{
foreach (['APP_SECRET_KEY', 'BACKUP_TOKEN'] as $k) {
$v = defined($k) ? (string) constant($k) : '';
if ($v === '' && function_exists('env')) {
$v = (string) env($k, '');
}
if ($v === '') {
$v = (string) (getenv($k) ?: '');
}
if ($v !== '' && $v !== 'replace_me') {
// Separada por domínio: a mesma APP_SECRET_KEY cifra as settings, e
// a mesma chave para dois fins nunca é boa ideia.
return hash('sha256', 'am-backup|' . $v);
}
}
return null;
}
/** Há chave para encriptar? A página de backups usa isto para avisar. */
function platform_backup_encryption_available(): bool
{
return platform_backup_key() !== null
&& class_exists('ZipArchive')
&& method_exists('ZipArchive', 'setEncryptionName')
&& defined('ZipArchive::EM_AES_256');
}
@@ -1,28 +0,0 @@
<?php
// ─── Server capability probes (shared) ──────────────────────────────────────
//
// Shared hosting frequently disables process-spawning functions via
// `disable_functions` in php.ini — calling one is then a FATAL "undefined
// function" error. Everything that shells out (git, composer) must probe
// first and degrade gracefully: hide the tool, refuse the action, never fatal.
/**
* True when PHP can spawn and manage subprocesses. Checks the whole proc_*
* family the toolkit uses (git_run needs open/close; composer_run also needs
* get_status/terminate) — hosts disable them as a set, and a partial set is
* as unusable as none.
*/
function platform_proc_open_available(): bool {
static $ok = null;
if ($ok === null) {
$disabled = array_map('trim', explode(',', strtolower((string) ini_get('disable_functions'))));
$ok = true;
foreach (['proc_open', 'proc_close', 'proc_get_status', 'proc_terminate'] as $fn) {
if (!function_exists($fn) || in_array($fn, $disabled, true)) {
$ok = false;
break;
}
}
}
return $ok;
}
-839
View File
@@ -1,839 +0,0 @@
<?php
// ─── Captcha partilhado: uma pergunta escrita, para qualquer formulário ─────
//
// Um desafio que a pessoa lê e responde, para os sítios onde as defesas
// invisíveis não chegam. Serve qualquer formulário de qualquer site da frota:
// registo, recuperação de password, contacto, comentários, inscrições.
//
// ─── Porque não é o reCAPTCHA (nem o hCaptcha, nem o Turnstile) ─────────────
//
// Três razões, e nenhuma é gosto por fazer à mão:
//
// 1. O ASSETS.md fixou a 2026-09-01 que nada de front-end sai para a rede em
// produção. Um captcha de terceiros é exactamente isso, e ainda por cima
// num formulário que não abre sem ele: o serviço em baixo é o formulário
// em baixo.
// 2. Corre JavaScript de terceiros na página — incluindo, quando o captcha
// está no back-office, na sessão de quem está autenticado como admin.
// 3. Manda para fora o IP e o comportamento de cada visitante, o que num site
// europeu é uma decisão de RGPD que ninguém tomou, e não uma dependência
// técnica.
//
// O preço é assumido: isto trava scripts, não trava um humano pago para
// resolver desafios. Era igualmente verdade com o reCAPTCHA.
//
// ─── A relação com os dois módulos de anti-spam ─────────────────────────────
//
// São três coisas diferentes e complementares:
//
// functions_antispam.php honeypot + blocklist + limite de 24 h
// functions_antispam_forms.php pontuação do conteúdo de um formulário
// este ficheiro uma pergunta visível, com resposta certa
//
// Os dois primeiros são invisíveis e recusam em silêncio — de propósito, porque
// um bot não deve ficar a saber o que o apanhou e uma lead recusada por engano
// tem de poder ser recuperada do registo. Isso só funciona quando há conteúdo
// para pontuar e quando a recusa pode ser silenciosa.
//
// O captcha é para o resto: um formulário de registo ou de recuperação de
// password não tem texto livre para pontuar, e recusar em silêncio um pedido de
// password nova deixa a pessoa a carregar no botão para sempre. Aqui a pessoa é
// informada, e pode tentar outra vez.
//
// Usar os dois juntos é o normal e não é redundante: as armadilhas apanham o
// script que nem olha para a página, o captcha apanha o que olha.
//
// ─── O desenho ──────────────────────────────────────────────────────────────
//
// **Sem estado no servidor.** O desafio viaja num token assinado por HMAC: o
// carimbo de tempo, um nonce aleatório e a assinatura. A pergunta *e a resposta*
// são derivadas do nonce com a chave do site, portanto a resposta nunca sai
// daqui e não há nada para guardar entre pedidos. Nenhuma tabela, nenhuma sessão
// obrigatória, e funciona atrás de qualquer cache.
//
// **Uma pergunta, uma tentativa.** Havendo sessão, o nonce fica marcado como
// usado assim que é verificado — certo ou errado. É o que impede que alguém peça
// um desafio e experimente as vinte respostas possíveis com o mesmo token. Sem
// sessão activa degrada para "reutilizável dentro da janela", e é por isso que o
// ttl é curto por omissão.
//
// **Funciona sem JavaScript**, porque a regra 10 do PHILOSOPHY.md diz que a
// página funciona sem ele, e porque um leitor de ecrã lê uma pergunta escrita e
// não lê uma imagem distorcida. Pela mesma razão não há imagens: a resposta é
// aceite em algarismos ou por extenso, com ou sem acentos.
//
// ─── Ligar num site ─────────────────────────────────────────────────────────
//
// require_once __DIR__ . '/../shared/platform-tools/functions_captcha.php';
//
// // no formulário, dentro do <form>:
// echo platform_captcha_field('registo', $pdo);
//
// // no handler, depois do CSRF:
// if (($erro = platform_captcha_verify('registo', $pdo)) !== null) {
// $error = $erro; // já em português, pronto a mostrar
// }
//
// O primeiro argumento é o âmbito: entra na assinatura, para que um desafio
// colhido no formulário de contacto não sirva no de registo. Dois formulários na
// mesma página com âmbitos diferentes são independentes.
//
// API pública:
// platform_captcha_config(?array) — configuração do módulo
// platform_captcha_field(string, ?PDO) — o bloco HTML pronto a imprimir
// platform_captcha_issue(string, ?PDO) — pergunta + token, para marcação própria
// platform_captcha_verify(string, ?PDO) — null passa, ou a mensagem de erro
// platform_captcha_ok(string, ?PDO) — o mesmo em booleano
//
// E o recurso — a razão pela qual este módulo existe. Ver a secção no fim:
// platform_captcha_appeal_offer(PDO, form, reason) — guarda a submissão recusada
// platform_captcha_appeal_field(token, form, ?PDO) — o bloco do segundo ecrã
// platform_captcha_appeal_claim(PDO, form, &$error) — devolve a submissão libertada
require_once __DIR__ . '/functions_settings.php';
require_once __DIR__ . '/functions_client_ip.php';
/**
* Configuração. Com um array define; sem argumentos, lê.
*/
function platform_captcha_config(?array $set = null): array
{
static $cfg = [
// 'math' (contas), 'letter' (letra de uma palavra) ou 'mixed' (sorteia
// uma das duas por desafio). Misturar é de graça e obriga quem
// automatiza a resolver dois formatos em vez de um.
'type' => 'mixed',
// Validade do desafio. Curta de propósito: sem sessão activa é ela que
// limita quantas respostas cabem num token. 15 minutos chegam para
// preencher um formulário e não chegam para um ataque com paciência.
'ttl' => 900,
// Nomes dos campos. Configuráveis porque um site pode já ter um campo
// chamado 'captcha' vindo de outra coisa.
'field_answer' => 'captcha_answer',
'field_token' => 'captcha_token',
// Classes do Bootstrap 5, que é o que toda a frota carrega. Um sítio sem
// Bootstrap passa strings vazias e estiliza por fora.
'class_wrap' => 'mb-3',
'class_label' => 'form-label',
'class_input' => 'form-control',
'class_help' => 'form-text',
'help' => 'Uma pergunta simples, para travar envios automáticos.',
'autocomplete' => 'off',
// Uma tentativa por pergunta, quando há sessão. Ver o cabeçalho.
'single_use' => true,
// Um token forjado ou em falta é sinal de bot e conta para a escalada do
// functions_ip_ban.php. Uma resposta errada NÃO conta, e a diferença é
// deliberada: a escada bane aos 3 registos em 24 h, e quem se engana
// duas vezes a escrever "catorze" não é um bot — seria banir gente por
// errar uma pergunta que nós próprios lhe fizemos.
'offence_forged' => true,
'offence_wrong' => false,
// ─── O recurso ──────────────────────────────────────────────────────
// Tabela própria, criada à cabeça como as do functions_ip_ban.php: a
// `blocked_submissions` tem forma diferente em cada site e é um registo,
// não uma fila de trabalho.
'appeal_table' => 'captcha_appeals',
// Duas horas para responder. É uma pessoa a olhar para o ecrã, não um
// formulário deixado aberto — e quanto mais curto, menos tempo a
// submissão recusada fica guardada.
'appeal_ttl' => 7200,
// Tentativas por recurso, e recursos por IP à hora. O primeiro impede
// que se esgotem as respostas possíveis numa só submissão retida; o
// segundo impede que a fila de recursos vire um depósito de spam.
'appeal_tries' => 6,
'appeal_max_ip' => 5,
// Motivos que NÃO dão direito a recurso. A recusa silenciosa existe
// para o bot não ficar a saber o que o apanhou, e estes dois não têm
// leitura inocente: o honeypot é um campo que ninguém vê, e o carimbo só
// é produzido pelo nosso próprio formulário. Oferecer recurso a quem
// falha ali é ensiná-lo a contornar. Todos os outros — país, limite por
// IP, pontuação de conteúdo, formulário expirado — têm falsos positivos
// reais e é para eles que isto serve.
'appeal_never' => ['honeypot', 'carimbo inválido'],
// Palavras do desafio da letra. Sem acentos, de propósito: a resposta é
// uma letra e não vale a pena pôr ninguém a decidir se escreve "a" ou
// "á".
'words' => [
'CADEIRA', 'JANELA', 'SAPATO', 'CAMINHO', 'MERCADO', 'PALAVRA',
'LARANJA', 'MONTANHA', 'TELEFONE', 'BICICLETA', 'ESCADA',
'FLORESTA', 'CAMISOLA', 'GUITARRA', 'PADEIRO', 'CADERNO',
],
];
if ($set !== null) {
$cfg = array_merge($cfg, $set);
}
return $cfg;
}
/**
* A chave que assina os desafios.
*
* Domínio próprio: um carimbo do anti-spam não valida aqui, nem ao contrário. A
* derivação e os seus três degraus estão em functions_settings.php, que é onde
* vive a única implementação.
*/
function platform_captcha_key(?PDO $pdo = null): string
{
return platform_derived_key('captcha', $pdo);
}
/**
* A pergunta e a resposta, derivadas do nonce.
*
* Determinista: o mesmo nonce, com a mesma chave e o mesmo âmbito, dá sempre o
* mesmo par. É isso que dispensa guardar seja o que for — o servidor volta a
* calcular a resposta no pedido seguinte em vez de se lembrar dela.
*
* Note-se que a resposta nunca vai para o cliente, nem sequer como hash: um hash
* de "14" quebra-se com vinte tentativas numa consola.
*
* @return array{question:string, answer:string}
*/
function platform_captcha_challenge(string $form, string $nonce, ?PDO $pdo = null): array
{
$cfg = platform_captcha_config();
$b = array_values(unpack(
'C*',
hash_hmac('sha256', 'q|' . $form . '|' . $nonce, platform_captcha_key($pdo), true)
));
$type = $cfg['type'];
if ($type === 'mixed') {
$type = ($b[0] % 2 === 0) ? 'math' : 'letter';
}
if ($type === 'letter') {
$words = array_values($cfg['words']);
$word = $words ? $words[$b[1] % count($words)] : 'CADEIRA';
// Até à quinta letra, e nunca além do tamanho da palavra.
$max = min(5, mb_strlen($word, 'UTF-8'));
$pos = 1 + ($b[2] % $max);
$ord = [1 => 'primeira', 2 => 'segunda', 3 => 'terceira', 4 => 'quarta', 5 => 'quinta'][$pos];
return [
'question' => 'Escreva a ' . $ord . ' letra da palavra ' . $word . '.',
'answer' => mb_substr($word, $pos - 1, 1, 'UTF-8'),
];
}
// Contas. Os limites dos operandos mantêm o resultado entre 0 e 20, que é o
// intervalo que platform_captcha_number_words() sabe escrever por extenso.
switch ($b[1] % 3) {
case 0:
$x = 1 + ($b[2] % 9);
$y = 1 + ($b[3] % 9);
$op = 'mais';
$r = $x + $y;
break;
case 1:
// O maior primeiro: um resultado negativo é uma pergunta injusta.
$x = 2 + ($b[2] % 8);
$y = 1 + ($b[3] % ($x - 1));
$op = 'menos';
$r = $x - $y;
break;
default:
$x = 2 + ($b[2] % 4);
$y = 2 + ($b[3] % 3);
$op = 'vezes';
$r = $x * $y;
}
// Um operando por extenso e o outro em algarismos, sorteados. Custa nada e
// parte o regex /(\d+)\s*\+\s*(\d+)/ com que se resolve um captcha destes em
// três linhas.
$words = platform_captcha_number_words();
$left = ($b[4] % 2 === 0) ? (string) $x : $words[$x];
$right = ($b[4] % 2 === 0) ? $words[$y] : (string) $y;
// "duas vezes três", não "dois vezes três": quem conta ali são as vezes, e
// "vez" é feminino. Só acontece com o operando da esquerda de uma
// multiplicação, e só quando calha ser escrito por extenso. A resposta não
// muda com isto — platform_captcha_canonical_answer() já lê "duas" como 2.
if ($op === 'vezes' && $left === 'dois') {
$left = 'duas';
}
return [
'question' => 'Quanto é ' . $left . ' ' . $op . ' ' . $right . '?',
'answer' => (string) $r,
];
}
/** 0–20 por extenso, já na forma normalizada (minúsculas, sem acentos). */
function platform_captcha_number_words(): array
{
return [
0 => 'zero', 'um', 'dois', 'tres', 'quatro', 'cinco', 'seis', 'sete',
'oito', 'nove', 'dez', 'onze', 'doze', 'treze', 'catorze', 'quinze',
'dezasseis', 'dezassete', 'dezoito', 'dezanove', 'vinte',
];
}
/**
* Normaliza uma resposta: minúsculas, sem acentos, sem nada que não seja letra
* ou algarismo. "Catorze", "catorze " e "CATORZE" são a mesma resposta.
*/
function platform_captcha_normalize(string $s): string
{
$s = mb_strtolower(trim($s), 'UTF-8');
$s = strtr($s, [
'á' => 'a', 'à' => 'a', 'â' => 'a', 'ã' => 'a', 'ä' => 'a',
'é' => 'e', 'è' => 'e', 'ê' => 'e', 'í' => 'i', 'ì' => 'i', 'î' => 'i',
'ó' => 'o', 'ò' => 'o', 'ô' => 'o', 'õ' => 'o', 'ö' => 'o',
'ú' => 'u', 'ù' => 'u', 'û' => 'u', 'ü' => 'u', 'ç' => 'c', 'ñ' => 'n',
]);
return (string) preg_replace('/[^a-z0-9]+/', '', $s);
}
/**
* A resposta de quem preencheu, posta na mesma forma que a nossa: por extenso
* vira algarismos, e as variantes do Brasil são aceites porque quem as escreve
* respondeu certo.
*/
function platform_captcha_canonical_answer(string $raw): string
{
$n = platform_captcha_normalize($raw);
$alias = [
'uma' => 'um', 'duas' => 'dois', 'quatorze' => 'catorze',
'dezesseis' => 'dezasseis', 'dezessete' => 'dezassete', 'dezenove' => 'dezanove',
];
$n = $alias[$n] ?? $n;
$words = array_flip(platform_captcha_number_words());
if (isset($words[$n])) {
return (string) $words[$n];
}
// "07" e "7" são a mesma resposta.
if ($n !== '' && ctype_digit($n)) {
return (string) (int) $n;
}
return $n;
}
/**
* Emite um desafio: a pergunta, o token e os nomes dos campos.
*
* Para quem quer a sua própria marcação — um formulário em grelha, um modal, um
* passo de um assistente. Quem só quer um campo normal usa
* platform_captcha_field(), que é isto com HTML à volta.
*
* @return array{question:string, token:string, field_answer:string, field_token:string, id:string}
*/
function platform_captcha_issue(string $form = 'form', ?PDO $pdo = null): array
{
$cfg = platform_captcha_config();
$ts = time();
$nonce = bin2hex(random_bytes(9));
$sig = platform_captcha_sign($form, $ts, $nonce, $pdo);
$ch = platform_captcha_challenge($form, $nonce, $pdo);
return [
'question' => $ch['question'],
'token' => $ts . '.' . $nonce . '.' . $sig,
'field_answer' => $cfg['field_answer'],
'field_token' => $cfg['field_token'],
// Único por instância: a mesma página pode ter dois captchas, e dois
// <label for="captcha"> mandam o clique sempre para o primeiro.
'id' => 'captcha-' . substr($nonce, 0, 8),
];
}
function platform_captcha_sign(string $form, int $ts, string $nonce, ?PDO $pdo = null): string
{
return hash_hmac('sha256', 't|' . $form . '|' . $ts . '|' . $nonce, platform_captcha_key($pdo));
}
/**
* O bloco pronto a imprimir dentro de um <form>.
*
* Sem JavaScript, sem imagens e sem dependências: um <label>, um <input> de
* texto e um campo escondido. `inputmode="numeric"` fica de fora de propósito —
* metade dos desafios pede uma letra, e um teclado numérico num campo onde se
* escreve "d" é pior do que nenhum.
*/
function platform_captcha_field(string $form = 'form', ?PDO $pdo = null): string
{
$cfg = platform_captcha_config();
$c = platform_captcha_issue($form, $pdo);
$esc = static fn(string $s): string => htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
$cls = static fn(string $k): string => $cfg[$k] !== '' ? ' class="' . $esc($cfg[$k]) . '"' : '';
$html = '<div' . $cls('class_wrap') . ' data-captcha="' . $esc($form) . '">'
. '<label for="' . $esc($c['id']) . '"' . $cls('class_label') . '>' . $esc($c['question']) . '</label>'
. '<input type="text" id="' . $esc($c['id']) . '" name="' . $esc($c['field_answer']) . '"'
. ' value="" required autocomplete="' . $esc($cfg['autocomplete']) . '"'
. ' autocapitalize="off" spellcheck="false"' . $cls('class_input') . '>'
. '<input type="hidden" name="' . $esc($c['field_token']) . '" value="' . $esc($c['token']) . '">';
if ($cfg['help'] !== '') {
$html .= '<div' . $cls('class_help') . '>' . $esc($cfg['help']) . '</div>';
}
return $html . '</div>';
}
/**
* Verifica a resposta. Devolve null quando passa, ou a mensagem a mostrar.
*
* **Falha fechada**, ao contrário do portão de país: um token que não chegou é
* um token inválido. O captcha só existe se a ausência dele for uma recusa —
* caso contrário bastava não enviar o campo. É por isso que um site que se
* esqueça de imprimir o campo vê o formulário recusado em vez de aberto: o erro
* aparece ao primeiro teste, em vez de ficar uma defesa desligada em silêncio.
*
* A mensagem devolvida é para mostrar a quem preencheu, já em português e já
* genérica: nunca diz qual das verificações falhou.
*/
function platform_captcha_verify(string $form = 'form', ?PDO $pdo = null): ?string
{
$cfg = platform_captcha_config();
$expired = 'A verificação expirou. Responda à nova pergunta e envie outra vez.';
$wrong = 'A resposta à pergunta de verificação não está certa. Tente de novo.';
$token = (string) ($_POST[$cfg['field_token']] ?? '');
$parts = explode('.', $token);
if (count($parts) !== 3) {
platform_captcha_offence($pdo, $form, 'token em falta ou malformado', (bool) $cfg['offence_forged']);
return $expired;
}
[$ts, $nonce, $sig] = $parts;
if (!ctype_digit($ts) || !ctype_xdigit($nonce) || !ctype_xdigit($sig)
|| !hash_equals(platform_captcha_sign($form, (int) $ts, $nonce, $pdo), $sig)) {
platform_captcha_offence($pdo, $form, 'token forjado', (bool) $cfg['offence_forged']);
return $expired;
}
// 60 s de folga para o futuro: o carimbo é feito no servidor, mas um relógio
// que ande atrasado depois de um restauro não há-de recusar toda a gente até
// alguém reparar.
$age = time() - (int) $ts;
if ($age > (int) $cfg['ttl'] || $age < -60) {
return $expired;
}
if (platform_captcha_used($nonce, false)) {
return $expired;
}
// Marcado antes de comparar, e não depois: é o que torna cada pergunta uma
// única tentativa. Marcar só em caso de sucesso deixava o token vivo para as
// vinte tentativas seguintes, que é precisamente o ataque.
platform_captcha_used($nonce, true, (int) $ts + (int) $cfg['ttl']);
$given = (string) ($_POST[$cfg['field_answer']] ?? '');
if (trim($given) === '') {
return 'Responda à pergunta de verificação.';
}
$ch = platform_captcha_challenge($form, $nonce, $pdo);
if (!hash_equals(
platform_captcha_canonical_answer($ch['answer']),
platform_captcha_canonical_answer($given)
)) {
platform_captcha_offence($pdo, $form, 'resposta errada', (bool) $cfg['offence_wrong']);
return $wrong;
}
return null;
}
/** O mesmo, em booleano, para quem já tem a sua própria mensagem. */
function platform_captcha_ok(string $form = 'form', ?PDO $pdo = null): bool
{
return platform_captcha_verify($form, $pdo) === null;
}
/**
* Nonces já verificados, guardados na sessão.
*
* Usa apenas a sessão que já estiver aberta — **não** chama session_start(). Numa
* página pública isso punha um cookie a toda a gente que passasse por um
* formulário, o que muda a política de cookies do site e estraga qualquer cache
* à frente. Sem sessão esta defesa não corre, e fica o ttl curto a limitar.
*/
function platform_captcha_used(string $nonce, bool $mark, int $expiresAt = 0): bool
{
$cfg = platform_captcha_config();
if (!$cfg['single_use'] || session_status() !== PHP_SESSION_ACTIVE) {
return false;
}
$now = time();
$used = $_SESSION['platform_captcha_used'] ?? [];
if (!is_array($used)) {
$used = [];
}
// Limpeza a cada passagem: sem isto a sessão cresce para sempre num site com
// um captcha no rodapé.
$used = array_filter($used, static fn($exp): bool => (int) $exp > $now);
$seen = isset($used[$nonce]);
if ($mark) {
$used[$nonce] = $expiresAt > 0 ? $expiresAt : $now + 900;
// Tecto: um bot com cookies pediria mil desafios e enchia a sessão.
if (count($used) > 60) {
$used = array_slice($used, -60, null, true);
}
}
$_SESSION['platform_captcha_used'] = $used;
return $seen;
}
/**
* Dá memória às falhas, quando há base de dados e o functions_ip_ban.php está
* carregado. Silencioso e sem consequências quando não há — um captcha não há-de
* exigir tabelas para poder fazer uma pergunta.
*/
function platform_captcha_offence(?PDO $pdo, string $form, string $detail, bool $enabled): void
{
if (!$enabled || !($pdo instanceof PDO) || !function_exists('platform_ip_record_offence')) {
return;
}
platform_ip_record_offence($pdo, 'captcha:' . mb_substr($form, 0, 28), $detail, platform_client_ip());
}
// ─── O recurso: uma segunda oportunidade a quem foi recusado ────────────────
//
// O problema concreto, e é o que fez nascer este módulo: o portão de país
// recusa quem chega de fora de Portugal, e isso apanha duas pessoas que não são
// spam nenhum — quem usa o iCloud Private Relay (a Apple faz sair o tráfego por
// endereços que as listas da ipdeny atribuem a outro país) e os emigrantes.
// Ambos recebiam a página de obrigado e ficavam à espera de uma resposta que
// nunca vinha, porque a submissão tinha sido deitada fora em silêncio.
//
// A recusa silenciosa continua a ser a coisa certa para um bot. O que estava
// errado era não haver caminho nenhum de volta para uma pessoa. Então:
//
// 1. a submissão recusada é guardada inteira, com a razão;
// 2. em vez da página de obrigado, a pessoa vê uma pergunta;
// 3. respondendo certo, a submissão original é libertada e o site trata-a
// como se tivesse passado à primeira.
//
// Três decisões que vale a pena não desfazer sem pensar:
//
// **Nem todos os motivos dão recurso.** O honeypot e o carimbo forjado ficam de
// fora (ver `appeal_never`): não têm falso positivo possível, e oferecer ali uma
// segunda tentativa é explicar a um bot o que o apanhou.
//
// **A submissão fica no servidor, não no browser.** O segundo ecrã só devolve a
// resposta ao desafio. Reenviar o conteúdo num campo escondido deixava editá-lo
// depois de ter sido pontuado — passava-se uma mensagem limpa na primeira volta
// e o texto verdadeiro na segunda.
//
// **O recurso não é preso ao IP.** Seria o reflexo natural e estragava
// exactamente o caso que isto veio resolver: o Private Relay muda o endereço de
// saída entre pedidos, e a pessoa voltaria com um IP diferente daquele com que
// foi recusada. O que prende o recurso é a assinatura do token.
/**
* A fila de recursos. Criada à cabeça, como as tabelas do functions_ip_ban.php:
* um site não há-de precisar de uma migração para poder desfazer um engano.
*/
function platform_captcha_appeal_ensure_table(PDO $pdo): bool
{
static $done = null;
if ($done !== null) {
return $done;
}
$cfg = platform_captcha_config();
$table = (string) $cfg['appeal_table'];
if (!preg_match('/^[A-Za-z0-9_]+$/', $table)) {
error_log('[captcha] nome de tabela inválido, recurso desligado: ' . $table);
return $done = false;
}
try {
$pdo->exec("
CREATE TABLE IF NOT EXISTS `{$table}` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`form` VARCHAR(40) NOT NULL,
`reason` VARCHAR(190) NOT NULL,
`payload` MEDIUMTEXT NOT NULL,
`ip` VARCHAR(45) NULL,
`attempts` INT NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`claimed_at` DATETIME NULL,
KEY `idx_captcha_appeals_ip` (`ip`, `created_at`),
KEY `idx_captcha_appeals_age` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
");
return $done = true;
} catch (Throwable $e) {
// Sem tabela não há recurso, e o site comporta-se como antes: recusa
// em silêncio. Uma segunda oportunidade que falha não pode derrubar a
// primeira defesa.
error_log('[captcha] tabela de recursos indisponível: ' . $e->getMessage());
return $done = false;
}
}
/** Este motivo admite recurso? Ver `appeal_never` na configuração. */
function platform_captcha_appeal_allowed(string $reason): bool
{
$lower = mb_strtolower($reason, 'UTF-8');
foreach (platform_captcha_config()['appeal_never'] as $needle) {
if ($needle !== '' && str_contains($lower, mb_strtolower((string) $needle, 'UTF-8'))) {
return false;
}
}
return true;
}
/**
* Guarda a submissão recusada e devolve o token do recurso — ou null quando não
* há recurso a oferecer, e aí o site faz o que já fazia.
*
* Chamar logo a seguir a uma recusa, com a razão que a recusa devolveu:
*
* if (($reason = platform_antispam_check($pdo, 'contacto', ...)) !== null) {
* $appeal = platform_captcha_appeal_offer($pdo, 'contacto', $reason);
* // $appeal !== null -> mostrar o desafio (ver platform_captcha_appeal_field)
* // $appeal === null -> página de obrigado, como sempre
* }
*
* O payload por omissão é o $_POST inteiro menos os campos de mecanismo. Um site
* que prefira guardar só o que lhe interessa passa o array que quiser.
*/
function platform_captcha_appeal_offer(
PDO $pdo,
string $form,
string $reason,
?array $payload = null
): ?string {
$cfg = platform_captcha_config();
if (!platform_captcha_appeal_allowed($reason) || !platform_captcha_appeal_ensure_table($pdo)) {
return null;
}
$table = (string) $cfg['appeal_table'];
$ip = platform_client_ip();
$payload ??= platform_captcha_appeal_payload();
try {
// Limpeza oportunista. Os recursos são raros, portanto sai barato fazê-la
// aqui e não há mais um cron para alguém se esquecer de instalar.
$pdo->prepare("DELETE FROM `{$table}` WHERE created_at < DATE_SUB(NOW(), INTERVAL ? SECOND)")
->execute([max(3600, (int) $cfg['appeal_ttl'] * 2)]);
if ($ip !== null) {
$st = $pdo->prepare("SELECT COUNT(*) FROM `{$table}` WHERE ip = ? AND created_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR)");
$st->execute([$ip]);
if ((int) $st->fetchColumn() >= (int) $cfg['appeal_max_ip']) {
// Já teve as oportunidades que havia para dar. Sem recurso, e
// sem dizer porquê.
return null;
}
}
$pdo->prepare("INSERT INTO `{$table}` (form, reason, payload, ip) VALUES (?, ?, ?, ?)")
->execute([
mb_substr($form, 0, 40),
mb_substr($reason, 0, 190),
(string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
$ip,
]);
$id = (int) $pdo->lastInsertId();
} catch (Throwable $e) {
error_log('[captcha] recurso não registado: ' . $e->getMessage());
return null;
}
return $id . '.' . platform_captcha_appeal_sign($id, $form, $pdo);
}
/**
* O que fica guardado de uma submissão retida: o $_POST sem os campos de
* mecanismo — o token CSRF, as armadilhas do anti-spam e o próprio captcha.
*
* Nenhum deles tem sentido depois de a submissão ser libertada (o carimbo vai
* estar velho, o honeypot vazio), e guardá-los era guardar um token válido
* numa tabela durante duas horas sem precisar.
*/
function platform_captcha_appeal_payload(?array $post = null): array
{
$cfg = platform_captcha_config();
$payload = $post ?? $_POST;
unset(
$payload['csrf_token'], $payload['_pt_csrf'], $payload['antispam_token'],
$payload['form_sig'], $payload['form_js'], $payload['form_ts'], $payload['website'],
$payload[$cfg['field_answer']], $payload[$cfg['field_token']], $payload['captcha_appeal']
);
return $payload;
}
function platform_captcha_appeal_sign(int $id, string $form, ?PDO $pdo = null): string
{
return hash_hmac('sha256', 'a|' . $form . '|' . $id, platform_derived_key('captcha-appeal', $pdo));
}
/**
* O bloco do segundo ecrã: o desafio mais o token do recurso.
*
* O token tem de sobreviver a uma resposta errada, por isso vai num campo
* escondido e é reimpresso a cada tentativa — com um desafio novo, porque o
* anterior ficou gasto.
*/
function platform_captcha_appeal_field(string $token, string $form = 'form', ?PDO $pdo = null): string
{
return '<input type="hidden" name="captcha_appeal" value="'
. htmlspecialchars($token, ENT_QUOTES, 'UTF-8') . '">'
. platform_captcha_field($form, $pdo);
}
/** O token do recurso que veio no POST, para reimprimir o ecrã. */
function platform_captcha_appeal_token(): string
{
return (string) ($_POST['captcha_appeal'] ?? '');
}
/**
* Tenta libertar a submissão retida. Três respostas possíveis:
*
* null e $error null — não havia recurso em curso; seguir o fluxo normal
* null e $error com texto — havia, e falhou: reimprimir o ecrã com a mensagem
* array — libertada. É o $_POST original, para processar
* exactamente como se tivesse passado à primeira.
*
* O site continua a decidir o que fazer com ela — criar a lead, abrir o ticket,
* enviar o email. Este módulo não sabe o que aquele formulário significa, e não
* é aqui que isso se decide.
*/
function platform_captcha_appeal_claim(PDO $pdo, string $form = 'form', ?string &$error = null): ?array
{
$error = null;
$cfg = platform_captcha_config();
$token = platform_captcha_appeal_token();
if ($token === '') {
return null;
}
$expired = 'Este pedido já não está disponível. Volte a preencher o formulário, por favor.';
$parts = explode('.', $token);
if (count($parts) !== 2 || !ctype_digit($parts[0]) || !ctype_xdigit($parts[1])
|| !hash_equals(platform_captcha_appeal_sign((int) $parts[0], $form, $pdo), $parts[1])) {
platform_captcha_offence($pdo, $form, 'recurso forjado', (bool) $cfg['offence_forged']);
$error = $expired;
return null;
}
if (!platform_captcha_appeal_ensure_table($pdo)) {
$error = $expired;
return null;
}
$id = (int) $parts[0];
$table = (string) $cfg['appeal_table'];
try {
$st = $pdo->prepare(
"SELECT payload, attempts, ip, created_at FROM `{$table}`
WHERE id = ? AND form = ? AND claimed_at IS NULL
AND created_at >= DATE_SUB(NOW(), INTERVAL ? SECOND)"
);
$st->execute([$id, mb_substr($form, 0, 40), (int) $cfg['appeal_ttl']]);
$row = $st->fetch(PDO::FETCH_ASSOC);
} catch (Throwable $e) {
error_log('[captcha] recurso não lido: ' . $e->getMessage());
$error = $expired;
return null;
}
// Já libertado, já expirado, ou nunca existiu: a mesma resposta para os três.
if (!$row) {
$error = $expired;
return null;
}
if ((int) $row['attempts'] >= (int) $cfg['appeal_tries']) {
// Gastou as tentativas. Fecha-se o recurso em vez de o deixar aberto a
// ser martelado até uma resposta calhar certa.
try {
$pdo->prepare("UPDATE `{$table}` SET claimed_at = NOW() WHERE id = ?")->execute([$id]);
} catch (Throwable) { /* a janela fecha-a de qualquer maneira */ }
$error = $expired;
return null;
}
try {
$pdo->prepare("UPDATE `{$table}` SET attempts = attempts + 1 WHERE id = ?")->execute([$id]);
} catch (Throwable $e) {
error_log('[captcha] tentativa não contada: ' . $e->getMessage());
}
if (($why = platform_captcha_verify($form, $pdo)) !== null) {
$error = $why; // resposta errada: o recurso continua de pé
return null;
}
$payload = json_decode((string) $row['payload'], true);
if (!is_array($payload)) {
error_log('[captcha] payload do recurso ' . $id . ' ilegível');
$error = $expired;
return null;
}
try {
$pdo->prepare("UPDATE `{$table}` SET claimed_at = NOW() WHERE id = ?")->execute([$id]);
} catch (Throwable $e) {
error_log('[captcha] recurso ' . $id . ' libertado mas não marcado: ' . $e->getMessage());
}
platform_captcha_appeal_forgive($pdo, $form, (string) ($row['ip'] ?? ''), (string) $row['created_at']);
return $payload;
}
/**
* Apaga as infracções que a recusa desta pessoa gerou.
*
* Sem isto o recurso era uma armadilha: cada tentativa de contacto somava uma
* infracção, e a escada do functions_ip_ban.php bane aos três registos em 24 h —
* ou seja, a terceira vez que um utilizador do Private Relay tentasse falar
* connosco ficava sem acesso ao site, depois de nós próprios lhe termos
* confirmado que era humano.
*
* **Não levanta banimentos**, e é a única parte disto que é uma escolha e não
* uma consequência. Um captcha que desfizesse um banimento seria um oráculo:
* resolve-se um desafio e recupera-se o acesso, o que é um alvo bem mais
* apetecível do que um formulário de contacto. Quem já está banido fala com uma
* pessoa, pelo email do ecrã de 403.
*/
function platform_captcha_appeal_forgive(PDO $pdo, string $form, string $ip, string $since): void
{
if ($ip === '' || $since === '') {
return;
}
try {
// Só as infracções daquele formulário, daquele endereço, desde o momento
// em que a submissão foi retida. O historial anterior fica de pé.
$pdo->prepare(
"DELETE FROM ip_offences
WHERE ip = ? AND kind IN (?, ?) AND created_at >= DATE_SUB(?, INTERVAL 5 SECOND)"
)->execute([$ip, 'antispam:' . $form, 'captcha:' . $form, $since]);
} catch (Throwable $e) {
// Tabela por criar (o site não usa o ip_ban) ou apagada: não é motivo
// para não libertar a submissão, que é o que a pessoa veio buscar.
error_log('[captcha] infracções do recurso não limpas: ' . $e->getMessage());
}
}
@@ -1,39 +0,0 @@
<?php
// ─── Changelog reader ────────────────────────────────────────────────────────
// Reads the repository CHANGELOG.md and exposes the most recent version entry,
// so the backoffice can show "Versão X — what changed" without a DB round-trip.
// The file is versioned with the code, so it updates automatically on git pull.
/**
* Return the latest CHANGELOG.md entry.
*
* Expected format:
* ## v2.2 — Adicionado pagamentos parciais
* - primeira alteração
* - segunda alteração
*
* @return array{version: ?string, changes: string[]}
*/
function changelog_latest(): array {
$file = dirname(__DIR__, 2) . '/CHANGELOG.md';
if (!is_file($file)) return ['version' => null, 'changes' => []];
$lines = @file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if ($lines === false) return ['version' => null, 'changes' => []];
$version = null;
$changes = [];
foreach ($lines as $line) {
if (preg_match('/^##\s+(.+)$/', $line, $m)) {
if ($version === null) {
$version = trim($m[1]);
continue;
}
break; // reached the next version entry
}
if ($version !== null && str_starts_with(ltrim($line), '-')) {
$changes[] = ltrim(ltrim($line), '- ');
}
}
return ['version' => $version, 'changes' => $changes];
}
@@ -1,64 +0,0 @@
<?php
// ─── Quem é o visitante, atrás do proxy ─────────────────────────────────────
//
// Isto vivia só dentro de functions_auth.php, como auth_client_ip(). Servia
// bem o throttle do login, mas mais nada lhe conseguia chegar: o antispam da
// fotografia e do mstudio precisava do mesmo cálculo e não podia carregar o
// módulo de auth, porque esses sites declaram o seu próprio auth_login() e o
// include rebentava com "Cannot redeclare function".
//
// Daí um ficheiro à parte, sem dependências e sem nada a executar no include.
// Quem quer só o IP inclui este; auth_client_ip() passou a ser um alias para
// aqui, para não haver duas versões da mesma regra a divergir com o tempo.
//
// A regra: toda a quinta está atrás do Nginx Proxy Manager, por isso o
// REMOTE_ADDR é sempre o contentor do proxy (172.18.0.2) e nunca o visitante.
// Só se acredita nos cabeçalhos do proxy quando TRUSTED_PROXY=true E o pedido
// chega mesmo de uma rede privada (o proxy); de fora, são forjáveis.
//
// E acredita-se no cabeçalho CERTO. O NPM faz
// proxy_set_header X-Real-IP $remote_addr;
// proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
// ou seja, X-Real-IP é sempre reescrito com o IP que o proxy viu, e o
// X-Forwarded-For é ACRESCENTADO ao que o cliente mandou. Esta função usava o
// primeiro valor do X-Forwarded-For — o que o cliente escreve. Com
// "X-Forwarded-For: 1.2.3.4" cada pedido tinha o IP que o atacante quisesse, e
// o throttle, o IP ban e o registo de auditoria contavam identidades inventadas
// (encontrado na auditoria do fitme3070, 25/09/2026). Agora:
// 1. X-Real-IP, se for um IP válido;
// 2. senão o ÚLTIMO valor do X-Forwarded-For (o que o proxy acrescentou);
// 3. senão REMOTE_ADDR.
if (!function_exists('platform_client_ip')) {
/**
* IP do visitante, ou null se não houver nenhum utilizável (CLI, cron).
*/
function platform_client_ip(): ?string
{
$trusted = function_exists('env')
? filter_var(env('TRUSTED_PROXY', 'false'), FILTER_VALIDATE_BOOLEAN)
: filter_var(getenv('TRUSTED_PROXY') ?: 'false', FILTER_VALIDATE_BOOLEAN);
$remote = (string) ($_SERVER['REMOTE_ADDR'] ?? '');
$remoteOk = $remote !== '' && filter_var($remote, FILTER_VALIDATE_IP);
// Proxy = endereço privado/reservado (rede Docker, LAN, loopback).
$fromProxy = $remoteOk
&& !filter_var($remote, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
if ($trusted && $fromProxy) {
$real = trim((string) ($_SERVER['HTTP_X_REAL_IP'] ?? ''));
if ($real !== '' && filter_var($real, FILTER_VALIDATE_IP)) {
return $real;
}
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$hops = array_map('trim', explode(',', (string) $_SERVER['HTTP_X_FORWARDED_FOR']));
$last = end($hops);
if ($last !== false && filter_var($last, FILTER_VALIDATE_IP)) {
return $last;
}
}
}
return $remoteOk ? $remote : null;
}
}
@@ -1,293 +0,0 @@
<?php
// ─── Composer runner (shared) ────────────────────────────────────────────────
//
// Lets the sysadmin "Atualizar plataforma" page run `composer install`/`update`
// from the browser (same idea as git_run): no SSH needed to (re)build vendor/.
//
// Invocation strategy, in order:
// 1. a project-local `composer.phar` in the repo root → `php composer.phar`
// 2. a global `composer` on the web user's PATH
// Composer needs a writable HOME for its cache/config; PHP-FPM's user often has
// none, so we point COMPOSER_HOME at a temp dir for the duration of the call.
require_once __DIR__ . '/functions_capabilities.php';
function composer_available(string $repo_root): array {
// Returns ['ok'=>bool, 'how'=>str, 'version'=>?str]. Short timeout — this
// runs on page load to populate the Dependências card header.
$res = composer_run(['--version'], $repo_root, 10);
// First line only: composer may prepend PHP deprecation noise on stderr.
$version = $res['ok'] ? trim(strtok($res['output'], "\n") ?: '') : null;
return [
'ok' => $res['ok'],
'how' => is_file($repo_root . '/composer.phar') ? 'composer.phar (local)' : 'composer (PATH)',
'version' => $version,
];
}
/**
* Run composer with $args in $repo_root. Captures stdout+stderr together.
* @return array{ok:bool,code:int,output:string}
*/
function composer_run(array $args, string $repo_root, int $timeout = 600): array {
// Fail soft on hosts with the proc_* family in disable_functions —
// calling proc_open() there is a FATAL "undefined function" error.
if (!platform_proc_open_available()) {
return ['ok' => false, 'code' => -1, 'output' => 'proc_open() está desativado no PHP deste servidor (disable_functions) — o composer via web não está disponível.'];
}
@set_time_limit(0);
$php = defined('PHP_BINARY') && PHP_BINARY ? PHP_BINARY : 'php';
$cmd = is_file($repo_root . '/composer.phar')
? [$php, $repo_root . '/composer.phar']
: ['composer'];
$cmd = array_merge($cmd, $args, ['--no-interaction']);
// Writable HOME/cache for composer (PHP-FPM user usually lacks one).
$home = sys_get_temp_dir() . '/platform-tools-composer';
@mkdir($home, 0775, true);
$env = getenv() ?: [];
$env['COMPOSER_HOME'] = $home;
$env['COMPOSER_CACHE_DIR'] = $home . '/cache';
$env['COMPOSER_NO_INTERACTION'] = '1';
if (empty($env['HOME'])) $env['HOME'] = $home;
// Some composer plugins want a PATH; keep the inherited one but ensure php's dir.
$phpDir = dirname($php);
if ($phpDir && $phpDir !== '.' && !empty($env['PATH']) && strpos($env['PATH'], $phpDir) === false) {
$env['PATH'] = $phpDir . PATH_SEPARATOR . $env['PATH'];
}
// stdout/stderr vão para ficheiros temporários e o processo é vigiado num
// poll com deadline: um composer que encrave (rede, prompt inesperado,
// lock de cache) não pode prender o request web indefinidamente. Pipes +
// stream_select seriam a alternativa, mas não funcionam de forma fiável
// em todas as plataformas — ficheiros funcionam em qualquer lado.
$outFile = tempnam(sys_get_temp_dir(), 'ptcomp_');
$errFile = tempnam(sys_get_temp_dir(), 'ptcomp_');
if ($outFile === false || $errFile === false) {
return ['ok' => false, 'code' => -1, 'output' => 'Sem espaço/permissões no diretório temporário.'];
}
$descs = [1 => ['file', $outFile, 'w'], 2 => ['file', $errFile, 'w']];
$proc = @proc_open($cmd, $descs, $pipes, $repo_root, $env);
if (!is_resource($proc)) {
@unlink($outFile);
@unlink($errFile);
return [
'ok' => false,
'code' => -1,
'output' => 'Não foi possível invocar o composer. Confirma que existe um `composer.phar` '
. 'na raiz do projeto OU um `composer` no PATH do utilizador do servidor web.',
];
}
$deadline = microtime(true) + max(1, $timeout);
$timed_out = false;
$code = -1;
while (true) {
$status = proc_get_status($proc);
if (!$status['running']) {
$code = $status['exitcode'];
break;
}
if (microtime(true) > $deadline) {
$timed_out = true;
@proc_terminate($proc, 9);
break;
}
usleep(200000);
}
proc_close($proc);
$out = (string) @file_get_contents($outFile);
$err = (string) @file_get_contents($errFile);
@unlink($outFile);
@unlink($errFile);
$output = trim($out);
if (trim($err) !== '') {
// Composer writes progress to stderr — include it.
$output .= ($output !== '' ? "\n" : '') . trim($err);
}
if ($timed_out) {
$output .= ($output !== '' ? "\n\n" : '')
. "(interrompido: excedeu o limite de {$timeout}s)";
return ['ok' => false, 'code' => -1, 'output' => $output];
}
return ['ok' => $code === 0, 'code' => $code, 'output' => $output !== '' ? $output : '(sem saída)'];
}
// Snapshot for the UI card: is there a composer.json? a vendor/autoload? which
// packages are declared?
function composer_state(string $repo_root): array {
$json_path = $repo_root . '/composer.json';
$has_json = is_file($json_path);
$declared = [];
if ($has_json) {
$data = json_decode((string) @file_get_contents($json_path), true);
if (is_array($data['require'] ?? null)) $declared = $data['require'];
}
return [
'has_json' => $has_json,
'has_lock' => is_file($repo_root . '/composer.lock'),
'has_autoload' => is_file($repo_root . '/vendor/autoload.php'),
'declared' => $declared,
];
}
/**
* Packages composer would actually move, per `composer update --dry-run`.
*
* This is the ONLY reliable signal for "can this be updated in place".
* `composer outdated` reports latest-status=semver-safe-update whenever a newer
* release is semver-compatible with what is INSTALLED -- it says nothing about
* whether the declared constraint permits it. A package pinned to an exact
* version (twbs/bootstrap: "5.3.3") is reported semver-safe while
* `composer update twbs/bootstrap` answers "Nothing to modify in lock file".
*
* Acting on latest-status would give the operator a button that runs, changes
* nothing, and reports success. Parsing the dry run costs one extra call and
* tells the truth.
*
* Returns ['name' => ['from' => x, 'to' => y]].
*/
function composer_updatable(string $repo_root, int $timeout = 180): array {
$res = composer_run(['update', '--dry-run', '--no-dev'], $repo_root, $timeout);
if (!$res['ok']) return [];
$moves = [];
// " - Upgrading vendor/pkg (v1.0.0 => v1.2.0)"
if (preg_match_all('/-\s+(?:Upgrading|Downgrading|Updating)\s+(\S+)\s+\(([^ )]+)\s*=>\s*([^)]+)\)/i',
(string) $res['output'], $m, PREG_SET_ORDER)) {
foreach ($m as $x) {
$moves[$x[1]] = ['from' => trim($x[2]), 'to' => trim($x[3])];
}
}
return $moves;
}
/**
* Packages with a newer release available.
*
* Runs `composer outdated --format=json`. That is a NETWORK call, so it is
* never run on page load -- the UI triggers it explicitly.
*
* $directOnly limits the list to packages named in composer.json's require,
* which is what a human can actually act on; transitive dependencies move when
* their parent does.
*
* Each row: name, version (installed), latest, latest-status, description,
* plus a derived 'semver_safe' flag:
* true -> latest satisfies the declared constraint, so `composer update <pkg>`
* will take it with no composer.json edit.
* false -> a major bump; composer will refuse until the constraint is widened.
* That distinction is the whole point of a per-package button: one is routine,
* the other is a decision.
*/
function composer_outdated(string $repo_root, bool $directOnly = true, int $timeout = 180): array {
$args = ['outdated', '--format=json', '--no-ansi'];
if ($directOnly) $args[] = '--direct';
$res = composer_run($args, $repo_root, $timeout);
if (!$res['ok']) {
return ['ok' => false, 'error' => $res['output'] ?: 'composer outdated falhou.', 'packages' => []];
}
// composer may prepend warnings on stdout before the JSON body, and on a
// project whose vendor/ was not built by composer it emits a bare "[]"
// followed by "No dependencies installed" -- which is valid JSON, just not
// an object. Look for either opener.
$out = (string) $res['output'];
$sObj = strpos($out, '{');
$sArr = strpos($out, '[');
$start = ($sObj === false) ? $sArr : (($sArr === false) ? $sObj : min($sObj, $sArr));
$body = $start === false ? '' : substr($out, $start);
$json = $body === '' ? null : json_decode($body, true);
// composer also appends human-readable notices AFTER the JSON body
// ("[]\nNo dependencies installed."), so decoding to end-of-output fails.
// Retry against successively shorter line-prefixes until one parses.
if (!is_array($json) && $body !== '') {
$lines = explode("\n", $body);
for ($i = count($lines) - 1; $i > 0; $i--) {
$try = json_decode(implode("\n", array_slice($lines, 0, $i)), true);
if (is_array($try)) { $json = $try; break; }
}
}
if (!is_array($json)) {
return ['ok' => false, 'error' => 'Resposta do composer não era JSON: ' . substr($out, 0, 300), 'packages' => []];
}
// Bare array, or an object with no 'installed' key: composer found nothing
// it manages. On a host that vendors dependencies by hand this is the
// expected answer, not a failure -- say so rather than showing a parse error.
if (!isset($json['installed'])) {
$hint = (stripos($out, 'No dependencies installed') !== false)
? 'O composer não gere nenhuma dependência aqui — o vendor/ não foi construído por ele. Corre "install" primeiro, ou este projeto vendoriza à mão.'
: 'O composer não devolveu pacotes instalados.';
return ['ok' => true, 'error' => null, 'packages' => [], 'note' => $hint];
}
$declared = composer_state($repo_root)['declared'];
// Authoritative "would this actually move" set -- see composer_updatable().
$updatable = composer_updatable($repo_root, $timeout);
$rows = [];
foreach (($json['installed'] ?? []) as $pkg) {
$name = (string) ($pkg['name'] ?? '');
if ($name === '') continue;
$status = (string) ($pkg['latest-status'] ?? '');
$rows[] = [
'name' => $name,
'version' => (string) ($pkg['version'] ?? '?'),
'latest' => (string) ($pkg['latest'] ?? '?'),
'status' => $status,
'constraint' => (string) ($declared[$name] ?? ''),
'description' => (string) ($pkg['description'] ?? ''),
// NOT derived from latest-status: that flag only says the newer
// release is semver-compatible with what is installed, not that the
// declared constraint admits it. An exact pin reports semver-safe
// and then refuses to move.
'updatable' => isset($updatable[$name]),
'would_move' => $updatable[$name] ?? null,
];
}
usort($rows, fn($a, $b) => ($b['updatable'] <=> $a['updatable']) ?: strcmp($a['name'], $b['name']));
return ['ok' => true, 'error' => null, 'packages' => $rows];
}
/**
* Every package name composer currently knows about, from composer.lock.
*
* Used to validate names arriving from a POST before they reach a shell
* argument list. Never pass a request-supplied string to composer_run()
* without checking it against this.
*/
function composer_known_packages(string $repo_root): array {
$lock = $repo_root . '/composer.lock';
if (!is_file($lock)) return [];
$data = json_decode((string) @file_get_contents($lock), true);
if (!is_array($data)) return [];
$names = [];
foreach (array_merge($data['packages'] ?? [], $data['packages-dev'] ?? []) as $p) {
if (!empty($p['name'])) $names[] = (string) $p['name'];
}
return $names;
}
/**
* Update specific packages, or all of them when $names is empty.
*
* --with-dependencies is required: updating a package whose new release needs a
* newer transitive dependency fails without it, with a resolver error that
* reads like the package itself is unavailable.
*/
function composer_update_packages(array $names, string $repo_root, int $timeout = 600): array {
$args = ['update', '--no-dev', '--optimize-autoloader'];
if ($names) {
$args = array_merge($args, ['--with-dependencies'], array_values($names));
}
return composer_run($args, $repo_root, $timeout);
}
@@ -1,427 +0,0 @@
<?php
// ─── Conector: uma chamada para ligar uma app ao platform-tools ─────────────
//
// Até aqui o platform-tools adivinhava em que site estava: procurava um $pdo
// global, depois \App\Database (surf), depois um base_url(), um getBaseUrl(), um
// APP_BASE_URL. Cada ferramenta nova tinha de se lembrar das variantes. Com o
// conector é o site que DECLARA, uma vez, no seu arranque:
//
// require_once __DIR__ . '/../shared/platform-tools/functions_connector.php';
// platform_connect($pdo, [
// 'base_url' => APP_BASE_URL,
// 'schema' => ['name' => 'full_name'], // só o que difere
// 'roles' => ['staff' => 40, 'client' => 20],
// 'remember' => true,
// 'contact_email' => 'geral@exemplo.pt',
// ]);
//
// e o conector liga a autenticação partilhada, o «manter sessão», o bloqueio de
// IP e os eventos de segurança, e fica com o que as outras ferramentas precisam
// de saber (PDO, URL, ambiente, guarda, layout).
//
// CONFORMIDADE
//
// platform_conformance() compara a base de dados com o que o código partilhado
// espera. Só procura avarias SILENCIOSAS — as que não dão erro nenhum e deixam
// uma defesa desligada. O exemplo que o motivou: o auth_attempts da fotografia
// tem `ip, scope` e não `identifier`; o throttle partilhado falharia a consulta
// e, por desenho, deixaria entrar — o throttle desligava-se sem ninguém saber.
//
// Em desenvolvimento, platform_connect() recusa-se a arrancar com um erro de
// conformidade (excepto nas páginas de sistema e na API, onde está o runner de
// migrações que o corrige). Em produção nunca: vai para o health do hub, como o
// check `conformance`.
//
// Este ficheiro só declara funções — o functions_auth.php inclui-o para ler os
// nomes padrão das colunas, e não pode arrastar nada consigo.
require_once __DIR__ . '/functions_routes.php';
// platform_audit() fica disponível a todo o site ligado (e às páginas e à API
// do platform-tools). Só declara funções — nada corre ao incluir.
require_once __DIR__ . '/functions_audit.php';
const PLATFORM_DEV_ENVS = ['dev', 'development', 'local', 'staging', 'test'];
class PlatformConformanceError extends RuntimeException {}
/** Os nomes padrão da tabela de contas. auth_schema() parte daqui. */
function platform_auth_schema_defaults(): array {
return [
'table' => 'users',
'id' => 'id',
'email' => 'email',
// A coluna do formulário de login. null => `email`. A quack entra por
// `username` e continua a precisar de `email` para a recuperação.
'login' => null,
'password' => 'password_hash',
'name' => 'name',
'role' => 'role',
'active' => 'is_active', // null => o site não tem esta coluna
'deleted_at' => null, // o site apaga por soft-delete
'last_login' => 'last_login_at',
'last_ip' => 'last_login_ip',
];
}
// ─── Registo ─────────────────────────────────────────────────────────────────
function platform_config(?array $set = null): array {
static $cfg = [];
if ($set !== null) $cfg = $set;
return $cfg;
}
function platform_connected(): bool {
return platform_config() !== [];
}
function platform_pdo(): ?PDO {
$pdo = platform_config()['pdo'] ?? null;
return $pdo instanceof PDO ? $pdo : null;
}
function platform_env(): string {
$env = platform_config()['env'] ?? null;
if ($env === null || $env === '') {
$env = function_exists('env') ? (string) env('APP_ENV', '') : (string) ($_ENV['APP_ENV'] ?? getenv('APP_ENV') ?: '');
}
// Sem APP_ENV é produção: o lado seguro, em que nada rebenta de propósito.
return strtolower(trim((string) $env)) ?: 'prod';
}
function platform_is_dev(): bool {
return in_array(platform_env(), PLATFORM_DEV_ENVS, true);
}
// ─── Ligação ─────────────────────────────────────────────────────────────────
/**
* Liga o site ao platform-tools. Chamar uma vez, no arranque, depois de abrir o
* PDO. $pdo null é uma montra estática: não há auth, bloqueio nem eventos.
*
* Opções (todas opcionais):
* base_url URL absoluto da app. Por omissão APP_BASE_URL.
* env 'prod' | 'dev' | … Por omissão APP_ENV; sem ele, 'prod'.
* auth 'shared' (functions_auth.php) ou 'own' (o site tem a sua).
* schema o que difere de platform_auth_schema_defaults().
* roles papéis próprios => nível (os de base são sysadmin/admin/user).
* session opções de auth_session_start(); false = o site abre a sua.
* remember true para repor sessões do «manter sessão iniciada»; ou as
* opções de auth_remember_resume() ('login', 'allow').
* A caixa e o olho do formulário: functions_login_ui.php.
* ip_guard false desliga o bloqueio de IP à porta.
* contact_email o email mostrado a quem for bloqueado por engano.
* guard callable que exige sysadmin (as páginas partilhadas).
* layout ['header' => …, 'navbar' => …, 'footer' => …].
* conformance 'auto' (recusa em dev) ou 'report' (só reporta).
* conformance_exempt fragmentos de caminho onde dev não recusa.
* router true quando o site tem o public/platform.php (router.php):
* os links para as páginas partilhadas passam a ir por lá.
* (O public/api/platform.php — api/router.php — é o mesmo
* para a API.)
* clean_urls true quando o .htaccess do site serve `/platform` a partir de
* `platform.php`: os URLs do router saem sem `.php`.
* api_files implementação própria de um endpoint: [nome => ficheiro].
* audit o registo de auditoria (functions_audit.php): ['table' =>
* 'audit_log', 'map' => [campo canónico => coluna do site],
* 'keep_days' => 730]. Só o que difere; sem nada, a tabela
* canónica `audit_log`, criada no primeiro uso.
* dashboard_url, migrations_dir, sysadmin_tools, hide_tools, page_vars —
* o que o router passa às páginas; ver router.php.
*/
function platform_connect(?PDO $pdo, array $cfg = []): void {
if (platform_connected()) return;
$cfg += [
'base_url' => null,
'env' => null,
'auth' => 'shared',
'schema' => [],
'roles' => [],
'session' => [],
'remember' => false,
'ip_guard' => true,
'contact_email' => null,
'guard' => null,
'layout' => [],
'conformance' => 'auto',
// O runner de migrações vive nas páginas de sistema, e o health na API:
// recusar lá deixava o site sem a ferramenta que o conserta.
'conformance_exempt' => ['/api/', '/sysadmin/', '/setup.php', '/platform.php'],
'router' => false,
'clean_urls' => false,
];
if (!in_array($cfg['auth'], ['shared', 'own'], true)) {
throw new InvalidArgumentException("platform_connect: auth tem de ser 'shared' ou 'own'");
}
$cfg['pdo'] = $pdo;
platform_config($cfg);
if ($cfg['auth'] === 'shared') {
require_once __DIR__ . '/functions_auth.php';
if ($cfg['schema']) auth_schema($cfg['schema']);
if ($cfg['roles']) auth_roles_register($cfg['roles']);
if ($cfg['session'] !== false) auth_session_start((array) $cfg['session']);
}
if (!$pdo instanceof PDO) return;
if ($cfg['conformance'] === 'auto' && platform_is_dev()) {
platform_conformance_enforce($pdo);
}
if ($cfg['ip_guard']) {
require_once __DIR__ . '/functions_ip_ban.php';
platform_ip_guard($pdo, $cfg['contact_email']);
}
if ($cfg['auth'] === 'shared' && $cfg['remember']) {
require_once __DIR__ . '/functions_remember.php';
// Um array em vez de true são as opções de auth_remember_resume() — o
// gancho 'login' de um site que guarda mais na sessão (a silver:
// full_name e avatar), ou o 'allow' de quem tem regras próprias.
auth_remember_resume($pdo, is_array($cfg['remember']) ? $cfg['remember'] : []);
}
}
function platform_conformance_enforce(PDO $pdo): void {
if (PHP_SAPI === 'cli') return;
$r = platform_conformance($pdo);
if ($r['ok']) return;
$errors = array_values(array_filter($r['issues'], fn($i) => $i['level'] === 'error'));
$lines = [];
foreach ($errors as $i) {
$lines[] = '- ' . $i['message'] . ($i['fix'] !== null ? "\n " . $i['fix'] : '');
}
$msg = "platform-tools: a base de dados não está conforme (" . count($errors) . " erro(s)).\n"
. implode("\n", $lines)
. "\nAplique as correcções numa migração (ou mapeie a coluna em auth_schema()).";
$path = (string) (parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH) ?? '');
foreach ((array) (platform_config()['conformance_exempt'] ?? []) as $frag) {
if ($frag !== '' && str_contains($path, (string) $frag)) {
error_log('[conformance] ' . str_replace("\n", ' | ', $msg));
return;
}
}
throw new PlatformConformanceError($msg);
}
// ─── Conformidade ────────────────────────────────────────────────────────────
/**
* As tabelas que o código partilhado escreve. `required`: colunas que ele lê ou
* escreve, com a definição para as acrescentar. `optional`: detectadas em tempo
* de execução, a falta não parte nada. `insert`: o que ele põe num INSERT — uma
* coluna NOT NULL sem valor por omissão fora desta lista faz o INSERT falhar.
*
* Todas são criadas no primeiro uso quando não existem; o problema é só quando
* existem com outra forma.
*/
function platform_standard_tables(): array {
return [
'auth_attempts' => [
'required' => [
'identifier' => "VARCHAR(190) NOT NULL DEFAULT ''",
'ip' => 'VARCHAR(45) NULL',
'attempted_at' => 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP',
],
'optional' => ['success' => 'TINYINT(1) NOT NULL DEFAULT 0'],
'insert' => ['identifier', 'ip'],
'why' => 'o throttle de login partilhado deixa entrar quando a consulta falha',
],
'auth_remember_tokens' => [
'required' => [
'user_id' => 'BIGINT UNSIGNED NOT NULL',
'selector' => 'CHAR(32) NOT NULL',
'validator_hash' => 'CHAR(64) NOT NULL',
'expires_at' => 'DATETIME NOT NULL',
'last_used_at' => 'DATETIME NULL',
'last_ip' => 'VARCHAR(45) NULL',
'user_agent' => 'VARCHAR(255) NULL',
],
'optional' => [],
'insert' => ['user_id', 'selector', 'validator_hash', 'expires_at', 'last_ip', 'user_agent'],
'why' => '«manter sessão iniciada» não emite nem repõe sessões',
],
'ip_bans' => [
'required' => [
'ip' => 'VARCHAR(45) NOT NULL',
'reason' => 'VARCHAR(190) NOT NULL',
'source' => "VARCHAR(40) NOT NULL DEFAULT 'auto'",
'expires_at' => 'DATETIME NULL',
'created_at' => 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP',
'created_by' => 'INT NULL',
'hits' => 'INT NOT NULL DEFAULT 0',
],
'optional' => [],
'insert' => ['ip', 'reason', 'source', 'expires_at', 'created_by'],
'why' => 'o bloqueio de IP falha aberto — ninguém é banido',
],
'ip_offences' => [
'required' => [
'ip' => 'VARCHAR(45) NOT NULL',
'kind' => 'VARCHAR(40) NOT NULL',
'detail' => 'VARCHAR(190) NULL',
'created_at' => 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP',
],
'optional' => [],
'insert' => ['ip', 'kind', 'detail'],
'why' => 'as infracções não se registam e a escalada de banimento nunca começa',
],
'system_settings' => [
'required' => [
'setting_key' => 'VARCHAR(100) NOT NULL',
'setting_value' => 'TEXT NULL',
'updated_by' => 'INT NULL',
],
'optional' => [],
'insert' => ['setting_key', 'setting_value', 'updated_by'],
'why' => 'as definições partilhadas e o emparelhamento com o hub não gravam',
],
];
}
/**
* Lê o esquema e avalia. Só leitura — nunca cria nem altera nada. Uma consulta.
* Devolve ['ok' => bool, 'issues' => [['level','table','message','fix'], …]].
*/
function platform_conformance(PDO $pdo): array {
static $cache = null;
if ($cache !== null) return $cache;
$schema = function_exists('auth_schema') ? auth_schema() : platform_auth_schema_defaults();
$tables = array_merge(array_keys(platform_standard_tables()), [$schema['table']]);
$in = implode(',', array_fill(0, count($tables), '?'));
$st = $pdo->prepare(
"SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, EXTRA
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN ($in)"
);
$st->execute($tables);
$columns = [];
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $c) {
$columns[$c['TABLE_NAME']][$c['COLUMN_NAME']] = $c;
}
$cfg = platform_config();
return $cache = platform_conformance_evaluate($columns, $schema, [
'connected' => $cfg !== [],
'auth' => $cfg['auth'] ?? null,
'app_env' => function_exists('env') ? (string) env('APP_ENV', '') : (string) ($_ENV['APP_ENV'] ?? getenv('APP_ENV') ?: ''),
'env' => platform_env(),
]);
}
/**
* A avaliação, sem base de dados. $columns: [tabela => [coluna => linha do
* information_schema]]. Separada para se poder testar com o esquema de cada site.
*/
function platform_conformance_evaluate(array $columns, array $schema, array $ctx): array {
$issues = [];
$add = static function (string $level, string $table, string $message, ?string $fix = null) use (&$issues): void {
$issues[] = ['level' => $level, 'table' => $table, 'message' => $message, 'fix' => $fix];
};
// ── Tabelas partilhadas ─────────────────────────────────────────────────
foreach (platform_standard_tables() as $table => $spec) {
if (!isset($columns[$table])) continue; // criada no primeiro uso, já com a forma certa
$have = $columns[$table];
foreach ($spec['required'] as $col => $def) {
if (!isset($have[$col])) {
$add('error', $table, "$table sem a coluna `$col` — {$spec['why']}",
"ALTER TABLE `$table` ADD COLUMN IF NOT EXISTS `$col` $def;");
}
}
foreach ($spec['optional'] as $col => $def) {
if (!isset($have[$col])) {
$add('info', $table, "$table sem a coluna opcional `$col`",
"ALTER TABLE `$table` ADD COLUMN IF NOT EXISTS `$col` $def;");
}
}
// Uma coluna antiga NOT NULL e sem valor por omissão faz falhar os INSERT
// do código partilhado — em modo estrito, um erro que ele engole.
foreach ($have as $col => $c) {
if (in_array($col, $spec['insert'], true)) continue;
$extra = strtolower((string) $c['EXTRA']);
if ($c['IS_NULLABLE'] === 'NO' && $c['COLUMN_DEFAULT'] === null
&& !str_contains($extra, 'auto_increment') && !str_contains($extra, 'generated')) {
$add('error', $table, "$table.`$col` é NOT NULL sem valor por omissão — os INSERT partilhados falham ({$spec['why']})",
"ALTER TABLE `$table` MODIFY `$col` {$c['COLUMN_TYPE']} NULL;");
}
}
}
// ── Tabela de contas ────────────────────────────────────────────────────
$defaults = platform_auth_schema_defaults();
$table = (string) $schema['table'];
$users = $columns[$table] ?? null;
if ($users === null) {
$add('error', $table, "tabela de contas `$table` não existe", null);
} else {
// O que se pode acrescentar sem mexer em código; o resto mapeia-se.
$addable = [
'active' => 'TINYINT(1) NOT NULL DEFAULT 1',
'last_login' => 'DATETIME NULL',
'last_ip' => 'VARCHAR(45) NULL',
];
foreach (['id', 'email', 'login', 'password', 'name', 'role', 'active', 'deleted_at', 'last_login', 'last_ip'] as $key) {
$col = $schema[$key] ?? null;
if ($col === null || isset($users[$col])) continue;
$fix = isset($addable[$key]) && $col === $defaults[$key]
? "ALTER TABLE `$table` ADD COLUMN IF NOT EXISTS `$col` {$addable[$key]}; -- ou auth_schema(['$key' => null])"
: "auth_schema(['$key' => '<coluna deste site>'])";
$add('error', $table, "$table sem a coluna `$col` (chave `$key`)", $fix);
}
}
// Os desvios mapeados não partem nada: ficam à vista para convergirem.
foreach ($defaults as $key => $std) {
$mine = $schema[$key] ?? null;
if ($mine === $std) continue;
if ($mine === null) {
$add('info', $table, "sem `$std` (desligado em auth_schema(['$key' => null]))");
} elseif ($std === null) {
$add('info', $table, "`$key` => `$mine` (extensão deste site)");
} else {
$add('info', $table, "`$mine` no lugar de `$std` (mapeado em auth_schema())");
}
}
// ── Ligação ─────────────────────────────────────────────────────────────
if (empty($ctx['connected'])) {
$add('info', '', 'ainda não chama platform_connect()');
} elseif (($ctx['auth'] ?? null) === 'own') {
$add('warn', '', 'autenticação própria — sem o endurecimento de sessão, o rehash e o throttle partilhados');
}
if (($ctx['app_env'] ?? '') === '') {
$add('warn', '', 'APP_ENV não definido — tratado como produção');
}
$ok = !array_filter($issues, fn($i) => $i['level'] === 'error');
return ['ok' => $ok, 'issues' => $issues];
}
/** `checks.conformance` para o health do hub. */
function platform_conformance_check(PDO $pdo): array {
$r = platform_conformance($pdo);
$count = ['error' => 0, 'warn' => 0, 'info' => 0];
foreach ($r['issues'] as $i) $count[$i['level']]++;
$first = null;
foreach ($r['issues'] as $i) {
if ($i['level'] === 'error') { $first = $i['message']; break; }
}
return [
'ok' => $r['ok'],
'detail' => $count['error'] . ' erro(s), ' . $count['warn'] . ' aviso(s), ' . $count['info'] . ' nota(s)'
. ($first !== null ? ' — ' . $first : ''),
'errors' => $count['error'],
'warnings' => $count['warn'],
'issues' => $r['issues'],
];
}
-46
View File
@@ -1,46 +0,0 @@
<?php
// ─── CSRF helpers (shared) ───────────────────────────────────────────────────
//
// Self-contained double-submit-free session token for every POST form rendered
// by the platform-tools pages (platform_update.php, backups.php) and verified
// by their handlers. Deliberately independent of any per-host csrf helper —
// the only contract is the same one the flash messages already rely on: the
// stub's bootstrap has a PHP session available.
//
// Public API:
// platform_csrf_token(): string — session token (created on first use)
// platform_csrf_field(): string — ready-to-echo hidden <input>
// platform_csrf_ok(): bool — validate the token sent with a POST
function platform_csrf_token(): string {
if (session_status() !== PHP_SESSION_ACTIVE) {
@session_start();
}
if (empty($_SESSION['platform_tools_csrf'])) {
$_SESSION['platform_tools_csrf'] = bin2hex(random_bytes(32));
}
return (string) $_SESSION['platform_tools_csrf'];
}
function platform_csrf_field(): string {
return '<input type="hidden" name="_pt_csrf" value="'
. htmlspecialchars(platform_csrf_token())
. '">';
}
function platform_csrf_ok(): bool {
if (session_status() !== PHP_SESSION_ACTIVE) {
@session_start();
}
$sent = (string) ($_POST['_pt_csrf'] ?? '');
$have = (string) ($_SESSION['platform_tools_csrf'] ?? '');
$ok = $sent !== '' && $have !== '' && hash_equals($have, $sent);
if (!$ok) {
// Uma sessão expirada também cai aqui; é no hub, com a frota inteira à
// vista, que um padrão se distingue de um formulário esquecido aberto.
require_once __DIR__ . '/functions_security_events.php';
platform_security_event(null, 'csrf_failed',
(string) (parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?? ''));
}
return $ok;
}
@@ -1,329 +0,0 @@
<?php
// ─────────────────────────────────────────────────────────────────────────────
// Feedback da frota → balcão de tickets do hub
//
// Quem opera estas plataformas todos os dias é quem primeiro dá pelos defeitos
// — e até aqui não tinha por onde os dizer sem sair do trabalho, abrir outro
// domínio e autenticar-se outra vez. A esmagadora maioria desses relatos
// morria aí, ou chegava por mensagem solta sem página, sem versão e sem conta.
//
// Isto é o lado da plataforma: valida o que o administrador escreveu, junta o
// contexto que ele não tem de saber recolher (página, versão, quem é, que
// browser) e entrega ao `hub_ticket_create()`, que já existia no
// functions_hub.php e fala com o `/api/tickets.php` do hub.
//
// Três ficheiros, uma responsabilidade cada:
// functions_feedback.php — isto: regras, contexto e envio
// feedback_widget.php — a bolha e o formulário, incluídos pelo layout
// api/feedback.php — o endpoint JSON que a bolha chama
//
// ─── Porque é que a permissão se lê aqui e não se confia no host ─────────────
//
// A frota não tem um bootstrap só. O backoffice carrega o `config/functions.php`
// inteiro e tem `is_admin()`; as páginas públicas da fotografia arrancam com
// meia dúzia de requires e não têm. Como a bolha é para aparecer nos dois
// sítios (um administrador dá pelo defeito tanto no painel como no site), a
// leitura do papel cai para `$_SESSION['user']` quando os helpers do site não
// estão carregados. O formato é o mesmo em toda a frota: `role`, e `roles` nos
// sites com papéis múltiplos (dance).
//
// Isto decide o que se MOSTRA. Quem decide o que se ACEITA é o endpoint, que
// corre depois do bootstrap completo do site e volta a perguntar.
// ─────────────────────────────────────────────────────────────────────────────
require_once __DIR__ . '/functions_hub.php';
// As categorias do hub (config/functions_tickets.php lá) que fazem sentido para
// quem reporta sobre a própria plataforma. 'conta' e 'outro' ficam de fora: a
// primeira é para quem não consegue entrar — e quem está a ver esta bolha
// entrou —, a segunda é uma gaveta que não ajuda a triar.
function platform_feedback_categories(): array
{
return [
'problema' => 'Algo não funciona',
'alteracao' => 'Pedido de alteração',
'sugestao' => 'Sugestão',
'duvida' => 'Dúvida',
];
}
/**
* A conta com sessão aberta, seja qual for o bootstrap do site.
*
* A frota guarda a sessão de três maneiras e nenhuma é conversora das outras:
*
* 1. `current_user()` — dance, fotografia, design
* 2. `$_SESSION['user']` (array) — surf/silver, e os mesmos sem os helpers
* carregados (o site público da fotografia)
* 3. chaves soltas na sessão — quack: user_id, username, role
*
* A terceira não traz e-mail, e o balcão do hub exige um para poder responder;
* por isso, e só nesse caso, vai-se buscá-lo à tabela `users` — uma vez, com o
* resultado na própria sessão, para não custar uma query por página.
*
* Nunca abre sessão: as páginas públicas que a não têm aberta não a devem
* passar a ter por causa de um include no rodapé.
*/
function platform_feedback_user(): ?array
{
if (function_exists('current_user')) {
$user = current_user();
if (is_array($user) && $user) {
return $user;
}
}
if (session_status() !== PHP_SESSION_ACTIVE) {
return null;
}
$user = $_SESSION['user'] ?? null;
if (is_array($user) && $user) {
return $user;
}
$id = (int) ($_SESSION['user_id'] ?? 0);
if ($id <= 0) {
return null;
}
$cache = $_SESSION['__ptfb_user'] ?? null;
if (is_array($cache) && (int) ($cache['id'] ?? 0) === $id) {
return $cache;
}
$user = [
'id' => $id,
'name' => (string) ($_SESSION['username'] ?? ''),
'email' => '',
'role' => (string) ($_SESSION['role'] ?? ''),
];
// `SELECT *` e não uma lista de colunas: os nomes divergem entre sites
// (name / full_name / display_name / username) e uma coluna inexistente
// seria um erro de SQL em vez de um campo em falta. Da linha ficam dois
// campos; o resto — hash da password incluído — morre nesta função.
$pdo = hub_pdo();
if ($pdo instanceof PDO) {
try {
$st = $pdo->prepare('SELECT * FROM users WHERE id = ? LIMIT 1');
$st->execute([$id]);
$row = $st->fetch(PDO::FETCH_ASSOC) ?: [];
$user['email'] = trim((string) ($row['email'] ?? ''));
$nome = platform_feedback_name($row);
if ($nome !== '') {
$user['name'] = $nome;
}
} catch (Throwable $e) {
error_log('platform_feedback_user: ' . $e->getMessage());
}
}
$_SESSION['__ptfb_user'] = $user;
return $user;
}
/**
* O nome da pessoa, venha a coluna com o nome que vier.
*
* name (dance, fotografia), full_name (surf), display_name/username (quack).
* A ordem é do mais human-readable para o mais técnico: um ticket assinado
* "andre_m" diz menos do que um assinado "André Moreira".
*/
function platform_feedback_name(array $user): string
{
foreach (['name', 'full_name', 'display_name', 'nome', 'username'] as $campo) {
$v = trim((string) ($user[$campo] ?? ''));
if ($v !== '') {
return $v;
}
}
return '';
}
/** Os papéis da conta — `roles` onde existe (dance), `role` nos restantes. */
function platform_feedback_roles(?array $user = null): array
{
$user ??= platform_feedback_user();
if (!$user) {
return [];
}
$roles = $user['roles'] ?? [];
if (!is_array($roles)) {
$roles = [];
}
if (!$roles && !empty($user['role'])) {
$roles = [(string) $user['role']];
}
return array_values(array_unique(array_map('strval', $roles)));
}
function platform_feedback_is_admin(?array $user = null): bool
{
return (bool) array_intersect(['admin', 'sysadmin'], platform_feedback_roles($user));
}
function platform_feedback_is_sysadmin(?array $user = null): bool
{
return in_array('sysadmin', platform_feedback_roles($user), true);
}
/**
* O commit em que esta instalação está, lido do .git à mão.
*
* Sem shell e sem git: `git_local_state()` faz três chamadas ao binário, e num
* alojamento cPanel não há binário nenhum. Aqui, ou o ficheiro está lá e
* responde-se, ou devolve-se string vazia e o relato vai sem esta linha.
*/
function platform_feedback_commit(?string $root = null): string
{
$root = $root ?: dirname(__DIR__, 2);
$head = @file_get_contents($root . '/.git/HEAD');
if (!is_string($head)) {
return '';
}
$head = trim($head);
if (str_starts_with($head, 'ref: ')) {
$ref = trim(substr($head, 5));
$hash = @file_get_contents($root . '/.git/' . $ref);
if (!is_string($hash)) {
// Repositório com as refs empacotadas (git gc) — não há ficheiro por ref.
$packed = @file_get_contents($root . '/.git/packed-refs');
$hash = (is_string($packed)
&& preg_match('/^([0-9a-f]{40})\s+' . preg_quote($ref, '/') . '$/m', $packed, $m))
? $m[1] : '';
}
$head = trim((string) $hash);
}
return preg_match('/^[0-9a-f]{7,40}$/', $head) ? substr($head, 0, 7) : '';
}
/**
* O rodapé técnico que vai colado à descrição.
*
* Não é enfeite: sem a página não se sabe onde foi, sem a versão não se sabe
* se já está corrigido, e sem o browser metade dos defeitos de interface não
* se reproduzem. Pedir isto a quem reporta é garantir que não vem — mas vai
* escrito à vista no formulário, porque o que segue junto com um relato tem de
* ser sabido por quem o envia.
*/
function platform_feedback_context(array $user, string $pageUrl): string
{
$linhas = [];
$nome = platform_feedback_name($user);
$email = trim((string) ($user['email'] ?? ''));
$papel = implode(', ', platform_feedback_roles($user));
$linhas[] = 'Conta: ' . ($nome !== '' ? $nome . ' <' . $email . '>' : $email)
. ($papel !== '' ? ' · ' . $papel : '');
if ($pageUrl !== '') {
$linhas[] = 'Página: ' . $pageUrl;
}
$plataforma = hub_app_name();
$host = (string) ($_SERVER['HTTP_HOST'] ?? '');
$linhas[] = 'Plataforma: ' . $plataforma . ($host !== '' ? ' (' . $host . ')' : '');
// O changelog não vem no bootstrap de nenhum site — carrega-se aqui. É um
// ficheiro sem efeitos e a versão é o campo que mais depressa diz se um
// defeito relatado já está corrigido.
if (!function_exists('changelog_latest') && is_file(__DIR__ . '/functions_changelog.php')) {
require_once __DIR__ . '/functions_changelog.php';
}
$versao = function_exists('changelog_latest') ? (string) (changelog_latest()['version'] ?? '') : '';
$commit = platform_feedback_commit();
$partes = array_filter([$versao, $commit !== '' ? 'commit ' . $commit : '']);
if ($partes) {
$linhas[] = 'Versão: ' . implode(' · ', $partes);
}
$ua = trim((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''));
if ($ua !== '') {
$linhas[] = 'Browser: ' . mb_substr($ua, 0, 300);
}
$linhas[] = 'Enviado em: ' . date('Y-m-d H:i');
return "\n\n---\n" . implode("\n", $linhas);
}
/**
* Valida, monta e entrega. Devolve o mesmo formato do hub_ticket_create():
* ['ok'=>bool, 'error'=>string, 'ref'=>string, 'track_url'=>string].
*
* $in: ['title', 'description', 'category', 'blocking', 'page_url']
*/
function platform_feedback_submit(array $in, ?array $user = null): array
{
$falha = static fn(string $msg): array => ['ok' => false, 'error' => $msg, 'ref' => '', 'track_url' => ''];
$user ??= platform_feedback_user();
if (!$user || !platform_feedback_is_admin($user)) {
return $falha('Sessão sem permissão para reportar.');
}
if (!hub_ticket_ready()) {
return $falha('Esta plataforma ainda não está ligada ao balcão de apoio.');
}
$email = trim((string) ($user['email'] ?? ''));
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
return $falha('A tua conta não tem um e-mail válido — sem ele não há como responder ao pedido.');
}
$titulo = trim((string) ($in['title'] ?? ''));
$texto = trim((string) ($in['description'] ?? ''));
// As mesmas medidas do intake do hub, verificadas deste lado para a pessoa
// saber o que falta antes de a mensagem atravessar a rede.
if (mb_strlen($titulo) < 5) {
return $falha('O assunto precisa de pelo menos 5 letras.');
}
if (mb_strlen($texto) < 10) {
return $falha('Descreve o que aconteceu — pelo menos 10 letras.');
}
$titulo = mb_substr($titulo, 0, 190);
$texto = mb_substr($texto, 0, 5000);
$categorias = platform_feedback_categories();
$categoria = (string) ($in['category'] ?? 'problema');
if (!isset($categorias[$categoria])) {
$categoria = 'problema';
}
// Só o "está a bloquear" sobe a prioridade. Um formulário com as três à
// escolha enche-se de urgentes; uma pergunta fechada não.
$prioridade = !empty($in['blocking']) ? 1 : 2;
// O URL da página vem do browser, e por isso não se acredita nele: só passa
// se for do próprio site. Colado à descrição, seria uma ligação que quem
// lê o ticket clicaria.
$pageUrl = platform_feedback_safe_page_url((string) ($in['page_url'] ?? ''));
return hub_ticket_create([
'submitter_email' => $email,
'submitter_name' => mb_substr(platform_feedback_name($user), 0, 190),
'title' => $titulo,
'description' => $texto . platform_feedback_context($user, $pageUrl),
'category' => $categoria,
'priority' => $prioridade,
]);
}
/**
* Aceita o URL da página só se apontar para este mesmo site. Um valor de fora
* seria uma ligação para terceiros dentro de um ticket que alguém vai abrir.
*/
function platform_feedback_safe_page_url(string $url): string
{
$url = trim($url);
if ($url === '' || mb_strlen($url) > 500) {
return '';
}
$host = parse_url($url, PHP_URL_HOST);
$mine = (string) ($_SERVER['HTTP_HOST'] ?? '');
if (!is_string($host) || $host === '' || $mine === '' || strcasecmp($host, $mine) !== 0) {
return '';
}
return in_array(parse_url($url, PHP_URL_SCHEME), ['http', 'https'], true) ? $url : '';
}
-544
View File
@@ -1,544 +0,0 @@
<?php
// ─── Country gating ─────────────────────────────────────────────────────────
//
// Some platforms in the estate only ever serve one country. dance is the clear
// case: an academy in Aveiro takes students who can walk into the building, so
// a registration from outside Portugal is spam by definition, not a lost sale.
// Filtering on content alone was losing that argument — the bots write valid
// Portuguese-looking names and use real mailboxes.
//
// Two levers, because they carry very different risk:
//
// GEO_MODE=forms — public POSTs (register, contact, trial) are refused from
// outside the allow-list. Reading the site stays open, so
// a search engine, a preview crawler or an emigrant reading
// the timetable is unaffected. This is the safe default.
// GEO_MODE=site — the whole platform is refused. Only sensible when nobody
// abroad has any business here at all; it also hides the
// site from crawlers hosted outside the country, which is
// usually not what anyone wants.
// GEO_MODE=off — module inert (the default when the env is unset).
//
// The data is the per-country CIDR list from ipdeny.com, refreshed by
// bin/geo-update.sh into data/geo/<cc>.v4 and <cc>.v6. It is committed to the
// repo so a deploy never depends on that host being up, and a cron refresh
// keeps it current — allocations move slowly, monthly is plenty.
//
// Failure posture is fail-OPEN, deliberately. A missing or corrupt data file
// means "allow and log loudly", never "lock everyone out". Getting this
// backwards turns a stale file into a total outage; the worst case of failing
// open is that the spam gets through the way it already does today. Set
// GEO_FAIL_CLOSED=true to invert it, if a platform would rather refuse.
require_once __DIR__ . '/functions_client_ip.php'; // platform_client_ip()
const GEO_DATA_DIR = __DIR__ . '/data/geo';
/** off | forms | site */
function geo_mode(): string
{
$m = strtolower(trim((string) geo_env('GEO_MODE', 'off')));
return in_array($m, ['forms', 'site'], true) ? $m : 'off';
}
/** Country codes that may reach us, upper-case. Empty means "no gate". */
function geo_allowed_countries(): array
{
$raw = (string) geo_env('GEO_ALLOW_COUNTRIES', '');
$out = [];
foreach (preg_split('/[\s,;]+/', $raw, -1, PREG_SPLIT_NO_EMPTY) ?: [] as $cc) {
$cc = strtoupper($cc);
if (preg_match('/^[A-Z]{2}$/', $cc)) {
$out[] = $cc;
}
}
return array_values(array_unique($out));
}
function geo_fail_closed(): bool
{
return filter_var(geo_env('GEO_FAIL_CLOSED', 'false'), FILTER_VALIDATE_BOOLEAN);
}
/**
* Config value: primeiro a base de dados, depois o .env, depois o default.
*
* A base ganha para a página do backoffice servir para alguma coisa -- mudar o
* modo no .env obriga a recriar o contentor, o que não é coisa que se peça a
* quem só quer desligar o filtro porque um cliente ficou de fora.
*
* Uma linha vazia conta como "não definido" e cai para o .env. É de propósito:
* em `platform_settings_get()` uma string vazia gravada silenciosamente ganha a
* um .env preenchido, e essa foi das armadilhas mais caras desta plataforma.
* Aqui vazio quer mesmo dizer vazio.
*
* O $pdo é apanhado do global porque este ficheiro é carregado por caminhos que
* ainda não têm ligação (o guard do site corre cedo). Sem base, usa o .env.
*
* A cache é por pedido. A página de configuração grava e redirige, por isso o
* pedido seguinte já lê o valor novo -- não é preciso invalidá-la à mão.
*/
function geo_env(string $key, string $default = ''): string
{
static $dbCache = [];
if (!array_key_exists($key, $dbCache)) {
global $pdo;
// So se guarda em cache o que a base de dados chegou mesmo a responder.
//
// Guardar tambem o falhanco parecia inofensivo e nao era: o guard do
// site corre cedo no bootstrap, antes de o functions_settings.php estar
// carregado, e a primeira pergunta -- GEO_MODE -- apanhava sempre o
// caminho sem base. O vazio ficava colado ao pedido inteiro, por isso o
// modo lia-se do .env (logo "off") enquanto a lista de paises, pedida
// mais tarde, ja vinha certa da base. O interruptor da pagina gravava e
// nao acendia nada, e a assimetria entre os dois valores tornava a
// avaria dificil de ver.
$consultada = false;
$valor = '';
if ($pdo instanceof PDO && function_exists('platform_settings_get')) {
try {
$valor = trim((string) platform_settings_get($pdo, strtolower($key), ''));
$consultada = true;
} catch (Throwable) {
$consultada = false;
}
}
if (!$consultada) {
// Sem base ainda: responde-se pelo .env desta vez e tenta-se de novo
// na proxima chamada, que pode ja ter ligacao.
return geo_env_fallback($key, $default);
}
$dbCache[$key] = $valor;
}
if ($dbCache[$key] !== '') {
return $dbCache[$key];
}
return geo_env_fallback($key, $default);
}
/** O valor do .env (ou do ambiente), quando a base nao tem nada a dizer. */
function geo_env_fallback(string $key, string $default = ''): string
{
if (function_exists('env')) {
$v = (string) env($key, '');
if ($v !== '') {
return $v;
}
return $default;
}
$v = getenv($key);
if ($v === false || $v === '') {
$v = $_ENV[$key] ?? $default;
}
return (string) $v;
}
/**
* Ranges for one country, as [startBinary, endBinary] pairs sorted ascending.
*
* Addresses are compared as the packed bytes inet_pton() produces, so the same
* strcmp() works for v4 (4 bytes) and v6 (16) without any integer arithmetic —
* and without the signed-32-bit trap ip2long() sets for anything above
* 127.255.255.255.
*/
function geo_ranges(string $cc, bool $v6): array
{
static $cache = [];
$key = $cc . ($v6 ? '.v6' : '.v4');
if (isset($cache[$key])) {
return $cache[$key];
}
$file = GEO_DATA_DIR . '/' . strtolower($cc) . ($v6 ? '.v6' : '.v4');
if (!is_file($file)) {
return $cache[$key] = [];
}
$ranges = [];
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);
if ($r !== null) {
$ranges[] = $r;
}
}
usort($ranges, fn($a, $b) => strcmp($a[0], $b[0]));
return $cache[$key] = $ranges;
}
/** "2.80.0.0/14" → [packed first address, packed last address], or null. */
function geo_cidr_to_range(string $cidr): ?array
{
$parts = explode('/', $cidr, 2);
$net = $parts[0];
$packed = @inet_pton($net);
if ($packed === false) {
return null;
}
$bits = strlen($packed) * 8;
$prefix = isset($parts[1]) ? (int) $parts[1] : $bits;
if ($prefix < 0 || $prefix > $bits) {
return null;
}
// Set every host bit to get the top of the range; the network address as
// given is the bottom. Done byte by byte so v4 and v6 share the code.
$end = $packed;
$host = $bits - $prefix;
for ($i = strlen($end) - 1; $i >= 0 && $host > 0; $i--) {
$take = min(8, $host);
$mask = (1 << $take) - 1;
$end[$i] = chr(ord($end[$i]) | $mask);
$host -= $take;
}
return [$packed, $end];
}
/** Is this address inside one of the country's ranges? */
function geo_ip_in_country(string $ip, string $cc): bool
{
$packed = @inet_pton($ip);
if ($packed === false) {
return false;
}
$ranges = geo_ranges($cc, strlen($packed) === 16);
if (!$ranges) {
return false;
}
$lo = 0;
$hi = count($ranges) - 1;
while ($lo <= $hi) {
$mid = intdiv($lo + $hi, 2);
if (strcmp($packed, $ranges[$mid][0]) < 0) {
$hi = $mid - 1;
} elseif (strcmp($packed, $ranges[$mid][1]) > 0) {
$lo = $mid + 1;
} else {
return true;
}
}
return false;
}
/**
* Loopback, private and link-local addresses always pass.
*
* Behind NPM the container talks to itself for health checks and cron, and a
* platform whose own cron is geo-blocked fails in ways that take a long time to
* understand.
*/
function geo_ip_is_internal(string $ip): bool
{
if ($ip === '') {
return true;
}
return !filter_var(
$ip,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
);
}
// ─── Duas maneiras de não bloquear quem não devia ser bloqueado ─────────────
//
// O portão por país compara o endereço com as alocações da ipdeny, e isso
// pressupõe uma coisa que já não é verdade para toda a gente: que o endereço de
// saída fica no país de quem navega. Dois casos em que não fica, e ambos
// custaram leads reais:
//
// 1. **iCloud Private Relay.** O tráfego do Safari sai por endereços da Apple
// (e dos parceiros dela), registados onde a Apple os registou e não onde
// está a pessoa. Alguém em Aveiro aparece-nos como estrangeiro, é recusado
// em silêncio, e fica à espera de uma resposta que nunca vem.
// 2. **VPNs de empresa, o escritório do cliente, um serviço de monitorização.**
// Casos que ninguém adivinha de antemão e que aparecem um de cada vez.
//
// Daí duas listas, com propósitos diferentes:
//
// GEO_ALLOW_IPS à mão, para o caso concreto que apareceu hoje.
// data/geo/icloud-relay.* automática, e com o país lá dentro.
//
// A diferença da segunda é que **não é uma whitelist**: a Apple publica, para
// cada gama de saída, o país que ela serve. Um utilizador de Relay em Portugal
// passa a contar como PT; um em França continua a contar como FR e o portão
// recusa-o na mesma. Uma whitelist cega às gamas do Relay abria o portão a
// qualquer subscritor do iCloud+ no mundo inteiro, que é precisamente o que o
// portão existe para não fazer.
/** Gamas que passam sempre, escritas à mão. Vazio = não há lista. */
function geo_allow_ips(): array
{
$raw = (string) geo_env('GEO_ALLOW_IPS', '');
$out = [];
foreach (preg_split('/[\s,;]+/', $raw, -1, PREG_SPLIT_NO_EMPTY) ?: [] as $entry) {
$r = geo_cidr_to_range($entry);
if ($r !== null) {
$out[] = $r;
} else {
error_log('[geo] GEO_ALLOW_IPS: entrada inválida ignorada: ' . $entry);
}
}
return $out;
}
/**
* Está na lista escrita à mão?
*
* Percorre tudo em vez de procurar por bisseção, ao contrário das listas de
* país: isto tem meia dúzia de linhas escritas por uma pessoa, e ordenar um
* array de cinco elementos para poupar três comparações seria código a mais
* para quem vier a seguir.
*/
function geo_ip_allowlisted(?string $ip): bool
{
if ($ip === null || $ip === '') {
return false;
}
$packed = @inet_pton($ip);
if ($packed === false) {
return false;
}
foreach (geo_allow_ips() as [$start, $end]) {
if (strlen($start) === strlen($packed)
&& strcmp($packed, $start) >= 0 && strcmp($packed, $end) <= 0) {
return true;
}
}
return false;
}
/** O Relay é para ter em conta? Desligar faz o portão voltar a vê-lo como estrangeiro. */
function geo_trust_relay(): bool
{
return filter_var(geo_env('GEO_TRUST_RELAY', 'true'), FILTER_VALIDATE_BOOLEAN);
}
/**
* Este endereço é uma saída do Private Relay ao serviço deste país?
*
* As gamas vivem em `relay-<cc>.v4|.v6`, no mesmo formato e lidas pelo mesmo
* código que as listas da ipdeny — daí o `geo_ip_in_country()` com um prefixo
* em vez de um leitor novo. Foram filtradas por país à nascença: a lista que a
* Apple publica traz 42 mil gamas IPv4 e 246 mil IPv6 (855 KB e 6,7 MB), e
* carregar isso a cada pedido para responder a uma pergunta sobre Portugal —
* 611 gamas — não se justifica.
*
* Sem ficheiro devolve false, e o portão comporta-se exactamente como antes de
* isto existir. É de propósito: uma lista por descarregar não pode ser motivo
* para deixar entrar alguém que o operador quis barrar.
*/
function geo_relay_serves(string $ip, string $cc): bool
{
return geo_ip_in_country($ip, 'relay-' . strtolower($cc));
}
/** Há listas do Relay instaladas para estes países? A página usa isto para avisar. */
function geo_relay_available(array $countries): bool
{
foreach ($countries as $cc) {
if (geo_ranges('relay-' . strtolower($cc), false) || geo_ranges('relay-' . strtolower($cc), true)) {
return true;
}
}
return false;
}
/**
* Why this address should be refused, or null when it may pass.
*
* Returns the reason rather than a bool so the caller can log it next to the
* antispam reasons, which read the same way.
*/
function geo_block_reason(?string $ip = null): ?string
{
if (geo_mode() === 'off') {
return null;
}
$allowed = geo_allowed_countries();
if (!$allowed) {
return null; // gate configured but no list: inert
}
$ip ??= platform_client_ip() ?? '';
if (geo_ip_is_internal($ip)) {
return null;
}
// A lista escrita à mão manda em tudo o resto, incluindo no fail-closed
// abaixo: é a única defesa que continua a funcionar quando os dados de país
// faltam, e é para isso que serve — desbloquear alguém agora, sem esperar
// por um download.
if (geo_ip_allowlisted($ip)) {
return null;
}
// Saída do Private Relay ao serviço de um país permitido. Corre antes das
// listas da ipdeny porque para uma gama da Apple a resposta delas é onde a
// Apple registou o endereço, que não diz nada sobre quem está a navegar.
//
// Uma saída que sirva outro país não é tratada à parte: cai no ciclo abaixo
// e é recusada como qualquer endereço estrangeiro. Isto **não** é uma
// whitelist do Relay — abrir todas as gamas dele deixava entrar qualquer
// subscritor do iCloud+ no mundo inteiro, que é precisamente o que o portão
// existe para não fazer.
if (geo_trust_relay()) {
foreach ($allowed as $cc) {
if (geo_relay_serves($ip, $cc)) {
return null;
}
}
}
// No usable data for any allowed country is a deployment fault, not a
// visitor's. Say so in the log and let them through.
$haveData = false;
foreach ($allowed as $cc) {
if (geo_ranges($cc, false) || geo_ranges($cc, true)) {
$haveData = true;
break;
}
}
if (!$haveData) {
error_log('[geo] sem dados para ' . implode(',', $allowed)
. ' em ' . GEO_DATA_DIR . ' -- correr bin/geo-update.sh');
return geo_fail_closed() ? 'geo: dados indisponíveis' : null;
}
foreach ($allowed as $cc) {
if (geo_ip_in_country($ip, $cc)) {
return null;
}
}
return 'origem fora de ' . implode('/', $allowed);
}
/**
* Gate for a public form POST. Returns the reason, or null to carry on.
*
* Only bites under GEO_MODE=forms or site — the caller decides what to do with
* the reason, exactly as it does with antispam_check().
*/
function geo_form_block_reason(?string $ip = null): ?string
{
return geo_mode() === 'off' ? null : geo_block_reason($ip);
}
/**
* Gate for the whole platform. Call once, early, from the bootstrap.
*
* Sends 403 and stops. Under GEO_MODE=forms this does nothing, so it is safe to
* wire in permanently and switch modes from the env alone.
*/
function geo_guard_site(): void
{
if (geo_mode() !== 'site') {
return;
}
$reason = geo_block_reason();
if ($reason === null) {
return;
}
http_response_code(403);
header('Content-Type: text/html; charset=utf-8');
// Tell caches this is about who asked, not about the URL.
header('Vary: X-Forwarded-For');
echo '<!doctype html><meta charset="utf-8">'
. '<title>Indisponível na sua região</title>'
. '<div style="font:16px/1.6 system-ui,sans-serif;max-width:34em;margin:18vh auto;padding:0 1.5rem">'
. '<h1 style="font-size:1.3rem">Indisponível na sua região</h1>'
. '<p>Este site serve apenas visitantes em Portugal. Se acha que se trata de '
. 'um engano, contacte-nos por email.</p></div>';
exit;
}
// ─── Atribuição de país, para a analítica ───────────────────────────────────
//
// O bloqueio acima responde a "este endereço está na lista?" e só precisa dos
// países permitidos. A analítica faz a pergunta ao contrário -- "de que país
// veio isto?" -- e para isso é preciso o mundo inteiro.
//
// O índice é gerado por bin/geo-build-index.php e não é versionado (ver o
// cabeçalho desse ficheiro). Sem ele, isto devolve null e quem chama regista o
// país como desconhecido: para uma estatística é um degradar aceitável, ao
// contrário do bloqueio, onde dados em falta seriam uma decisão de segurança.
/**
* Código ISO do país deste endereço, ou null se não se souber.
*
* Busca binária por fseek() sobre registos de largura fixa. São centenas de
* milhares de ranges: carregá-los para memória a cada visita custaria mais do
* que a página que se está a servir.
*/
function geo_ip_country(?string $ip): ?string
{
if ($ip === null || $ip === '') {
return null;
}
$packed = @inet_pton($ip);
if ($packed === false) {
return null;
}
$len = strlen($packed); // 4 (v4) ou 16 (v6)
$file = GEO_DATA_DIR . '/country-index.' . ($len === 16 ? 'v6' : 'v4');
$rec = $len * 2 + 2; // início + fim + código
static $handles = [];
if (!array_key_exists($file, $handles)) {
$handles[$file] = is_file($file) ? (fopen($file, 'rb') ?: null) : null;
}
$fh = $handles[$file];
if ($fh === null) {
return null;
}
$size = fstat($fh)['size'] ?? 0;
$n = intdiv((int) $size, $rec);
if ($n === 0) {
return null;
}
$lo = 0;
$hi = $n - 1;
while ($lo <= $hi) {
$mid = intdiv($lo + $hi, 2);
if (fseek($fh, $mid * $rec) !== 0) {
return null;
}
$row = fread($fh, $rec);
if ($row === false || strlen($row) !== $rec) {
return null;
}
$start = substr($row, 0, $len);
$end = substr($row, $len, $len);
if (strcmp($packed, $start) < 0) {
$hi = $mid - 1;
} elseif (strcmp($packed, $end) > 0) {
$lo = $mid + 1;
} else {
return substr($row, $len * 2, 2);
}
}
return null;
}
/** Há índice de países instalado? A página de analítica usa isto para avisar. */
function geo_country_index_available(): bool
{
return is_file(GEO_DATA_DIR . '/country-index.v4');
}
-444
View File
@@ -1,444 +0,0 @@
<?php
require_once __DIR__ . '/functions_capabilities.php';
/**
* Run a git command in the project root and capture stdout+stderr together.
* Uses proc_open() in array form (no shell) so git placeholders like
* %s / %cd / `|` in --format pass through cleanly without word splitting.
*
* `safe.directory` is intentionally NOT honored by git via `-c` or
* `GIT_CONFIG_*` env vars — only via the system/per-user gitconfig. To
* bypass the "dubious ownership" guard without touching the host's
* gitconfig (typical Docker scenario: repo owned by root, PHP runs as
* www-data) we write a tiny config to a temp file and point
* `GIT_CONFIG_GLOBAL` at it for the duration of the call.
*
* @param array<string> $args Git args (e.g. ['log', '-1', '--format=%cd|%s'])
* @return array{ok:bool,code:int,output:string}
*/
function git_run(array $args, string $repo): array {
// On hosts with proc_open() in disable_functions, calling it is a FATAL
// "undefined function" error — fail soft so callers can degrade.
if (!platform_proc_open_available()) {
return ['ok' => false, 'code' => -1, 'output' => 'proc_open() está desativado no PHP deste servidor (disable_functions) — as operações git via web não estão disponíveis.'];
}
// One temp gitconfig per REQUEST, not per call: a page load fires 30+ git
// invocations and each one was writing + unlinking its own temp file.
// `safe.directory = *` covers the main repo and every submodule alike.
static $tmp_gitconfig = null;
if ($tmp_gitconfig === null) {
$tmp_gitconfig = @tempnam(sys_get_temp_dir(), 'platgit_');
if ($tmp_gitconfig !== false) {
@file_put_contents($tmp_gitconfig, "[safe]\n\tdirectory = *\n");
$cfg = $tmp_gitconfig;
register_shutdown_function(function () use ($cfg) { @unlink($cfg); });
}
}
// Base env every git subprocess shares. GIT_CONFIG_GLOBAL must point at
// the temp gitconfig BEFORE we even read the remote URL, otherwise the
// safe.directory guard can break that call.
$env = getenv() ?: [];
if ($tmp_gitconfig !== false) {
$env['GIT_CONFIG_GLOBAL'] = $tmp_gitconfig;
}
$env['GIT_TERMINAL_PROMPT'] = '0';
// HTTP credentials for non-interactive fetch/pull. PHP has no TTY, so any
// prompt for username/password kills the call. If GIT_HTTP_USERNAME +
// GIT_HTTP_TOKEN are set (via env() or getenv()), write them to a chmod
// 0600 temp file and tell git to read credentials from there via the
// built-in `store` helper. Resolved once per repo per request (the remote
// URL probe is itself a git subprocess); cleaned up at shutdown.
static $creds_cache = [];
if (!array_key_exists($repo, $creds_cache)) {
$creds_cache[$repo] = null;
$username = (string) (function_exists('env')
? (env('GIT_HTTP_USERNAME') ?: '')
: (getenv('GIT_HTTP_USERNAME') ?: ''));
$token = (string) (function_exists('env')
? (env('GIT_HTTP_TOKEN') ?: '')
: (getenv('GIT_HTTP_TOKEN') ?: ''));
if ($username !== '' && $token !== '') {
// Read the remote URL via git itself — works for regular repos AND
// submodules (where .git is a file pointing to .git/modules/<name>/,
// so parsing $repo/.git/config directly would fail).
$url_cmd = ['git', '-C', $repo, 'config', '--get', 'remote.origin.url'];
$url_desc = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$url_proc = @proc_open($url_cmd, $url_desc, $url_pipes, null, $env);
$remote_url = '';
if (is_resource($url_proc)) {
$remote_url = trim((string) stream_get_contents($url_pipes[1]));
fclose($url_pipes[1]);
fclose($url_pipes[2]);
proc_close($url_proc);
}
if ($remote_url !== '' && preg_match('#^https?://#', $remote_url)) {
$url = parse_url($remote_url);
if (isset($url['scheme'], $url['host'])) {
$host = $url['host'] . (isset($url['port']) ? ':' . $url['port'] : '');
$line = $url['scheme'] . '://'
. rawurlencode($username) . ':' . rawurlencode($token)
. '@' . $host . "\n";
$creds_file = @tempnam(sys_get_temp_dir(), 'gitcred_');
if ($creds_file !== false) {
@chmod($creds_file, 0600);
@file_put_contents($creds_file, $line);
register_shutdown_function(function () use ($creds_file) { @unlink($creds_file); });
$creds_cache[$repo] = $creds_file;
}
}
}
}
}
$creds_file = $creds_cache[$repo];
$cmd = ['git', '-C', $repo];
if ($creds_file !== null) {
$cmd[] = '-c'; $cmd[] = 'credential.helper=';
$cmd[] = '-c'; $cmd[] = 'credential.helper=store --file=' . $creds_file;
}
$cmd = array_merge($cmd, $args);
$descs = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$proc = @proc_open($cmd, $descs, $pipes, null, $env);
if (!is_resource($proc)) {
return ['ok' => false, 'code' => -1, 'output' => 'Não foi possível invocar o git. Confirme que está instalado e no PATH do utilizador do servidor web.'];
}
$out = stream_get_contents($pipes[1]);
$err = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
$code = proc_close($proc);
$output = trim((string) $out);
if ($err !== '' && $err !== false) {
$output .= ($output !== '' ? "\n" : '') . trim($err);
}
return ['ok' => $code === 0, 'code' => $code, 'output' => $output];
}
/**
* Submodule paths declared in .gitmodules, relative to the repo root.
*
* Reads them via git itself (`git config -f .gitmodules --get-regexp path`) so
* the result matches exactly what git acts on. Returns [] when there are no
* submodules (or no .gitmodules) — callers must treat that as a no-op, never an
* error.
*
* @return array<string>
*/
function git_submodule_paths(string $repo): array {
if (!is_file($repo . '/.gitmodules')) return [];
$res = git_run(['config', '-f', '.gitmodules', '--get-regexp', 'path'], $repo);
if (!$res['ok'] || $res['output'] === '') return [];
$paths = [];
foreach (preg_split('/\R/', $res['output']) as $line) {
// Each line: "submodule.<name>.path <path>"
$parts = preg_split('/\s+/', trim($line), 2);
if (count($parts) === 2 && $parts[1] !== '') {
$paths[] = $parts[1];
}
}
return $paths;
}
/**
* Idempotently stop git from tracking the filesystem executable bit, in the
* main repo AND every initialized submodule. Run this at the very start of any
* update flow, BEFORE fetch/pull/reset.
*
* `core.fileMode` is LOCAL per-repo config (it never travels in a commit), so on
* a bind-mount/Docker volume that reports +x inconsistently it must be re-asserted
* on every run — hence a function, not a versioned file. With it set to false the
* phantom `old mode 100644 → new mode 100755` diffs (and the resulting
* `modified: <submodule>` in the parent) simply vanish from `git status`.
*
* @return array{ok:bool,log:array<string>,normalized:bool}
*/
function git_harden_repo(string $repo): array {
$log = [];
$all_false = true;
$before = trim(git_run(['config', '--get', 'core.fileMode'], $repo)['output']);
$set = git_run(['config', 'core.fileMode', 'false'], $repo);
$log[] = 'core.fileMode=false (repo principal) → '
. ($set['ok'] ? ($before === 'false' ? 'já normalizado' : 'normalizado') : 'FALHOU')
. ($set['ok'] || $set['output'] === '' ? '' : ': ' . $set['output']);
if (!$set['ok']) $all_false = false;
foreach (git_submodule_paths($repo) as $path) {
$full = $repo . '/' . $path;
if (!file_exists($full . '/.git')) {
$log[] = "core.fileMode (submódulo {$path}) → ignorado (não inicializado)";
continue;
}
$b = trim(git_run(['config', '--get', 'core.fileMode'], $full)['output']);
$rs = git_run(['config', 'core.fileMode', 'false'], $full);
$log[] = "core.fileMode=false (submódulo {$path}) → "
. ($rs['ok'] ? ($b === 'false' ? 'já normalizado' : 'normalizado') : 'FALHOU');
if (!$rs['ok']) $all_false = false;
}
return ['ok' => $all_false, 'log' => $log, 'normalized' => $all_false];
}
/**
* Bring submodules back in line with the SHAs pinned by the parent's index,
* discarding ONLY drift (in practice: filesystem-mode flips). `--force` is the
* point — it resets each submodule's working tree to the pinned commit. Because
* the pinned content is identical to what's checked out, nothing real is lost;
* the only thing thrown away is the phantom mode diff. `--init` also clones any
* submodule freshly added upstream.
*
* A submodule declared `ignore = all` in .gitmodules follows its branch instead
* (`--remote`) — see git_submodule_follows_branch().
*
* Safe and quiet when there are no submodules.
*
* @return array{ok:bool,log:array<string>}
*/
function git_sync_submodules(string $repo): array {
$paths = git_submodule_paths($repo);
if ($paths === []) {
return ['ok' => true, 'log' => ['(sem submódulos)']];
}
$log = [];
$sync = git_run(['submodule', 'sync', '--recursive'], $repo);
$log[] = 'git submodule sync --recursive → ' . ($sync['ok'] ? 'OK' : 'FALHOU');
if (trim($sync['output']) !== '') $log[] = $sync['output'];
$ok = $sync['ok'];
$pinned = $tracking = [];
foreach ($paths as $path) {
if (git_submodule_follows_branch($repo, $path)) $tracking[] = $path;
else $pinned[] = $path;
}
// Um submódulo com `ignore = all` segue o seu branch, não o commit gravado
// (é o que o .gitmodules da quack declara). Pô-lo no commit gravado era
// fazer a plataforma andar para trás a cada atualização.
foreach ([[$pinned, []], [$tracking, ['--remote']]] as [$group, $extra]) {
if (!$group) continue;
$args = array_merge(['submodule', 'update', '--init', '--recursive', '--force'], $extra, ['--'], $group);
$upd = git_run($args, $repo);
$log[] = 'git ' . implode(' ', $args) . ' → ' . ($upd['ok'] ? 'OK' : 'FALHOU');
if (trim($upd['output']) !== '') $log[] = $upd['output'];
$ok = $ok && $upd['ok'];
}
return ['ok' => $ok, 'log' => $log];
}
/**
* O submódulo segue o branch (`update --remote`) em vez do commit gravado? Sim
* quando o .gitmodules lhe põe `ignore = all`: o commit gravado deixa de ser
* vigiado, portanto só pode ser um ponto de partida, não uma versão a cumprir.
*/
function git_submodule_follows_branch(string $repo, string $path): bool {
$res = git_run(['config', '-f', '.gitmodules', '--get-regexp', '^submodule\..*\.path$'], $repo);
if (!$res['ok']) return false;
foreach (preg_split('/\R/', trim($res['output'])) as $line) {
$parts = preg_split('/\s+/', trim($line), 2);
if (count($parts) === 2 && $parts[1] === $path) {
$name = substr($parts[0], strlen('submodule.'), -strlen('.path'));
$ignore = git_run(['config', '-f', '.gitmodules', '--get', "submodule.$name.ignore"], $repo);
return $ignore['ok'] && trim($ignore['output']) === 'all';
}
}
return false;
}
/**
* True when a `git diff` contains ONLY executable-bit changes — i.e. every
* changed file is just `old mode …` / `new mode …` with no content hunks, no
* index lines, no rename/binary/submodule markers. An empty diff returns false
* (the caller decides what "no diff" means in its context).
*/
function git_diff_is_mode_only(string $diff): bool {
$diff = trim($diff);
if ($diff === '') return false;
foreach (preg_split('/\R/', $diff) as $line) {
if ($line === '') continue;
if (str_starts_with($line, 'diff --git ')) continue;
if (str_starts_with($line, 'old mode ')) continue;
if (str_starts_with($line, 'new mode ')) continue;
// Anything else (index/@@/+/-/Subproject commit/Binary/rename) is real.
return false;
}
return true;
}
/**
* Snapshot how the local repo diverges from origin/<branch>, classifying the
* divergence as either "safe to hard-reset" (only mode flips / submodule drift)
* or "real work that must never be discarded".
*
* Call this AFTER git_harden_repo() + a fetch (and ideally after
* git_sync_submodules()), so phantom mode diffs have already disappeared from
* `git status` and a leftover dirty entry genuinely means real content.
*
* @return array{
* branch:string, ahead:int, behind:int,
* content_dirty:bool, dirty_files:string,
* real_ahead:bool, ahead_log:string,
* safe_to_reset:bool
* }
*/
function git_remote_branches(string $repo): array {
$res = git_run(['for-each-ref', 'refs/remotes/origin', '--format=%(refname:short)'], $repo);
if (!$res['ok']) return [];
$branches = [];
foreach (explode(PHP_EOL, trim((string) $res['output'])) as $line) {
$line = trim($line);
if ($line === '' || $line === 'origin/HEAD') continue;
if (str_starts_with($line, 'origin/')) $line = substr($line, 7);
$branches[] = $line;
}
return array_values(array_unique($branches));
}
function git_local_state(string $repo, ?string $branch = null): array {
if ($branch === null || $branch === '') {
$branch = trim(git_run(['rev-parse', '--abbrev-ref', 'HEAD'], $repo)['output']);
}
$ref = 'origin/' . $branch;
// Working-tree changes. Untracked files (??) are ignored: `reset --hard`
// never deletes them, so they don't make a reset unsafe.
$st = git_run(['status', '--porcelain'], $repo);
$dirty_lines = [];
$content_dirty = false;
foreach (preg_split('/\R/', $st['output']) as $line) {
if ($line === '') continue;
$dirty_lines[] = $line;
if (str_starts_with($line, '??')) continue;
$content_dirty = true;
}
$ahead = (int) trim(git_run(['rev-list', '--count', $ref . '..HEAD'], $repo)['output']);
$behind = (int) trim(git_run(['rev-list', '--count', 'HEAD..' . $ref], $repo)['output']);
// Are the local-only commits real, or just committed mode flips?
$real_ahead = false;
$ahead_log = '';
if ($ahead > 0) {
$ahead_log = git_run(['log', '--oneline', '--no-color', $ref . '..HEAD'], $repo)['output'];
// Three-dot: what the local commits introduced since the merge-base.
$diff = trim(git_run(['diff', '--no-color', $ref . '...HEAD'], $repo)['output']);
$real_ahead = $diff !== '' && !git_diff_is_mode_only($diff);
}
return [
'branch' => $branch,
'ahead' => $ahead,
'behind' => $behind,
'content_dirty' => $content_dirty,
'dirty_files' => implode("\n", $dirty_lines),
'real_ahead' => $real_ahead,
'ahead_log' => $ahead_log,
'safe_to_reset' => !$content_dirty && !$real_ahead,
];
}
/**
* The robust "Atualizar plataforma" git step. Non-destructive by default; only
* discards state when the divergence is provably nothing but mode/submodule drift.
*
* 1. git_harden_repo() — kill phantom mode diffs first.
* 2. git pull --ff-only — the happy path.
* 3. on failure: fetch, realign submodules, then classify the local state.
* · safe (mode/submodule only) → fetch + reset --hard origin/<branch>.
* · real changes/commits → STOP, report the divergence, discard nothing.
* 4. git submodule sync + update --init --recursive --force.
*
* @return array{ok:bool,needs_confirm:bool,output:string,state:?array}
*/
function git_safe_update(string $repo): array {
$log = [];
$branch = trim(git_run(['rev-parse', '--abbrev-ref', 'HEAD'], $repo)['output']);
$log[] = '── Normalização de permissões (core.fileMode=false) ──';
foreach (git_harden_repo($repo)['log'] as $l) $log[] = $l;
$log[] = '';
$log[] = '── git pull --ff-only ──';
$pull = git_run(['pull', '--ff-only'], $repo);
$log[] = $pull['output'] !== '' ? $pull['output'] : '(sem saída)';
if ($pull['ok']) {
$log[] = '';
$log[] = '── Submódulos ──';
foreach (git_sync_submodules($repo)['log'] as $l) $log[] = $l;
return ['ok' => true, 'needs_confirm' => false, 'output' => implode("\n", $log), 'state' => null];
}
// Fast-forward refused — diverged or blocked by local state. Fetch, realign
// submodule working trees (so mode-drift inside them isn't mistaken for real
// changes), then classify.
$log[] = '';
$log[] = '── Fast-forward falhou — a analisar estado local ──';
$fetch = git_run(['fetch', 'origin', '--prune'], $repo);
if (trim($fetch['output']) !== '') $log[] = $fetch['output'];
foreach (git_sync_submodules($repo)['log'] as $l) $log[] = $l;
$state = git_local_state($repo, $branch);
if ($state['safe_to_reset']) {
$log[] = '';
$log[] = "── Divergência apenas de modo/submódulo — alinhar com origin/{$branch} ──";
$reset = git_run(['reset', '--hard', 'origin/' . $branch], $repo);
$log[] = trim($reset['output']) !== '' ? $reset['output'] : '(reset --hard concluído)';
if ($reset['ok']) {
foreach (git_sync_submodules($repo)['log'] as $l) $log[] = $l;
}
return ['ok' => $reset['ok'], 'needs_confirm' => false, 'output' => implode("\n", $log), 'state' => $state];
}
// Real local work — refuse to touch it.
$log[] = '';
$log[] = '── ALTERAÇÕES LOCAIS REAIS — atualização interrompida (nada foi descartado) ──';
if ($state['content_dirty']) {
$log[] = 'Alterações de conteúdo não-comitadas:';
$log[] = $state['dirty_files'];
}
if ($state['real_ahead']) {
$log[] = "Commits locais por enviar (origin/{$branch}..HEAD):";
$log[] = $state['ahead_log'];
}
$log[] = '';
$log[] = 'Resolve manualmente (commit/push/stash) ou usa "Sincronização forçada"';
$log[] = 'com confirmação explícita para descartar o estado local e alinhar com o remoto.';
return ['ok' => false, 'needs_confirm' => true, 'output' => implode("\n", $log), 'state' => $state];
}
/**
* The explicit, user-confirmed destructive escape hatch: discard whatever is
* local and force the working tree to match origin/<branch>. Only ever reached
* from a UI action gated behind an explicit confirmation checkbox.
*
* @return array{ok:bool,output:string}
*/
function git_force_sync(string $repo): array {
$log = [];
$branch = trim(git_run(['rev-parse', '--abbrev-ref', 'HEAD'], $repo)['output']);
foreach (git_harden_repo($repo)['log'] as $l) $log[] = $l;
$fetch = git_run(['fetch', 'origin', '--prune'], $repo);
$log[] = 'git fetch origin --prune → ' . ($fetch['ok'] ? 'OK' : 'FALHOU');
if (trim($fetch['output']) !== '') $log[] = $fetch['output'];
$reset = git_run(['reset', '--hard', 'origin/' . $branch], $repo);
$log[] = "git reset --hard origin/{$branch} → " . ($reset['ok'] ? 'OK' : 'FALHOU');
if (trim($reset['output']) !== '') $log[] = $reset['output'];
if ($reset['ok']) {
foreach (git_sync_submodules($repo)['log'] as $l) $log[] = $l;
}
return ['ok' => $reset['ok'], 'output' => implode("\n", $log)];
}
-663
View File
@@ -1,663 +0,0 @@
<?php
// ─────────────────────────────────────────────────────────────────────────────
// Integração com o hub — chave de API, verificação, emparelhamento e tickets
//
// Nasceu dentro da quack (`config/functions_hub.php`) e vive agora aqui, porque
// a partir do momento em que a segunda plataforma se liga ao hub o cliente
// deixa de ser código da quack e passa a ser infraestrutura da frota. Sete
// cópias divergiriam à primeira correcção — e uma correcção aqui é
// tipicamente de segurança.
//
// O hub precisa de três coisas de cada plataforma: saber se está de pé, ver as
// últimas cópias de segurança (ficheiros e base de dados), e poder disparar
// cópias e atualizações. Tudo isso entra por dois endpoints:
//
// api/health.php GET — leitura, é o que o monitor do hub sonda
// api/hub_ops.php POST — ações (cópia, atualização), o que muda alguma coisa
//
// e um terceiro só para o emparelhamento:
//
// api/hub_pair.php POST — o hub chama aqui ao aprovar; é a única etapa em que
// as chaves saem desta plataforma
//
// **As chaves são geradas AQUI, não no hub.** Quem manda no acesso é quem o
// concede: a plataforma emite a chave, o hub recebe-a. O contrário obrigava a
// confiar que o hub nunca a perde, e não há forma de a revogar deste lado.
//
// Guardadas cifradas no `system_settings` (AES-256-GCM), porque a plataforma
// guarda cópias da base de dados e uma chave em texto simples ficaria dentro de
// todas elas. O `.env` continua a **ganhar** (HUB_HEALTH_KEY / HUB_OPS_KEY): é a
// configuração mais segura, porque um valor no .env nunca chega a um dump — mas
// nem toda a instalação tem shell para lá chegar (cPanel), e é por isso que a UI
// existe.
//
// ─── Portabilidade entre stacks ──────────────────────────────────────────────
//
// Um site ligado por platform_connect() declara o PDO e o URL; os outros ainda
// são lidos do stack `config/` (env(), setting(), base_url(), APP_NAME). Nada
// aqui chama esses helpers directamente — passa tudo pelos acessores
// hub_env()/hub_setting()/hub_base_url() abaixo, que
// resolvem em **tempo de chamada** (não de carregamento: este ficheiro é
// incluído do topo do functions.php, quando metade deles ainda não existe) e
// caem para os primitivos do platform-tools quando o site não os tem.
// ─────────────────────────────────────────────────────────────────────────────
require_once __DIR__ . '/functions_settings.php';
const HUB_KEY_SETTING = 'hub_api_key';
const HUB_KEY_ENV = 'HUB_HEALTH_KEY';
// Chave SEPARADA para as ações (cópias, atualização). O contrato do agente de
// saúde do hub diz, e com razão, que «um agente que sabe fazer coisas é uma
// porta de entrada com uma chave só a proteger». A leitura corre de 5 em 5
// minutos e a chave anda por logs, proxies e pela base de dados do hub; a de
// operações é usada à mão, poucas vezes. Com duas, quem sonda não dispara nada.
const HUB_OPS_KEY_SETTING = 'hub_ops_key';
const HUB_OPS_KEY_ENV = 'HUB_OPS_KEY';
const HUB_PAIRING_TTL = 86400; // 24 h — dá tempo de aprovar sem deixar aberto
// ─── Acessores portáteis ─────────────────────────────────────────────────────
/** A raiz do repositório do site — este ficheiro está em shared/platform-tools/. */
function hub_root(): string {
return dirname(__DIR__, 2);
}
/**
* O $pdo do site: o que declarou em platform_connect(), ou o global que o
* config/db.php abre. Não escreve em $GLOBALS: o site é que manda no seu escopo.
*/
function hub_pdo(): ?PDO {
// Um site ligado por platform_connect() já disse qual é — não se adivinha.
if (function_exists('platform_pdo') && ($declared = platform_pdo()) !== null) return $declared;
$pdo = $GLOBALS['pdo'] ?? null;
return $pdo instanceof PDO ? $pdo : null;
}
function hub_env(string $key, string $default = ''): string {
if (function_exists('env')) return (string) env($key, $default);
$v = $_ENV[$key] ?? (getenv($key) !== false ? getenv($key) : null);
return $v === null ? $default : (string) $v;
}
/** Leitura simples de uma definição: DB primeiro, como em todo o resto. */
function hub_setting(string $key, string $default = ''): string {
if (function_exists('setting')) return (string) setting($key, $default);
$pdo = hub_pdo();
return $pdo ? (string) platform_settings_get($pdo, $key, $default) : $default;
}
/**
* Definição com fallback ao .env — mas ao contrário do que o nome sugere, quem
* ganha é o .env. Ver a nota do topo: um valor no .env nunca chega a um dump.
*/
function hub_setting_or_env(string $key, string $envKey, string $default = ''): string {
if (function_exists('setting_or_env')) return (string) setting_or_env($key, $envKey, $default);
$env = trim(hub_env($envKey, ''));
if ($env !== '') return $env;
$val = trim(hub_setting($key, ''));
return $val !== '' ? $val : $default;
}
/** O mesmo, mas o valor guardado está cifrado. */
function hub_setting_secret(string $key, string $envKey): string {
if (function_exists('setting_secret')) return (string) setting_secret($key, $envKey, '');
$env = trim(hub_env($envKey, ''));
if ($env !== '') return $env;
return hub_secret_decrypt(hub_setting($key, ''));
}
/**
* Escrita. Usa o db_update_setting() do site quando existe — é ele que limpa a
* cache de definições desse site, e uma escrita que não a limpa faz a página
* mostrar o valor antigo logo a seguir a gravar.
*
* Guarda-se sempre já cifrado (ver hub_secret_encrypt); o platform_settings_set()
* reconhece o prefixo `enc:v1:` e não volta a cifrar por cima.
*/
function hub_setting_set(PDO $pdo, string $key, string $value, ?int $userId = null): void {
if (function_exists('db_update_setting')) { db_update_setting($pdo, $key, $value, $userId); return; }
platform_settings_set($pdo, $key, $value, $userId);
if (function_exists('settings_cache_clear')) settings_cache_clear();
}
function hub_secret_encrypt(string $plain): string {
return function_exists('secret_encrypt') ? secret_encrypt($plain) : platform_secret_encrypt($plain);
}
function hub_secret_decrypt(string $stored): string {
return function_exists('secret_decrypt') ? secret_decrypt($stored) : platform_secret_decrypt($stored);
}
/** O nome da plataforma como aparece no hub. */
function hub_app_name(): string {
if (defined('APP_NAME') && (string) APP_NAME !== '') return (string) APP_NAME;
$n = hub_env('APP_NAME', '');
if ($n !== '') return $n;
return parse_url(hub_base_url(''), PHP_URL_HOST) ?: 'plataforma';
}
/**
* URL absoluto desta plataforma: o declarado em platform_connect(), o
* base_url() do site, ou o APP_BASE_URL, que está em todos os .env da frota.
*/
function hub_base_url(string $path = ''): string {
$declared = function_exists('platform_config') ? (string) (platform_config()['base_url'] ?? '') : '';
if ($declared !== '') return rtrim($declared, '/') . ($path === '' ? '' : '/' . ltrim($path, '/'));
if (function_exists('base_url')) return (string) base_url($path);
$base = hub_env('APP_BASE_URL', '');
return rtrim($base, '/') . ($path === '' ? '' : '/' . ltrim($path, '/'));
}
// ─── Chaves ──────────────────────────────────────────────────────────────────
/** A chave em vigor, venha do .env ou da base de dados. '' = não configurada. */
function hub_api_key(): string {
return hub_setting_secret(HUB_KEY_SETTING, HUB_KEY_ENV);
}
/** A chave de operações. '' = ações desligadas nesta plataforma. */
function hub_ops_key(): string {
return hub_setting_secret(HUB_OPS_KEY_SETTING, HUB_OPS_KEY_ENV);
}
function hub_ops_key_is_from_env(): bool {
return trim(hub_env(HUB_OPS_KEY_ENV, '')) !== '';
}
function hub_ops_key_rotate(PDO $pdo, ?int $userId = null): string {
$key = bin2hex(random_bytes(24));
hub_setting_set($pdo, HUB_OPS_KEY_SETTING, hub_secret_encrypt($key), $userId);
return $key;
}
function hub_ops_key_clear(PDO $pdo, ?int $userId = null): void {
hub_setting_set($pdo, HUB_OPS_KEY_SETTING, '', $userId);
}
/** true quando a chave está fixada no .env — editá-la na UI não faria nada. */
function hub_api_key_is_from_env(): bool {
return trim(hub_env(HUB_KEY_ENV, '')) !== '';
}
/** Gera uma chave nova e grava-a cifrada. Devolve a chave em claro, uma vez. */
function hub_api_key_rotate(PDO $pdo, ?int $userId = null): string {
$key = bin2hex(random_bytes(24)); // 48 hex — largo o suficiente para não valer a pena adivinhar
hub_setting_set($pdo, HUB_KEY_SETTING, hub_secret_encrypt($key), $userId);
return $key;
}
function hub_api_key_clear(PDO $pdo, ?int $userId = null): void {
hub_setting_set($pdo, HUB_KEY_SETTING, '', $userId);
}
// ─── Resposta e autenticação dos endpoints ───────────────────────────────────
function hub_json(array $data, int $code = 200): never {
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
/**
* Autentica o pedido do hub. Aceita `X-Hub-Key` e, em alternativa,
* `Authorization: Bearer`.
*
* Não é redundância: o proxy à frente dos contentores andmore remove o
* cabeçalho Authorization antes de o PHP o ver, e cabeçalhos `X-` passam. O
* Bearer fica suportado para instalações onde passe (cPanel) e porque é a forma
* convencional.
*/
function hub_key_verify_or_die(): void {
_hub_verify_or_die(hub_api_key(), 'chave de API não configurada nesta plataforma');
}
/**
* Ações. Aceita SÓ a chave de operações — a de leitura, que anda pelo monitor de
* 5 em 5 minutos, não dispara cópias nem atualizações.
*/
function hub_ops_verify_or_die(): void {
_hub_verify_or_die(hub_ops_key(), 'operações remotas desligadas nesta plataforma (sem chave de operações)');
}
function _hub_verify_or_die(string $expected, string $missingMessage): void {
$given = (string) ($_SERVER['HTTP_X_HUB_KEY'] ?? '');
if ($given === '') {
$header = (string) ($_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? '');
$given = str_starts_with($header, 'Bearer ') ? substr($header, 7) : '';
}
if ($expected === '') {
hub_json(['ok' => false, 'error' => $missingMessage], 503);
}
// hash_equals: uma comparação normal sai ao primeiro byte diferente e deixa
// medir a chave pelo tempo de resposta.
if (!hash_equals($expected, $given)) {
hub_json(['ok' => false, 'error' => 'unauthorized'], 401);
}
}
/**
* As ações que o api/hub_ops.php aceita. Anunciadas no health para o hub não
* ter de manter uma lista por plataforma nem adivinhar o que cada uma sabe
* fazer — uma instalação sem git responde `git.pull` com um erro explicado, e o
* hub mostra isso em vez de um botão que não faz nada.
*/
function hub_ops_actions(): array {
return ['backup.list', 'backup.trigger', 'git.status', 'git.pull', 'update.run', 'update.status',
'migrations.list', 'migrations.apply_pending',
'ip_bans.list', 'ip_bans.ban', 'ip_bans.unban', 'tracking.summary'];
}
// ─── Carregamento tardio dos helpers do site ─────────────────────────────────
/**
* Carrega os helpers de cópias se ainda não estiverem em memória. Nem todas as
* plataformas os incluem no bootstrap (a fotografia não), e um endpoint que
* respondesse «sem cópias» só porque o ficheiro não tinha sido carregado seria
* pior do que não responder nada.
*/
function hub_load_backup_helpers(): void {
if (function_exists('backup_list')) return;
// O wrapper do site PRIMEIRO: é lá que se define o BACKUP_TRIGGER_URL_PREFIX
// antes de incluir o partilhado (a fotografia manda a cópia para api/, porque
// sob o docroot public/ o shared/ não se alcança por HTTP). Carregar o
// partilhado primeiro saltava essa definição, e a cópia disparada pelo hub
// batia num 404 que o curl dá como sucesso.
foreach ([hub_root() . '/config/functions_backup.php',
__DIR__ . '/functions_backup.php'] as $candidate) {
if (is_file($candidate)) { require_once $candidate; return; }
}
}
/**
* O mesmo para o runner de migrações. O stub da app tem-no comentado de
* propósito (não vale a pena carregá-lo em cada pedido), portanto sem isto o
* check respondia «runner indisponível» com ok:true — um check que não sabe
* medir a dizer que está tudo bem, exactamente enquanto duas migrações estavam
* por aplicar. Um check que mente é pior do que não existir.
*/
function hub_load_migration_helpers(): void {
if (function_exists('migrations_status')) return;
foreach ([__DIR__ . '/functions_migrations.php',
hub_root() . '/config/functions_migrations.php'] as $candidate) {
if (is_file($candidate)) { require_once $candidate; }
if (function_exists('migrations_status')) return;
}
}
// ─── Cópias de segurança ─────────────────────────────────────────────────────
/**
* Últimas cópias de segurança, por tipo. É a pergunta que o hub faz — «esta
* plataforma tem cópia de ontem?» — e não «lista-me tudo».
*/
function hub_backup_summary(): array {
hub_load_backup_helpers();
if (!function_exists('backup_list')) {
return ['available' => false, 'reason' => 'backups não disponíveis nesta plataforma'];
}
$out = ['available' => true, 'db' => null, 'files' => null, 'count' => 0, 'total_bytes' => 0];
foreach (backup_list() as $b) {
$out['count']++;
$out['total_bytes'] += (int) ($b['size_bytes'] ?? 0);
$type = ($b['type'] ?? '') === 'db' ? 'db' : 'files';
$created = (int) ($b['created_at'] ?? 0);
// backup_list() vem do mais recente para o mais antigo, mas não se
// depende disso: compara-se a data e fica a maior.
if ($out[$type] === null || $created > (int) $out[$type]['created_at']) {
$out[$type] = [
'filename' => (string) ($b['filename'] ?? ''),
'created_at' => $created,
'created_iso'=> $created ? gmdate('c', $created) : null,
'size_bytes' => (int) ($b['size_bytes'] ?? 0),
'size_human' => (string) ($b['size_human'] ?? ''),
// A idade é o que responde à pergunta real («há cópia recente?»)
// sem o hub ter de saber o fuso horário desta máquina.
'age_hours' => $created ? round((time() - $created) / 3600, 1) : null,
];
}
}
return $out;
}
// ─── Checks no formato do contrato do hub ────────────────────────────────────
// docs/CONTRATO_AGENTE_SAUDE.md do hub: `git` e `backup` são entradas DENTRO de
// `checks`, não chaves de topo — foi por as mandar à parte que o painel os
// mostrava como «não reportado». Os nomes dos campos com coluna própria no hub
// (git.branch/commit/behind/dirty, backup.last_at, disk.used_percent) têm de ser
// exactamente estes, senão ficam só no payload_json e não aparecem.
/** `checks.backup` — a pergunta é «há cópia recente?», não «lista tudo». */
function hub_backup_check(int $staleHours = 48): array {
$s = hub_backup_summary();
if (empty($s['available'])) {
// Ausente ≠ falha: o contrato diz que o que não vier aparece como «não
// reportado». Uma plataforma sem cópias automáticas não manda o check.
return [];
}
$newest = null;
foreach (['db', 'files'] as $t) {
if ($s[$t] && ($newest === null || $s[$t]['created_at'] > $newest['created_at'])) $newest = $s[$t];
}
if (!$newest) {
return ['ok' => false, 'detail' => 'nenhuma cópia encontrada', 'last_at' => null,
'db' => null, 'files' => null];
}
return [
'ok' => $newest['age_hours'] !== null && $newest['age_hours'] <= $staleHours,
'last_at' => date('Y-m-d H:i', $newest['created_at']),
'detail' => 'há ' . $newest['age_hours'] . ' h · ' . $newest['size_human'],
// Extra: fica no payload_json do hub mesmo sem coluna própria.
'db' => $s['db'] ? ['last_at' => date('Y-m-d H:i', $s['db']['created_at']), 'age_hours' => $s['db']['age_hours'], 'size' => $s['db']['size_human']] : null,
'files' => $s['files'] ? ['last_at' => date('Y-m-d H:i', $s['files']['created_at']), 'age_hours' => $s['files']['age_hours'], 'size' => $s['files']['size_human']] : null,
'count' => $s['count'],
];
}
/** `checks.git` — leitura offline; `behind` é o último ref conhecido. */
function hub_git_check(string $root): array {
if (!is_dir($root . '/.git')) return []; // não reportado (cPanel, cópia sem repo)
require_once __DIR__ . '/functions_git.php';
if (!function_exists('git_run')) return [];
$branch = git_run(['rev-parse', '--abbrev-ref', 'HEAD'], $root);
// Ter um .git não chega: é preciso um git que corra. O contentor do apex
// não traz o binário — de propósito, porque é uma montra estática montada
// read-only e o deploy corre git no HOST, como o deploy, nunca lá dentro.
//
// Sem esta saída, o check reportava ok:false com tudo a null e o cartão
// ficava amarelo para sempre por uma medição que nunca foi possível fazer.
// Não conseguir medir é «não reportado» -- a mesma resposta que este check
// já dá a um cPanel sem repositório --, e não uma avaria.
//
// `code === -1` é o sinal, e não a mensagem: é o que o git_run() devolve
// quando não chegou a correr nada -- binário ausente, ou proc_open()
// desativado no php.ini, que é o caso de muito alojamento partilhado. Um
// git que corra e falhe devolve o código de saída DELE, e esse é um
// resultado a reportar.
if (!$branch['ok'] && (int) $branch['code'] === -1) {
return [];
}
$commit = git_run(['rev-parse', '--short', 'HEAD'], $root);
$dirty = git_run(['status', '--porcelain'], $root);
$count = git_run(['rev-list', '--left-right', '--count', 'HEAD...@{u}'], $root);
$behind = null;
if ($count['ok'] && preg_match('/^(\d+)\s+(\d+)/', $count['output'], $m)) $behind = (int) $m[2];
return [
// Estar atrasado do remoto não é uma avaria — é o cartão a ficar amarelo,
// que é o que o hub faz com behind > 0. Aqui `ok` é só «consegui ler».
'ok' => $branch['ok'],
'branch' => $branch['ok'] ? trim($branch['output']) : null,
'commit' => $commit['ok'] ? trim($commit['output']) : null,
'behind' => $behind,
'dirty' => $dirty['ok'] ? (trim($dirty['output']) !== '') : null,
];
}
/** Versão mostrada no hub: o commit curto, ou a data do ficheiro mais recente. */
function hub_version(string $root, array $gitCheck): string {
if (!empty($gitCheck['commit'])) return (string) $gitCheck['commit'];
foreach (['/config/functions.php', '/public/index.php'] as $rel) {
if (is_file($root . $rel)) return date('Y.m.d', (int) filemtime($root . $rel));
}
return '';
}
// ─── Emparelhamento automático (quick setup) ─────────────────────────────────
//
// Fluxo, e a razão de ser desta ordem:
// 1. A plataforma ANUNCIA-SE ao hub: URL, slug e um código de emparelhamento.
// **Nenhuma chave sai nesta etapa.**
// 2. O hub guarda-a como pendente. Nada é sondado até o André aprovar.
// 3. Ao aprovar, é o HUB que chama a plataforma (api/hub_pair.php) com o
// código e o âmbito pedido; só então as chaves são geradas e entregues.
//
// A inversão da etapa 3 é o ponto todo: as chaves viajam numa ligação que o hub
// iniciou, para um endpoint que ele já conhece — e não numa que a plataforma
// abriu para um URL que lhe disseram. Um URL de hub errado no anúncio revela um
// código de emparelhamento inútil sozinho, não uma credencial.
//
// O código é de uso único e tem validade: um anúncio esquecido não fica a valer
// como convite aberto para sempre.
/**
* As chaves e o URL do hub vivem no `system_settings`. Nem toda a plataforma da
* frota tem essa tabela criada (a dsa tem o helper mas a migração 017 estava por
* aplicar, 2026-08-13), e sem ela qualquer gravação rebenta com um PDOException
* em cima do utilizador. A página pergunta primeiro e explica-se.
*/
function hub_storage_ready(PDO $pdo): bool {
static $ready = null;
if ($ready !== null) return $ready;
try {
$ready = (bool) $pdo->query("SHOW TABLES LIKE 'system_settings'")->fetch();
} catch (Throwable $e) {
$ready = false;
}
return $ready;
}
function hub_url(): string {
return rtrim(trim(hub_setting_or_env('hub_url', 'HUB_URL', '')), '/');
}
/** Estado do emparelhamento, para a UI. */
function hub_pairing_state(): array {
return [
'secret' => hub_setting('hub_pairing_secret', ''),
'state' => hub_setting('hub_pairing_state', ''), // '' | announced | paired
'hub' => hub_setting('hub_pairing_hub', ''),
'at' => hub_setting('hub_pairing_at', ''),
'expired' => (int) hub_setting('hub_pairing_at', '0') > 0
&& (time() - (int) hub_setting('hub_pairing_at', '0')) > HUB_PAIRING_TTL,
];
}
/**
* Anuncia esta plataforma ao hub. Devolve ['ok'=>bool,'error'=>string].
* Não envia chaves — ver a nota acima.
*/
function hub_announce(PDO $pdo, ?int $userId = null): array {
if (!hub_storage_ready($pdo)) {
return ['ok' => false, 'error' => 'esta plataforma não tem a tabela system_settings — falta aplicar as migrações antes de emparelhar'];
}
$hub = hub_url();
if ($hub === '') return ['ok' => false, 'error' => 'URL do hub não configurado (HUB_URL no .env ou nas definições).'];
if (!str_starts_with($hub, 'https://')) {
// Uma credencial vai acabar por atravessar este caminho na etapa 3.
return ['ok' => false, 'error' => 'O URL do hub tem de ser https.'];
}
if (!function_exists('curl_init')) return ['ok' => false, 'error' => 'cURL indisponível nesta instalação.'];
$secret = bin2hex(random_bytes(24));
$body = json_encode([
'action' => 'announce',
'slug' => hub_platform_slug(),
'name' => hub_app_name(),
'base_url' => rtrim(hub_base_url(''), '/'),
'health_path' => 'api/health.php',
'ops_path' => 'api/hub_ops.php',
'pair_path' => 'api/hub_pair.php',
'pairing_code'=> $secret,
'env' => hub_env('APP_ENV', 'prod'),
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$ch = curl_init($hub . '/api/enroll.php');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_TIMEOUT => 15,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Accept: application/json'],
CURLOPT_USERAGENT => 'andmore-platform-announce/1.0',
]);
$raw = curl_exec($ch);
$http = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($raw === false || $err !== '') return ['ok' => false, 'error' => 'Sem resposta do hub: ' . ($err ?: 'erro desconhecido')];
$res = json_decode((string) $raw, true);
if (!is_array($res)) return ['ok' => false, 'error' => 'Resposta do hub não é JSON (HTTP ' . $http . ').'];
if (empty($res['ok'])) return ['ok' => false, 'error' => (string) ($res['error'] ?? 'O hub recusou o anúncio.')];
// Só se guarda o código DEPOIS de o hub o aceitar — de outra forma ficava cá
// um segredo válido para um emparelhamento que nunca existiu.
hub_setting_set($pdo, 'hub_pairing_secret', hub_secret_encrypt($secret), $userId);
hub_setting_set($pdo, 'hub_pairing_state', 'announced', $userId);
hub_setting_set($pdo, 'hub_pairing_hub', $hub, $userId);
hub_setting_set($pdo, 'hub_pairing_at', (string) time(), $userId);
return ['ok' => true, 'error' => '', 'hub' => $hub, 'status' => (string) ($res['status'] ?? 'pending')];
}
/**
* Slug estável desta plataforma, derivado do **URL completo** — host E caminho.
*
* Só o host não chega: em desenvolvimento a frota inteira vive no mesmo host
* (`development.andmore.pt/design.andmore.pt`, `.../fotografia.andmore.pt`), e
* um slug por host fazia a segunda plataforma a anunciar-se passar por cima da
* primeira no hub. Em produção, onde cada uma tem o seu domínio, o caminho é
* vazio e o resultado é o mesmo de antes.
*/
function hub_platform_slug(): string {
$parts = parse_url(rtrim(hub_base_url(''), '/')) ?: [];
$raw = ($parts['host'] ?? 'plataforma') . ($parts['path'] ?? '');
$slug = strtolower(preg_replace('/[^a-z0-9]+/i', '-', $raw));
return trim($slug, '-') ?: 'plataforma';
}
/**
* Consome o código de emparelhamento (uso único) e devolve as chaves pedidas.
* Chamado por api/hub_pair.php, que é quem o hub contacta ao aprovar.
*/
function hub_pair_complete(PDO $pdo, string $code, bool $withOps): array {
$st = hub_pairing_state();
$stored = $st['secret'] !== '' ? hub_secret_decrypt($st['secret']) : '';
if ($stored === '' || $st['state'] !== 'announced') {
return ['ok' => false, 'error' => 'não há emparelhamento pendente'];
}
if ($st['expired']) {
return ['ok' => false, 'error' => 'o código de emparelhamento expirou — anuncia outra vez'];
}
if (!hash_equals($stored, $code)) {
return ['ok' => false, 'error' => 'código de emparelhamento inválido'];
}
$out = ['ok' => true, 'api_key' => hub_api_key_rotate($pdo, null), 'ops_key' => null];
// A de operações só sai se o hub a pedir: limita o estrago de uma aprovação
// dada por engano ao que se vê, em vez do que se dispara.
if ($withOps) $out['ops_key'] = hub_ops_key_rotate($pdo, null);
// Uso único: o código morre aqui, tenha corrido bem ou não volte a ser preciso.
hub_setting_set($pdo, 'hub_pairing_secret', '', null);
hub_setting_set($pdo, 'hub_pairing_state', 'paired', null);
return $out;
}
/**
* ─── Abrir um ticket no balcão do hub ────────────────────────────────────────
*
* O intake do hub (`/api/tickets.php`) existe precisamente para uma plataforma
* que prefira ter o SEU formulário em vez de despachar o utilizador para o
* /suporte/ de outro site. Era isso que a quack fazia: um link para fora, que
* mandava um aluno a meio de uma aula para um domínio que ele não conhece,
* autenticar-se outra vez, para dizer que um botão não funcionava.
*
* A chave é a MESMA que o hub usa para sondar esta plataforma — é ela que
* identifica a origem, e por isso o ticket já chega ao hub com a app certa e o
* `site_origin` preenchido, sem o aluno ter de dizer de onde vem.
*
* Devolve ['ok'=>bool, 'ref'=>string, 'track_url'=>string, 'error'=>string].
* Os erros vêm em português e prontos a mostrar: quem chama isto é um
* formulário, não outro programa.
*/
function hub_ticket_create(array $d): array
{
$falha = static fn(string $msg): array => ['ok' => false, 'error' => $msg, 'ref' => '', 'track_url' => ''];
$hub = hub_url();
$key = hub_api_key();
if ($hub === '') return $falha('O balcão de apoio ainda não está ligado a esta plataforma.');
if ($key === '') return $falha('Esta plataforma ainda não está emparelhada com o balcão de apoio.');
if (!function_exists('curl_init')) return $falha('cURL indisponível nesta instalação.');
$body = json_encode([
'submitter_email' => (string) ($d['submitter_email'] ?? ''),
'submitter_name' => (string) ($d['submitter_name'] ?? ''),
'title' => (string) ($d['title'] ?? ''),
'description' => (string) ($d['description'] ?? ''),
'category' => (string) ($d['category'] ?? 'outro'),
'priority' => (int) ($d['priority'] ?? 2),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$ch = curl_init($hub . '/api/tickets.php');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_TIMEOUT => 15,
CURLOPT_CONNECTTIMEOUT => 5,
// X-Hub-Key e não Authorization: o proxy à frente destes contentores
// come o cabeçalho Authorization antes de chegar ao PHP. O intake aceita
// os dois e lê este primeiro, por esta mesma razão.
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Accept: application/json',
'X-Hub-Key: ' . $key],
CURLOPT_USERAGENT => 'andmore-platform-ticket/1.0',
]);
$raw = curl_exec($ch);
$http = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($raw === false || $err !== '') {
error_log('hub_ticket_create: ' . ($err ?: 'sem resposta'));
return $falha('Não foi possível falar com o balcão de apoio. Tenta daqui a bocado.');
}
$res = json_decode((string) $raw, true);
if (!is_array($res)) {
error_log('hub_ticket_create: resposta não-JSON (HTTP ' . $http . '): ' . substr((string) $raw, 0, 300));
return $falha('O balcão de apoio respondeu de forma inesperada (HTTP ' . $http . ').');
}
if (empty($res['ok'])) {
// Traduzir os códigos do intake. Mostrar "invalid_email" a um utilizador
// não lhe diz o que fazer a seguir, e é ele que tem de agir.
$motivos = [
'invalid_email' => 'O e-mail da tua conta não é válido — fala com um administrador.',
'title_or_description_too_short' => 'Escreve um assunto com pelo menos 5 letras e uma descrição com pelo menos 10.',
'rate_limited' => 'Já foram abertos muitos pedidos há pouco. Tenta daqui a uma hora.',
'invalid_key' => 'Esta plataforma perdeu a ligação ao balcão de apoio — fala com um administrador.',
'missing_key' => 'Esta plataforma perdeu a ligação ao balcão de apoio — fala com um administrador.',
];
$codigo = (string) ($res['error'] ?? '');
error_log('hub_ticket_create: o hub recusou (' . $codigo . ')');
return $falha($motivos[$codigo] ?? 'O balcão de apoio recusou o pedido (' . $codigo . ').');
}
return [
'ok' => true,
'error' => '',
'ref' => (string) ($res['ref'] ?? ''),
'track_url' => (string) ($res['track_url'] ?? ''),
];
}
/**
* Há balcão de apoio ligado? (URL do hub + chave emparelhada.)
*
* Serve para o painel decidir se mostra o formulário ou um aviso. Sem isto, uma
* instalação por emparelhar mostrava o formulário na mesma e só dizia que não
* havia ligação DEPOIS de o utilizador escrever tudo.
*/
function hub_ticket_ready(): bool
{
return hub_url() !== '' && hub_api_key() !== '';
}
-280
View File
@@ -1,280 +0,0 @@
<?php
// ─── Bloqueio de IP, com memória ────────────────────────────────────────────
//
// A quinta tinha três defesas por IP e nenhuma bloqueava:
//
// throttle de login temporário, por identificador+IP, esquece em 15 min
// contador do antispam por hora e por formulário, recomeça do zero à hora
// portão de país recusa por país, não por comportamento
//
// Nenhuma delas tem memória. Um IP que insista todos os dias às 3 da manhã é,
// para as três, um visitante novo de cada vez. Isto acrescenta a peça que
// faltava: as infracções ficam registadas, e a partir de um limiar o endereço
// passa a ser recusado à porta, antes de chegar ao formulário.
//
// A escalada é deliberadamente lenta e o primeiro banimento é curto. Um IP é
// muitas vezes partilhado -- uma escola, um escritório, o CGNAT de um operador
// móvel -- e banir alguém para sempre à terceira tentativa apanha pessoas a
// mais. Quem insiste é que sobe:
//
// 3 infracções em 24h → 1 hora
// 5 → 24 horas
// 8 → 7 dias
// 12 → permanente
//
// Nada disto se aplica a endereços internos: atrás do NPM o contentor fala
// consigo próprio para cron e health checks, e auto-banir-se seria uma avaria
// difícil de diagnosticar.
//
// Com o site emparelhado com o hub, os banimentos e as infracções seguem para lá
// e os banimentos dos outros sites chegam cá com source='fleet' — ver
// functions_security_events.php.
require_once __DIR__ . '/functions_client_ip.php';
require_once __DIR__ . '/functions_security_events.php';
/** [infracções em 24h => minutos de banimento]. null = permanente. */
const IP_BAN_LADDER = [3 => 60, 5 => 1440, 8 => 10080, 12 => null];
function platform_ip_ban_ensure_tables(PDO $pdo): void
{
static $done = false;
if ($done) {
return;
}
$done = true;
$pdo->exec("
CREATE TABLE IF NOT EXISTS `ip_bans` (
`ip` VARCHAR(45) NOT NULL PRIMARY KEY,
`reason` VARCHAR(190) NOT NULL,
`source` VARCHAR(40) NOT NULL DEFAULT 'auto',
`expires_at` DATETIME NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`created_by` INT NULL,
`hits` INT NOT NULL DEFAULT 0,
KEY `idx_ip_bans_expires` (`expires_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
");
// As infracções vivem à parte do banimento: é a contagem delas que decide
// o degrau, e apagar um banimento não deve apagar o historico que o
// justificou -- senão um unban devolve a pessoa ao degrau zero e a escalada
// recomeça do princípio.
$pdo->exec("
CREATE TABLE IF NOT EXISTS `ip_offences` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`ip` VARCHAR(45) NOT NULL,
`kind` VARCHAR(40) NOT NULL,
`detail` VARCHAR(190) NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY `idx_ip_offences` (`ip`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
");
}
/** Endereços que nunca são banidos: loopback, privados, link-local. */
function platform_ip_ban_exempt(?string $ip): bool
{
if ($ip === null || $ip === '') {
return true;
}
return !filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
}
/**
* O banimento em vigor para este endereço, ou null.
*
* Um banimento expirado não é apagado -- fica como histórico, e a consulta
* ignora-o. Apagar linhas aqui perderia a razão pela qual alguém foi banido da
* primeira vez.
*/
function platform_ip_banned(PDO $pdo, ?string $ip = null): ?array
{
$ip ??= platform_client_ip();
if (platform_ip_ban_exempt($ip)) {
return null;
}
try {
platform_ip_ban_ensure_tables($pdo);
$st = $pdo->prepare(
'SELECT * FROM ip_bans WHERE ip = ? AND (expires_at IS NULL OR expires_at > NOW()) LIMIT 1'
);
$st->execute([$ip]);
return $st->fetch(PDO::FETCH_ASSOC) ?: null;
} catch (Throwable $e) {
// Falha ABERTO: sem base de dados não se tranca ninguém à porta. Uma
// avaria de infraestrutura não deve transformar-se em site inacessível.
error_log('[ip-ban] consulta falhou, a deixar passar: ' . $e->getMessage());
return null;
}
}
/** Bane um endereço. $minutes null = permanente. */
function platform_ip_ban(PDO $pdo, string $ip, string $reason, ?int $minutes = 60,
string $source = 'auto', ?int $userId = null): bool
{
if (platform_ip_ban_exempt($ip)) {
return false;
}
try {
platform_ip_ban_ensure_tables($pdo);
$expires = $minutes === null ? null : date('Y-m-d H:i:s', time() + $minutes * 60);
$pdo->prepare(
'INSERT INTO ip_bans (ip, reason, source, expires_at, created_by)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
reason = VALUES(reason), source = VALUES(source),
expires_at = VALUES(expires_at), created_at = NOW(), created_by = VALUES(created_by)'
)->execute([$ip, mb_substr($reason, 0, 190), $source, $expires, $userId]);
error_log("[ip-ban] $ip banido ($source): $reason" . ($expires ? " até $expires" : ' permanentemente'));
// As cópias da frota não voltam ao hub: ecoavam em ciclo.
if ($source !== 'fleet') {
platform_security_event($pdo, 'ip_ban', $reason, $ip, [
'source' => $source,
'expires_ts' => $minutes === null ? null : time() + $minutes * 60,
]);
}
return true;
} catch (Throwable $e) {
error_log('[ip-ban] não conseguiu banir: ' . $e->getMessage());
return false;
}
}
function platform_ip_unban(PDO $pdo, string $ip): bool
{
try {
platform_ip_ban_ensure_tables($pdo);
// Levanta também na frota — incluindo quando isto era uma cópia vinda de
// outro site. Sem isso a sincronização seguinte voltava a pô-la cá.
platform_security_event($pdo, 'ip_unban', null, $ip);
return $pdo->prepare('DELETE FROM ip_bans WHERE ip = ?')->execute([$ip]);
} catch (Throwable $e) {
error_log('[ip-ban] não conseguiu levantar: ' . $e->getMessage());
return false;
}
}
/**
* Regista uma infracção e, se o limiar for atingido, bane.
*
* É este o ponto de ligação para as defesas que já existem: o antispam chama-o
* quando recusa, o throttle de login quando esgota as tentativas, o portão de
* país quando bloqueia. Cada uma continua a fazer o que fazia -- isto só lhes
* acrescenta a memória que nenhuma tinha.
*
* Devolve o banimento aplicado, ou null se ainda não chegou lá.
*/
function platform_ip_record_offence(PDO $pdo, string $kind, ?string $detail = null, ?string $ip = null): ?array
{
$ip ??= platform_client_ip();
if (platform_ip_ban_exempt($ip)) {
return null;
}
try {
platform_ip_ban_ensure_tables($pdo);
$pdo->prepare('INSERT INTO ip_offences (ip, kind, detail) VALUES (?, ?, ?)')
->execute([$ip, mb_substr($kind, 0, 40), $detail === null ? null : mb_substr($detail, 0, 190)]);
platform_security_event($pdo, 'offence', $kind . ($detail !== null && $detail !== '' ? ' · ' . $detail : ''), $ip);
$st = $pdo->prepare('SELECT COUNT(*) FROM ip_offences WHERE ip = ? AND created_at >= NOW() - INTERVAL 1 DAY');
$st->execute([$ip]);
$n = (int) $st->fetchColumn();
// O degrau mais alto que este número atinge.
$minutes = false;
foreach (IP_BAN_LADDER as $threshold => $mins) {
if ($n >= $threshold) {
$minutes = $mins;
}
}
if ($minutes === false) {
return null;
}
platform_ip_ban($pdo, $ip, "$n infracções em 24h (última: $kind)", $minutes, 'auto');
return ['ip' => $ip, 'offences' => $n, 'minutes' => $minutes];
} catch (Throwable $e) {
error_log('[ip-ban] não registou a infracção: ' . $e->getMessage());
return null;
}
}
/**
* Corta o pedido se o endereço estiver banido. Chamar cedo, do bootstrap.
*
* 403 e não 404: um bot não lê nenhum dos dois, e para uma pessoa apanhada por
* engano um 403 com um email de contacto é a diferença entre perceber o que se
* passa e achar que o site está avariado.
*/
function platform_ip_guard(PDO $pdo, ?string $contactEmail = null): void
{
hub_security_maybe_sync($pdo);
$ban = platform_ip_banned($pdo);
if ($ban === null) {
return;
}
try {
$pdo->prepare('UPDATE ip_bans SET hits = hits + 1 WHERE ip = ?')->execute([$ban['ip']]);
} catch (Throwable) { /* contar é acessório */ }
http_response_code(403);
header('Content-Type: text/html; charset=utf-8');
header('Retry-After: ' . ($ban['expires_at'] ? max(60, strtotime($ban['expires_at']) - time()) : 86400));
$mail = $contactEmail !== null
? '<p>Se acha que se trata de um engano, escreva para <a href="mailto:'
. htmlspecialchars($contactEmail) . '">' . htmlspecialchars($contactEmail) . '</a>.</p>'
: '';
echo '<!doctype html><meta charset="utf-8"><title>Acesso bloqueado</title>'
. '<div style="font:16px/1.6 system-ui,sans-serif;max-width:34em;margin:18vh auto;padding:0 1.5rem">'
. '<h1 style="font-size:1.3rem">Acesso bloqueado</h1>'
. '<p>O seu endereço foi bloqueado por atividade automatizada repetida.</p>'
. $mail . '</div>';
exit;
}
// ─── Consultas para o backoffice ────────────────────────────────────────────
function platform_ip_ban_list(PDO $pdo, int $limit = 200): array
{
try {
platform_ip_ban_ensure_tables($pdo);
$st = $pdo->prepare(
'SELECT b.*,
(SELECT COUNT(*) FROM ip_offences o WHERE o.ip = b.ip) AS offences,
(b.expires_at IS NOT NULL AND b.expires_at <= NOW()) AS expired
FROM ip_bans b
ORDER BY (b.expires_at IS NULL OR b.expires_at > NOW()) DESC, b.created_at DESC
LIMIT ' . max(1, min(500, $limit))
);
$st->execute();
return $st->fetchAll(PDO::FETCH_ASSOC);
} catch (Throwable) {
return [];
}
}
/** Endereços com infracções mas ainda sem banimento — quem está a subir. */
function platform_ip_offenders(PDO $pdo, int $days = 7, int $limit = 30): array
{
try {
platform_ip_ban_ensure_tables($pdo);
$st = $pdo->prepare(
'SELECT o.ip, COUNT(*) AS n, MAX(o.created_at) AS last_seen,
GROUP_CONCAT(DISTINCT o.kind ORDER BY o.kind SEPARATOR ", ") AS kinds
FROM ip_offences o
LEFT JOIN ip_bans b ON b.ip = o.ip AND (b.expires_at IS NULL OR b.expires_at > NOW())
WHERE o.created_at >= NOW() - INTERVAL ? DAY AND b.ip IS NULL
GROUP BY o.ip
ORDER BY n DESC, last_seen DESC
LIMIT ' . max(1, min(200, $limit))
);
$st->execute([max(1, $days)]);
return $st->fetchAll(PDO::FETCH_ASSOC);
} catch (Throwable) {
return [];
}
}
@@ -1,86 +0,0 @@
<?php
// ─── Peças do formulário de entrada (partilhadas) ───────────────────────────
//
// O platform-tools dá a mecânica da autenticação (functions_auth.php,
// functions_remember.php) e cada site desenha a sua página de entrada — a
// marca e o layout são do site. O que NÃO deve variar de site para site são as
// duas peças que qualquer entrada da frota tem de ter, e é o que vive aqui:
//
// platform_password_field() — a palavra-passe com o olho para a mostrar
// platform_remember_field() — a caixa «Manter sessão iniciada»
//
// Antes disto, cada site tinha as suas (ou não tinha: a silver não tinha
// nenhuma das duas). Markup Bootstrap (input-group, form-check), que é o que as
// páginas de entrada da frota usam; o olho é um SVG embutido, sem depender dos
// ícones, e o script sai uma vez por página.
//
// Do lado do servidor, «manter sessão» é o auth_remember_issue() depois de
// entrar, o 'remember' => true no platform_connect() para repor a sessão, e o
// auth_remember_clear() ao sair — ver o README, «Connecting a site».
/**
* O campo da palavra-passe com o botão de a mostrar.
*
* $o: name ('password'), id ('password'), label ('Palavra-passe'; '' = sem
* etiqueta), autocomplete ('current-password' | 'new-password'), required
* (true), minlength (null), class (classes extra do <input>).
*/
function platform_password_field(array $o = []): string
{
static $scriptPrinted = false;
$o += ['name' => 'password', 'id' => 'password', 'label' => 'Palavra-passe',
'autocomplete' => 'current-password', 'required' => true, 'minlength' => null, 'class' => ''];
$e = fn($s) => htmlspecialchars((string) $s, ENT_QUOTES, 'UTF-8');
$eye = '<svg class="pt-eye-open" width="18" height="18" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">'
. '<path d="M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8M1.17 8a13 13 0 0 1 1.66-2.04C4.12 4.67 5.88 3.5 8 3.5s3.88 1.17 5.17 2.46A13 13 0 0 1 14.83 8q-.09.13-.2.3c-.3.46-.73 1.06-1.29 1.66C12 11.33 10.12 12.5 8 12.5s-3.88-1.17-5.17-2.46A13 13 0 0 1 1.17 8"/>'
. '<path d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5M4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0"/></svg>';
$eyeOff = '<svg class="pt-eye-closed" width="18" height="18" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" style="display:none">'
. '<path d="M13.36 11.24C15.07 9.72 16 8 16 8s-3-5.5-8-5.5a7 7 0 0 0-2.8.6l.77.77A6 6 0 0 1 8 3.5c2.12 0 3.88 1.17 5.17 2.46A13 13 0 0 1 14.83 8q-.09.13-.2.3c-.3.46-.73 1.06-1.29 1.66q-.18.18-.37.35z"/>'
. '<path d="M11.3 9.18a3.5 3.5 0 0 0-4.47-4.47l.82.82a2.5 2.5 0 0 1 2.83 2.83zm-2.94 1.47.82.82a3.5 3.5 0 0 1-4.47-4.47l.82.82a2.5 2.5 0 0 0 2.83 2.83"/>'
. '<path d="M3.35 5.47q-.27.24-.52.49A13 13 0 0 0 1.17 8l.2.3c.3.46.73 1.06 1.29 1.66C4.12 11.33 5.88 12.5 8 12.5c.72 0 1.4-.13 2.03-.36l.77.77A7 7 0 0 1 8 13.5C3 13.5 0 8 0 8s.94-1.73 2.64-3.24zm10.3 8.88-12-12 .7-.7 12 12z"/></svg>';
$html = '';
if ($o['label'] !== '') $html .= '<label class="form-label" for="' . $e($o['id']) . '">' . $e($o['label']) . '</label>';
$html .= '<div class="input-group">'
. '<input type="password" class="form-control ' . $e($o['class']) . '" id="' . $e($o['id']) . '" name="' . $e($o['name']) . '"'
. ' autocomplete="' . $e($o['autocomplete']) . '"'
. ($o['required'] ? ' required' : '')
. ($o['minlength'] ? ' minlength="' . (int) $o['minlength'] . '"' : '') . '>'
. '<button type="button" class="btn btn-outline-secondary" data-pt-reveal="' . $e($o['id']) . '"'
. ' aria-label="Mostrar a palavra-passe" aria-pressed="false" title="Mostrar a palavra-passe">' . $eye . $eyeOff . '</button>'
. '</div>';
if (!$scriptPrinted) {
$scriptPrinted = true;
// Um clique mostra, outro esconde. Volta sempre a esconder ao submeter,
// para o browser não oferecer guardar a palavra-passe como texto.
$html .= '<script>(function(){'
. 'document.addEventListener("click",function(ev){var b=ev.target.closest("[data-pt-reveal]");if(!b)return;'
. 'var i=document.getElementById(b.getAttribute("data-pt-reveal"));if(!i)return;var show=i.type==="password";'
. 'i.type=show?"text":"password";b.setAttribute("aria-pressed",show?"true":"false");'
. 'b.setAttribute("aria-label",show?"Esconder a palavra-passe":"Mostrar a palavra-passe");b.title=b.getAttribute("aria-label");'
. 'b.querySelector(".pt-eye-open").style.display=show?"none":"";b.querySelector(".pt-eye-closed").style.display=show?"":"none";});'
. 'document.addEventListener("submit",function(ev){ev.target.querySelectorAll("[data-pt-reveal]").forEach(function(b){'
. 'var i=document.getElementById(b.getAttribute("data-pt-reveal"));if(i)i.type="password";});},true);'
. '})();</script>';
}
return $html;
}
/**
* A caixa «Manter sessão iniciada». Desmarcada por omissão (e remarcada quando
* o formulário volta com um erro). O número de dias vem do mesmo sítio que o
* mecanismo usa, para o texto não mentir.
*/
function platform_remember_field(string $name = 'remember'): string
{
$days = function_exists('auth_remember_days') ? auth_remember_days() : 30;
$checked = !empty($_POST[$name]) ? ' checked' : '';
$id = 'pt-remember-' . preg_replace('/[^a-z0-9_-]/i', '', $name);
return '<div class="form-check">'
. '<input class="form-check-input" type="checkbox" name="' . htmlspecialchars($name) . '" id="' . $id . '" value="1"' . $checked . '>'
. '<label class="form-check-label" for="' . $id . '">Manter sessão iniciada</label>'
. '<div class="form-text">Durante ' . (int) $days . ' dias neste dispositivo. Não marque num computador partilhado.</div>'
. '</div>';
}
-336
View File
@@ -1,336 +0,0 @@
<?php
// ─── Shared SMTP transport ───────────────────────────────────────────────────
//
// The TRANSPORT half of outbound email only: build a configured PHPMailer,
// apply the dev safety nets, hand the message to SMTP. Deliberately NOT the
// queue: fotografia's message_queue (21 cols, multi-channel, soft-delete),
// hub2's message_queue (19 cols, template-driven) and surf's
// notification_queue (12 cols, keyed on lesson_id + a JSON payload) encode app
// domain, not infrastructure, and have no useful common shape. Each app keeps
// its own queue and calls platform_mail_send() at the point of egress.
//
// Public API:
// platform_mail_config(): array
// platform_mail_available(string $repo_root = ''): bool
// platform_mail_make(string $repo_root = ''): ?PHPMailer
// platform_mail_send(array $msg, string $repo_root = ''): array
// platform_mail_test(string $repo_root = ''): array
/**
* Reads SMTP settings from whichever mechanism the host uses.
*
* Precedence: a constant already defined by the app wins (fotografia and hub2
* define SMTP_HOST et al. during bootstrap), then env()/getenv()/$_ENV for
* hosts that do not. Without this the toolkit would only work on one of them.
*/
/**
* $override lets a host supply settings the toolkit cannot discover.
*
* Not every install exposes SMTP through constants or the environment: hub2
* keeps them in system_settings with the password encrypted, reachable only
* through its own smtp_config(). Without this the transport would silently
* fall back to an unconfigured mailer on those hosts.
*/
function platform_mail_config(?array $override = null): array {
$get = function (string $key, string $default = '') {
if (defined($key)) {
$v = constant($key);
return is_bool($v) ? ($v ? '1' : '') : (string) $v;
}
if (function_exists('env')) {
$v = env($key, null);
if ($v !== null && $v !== '') return (string) $v;
}
$v = getenv($key);
if ($v === false || $v === '') $v = $_ENV[$key] ?? '';
return (string) ($v === false ? $default : ($v === '' ? $default : $v));
};
// Primeiro nome que resolver ganha. Os sites da quinta declaram
// SMTP_USER/SMTP_PASS/SMTP_FROM_EMAIL, este módulo nasceu a ler
// SMTP_USERNAME/SMTP_PASSWORD/EMAIL_FROM, e ninguém dava pela diferença: o
// envio real passa por platform_smtp_config($pdo), que lê a base. Só o
// diagnóstico é que lia daqui -- e mostrava utilizador e password vazios
// enquanto o email saía bem, que é precisamente a altura em que uma página
// de diagnóstico não pode mentir. Pior: platform_mail_send() sem override
// autenticava-se com credenciais vazias.
$first = function (array $keys, string $default = '') use ($get): string {
foreach ($keys as $k) {
$v = $get($k);
if ($v !== '') return $v;
}
return $default;
};
$cfg = [
'host' => $first(['SMTP_HOST']),
'port' => (int) ($get('SMTP_PORT', '587') ?: 587),
'username' => $first(['SMTP_USERNAME', 'SMTP_USER']),
'password' => $first(['SMTP_PASSWORD', 'SMTP_PASS']),
'secure' => $first(['SMTP_SECURE'], 'tls'),
'from' => $first(['EMAIL_FROM', 'SMTP_FROM_EMAIL', 'SMTP_FROM']),
'from_name' => $first(['EMAIL_FROM_NAME', 'NOTIFY_FROM_NAME']),
// Safety nets. Only consulted when APP_ENV is not 'prod', so a
// misconfigured production install cannot silently swallow mail.
'env' => $get('APP_ENV', 'prod'),
'dev_print' => filter_var($get('EMAIL_DEV_PRINT', ''), FILTER_VALIDATE_BOOLEAN),
'dev_redirect' => $get('EMAIL_DEV_REDIRECT'),
];
if ($override) {
foreach ($override as $k => $v) {
// Only accept keys we know, and never let an override blank a value
// the host already resolved -- a partial array must not wipe the rest.
if (array_key_exists($k, $cfg) && $v !== null && $v !== '') $cfg[$k] = $v;
}
}
return $cfg;
}
function platform_mail_is_configured(?array $override = null): bool {
return platform_mail_config($override)['host'] !== '';
}
/**
* Loads PHPMailer, preferring composer's autoloader and falling back to the
* hand-vendored copy some installs carry at vendor/PHPMailer/src/.
* Both layouts exist across the fleet, so neither can be assumed.
*/
function platform_mail_available(string $repo_root = ''): bool {
if (class_exists(\PHPMailer\PHPMailer\PHPMailer::class)) return true;
$root = $repo_root !== '' ? rtrim($repo_root, '/') : dirname(__DIR__, 2);
foreach ([$root . '/vendor/autoload.php'] as $auto) {
if (is_file($auto)) {
require_once $auto;
if (class_exists(\PHPMailer\PHPMailer\PHPMailer::class)) return true;
}
}
$base = $root . '/vendor/PHPMailer/src/';
if (is_file($base . 'PHPMailer.php')) {
require_once $base . 'Exception.php';
require_once $base . 'PHPMailer.php';
require_once $base . 'SMTP.php';
return class_exists(\PHPMailer\PHPMailer\PHPMailer::class);
}
return false;
}
/** A configured PHPMailer, or null when the class cannot be loaded. */
function platform_mail_make(string $repo_root = '', ?array $override = null) {
if (!platform_mail_available($repo_root)) return null;
$c = platform_mail_config($override);
$mail = new \PHPMailer\PHPMailer\PHPMailer(true); // true = throw on failure
$mail->CharSet = 'UTF-8';
if ($c['host'] !== '') {
$mail->isSMTP();
$mail->Host = $c['host'];
$mail->Port = $c['port'];
$mail->SMTPAuth = ($c['username'] !== '');
$mail->Username = $c['username'];
$mail->Password = $c['password'];
// '' and 'none' both mean plaintext; assigning either to SMTPSecure
// makes PHPMailer attempt a protocol it cannot negotiate.
if ($c['secure'] !== '' && strtolower($c['secure']) !== 'none') {
$mail->SMTPSecure = $c['secure'];
}
} else {
$mail->isMail(); // hand off to the local MTA if one exists
}
if ($c['from'] !== '') {
$mail->setFrom($c['from'], $c['from_name']);
}
return $mail;
}
/**
* Sends one message.
*
* $msg: to, to_name, subject, body (html), alt_body, reply_to, reply_to_name,
* attachments [[path, name], …]
*
* Returns ['ok' => bool, 'skipped' => bool, 'error' => ?string].
* 'skipped' is true when a dev safety net swallowed the message on purpose --
* distinct from a failure, so a queue worker can mark it sent rather than
* retrying forever against a dev box.
*/
function platform_mail_send(array $msg, string $repo_root = '', ?array $override = null, ?PDO $pdo = null): array {
$c = platform_mail_config($override);
$to = trim((string) ($msg['to'] ?? ''));
if ($to === '') {
return ['ok' => false, 'skipped' => false, 'error' => 'Destinatário vazio.'];
}
$isProd = ($c['env'] === 'prod');
// Log instead of sending. Checked BEFORE loading PHPMailer so a host with
// no mailer installed can still run end to end in development.
if (!$isProd && $c['dev_print']) {
error_log(sprintf('[mail:dev_print] to=%s subject=%s', $to, (string) ($msg['subject'] ?? '')));
return ['ok' => true, 'skipped' => true, 'error' => null];
}
// Tecto de envio. Com $pdo, e antes de se gastar tempo a montar o PHPMailer.
// Uma mensagem recusada aqui devolve rate_limited: quem chama de uma fila
// deve deixá-la pendente e voltar a tentar, não marcá-la como falhada.
if ($pdo instanceof PDO) {
require_once __DIR__ . '/functions_mail_rate.php';
$capped = platform_mail_rate_blocked($pdo);
if ($capped !== null) {
error_log('[mail-rate] envio adiado: ' . $capped);
require_once __DIR__ . '/functions_security_events.php';
platform_security_event($pdo, 'mail_rate_limited', $capped, '');
return ['ok' => false, 'skipped' => false, 'rate_limited' => true, 'error' => $capped];
}
}
$mail = platform_mail_make($repo_root, $override);
if ($mail === null) {
return ['ok' => false, 'skipped' => false,
'error' => 'PHPMailer indisponível — corre "composer install", ou activa EMAIL_DEV_PRINT fora de produção.'];
}
try {
// Redirect every recipient to one inbox outside production, so testing
// against a copy of the live database cannot mail real clients.
if (!$isProd && $c['dev_redirect'] !== '') {
$mail->addAddress($c['dev_redirect']);
$mail->Subject = '[DEV] ' . (string) ($msg['subject'] ?? '');
// Banner naming the real recipient. Without it a redirected mailbox
// fills with messages that all look addressed to itself, and it is
// impossible to tell who each one was actually for.
$orig = $to . (($msg['to_name'] ?? '') !== '' ? ' <' . $msg['to_name'] . '>' : '');
$banner = '<div style="background:#fffae6;border:1px solid #e0c060;padding:10px 14px;'
. 'margin-bottom:16px;font-family:Arial,sans-serif;font-size:13px;color:#664400;">'
. '<strong>[DEV redirect]</strong> Destinatário original: '
. htmlspecialchars($orig, ENT_QUOTES, 'UTF-8') . '</div>';
$msg['body'] = $banner . (string) ($msg['body'] ?? '');
} else {
$mail->addAddress($to, (string) ($msg['to_name'] ?? ''));
$mail->Subject = (string) ($msg['subject'] ?? '');
}
if (!empty($msg['reply_to'])) {
$mail->addReplyTo((string) $msg['reply_to'], (string) ($msg['reply_to_name'] ?? ''));
}
$html = (string) ($msg['body'] ?? '');
$text = (string) ($msg['alt_body'] ?? '');
if ($html !== '') {
$mail->isHTML(true);
$mail->Body = $html;
$mail->AltBody = $text !== '' ? $text : trim(strip_tags($html));
} else {
$mail->isHTML(false);
$mail->Body = $text;
}
foreach ((array) ($msg['attachments'] ?? []) as $att) {
if (is_array($att) && !empty($att[0]) && is_file($att[0])) {
$mail->addAttachment($att[0], (string) ($att[1] ?? ''));
}
}
$mail->send();
if ($pdo instanceof PDO) {
platform_mail_rate_record($pdo);
}
return ['ok' => true, 'skipped' => false, 'error' => null];
} catch (\Throwable $e) {
// ErrorInfo carries PHPMailer's own diagnosis, which is usually more
// specific than the exception message.
$err = $mail->ErrorInfo !== '' ? $mail->ErrorInfo : $e->getMessage();
return ['ok' => false, 'skipped' => false, 'error' => $err];
}
}
/**
* Opens an SMTP connection and closes it without sending. Used by the
* "testar ligação" control so a misconfiguration surfaces before a queue
* fills up with failures.
*/
function platform_mail_test(string $repo_root = '', ?array $override = null): array {
if (!platform_mail_is_configured($override)) {
return ['ok' => false, 'error' => 'SMTP_HOST não está configurado.', 'log' => ''];
}
$mail = platform_mail_make($repo_root, $override);
if ($mail === null) {
return ['ok' => false, 'error' => 'PHPMailer indisponível — corre "composer install".', 'log' => ''];
}
$log = '';
$mail->SMTPDebug = 2; // PHPMailer redacts the credential lines itself
$mail->Debugoutput = function ($str) use (&$log) { $log .= $str; };
try {
$ok = $mail->smtpConnect();
$mail->smtpClose();
return ['ok' => (bool) $ok, 'error' => $ok ? '' : 'A ligação foi recusada.', 'log' => $log];
} catch (\Throwable $e) {
return ['ok' => false, 'error' => $mail->ErrorInfo !== '' ? $mail->ErrorInfo : $e->getMessage(), 'log' => $log];
}
}
/**
* O que aconteceria a uma mensagem enviada agora mesmo.
*
* Devolve [classe-bootstrap, título, explicação]. Vale a pena ser explícito:
* "configurado" e "entrega" não são a mesma coisa, e uma rede de segurança de
* desenvolvimento a engolir email em silêncio é, vista de fora, indistinguível
* de sucesso.
*
* Vivia dentro do smtp_status.php. Saiu para aqui quando o comms.php passou a
* mostrar o mesmo veredicto -- duas cópias desta lista de casos divergiriam, e
* a que ficasse para trás mentiria a alguém a meio de uma avaria.
*/
function platform_mail_diagnosis(string $repo_root = '', ?array $override = null, ?PDO $pdo = null): array {
// Com $pdo, diagnostica-se a configuração que realmente envia (a da base),
// e não a que estiver no ambiente.
if ($override === null && $pdo !== null && function_exists('platform_smtp_config')) {
$override = platform_smtp_config($pdo);
}
$cfg = platform_mail_config($override);
$isProd = ($cfg['env'] === 'prod');
if (!$isProd && $cfg['dev_print']) {
return ['warning', 'Não entrega', 'EMAIL_DEV_PRINT está activo e APP_ENV não é prod — as mensagens são registadas no log, não enviadas.'];
}
if ($cfg['host'] === '') {
return ['danger', 'Não configurado', 'O servidor SMTP está vazio. Sem servidor configurado o envio falha (não há MTA local neste contentor).'];
}
if (!platform_mail_available($repo_root)) {
return ['danger', 'PHPMailer em falta', 'A classe não foi encontrada — corre "composer install".'];
}
if (!$isProd && $cfg['dev_redirect'] !== '') {
return ['warning', 'Redireccionado', 'Todas as mensagens vão para ' . $cfg['dev_redirect'] . ' em vez do destinatário real (APP_ENV não é prod).'];
}
return ['success', 'Operacional', 'As mensagens são entregues ao servidor SMTP configurado.'];
}
/**
* Os valores em vigor, prontos para uma tabela: [etiqueta, valor, ok?].
*
* A password nunca sai daqui, nem mascarada com o comprimento real -- isso
* ainda diz quantos caracteres tem.
*/
function platform_mail_config_rows(?array $override = null, ?PDO $pdo = null): array {
if ($override === null && $pdo !== null && function_exists('platform_smtp_config')) {
$override = platform_smtp_config($pdo);
}
$cfg = platform_mail_config($override);
$isProd = ($cfg['env'] === 'prod');
return [
['Servidor', $cfg['host'] !== '' ? $cfg['host'] : '— vazio —', $cfg['host'] !== ''],
['Porta', (string) $cfg['port'], true],
['Cifra', $cfg['secure'] !== '' ? $cfg['secure'] : 'nenhuma', true],
['Utilizador', $cfg['username'] !== '' ? $cfg['username'] : '— vazio —', true],
['Palavra-passe', $cfg['password'] !== '' ? '•••••••• (definida)' : '— vazia —', $cfg['password'] !== ''],
['Remetente', $cfg['from'] !== '' ? $cfg['from'] : '— vazio —', $cfg['from'] !== ''],
['Nome do remetente', $cfg['from_name'] !== '' ? $cfg['from_name'] : '— vazio —', true],
['APP_ENV', $cfg['env'], true],
['EMAIL_DEV_PRINT', $cfg['dev_print'] ? 'true' : 'false', !$cfg['dev_print'] || $isProd],
['EMAIL_DEV_REDIRECT', $cfg['dev_redirect'] !== '' ? $cfg['dev_redirect'] : '— vazio —', true],
];
}
@@ -1,153 +0,0 @@
<?php
// ─── Tecto de envio de email ────────────────────────────────────────────────
//
// A fila de notificações tinha backoff de retentativas mas nenhum tecto: nada
// impedia que um evento em cadeia despachasse centenas de mensagens seguidas.
// Foi o que os 14 registos de bot do dance quase produziram em 25/08 -- cada um
// disparou um email de confirmação para a caixa de um terceiro, e só não foram
// mais porque os bots pararam.
//
// O que isto protege não é o servidor, é a reputação do domínio. Um pico de
// envios para endereços que nunca pediram nada é exactamente o padrão que os
// filtros de spam procuram, e recuperar uma reputação queimada leva semanas.
//
// Duas janelas, porque protegem de coisas diferentes:
//
// MAIL_RATE_PER_MINUTE trava o disparo em cadeia -- o ciclo que envia sem
// parar por causa de um erro de lógica
// MAIL_RATE_PER_HOUR trava a campanha lenta -- o bot que manda um por
// minuto durante a noite e não dispara o primeiro
//
// Falha FECHADO, ao contrário do bloqueio por país. Aqui a dúvida é entre não
// enviar uma mensagem legítima e queimar o domínio: a primeira recupera-se
// sozinha na retentativa seguinte, a segunda não. Uma mensagem recusada pelo
// tecto fica na fila, não se perde.
require_once __DIR__ . '/functions_settings.php';
/**
* Cria a tabela de contagem se faltar.
*
* Tabela própria e não uma contagem sobre notification_queue: nem todos os
* sites têm fila, e os que têm apagam linhas antigas -- a contagem passaria a
* depender da política de limpeza de outra coisa.
*/
function platform_mail_rate_ensure_table(PDO $pdo): void
{
static $done = false;
if ($done) {
return;
}
$done = true;
$pdo->exec("
CREATE TABLE IF NOT EXISTS `mail_sends` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`sent_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY `idx_mail_sends_at` (`sent_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
");
}
/** Tectos em vigor, da base de dados, do .env, ou os do módulo. */
function platform_mail_rate_limits(?PDO $pdo = null): array
{
$get = function (string $key, int $default) use ($pdo): int {
if ($pdo instanceof PDO && function_exists('platform_settings_get')) {
$v = trim((string) platform_settings_get($pdo, strtolower($key), ''));
if ($v !== '' && ctype_digit($v)) {
return (int) $v;
}
}
if (function_exists('env')) {
$v = trim((string) env($key, ''));
if ($v !== '' && ctype_digit($v)) {
return (int) $v;
}
}
return $default;
};
return [
// Generosos de propósito. Isto é uma rede contra disparos em cadeia,
// não uma quota: um tecto apertado de mais transforma-se em avarias
// silenciosas no dia em que a academia mandar mesmo 60 confirmações.
'per_minute' => max(1, $get('MAIL_RATE_PER_MINUTE', 20)),
'per_hour' => max(1, $get('MAIL_RATE_PER_HOUR', 200)),
];
}
/**
* Pode sair mais uma mensagem agora?
*
* Devolve null quando sim, ou o motivo quando não -- para quem chama poder
* registá-lo em vez de falhar em silêncio.
*/
function platform_mail_rate_blocked(PDO $pdo): ?string
{
try {
platform_mail_rate_ensure_table($pdo);
$lim = platform_mail_rate_limits($pdo);
$st = $pdo->query("
SELECT
SUM(sent_at >= NOW() - INTERVAL 1 MINUTE) AS m,
SUM(sent_at >= NOW() - INTERVAL 1 HOUR) AS h
FROM mail_sends
WHERE sent_at >= NOW() - INTERVAL 1 HOUR
");
$r = $st->fetch(PDO::FETCH_ASSOC) ?: ['m' => 0, 'h' => 0];
if ((int) $r['m'] >= $lim['per_minute']) {
return "tecto de {$lim['per_minute']} envios/minuto atingido";
}
if ((int) $r['h'] >= $lim['per_hour']) {
return "tecto de {$lim['per_hour']} envios/hora atingido";
}
return null;
} catch (Throwable $e) {
// Falha fechado: sem forma de contar, não se dispara às cegas.
error_log('[mail-rate] contagem indisponível, a travar por precaução: ' . $e->getMessage());
return 'contador de envios indisponível';
}
}
/** Regista um envio. Chamar DEPOIS de a mensagem sair, nunca antes. */
function platform_mail_rate_record(PDO $pdo): void
{
try {
platform_mail_rate_ensure_table($pdo);
$pdo->exec("INSERT INTO mail_sends () VALUES ()");
// Limpeza oportunista: a tabela só serve para as últimas 24h, e sem
// isto cresceria para sempre. Uma vez em cada cem inserções chega.
if (random_int(1, 100) === 1) {
$pdo->exec("DELETE FROM mail_sends WHERE sent_at < NOW() - INTERVAL 1 DAY");
}
} catch (Throwable $e) {
error_log('[mail-rate] não registou o envio: ' . $e->getMessage());
}
}
/** Quantos saíram no último minuto e na última hora — para a página de estado. */
function platform_mail_rate_usage(PDO $pdo): array
{
$lim = platform_mail_rate_limits($pdo);
try {
platform_mail_rate_ensure_table($pdo);
$r = $pdo->query("
SELECT
SUM(sent_at >= NOW() - INTERVAL 1 MINUTE) AS m,
SUM(sent_at >= NOW() - INTERVAL 1 HOUR) AS h
FROM mail_sends
WHERE sent_at >= NOW() - INTERVAL 1 HOUR
")->fetch(PDO::FETCH_ASSOC) ?: [];
return [
'minute' => (int) ($r['m'] ?? 0),
'hour' => (int) ($r['h'] ?? 0),
'per_minute' => $lim['per_minute'],
'per_hour' => $lim['per_hour'],
];
} catch (Throwable) {
return ['minute' => 0, 'hour' => 0] + $lim;
}
}
@@ -1,208 +0,0 @@
<?php
// ─── DB migrations runner ────────────────────────────────────────────────────
//
// Lightweight, dependency-free runner over the `database/migration_*.sql`
// files in the host repo. State is tracked in `applied_migrations` (auto-created
// on first call). Files are matched by basename only — path traversal is
// blocked by an explicit regex.
//
// Public API:
// db_ensure_migrations_table(PDO $pdo): void
// migrations_dir(): string — host repo migrations dir
// migrations_list_files(): array — basenames, sorted
// migrations_status(PDO $pdo): array — [{filename, applied_at|null}]
// migrations_apply_file(PDO, string $filename, ?int $userId): array
// migrations_mark_all_applied(PDO, ?int $userId): int
// migrations_split_sql(string $sql): array — string/comment-aware splitter
//
// Migrations directory resolution:
// 1. $GLOBALS['MIGRATIONS_DIR'] if set (set this in the stub for non-standard layouts)
// 2. Otherwise: dirname(__DIR__, 2) . '/database'
// Assumes this file lives at <host_repo>/shared/platform-tools/functions_migrations.php
// → resolves to <host_repo>/database.
function migrations_dir(): string {
$dir = $GLOBALS['MIGRATIONS_DIR'] ?? (dirname(__DIR__, 2) . '/database');
return rtrim($dir, '/');
}
function db_ensure_migrations_table(PDO $pdo): void {
// FK on applied_by is intentionally omitted: tenants vary in whether
// they have a `users` table by this name. The application enforces
// referential semantics already (we only ever insert the caller's
// user_id; deletion of that user is rare and orphan rows are harmless).
$pdo->exec(
"CREATE TABLE IF NOT EXISTS `applied_migrations` (
`filename` VARCHAR(150) NOT NULL,
`applied_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`applied_by` INT DEFAULT NULL,
PRIMARY KEY (`filename`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
}
function migrations_list_files(): array {
$files = glob(migrations_dir() . '/migration_*.sql') ?: [];
sort($files);
return array_map('basename', $files);
}
function migrations_status(PDO $pdo): array {
db_ensure_migrations_table($pdo);
$applied = $pdo->query("SELECT filename, applied_at FROM applied_migrations")
->fetchAll(PDO::FETCH_KEY_PAIR);
$applied = $applied ?: [];
$rows = [];
foreach (migrations_list_files() as $file) {
$rows[] = [
'filename' => $file,
'applied_at' => $applied[$file] ?? null,
];
}
return $rows;
}
// Split a SQL blob into individual statements, respecting strings, escapes,
// line comments (-- … and # …) and block comments (/* … */). Comments are
// recognised inside the same state machine that tracks string literals, so a
// value like 'use -- with care' or 'a /* b */ c' passes through untouched.
// DELIMITER changes are not supported (project migrations don't use stored
// procedures).
function migrations_split_sql(string $sql): array {
$statements = [];
$current = '';
$in_string = false;
$quote = null;
$len = strlen($sql);
for ($i = 0; $i < $len; $i++) {
$ch = $sql[$i];
if ($in_string) {
// Backslash escapes apply inside '…' and "…", never inside `…`.
if ($ch === '\\' && $quote !== '`' && $i + 1 < $len) {
$current .= $ch . $sql[$i + 1];
$i++;
continue;
}
if ($ch === $quote) {
if ($i + 1 < $len && $sql[$i + 1] === $quote) {
$current .= $ch . $sql[$i + 1];
$i++;
continue;
}
$in_string = false;
$quote = null;
}
$current .= $ch;
continue;
}
// Block comment /* … */ — dropped (including /*! … */ version hints).
if ($ch === '/' && $i + 1 < $len && $sql[$i + 1] === '*') {
$end = strpos($sql, '*/', $i + 2);
$i = ($end === false) ? $len : $end + 1;
continue;
}
// Line comment: `#`, or `--` followed by whitespace/EOL (MySQL rule —
// `a--b` is arithmetic, not a comment).
if ($ch === '#'
|| ($ch === '-' && $i + 1 < $len && $sql[$i + 1] === '-'
&& ($i + 2 >= $len || strpbrk($sql[$i + 2], " \t\r\n") !== false))) {
$nl = strpos($sql, "\n", $i);
if ($nl === false) break; // comment runs to end of input
$i = $nl - 1; // keep the \n as statement whitespace
continue;
}
if ($ch === "'" || $ch === '"' || $ch === '`') {
$in_string = true;
$quote = $ch;
$current .= $ch;
continue;
}
if ($ch === ';') {
$stmt = trim($current);
if ($stmt !== '') $statements[] = $stmt;
$current = '';
continue;
}
$current .= $ch;
}
$stmt = trim($current);
if ($stmt !== '') $statements[] = $stmt;
return $statements;
}
function migrations_apply_file(PDO $pdo, string $filename, ?int $userId = null): array {
if (!preg_match('/^migration_[a-z0-9_]+\.sql$/i', $filename)) {
return ['ok' => false, 'error' => 'Nome de migration inválido.'];
}
$path = migrations_dir() . '/' . $filename;
if (!is_file($path)) {
return ['ok' => false, 'error' => "Ficheiro não encontrado: {$filename}"];
}
db_ensure_migrations_table($pdo);
$stmt = $pdo->prepare("SELECT 1 FROM applied_migrations WHERE filename = ?");
$stmt->execute([$filename]);
if ($stmt->fetchColumn()) {
return ['ok' => false, 'error' => 'Já aplicada anteriormente.'];
}
$sql = file_get_contents($path);
if ($sql === false) {
return ['ok' => false, 'error' => 'Não foi possível ler o ficheiro.'];
}
$statements = migrations_split_sql($sql);
if (!$statements) {
return ['ok' => false, 'error' => 'O ficheiro não contém statements SQL.'];
}
$applied = 0;
foreach ($statements as $statement) {
try {
$pdo->exec($statement);
$applied++;
} catch (PDOException $e) {
return [
'ok' => false,
'error' => 'Erro no statement #' . ($applied + 1) . ': ' . $e->getMessage(),
'applied' => $applied,
'total' => count($statements),
];
}
}
$stmt = $pdo->prepare(
"INSERT INTO applied_migrations (filename, applied_by) VALUES (?, ?)"
);
$stmt->execute([$filename, $userId]);
return ['ok' => true, 'applied' => $applied, 'total' => count($statements)];
}
function migrations_mark_all_applied(PDO $pdo, ?int $userId = null): int {
db_ensure_migrations_table($pdo);
$existing = $pdo->query("SELECT filename FROM applied_migrations")
->fetchAll(PDO::FETCH_COLUMN);
$existing = array_flip($existing ?: []);
$stmt = $pdo->prepare(
"INSERT INTO applied_migrations (filename, applied_by) VALUES (?, ?)"
);
$count = 0;
foreach (migrations_list_files() as $file) {
if (!isset($existing[$file])) {
$stmt->execute([$file, $userId]);
$count++;
}
}
return $count;
}
@@ -1,214 +0,0 @@
<?php
// ─── Password reset over the shared SMTP transport ───────────────────────────
//
// Recovery used to be somebody else's problem: silver's views/auth/restore.php
// and reset_password.php were one-line redirects to id.andmore.pt, which no
// longer exists, so nobody could recover an account at all. This implements it
// locally, delivering through platform_mail_send() with platform_smtp_config()
// -- the same transport the rest of the estate already uses, so a site that can
// send any mail can send these.
//
// Token handling, and why:
// - The token is 32 random bytes, hex. Only its SHA-256 is stored, so a
// leaked database dump cannot be used to reset anyone's password.
// - Requesting a reset for an unknown address returns the same result as a
// known one. Otherwise the form enumerates which addresses have accounts.
// - Consuming a token deletes every outstanding token for that user, so a
// forwarded or resent link cannot be replayed after the real reset.
// - The session is regenerated on success: a password reset is a privilege
// change, and any session an attacker already holds must not survive it.
require_once __DIR__ . '/functions_auth.php';
require_once __DIR__ . '/functions_mail.php';
require_once __DIR__ . '/functions_settings.php';
const PASSWORD_RESET_TTL_SECONDS = 3600; // one hour
function password_reset_ensure_table(PDO $pdo): void {
$pdo->exec(
"CREATE TABLE IF NOT EXISTS `password_resets` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` INT NOT NULL,
`token_hash` CHAR(64) NOT NULL,
`expires_at` DATETIME NOT NULL,
`used_at` DATETIME NULL,
`requested_ip` VARCHAR(45) NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_token_hash` (`token_hash`),
KEY `idx_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
}
function password_reset_hash(string $token): string {
return hash('sha256', $token);
}
/**
* Issue a reset link and mail it.
*
* $resetUrl receives the token: "https://site/auth/reset.php?token=%s".
*
* 'ok' is what the public form should show, and it is true for every
* well-formed address -- known or not, delivered or not. A transport failure
* comes back separately in 'delivery_error', because if that were folded into
* 'ok' the form would answer differently for a known address than an unknown
* one whenever SMTP is down, which is the enumeration leak this is avoiding.
* Show 'delivery_error' to an operator, never to an anonymous visitor.
*/
function password_reset_request(PDO $pdo, string $email, string $resetUrl, array $opts = []): array {
password_reset_ensure_table($pdo);
$s = auth_schema();
$email = mb_strtolower(trim($email));
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
return ['ok' => false, 'sent' => false, 'error' => 'Endereço de email inválido.',
'delivery_error' => null];
}
$st = $pdo->prepare(
"SELECT * FROM `{$s['table']}` WHERE `{$s['email']}` = ? AND " . auth_active_sql() . " LIMIT 1"
);
$st->execute([$email]);
$user = $st->fetch(PDO::FETCH_ASSOC) ?: null;
if ($user === null) {
error_log("[password_reset] request for unknown or inactive address: {$email}");
return ['ok' => true, 'sent' => false, 'error' => null, 'delivery_error' => null];
}
$token = bin2hex(random_bytes(32));
$pdo->prepare(
"INSERT INTO password_resets (user_id, token_hash, expires_at, requested_ip)
VALUES (?, ?, DATE_ADD(NOW(), INTERVAL ? SECOND), ?)"
)->execute([
(int) $user[$s['id']],
password_reset_hash($token),
(int) ($opts['ttl'] ?? PASSWORD_RESET_TTL_SECONDS),
auth_client_ip(),
]);
$link = sprintf($resetUrl, urlencode($token));
$appName = (string) ($opts['app_name'] ?? 'a plataforma');
$name = trim((string) ($user[$s['name']] ?? '')) ?: $email;
$minutes = (int) round(((int) ($opts['ttl'] ?? PASSWORD_RESET_TTL_SECONDS)) / 60);
$safeName = htmlspecialchars($name, ENT_QUOTES, 'UTF-8');
$safeApp = htmlspecialchars($appName, ENT_QUOTES, 'UTF-8');
$safeLink = htmlspecialchars($link, ENT_QUOTES, 'UTF-8');
$html = "<p>Olá {$safeName},</p>"
. "<p>Recebemos um pedido para redefinir a palavra-passe da tua conta em <strong>{$safeApp}</strong>.</p>"
. "<p><a href=\"{$safeLink}\">Definir uma nova palavra-passe</a></p>"
. "<p>O link expira dentro de {$minutes} minutos e só pode ser usado uma vez.</p>"
. "<p>Se não foste tu, ignora este email — a palavra-passe atual mantém-se.</p>";
$text = "Olá {$name},\n\n"
. "Recebemos um pedido para redefinir a palavra-passe da tua conta em {$appName}.\n\n"
. "{$link}\n\n"
. "O link expira dentro de {$minutes} minutos e só pode ser usado uma vez.\n"
. "Se não foste tu, ignora este email — a palavra-passe atual mantém-se.\n";
$res = platform_mail_send(
[
'to' => $email,
'to_name' => $name,
'subject' => 'Redefinir a palavra-passe — ' . $appName,
'body' => $html,
'alt_body' => $text,
],
(string) ($opts['repo_root'] ?? ''),
platform_smtp_config($pdo)
);
if (empty($res['ok'])) {
error_log('[password_reset] send failed: ' . (string) ($res['error'] ?? 'unknown'));
return ['ok' => true, 'sent' => false, 'error' => null,
'delivery_error' => 'Não foi possível enviar o email. Verifica a configuração de SMTP.'];
}
return ['ok' => true, 'sent' => true, 'error' => null, 'delivery_error' => null];
}
/** Returns the user row a valid, unexpired, unused token belongs to. */
function password_reset_validate(PDO $pdo, string $token): ?array {
password_reset_ensure_table($pdo);
$s = auth_schema();
$st = $pdo->prepare(
"SELECT r.id AS reset_id, r.user_id
FROM password_resets r
WHERE r.token_hash = ? AND r.used_at IS NULL AND r.expires_at > NOW()
LIMIT 1"
);
$st->execute([password_reset_hash($token)]);
$row = $st->fetch(PDO::FETCH_ASSOC);
if (!$row) return null;
$st = $pdo->prepare(
"SELECT * FROM `{$s['table']}` WHERE `{$s['id']}` = ? AND " . auth_active_sql() . " LIMIT 1"
);
$st->execute([(int) $row['user_id']]);
$user = $st->fetch(PDO::FETCH_ASSOC) ?: null;
if ($user === null) return null;
$user['_reset_id'] = (int) $row['reset_id'];
return $user;
}
/**
* Set the new password and burn every outstanding token for that user.
*
* Wrapped in a transaction: a password changed without its token being
* invalidated would leave a working reset link behind.
*/
function password_reset_consume(PDO $pdo, string $token, string $newPassword, array $opts = []): array {
$minLength = (int) ($opts['min_length'] ?? 10);
if (mb_strlen($newPassword) < $minLength) {
return ['ok' => false, 'error' => "A palavra-passe tem de ter pelo menos {$minLength} caracteres."];
}
$user = password_reset_validate($pdo, $token);
if ($user === null) {
return ['ok' => false, 'error' => 'Link inválido ou expirado. Pede um novo.'];
}
$s = auth_schema();
try {
$pdo->beginTransaction();
$pdo->prepare("UPDATE `{$s['table']}` SET `{$s['password']}` = ? WHERE `{$s['id']}` = ?")
->execute([auth_password_hash($newPassword), (int) $user[$s['id']]]);
$pdo->prepare("UPDATE password_resets SET used_at = NOW() WHERE user_id = ? AND used_at IS NULL")
->execute([(int) $user[$s['id']]]);
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
error_log('[password_reset] consume failed: ' . $e->getMessage());
return ['ok' => false, 'error' => 'Não foi possível guardar a nova palavra-passe.'];
}
// A completed reset is a privilege change; no pre-existing session survives.
auth_session_regenerate();
auth_throttle_clear($pdo, (string) ($user[$s['email']] ?? ''));
// Uma palavra-passe reposta termina as sessões lembradas em todos os
// dispositivos: repor é o que se faz quando se suspeita de que alguém a tem.
// (O dance fazia-o à mão; passou a ser aqui, para toda a frota.)
require_once __DIR__ . '/functions_remember.php';
try { auth_remember_forget_user($pdo, (int) $user[$s['id']]); }
catch (Throwable $e) { error_log('[password_reset] remember: ' . $e->getMessage()); }
return ['ok' => true, 'error' => null, 'user_id' => (int) $user[$s['id']]];
}
/** Housekeeping for a cron job: drop spent and expired rows. */
function password_reset_prune(PDO $pdo, int $keepDays = 7): int {
password_reset_ensure_table($pdo);
$st = $pdo->prepare(
"DELETE FROM password_resets
WHERE (used_at IS NOT NULL OR expires_at < NOW())
AND created_at < (NOW() - INTERVAL ? DAY)"
);
$st->execute([$keepDays]);
return $st->rowCount();
}
@@ -1,379 +0,0 @@
<?php
// ─── "Manter sessão iniciada" — login persistente ────────────────────────────
//
// A sessão do PHP morre quando o browser fecha, e o garbage collector apaga-a
// do servidor bem antes disso — `session.gc_maxlifetime` são 24 minutos por
// omissão. Esticar o cookie da sessão para trinta dias não resolve: o cookie
// sobrevive, os dados do lado do servidor não, e o utilizador é expulso na
// mesma, agora sem perceber porquê. Um "lembrar-me" honesto precisa de estado
// próprio, guardado numa tabela, com validade própria.
//
// MECANISMO — selector/validator
//
// O cookie leva dois valores separados por dois pontos: `<selector>:<validator>`.
//
// selector procura a linha. Está em claro na base de dados e é UNIQUE, por
// isso a consulta é um índice, não uma varredura.
// validator prova que o cookie é mesmo aquele. Na base de dados só existe o
// SHA-256 dele.
//
// A razão para os separar é a mesma que faz guardar palavras-passe com hash:
// quem leve um dump da tabela não consegue forjar um cookie a partir dele. Um
// esquema de token único obrigaria a procurar pelo próprio segredo — ou o
// segredo fica em claro na base de dados, ou não há forma de o indexar. A
// comparação é feita com hash_equals(), não com ===, para não dar o resultado
// por temporização.
//
// ROTAÇÃO E DETECÇÃO DE ROUBO
//
// Cada utilização gasta o validator e emite outro. Um cookie roubado vale, por
// isso, até à próxima visita do dono — e quando o dono volta, apresenta um
// validator que já não bate certo com um selector que existe. Isso não acontece
// por acidente: é a assinatura de uma cópia em circulação. Nesse caso apagam-se
// TODOS os tokens da conta, o que corta o atacante e obriga o dono a entrar de
// novo. Preferimos o incómodo à sessão silenciosamente partilhada.
//
// O QUE ISTO NÃO É
//
// Não é uma sessão privilegiada. `auth_remember_active()` diz se a sessão
// actual nasceu de um cookie em vez de uma palavra-passe escrita agora, para
// que uma operação sensível (mudar palavra-passe, apagar conta) possa exigir
// autenticação fresca. Cabe a cada página decidir — este ficheiro só regista
// a proveniência.
require_once __DIR__ . '/functions_auth.php';
require_once __DIR__ . '/functions_client_ip.php';
/**
* Nome do cookie. Prefixado por instalação para que dois sites no mesmo domínio
* (fotografia e formação são ambos *.andmore.pt) não se pisem: o cookie de um
* chegaria ao outro, que procuraria o selector na SUA tabela e não o
* encontraria — um logout sem explicação a cada troca de separador.
*/
function auth_remember_cookie_name(): string {
// Uma constante do site ganha ao .env: é como um site que já tinha cookie
// próprio antes deste módulo (o dance, `dsa_remember`) passa a usá-lo sem
// deitar fora as sessões lembradas que já existiam.
if (defined('AUTH_REMEMBER_COOKIE') && (string) AUTH_REMEMBER_COOKIE !== '') {
return (string) AUTH_REMEMBER_COOKIE;
}
$name = function_exists('env') ? (string) env('AUTH_REMEMBER_COOKIE', '') : '';
return $name !== '' ? $name : 'am_remember';
}
/** Validade, em dias. Trinta é o costume; abaixo de 1 desliga o mecanismo. */
function auth_remember_days(): int {
$d = function_exists('env') ? (int) env('AUTH_REMEMBER_DAYS', 30) : 30;
return $d > 0 ? $d : 30;
}
function auth_remember_ensure_table(PDO $pdo): void {
// Sem chave estrangeira para users: o nome da tabela de contas varia de site
// para site (é o que auth_schema() existe para resolver) e o tipo da coluna
// id também — INT UNSIGNED nuns, BIGINT noutros. Uma FK aqui seria uma
// promessa que este ficheiro não pode cumprir em todo o lado.
$pdo->exec(
"CREATE TABLE IF NOT EXISTS `auth_remember_tokens` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` BIGINT UNSIGNED NOT NULL,
`selector` CHAR(32) NOT NULL,
`validator_hash` CHAR(64) NOT NULL,
`expires_at` DATETIME NOT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`last_used_at` DATETIME NULL,
`last_ip` VARCHAR(45) NULL,
`user_agent` VARCHAR(255) NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_remember_selector` (`selector`),
KEY `idx_remember_user` (`user_id`),
KEY `idx_remember_expires` (`expires_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
}
/** Escreve (ou apaga, com $value = null) o cookie com os parâmetros certos. */
function auth_remember_set_cookie(?string $value, int $expires = 0): void {
if (headers_sent()) return; // nada a fazer, e um warning não ajudaria
$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
setcookie(auth_remember_cookie_name(), (string) $value, [
'expires' => $expires,
'path' => '/',
'secure' => $https,
'httponly' => true,
// Lax e não Strict: com Strict, quem chega ao site por um link de fora
// (um email com a ligação para a plataforma) não traz o cookie e vê o
// formulário de login — exactamente a situação que isto devia evitar.
'samesite' => 'Lax',
]);
}
/** Parte o cookie nos seus dois campos. null se estiver ausente ou deformado. */
function auth_remember_parse_cookie(): ?array {
$raw = (string) ($_COOKIE[auth_remember_cookie_name()] ?? '');
if ($raw === '' || substr_count($raw, ':') !== 1) return null;
[$selector, $validator] = explode(':', $raw, 2);
if (strlen($selector) !== 32 || !ctype_xdigit($selector)) return null;
if (strlen($validator) !== 64 || !ctype_xdigit($validator)) return null;
return ['selector' => $selector, 'validator' => $validator];
}
/**
* Emite um token novo para $userId e manda o cookie.
*
* Chamado depois de uma autenticação bem sucedida, e só quando o utilizador
* pediu para ser lembrado.
*/
function auth_remember_issue(PDO $pdo, int $userId): void {
if ($userId <= 0) return;
auth_remember_ensure_table($pdo);
$selector = bin2hex(random_bytes(16)); // 32 hex
$validator = bin2hex(random_bytes(32)); // 64 hex
$expires = time() + auth_remember_days() * 86400;
try {
$pdo->prepare(
"INSERT INTO `auth_remember_tokens`
(`user_id`, `selector`, `validator_hash`, `expires_at`, `last_ip`, `user_agent`)
VALUES (?, ?, ?, ?, ?, ?)"
)->execute([
$userId,
$selector,
hash('sha256', $validator),
date('Y-m-d H:i:s', $expires),
platform_client_ip(),
mb_substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255),
]);
} catch (Throwable $e) {
// Um login válido nunca deve falhar porque o "lembrar-me" não coube.
error_log('[auth/remember] issue failed: ' . $e->getMessage());
return;
}
auth_remember_set_cookie($selector . ':' . $validator, $expires);
// Barato, e evita que a tabela cresça para sempre numa instalação sem cron.
auth_remember_prune($pdo);
}
/**
* Tenta repor a sessão a partir do cookie.
*
* Devolve a linha do utilizador (e deixa a sessão iniciada) ou null. Não
* redirecciona: quem chama decide o que fazer com o null.
*
* $opts — as duas coisas que um site pode ter de dizer, e que este ficheiro não
* pode adivinhar. Sem elas o comportamento é o de sempre.
*
* 'allow' => fn(PDO $pdo, array $user): bool
* Uma regra do site sobre quem pode entrar, a somar ao auth_active_sql()
* (ex.: o dance recusa uma conta com o email por confirmar fora do prazo).
* false trata-se como a conta ter deixado de existir: caem todos os
* tokens dela, porque a mesma recusa valeria para qualquer outro.
*
* 'login' => fn(PDO $pdo, array $user): void
* O que abre a sessão, em vez do auth_login() nu. Um site que guarda mais
* na sessão do que o contrato partilhado (o dance põe lá a linha da conta
* e os papéis) tem de ser ele a abri-la — senão a sessão reposta pelo
* cookie seria diferente da que o login abre, e o portal via-a vazia.
* Tem de chamar auth_login() (ou algo que o chame).
*/
function auth_remember_resume(PDO $pdo, array $opts = []): ?array {
if (auth_check()) return auth_user(); // já há sessão; nada a repor
$cookie = auth_remember_parse_cookie();
if ($cookie === null) return null;
try {
auth_remember_ensure_table($pdo);
$st = $pdo->prepare(
"SELECT * FROM `auth_remember_tokens` WHERE `selector` = ? LIMIT 1"
);
$st->execute([$cookie['selector']]);
$row = $st->fetch(PDO::FETCH_ASSOC) ?: null;
} catch (Throwable $e) {
error_log('[auth/remember] lookup failed: ' . $e->getMessage());
return null;
}
if ($row === null) {
// Selector desconhecido — token já gasto, expirado e podado, ou lixo.
auth_remember_forget_cookie();
return null;
}
if (!hash_equals((string) $row['validator_hash'], hash('sha256', $cookie['validator']))) {
// Selector certo, validator errado. Ver a nota sobre rotação no topo:
// isto é a assinatura de um cookie em circulação, não um engano.
error_log('[auth/remember] validator mismatch for user ' . (int) $row['user_id']
. ' — a revogar todos os tokens da conta');
auth_remember_forget_user($pdo, (int) $row['user_id']);
auth_remember_forget_cookie();
return null;
}
if (strtotime((string) $row['expires_at']) <= time()) {
auth_remember_delete($pdo, (string) $row['selector']);
auth_remember_forget_cookie();
return null;
}
// Token bom. Carregar a conta pelo mapa de colunas do site — e voltar a
// passar por auth_active_sql(), porque uma conta desactivada depois de o
// cookie ser emitido não pode entrar por esta porta.
$s = auth_schema();
try {
$u = $pdo->prepare(
"SELECT * FROM `{$s['table']}` WHERE `{$s['id']}` = ? AND " . auth_active_sql() . " LIMIT 1"
);
$u->execute([(int) $row['user_id']]);
$user = $u->fetch(PDO::FETCH_ASSOC) ?: null;
} catch (Throwable $e) {
error_log('[auth/remember] user load failed: ' . $e->getMessage());
return null;
}
if ($user !== null && isset($opts['allow']) && is_callable($opts['allow'])) {
try {
if (!($opts['allow'])($pdo, $user)) {
$user = null;
}
} catch (Throwable $e) {
// Falha aberta para o ecrã de login, não para dentro do portal: uma
// regra que não se conseguiu avaliar não deixa ninguém passar.
error_log('[auth/remember] allow hook failed: ' . $e->getMessage());
return null;
}
}
if ($user === null) {
auth_remember_forget_user($pdo, (int) $row['user_id']);
auth_remember_forget_cookie();
return null;
}
try {
if (isset($opts['login']) && is_callable($opts['login'])) {
($opts['login'])($pdo, $user);
} else {
auth_login($pdo, $user);
}
} catch (Throwable $e) {
// Um cookie nunca pode partir a página — nem o site público, que também
// passa por aqui. Quem vier a seguir vê o ecrã de login.
error_log('[auth/remember] login failed: ' . $e->getMessage());
return null;
}
$_SESSION['_auth_remembered'] = true;
// Rotação: o validator que acabou de ser usado deixa de valer.
auth_remember_rotate($pdo, (string) $row['selector']);
return $user;
}
/** Gasta o validator actual e emite outro para o mesmo selector. */
function auth_remember_rotate(PDO $pdo, string $selector): void {
$validator = bin2hex(random_bytes(32));
$expires = time() + auth_remember_days() * 86400;
try {
$pdo->prepare(
"UPDATE `auth_remember_tokens`
SET `validator_hash` = ?, `expires_at` = ?, `last_used_at` = NOW(), `last_ip` = ?
WHERE `selector` = ?"
)->execute([
hash('sha256', $validator),
date('Y-m-d H:i:s', $expires),
platform_client_ip(),
$selector,
]);
} catch (Throwable $e) {
error_log('[auth/remember] rotate failed: ' . $e->getMessage());
return;
}
auth_remember_set_cookie($selector . ':' . $validator, $expires);
}
/** Apaga o cookie do browser, sem tocar na base de dados. */
function auth_remember_forget_cookie(): void {
auth_remember_set_cookie('', time() - 42000);
unset($_COOKIE[auth_remember_cookie_name()]);
}
function auth_remember_delete(PDO $pdo, string $selector): void {
try {
$pdo->prepare("DELETE FROM `auth_remember_tokens` WHERE `selector` = ?")
->execute([$selector]);
} catch (Throwable $e) {
error_log('[auth/remember] delete failed: ' . $e->getMessage());
}
}
/**
* Esquece este dispositivo: apaga o token que o cookie actual identifica e o
* próprio cookie. É o que o logout deve chamar — sair numa máquina não pode
* terminar a sessão do telemóvel.
*/
function auth_remember_clear(PDO $pdo): void {
$cookie = auth_remember_parse_cookie();
if ($cookie !== null) auth_remember_delete($pdo, $cookie['selector']);
auth_remember_forget_cookie();
}
/**
* Esquece TODOS os dispositivos de uma conta. Para a mudança de palavra-passe,
* para a suspeita de roubo, e para o botão "terminar sessão em todo o lado".
*/
function auth_remember_forget_user(PDO $pdo, int $userId): int {
try {
$st = $pdo->prepare("DELETE FROM `auth_remember_tokens` WHERE `user_id` = ?");
$st->execute([$userId]);
return $st->rowCount();
} catch (Throwable $e) {
error_log('[auth/remember] forget_user failed: ' . $e->getMessage());
return 0;
}
}
/** Remove tokens expirados. Idempotente, barato, seguro de chamar sempre. */
function auth_remember_prune(PDO $pdo): void {
try {
$pdo->exec("DELETE FROM `auth_remember_tokens` WHERE `expires_at` <= NOW()");
} catch (Throwable $e) {
error_log('[auth/remember] prune failed: ' . $e->getMessage());
}
}
/**
* A sessão actual veio de um cookie em vez de uma palavra-passe escrita agora?
*
* Quem for pedir uma operação sensível deve exigir autenticação fresca quando
* isto for verdade.
*/
function auth_remember_active(): bool {
return !empty($_SESSION['_auth_remembered']);
}
/** Tokens activos de uma conta — para uma página "as minhas sessões". */
function auth_remember_list(PDO $pdo, int $userId): array {
try {
$st = $pdo->prepare(
"SELECT `id`, `selector`, `created_at`, `last_used_at`, `last_ip`, `user_agent`, `expires_at`
FROM `auth_remember_tokens`
WHERE `user_id` = ? AND `expires_at` > NOW()
ORDER BY `last_used_at` DESC, `created_at` DESC"
);
$st->execute([$userId]);
return $st->fetchAll(PDO::FETCH_ASSOC) ?: [];
} catch (Throwable $e) {
error_log('[auth/remember] list failed: ' . $e->getMessage());
return [];
}
}
-355
View File
@@ -1,355 +0,0 @@
<?php
// ─── Restore helpers (shared) ────────────────────────────────────────────────
//
// Counterpart to functions_backup.php. Restores an artefact produced by
// backups/db_backup.php or backups/backup_maker.php.
//
// Path assumptions (when this file lives at <host>/shared/platform-tools/):
// backup_dir() → <host>/backups/ (source of artefacts)
// BACKUP_FILES_DIR → subtree the files backup archives, relative to <host>
//
// Public API:
// restore_inspect(string $filename): array
// restore_db(PDO, string $filename, array $opt = []): array
// restore_files(string $filename, array $opt = []): array
//
// EVERY restore is destructive. Both restore_db() and restore_files() take a
// safety backup of the CURRENT state first and return its filename in the
// report, so a restore of the wrong artefact is itself recoverable. Pass
// ['safety' => false] only from a caller that has already taken one.
require_once __DIR__ . '/functions_backup.php';
require_once __DIR__ . '/functions_migrations.php'; // migrations_split_sql()
/**
* Abre um artefacto, com a chave de encriptação quando existe.
*
* Os backups passaram a sair encriptados com AES-256 (ver db_backup.php), mas
* os anteriores estão em claro. setPassword() num ZIP não encriptado é
* inofensivo -- é ignorado -- por isso a mesma chamada serve os dois e não é
* preciso adivinhar qual é qual pelo nome do ficheiro.
*/
function restore_open_zip(string $path): ?ZipArchive
{
$zip = new ZipArchive();
if ($zip->open($path) !== true) {
return null;
}
$key = function_exists('platform_backup_key') ? platform_backup_key() : null;
if ($key !== null) {
$zip->setPassword($key);
}
return $zip;
}
function restore_site_root(): string {
return dirname(__DIR__, 2);
}
/**
* Takes a backup of the CURRENT state, synchronously, and returns its filename.
*
* Deliberately does NOT use backup_trigger(): that fires an HTTP self-call at
* base_url(), which (a) is defined in the host app rather than the toolkit, so
* it is absent when the runner is invoked standalone, and (b) requires the host
* to resolve its own public name -- which containerised hosts frequently cannot.
* Including the worker in-process is synchronous, needs no network, and removes
* the race where we polled for the artefact to appear.
*/
function restore_safety_backup(string $type): ?string {
$before = array_column(backup_list(), 'filename');
$script = __DIR__ . '/backups/' . ($type === 'db' ? 'db_backup.php' : 'backup_maker.php');
if (!is_file($script)) return null;
// The workers gate non-CLI invocation on BACKUP_TOKEN. Satisfy that guard
// explicitly for the in-process call, then restore the superglobal.
$prevGet = $_GET;
$_GET['token'] = defined('BACKUP_TOKEN') ? BACKUP_TOKEN : '';
ob_start();
try {
include $script; // echoes progress; we discard it
} catch (Throwable $e) {
ob_end_clean();
$_GET = $prevGet;
return null;
}
ob_end_clean();
$_GET = $prevGet;
clearstatcache();
$new = array_diff(array_column(backup_list(), 'filename'), $before);
return $new ? (string) reset($new) : null;
}
/**
* Validates an artefact and reports what restoring it would do. Never mutates
* anything — safe to call from a confirmation screen.
*/
function restore_inspect(string $filename): array {
$out = ['ok' => false, 'filename' => $filename, 'type' => null, 'entries' => 0, 'error' => null];
// Reuse the backup naming contract rather than inventing a second one: this
// is what stops ../../ and absolute paths reaching ZipArchive::open().
if (!backup_filename_is_safe($filename)) {
$out['error'] = 'Nome de ficheiro inválido.';
return $out;
}
$path = backup_dir() . '/' . $filename;
if (!is_file($path)) {
$out['error'] = 'Ficheiro não encontrado.';
return $out;
}
$out['path'] = $path;
$out['size_bytes'] = filesize($path) ?: 0;
$out['size_human'] = backup_human_size($out['size_bytes']);
// Type comes from the name, matching how backup_list() labels them.
$out['type'] = str_starts_with($filename, 'db_backup_') ? 'db' : 'files';
if (!class_exists('ZipArchive')) {
$out['error'] = 'Extensão zip indisponível.';
return $out;
}
$zip = restore_open_zip($path);
if ($zip === null) {
$out['error'] = 'ZIP ilegível ou corrompido.';
return $out;
}
$entries = [];
$unsafe = [];
// BACKUP_FILES_DIR pode listar várias pastas (ver backup_maker.php), por
// isso o artefacto traz entradas com prefixos diferentes. Validar contra um
// único prefixo marcaria TODAS as outras como inseguras e o restore
// recusava um backup perfeitamente válido.
$prefixes = array_values(array_filter(array_map(
fn($d) => trim($d, " \t/"),
explode(',', defined('BACKUP_FILES_DIR') ? BACKUP_FILES_DIR : 'uploads')
), fn($d) => $d !== ''));
if (!$prefixes) { $prefixes = ['uploads']; }
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = (string) $zip->getNameIndex($i);
$entries[] = $name;
// Zip-slip: an entry may not escape the extraction root. Absolute paths,
// parent traversal, and Windows drive/UNC prefixes are all rejected.
if (str_starts_with($name, '/')
|| str_contains($name, '..')
|| preg_match('#^[A-Za-z]:#', $name)
|| str_starts_with($name, '\\\\')) {
$unsafe[] = $name;
continue;
}
// A files artefact may only contain the archived subtree. This keeps an
// extraction from writing anywhere else in the repo -- which matters
// doubly when the source tree is mounted read-only.
if ($out['type'] === 'files' && $name !== '') {
$inScope = false;
foreach ($prefixes as $pfx) {
if ($name === $pfx || str_starts_with($name, $pfx . '/')) { $inScope = true; break; }
}
if (!$inScope) $unsafe[] = $name;
}
}
if ($out['type'] === 'db') {
$sqlEntries = array_values(array_filter($entries, fn($e) => str_ends_with(strtolower($e), '.sql')));
if (count($sqlEntries) !== 1) {
$zip->close();
$out['error'] = 'Esperava exactamente um .sql no ZIP, encontrei ' . count($sqlEntries) . '.';
return $out;
}
$out['sql_entry'] = $sqlEntries[0];
$st = $zip->statName($sqlEntries[0]);
$out['sql_bytes'] = $st['size'] ?? 0;
}
$zip->close();
$out['entries'] = count($entries);
if ($unsafe) {
$out['error'] = 'ZIP contém caminhos inseguros: ' . implode(', ', array_slice($unsafe, 0, 5));
$out['unsafe'] = $unsafe;
return $out;
}
$out['ok'] = true;
return $out;
}
/**
* Restores a database artefact. Replaces the current schema and data.
*
* $opt:
* safety bool take a safety dump first (default true)
* drop_extra_tables bool drop tables absent from the dump (default true)
*/
function restore_db(PDO $pdo, string $filename, array $opt = []): array {
$safety = $opt['safety'] ?? true;
$dropExtra = $opt['drop_extra_tables'] ?? true;
$report = ['ok' => false, 'filename' => $filename, 'safety_backup' => null,
'statements' => 0, 'applied' => 0, 'dropped_extra' => [], 'error' => null];
$info = restore_inspect($filename);
if (!$info['ok']) { $report['error'] = $info['error']; return $report; }
if ($info['type'] !== 'db') { $report['error'] = 'Não é um backup de base de dados.'; return $report; }
// Safety first: capture the state we are about to destroy.
if ($safety) {
$report['safety_backup'] = restore_safety_backup('db');
if ($report['safety_backup'] === null) {
$report['error'] = 'Backup de segurança falhou — restauro abortado.';
return $report;
}
}
$zip = restore_open_zip($info['path']);
if ($zip === null) { $report['error'] = 'ZIP ilegível.'; return $report; }
$sql = $zip->getFromName($info['sql_entry']);
$zip->close();
if ($sql === false || $sql === '') { $report['error'] = 'Dump vazio.'; return $report; }
$tablesBefore = restore_current_tables($pdo);
// Reuse the migration splitter: it is string-, backtick- and comment-aware,
// so it will not sever a statement inside a quoted value.
$statements = migrations_split_sql($sql);
$report['statements'] = count($statements);
$pdo->exec('SET FOREIGN_KEY_CHECKS = 0');
try {
foreach ($statements as $st) {
if (trim($st) === '') continue;
$pdo->exec($st);
$report['applied']++;
}
// The dump DROPs and CREATEs everything it knows about, but a table that
// exists here and not in the dump would survive and leave the schema in
// a state the artefact never described.
if ($dropExtra) {
$after = restore_current_tables($pdo);
$inDump = [];
if (preg_match_all('/DROP TABLE IF EXISTS `([^`]+)`/i', $sql, $m)) $inDump = $m[1];
if (preg_match_all('/CREATE TABLE `([^`]+)`/i', $sql, $m2)) $inDump = array_merge($inDump, $m2[1]);
$inDump = array_unique($inDump);
foreach ($after as $t) {
if (!in_array($t, $inDump, true)) {
$pdo->exec('DROP TABLE `' . str_replace('`', '``', $t) . '`');
$report['dropped_extra'][] = $t;
}
}
}
} catch (Throwable $e) {
$report['error'] = 'Erro no statement #' . ($report['applied'] + 1) . ': ' . $e->getMessage();
$pdo->exec('SET FOREIGN_KEY_CHECKS = 1');
return $report;
}
// Always restore FK enforcement -- the dump switches it off and never back on.
$pdo->exec('SET FOREIGN_KEY_CHECKS = 1');
$report['tables_before'] = count($tablesBefore);
$report['tables_after'] = count(restore_current_tables($pdo));
$report['ok'] = true;
return $report;
}
/** Table names in the current schema. Buffered so no cursor is left open. */
function restore_current_tables(PDO $pdo): array {
$st = $pdo->query("SHOW FULL TABLES WHERE Table_type = 'BASE TABLE'");
$rows = $st->fetchAll(PDO::FETCH_COLUMN); // fetchAll, not fetch: leaving an
$st->closeCursor(); // unbuffered cursor open makes the
return $rows ?: []; // next exec() fail with error 2014.
}
/**
* Restores a files artefact into BACKUP_FILES_DIR.
*
* $opt:
* safety bool archive the current subtree first (default true)
* purge bool delete files absent from the artefact (default false)
*/
function restore_files(string $filename, array $opt = []): array {
$safety = $opt['safety'] ?? true;
$purge = $opt['purge'] ?? false;
$report = ['ok' => false, 'filename' => $filename, 'safety_backup' => null,
'extracted' => 0, 'purged' => 0, 'error' => null];
$info = restore_inspect($filename);
if (!$info['ok']) { $report['error'] = $info['error']; return $report; }
if ($info['type'] !== 'files') { $report['error'] = 'Não é um backup de ficheiros.'; return $report; }
$root = restore_site_root();
$prefix = trim(defined('BACKUP_FILES_DIR') ? BACKUP_FILES_DIR : 'uploads', '/');
// Empty BACKUP_FILES_DIR means the host keeps no local files (see
// backup_maker.php). Without this guard $target would collapse to the
// repository root and a files artefact would be extracted over the source.
if ($prefix === '') {
$report['error'] = 'Restauro de ficheiros desactivado (BACKUP_FILES_DIR vazio) — este host não guarda ficheiros localmente.';
return $report;
}
$target = $root . '/' . $prefix;
if (!is_dir($target)) { $report['error'] = "Pasta de destino não existe: $target"; return $report; }
if (!is_writable($target)){ $report['error'] = "Pasta de destino não é escrevível: $target"; return $report; }
if ($safety) {
$report['safety_backup'] = restore_safety_backup('files');
if ($report['safety_backup'] === null) {
$report['error'] = 'Backup de segurança falhou — restauro abortado.';
return $report;
}
}
$zip = restore_open_zip($info['path']);
if ($zip === null) { $report['error'] = 'ZIP ilegível.'; return $report; }
$seen = [];
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = (string) $zip->getNameIndex($i);
if ($name === '' || str_ends_with($name, '/')) continue;
// restore_inspect() already rejected unsafe names, but re-check here:
// the guard must sit immediately before the write, not only in a
// separate function a future caller might skip.
if (str_contains($name, '..') || str_starts_with($name, '/')
|| !str_starts_with($name, $prefix . '/')) {
continue;
}
$dest = $root . '/' . $name;
$dir = dirname($dest);
if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) continue;
$data = $zip->getFromIndex($i);
if ($data === false) continue;
if (@file_put_contents($dest, $data) !== false) {
$report['extracted']++;
$seen[] = $dest;
}
}
$zip->close();
// Optional exact-mirror: remove anything the artefact does not contain.
// Off by default -- a partial artefact would otherwise delete live uploads.
if ($purge) {
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($target, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($it as $f) {
if ($f->isFile() && !in_array($f->getPathname(), $seen, true)
&& basename($f->getPathname()) !== '.htaccess') {
if (@unlink($f->getPathname())) $report['purged']++;
}
}
}
$report['ok'] = true;
return $report;
}
-105
View File
@@ -1,105 +0,0 @@
<?php
// ─── Rotas do front controller (router.php) ─────────────────────────────────
//
// A lista fechada de páginas partilhadas que o platform.php de um site serve.
// Uma ferramenta nova entra aqui e aparece em todos os sites que usam o router
// no próximo update do submódulo.
//
// file a página, relativa a este directório
// needs_db sem base de dados (montra estática) responde 503 explicado
// backup_helpers carrega os helpers de cópias antes (o que os stubs faziam
// com require config/functions_backup.php)
function platform_routes(): array {
return [
'index' => ['file' => 'index.php'],
'platform_update' => ['file' => 'platform_update.php'],
'backups' => ['file' => 'backups.php', 'needs_db' => true, 'backup_helpers' => true],
'restore' => ['file' => 'restore.php', 'needs_db' => true, 'backup_helpers' => true],
'ip_bans' => ['file' => 'ip_bans.php', 'needs_db' => true],
'geo' => ['file' => 'geo.php'],
'tracking' => ['file' => 'tracking.php', 'needs_db' => true],
'hub_integration' => ['file' => 'hub_integration.php'],
'smtp_status' => ['file' => 'smtp_status.php', 'needs_db' => true],
'audit' => ['file' => 'audit.php', 'needs_db' => true],
];
}
/**
* Os endpoints partilhados que o public/api/platform.php de um site serve
* (api/router.php). Cada um continua a autenticar-se a si próprio — chave de
* saúde, chave de operações, HMAC, sessão + CSRF —; o router só despacha.
*
* Os URLs antigos (api/health.php, api/hub_ops.php, …) ficam onde estão: os
* webhooks do Gitea e os caminhos que o hub guarda por plataforma apontam para
* eles. Um endpoint NOVO entra só aqui e chega a todos os sites sem stub.
*/
function platform_api_routes(): array {
return [
'health' => 'api/hub_health.php',
'hub_ops' => 'api/hub_ops.php',
'hub_pair' => 'api/hub_pair.php',
'feedback' => 'api/feedback.php',
'agent' => 'api/agent.php',
'auto_update' => 'api/auto_update.php',
'backup_trigger' => 'api/backup_trigger.php',
'backup_download' => 'api/backup_download.php',
];
}
/** O site declarou `'router' => true` no platform_connect() (tem o public/platform.php). */
function platform_router_enabled(): bool {
return function_exists('platform_config') && !empty(platform_config()['router']);
}
/**
* URLs sem `.php`? Só quando o site o declara (`'clean_urls' => true`), porque
* precisa da regra do .htaccess que serve `/platform` a partir de
* `platform.php` — sem ela, o URL limpo dava 404.
*/
function platform_clean_urls(): bool {
return function_exists('platform_config') && !empty(platform_config()['clean_urls']);
}
/** URL de uma ferramenta pelo router; $query sem o `?` (p. ex. 'tab=db'). */
function platform_route_url(string $tool, string $query = ''): string {
$script = platform_clean_urls() ? 'platform' : 'platform.php';
$path = $script . '?tool=' . rawurlencode($tool) . ($query !== '' ? '&' . ltrim($query, '?&') : '');
return platform_route_absolute_url($path);
}
/** URL de um endpoint pelo router da API. */
function platform_api_url(string $endpoint, string $query = ''): string {
$script = platform_clean_urls() ? 'api/platform' : 'api/platform.php';
$path = $script . '?endpoint=' . rawurlencode($endpoint) . ($query !== '' ? '&' . ltrim($query, '?&') : '');
return platform_route_absolute_url($path);
}
/** Um caminho relativo ao base_url passa a absoluto; um URL ou caminho absoluto fica. */
function platform_route_absolute_url(string $url): string {
if ($url === '' || preg_match('#^([a-z][a-z0-9+.-]*:)?//#i', $url) || $url[0] === '/') return $url;
if (function_exists('base_url')) return (string) base_url($url);
if (function_exists('hub_base_url')) return hub_base_url($url);
return '/' . $url;
}
/**
* As ferramentas que este site serve, com o URL de cada uma — anunciadas no
* health para o hub poder ligar para a página local. [] sem router: os stubs
* antigos variam de site para site, e anunciar um URL que dá 404 é pior do que
* não anunciar.
*/
function platform_routes_announced(): array {
if (!platform_router_enabled()) return [];
$out = [];
foreach (array_keys(platform_routes()) as $tool) $out[$tool] = platform_route_url($tool);
return $out;
}
/** O mesmo para os endpoints, com a mesma condição. */
function platform_api_routes_announced(): array {
if (!platform_router_enabled()) return [];
$out = [];
foreach (array_keys(platform_api_routes()) as $endpoint) $out[$endpoint] = platform_api_url($endpoint);
return $out;
}
-159
View File
@@ -1,159 +0,0 @@
<?php
// ─── Schema export (shared) ──────────────────────────────────────────────────
//
// Generates a deterministic, structure-only SQL dump of the host database into
// <host_repo>/database/schema.sql, suitable for committing to git.
//
// "Deterministic" is the whole point: no generation timestamp, no server-version
// header, AUTO_INCREMENT counters stripped, view DEFINERs removed, and tables
// emitted in alphabetical order. Re-running against an unchanged schema produces
// a byte-identical file — so a `git diff` on schema.sql is a true changelog of
// structural changes, not export noise. (Contrast the hand-made phpMyAdmin dumps,
// whose "Generation Time" line and AUTO_INCREMENT counters churn on every export.)
//
// Built entirely over the existing PDO connection (SHOW FULL TABLES /
// SHOW CREATE TABLE / SHOW CREATE VIEW) — no mysqldump binary and no shell
// access required. That matters here: the platform hosts have no usable PHP or
// mysql CLI, so this runs web-triggered from the platform update page.
//
// This is schema only. Row data is out of scope by design — that's what the
// backup API (functions_backup.php) is for.
//
// Public API:
// schema_dump_path(): string — absolute path to schema.sql
// schema_build(PDO): string — full SQL text (does not write)
// schema_dump_to_file(PDO): array — {ok, path?, bytes?, tables?, views?, error?}
/**
* Resolve the target schema.sql path. Reuses the migrations dir resolution so
* the file lands next to migration_*.sql in the host's database/ folder.
*/
function schema_dump_path(): string {
if (function_exists('migrations_dir')) {
$dir = migrations_dir();
} else {
$dir = $GLOBALS['MIGRATIONS_DIR'] ?? (dirname(__DIR__, 2) . '/database');
$dir = rtrim($dir, '/');
}
return $dir . '/schema.sql';
}
/** Strip the AUTO_INCREMENT counter so a changed row count isn't a schema diff. */
function schema_normalize_table_ddl(string $sql): string {
return preg_replace('/\s+AUTO_INCREMENT=\d+\b/', '', $sql);
}
/**
* Strip the per-host DEFINER (and its SQL SECURITY clause) from a view DDL.
* The definer references a DB user that differs between dev/live and between
* tenants, so leaving it in would make the dump non-deterministic and break
* replay on a host where that user does not exist.
*/
function schema_normalize_view_ddl(string $sql): string {
$sql = preg_replace('/\sDEFINER=`[^`]*`@`[^`]*`/', '', $sql, 1);
$sql = preg_replace('/\sSQL SECURITY DEFINER/', '', $sql, 1);
return $sql;
}
/** Quote an identifier for interpolation into a SHOW CREATE statement. */
function schema_quote_ident(string $name): string {
return '`' . str_replace('`', '``', $name) . '`';
}
/**
* Build the full schema dump as a string. Throws PDOException on query failure
* (the caller turns that into a flash message).
*/
function schema_build(PDO $pdo): string {
$dbName = (string) $pdo->query('SELECT DATABASE()')->fetchColumn();
// Split base tables from views: views must be (re)created after the tables
// they reference, and SHOW CREATE differs per object type.
$tables = [];
$views = [];
foreach ($pdo->query('SHOW FULL TABLES')->fetchAll(PDO::FETCH_NUM) as $row) {
[$name, $type] = $row;
if (strcasecmp((string) $type, 'VIEW') === 0) {
$views[] = $name;
} else {
$tables[] = $name;
}
}
sort($tables, SORT_STRING);
sort($views, SORT_STRING);
$out = [];
$out[] = '-- Schema dump for database `' . $dbName . '`';
$out[] = '-- Structure only. Generated by platform-tools (functions_schema.php).';
$out[] = '-- Deterministic: no timestamp, AUTO_INCREMENT stripped, objects sorted.';
$out[] = '-- Do NOT edit by hand — re-run the export from the platform update page.';
$out[] = '';
$out[] = 'SET FOREIGN_KEY_CHECKS = 0;';
$out[] = '';
foreach ($tables as $t) {
$create = $pdo->query('SHOW CREATE TABLE ' . schema_quote_ident($t))
->fetch(PDO::FETCH_NUM)[1] ?? '';
$create = schema_normalize_table_ddl($create);
$out[] = '-- -----------------------------------------------------------';
$out[] = '-- Table `' . $t . '`';
$out[] = '-- -----------------------------------------------------------';
$out[] = 'DROP TABLE IF EXISTS ' . schema_quote_ident($t) . ';';
$out[] = $create . ';';
$out[] = '';
}
foreach ($views as $v) {
$create = $pdo->query('SHOW CREATE VIEW ' . schema_quote_ident($v))
->fetch(PDO::FETCH_NUM)[1] ?? '';
$create = schema_normalize_view_ddl($create);
$out[] = '-- -----------------------------------------------------------';
$out[] = '-- View `' . $v . '`';
$out[] = '-- -----------------------------------------------------------';
$out[] = 'DROP VIEW IF EXISTS ' . schema_quote_ident($v) . ';';
$out[] = $create . ';';
$out[] = '';
}
$out[] = 'SET FOREIGN_KEY_CHECKS = 1;';
$out[] = '';
return implode("\n", $out);
}
/**
* Build the dump and write it atomically to schema.sql. Never throws — returns
* a result array the view can render directly.
*/
function schema_dump_to_file(PDO $pdo): array {
try {
$sql = schema_build($pdo);
} catch (Throwable $e) {
return ['ok' => false, 'error' => 'Erro ao ler o esquema: ' . $e->getMessage()];
}
$path = schema_dump_path();
$dir = dirname($path);
if (!is_dir($dir) && !@mkdir($dir, 0775, true)) {
return ['ok' => false, 'error' => 'Não foi possível criar a pasta ' . $dir . '.'];
}
// Write to a temp file then rename: a crash mid-write can't leave a
// truncated schema.sql staged for commit.
$tmp = $path . '.tmp';
if (@file_put_contents($tmp, $sql, LOCK_EX) === false) {
return ['ok' => false, 'error' => 'Falha ao escrever ' . basename($path) . ' (permissões?).'];
}
if (!@rename($tmp, $path)) {
@unlink($tmp);
return ['ok' => false, 'error' => 'Falha ao substituir ' . basename($path) . '.'];
}
return [
'ok' => true,
'path' => $path,
'bytes' => strlen($sql),
'tables' => preg_match_all('/^CREATE TABLE /m', $sql),
'views' => preg_match_all('/^CREATE(?: ALGORITHM=\S+)? VIEW /m', $sql),
];
}
@@ -1,398 +0,0 @@
<?php
// ─── Eventos de segurança e banimentos da frota, via hub ────────────────────
//
// Cada plataforma regista localmente o que as defesas recusam (banimentos,
// infracções, logins falhados, CSRF, tecto de envio) numa caixa de saída, e
// envia-a em lote ao hub: POST <hub>/api/security_events.php, autenticado com a
// MESMA chave com que o hub sonda esta plataforma (X-Hub-Key). A resposta traz
// os banimentos activos da frota, que entram no ip_bans local com
// source='fleet' — um IP banido na dance passa a ser recusado na fotografia.
//
// Porquê uma caixa de saída e não um POST por evento: um ataque gera centenas
// de recusas por minuto. Um pedido HTTP por recusa punha cada pedido do
// atacante à espera da rede e transformava o ataque a um site num ataque ao
// hub. Em lote, mil recusas são um pedido.
//
// Quando é que o lote sai:
// • no fim de um pedido qualquer (platform_ip_guard() regista-o), no máximo a
// cada HUB_SECURITY_FLUSH_EVERY s se há eventos e HUB_SECURITY_SYNC_EVERY s
// se não há — sem eventos continua a sincronizar, é assim que os
// banimentos dos OUTROS sites chegam aqui;
// • pelo cron: bin/hub-security-sync.php, que esvazia a caixa toda.
//
// Nada disto bloqueia nem rebenta. Sem hub emparelhado não se regista nada
// (senão a caixa crescia para sempre); com o hub em baixo os eventos esperam,
// com tecto, e os banimentos locais funcionam exactamente como antes.
//
// Origem de cada decisão sobre banimentos da frota:
// • um banimento chega ao hub só a partir do site que o decidiu — as cópias
// (source='fleet') nunca são reenviadas, senão ecoavam em ciclo;
// • levantar um banimento em QUALQUER site levanta-o na frota: as cópias
// desaparecem de todos na sincronização seguinte. Sem isto, um unban local
// de uma cópia era desfeito cinco minutos depois. O site de origem mantém
// o seu próprio banimento até expirar ou ser levantado lá.
require_once __DIR__ . '/functions_client_ip.php';
const HUB_SECURITY_FLUSH_EVERY = 30;
const HUB_SECURITY_SYNC_EVERY = 300;
const HUB_SECURITY_BATCH = 200;
// Tecto da caixa com o hub em baixo. Os banimentos nunca são descartados; o
// resto sai do mais antigo para o mais recente.
const HUB_SECURITY_OUTBOX_MAX = 5000;
function hub_security_enabled(): bool {
static $on = null;
if ($on !== null) return $on;
try {
if (!function_exists('hub_url')) require_once __DIR__ . '/functions_hub.php';
$on = hub_url() !== '' && hub_api_key() !== '';
} catch (Throwable $e) {
$on = false;
}
return $on;
}
/**
* Carimbos em ficheiro, para o fim de cada pedido decidir «há sincronização a
* fazer?» com dois stat() e sem tocar na base de dados.
*/
function hub_security_stamp(string $name): string {
return rtrim(sys_get_temp_dir(), '/\\') . '/hub-security-'
. substr(md5(dirname(__DIR__, 2)), 0, 12) . '-' . $name;
}
function hub_security_ensure_table(PDO $pdo): void {
static $done = false;
if ($done) return;
$done = true;
// occurred_at em epoch e não DATETIME: o hub corre noutro contentor, e um
// DATETIME sem fuso lido do outro lado seria uma hora errada em metade do ano.
$pdo->exec(
"CREATE TABLE IF NOT EXISTS `hub_security_outbox` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
`kind` VARCHAR(40) NOT NULL,
`ip` VARCHAR(45) NULL,
`detail` VARCHAR(190) NULL,
`data` TEXT NULL,
`occurred_at` INT UNSIGNED NOT NULL,
`attempts` INT NOT NULL DEFAULT 0,
KEY `idx_hso_kind` (`kind`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
}
/**
* Destino alternativo dos eventos: fn(?PDO, kind, ?detail, ?ip, array data).
* É o hub que se regista aqui — ele é o registo da frota, e mandar os seus
* próprios eventos a si mesmo por HTTP exigia emparelhá-lo consigo próprio.
*/
function platform_security_event_sink(?callable $sink = null): ?callable {
static $current = null;
if ($sink !== null) $current = $sink;
return $current;
}
/**
* Regista um evento para o hub. Nunca lança e nunca espera pela rede.
*
* $kind curto e estável (ip_ban, ip_unban, offence, login_failed,
* login_throttled, csrf_failed, mail_rate_limited): é por ele que o hub agrupa.
*/
function platform_security_event(?PDO $pdo, string $kind, ?string $detail = null,
?string $ip = null, array $data = []): void {
try {
$ip ??= platform_client_ip();
$ip = ($ip !== null && $ip !== '') ? $ip : null;
// Os detalhes das infracções trazem às vezes o email de quem submeteu
// (o antispam usa-o como assunto). Não tem de sair do site para o hub.
if ($detail !== null) {
$detail = preg_replace_callback('/[^\s@<>"\'(),;:]+@[^\s@<>"\'(),;:]+\.[a-z]{2,}/i',
static fn(array $m): string => hub_security_mask_identifier($m[0]), $detail);
}
$sink = platform_security_event_sink();
if ($sink !== null) {
$sink($pdo ?? (($GLOBALS['pdo'] ?? null) instanceof PDO ? $GLOBALS['pdo'] : null), $kind, $detail, $ip, $data);
return;
}
if (!hub_security_enabled()) return;
$pdo ??= hub_pdo();
if (!$pdo instanceof PDO) return;
hub_security_ensure_table($pdo);
$pdo->prepare(
'INSERT INTO hub_security_outbox (kind, ip, detail, data, occurred_at) VALUES (?, ?, ?, ?, ?)'
)->execute([
mb_substr($kind, 0, 40),
$ip,
$detail === null ? null : mb_substr($detail, 0, 190),
$data ? json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null,
time(),
]);
@touch(hub_security_stamp('dirty'));
} catch (Throwable $e) {
error_log('[hub-security] evento não registado: ' . $e->getMessage());
}
}
/** «a***@exemplo.pt» — chega para ver um ataque a uma conta sem mandar o email ao hub. */
function hub_security_mask_identifier(string $id): string {
$id = mb_strtolower(trim($id));
$at = strrpos($id, '@');
if ($at === false) return mb_substr($id, 0, 1) . '***';
return mb_substr($id, 0, 1) . '***' . substr($id, $at);
}
/**
* Chamado cedo em cada pedido (pelo platform_ip_guard). Só agenda: o trabalho
* corre no fim, depois de a resposta sair quando o SAPI o permite.
*/
function hub_security_maybe_sync(PDO $pdo): void {
static $registered = false;
if ($registered) return;
$registered = true;
register_shutdown_function(static function () use ($pdo): void {
try {
if (!hub_security_due() || !hub_security_enabled()) return;
if (function_exists('fastcgi_finish_request')) {
@fastcgi_finish_request();
}
hub_security_sync($pdo, 1);
} catch (Throwable $e) {
error_log('[hub-security] sincronização falhou: ' . $e->getMessage());
}
});
}
function hub_security_due(): bool {
$attempt = hub_security_stamp('attempt');
$last = @filemtime($attempt) ?: 0;
$every = is_file(hub_security_stamp('dirty')) && !is_file(hub_security_stamp('failing'))
? HUB_SECURITY_FLUSH_EVERY
: HUB_SECURITY_SYNC_EVERY;
if (time() - $last < $every) return false;
// Marcar a tentativa ANTES de a fazer: os pedidos concorrentes desistem, e
// um hub em baixo não é tentado a cada pedido. Sem pasta temporária
// gravável não há como saber, e fica tudo para o cron.
return @touch($attempt);
}
/**
* Envia a caixa de saída e aplica a lista da frota que vem na resposta.
* $maxBatches: 1 no fim de um pedido, mais no cron.
*/
function hub_security_sync(PDO $pdo, int $maxBatches = 25): array {
if (!hub_security_enabled()) return ['ok' => false, 'error' => 'plataforma não emparelhada com o hub'];
if (!function_exists('curl_init')) return ['ok' => false, 'error' => 'cURL indisponível'];
hub_security_ensure_table($pdo);
@touch(hub_security_stamp('attempt'));
// Um só sincronizador de cada vez, entre todos os processos PHP do site. O
// nome leva a base de dados: os locks do MariaDB são do servidor inteiro, e a
// frota partilha o servidor — sem isto a sincronização de um site fazia os
// outros desistir.
if ((int) $pdo->query("SELECT GET_LOCK(CONCAT('hub_security_sync:', DATABASE()), 0)")->fetchColumn() !== 1) {
return ['ok' => true, 'skipped' => 'já a correr'];
}
$out = ['ok' => true, 'sent' => 0, 'fleet' => null, 'error' => null];
try {
hub_security_trim_outbox($pdo);
for ($i = 0; $i < max(1, $maxBatches); $i++) {
$rows = $pdo->query(
'SELECT * FROM hub_security_outbox ORDER BY id LIMIT ' . HUB_SECURITY_BATCH
)->fetchAll(PDO::FETCH_ASSOC);
$events = [];
foreach ($rows as $r) {
$events[] = [
'kind' => $r['kind'],
'ip' => $r['ip'],
'detail' => $r['detail'],
'data' => $r['data'] !== null ? json_decode((string) $r['data'], true) : null,
'ts' => (int) $r['occurred_at'],
];
}
$res = hub_security_post(['events' => $events]);
if (!$res['ok']) {
if ($rows) {
$ids = implode(',', array_map('intval', array_column($rows, 'id')));
$pdo->exec("UPDATE hub_security_outbox SET attempts = attempts + 1 WHERE id IN ($ids)");
}
@touch(hub_security_stamp('failing'));
$out['ok'] = false;
$out['error'] = $res['error'];
error_log('[hub-security] envio falhou: ' . $res['error']);
break;
}
@unlink(hub_security_stamp('failing'));
if ($rows) {
$ids = implode(',', array_map('intval', array_column($rows, 'id')));
$pdo->exec("DELETE FROM hub_security_outbox WHERE id IN ($ids)");
$out['sent'] += count($rows);
}
if (is_array($res['data']['fleet'] ?? null)) {
$out['fleet'] = hub_security_apply_fleet($pdo, $res['data']['fleet']);
}
@touch(hub_security_stamp('synced'));
if (count($rows) < HUB_SECURITY_BATCH) {
// Contar de novo: um evento que entrou durante o envio tem de
// manter a marca, senão esperava pela sincronização lenta.
if ((int) $pdo->query('SELECT COUNT(*) FROM hub_security_outbox')->fetchColumn() === 0) {
@unlink(hub_security_stamp('dirty'));
}
break;
}
}
} finally {
$pdo->query("SELECT RELEASE_LOCK(CONCAT('hub_security_sync:', DATABASE()))");
}
return $out;
}
function hub_security_trim_outbox(PDO $pdo): void {
$n = (int) $pdo->query('SELECT COUNT(*) FROM hub_security_outbox')->fetchColumn();
if ($n <= HUB_SECURITY_OUTBOX_MAX) return;
$excess = $n - HUB_SECURITY_OUTBOX_MAX;
$pdo->exec(
"DELETE FROM hub_security_outbox WHERE kind NOT IN ('ip_ban', 'ip_unban')
ORDER BY id LIMIT " . (int) $excess
);
error_log("[hub-security] caixa de saída acima do tecto — $excess eventos antigos descartados");
}
/** POST ao hub. ['ok'=>bool, 'data'=>array|null, 'error'=>string]. */
function hub_security_post(array $payload): array {
$ch = curl_init(hub_url() . '/api/security_events.php');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
// Curto: no pior caso isto corre no fim de um pedido de um visitante.
CURLOPT_CONNECTTIMEOUT => 2,
CURLOPT_TIMEOUT => 5,
// X-Hub-Key e não Authorization: o proxy come o Authorization (ver functions_hub.php).
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Accept: application/json',
'X-Hub-Key: ' . hub_api_key()],
CURLOPT_USERAGENT => 'andmore-platform-security/1.0',
]);
$raw = curl_exec($ch);
$http = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($raw === false || $err !== '') return ['ok' => false, 'data' => null, 'error' => $err ?: 'sem resposta'];
$data = json_decode((string) $raw, true);
if (!is_array($data)) return ['ok' => false, 'data' => null, 'error' => "resposta não-JSON (HTTP $http)"];
if (empty($data['ok'])) return ['ok' => false, 'data' => $data, 'error' => (string) ($data['error'] ?? "HTTP $http")];
return ['ok' => true, 'data' => $data, 'error' => ''];
}
/**
* Aplica a lista da frota ao ip_bans local.
*
* Um banimento próprio activo ganha sempre à cópia — foi decidido aqui, com o
* motivo daqui. As cópias que o hub deixou de mandar (levantadas ou expiradas)
* saem, mas só quando a lista vem completa: uma lista truncada não diz nada
* sobre o que ficou de fora.
*/
function hub_security_apply_fleet(PDO $pdo, array $fleet): array {
require_once __DIR__ . '/functions_ip_ban.php';
platform_ip_ban_ensure_tables($pdo);
$local = [];
foreach ($pdo->query(
"SELECT ip, source, reason, expires_at,
(expires_at IS NULL OR expires_at > NOW()) AS active
FROM ip_bans
WHERE source = 'fleet' OR expires_at IS NULL OR expires_at > NOW()"
) as $r) {
$local[$r['ip']] = $r;
}
$upsert = $pdo->prepare(
"INSERT INTO ip_bans (ip, reason, source, expires_at, created_by) VALUES (?, ?, 'fleet', ?, NULL)
ON DUPLICATE KEY UPDATE reason = VALUES(reason), source = 'fleet',
expires_at = VALUES(expires_at), created_at = NOW(), created_by = NULL"
);
$seen = [];
$applied = 0;
foreach ((array) ($fleet['bans'] ?? []) as $b) {
$ip = trim((string) ($b['ip'] ?? ''));
if (!filter_var($ip, FILTER_VALIDATE_IP) || platform_ip_ban_exempt($ip)) continue;
$seen[$ip] = true;
$cur = $local[$ip] ?? null;
if ($cur !== null && $cur['source'] !== 'fleet' && (int) $cur['active'] === 1) continue;
$ts = isset($b['expires_ts']) ? (int) $b['expires_ts'] : null;
$expires = $ts ? date('Y-m-d H:i:s', $ts) : null;
$reason = mb_substr('frota · ' . (string) ($b['origin'] ?? '?') . ' · ' . (string) ($b['reason'] ?? ''), 0, 190);
if ($cur !== null && $cur['source'] === 'fleet'
&& $cur['reason'] === $reason && $cur['expires_at'] === $expires) {
continue;
}
try {
$upsert->execute([$ip, $reason, $expires]);
$applied++;
} catch (Throwable $e) {
error_log('[hub-security] cópia de banimento não aplicada: ' . $e->getMessage());
}
}
$removed = 0;
if (!empty($fleet['complete'])) {
$del = $pdo->prepare("DELETE FROM ip_bans WHERE ip = ? AND source = 'fleet'");
foreach ($local as $ip => $r) {
if ($r['source'] === 'fleet' && !isset($seen[$ip])) {
$del->execute([$ip]);
$removed++;
}
}
}
return ['received' => count($seen), 'applied' => $applied, 'removed' => $removed];
}
/**
* `checks.security` para o health do hub. A pergunta é «o envio está a
* funcionar?»: eventos presos há mais de uma hora querem dizer que não.
*/
function hub_security_check(PDO $pdo): array {
if (!hub_security_enabled()) return [];
hub_security_ensure_table($pdo);
$row = $pdo->query('SELECT COUNT(*) n, MIN(occurred_at) oldest FROM hub_security_outbox')
->fetch(PDO::FETCH_ASSOC);
$pending = (int) ($row['n'] ?? 0);
$oldest = $row['oldest'] !== null ? time() - (int) $row['oldest'] : 0;
$fleet = 0;
try {
$fleet = (int) $pdo->query(
"SELECT COUNT(*) FROM ip_bans WHERE source = 'fleet' AND (expires_at IS NULL OR expires_at > NOW())"
)->fetchColumn();
} catch (Throwable) { /* ip_bans ainda por criar */ }
$synced = @filemtime(hub_security_stamp('synced')) ?: null;
$detail = $pending . ' por enviar'
. ($synced ? ' · sincronizado há ' . max(0, (int) round((time() - $synced) / 60)) . ' min' : ' · nunca sincronizou')
. ' · ' . $fleet . ' banimento(s) da frota';
return [
'ok' => $oldest < 3600,
'detail' => $detail,
'pending' => $pending,
'oldest_seconds' => $oldest,
'fleet_bans' => $fleet,
'synced_at' => $synced ? date('c', $synced) : null,
];
}
@@ -1,412 +0,0 @@
<?php
// ─── Shared DB-backed settings + secret storage ──────────────────────────────
//
// One place for per-install configuration that must be editable from the
// backoffice rather than a file: SMTP credentials above all. fotografia and
// hub2 had already converged on the same table shape and the same AES-256-GCM
// envelope; this makes that the standard and gives silver (which had no
// settings table at all) the same thing.
//
// Table: system_settings(setting_key, setting_value, updated_by, updated_at)
//
// Secrets (smtp_password, API tokens, …) are encrypted at rest with AES-256-GCM
// under a key that lives only in .env, so a leaked database dump is not enough
// to recover them. Which keys count as secret is decided by
// platform_settings_is_secret() -- one policy for the whole estate, rather than
// a per-site list each caller has to remember to update.
//
// Public API:
// platform_settings_ensure_table(PDO)
// platform_settings_get(PDO, key, default)
// platform_settings_set(PDO, key, value, ?userId, ?encrypt) encrypt=null -> auto
// platform_settings_all(PDO, revealSecrets=false)
// platform_settings_is_secret(key): bool
// platform_settings_encrypt_existing(PDO): array -> idempotent migration
// platform_secret_encrypt(string) / platform_secret_decrypt(?string)
// platform_secret_mask(string)
// platform_smtp_config(PDO): array -> the shape functions_mail.php expects
const PLATFORM_SECRET_PREFIX = 'enc:v1:';
/**
* Key used for the settings envelope.
*
* Derivation is domain-separated ('am-secret|') so the same APP_SECRET_KEY
* cannot produce the same key material for two different purposes. Falls back
* to BACKUP_TOKEN because some installs only ever set that one.
*/
function platform_secret_key(): ?string {
if (!extension_loaded('openssl')) return null;
foreach (['APP_SECRET_KEY', 'BACKUP_TOKEN'] as $k) {
$v = defined($k) ? (string) constant($k) : '';
if ($v === '') {
$v = function_exists('env') ? (string) env($k, '') : (string) (getenv($k) ?: ($_ENV[$k] ?? ''));
}
if ($v !== '' && $v !== 'replace_me') {
return hash('sha256', 'am-secret|' . $v, true);
}
}
return null;
}
function platform_secret_encrypt(string $plain): string {
if ($plain === '') return '';
$key = platform_secret_key();
// No key: store as-is rather than losing the value. The caller is warned by
// platform_settings_secret_available().
if ($key === null) return $plain;
$iv = random_bytes(12); // GCM standard nonce
$tag = '';
$ct = openssl_encrypt($plain, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag);
if ($ct === false) return $plain;
return PLATFORM_SECRET_PREFIX . base64_encode($iv . $tag . $ct);
}
function platform_secret_decrypt(?string $stored): string {
$stored = (string) $stored;
if ($stored === '' || !str_starts_with($stored, PLATFORM_SECRET_PREFIX)) return $stored;
$raw = base64_decode(substr($stored, strlen(PLATFORM_SECRET_PREFIX)), true);
if ($raw === false || strlen($raw) < 29) return '';
$iv = substr($raw, 0, 12); $tag = substr($raw, 12, 16); $ct = substr($raw, 28);
$key = platform_secret_key();
if ($key !== null) {
$out = openssl_decrypt($ct, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag);
if ($out !== false) return $out;
}
error_log('[settings] encrypted value present but the key does not decrypt it -- has APP_SECRET_KEY changed?');
return '';
}
function platform_settings_secret_available(): bool {
return platform_secret_key() !== null;
}
/** Creates the table when missing. Safe to call on every request. */
function platform_settings_ensure_table(PDO $pdo): void {
$pdo->exec(
"CREATE TABLE IF NOT EXISTS `system_settings` (
`setting_key` VARCHAR(100) NOT NULL,
`setting_value` TEXT NULL,
`updated_by` INT NULL,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`setting_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
}
/**
* Per-request read cache, shared by get() and invalidated by set().
*
* Returned by reference so the writers can actually mutate it. It used to be a
* `static` local inside platform_settings_get(), which nothing else could
* reach: a page that read a setting, wrote it, then read it back got the value
* from before its own write. The setup page does exactly that -- save, then
* re-check whether the platform is configured -- and would have reported the
* gate unsatisfied immediately after satisfying it.
*/
function &platform_settings_cache(): array {
static $cache = [];
return $cache;
}
/** Drop one key, or the whole cache when $key is null. */
function platform_settings_cache_clear(?string $key = null): void {
$cache = &platform_settings_cache();
if ($key === null) { $cache = []; return; }
unset($cache[$key]);
}
function platform_settings_get(PDO $pdo, string $key, $default = '') {
$cache = &platform_settings_cache();
if (array_key_exists($key, $cache)) return $cache[$key];
try {
$st = $pdo->prepare("SELECT setting_value FROM system_settings WHERE setting_key = ? LIMIT 1");
$st->execute([$key]);
$v = $st->fetchColumn();
$st->closeCursor();
} catch (Throwable $e) {
return $default; // table absent on a host that has not run ensure_table yet
}
if ($v === false || $v === null) return $cache[$key] = $default;
return $cache[$key] = platform_secret_decrypt((string) $v);
}
/**
* Is this setting a secret, and therefore encrypted at rest?
*
* The decision lives here, not in the callers. Each site used to keep its own
* list -- fotografia had ['smtp_password','twilio_token'] hardcoded in
* comms.php, hub2 encrypted only smtp_password -- so the same key could be
* ciphertext on one install and plaintext on another, and a newly added
* credential was secret only if whoever added it remembered.
*
* Suffix rules cover new keys automatically; the exact list catches the ones
* whose names do not follow the convention.
*/
function platform_settings_is_secret(string $key): bool {
static $exact = [
'smtp_password', 'twilio_token', 'twilio_auth_token',
'ig_app_secret', 'ig_access_token', 'nextcloud_share_token',
'hub_ops_secret', 'hub_content_secret', 'antispam_secret',
'notify_api_key',
];
$k = strtolower(trim($key));
if (in_array($k, $exact, true)) return true;
foreach (['_password', '_secret', '_token', '_api_key', '_apikey', '_private_key'] as $suffix) {
if (str_ends_with($k, $suffix)) return true;
}
return false;
}
/**
* Write a setting.
*
* $encrypt defaults to null, meaning "decide from the key name". Pass true or
* false only to override that deliberately -- the old signature took a plain
* bool defaulting to false, so any existing caller passing true still forces
* encryption and one passing false still forces plaintext.
*/
function platform_settings_set(PDO $pdo, string $key, string $value, ?int $userId = null, ?bool $encrypt = null): void {
platform_settings_ensure_table($pdo);
$wantEncrypted = $encrypt ?? platform_settings_is_secret($key);
if (!$wantEncrypted || $value === '') {
// An empty secret is stored empty, not as ciphertext of "": callers use
// '' to mean "cleared", and encrypting it would hide that.
$stored = $value;
} elseif (str_starts_with($value, PLATFORM_SECRET_PREFIX)) {
// Already encrypted by a caller that does its own crypto (hub2 and
// fotografia both do). Wrapping it again would make it undecryptable.
$stored = $value;
} else {
$stored = platform_secret_encrypt($value);
if (!str_starts_with($stored, PLATFORM_SECRET_PREFIX)) {
// platform_secret_encrypt falls back to returning the plaintext when
// no key is configured. Storing it is still better than losing the
// value, but it must not happen quietly.
error_log("[settings] '{$key}' stored UNENCRYPTED: no APP_SECRET_KEY/BACKUP_TOKEN available");
}
}
$st = $pdo->prepare(
"INSERT INTO system_settings (setting_key, setting_value, updated_by)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), updated_by = VALUES(updated_by)"
);
$st->execute([$key, $stored, $userId]);
// Without this the caller reads its own stale value for the rest of the
// request -- including "did that save work?", which is the one question a
// save is usually followed by.
platform_settings_cache_clear($key);
}
/**
* Renders a secret for display.
*
* An API key is shown as "amk_1a2b…9f4c" because operators identify keys by
* their prefix. A password gets no prefix at all -- there is nothing to
* recognise, and eight leading characters in a screenshot or a support ticket
* is a real leak.
*/
function platform_secret_mask(string $plain, string $key = ''): string {
if ($plain === '') return '—';
$isPassword = $key === '' || str_ends_with(strtolower($key), '_password');
if ($isPassword) return '•••••••• (definida)';
if (strlen($plain) <= 12) return str_repeat('•', strlen($plain));
return substr($plain, 0, 8) . '…' . substr($plain, -4);
}
/**
* Every setting, decrypted. Secrets are masked unless $revealSecrets is true,
* so the common cases -- an admin listing, a diagnostics dump -- cannot leak a
* credential into a page or a log by accident.
*/
function platform_settings_all(PDO $pdo, bool $revealSecrets = false): array {
platform_settings_ensure_table($pdo);
$out = [];
$st = $pdo->query("SELECT setting_key, setting_value FROM system_settings ORDER BY setting_key");
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $row) {
$key = (string) $row['setting_key'];
$plain = platform_secret_decrypt((string) $row['setting_value']);
$out[$key] = (!$revealSecrets && platform_settings_is_secret($key))
? platform_secret_mask($plain, $key)
: $plain;
}
return $out;
}
/**
* Encrypt any secret-named row still sitting in plaintext.
*
* Idempotent: rows already carrying the enc:v1: prefix are skipped, so this is
* safe to run on every deploy. Returns ['encrypted'=>[], 'skipped'=>[], 'failed'=>[]].
*/
function platform_settings_encrypt_existing(PDO $pdo): array {
platform_settings_ensure_table($pdo);
$report = ['encrypted' => [], 'skipped' => [], 'failed' => []];
if (!platform_settings_secret_available()) {
$report['failed'][] = 'no encryption key (APP_SECRET_KEY / BACKUP_TOKEN)';
return $report;
}
$rows = $pdo->query("SELECT setting_key, setting_value FROM system_settings")->fetchAll(PDO::FETCH_ASSOC);
$upd = $pdo->prepare("UPDATE system_settings SET setting_value = ? WHERE setting_key = ?");
foreach ($rows as $row) {
$key = (string) $row['setting_key'];
$value = (string) $row['setting_value'];
if (!platform_settings_is_secret($key)) continue;
if ($value === '') { $report['skipped'][] = "{$key} (empty)"; continue; }
if (str_starts_with($value, PLATFORM_SECRET_PREFIX)) { $report['skipped'][] = "{$key} (already encrypted)"; continue; }
$enc = platform_secret_encrypt($value);
if (!str_starts_with($enc, PLATFORM_SECRET_PREFIX)) { $report['failed'][] = $key; continue; }
$upd->execute([$enc, $key]);
platform_settings_cache_clear($key);
$report['encrypted'][] = $key;
}
return $report;
}
/**
* The standard SMTP settings, read from the database with the .env value as
* fallback.
*
* Precedence is deliberate: a value set in the backoffice wins, because that is
* the one an operator can change without shell access. .env remains useful for
* bootstrapping an install before anyone can log in.
*
* Returns exactly the shape platform_mail_send()'s $override expects.
*/
function platform_smtp_config(PDO $pdo): array {
$env = function (string $k, string $d = '') {
if (defined($k)) { $v = (string) constant($k); if ($v !== '') return $v; }
if (function_exists('env')) { $v = (string) env($k, ''); if ($v !== '') return $v; }
$v = getenv($k); if ($v === false || $v === '') $v = (string) ($_ENV[$k] ?? '');
return $v !== '' ? $v : $d;
};
return [
'host' => platform_settings_get($pdo, 'smtp_host', $env('SMTP_HOST')),
'port' => (int) platform_settings_get($pdo, 'smtp_port', $env('SMTP_PORT', '587')),
'username' => platform_settings_get($pdo, 'smtp_username', $env('SMTP_USERNAME')),
'password' => platform_settings_get($pdo, 'smtp_password', $env('SMTP_PASSWORD')),
'secure' => platform_settings_get($pdo, 'smtp_secure', $env('SMTP_SECURE', 'tls')),
'from' => platform_settings_get($pdo, 'email_from', $env('EMAIL_FROM')),
'from_name' => platform_settings_get($pdo, 'email_from_name', $env('EMAIL_FROM_NAME')),
];
}
/**
* URL of a page in this platform's backoffice.
*
* The shared pages and API endpoints used to hardcode `views/sysadmin/...`,
* which is right for four of the five platforms and wrong for design, whose
* backoffice lives at `views/admin/`. Clicking "Backup da base de dados" there
* ran the backup and then redirected to a page that does not exist -- the work
* succeeded and the operator saw a 404, which is the most confusing pair of
* outcomes available.
*
* A site overrides the segment by defining PLATFORM_ADMIN_DIR (or setting it in
* .env) to e.g. 'views/admin'. Default keeps the existing behaviour.
*
* A site on the front controller (`'router' => true` in platform_connect())
* gets platform.php?tool=… for every shared page; its own pages keep the dir.
*/
function platform_admin_url(string $page = ''): string {
if (function_exists('platform_router_enabled') && platform_router_enabled()) {
[$file, $query] = array_pad(explode('?', $page, 2), 2, '');
$tool = $file === '' ? 'index' : preg_replace('/\.php$/', '', basename($file));
if (isset(platform_routes()[$tool])) return platform_route_url($tool, $query);
}
$dir = 'views/sysadmin';
if (defined('PLATFORM_ADMIN_DIR') && constant('PLATFORM_ADMIN_DIR') !== '') {
$dir = (string) constant('PLATFORM_ADMIN_DIR');
} elseif (function_exists('env') && (string) env('PLATFORM_ADMIN_DIR', '') !== '') {
$dir = (string) env('PLATFORM_ADMIN_DIR', '');
} elseif (($e = getenv('PLATFORM_ADMIN_DIR')) !== false && $e !== '') {
$dir = $e;
}
$path = trim($dir, '/') . ($page !== '' ? '/' . ltrim($page, '/') : '');
return function_exists('base_url') ? base_url($path) : '/' . $path;
}
/**
* A stable HMAC key for a named purpose.
*
* Signed form tokens (anti-spam stamps, captcha challenges) are issued in one
* request and verified in another, so the key behind them must be **the same
* across requests** -- a per-request key rejects every visitor with "invalid
* token" and the failure looks like a bot problem rather than a bug.
*
* Three sources, in order, all stable:
*
* 1. platform_secret_key() -- the .env key, the normal case;
* 2. a random value stored in system_settings, when there is no .env key;
* 3. derived from the install's own configuration, for an install with
* neither. It is a weaker secret than the other two, but it is constant,
* and a defence that locks every form is worse than a weaker signature.
*
* Always domain-separated by $purpose, so a stamp minted for one feature can
* never validate in another. Note that functions_antispam_forms.php keeps its
* own older derivation on purpose: changing the key of a live site invalidates
* the forms that are open at that instant, and those submissions are refused
* silently -- leads lost for the sake of tidiness. New callers use this one.
*
* Pass the PDO wherever you have one. Source 3 is a last resort, and a site
* that reaches it from one call site with a PDO and another without would sign
* with two different keys.
*/
function platform_derived_key(string $purpose, ?PDO $pdo = null, string $settingKey = 'shared_hmac_key'): string {
static $bases = [];
if (!isset($bases[$settingKey])) {
$base = '';
$envKey = platform_secret_key();
if ($envKey !== null) {
$base = bin2hex($envKey);
} elseif ($pdo instanceof PDO) {
$base = (string) platform_settings_get($pdo, $settingKey, '');
if ($base === '') {
$fresh = bin2hex(random_bytes(32));
try {
// Stored in the clear on purpose: encrypting it would need
// the key we do not have (that is why we are here), and a
// value that decrypts differently on each read means every
// token minted before this request stops verifying.
platform_settings_set($pdo, $settingKey, $fresh, null, false);
$base = $fresh;
} catch (Throwable $e) {
error_log('[settings] ' . $settingKey . ' not persisted (' . $e->getMessage()
. '); deriving from install config');
}
}
}
if ($base === '') {
$read = static function (string $k): string {
if (defined($k)) return (string) constant($k);
if (function_exists('env')) return (string) env($k, '');
return (string) (getenv($k) ?: ($_ENV[$k] ?? ''));
};
// Not cached: this branch is the fallback, and a later call that
// does carry a PDO must be free to find the real key instead of
// inheriting this one.
return hash('sha256', 'am-key|' . $purpose . '|derived|'
. $read('DB_NAME') . '|' . $read('DB_USER') . '|' . $read('DB_PASS')
. '|' . $read('APP_BASE_URL'), true);
}
$bases[$settingKey] = $base;
}
return hash('sha256', 'am-key|' . $purpose . '|' . $bases[$settingKey], true);
}
-183
View File
@@ -1,183 +0,0 @@
<?php
// ─── First-run setup gate ────────────────────────────────────────────────────
//
// A platform that cannot send mail is a platform where nobody can recover an
// account, no notification arrives, and no contact form reaches anyone -- and
// none of that fails visibly. It fails as silence, weeks later, which is the
// worst way for it to fail.
//
// So SMTP is not optional configuration discovered later: it is part of
// standing the site up. Until it is configured, platform_setup_guard() holds
// the site on the setup page.
//
// Wiring, in the site's bootstrap after the PDO is open and the session
// started:
//
// require_once __DIR__ . '/../shared/platform-tools/functions_setup.php';
// platform_setup_guard($pdo, base_url('setup.php'), [
// 'only' => ['/views/admin/', '/views/sysadmin/'],
// ]);
require_once __DIR__ . '/functions_settings.php';
require_once __DIR__ . '/functions_mail.php';
// Deliberately NOT requiring functions_auth.php. hub2 already defines its own
// auth_login() in config/functions_user.php, so pulling the shared auth in here
// would fatal on a redeclare for any site that has not migrated yet -- and the
// setup gate has to work on exactly those sites. setup.php needs two column
// names, not the whole module.
/**
* The users table and role column, for the setup page's access check.
*
* Uses auth_schema() when the site has wired the shared auth, and the same
* defaults otherwise. Kept separate from auth_schema() so this file has no
* dependency on it: a missing helper must never be the reason a page that
* writes SMTP credentials decides nobody is an admin.
*/
function platform_setup_user_schema(): array {
if (function_exists('auth_schema')) {
$s = auth_schema();
return ['table' => $s['table'], 'role' => $s['role']];
}
return ['table' => 'users', 'role' => 'role'];
}
/**
* Is outbound mail actually configured?
*
* Deliberately checks *resolved* values, not whether a key exists. Every SMTP
* key on three of the four sites existed in .env with an empty value, which a
* presence check reads as configured and a value check reads as broken -- that
* distinction is what hid the estate-wide outage.
*
* A host and a from-address are the minimum: without the first nothing can be
* sent, and without the second nothing that is sent has a sender.
*/
function platform_setup_smtp_ok(PDO $pdo): bool {
$c = platform_smtp_config($pdo);
return trim((string) ($c['host'] ?? '')) !== ''
&& trim((string) ($c['from'] ?? '')) !== '';
}
/** Per-field detail, for rendering the setup form and its warnings. */
function platform_setup_state(PDO $pdo): array {
$c = platform_smtp_config($pdo);
return [
'smtp_ok' => platform_setup_smtp_ok($pdo),
'config' => $c,
'secrets_ok' => platform_settings_secret_available(),
'mailer_present' => function_exists('platform_mail_available'),
'missing' => array_values(array_filter([
trim((string) ($c['host'] ?? '')) === '' ? 'smtp_host' : null,
trim((string) ($c['from'] ?? '')) === '' ? 'email_from' : null,
])),
];
}
/**
* Hold the backoffice on the setup page until SMTP is configured.
*
* `only` lists the path prefixes the gate applies to, and it is the option to
* use: the gate belongs on the backoffice, not the public site. A portfolio, a
* class timetable or a public tool has no reason to go dark because outbound
* mail is unconfigured, and taking the shopfront down to nag the operator
* punishes the wrong person. The backoffice is where the operator already is,
* and where the setup page is reachable from.
*
* With `only` omitted the gate covers everything, minus `exempt`. That is the
* right shape only for an install with no public face at all.
*
* Two exclusions are unconditional. CLI is never gated: a cron worker or a
* migration must still run on a half-configured install, and a redirect means
* nothing without a browser. And a caller that wants JSON gets a 503, never a
* 302 to an HTML page -- anything expecting JSON would parse that as success.
*/
function platform_setup_guard(PDO $pdo, string $setupUrl, array $opts = []): void {
if (PHP_SAPI === 'cli') return;
static $checked = false;
if ($checked) return;
$checked = true;
if (platform_setup_smtp_ok($pdo)) return;
$path = (string) parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
// Never redirect the setup page to itself.
$setupPath = (string) parse_url($setupUrl, PHP_URL_PATH);
if ($setupPath !== '' && str_starts_with($path, $setupPath)) return;
// Scoped mode: anything outside the listed prefixes is public, and stays up.
$only = (array) ($opts['only'] ?? []);
if ($only) {
$inScope = false;
foreach ($only as $prefix) {
if ($prefix !== '' && str_contains($path, (string) $prefix)) { $inScope = true; break; }
}
if (!$inScope) return;
}
foreach ((array) ($opts['exempt'] ?? []) as $prefix) {
if ($prefix !== '' && str_contains($path, (string) $prefix)) return;
}
// A machine caller gets a machine answer. A 302 to an HTML page would be
// parsed as a successful response by anything expecting JSON.
$wantsJson = str_contains((string) ($_SERVER['HTTP_ACCEPT'] ?? ''), 'application/json')
|| str_contains($path, '/api/');
if ($wantsJson) {
http_response_code(503);
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'status' => 'error',
'error' => 'Plataforma por configurar: falta o SMTP.',
]);
exit;
}
header('Location: ' . $setupUrl);
exit;
}
/**
* Save the SMTP settings.
*
* Writes through platform_settings_set(), so smtp_password is encrypted at
* rest by the estate-wide policy rather than by anything decided here. An empty
* password means "leave the stored one alone" -- the field is never rendered
* with the real value, so blank cannot mean "clear it".
*/
function platform_setup_save_smtp(PDO $pdo, array $in, ?int $userId = null): array {
$host = trim((string) ($in['smtp_host'] ?? ''));
$from = trim((string) ($in['email_from'] ?? ''));
$port = (int) ($in['smtp_port'] ?? 587);
$errors = [];
if ($host === '') $errors[] = 'Indique o servidor SMTP.';
if ($from === '' || !filter_var($from, FILTER_VALIDATE_EMAIL))
$errors[] = 'Indique um remetente válido.';
if ($port < 1 || $port > 65535) $errors[] = 'Porta inválida.';
if ($errors) return ['ok' => false, 'errors' => $errors];
platform_settings_set($pdo, 'smtp_host', $host, $userId);
platform_settings_set($pdo, 'smtp_port', (string) $port, $userId);
platform_settings_set($pdo, 'smtp_secure', (string) ($in['smtp_secure'] ?? 'tls'), $userId);
platform_settings_set($pdo, 'smtp_username', trim((string) ($in['smtp_username'] ?? '')), $userId);
platform_settings_set($pdo, 'email_from', $from, $userId);
platform_settings_set($pdo, 'email_from_name', trim((string) ($in['email_from_name'] ?? '')), $userId);
$pw = (string) ($in['smtp_password'] ?? '');
if ($pw !== '') {
// platform_settings_is_secret('smtp_password') is true, so this is
// encrypted on the way in -- no flag passed, no caller decision.
platform_settings_set($pdo, 'smtp_password', $pw, $userId);
}
return ['ok' => true, 'errors' => []];
}
/** Connect-and-authenticate probe against the values just saved. */
function platform_setup_test_smtp(PDO $pdo, string $repoRoot = ''): array {
return platform_mail_test($repoRoot, platform_smtp_config($pdo));
}
@@ -1,482 +0,0 @@
<?php
// ─── Origem das visitas ─────────────────────────────────────────────────────
//
// Responde a "de onde veio esta pessoa?" em três eixos:
//
// URL /?source=montra, /?utm_source=instagram&utm_campaign=natal,
// ou o domínio de quem enviou o link
// dispositivo telemóvel/tablet/desktop, sistema, browser, idioma
// geografia país, pelo índice de shared/platform-tools/data/geo
//
// Nasceu na fotografia (config/functions_tracking.php) e veio para aqui para os
// outros sites o usarem. As mudanças em relação ao original estão assinaladas
// onde aparecem; a que mais importa é o IP.
//
// Contar visitas por origem é a metade fácil e a menos útil. O que diz se vale
// a pena reimprimir um QR code é quantos PEDIDOS produziu, por isso a origem de
// primeiro toque fica guardada e é carimbada na conversão que a pessoa venha a
// submeter — ver platform_track_conversion().
//
// Primeiro toque ganha durante toda a janela de atribuição: quem chega por um
// QR code, sai, e volta dias depois pelo Google continua a ser creditado ao QR
// code que fez o trabalho.
//
// Nunca se guarda o endereço IP. O hash existe só para aproximar visitantes
// únicos e é salgado com um segredo do servidor, por isso não se reverte.
require_once __DIR__ . '/functions_client_ip.php';
require_once __DIR__ . '/functions_geo.php';
const PLATFORM_TRACKING_COOKIE = 'am_src';
/**
* Cria as tabelas se faltarem. Chamada pelas funções de escrita, para um site
* poder adoptar isto sem migração à mão.
*/
function platform_tracking_ensure_tables(PDO $pdo): void
{
static $done = false;
if ($done) {
return;
}
$done = true;
$pdo->exec("
CREATE TABLE IF NOT EXISTS `visit_sources` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`source` VARCHAR(60) NOT NULL,
`medium` VARCHAR(60) NULL,
`campaign` VARCHAR(80) NULL,
`referrer_host` VARCHAR(120) NULL,
`landing_path` VARCHAR(190) NULL,
`ip_hash` CHAR(64) NULL,
`user_agent` VARCHAR(255) NULL,
`is_bot` TINYINT(1) NOT NULL DEFAULT 0,
`device_type` VARCHAR(12) NULL,
`os` VARCHAR(24) NULL,
`browser` VARCHAR(24) NULL,
`language` VARCHAR(5) NULL,
`country` CHAR(2) NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY `idx_vs_source` (`source`),
KEY `idx_vs_bot` (`is_bot`, `created_at`),
KEY `idx_vs_country` (`country`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
");
// Conversões numa tabela só, com o nome do formulário como chave, em vez de
// uma coluna `source` em cada tabela de destino. Um site com contactos,
// aulas experimentais e registos precisaria de três migrações e três sítios
// por onde a atribuição se pode perder; assim é uma linha por handler e o
// relatório é o mesmo em toda a quinta.
$pdo->exec("
CREATE TABLE IF NOT EXISTS `tracking_conversions` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`form` VARCHAR(40) NOT NULL,
`source` VARCHAR(60) NOT NULL,
`campaign` VARCHAR(80) NULL,
`country` CHAR(2) NULL,
`ip_hash` CHAR(64) NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY `idx_tc_form` (`form`, `created_at`),
KEY `idx_tc_source` (`source`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
");
// Instalações que já tinham a tabela antes de haver país.
try {
$pdo->exec("ALTER TABLE `visit_sources` ADD COLUMN `country` CHAR(2) NULL AFTER `language`");
$pdo->exec("ALTER TABLE `visit_sources` ADD KEY `idx_vs_country` (`country`)");
} catch (PDOException) { /* já existe */ }
}
/**
* Regista este pedido como visita, quando é o primeiro da sessão.
*
* Seguro em qualquer página pública: escreve no máximo uma linha por sessão e
* nunca lança — a analítica não vale uma página em baixo.
*/
function platform_track_visit(PDO $pdo): void
{
try {
if (!empty($_SESSION['_visit_tracked'])) {
return;
}
$_SESSION['_visit_tracked'] = 1;
platform_tracking_ensure_tables($pdo);
$parsed = platform_tracking_parse_request();
$existing = platform_tracking_stored_source();
if ($parsed['explicit'] || $existing === null) {
platform_tracking_store_source($pdo, $parsed['source'], $parsed['campaign']);
}
$ua = substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255);
$dev = platform_tracking_parse_user_agent($ua);
$lang = platform_tracking_parse_language((string) ($_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? ''));
$pdo->prepare("
INSERT INTO visit_sources
(source, medium, campaign, referrer_host, landing_path, ip_hash, user_agent, is_bot,
device_type, os, browser, language, country)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
")->execute([
$parsed['source'],
$parsed['medium'],
$parsed['campaign'],
$parsed['referrer_host'],
$parsed['landing_path'],
platform_tracking_ip_hash(),
$ua,
platform_tracking_is_bot($ua) ? 1 : 0,
$dev['device_type'],
$dev['os'],
$dev['browser'],
$lang,
geo_ip_country(platform_client_ip()),
]);
} catch (\Throwable $e) {
error_log('[tracking] ' . $e->getMessage());
}
}
/**
* Carimba uma conversão com a origem que a pessoa trouxe.
*
* `$form` é o nome do formulário ('contacto', 'trial', 'registo'…). Chamar
* depois de a submissão ter sido aceite, nunca antes: uma tentativa recusada
* pelo antispam não é uma conversão, e contá-la faria uma campanha parecer
* melhor por estar a atrair bots.
*/
function platform_track_conversion(PDO $pdo, string $form): void
{
try {
platform_tracking_ensure_tables($pdo);
$attr = platform_tracking_current_source();
$pdo->prepare("
INSERT INTO tracking_conversions (form, source, campaign, country, ip_hash)
VALUES (?, ?, ?, ?, ?)
")->execute([
substr($form, 0, 40),
$attr['source'],
$attr['campaign'],
geo_ip_country(platform_client_ip()),
platform_tracking_ip_hash(),
]);
} catch (\Throwable $e) {
error_log('[tracking] conversão: ' . $e->getMessage());
}
}
/**
* De onde veio este pedido.
*
* `explicit` diz se o visitante trazia um parâmetro de campanha, que é o que
* decide se deve sobrepor-se a uma origem já guardada.
*/
function platform_tracking_parse_request(): array
{
$get = fn(string $k): string => trim((string) ($_GET[$k] ?? ''));
$source = $get('source') ?: $get('utm_source') ?: $get('ref');
$medium = $get('utm_medium') ?: ($get('source') ? 'campaign' : null);
$campaign = $get('utm_campaign') ?: null;
$explicit = $source !== '';
$refHost = null;
if (!empty($_SERVER['HTTP_REFERER'])) {
$host = parse_url((string) $_SERVER['HTTP_REFERER'], PHP_URL_HOST);
if ($host) {
$refHost = strtolower(preg_replace('/^www\./', '', $host));
// Um referrer do nosso próprio site não é uma origem de aquisição.
$base = function_exists('env') ? (string) env('APP_BASE_URL', '') : '';
$self = strtolower((string) parse_url($base, PHP_URL_HOST));
if ($self !== '' && $refHost === preg_replace('/^www\./', '', $self)) {
$refHost = null;
}
// O host actual também conta como próprio: o APP_BASE_URL da quinta
// aponta para o domínio de serviço e não para o público, por isso
// sozinho deixava passar navegação interna como se fosse referral.
$here = strtolower(preg_replace('/^www\./', '', (string) ($_SERVER['HTTP_HOST'] ?? '')));
if ($here !== '' && $refHost === $here) {
$refHost = null;
}
}
}
if ($source === '') {
$source = $refHost ?: 'direct';
$medium = $medium ?: ($refHost ? 'referral' : 'direct');
}
return [
'source' => platform_tracking_normalise($source),
'medium' => $medium ? platform_tracking_normalise($medium, 60) : null,
'campaign' => $campaign ? platform_tracking_normalise($campaign, 80) : null,
'referrer_host' => $refHost ? substr($refHost, 0, 120) : null,
'landing_path' => substr((string) (parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH) ?: '/'), 0, 190),
'explicit' => $explicit,
];
}
/**
* Reduz um valor cru a uma chave estável, para "Montra", "montra " e "MONTRA"
* não darem três linhas no relatório.
*/
function platform_tracking_normalise(string $v, int $max = 60): string
{
$v = trim($v);
// Transliterar em vez de remover: tirar os acentos à bruta transformava
// "café" em "caf" e "são-joão" em "so-joo", que é exactamente o género de
// nome que uma campanha portuguesa usa.
if (function_exists('iconv')) {
$t = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $v);
if ($t !== false && $t !== '') $v = $t;
}
$v = strtolower($v);
// O TRANSLIT do iconv pode emitir formas como "c'e" ou "~a", conforme o locale.
$v = preg_replace("/['\"^~`]/", '', $v) ?? '';
$v = preg_replace('/[^a-z0-9._\- ]+/', '', $v) ?? '';
$v = preg_replace('/\s+/', '-', trim($v)) ?? '';
$v = trim(preg_replace('/-+/', '-', $v) ?? '', '-');
return substr($v, 0, $max) ?: 'direct';
}
/**
* Hash salgado e não reversível do visitante. O endereço nunca é guardado.
*
* Usa platform_client_ip() e não REMOTE_ADDR. Era essa a versão original, e
* atrás do NPM o REMOTE_ADDR é sempre o proxy: todos os visitantes ficavam com
* o mesmo hash, o que fazia o COUNT(DISTINCT ip_hash) do relatório -- a coluna
* "visitantes" -- dar sempre 1.
*/
function platform_tracking_ip_hash(): ?string
{
$ip = platform_client_ip();
if ($ip === null || $ip === '') {
return null;
}
$env = fn(string $k) => function_exists('env') ? (string) env($k, '') : (string) (getenv($k) ?: '');
$salt = $env('ANTISPAM_SECRET') ?: $env('APP_SECRET_KEY') ?: $env('BACKUP_TOKEN') ?: 'am-tracking';
return hash('sha256', $salt . '|' . $ip);
}
/**
* Categorias grosseiras de dispositivo e plataforma a partir do User-Agent.
*
* A ordem importa em todo o lado: praticamente todos os browsers mentem no
* User-Agent por compatibilidade. O Edge e o Opera dizem-se Chrome, o Chrome
* diz-se Safari, e qualquer tablet Android diz-se Android. Cada teste tem de
* excluir os impostores antes de o mais geral correr.
*
* Grosseiro de propósito: a pergunta é "desktop ou Android?", não "que versão
* de correcção?". Números de versão estreitariam o visitante sem acrescentar
* nada à resposta.
*/
function platform_tracking_parse_user_agent(string $ua): array
{
$out = ['device_type' => 'other', 'os' => 'Desconhecido', 'browser' => 'Desconhecido'];
if ($ua === '') return $out;
// O iPadOS 13+ diz-se "Macintosh", por isso o teste do iPad precisa da
// pista de toque antes de o do macOS poder ser acreditado.
if (preg_match('/iPad/i', $ua) || (preg_match('/Macintosh/i', $ua) && preg_match('/Mobile\/|Touch/i', $ua))) {
$out['os'] = 'iPadOS'; $out['device_type'] = 'tablet';
} elseif (preg_match('/iPhone|iPod/i', $ua)) {
$out['os'] = 'iOS'; $out['device_type'] = 'mobile';
} elseif (preg_match('/Android/i', $ua)) {
$out['os'] = 'Android';
// Os tablets Android omitem "Mobile"; os telemóveis incluem-no.
$out['device_type'] = preg_match('/Mobile/i', $ua) ? 'mobile' : 'tablet';
} elseif (preg_match('/Windows Phone/i', $ua)) {
$out['os'] = 'Windows Phone'; $out['device_type'] = 'mobile';
} elseif (preg_match('/Windows NT/i', $ua)) {
$out['os'] = 'Windows'; $out['device_type'] = 'desktop';
} elseif (preg_match('/CrOS/i', $ua)) {
$out['os'] = 'ChromeOS'; $out['device_type'] = 'desktop';
} elseif (preg_match('/Mac OS X|Macintosh/i', $ua)) {
$out['os'] = 'macOS'; $out['device_type'] = 'desktop';
} elseif (preg_match('/Linux/i', $ua)) {
$out['os'] = 'Linux'; $out['device_type'] = 'desktop';
}
// Edge antes do Chrome (Edg/ contém Chrome/), Opera antes do Chrome, Chrome
// antes do Safari, e os browsers embutidos antes de todos — quem toca num
// link dentro do Instagram está a navegar na webview do Instagram, e contar
// isso como Safari esconderia quanto tráfego as redes realmente mandam.
if (preg_match('/FBAN|FBAV|FB_IAB/i', $ua)) $out['browser'] = 'Facebook (in-app)';
elseif (preg_match('/Instagram/i', $ua)) $out['browser'] = 'Instagram (in-app)';
elseif (preg_match('/\bEdgA?\//i', $ua)) $out['browser'] = 'Edge';
elseif (preg_match('/OPR\/|Opera/i', $ua)) $out['browser'] = 'Opera';
elseif (preg_match('/SamsungBrowser/i', $ua)) $out['browser'] = 'Samsung Internet';
elseif (preg_match('/Firefox\/|FxiOS/i', $ua)) $out['browser'] = 'Firefox';
elseif (preg_match('/CriOS/i', $ua)) $out['browser'] = 'Chrome';
elseif (preg_match('/Chrome\//i', $ua)) $out['browser'] = 'Chrome';
elseif (preg_match('/Safari\//i', $ua)) $out['browser'] = 'Safari';
return $out;
}
/**
* Idioma principal que o browser pede, em duas letras ('pt', 'en'…). Serve para
* distinguir visitantes locais de estrangeiros; nada mais granular é guardado.
*/
function platform_tracking_parse_language(string $header): ?string
{
if ($header === '') return null;
// "pt-PT,pt;q=0.9,en;q=0.8" → pt
if (!preg_match('/^\s*([a-zA-Z]{2})/', $header, $m)) return null;
return strtolower($m[1]);
}
/** Filtro de crawlers, rude mas eficaz — mantém o relatório sobre pessoas. */
function platform_tracking_is_bot(string $ua): bool
{
if ($ua === '') return true;
return (bool) preg_match(
'/bot|crawl|spider|slurp|bingpreview|facebookexternalhit|whatsapp|telegram|preview|monitor|curl|wget|python-requests|headless|lighthouse|pingdom|uptime/i',
$ua
);
}
// ─── Guardar o primeiro toque ───────────────────────────────────────────────
function platform_tracking_store_source(PDO $pdo, string $source, ?string $campaign): void
{
$days = 30;
if (function_exists('platform_settings_get')) {
$days = max(1, (int) (platform_settings_get($pdo, 'tracking_attribution_days', '30') ?: 30));
}
$payload = $source . '|' . ($campaign ?? '');
$_SESSION['_visit_source'] = $payload;
// Cookie para quem volta dias depois continuar a ser creditado à campanha
// que o trouxe da primeira vez.
@setcookie(PLATFORM_TRACKING_COOKIE, $payload, [
'expires' => time() + ($days * 86400),
'path' => '/',
'secure' => (($_SERVER['HTTPS'] ?? '') !== '' || ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https'),
'httponly' => true,
'samesite' => 'Lax',
]);
}
/** "source|campaign" guardado, ou null quando não há nada. */
function platform_tracking_stored_source(): ?string
{
$raw = $_SESSION['_visit_source'] ?? $_COOKIE[PLATFORM_TRACKING_COOKIE] ?? null;
return is_string($raw) && $raw !== '' ? $raw : null;
}
/**
* A origem a creditar a uma conversão, como ['source' => …, 'campaign' => …].
* Cai para o pedido actual, para um formulário submetido logo na primeira
* página continuar a ser atribuído.
*/
function platform_tracking_current_source(): array
{
$raw = platform_tracking_stored_source();
if ($raw !== null) {
[$source, $campaign] = array_pad(explode('|', $raw, 2), 2, '');
return [
'source' => platform_tracking_normalise($source ?: 'direct'),
'campaign' => $campaign !== '' ? platform_tracking_normalise($campaign, 80) : null,
];
}
$p = platform_tracking_parse_request();
return ['source' => $p['source'], 'campaign' => $p['campaign']];
}
// ─── Relatórios ─────────────────────────────────────────────────────────────
/**
* Visitas e conversões por origem, nos últimos N dias.
*
* As duas metades são contadas numa passagem por tabela e juntas em PHP, e não
* com um JOIN — uma visita e uma conversão não têm relação ao nível da linha,
* só a chave de origem em comum.
*/
function platform_tracking_report(PDO $pdo, int $days = 30): array
{
$days = max(1, $days);
$visits = $pdo->prepare("
SELECT source,
COUNT(*) AS visits,
COUNT(DISTINCT ip_hash) AS visitors,
MAX(created_at) AS last_seen
FROM visit_sources
WHERE is_bot = 0 AND created_at >= NOW() - INTERVAL ? DAY
GROUP BY source
");
$visits->execute([$days]);
$visits = $visits->fetchAll(PDO::FETCH_ASSOC);
$conv = $pdo->prepare("
SELECT source, COUNT(*) AS conversions
FROM tracking_conversions
WHERE created_at >= NOW() - INTERVAL ? DAY
GROUP BY source
");
$conv->execute([$days]);
$conv = $conv->fetchAll(PDO::FETCH_KEY_PAIR);
$rows = [];
foreach ($visits as $v) {
$src = (string) $v['source'];
$rows[$src] = [
'source' => $src,
'visits' => (int) $v['visits'],
'visitors' => (int) $v['visitors'],
'conversions' => (int) ($conv[$src] ?? 0),
'last_seen' => $v['last_seen'],
];
unset($conv[$src]);
}
// Conversões cuja origem já não tem visitas na janela — uma pessoa que
// chegou há dois meses e só agora submeteu. Continuam a contar.
foreach ($conv as $src => $n) {
$rows[$src] = ['source' => (string) $src, 'visits' => 0, 'visitors' => 0,
'conversions' => (int) $n, 'last_seen' => null];
}
uasort($rows, fn($a, $b) => [$b['conversions'], $b['visits']] <=> [$a['conversions'], $a['visits']]);
return array_values($rows);
}
/** Contagem por uma coluna qualquer de visit_sources (device_type, os, country…). */
function platform_tracking_breakdown(PDO $pdo, string $column, int $days = 30, int $limit = 12): array
{
$allowed = ['device_type', 'os', 'browser', 'language', 'country', 'referrer_host'];
if (!in_array($column, $allowed, true)) {
return []; // o nome entra numa query -- só os da lista
}
$st = $pdo->prepare("
SELECT COALESCE(NULLIF(`$column`, ''), '—') AS label,
COUNT(*) AS visits,
COUNT(DISTINCT ip_hash) AS visitors
FROM visit_sources
WHERE is_bot = 0 AND created_at >= NOW() - INTERVAL ? DAY
GROUP BY label
ORDER BY visits DESC
LIMIT " . max(1, min(100, $limit))
);
$st->execute([$days]);
return $st->fetchAll(PDO::FETCH_ASSOC);
}
/** Conversões por formulário, nos últimos N dias. */
function platform_tracking_conversions_by_form(PDO $pdo, int $days = 30): array
{
$st = $pdo->prepare("
SELECT form, COUNT(*) AS n, MAX(created_at) AS last_seen
FROM tracking_conversions
WHERE created_at >= NOW() - INTERVAL ? DAY
GROUP BY form
ORDER BY n DESC
");
$st->execute([$days]);
return $st->fetchAll(PDO::FETCH_ASSOC);
}
-641
View File
@@ -1,641 +0,0 @@
<?php
// ─── Página de configuração do bloqueio por país ────────────────────────────
//
// O módulo (functions_geo.php) nasceu configurado só por .env, o que obrigava a
// recriar o contentor para mudar seja o que for. Para um interruptor que se
// mexe quando um cliente legítimo fica de fora, isso é demasiado atrito -- e
// não era coerente com o SMTP, que é igualmente infraestrutura e tem página.
//
// A página grava em system_settings, que ganha ao .env (ver geo_env()). O .env
// continua a valer como valor de arranque: um site que nunca abra esta página
// comporta-se exactamente como antes.
//
// Carregada por um stub em views/sysadmin/geo.php, como as outras partilhadas.
if (!isset($pdo) || !$pdo instanceof PDO) {
http_response_code(500);
exit('geo.php: sem PDO — o arranque do site (config/db.php) tem de abrir a ligação primeiro.');
}
require_once __DIR__ . '/functions_geo.php';
// O token do platform-tools, carregado aqui e não presumido: numa plataforma que
// não o tivesse carregado por outro caminho (a silver), o formulário saía sem
// token e todas as gravações davam «Token inválido» (2026-09-23).
require_once __DIR__ . '/functions_csrf.php';
// O próprio caminho, e não um base_url('views/sysadmin/...') fixo: o design
// põe as páginas partilhadas em views/admin/, e um caminho cravado aqui mandava
// os links desta página para um 404 nesse site.
$selfUrl = strtok((string) ($_SERVER['REQUEST_URI'] ?? ''), '?') ?: '';
// Pelo front controller a página é /platform?tool=geo: sem o `tool`, o redirect
// depois de gravar ia parar ao índice do Sistema em vez de voltar aqui.
if (isset($_GET['tool'])) $selfUrl .= '?tool=' . rawurlencode((string) $_GET['tool']);
$user = function_exists('current_user') ? current_user() : null;
$uid = isset($user['id']) ? (int) $user['id'] : null;
$probe = trim((string) ($_GET['probe'] ?? ''));
$probeResult = null;
// Gravar uma definição. Pela função do site quando existe — a fotografia e o
// dance limpam a sua cache de definições nela —, senão pela do platform-tools,
// que é de onde o geo_env() lê. Até 2026-09-23 chamava sempre a do site, e numa
// plataforma sem ela (a silver) gravar dava um erro fatal.
$saveSetting = function (string $key, string $value) use ($pdo, $uid): void {
if (function_exists('db_update_setting')) {
db_update_setting($pdo, $key, $value, $uid);
} else {
require_once __DIR__ . '/functions_settings.php';
platform_settings_set($pdo, $key, $value, $uid);
}
};
// ─── POST ────────────────────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$csrfOk = function_exists('csrf_verify')
? csrf_verify($_POST['csrf_token'] ?? '')
: (function_exists('platform_csrf_ok') && platform_csrf_ok());
if (!$csrfOk) {
$_SESSION['flash'] = ['type' => 'danger', 'msg' => 'Token inválido.'];
header('Location: ' . $selfUrl);
exit;
}
if (($_POST['action'] ?? 'save') === 'refresh') {
// Corre o mesmo script que o cron corre. Não se duplica a lógica de
// validação aqui: é ela que impede um download mau de substituir uma
// lista boa, e duas cópias divergiriam.
$countries = geo_allowed_countries();
if (!$countries) {
$_SESSION['flash'] = ['type' => 'warning', 'msg' => 'Defina primeiro os países permitidos.'];
} else {
$args = ' ' . implode(' ', array_map('escapeshellarg', $countries)) . ' 2>&1';
$out = [];
$code = 1;
exec(escapeshellcmd(__DIR__ . '/bin/geo-update.sh') . $args, $out, $code);
// O Relay corre a seguir e em separado: são duas fontes
// independentes, e a da Apple estar em baixo não pode impedir a
// actualização das listas de país (nem o contrário).
$relayOut = [];
$relayCode = 1;
exec(escapeshellcmd(__DIR__ . '/bin/geo-relay-update.sh') . $args, $relayOut, $relayCode);
$msg = implode(' · ', array_slice(array_merge($out, $relayOut), 0, 8));
$_SESSION['flash'] = ($code === 0 && $relayCode === 0)
? ['type' => 'success', 'msg' => 'Listas actualizadas. ' . $msg]
: ['type' => ($code === 0 ? 'warning' : 'danger'), 'msg' =>
($code === 0
? 'Países actualizados; o iCloud Private Relay falhou (lista anterior mantida). '
: 'A actualização falhou (as listas anteriores mantêm-se). ') . $msg];
}
header('Location: ' . $selfUrl);
exit;
}
// O botão do "acrescentar o meu endereço" grava só a lista de endereços e
// devolve a página. Parte do texto que veio no formulário, e não do que está
// gravado, para não deitar fora o que a pessoa tenha escrito antes de
// carregar -- que é a maneira mais rápida de alguém perder confiança numa
// página de configuração.
if (($_POST['action'] ?? 'save') === 'add_my_ip') {
$ip = platform_client_ip() ?? '';
$lista = preg_split('/[\s,;]+/', (string) ($_POST['geo_allow_ips'] ?? ''), -1, PREG_SPLIT_NO_EMPTY) ?: [];
if ($ip !== '' && !in_array($ip, $lista, true)) {
$lista[] = $ip;
}
$saveSetting('geo_allow_ips', implode(',', $lista));
$_SESSION['flash'] = ['type' => 'success', 'msg' => 'O endereço ' . $ip . ' passa sempre a partir de agora.'];
header('Location: ' . $selfUrl);
exit;
}
$mode = strtolower(trim((string) ($_POST['geo_mode'] ?? 'off')));
if (!in_array($mode, ['off', 'forms', 'site'], true)) {
$mode = 'off';
}
// As caixas mais o campo de escape, tratados da mesma maneira. Um código que
// não seja ISO-3166 alfa-2 é recusado com aviso, e não engolido: um país mal
// escrito na lista é indistinguível de "ninguém passa".
//
// O campo de escape existe porque a lista de caixas tem 72 países e não 249:
// ver o cabeçalho de data/countries-pt.php.
$rejected = [];
$valid = [];
$submetidos = array_merge(
array_map('strval', (array) ($_POST['geo_countries'] ?? [])),
preg_split('/[\s,;]+/', (string) ($_POST['geo_countries_extra'] ?? ''), -1, PREG_SPLIT_NO_EMPTY) ?: []
);
foreach ($submetidos as $cc) {
$up = strtoupper($cc);
if (preg_match('/^[A-Z]{2}$/', $up)) {
$valid[$up] = true;
} else {
$rejected[] = $cc;
}
}
$countries = implode(',', array_keys($valid));
$failClosed = !empty($_POST['geo_fail_closed']) ? 'true' : 'false';
// Guarda-rede: ligar o modo `site` sem dados é tirar a plataforma do ar
// para toda a gente. Com fail-open não chega a acontecer, mas a combinação
// `site` + fail-closed + sem listas é irrecuperável pelo browser.
$haveData = false;
foreach (array_keys($valid) as $cc) {
if (geo_ranges($cc, false) || geo_ranges($cc, true)) {
$haveData = true;
break;
}
}
if ($mode !== 'off' && $failClosed === 'true' && !$haveData) {
$_SESSION['flash'] = ['type' => 'danger', 'msg' =>
'Recusado: com "falhar fechado" e sem listas descarregadas, ninguém '
. 'conseguiria entrar — incluindo o utilizador. Actualize as listas primeiro.'];
header('Location: ' . $selfUrl);
exit;
}
// Endereços que passam sempre. Validados aqui pela mesma função que o
// módulo usa para os ler, para que uma entrada inválida seja recusada com
// aviso em vez de ficar gravada a não fazer nada -- que é indistinguível de
// "guardei e continua bloqueado".
$badIps = [];
$validIps = [];
foreach (preg_split('/[\s,;]+/', (string) ($_POST['geo_allow_ips'] ?? ''), -1, PREG_SPLIT_NO_EMPTY) ?: [] as $entry) {
if (geo_cidr_to_range($entry) !== null) {
$validIps[$entry] = true;
} else {
$badIps[] = $entry;
}
}
$allowIps = implode(',', array_keys($validIps));
$trustRelay = !empty($_POST['geo_trust_relay']) ? 'true' : 'false';
$saveSetting('geo_mode', $mode);
$saveSetting('geo_allow_countries', $countries);
$saveSetting('geo_fail_closed', $failClosed);
$saveSetting('geo_allow_ips', $allowIps);
$saveSetting('geo_trust_relay', $trustRelay);
if (function_exists('platform_audit')) {
platform_audit($pdo, 'settings.geo', ['target_type' => 'settings', 'summary' => 'Bloqueio por país guardado',
'changes' => ['geo_mode' => $mode, 'geo_allow_countries' => $countries, 'geo_fail_closed' => $failClosed,
'geo_allow_ips' => $allowIps, 'geo_trust_relay' => $trustRelay]]);
}
$ignorado = array_merge(
$rejected ? ['códigos de país: ' . implode(', ', $rejected)] : [],
$badIps ? ['endereços: ' . implode(', ', $badIps)] : []
);
$_SESSION['flash'] = $ignorado
? ['type' => 'warning', 'msg' => 'Guardado, mas isto não é válido e foi ignorado — ' . implode(' · ', $ignorado)]
: ['type' => 'success', 'msg' => 'Configuração guardada.'];
header('Location: ' . $selfUrl);
exit;
}
// ─── Estado actual ───────────────────────────────────────────────────────────
$mode = geo_mode();
$countries = geo_allowed_countries();
$failClosed = geo_fail_closed();
$allowIps = preg_split('/[\s,;]+/', (string) geo_env('GEO_ALLOW_IPS', ''), -1, PREG_SPLIT_NO_EMPTY) ?: [];
$trustRelay = geo_trust_relay();
// A lista de caixas, e os códigos já guardados que não constam dela -- esses
// aparecem à mesma, num grupo à parte: uma página que perde silenciosamente um
// valor gravado é pior do que uma página feia.
$grupos = require __DIR__ . '/data/countries-pt.php';
$conhecidos = array_merge(...array_map('array_keys', array_values($grupos)));
$foraDaLista = array_values(array_diff($countries, $conhecidos));
/** Há alguma lista de endereços para este país? (não abre os ficheiros) */
$temDados = function (string $cc): bool {
$base = GEO_DATA_DIR . '/' . strtolower($cc);
return is_file($base . '.v4') || is_file($base . '.v6');
};
$relayOk = geo_relay_available($countries);
// De onde veio cada valor — é o que explica um "guardei e não mudou nada".
$origin = function (string $key) use ($pdo): string {
$db = trim((string) platform_settings_get($pdo, strtolower($key), ''));
if ($db !== '') return 'base de dados';
return (function_exists('env') && (string) env($key, '') !== '') ? '.env' : 'omissão';
};
$dataFiles = [];
foreach ($countries as $cc) {
// Duas fontes por país: a ipdeny (alocações nacionais) e a Apple (saídas do
// Private Relay ao serviço desse país). Lado a lado na mesma tabela porque
// a pergunta do operador é uma só -- "tenho dados para PT?".
foreach ([['', 'ipdeny'], ['relay-', 'iCloud Relay']] as [$prefix, $fonte]) {
foreach (['v4', 'v6'] as $fam) {
$key = $prefix . strtolower($cc);
$f = GEO_DATA_DIR . '/' . $key . '.' . $fam;
$dataFiles[] = [
'cc' => $cc,
'fonte' => $fonte,
'fam' => $fam,
'exists' => is_file($f),
'ranges' => is_file($f) ? count(geo_ranges($key, $fam === 'v6')) : 0,
'mtime' => is_file($f) ? date('Y-m-d H:i', filemtime($f)) : '—',
];
}
}
}
if ($probe !== '') {
$probeResult = filter_var($probe, FILTER_VALIDATE_IP)
? ['ip' => $probe, 'reason' => geo_block_reason($probe), 'internal' => geo_ip_is_internal($probe)]
: ['ip' => $probe, 'invalid' => true];
}
$myIp = platform_client_ip() ?? '';
// O stub pode dizer onde fica o hub e a página de submissões bloqueadas; nem
// todos os sites os têm no mesmo caminho (o design usa views/admin/).
$hubUrl = $platform_dashboard_url
?? (function_exists('platform_admin_url') ? platform_admin_url('index.php') : 'index.php');
$blockedUrl = $platform_blocked_url
?? (function_exists('base_url') ? base_url('views/admin/blocked_submissions.php') : null);
$pageTitle = 'Bloqueio por país';
// O layout do site, como nas outras páginas partilhadas (o router passa-o a
// partir do platform_connect()). Até 2026-09-23 esta página fixava
// includes/header.php e ignorava-o: num site com layout noutro sítio saía sem
// cabeçalho nenhum.
$layout_header = $platform_layout_header ?? (dirname(__DIR__, 2) . '/includes/header.php');
$layout_navbar = $platform_layout_navbar ?? (dirname(__DIR__, 2) . '/includes/navbar.php');
$layout_footer = $platform_layout_footer ?? (dirname(__DIR__, 2) . '/includes/footer.php');
if (is_file($layout_header)) { include $layout_header; }
if (is_file($layout_navbar)) { include $layout_navbar; }
$csrfField = function_exists('csrf_field') ? csrf_field()
: (function_exists('platform_csrf_field') ? platform_csrf_field() : '');
$h = fn($s) => htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
?>
<div class="container py-4" style="max-width:920px;">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-4">
<div>
<h3 class="fw-bold mb-1"><i class="bi bi-globe-americas me-2"></i>Bloqueio por país</h3>
<p class="text-muted mb-0">Recusa submissões — ou o site inteiro — a quem chega de fora da lista.</p>
</div>
<a href="<?= $h($hubUrl) ?>" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Sistema
</a>
</div>
<?php if (!empty($_SESSION['flash'])): $f = $_SESSION['flash']; unset($_SESSION['flash']); ?>
<div class="alert alert-<?= $h($f['type']) ?> py-2"><?= $h($f['msg']) ?></div>
<?php endif; ?>
<?php
// Uma frase que diz o que está a acontecer neste momento. É a pergunta com
// que se abre esta página, e antes só se respondia lendo três campos e
// sabendo de cor o que cada um faz.
$paisesTxt = $countries ? implode('/', $countries) : '(nenhum)';
[$cor, $frase] = match ($mode) {
'forms' => ['primary', 'Os formulários públicos só aceitam envios de <strong>' . $h($paisesTxt)
. '</strong>. Ler o site continua aberto a toda a gente.'],
'site' => ['danger', 'O site inteiro responde <strong>403</strong> a quem vier de fora de <strong>'
. $h($paisesTxt) . '</strong>.'],
default => ['secondary', 'O filtro está <strong>desligado</strong>. Ninguém é recusado por causa do país.'],
};
$extras = [];
if ($mode !== 'off' && $trustRelay && $relayOk) {
$extras[] = 'O iCloud Private Relay é atribuído ao país que serve.';
}
if ($allowIps) {
$extras[] = count($allowIps) . ' endereço' . (count($allowIps) === 1 ? '' : 's')
. ' na lista de excepções, que passam sempre.';
}
?>
<div class="alert alert-<?= $cor ?> py-2">
<?= $frase ?>
<?php foreach ($extras as $e): ?><span class="d-block small mt-1"><?= $h($e) ?></span><?php endforeach; ?>
</div>
<?php if ($mode !== 'off' && !$countries): ?>
<div class="alert alert-warning py-2">
<strong>Filtro ligado sem países.</strong> Sem lista, o módulo fica inerte de propósito —
escolha pelo menos um país abaixo.
</div>
<?php elseif ($mode !== 'off' && !$dataFiles): ?>
<div class="alert alert-warning py-2">
<strong>Filtro ligado sem listas.</strong> Nada é bloqueado — o módulo falha aberto de
propósito. Carregue em "Actualizar listas" abaixo.
</div>
<?php endif; ?>
<div class="card shadow-sm mb-4">
<div class="card-body p-4">
<form method="POST">
<?= $csrfField ?>
<div class="mb-4">
<label class="form-label fw-semibold">Modo</label>
<?php foreach ([
'off' => ['Desligado', 'O módulo não faz nada.'],
'forms' => ['Só os formulários', 'Registo, contacto e aula experimental. Ler o site continua aberto a toda a gente — motores de busca e pré-visualizações de links incluídos.'],
'site' => ['O site inteiro', 'Responde 403 a quem vier de fora. Também esconde o site de qualquer crawler alojado no estrangeiro, o que raramente é o que se quer.'],
] as $val => [$label, $desc]): ?>
<div class="form-check mb-2">
<input class="form-check-input" type="radio" name="geo_mode" id="mode-<?= $val ?>"
value="<?= $val ?>" <?= $mode === $val ? 'checked' : '' ?>>
<label class="form-check-label" for="mode-<?= $val ?>">
<strong><?= $h($label) ?></strong>
<span class="d-block text-muted small"><?= $h($desc) ?></span>
</label>
</div>
<?php endforeach; ?>
<div class="form-text">Valor activo vem de: <strong><?= $h($origin('GEO_MODE')) ?></strong>.</div>
</div>
<div class="mb-4">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-2">
<label class="form-label fw-semibold mb-0">Países permitidos</label>
<span class="badge rounded-pill text-bg-light border">
<?= count($countries) ?> seleccionado<?= count($countries) === 1 ? '' : 's' ?>
</span>
</div>
<!-- Começa escondido e é o JavaScript que o mostra: um campo de
procura que não procura nada é pior do que não haver campo. -->
<input type="search" id="geo-filtro" class="form-control form-control-sm mb-3 d-none"
placeholder="Procurar país…" autocomplete="off" aria-label="Procurar país">
<?php foreach ($grupos as $grupo => $lista): ?>
<?php
// O grupo abre quando tem algum país escolhido lá dentro. Sem
// isto, a lista guardada podia estar toda dentro de secções
// fechadas e a página parecia vazia.
$temEscolha = (bool) array_intersect(array_keys($lista), $countries);
?>
<details class="mb-2 geo-grupo" <?= ($temEscolha || $grupo === 'Mais usados') ? 'open' : '' ?>>
<summary class="fw-semibold small text-uppercase text-muted py-1" style="cursor:pointer;">
<?= $h($grupo) ?>
<span class="text-body-tertiary fw-normal text-lowercase">(<?= count($lista) ?>)</span>
</summary>
<div class="row g-1 ps-1 pt-1">
<?php foreach ($lista as $cc => $nome): ?>
<div class="col-6 col-md-4 geo-pais" data-nome="<?= $h(mb_strtolower($nome . ' ' . $cc, 'UTF-8')) ?>">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="geo_countries[]"
value="<?= $h($cc) ?>" id="cc-<?= $h($cc) ?>"
<?= in_array($cc, $countries, true) ? 'checked' : '' ?>>
<label class="form-check-label" for="cc-<?= $h($cc) ?>">
<?= $h($nome) ?>
<span class="text-body-tertiary small"><?= $h($cc) ?></span>
<?php if (in_array($cc, $countries, true) && !$temDados($cc)): ?>
<span class="badge text-bg-warning ms-1">sem listas</span>
<?php endif; ?>
</label>
</div>
</div>
<?php endforeach; ?>
</div>
</details>
<?php endforeach; ?>
<?php if ($foraDaLista): ?>
<details class="mb-2" open>
<summary class="fw-semibold small text-uppercase text-muted py-1" style="cursor:pointer;">
Guardados, fora da lista acima
</summary>
<div class="row g-1 ps-1 pt-1">
<?php foreach ($foraDaLista as $cc): ?>
<div class="col-6 col-md-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="geo_countries[]"
value="<?= $h($cc) ?>" id="cc-<?= $h($cc) ?>" checked>
<label class="form-check-label" for="cc-<?= $h($cc) ?>">
<code><?= $h($cc) ?></code>
<?php if (!$temDados($cc)): ?>
<span class="badge text-bg-warning ms-1">sem listas</span>
<?php endif; ?>
</label>
</div>
</div>
<?php endforeach; ?>
</div>
</details>
<?php endif; ?>
<div class="row g-2 align-items-end mt-2">
<div class="col-sm-5">
<label for="geo_countries_extra" class="form-label small text-muted mb-1">
Outro país (código ISO)
</label>
<input type="text" class="form-control form-control-sm" id="geo_countries_extra"
name="geo_countries_extra" placeholder="IE, SN" value="">
</div>
<div class="col-sm-7">
<div class="form-text mt-0">
Para um país que não esteja nas caixas. Fica guardado e passa a aparecer aqui.
Valor activo vem de: <strong><?= $h($origin('GEO_ALLOW_COUNTRIES')) ?></strong>.
</div>
</div>
</div>
</div>
<div class="mb-4 p-3 rounded border bg-body-tertiary">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="geo_trust_relay"
name="geo_trust_relay" value="1" <?= $trustRelay ? 'checked' : '' ?>>
<label class="form-check-label" for="geo_trust_relay">
<strong>Aceitar o iCloud Private Relay</strong>
<span class="d-block text-muted small">
O Safari com Private Relay sai por endereços da Apple, registados onde a Apple os
registou e não onde está a pessoa — quem está em Aveiro aparece-nos como
estrangeiro e é recusado. A Apple publica o país que cada gama serve, e com esta
opção é esse país que conta. <strong>Não é uma excepção ao filtro:</strong> uma
saída ao serviço de outro país continua a ser recusada como qualquer endereço
estrangeiro.
</span>
</label>
</div>
<div class="small mt-2 ps-4">
<?php if (!$countries): ?>
<span class="text-muted">Defina primeiro os países.</span>
<?php elseif ($relayOk): ?>
<span class="text-success"><i class="bi bi-check-circle me-1"></i>Listas do Relay instaladas.</span>
<?php else: ?>
<span class="text-warning-emphasis">
<i class="bi bi-exclamation-triangle me-1"></i>
Ainda sem listas do Relay — carregue em <strong>Actualizar listas</strong>, mais abaixo.
Até lá esta opção não muda nada.
</span>
<?php endif; ?>
</div>
</div>
<div class="mb-4">
<label for="geo_allow_ips" class="form-label fw-semibold">Endereços que passam sempre</label>
<textarea class="form-control font-monospace" id="geo_allow_ips" name="geo_allow_ips"
rows="3" spellcheck="false"
placeholder="85.240.0.0/16&#10;2001:8a0::/32"><?= $h(implode("\n", $allowIps)) ?></textarea>
<div class="form-text">
Um por linha, endereço solto ou gama em CIDR. Passam antes de qualquer outra
verificação — incluindo quando as listas de país estão em falta, que é o caso em que
isto é a única defesa que ainda funciona. Para o cliente que ficou de fora hoje, para
a VPN do escritório, para um serviço de monitorização.
<?php if ($allowIps): ?>
Activos: <strong><?= count($allowIps) ?></strong>, de
<strong><?= $h($origin('GEO_ALLOW_IPS')) ?></strong>.
<?php endif; ?>
</div>
<?php if ($myIp !== '' && !geo_ip_allowlisted($myIp)): ?>
<button type="submit" name="action" value="add_my_ip" class="btn btn-outline-secondary btn-sm mt-2">
<i class="bi bi-plus-lg me-1"></i>Acrescentar o meu endereço (<?= $h($myIp) ?>)
</button>
<?php endif; ?>
</div>
<div class="mb-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="geo_fail_closed"
name="geo_fail_closed" value="1" <?= $failClosed ? 'checked' : '' ?>>
<label class="form-check-label" for="geo_fail_closed">
<strong>Falhar fechado</strong>
<span class="d-block text-muted small">
Sem esta opção (recomendado), um ficheiro de listas em falta deixa passar toda a
gente e regista o problema no log. Com ela, recusa. Ligá-la transforma um ficheiro
velho numa indisponibilidade total — é por isso que vem desligada.
</span>
</label>
</div>
</div>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>Guardar
</button>
</form>
</div>
</div>
<div class="card shadow-sm mb-4">
<div class="card-body p-4">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<h5 class="mb-0">Listas de endereços</h5>
<form method="POST" class="m-0">
<?= $csrfField ?>
<input type="hidden" name="action" value="refresh">
<button type="submit" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-repeat me-1"></i>Actualizar listas
</button>
</form>
</div>
<?php if (!$dataFiles): ?>
<p class="text-muted mb-0">Sem países definidos.</p>
<?php else: ?>
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead><tr><th>País</th><th>Origem</th><th>Família</th><th class="text-end">Gamas</th><th>Actualizado</th></tr></thead>
<tbody>
<?php foreach ($dataFiles as $d): ?>
<tr>
<td><?= $h($d['cc']) ?></td>
<td class="small"><?= $h($d['fonte']) ?></td>
<td><code><?= $h($d['fam']) ?></code></td>
<td class="text-end">
<?php if ($d['exists']): ?><?= (int) $d['ranges'] ?>
<?php else: ?><span class="badge bg-warning text-dark">em falta</span><?php endif; ?>
</td>
<td class="text-muted small"><?= $h($d['mtime']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<p class="form-text mb-0 mt-2">
Alocações nacionais da ipdeny.com (mensal chega — movem-se devagar) e saídas do iCloud
Private Relay publicadas pela Apple (semanal, que é o ritmo a que aparecem gamas novas).
As duas são independentes: uma em baixo não impede a outra de actualizar, e um download
curto ou inválido é recusado com a lista anterior a manter-se.
</p>
<?php endif; ?>
</div>
</div>
<div class="card shadow-sm">
<div class="card-body p-4">
<h5 class="mb-3">Testar um endereço</h5>
<form method="GET" class="row g-2 align-items-end">
<div class="col-sm-7">
<label for="probe" class="form-label small text-muted mb-1">IP a testar</label>
<input type="text" class="form-control" id="probe" name="probe"
value="<?= $h($probe) ?>" placeholder="<?= $h($myIp ?: '2.80.0.1') ?>">
</div>
<div class="col-sm-5">
<button type="submit" class="btn btn-outline-primary w-100">
<i class="bi bi-search me-1"></i>Verificar
</button>
</div>
</form>
<?php if ($probeResult !== null): ?>
<div class="mt-3">
<?php if (!empty($probeResult['invalid'])): ?>
<div class="alert alert-warning py-2 mb-0"><code><?= $h($probeResult['ip']) ?></code> não é um endereço IP válido.</div>
<?php elseif ($probeResult['reason'] === null): ?>
<div class="alert alert-success py-2 mb-0">
<code><?= $h($probeResult['ip']) ?></code> passa
<?= !empty($probeResult['internal']) ? ' — é um endereço interno, e esses passam sempre (cron e health checks do próprio contentor).' : '.' ?>
</div>
<?php else: ?>
<div class="alert alert-danger py-2 mb-0">
<code><?= $h($probeResult['ip']) ?></code> seria recusado: <?= $h($probeResult['reason']) ?>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if ($myIp !== ''): ?>
<p class="form-text mt-3 mb-0">
O seu endereço agora é <code><?= $h($myIp) ?></code>
<?php $mine = geo_block_reason($myIp); ?>
— <?= $mine === null ? 'passa' : '<strong class="text-danger">seria recusado: ' . $h($mine) . '</strong>' ?>.
</p>
<?php endif; ?>
</div>
</div>
<p class="text-muted small mt-4 mb-0">
As recusas ficam registadas com o motivo e o IP em
<a href="<?= $h($blockedUrl ?? '#') ?>">Submissões bloqueadas</a>.
</p>
</div>
<script>
// Procura entre as caixas de país. É enfeite: sem JavaScript o campo nem
// aparece (nasce com d-none) e a lista fica toda visível, que é o
// comportamento correcto -- a regra 10 do PHILOSOPHY.md diz que sem JS a
// página dá mais informação, não menos.
(function () {
var campo = document.getElementById('geo-filtro');
if (!campo) { return; }
campo.classList.remove('d-none');
campo.addEventListener('input', function () {
var termo = campo.value.trim().toLowerCase();
document.querySelectorAll('.geo-grupo').forEach(function (grupo) {
var visiveis = 0;
grupo.querySelectorAll('.geo-pais').forEach(function (pais) {
var bate = termo === '' || (pais.dataset.nome || '').indexOf(termo) !== -1;
pais.hidden = !bate;
if (bate) { visiveis++; }
});
// Abre o que tem resultados e esconde o grupo que ficou vazio, para
// não ficarem secções fechadas a esconder aquilo que se procurou.
grupo.hidden = visiveis === 0;
if (termo !== '' && visiveis > 0) { grupo.open = true; }
});
});
})();
</script>
<?php
if (is_file($layout_footer)) { include $layout_footer; }
-72
View File
@@ -1,72 +0,0 @@
<?php
// ─── Arranque partilhado dos endpoints do hub ────────────────────────────────
//
// Os três endpoints (health, ops, pair) são iguais em todas as plataformas, mas
// o arranque de cada site vive no seu config/db.php, que abre a ligação, e o
// config/functions.php com os helpers. (A silver tinha um stack `src/` à parte
// até 2026-09; passou para o mesmo contrato e o ramo dela saiu daqui.)
//
// Em vez de dezoito stubs com o arranque copiado — três endpoints × seis sites,
// e o dobro dos sítios onde uma correcção se esquece —, detecta-se aqui. Um
// site com um arranque próprio define $pdo antes de incluir isto e este ficheiro
// não lhe toca.
//
// ─── Plataformas sem base de dados ───────────────────────────────────────────
//
// O apex (andmore.pt) é uma montra estática: sem base de dados, sem sessão, sem
// autenticação, e montada read-only no contentor. Continua a ser uma plataforma
// da frota que vale a pena monitorizar — se estiver em baixo, é a primeira
// página que um cliente vê.
//
// Não pode é ser tratada como as outras. Aqui distingue-se **não ter** base de
// dados de **não conseguir** abri-la:
//
// não há config/db.php → não há camada de dados nenhuma,
// é o desenho do site. Segue sem.
// existe mas o $pdo não apareceu → avaria. 503, como antes.
//
// A distinção sai da presença dos ficheiros e não de um flag por site, porque um
// flag é uma coisa a mais para alguém pôr errada: um site com base de dados a
// arder responderia «sou estático, está tudo bem».
//
// Sem base de dados as chaves têm de vir do `.env` — o hub_setting_secret() já
// lê o .env primeiro, portanto não é caso especial nenhum; é o caminho normal
// sem o fallback.
//
// Deixa em cima da mesa: $pdo (ou null), $hub_has_db_layer, $repo_root, e o
// functions_hub.php carregado.
$repo_root = dirname(__DIR__, 2);
/** Este site tem sequer uma camada de dados? Ver a nota acima. */
$hub_has_db_layer = is_file($repo_root . '/config/db.php');
if (!isset($pdo) || !$pdo instanceof PDO) {
if (is_file($repo_root . '/config/db.php')) {
require_once $repo_root . '/config/db.php';
// Nem todas as plataformas carregam o functions.php a partir do db.php
// (a dsa e o hub carregam; a fotografia e o design não). O require_once
// sozinho não chega como guarda: quando o db.php o incluiu por outro
// caminho relativo, o realpath difere e o segundo include rebenta com
// "Cannot redeclare base_url()".
if (!function_exists('base_url') && is_file($repo_root . '/config/functions.php')) {
require_once $repo_root . '/config/functions.php';
}
}
}
require_once __DIR__ . '/functions_hub.php';
if ((!isset($pdo) || !$pdo instanceof PDO) && $hub_has_db_layer) {
// Este site TEM camada de dados e mesmo assim não chegou aqui um $pdo: é
// avaria, não desenho. Responder 401 daria a entender ao hub que a chave
// está errada quando o que falta é o arranque.
hub_json(['ok' => false, 'error' => 'esta plataforma não expôs uma ligação à base de dados ao endpoint do hub'], 503);
}
// Os endpoints leem-no como global (hub_pdo()); o $GLOBALS explícito cobre o
// caso de este ficheiro ser incluído de dentro de uma função num stub futuro.
// Numa plataforma estática fica a null de propósito — o hub_pdo() devolve null
// e quem depende de base de dados salta o seu check em vez de rebentar.
$GLOBALS['pdo'] = (isset($pdo) && $pdo instanceof PDO) ? $pdo : null;
$pdo = $GLOBALS['pdo'];
-371
View File
@@ -1,371 +0,0 @@
<?php
// ─── Integração com o hub (página de sysadmin partilhada) ────────────────────
//
// Gerar e rodar as chaves que o hub usa para ler o estado desta plataforma e
// para disparar cópias e atualizações, e fazer o anúncio que arranca o
// emparelhamento.
//
// Sysadmin: são credenciais de operações, não definições de conteúdo.
//
// Carregada por um stub em views/sysadmin/hub_integration.php (views/admin/ no
// design), como as outras páginas partilhadas — é o stub que abre a ligação,
// exige o sysadmin e diz onde estão o header e o footer do site.
if (!isset($pdo) || !$pdo instanceof PDO) {
http_response_code(500);
exit('hub_integration.php: sem PDO — o arranque do site (config/db.php) tem de abrir a ligação primeiro.');
}
require_once __DIR__ . '/functions_hub.php';
require_once __DIR__ . '/functions_csrf.php';
$repo_root = $repo_root ?? dirname(__DIR__, 2);
// O próprio caminho, e não um views/sysadmin/... cravado: o design põe as
// páginas partilhadas em views/admin/, e um caminho fixo mandava os links desta
// página para um 404 nesse site.
$selfUrl = strtok((string) ($_SERVER['REQUEST_URI'] ?? ''), '?') ?: '';
$me = function_exists('current_user') ? (current_user() ?: []) : [];
$uid = (int) ($me['id'] ?? 0) ?: null;
// As chaves em claro só existem nesta resposta, logo a seguir a serem geradas.
$fresh = null;
$freshKind = null;
$storageReady = hub_storage_ready($pdo);
// O que acabámos de gravar nesta mesma resposta. O settings_cache_load() memoiza
// num `static`, portanto um valor escrito neste request é invisível ao
// setting() a seguir — a página mostraria o valor antigo e pareceria não ter
// gravado. (Mesma armadilha do settings_claim_daily() na fotografia.)
$hubUrlJustSaved = null;
$justAnnounced = false;
$notice = null; // nem todos os sites da frota têm flash helpers
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !$storageReady) {
$notice = ['Falta a tabela system_settings nesta instalação — aplica as migrações primeiro.', 'danger'];
} elseif ($_SERVER['REQUEST_METHOD'] === 'POST') {
// $csrfField below picks csrf_field() (config/ stack: field `_csrf`,
// session key `csrf_token`) whenever it exists, falling back to this
// file's own platform_csrf_field() (field `_pt_csrf`, session key
// `platform_tools_csrf`) otherwise. Validation must check the SAME pair
// it rendered. It used to check for a `csrf_verify()` that no host's
// config/functions_csrf.php actually defines, so that branch never ran —
// every POST fell through to platform_csrf_ok(), which reads `_pt_csrf`
// and never matched the `_csrf` field the page actually renders on any
// config/-stack site. Result: every button here 403'd with "Pedido
// rejeitado" (found on beatriz.andmore.pt, 2026-09-13; likely affects
// every other config/-stack site's hub_integration.php too).
$csrfOk = function_exists('csrf_field')
? (($_POST['_csrf'] ?? '') !== '' && ($_SESSION['csrf_token'] ?? '') !== ''
&& hash_equals((string) $_SESSION['csrf_token'], (string) $_POST['_csrf']))
: platform_csrf_ok();
if (!$csrfOk) {
http_response_code(403);
exit('Pedido rejeitado (token CSRF ausente ou inválido). Recarrega a página.');
}
$act = (string) ($_POST['action'] ?? '');
$isOps = str_ends_with($act, '_ops');
$fromEnvNow = $isOps ? hub_ops_key_is_from_env() : hub_api_key_is_from_env();
if ($fromEnvNow && $act !== 'set_hub_url' && $act !== 'announce') {
$notice = ['Essa chave está fixada no .env desta instalação — mexer aqui não teria efeito.', 'warning'];
} elseif (str_starts_with($act, 'rotate')) {
$fresh = $isOps ? hub_ops_key_rotate($pdo, $uid) : hub_api_key_rotate($pdo, $uid);
$freshKind = $isOps ? 'operações' : 'leitura';
$notice = ['Chave gerada. Copia-a agora — não volta a ser mostrada.', 'success'];
} elseif ($act === 'set_hub_url') {
// Sem shell não há como mexer no .env; daí o URL poder ser definido
// aqui. O .env continua a ganhar quando existe.
$url = rtrim(trim((string) ($_POST['hub_url'] ?? '')), '/');
if ($url !== '' && !str_starts_with($url, 'https://')) {
$notice = ['O URL do hub tem de ser https — uma credencial vai atravessar este caminho.', 'danger'];
} else {
hub_setting_set($pdo, 'hub_url', $url, $uid);
$hubUrlJustSaved = $url;
$notice = [$url === '' ? 'URL do hub removido.' : 'URL do hub guardado.', 'success'];
}
} elseif ($act === 'announce') {
$r = hub_announce($pdo, $uid);
$justAnnounced = !empty($r['ok']);
$notice = [$r['ok']
? 'Plataforma anunciada a ' . $r['hub'] . '. Aprova-a na Frota do hub para concluir.'
: 'Não foi possível anunciar: ' . $r['error'], $r['ok'] ? 'success' : 'danger'];
} elseif (str_starts_with($act, 'clear')) {
if ($isOps) {
hub_ops_key_clear($pdo, $uid);
$notice = ['Chave de operações removida — o hub deixa de poder disparar cópias ou atualizações.', 'warning'];
} else {
hub_api_key_clear($pdo, $uid);
$notice = ['Chave de leitura removida — o hub deixa de conseguir ver esta plataforma.', 'warning'];
}
}
// As chaves nunca entram no registo — só que foram mexidas, por quem e quando.
if ($act !== '' && isset($notice) && ($notice[1] ?? '') !== 'danger' && function_exists('platform_audit')) {
platform_audit($pdo instanceof PDO ? $pdo : null, 'hub.' . $act, ['target_type' => 'hub', 'summary' => (string) $notice[0]]);
}
}
$key = hub_api_key();
$fromEnv = hub_api_key_is_from_env();
$opsKey = hub_ops_key();
$opsFromEnv = hub_ops_key_is_from_env();
$backups = hub_backup_summary();
$hubUrl = $hubUrlJustSaved ?? hub_url();
$pairing = hub_pairing_state();
if ($justAnnounced) {
// Idem: o estado do emparelhamento acabou de mudar neste request.
$pairing['state'] = 'announced';
$pairing['hub'] = $hubUrl;
$pairing['expired'] = false;
}
$backupsUrl = function_exists('platform_admin_url') ? platform_admin_url('backups.php') : null;
$page_title = 'Integração com o hub';
$pageTitle = $page_title; // os headers da frota não usam todos o mesmo nome
$layout_header = $platform_layout_header ?? ($repo_root . '/includes/header.php');
if (is_file($layout_header)) include $layout_header;
$csrfField = function_exists('csrf_field') ? csrf_field() : platform_csrf_field();
$h = fn($s) => htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
?>
<div class="container py-4">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
<h1 class="h3 mb-0">Integração com o hub</h1>
<?php if ($backupsUrl): ?>
<a class="btn btn-sm btn-outline-secondary" href="<?= $h($backupsUrl) ?>"><i class="bi bi-archive me-1"></i>Cópias de segurança</a>
<?php endif; ?>
</div>
<?php if ($notice): ?>
<div class="alert alert-<?= $h($notice[1]) ?> alert-dismissible fade show">
<?= $h($notice[0]) ?>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
<?php endif; ?>
<?php if (!$storageReady): ?>
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>Esta instalação não tem a tabela <code>system_settings</code>.</strong>
É onde as chaves e o URL do hub são guardados — sem ela não há emparelhamento nem
geração de chaves. Aplica as migrações pendentes em <em>Sistema → Atualizar plataforma</em>
e volta aqui.
</div>
<?php endif; ?>
<div class="row g-4">
<div class="col-lg-7">
<div class="card mb-3"><div class="card-body">
<h6 class="text-muted small text-uppercase">Chave de API</h6>
<?php if ($fresh !== null): ?>
<div class="alert alert-success">
<div class="fw-semibold mb-1"><i class="bi bi-key me-1"></i>Chave de <?= $h($freshKind) ?> — copia-a agora</div>
<div class="input-group input-group-sm">
<input class="form-control font-monospace" id="freshKey" readonly value="<?= $h($fresh) ?>">
<button class="btn btn-outline-secondary" type="button" id="copyKey"><i class="bi bi-clipboard"></i></button>
</div>
<div class="small mt-2 mb-0">
Fica guardada cifrada. Esta é a única vez que aparece em claro — se a perderes, gera outra.
</div>
</div>
<?php endif; ?>
<?php
// O mesmo bloco serve as duas chaves: só mudam os rótulos e o
// name da ação. Duas cópias divergiriam à primeira alteração.
$_render_key = function (string $kind, string $key, bool $fromEnv, string $envName, string $help) use ($csrfField, $h) {
$suffix = $kind === 'ops' ? '_ops' : '';
?>
<?php if ($fromEnv): ?>
<p class="mb-2"><span class="badge text-bg-secondary">Fixada no .env</span></p>
<p class="small text-muted">
Esta instalação define <code><?= $h($envName) ?></code> no <code>.env</code>, e o
<code>.env</code> ganha. É a configuração mais segura — um valor no <code>.env</code> nunca entra
numa cópia da base de dados. Para gerir por aqui, remove-a de lá.
</p>
<?php elseif ($key !== ''): ?>
<p class="mb-2"><span class="badge text-bg-success">Configurada</span>
<span class="text-muted small">guardada cifrada na base de dados</span></p>
<?php else: ?>
<p class="mb-2"><span class="badge text-bg-warning text-dark">Por configurar</span></p>
<p class="small text-muted"><?= $h($help) ?></p>
<?php endif; ?>
<?php if (!$fromEnv): ?>
<div class="d-flex gap-2 flex-wrap">
<form method="POST" onsubmit="return confirm('<?= $key !== '' ? 'Gerar uma chave nova invalida a atual — terás de a colar no hub. Continuar?' : 'Gerar chave?' ?>');"><?= $csrfField ?>
<input type="hidden" name="action" value="rotate<?= $suffix ?>">
<button class="btn btn-sm btn-primary"><i class="bi bi-arrow-repeat me-1"></i><?= $key !== '' ? 'Gerar nova' : 'Gerar chave' ?></button>
</form>
<?php if ($key !== ''): ?>
<form method="POST" onsubmit="return confirm('Remover esta chave?');"><?= $csrfField ?>
<input type="hidden" name="action" value="clear<?= $suffix ?>">
<button class="btn btn-sm btn-outline-danger"><i class="bi bi-x-circle me-1"></i>Remover</button>
</form>
<?php endif; ?>
</div>
<?php endif;
};
?>
<p class="small text-muted">
Duas chaves de propósito: a de <strong>leitura</strong> é sondada de poucos em poucos minutos e só
devolve estado; a de <strong>operações</strong> dispara cópias e atualizações e usa-se à mão.
Assim, quem sonda não consegue disparar nada.
</p>
<div class="mb-3">
<div class="fw-semibold small text-uppercase text-muted mb-1">Leitura (estado)</div>
<?php $_render_key('read', $key, $fromEnv, 'HUB_HEALTH_KEY', 'Sem esta chave o api/health.php responde 503 e o hub não vê nada.'); ?>
</div>
<hr>
<div>
<div class="fw-semibold small text-uppercase text-muted mb-1">Operações (cópias e atualizações)</div>
<?php $_render_key('ops', $opsKey, $opsFromEnv, 'HUB_OPS_KEY', 'Sem esta chave as operações remotas ficam desligadas — o estado continua a ser lido na mesma.'); ?>
</div>
</div></div>
<?php // ── Ligação rápida ────────────────────────────────────────
// O emparelhamento arranca aqui mas fecha no hub: esta
// plataforma anuncia-se (sem chaves) e fica à espera de
// aprovação. Ver a nota em functions_hub.php. ?>
<div class="card mb-3 border-primary"><div class="card-body">
<h6 class="text-muted small text-uppercase">Ligação rápida ao hub</h6>
<?php $hubFromEnv = trim(hub_env('HUB_URL', '')) !== ''; ?>
<form method="POST" class="mb-3"><?= $csrfField ?>
<input type="hidden" name="action" value="set_hub_url">
<label class="form-label small text-muted mb-1" for="hub-url">URL do hub</label>
<div class="input-group input-group-sm">
<input class="form-control" id="hub-url" name="hub_url" type="url"
placeholder="https://hub.andmore.pt"
<?= $hubFromEnv ? 'disabled' : '' ?>
value="<?= $h($hubUrl) ?>">
<button class="btn btn-outline-secondary" <?= $hubFromEnv ? 'disabled' : '' ?>>Guardar</button>
</div>
<div class="form-text">
<?= $hubFromEnv
? 'Fixado em <code>HUB_URL</code> no .env desta instalação.'
: 'Em desenvolvimento aponta para o hub de desenvolvimento; em produção, para o de produção.' ?>
</div>
</form>
<?php if ($hubUrl === ''): ?>
<p class="small text-muted mb-0">Define o URL acima para poderes anunciar esta plataforma.</p>
<?php else: ?>
<?php if ($pairing['state'] === 'announced' && !$pairing['expired']): ?>
<div class="alert alert-info py-2 small mb-2">
<i class="bi bi-hourglass-split me-1"></i>
Anunciada a <code><?= $h($pairing['hub']) ?></code> — falta aprovar na
<strong>Frota</strong> do hub. As chaves só são geradas nesse momento.
</div>
<?php elseif ($pairing['state'] === 'paired'): ?>
<div class="alert alert-success py-2 small mb-2">
<i class="bi bi-check-circle me-1"></i>Emparelhada com <code><?= $h($pairing['hub']) ?></code>.
</div>
<?php elseif ($pairing['state'] === 'announced' && $pairing['expired']): ?>
<div class="alert alert-warning py-2 small mb-2">
O anúncio anterior expirou sem ser aprovado. Anuncia outra vez.
</div>
<?php endif; ?>
<form method="POST"><?= $csrfField ?>
<input type="hidden" name="action" value="announce">
<button class="btn btn-sm btn-primary">
<i class="bi bi-broadcast me-1"></i><?= $pairing['state'] ? 'Anunciar outra vez' : 'Anunciar ao hub' ?>
</button>
</form>
<p class="small text-muted mt-2 mb-0">
O anúncio leva só o URL e um código de emparelhamento — <strong>nenhuma chave sai daqui</strong>.
Voltar a anunciar gera um código novo e invalida o anterior.
</p>
<?php endif; ?>
</div></div>
<div class="card mb-3"><div class="card-body">
<h6 class="text-muted small text-uppercase">O que dar ao hub</h6>
<p class="small text-muted mb-2">No hub, em Frota → esta app, cola o URL base e a chave.</p>
<dl class="row small mb-0">
<dt class="col-sm-4">Estado (leitura)</dt>
<dd class="col-sm-8"><code>GET <?= $h(hub_base_url('api/health.php')) ?></code></dd>
<dt class="col-sm-4">Operações</dt>
<dd class="col-sm-8"><code>POST <?= $h(hub_base_url('api/hub_ops.php')) ?></code></dd>
<dt class="col-sm-4">Cabeçalho</dt>
<dd class="col-sm-8"><code>X-Hub-Key: &lt;chave&gt;</code> — a de leitura no estado, a de operações no resto</dd>
</dl>
<p class="small text-muted mt-2 mb-0">
O <code>Authorization: Bearer</code> também é aceite, mas o proxy à frente dos contentores
remove esse cabeçalho — daí o <code>X-Hub-Key</code> ser o caminho principal.
</p>
</div></div>
</div>
<div class="col-lg-5">
<div class="card mb-3"><div class="card-body">
<h6 class="text-muted small text-uppercase">O que o hub vai ver</h6>
<ul class="list-unstyled small mb-0">
<li class="py-1 border-bottom border-opacity-10"><i class="bi bi-activity me-1"></i>Base de dados, SMTP, fila de envios, migrações por aplicar, espaço em disco</li>
<li class="py-1 border-bottom border-opacity-10"><i class="bi bi-archive me-1"></i>Última cópia de ficheiros e da base de dados, com a idade</li>
<li class="py-1"><i class="bi bi-git me-1"></i>Ramo e último commit, quando esta instalação é um repositório git</li>
</ul>
</div></div>
<div class="card mb-3"><div class="card-body">
<h6 class="text-muted small text-uppercase">O que o hub vai poder disparar</h6>
<ul class="list-unstyled small mb-2">
<li class="py-1"><code>backup.trigger</code> — cópia de ficheiros ou da base de dados</li>
<li class="py-1"><code>git.pull</code> + <code>migrations.apply_pending</code> — atualização da plataforma</li>
</ul>
<p class="small text-muted mb-0">
Num alojamento cPanel o <code>git.pull</code> costuma não estar disponível. Isso está previsto:
a resposta explica-o e <strong>as cópias continuam a funcionar</strong>, que é a parte que
protege o cliente.
</p>
</div></div>
<div class="card"><div class="card-body">
<h6 class="text-muted small text-uppercase">Cópias agora</h6>
<?php if (empty($backups['available'])): ?>
<p class="small text-muted mb-0">Cópias não disponíveis nesta instalação.</p>
<?php else: ?>
<dl class="row small mb-0">
<dt class="col-5">Base de dados</dt>
<dd class="col-7"><?= $backups['db']
? $h($backups['db']['size_human']) . ' · há ' . $h((string) $backups['db']['age_hours']) . ' h'
: '<span class="text-warning">nenhuma</span>' ?></dd>
<dt class="col-5">Ficheiros</dt>
<dd class="col-7"><?= $backups['files']
? $h($backups['files']['size_human']) . ' · há ' . $h((string) $backups['files']['age_hours']) . ' h'
: '<span class="text-warning">nenhuma</span>' ?></dd>
<dt class="col-5">Total</dt>
<dd class="col-7"><?= (int) $backups['count'] ?> ficheiro(s)</dd>
</dl>
<?php endif; ?>
</div></div>
</div>
</div>
</div>
<script>
document.getElementById('copyKey')?.addEventListener('click', function () {
var i = document.getElementById('freshKey');
i.select();
navigator.clipboard.writeText(i.value).then(function () {
if (window.AdminUX) AdminUX.toast('Chave copiada.');
}).catch(function () {
if (window.AdminUX) AdminUX.toast('Copia com Ctrl+C.', 'warning');
});
});
</script>
<?php
$layout_footer = $platform_layout_footer ?? ($repo_root . '/includes/footer.php');
if (is_file($layout_footer)) include $layout_footer;
-185
View File
@@ -1,185 +0,0 @@
<?php
require_once __DIR__ . '/functions_settings.php'; // platform_admin_url()
// ─── Shared sysadmin hub ─────────────────────────────────────────────────────
//
// Landing page for the sysadmin area. The host navbar points only here; every
// other sysadmin tool is reached via the cards on this page.
//
// Bootstrap contract — stub at <host>/views/sysadmin/index.php must:
// 1. Load $pdo (config/db.php + functions.php)
// 2. Run require_sysadmin()
// 3. Set $repo_root
// Optional overrides (same as platform_update.php / backups.php):
// $platform_layout_header / $platform_layout_navbar / $platform_layout_footer
// $sysadmin_tools — array of card definitions; merged with the defaults below.
// Each entry: ['title'=>str, 'description'=>str, 'icon'=>str (bi-*),
// 'url'=>str, 'badge'=>?str, 'color'=>str (primary/info/…)]
if (!isset($repo_root) || !is_string($repo_root)) {
$repo_root = realpath(dirname(__DIR__, 2)) ?: dirname(__DIR__, 2);
}
$repo_root = rtrim($repo_root, '/');
$base = function (string $path): string {
return function_exists('base_url') ? base_url($path) : '/' . ltrim($path, '/');
};
$default_tools = [
[
'title' => 'Atualizar plataforma',
'description' => 'Git pull/push, OPcache, migrations e log de erros.',
'icon' => 'bi-arrow-repeat',
'url' => platform_admin_url('platform_update.php'),
'color' => 'primary',
],
[
'title' => 'Backups',
'description' => 'Criar e transferir cópias de segurança dos uploads e da base de dados.',
'icon' => 'bi-archive',
'url' => platform_admin_url('backups.php'),
'color' => 'secondary',
],
[
'title' => 'Estado SMTP',
'description' => 'Configuração do envio de email, teste de ligação e mensagem de teste.',
'icon' => 'bi-envelope-check',
'url' => platform_admin_url('smtp_status.php'),
'color' => 'info',
],
// Por omissão e não site a site: a partir do momento em que o cliente do hub
// é partilhado, uma plataforma que o tenha instalado sem mostrar a página
// fica ligada ao hub sem ninguém saber por onde a desligar.
[
'title' => 'Integração com o hub',
'description' => 'Anunciar esta plataforma à Frota e gerir as chaves de leitura e de operações.',
'icon' => 'bi-hdd-network',
'url' => platform_admin_url('hub_integration.php'),
'color' => 'secondary',
// Um site que ainda não tenha o stub não deve mostrar um cartão para 404.
'available' => (function_exists('platform_router_enabled') && platform_router_enabled())
|| is_file($repo_root . '/public/views/sysadmin/hub_integration.php')
|| is_file($repo_root . '/public/views/admin/hub_integration.php'),
],
];
// Com o router, todas as páginas partilhadas existem em todos os sites — por
// isso aparecem sozinhas, e uma ferramenta nova chega a toda a frota sem cartão
// nenhum a escrever site a site. Sem router ficam de fora: cada site tem só os
// stubs que tem, e um cartão para 404 é pior do que cartão nenhum.
$routed = function_exists('platform_router_enabled') && platform_router_enabled();
$default_tools = array_merge($default_tools, [
[
'title' => 'Bloqueio de IP',
'description' => 'Endereços recusados à porta por atividade automatizada repetida, e quem está a caminho disso.',
'icon' => 'bi-shield-slash',
'url' => platform_admin_url('ip_bans.php'),
'color' => 'warning',
'available' => $routed,
],
[
'title' => 'Bloqueio por país',
'description' => 'Recusar formulários — ou o site — a quem chega de fora do país.',
'icon' => 'bi-globe-americas',
'url' => platform_admin_url('geo.php'),
'color' => 'warning',
'available' => $routed,
],
[
'title' => 'Restaurar base de dados',
'description' => 'Aplicar um backup de base de dados, com inspecção prévia e cópia de segurança automática do estado actual.',
'icon' => 'bi-arrow-counterclockwise',
'url' => platform_admin_url('restore.php'),
'color' => 'danger',
'available' => $routed,
],
[
'title' => 'Origem das visitas',
'description' => 'De onde vêm as pessoas, com que dispositivo e de que país — e quais dessas origens produzem pedidos.',
'icon' => 'bi-graph-up-arrow',
'url' => platform_admin_url('tracking.php'),
'color' => 'success',
'available' => $routed,
],
[
'title' => 'Registo de auditoria',
'description' => 'Quem fez o quê, a quê e quando — com o antes e o depois de cada alteração.',
'icon' => 'bi-journal-text',
'url' => platform_admin_url('audit.php'),
'color' => 'secondary',
'available' => $routed,
],
]);
// Um site pode esconder um cartão por omissão declarando
// $platform_hide_tools = ['smtp_status.php'] no seu stub. Serve para quando a
// plataforma tem uma página própria que já faz o mesmo -- o dance tem as
// Comunicações, que editam a configuração e correm os mesmos testes, e ter os
// dois cartões lado a lado só faz hesitar em qual clicar.
$hide = array_map('strval', $platform_hide_tools ?? []);
if ($hide) {
$default_tools = array_filter(
$default_tools,
fn($t) => !array_filter($hide, fn($h) => str_contains((string) $t['url'], $h))
);
}
// Cards podem declarar 'available' => bool para serem escondidos quando o
// servidor não suporta a ferramenta (ex.: exige proc_open). Ausente = visível.
$tools = array_filter(
array_merge($default_tools, $sysadmin_tools ?? []),
fn($t) => !isset($t['available']) || $t['available']
);
// Um cartão próprio que aponta para o mesmo sítio que um por omissão não o
// repete (um site que ainda declara «Bloqueio de IP» à mão, por exemplo).
$seen = [];
$tools = array_filter($tools, function ($t) use (&$seen) {
$url = (string) ($t['url'] ?? '');
if ($url === '' || isset($seen[$url])) return $url === '';
return $seen[$url] = true;
});
$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');
$pageTitle = 'Sistema';
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;">
<div class="mb-4">
<h2 class="mb-1"><i class="bi bi-shield-lock me-2"></i>Sistema</h2>
<p class="text-muted small mb-0">Ferramentas de administração da plataforma.</p>
</div>
<div class="row g-3">
<?php foreach ($tools as $t): ?>
<div class="col-12 col-md-6">
<a href="<?= htmlspecialchars($t['url']) ?>"
class="card shadow-sm h-100 text-decoration-none text-reset border-0">
<div class="card-body d-flex gap-3">
<div class="flex-shrink-0">
<span class="d-inline-flex align-items-center justify-content-center rounded-3 text-bg-<?= htmlspecialchars($t['color'] ?? 'primary') ?>"
style="width:48px;height:48px;font-size:1.5rem;">
<i class="bi <?= htmlspecialchars($t['icon'] ?? 'bi-tools') ?>"></i>
</span>
</div>
<div class="flex-grow-1">
<div class="d-flex align-items-center gap-2">
<h5 class="mb-0"><?= htmlspecialchars($t['title']) ?></h5>
<?php if (!empty($t['badge'])): ?>
<span class="badge text-bg-warning"><?= htmlspecialchars($t['badge']) ?></span>
<?php endif; ?>
</div>
<p class="text-muted small mb-0 mt-1"><?= htmlspecialchars($t['description']) ?></p>
</div>
<div class="flex-shrink-0 align-self-center">
<i class="bi bi-chevron-right text-muted"></i>
</div>
</div>
</a>
</div>
<?php endforeach; ?>
</div>
</div>
<?php if (is_file($layout_footer)) include $layout_footer; ?>
-228
View File
@@ -1,228 +0,0 @@
<?php
// ─── Página de bloqueio de IP ───────────────────────────────────────────────
//
// Contrato do stub, como as outras partilhadas: $pdo, guarda de sysadmin,
// $repo_root.
//
// Duas listas, e a segunda é a que interessa vigiar: os banidos dizem o que já
// aconteceu, os "a subir" dizem o que está a acontecer agora. Um endereço com
// duas infracções ainda não foi banido -- ver a lista antes de ele lá chegar é
// a diferença entre perceber um padrão e descobri-lo depois.
//
// Levantar um banimento não apaga as infracções: se apagasse, o endereço voltava
// ao degrau zero e a escalada recomeçava do princípio, o que faria de cada
// engano de um administrador um convite a repetir tudo.
if (!isset($pdo) || !$pdo instanceof PDO) {
http_response_code(500);
exit('ip_bans.php: sem PDO — o arranque do site (config/db.php) tem de abrir a ligação primeiro.');
}
require_once __DIR__ . '/functions_ip_ban.php';
require_once __DIR__ . '/functions_csrf.php';
$selfUrl = strtok((string) ($_SERVER['REQUEST_URI'] ?? ''), '?') ?: '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!platform_csrf_ok()) {
$_SESSION['ipban_flash'] = ['type' => 'danger', 'msg' => 'Pedido rejeitado (token CSRF inválido).'];
header('Location: ' . $selfUrl, true, 303);
exit;
}
$uid = isset($_SESSION['user']['id']) ? (int) $_SESSION['user']['id'] : null;
$action = $_POST['action'] ?? '';
$ip = trim((string) ($_POST['ip'] ?? ''));
if ($action === 'unban' && $ip !== '') {
platform_ip_unban($pdo, $ip);
if (function_exists('platform_audit')) platform_audit($pdo, 'ip.unban', ['target_type' => 'ip', 'target_label' => $ip]);
$_SESSION['ipban_flash'] = ['type' => 'success', 'msg' => "Banimento de $ip levantado. O histórico de infracções mantém-se."];
} elseif ($action === 'ban') {
if (!filter_var($ip, FILTER_VALIDATE_IP)) {
$_SESSION['ipban_flash'] = ['type' => 'warning', 'msg' => 'Endereço IP inválido.'];
} elseif (platform_ip_ban_exempt($ip)) {
$_SESSION['ipban_flash'] = ['type' => 'warning', 'msg' =>
'Endereços internos não podem ser banidos — o contentor fala consigo próprio para o cron e os health checks.'];
} else {
$dur = $_POST['duration'] ?? '60';
$mins = $dur === 'perm' ? null : max(1, (int) $dur);
$reason = trim((string) ($_POST['reason'] ?? '')) ?: 'banido manualmente';
platform_ip_ban($pdo, $ip, $reason, $mins, 'manual', $uid);
if (function_exists('platform_audit')) platform_audit($pdo, 'ip.ban', ['target_type' => 'ip', 'target_label' => $ip,
'summary' => $reason, 'changes' => ['minutes' => $mins]]);
$_SESSION['ipban_flash'] = ['type' => 'success', 'msg' => "$ip banido."];
}
}
header('Location: ' . $selfUrl, true, 303);
exit;
}
$flash = $_SESSION['ipban_flash'] ?? null;
unset($_SESSION['ipban_flash']);
$bans = platform_ip_ban_list($pdo);
$offenders = platform_ip_offenders($pdo, 7);
$activos = array_values(array_filter($bans, fn($b) => empty($b['expired'])));
$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');
$pageTitle = 'Bloqueio de IP';
if (is_file($layout_header)) include $layout_header;
if (is_file($layout_navbar)) include $layout_navbar;
$h = fn($s) => htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
?>
<div class="container py-4" style="max-width:960px;">
<div class="mb-4">
<h3 class="fw-bold mb-1"><i class="bi bi-shield-slash me-2"></i>Bloqueio de IP</h3>
<p class="text-muted mb-0">
Endereços recusados à porta por atividade automatizada repetida.
</p>
</div>
<?php if ($flash): ?>
<div class="alert alert-<?= $h($flash['type']) ?> py-2"><?= $h($flash['msg']) ?></div>
<?php endif; ?>
<div class="alert alert-light border small">
<strong>Como a escalada funciona.</strong> Cada recusa do antispam, do portão de
país ou do throttle de login conta como uma infracção. A partir de
<strong>3 infracções em 24 horas</strong> o endereço é banido 1 hora; às 5, um dia;
às 8, uma semana; às 12, permanentemente.
<span class="d-block mt-1 text-muted">
A subida é lenta de propósito: um IP é muitas vezes partilhado — uma escola, um
escritório, o CGNAT de um operador móvel — e banir para sempre à terceira
tentativa apanha pessoas a mais.
</span>
<span class="d-block mt-1 text-muted">
Com o site emparelhado com o hub, os banimentos daqui passam aos outros sites da frota e os
deles chegam cá com a origem <strong>frota</strong>. Levantar um banimento retira as cópias de
todos os sites; o site onde ele nasceu mantém o seu até expirar ou ser levantado lá.
</span>
</div>
<div class="card shadow-sm mb-4">
<div class="card-header fw-semibold">
<i class="bi bi-slash-circle me-1"></i>Banidos
<span class="badge text-bg-secondary ms-1"><?= count($activos) ?> activo<?= count($activos) === 1 ? '' : 's' ?></span>
</div>
<div class="card-body">
<?php if (!$bans): ?>
<p class="text-muted mb-0">Nenhum endereço banido até agora.</p>
<?php else: ?>
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead><tr><th>IP</th><th>Motivo</th><th>Origem</th><th>Até</th><th class="text-end">Infr.</th><th class="text-end">Tentat.</th><th></th></tr></thead>
<tbody>
<?php foreach ($bans as $b): $activo = empty($b['expired']); ?>
<tr class="<?= $activo ? '' : 'text-muted' ?>">
<td><code><?= $h($b['ip']) ?></code></td>
<td class="small"><?= $h($b['reason']) ?></td>
<td><span class="badge text-bg-<?= match ($b['source']) { 'manual' => 'primary', 'fleet' => 'info', default => 'secondary' } ?>"
<?= $b['source'] === 'fleet' ? 'title="Banido noutro site da frota. Levantar aqui retira as cópias de todos os sites."' : '' ?>><?= $h($b['source'] === 'fleet' ? 'frota' : $b['source']) ?></span></td>
<td class="small">
<?php if ($b['expires_at'] === null): ?>
<span class="badge text-bg-danger">permanente</span>
<?php elseif ($activo): ?>
<?= $h(substr((string) $b['expires_at'], 0, 16)) ?>
<?php else: ?>
expirou
<?php endif; ?>
</td>
<td class="text-end"><?= (int) $b['offences'] ?></td>
<td class="text-end"><?= (int) $b['hits'] ?></td>
<td class="text-end">
<?php if ($activo): ?>
<form method="post" class="m-0 d-inline">
<?= platform_csrf_field() ?>
<input type="hidden" name="action" value="unban">
<input type="hidden" name="ip" value="<?= $h($b['ip']) ?>">
<button class="btn btn-outline-secondary btn-sm">Levantar</button>
</form>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<p class="form-text mb-0 mt-2">
<strong>Tentat.</strong> é quantas vezes o endereço voltou depois de banido —
um número alto é o sinal mais claro de que não é uma pessoa.
</p>
<?php endif; ?>
</div>
</div>
<div class="card shadow-sm mb-4">
<div class="card-header fw-semibold"><i class="bi bi-graph-up me-1"></i>A subir — infracções sem banimento (7 dias)</div>
<div class="card-body">
<?php if (!$offenders): ?>
<p class="text-muted mb-0">Nenhuma infracção registada no período.</p>
<?php else: ?>
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead><tr><th>IP</th><th class="text-end">Infracções</th><th>Tipos</th><th>Última</th><th></th></tr></thead>
<tbody>
<?php foreach ($offenders as $o): ?>
<tr>
<td><code><?= $h($o['ip']) ?></code></td>
<td class="text-end fw-semibold"><?= (int) $o['n'] ?></td>
<td class="small text-muted"><?= $h($o['kinds']) ?></td>
<td class="small text-muted"><?= $h(substr((string) $o['last_seen'], 0, 16)) ?></td>
<td class="text-end">
<form method="post" class="m-0 d-inline">
<?= platform_csrf_field() ?>
<input type="hidden" name="action" value="ban">
<input type="hidden" name="ip" value="<?= $h($o['ip']) ?>">
<input type="hidden" name="duration" value="1440">
<input type="hidden" name="reason" value="banido a partir da lista de infracções">
<button class="btn btn-outline-danger btn-sm">Banir 24h</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
<div class="card shadow-sm">
<div class="card-header fw-semibold"><i class="bi bi-plus-circle me-1"></i>Banir à mão</div>
<div class="card-body">
<form method="post" class="row g-2 align-items-end">
<?= platform_csrf_field() ?>
<input type="hidden" name="action" value="ban">
<div class="col-md-4">
<label class="form-label small" for="banIp">Endereço IP</label>
<input type="text" class="form-control form-control-sm font-monospace" id="banIp" name="ip"
placeholder="203.0.113.7" autocomplete="off">
</div>
<div class="col-md-3">
<label class="form-label small" for="banDur">Duração</label>
<select class="form-select form-select-sm" id="banDur" name="duration">
<option value="60">1 hora</option>
<option value="1440" selected>24 horas</option>
<option value="10080">7 dias</option>
<option value="perm">Permanente</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label small" for="banReason">Motivo</label>
<input type="text" class="form-control form-control-sm" id="banReason" name="reason"
placeholder="porquê">
</div>
<div class="col-md-2">
<button class="btn btn-danger btn-sm w-100">Banir</button>
</div>
</form>
</div>
</div>
</div>
<?php if (is_file($layout_footer)) include $layout_footer; ?>
File diff suppressed because it is too large. Load diff
-257
View File
@@ -1,257 +0,0 @@
<?php
// ─── Página de restauro (partilhada) ────────────────────────────────────────
//
// Contraparte da backups.php. O functions_restore.php existia há tempo e estava
// completo — inspecção, backup de segurança, divisão de statements, remoção de
// tabelas que o dump não conhece — mas nenhuma página o expunha. Havia backups
// que ninguém sabia se restauravam, que é o mesmo que não ter backups.
//
// Contrato do stub, como o backups.php: $pdo, guarda de sysadmin, $repo_root.
//
// Três passos, e não um botão:
//
// 1. escolher a lista só mostra artefactos de base de dados
// 2. inspeccionar o que está dentro do ZIP, antes de se decidir
// 3. confirmar escrevendo o nome do ficheiro à mão
//
// O passo 3 é de propósito. Uma checkbox aprende-se a clicar sem ler; escrever
// "db_backup_25_08_2026_02_00_11_a3f9c1.zip" obriga a olhar para QUAL artefacto
// se está prestes a aplicar. Isto apaga a base de dados inteira -- não é o sítio
// para poupar dois segundos ao utilizador.
//
// O restauro tira sempre um backup de segurança do estado actual antes de tocar
// em nada, e o nome dele aparece no relatório: restaurar o artefacto errado é,
// ele próprio, recuperável.
if (!isset($pdo) || !$pdo instanceof PDO) {
http_response_code(500);
exit('restore.php: sem PDO — o arranque do site (config/db.php) tem de abrir a ligação primeiro.');
}
require_once __DIR__ . '/functions_restore.php';
require_once __DIR__ . '/functions_csrf.php';
$selfUrl = strtok((string) ($_SERVER['REQUEST_URI'] ?? ''), '?') ?: '';
// ── POST ────────────────────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!platform_csrf_ok()) {
$_SESSION['restore_flash'] = ['type' => 'danger', 'msg' => 'Pedido rejeitado (token CSRF inválido).'];
header('Location: ' . $selfUrl, true, 303);
exit;
}
$file = (string) ($_POST['filename'] ?? '');
$typed = trim((string) ($_POST['confirm_name'] ?? ''));
$dropExtra = !empty($_POST['drop_extra']);
if ($typed !== $file) {
$_SESSION['restore_flash'] = ['type' => 'warning', 'msg' =>
'O nome escrito não corresponde ao ficheiro escolhido — nada foi feito.'];
header('Location: ' . $selfUrl . '?f=' . urlencode($file), true, 303);
exit;
}
// A partir daqui a base de dados vai ser substituída.
$report = restore_db($pdo, $file, ['safety' => true, 'drop_extra_tables' => $dropExtra]);
// Depois do restauro: a tabela do registo também foi reposta, e é na nova que
// tem de ficar escrito que isto aconteceu.
if (function_exists('platform_audit')) {
platform_audit($pdo, 'backup.restore', ['target_type' => 'backup', 'target_label' => $file,
'summary' => !empty($report['ok']) ? 'Base de dados reposta' : 'Restauro falhou',
'changes' => ['drop_extra_tables' => $dropExtra]]);
}
$_SESSION['restore_report'] = $report;
header('Location: ' . $selfUrl, true, 303);
exit;
}
$flash = $_SESSION['restore_flash'] ?? null;
$report = $_SESSION['restore_report'] ?? null;
unset($_SESSION['restore_flash'], $_SESSION['restore_report']);
$all = backup_list();
$dbFiles = array_values(array_filter($all, fn($b) => ($b['type'] ?? '') === 'db'));
$selected = (string) ($_GET['f'] ?? '');
$inspect = $selected !== '' ? restore_inspect($selected) : null;
$tables = restore_current_tables($pdo);
$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');
$backupsUrl = $platform_backups_url
?? (function_exists('platform_admin_url') ? platform_admin_url('backups.php') : 'backups.php');
$pageTitle = 'Restaurar base de dados';
if (is_file($layout_header)) include $layout_header;
if (is_file($layout_navbar)) include $layout_navbar;
$h = fn($s) => htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
?>
<div class="container py-4" style="max-width:900px;">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-4">
<div>
<h3 class="fw-bold mb-1"><i class="bi bi-arrow-counterclockwise me-2"></i>Restaurar base de dados</h3>
<p class="text-muted mb-0">Substitui o esquema e os dados actuais por um artefacto de backup.</p>
</div>
<a href="<?= $h($backupsUrl) ?>" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-archive me-1"></i>Backups
</a>
</div>
<?php if ($flash): ?>
<div class="alert alert-<?= $h($flash['type']) ?> py-2"><?= $h($flash['msg']) ?></div>
<?php endif; ?>
<?php if (is_array($report)): ?>
<div class="card shadow-sm mb-4 border-<?= $report['ok'] ? 'success' : 'danger' ?>">
<div class="card-header fw-semibold">
<i class="bi bi-<?= $report['ok'] ? 'check-circle' : 'x-octagon' ?> me-1"></i>
<?= $report['ok'] ? 'Restauro concluído' : 'Restauro falhou' ?>
</div>
<div class="card-body">
<table class="table table-sm align-middle mb-0">
<tbody>
<tr><td class="text-muted" style="width:14rem;">Artefacto aplicado</td>
<td><code><?= $h($report['filename']) ?></code></td></tr>
<tr><td class="text-muted">Backup de segurança</td>
<td><?= $report['safety_backup']
? '<code>' . $h($report['safety_backup']) . '</code>'
: '<span class="text-danger">nenhum</span>' ?></td></tr>
<tr><td class="text-muted">Statements</td>
<td><?= (int) $report['applied'] ?> aplicados de <?= (int) $report['statements'] ?></td></tr>
<?php if (isset($report['tables_before'])): ?>
<tr><td class="text-muted">Tabelas</td>
<td><?= (int) $report['tables_before'] ?> antes &rarr; <?= (int) $report['tables_after'] ?> depois</td></tr>
<?php endif; ?>
<?php if (!empty($report['dropped_extra'])): ?>
<tr><td class="text-muted">Tabelas removidas</td>
<td><code><?= $h(implode(', ', $report['dropped_extra'])) ?></code>
<div class="form-text">Existiam aqui e não no artefacto.</div></td></tr>
<?php endif; ?>
<?php if (!empty($report['error'])): ?>
<tr><td class="text-muted">Erro</td>
<td class="text-danger"><?= $h($report['error']) ?></td></tr>
<?php endif; ?>
</tbody>
</table>
<?php if (!$report['ok'] && $report['safety_backup']): ?>
<p class="form-text mb-0 mt-2">
O estado anterior está em <code><?= $h($report['safety_backup']) ?></code> —
restaurá-lo devolve a base ao que era antes desta tentativa.
</p>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<div class="alert alert-warning d-flex gap-2 align-items-start">
<i class="bi bi-exclamation-triangle-fill mt-1"></i>
<div>
<strong>Isto é destrutivo.</strong> A base de dados actual
(<?= count($tables) ?> tabelas) é substituída pela do artefacto. Antes de tocar
em nada é tirado um backup de segurança do estado actual, cujo nome aparece no
relatório — mas planeie o restauro para uma altura em que ninguém esteja a usar
a plataforma.
</div>
</div>
<div class="card shadow-sm mb-4">
<div class="card-header fw-semibold"><i class="bi bi-list-ul me-1"></i>Artefactos de base de dados</div>
<div class="card-body">
<?php if (!$dbFiles): ?>
<p class="text-muted mb-0">
Não há backups de base de dados em <code>backups/</code>.
Crie um a partir da página de <a href="<?= $h($backupsUrl) ?>">Backups</a>.
</p>
<?php else: ?>
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead><tr><th>Ficheiro</th><th class="text-end">Tamanho</th><th>Criado</th><th></th></tr></thead>
<tbody>
<?php foreach ($dbFiles as $b): ?>
<tr<?= $b['filename'] === $selected ? ' class="table-active"' : '' ?>>
<td><code class="small"><?= $h($b['filename']) ?></code></td>
<td class="text-end text-muted small"><?= $h($b['size_human']) ?></td>
<td class="text-muted small"><?= $h(date('Y-m-d H:i', (int) $b['created_at'])) ?></td>
<td class="text-end">
<a class="btn btn-outline-secondary btn-sm"
href="<?= $h($selfUrl) ?>?f=<?= urlencode($b['filename']) ?>">Inspeccionar</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
<?php if ($inspect !== null): ?>
<div class="card shadow-sm">
<div class="card-header fw-semibold"><i class="bi bi-search me-1"></i>Inspecção</div>
<div class="card-body">
<?php if (!$inspect['ok']): ?>
<div class="alert alert-danger py-2 mb-0"><?= $h($inspect['error']) ?></div>
<?php elseif ($inspect['type'] !== 'db'): ?>
<div class="alert alert-warning py-2 mb-0">
<code><?= $h($inspect['filename']) ?></code> é um backup de ficheiros, não de base de dados.
</div>
<?php else: ?>
<table class="table table-sm align-middle">
<tbody>
<tr><td class="text-muted" style="width:14rem;">Ficheiro</td>
<td><code><?= $h($inspect['filename']) ?></code></td></tr>
<tr><td class="text-muted">Tamanho</td><td><?= $h($inspect['size_human']) ?></td></tr>
<tr><td class="text-muted">Entradas no ZIP</td><td><?= (int) $inspect['entries'] ?></td></tr>
<?php if (!empty($inspect['sql_entry'])): ?>
<tr><td class="text-muted">Dump</td><td><code><?= $h($inspect['sql_entry']) ?></code></td></tr>
<?php endif; ?>
</tbody>
</table>
<hr>
<form method="post" class="d-flex flex-column gap-3">
<?= platform_csrf_field() ?>
<input type="hidden" name="filename" value="<?= $h($inspect['filename']) ?>">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="dropExtra" name="drop_extra" value="1" checked>
<label class="form-check-label" for="dropExtra">
Remover tabelas que o artefacto não conhece
<span class="d-block text-muted small">
Sem isto, uma tabela criada depois deste backup sobrevive e deixa o
esquema num estado que o artefacto nunca descreveu. Desligue só se
souber que quer manter alguma.
</span>
</label>
</div>
<div>
<label class="form-label small fw-semibold" for="confirmName">
Para confirmar, escreva o nome do ficheiro
</label>
<input type="text" class="form-control font-monospace" id="confirmName"
name="confirm_name" autocomplete="off" spellcheck="false"
placeholder="<?= $h($inspect['filename']) ?>">
<div class="form-text">
É escrito à mão de propósito: obriga a olhar para qual artefacto está
prestes a aplicar.
</div>
</div>
<div>
<button type="submit" class="btn btn-danger">
<i class="bi bi-arrow-counterclockwise me-1"></i>Restaurar esta base de dados
</button>
</div>
</form>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
</div>
<?php if (is_file($layout_footer)) include $layout_footer; ?>
-110
View File
@@ -1,110 +0,0 @@
<?php
// ─── Front controller das páginas de sistema ─────────────────────────────────
//
// Um só stub por site, em vez de um por página:
//
// <?php require __DIR__ . '/../shared/platform-tools/router.php'; // public/platform.php
//
// e cada página partilhada abre em platform.php?tool=<nome>. Uma ferramenta nova
// no platform-tools aparece em todos os sites no próximo update do submódulo,
// sem ficheiro nenhum a criar do lado de lá.
//
// O que os stubs faziam à mão vem agora do platform_connect() do site:
//
// guard quem pode entrar (obrigatório — sem ele isto recusa)
// guards guarda própria de uma ferramenta, p. ex. ['tracking' => 'require_admin']
// replaced_tools ferramenta que o site substitui por página sua:
// ['smtp_status' => 'views/sysadmin/comms.php']
// layout ['header' => …, 'navbar' => …, 'footer' => …]
// dashboard_url o «Voltar» das páginas (relativo ao base_url, ou absoluto);
// em array, por ferramenta — as outras voltam ao índice
// migrations_dir onde vivem as migrações, quando não é database/migrations/
// sysadmin_tools cartões próprios do índice (array, ou callable que o devolve)
// hide_tools cartões por omissão a esconder, p. ex. ['smtp_status']
// page_vars variáveis `platform_*` extra de uma página, p. ex.
// ['platform_error_log_file' => …]
//
// Query string (platform.php?tool=backups) e não /platform/backups: um caminho
// bonito precisa de regras de reescrita, e a frota tem Apache em Docker, cPanel
// e talvez nginx. Isto funciona em todos sem configurar nada.
//
// A tabela de rotas é uma lista fechada, e cada página continua a fazer as suas
// verificações (CSRF, confirmação escrita). O router só despacha e guarda a
// porta — nunca inclui um nome vindo do pedido.
declare(strict_types=1);
require_once __DIR__ . '/hub_bootstrap.php'; // $repo_root, $pdo, config/db.php + functions.php
require_once __DIR__ . '/functions_connector.php';
require_once __DIR__ . '/functions_routes.php';
$tool = (string) ($_GET['tool'] ?? 'index');
$route = platform_routes()[$tool] ?? null;
if ($route === null) {
http_response_code(404);
exit('Ferramenta desconhecida.');
}
$cfg = platform_config();
// Uma guarda por ferramenta quando o site a declara (a Origem das visitas é de
// admin na fotografia, não só de sysadmin); senão a geral.
$guard = $cfg['guards'][$tool] ?? ($cfg['guard'] ?? null);
if ($cfg === [] || !is_callable($guard)) {
// Falha fechada: sem guarda declarada não há forma de saber quem pode entrar,
// e uma página de sistema aberta a qualquer um é pior do que nenhuma.
http_response_code(500);
exit('Este site ainda não declara uma guarda de sysadmin no platform_connect() — '
. 'as páginas de sistema não abrem sem ela.');
}
$guard();
// Um site pode ter página própria no lugar de uma partilhada (as Comunicações
// da fotografia em vez do Estado SMTP: dois editores da mesma configuração
// acabavam por mostrar um o que o outro não gravou). O URL partilhado continua
// a chegar lá.
if (!empty($cfg['replaced_tools'][$tool])) {
header('Location: ' . platform_route_absolute_url((string) $cfg['replaced_tools'][$tool]), true, 302);
exit;
}
if (!$pdo instanceof PDO && !empty($route['needs_db'])) {
http_response_code(503);
exit('Esta ferramenta precisa de base de dados, e este site não tem.');
}
// O que os stubs definiam antes de incluir a página.
foreach (['header', 'navbar', 'footer'] as $part) {
if (!empty($cfg['layout'][$part])) ${'platform_layout_' . $part} = $cfg['layout'][$part];
}
// Em array é por ferramenta, e as que lá não estão voltam ao índice do Sistema
// (é de lá que se chega a elas): ['index' => 'views/admin/users.php'].
if (is_array($cfg['dashboard_url'] ?? null)) {
$platform_dashboard_url = isset($cfg['dashboard_url'][$tool])
? platform_route_absolute_url((string) $cfg['dashboard_url'][$tool])
: platform_route_url('index');
} elseif (!empty($cfg['dashboard_url'])) {
$platform_dashboard_url = platform_route_absolute_url((string) $cfg['dashboard_url']);
}
if (!empty($cfg['migrations_dir'])) {
$GLOBALS['MIGRATIONS_DIR'] = (string) $cfg['migrations_dir'];
}
foreach ((array) ($cfg['page_vars'] ?? []) as $name => $value) {
if (is_string($name) && str_starts_with($name, 'platform_')) ${$name} = $value;
}
if ($tool === 'index') {
$tools = $cfg['sysadmin_tools'] ?? [];
$sysadmin_tools = is_callable($tools) ? (array) $tools() : (array) $tools;
foreach ($sysadmin_tools as &$card) {
if (isset($card['url'])) $card['url'] = platform_route_absolute_url((string) $card['url']);
}
unset($card);
// Sem o .php: o índice compara com str_contains() no URL do cartão, e o do
// router é …?tool=smtp_status, não …/smtp_status.php.
$platform_hide_tools = array_map(fn($h) => preg_replace('/\.php$/', '', (string) $h), (array) ($cfg['hide_tools'] ?? []));
}
if (!empty($route['backup_helpers'])) {
hub_load_backup_helpers();
}
unset($cfg, $tools, $part, $name, $value);
require __DIR__ . '/' . $route['file'];
-235
View File
@@ -1,235 +0,0 @@
<?php
// ─── Shared first-run setup page ─────────────────────────────────────────────
//
// A view fragment, like platform_update.php and backups.php: the site's own
// setup.php stub opens the PDO, defines $repo_root and $setup_title, then
// requires this file.
//
// Who may run it. On a fresh install there is no one to authenticate, so the
// page must be usable before any account exists -- but it writes credentials,
// so it cannot stay open afterwards. The rule: open while the platform has no
// sysadmin, sysadmin-only once it has one. That way the window closes by
// itself, without anyone remembering to delete a file.
require_once __DIR__ . '/functions_setup.php';
require_once __DIR__ . '/functions_csrf.php';
if (!isset($pdo) || !$pdo instanceof PDO) {
http_response_code(500);
exit('setup.php: no PDO — the site bootstrap (config/db.php) must open the connection first.');
}
$repo_root = $repo_root ?? dirname(__DIR__, 2);
$setup_title = $setup_title ?? 'Configuração inicial';
// ─── Access ─────────────────────────────────────────────────────────────────
// Fail closed. The only state that legitimately opens this page is "the users
// table does not exist yet" -- a genuinely fresh install. Anything else that
// goes wrong (a query error, a missing helper, a permissions problem) is
// treated as "already configured", because a page that writes SMTP credentials
// must not become public the moment an unrelated thing breaks.
//
// This was originally a try/catch around the whole check that set
// $sysadminExists = false on any Throwable. On a site that had not wired the
// shared auth, auth_schema() was undefined, the catch swallowed the Error, and
// the page served itself to anonymous visitors on an install that had a
// sysadmin -- confirmed on fotografia. That is the wrong direction to fail in.
$s = platform_setup_user_schema();
$usersTableExists = false;
try {
$usersTableExists = $pdo->query("SHOW TABLES LIKE " . $pdo->quote($s['table']))->fetchColumn() !== false;
} catch (Throwable $e) {
$usersTableExists = true; // cannot tell -> assume configured
}
if (!$usersTableExists) {
$sysadminExists = false; // fresh install: nobody to authenticate yet
} else {
try {
$sysadminExists = (bool) $pdo->query(
"SELECT 1 FROM `{$s['table']}` WHERE `{$s['role']}` = 'sysadmin' LIMIT 1"
)->fetchColumn();
} catch (Throwable $e) {
error_log('[setup] sysadmin check failed, denying access: ' . $e->getMessage());
$sysadminExists = true;
}
}
$isSysadmin = function_exists('auth_is_sysadmin')
? auth_is_sysadmin()
: (($_SESSION['user']['role'] ?? '') === 'sysadmin');
if ($sysadminExists && !$isSysadmin) {
http_response_code(403);
exit('Configuração já concluída. Entre como sysadmin para a alterar.');
}
// ─── Actions ────────────────────────────────────────────────────────────────
$errors = [];
$saved = false;
$test = null;
$state = platform_setup_state($pdo);
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!platform_csrf_ok()) {
$errors[] = 'Pedido rejeitado (token CSRF ausente ou inválido). Recarregue a página.';
} else {
$action = (string) ($_POST['_action'] ?? 'save');
$uid = isset($_SESSION['user']['id']) ? (int) $_SESSION['user']['id'] : null;
$r = platform_setup_save_smtp($pdo, $_POST, $uid);
if (!$r['ok']) {
$errors = $r['errors'];
} else {
$saved = true;
$state = platform_setup_state($pdo);
if ($action === 'save_test') {
$test = platform_setup_test_smtp($pdo, $repo_root);
}
}
}
}
$c = $state['config'];
$h = fn($v) => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
?><!doctype html>
<html lang="pt"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title><?= $h($setup_title) ?></title>
<style>
:root { color-scheme: light dark; --ink:#14181B; --soft:#4A555A; --line:#D3DADC;
--bg:#F4F6F7; --card:#FFF; --ok:#35704A; --okbg:#DFEDE3;
--bad:#AF3A2B; --badbg:#F7E3E0; --warn:#8C6410; --warnbg:#F6ECD6; --accent:#0E6E6B; }
@media (prefers-color-scheme: dark) { :root {
--ink:#E3E8E9; --soft:#A5B1B5; --line:#2A3336; --bg:#0F1315; --card:#161B1E;
--ok:#74B98C; --okbg:#172C1F; --bad:#E8867A; --badbg:#35201C;
--warn:#D9AC55; --warnbg:#2F2716; --accent:#4FC0B8; } }
*{box-sizing:border-box} body{margin:0;background:var(--bg);color:var(--ink);
font:16px/1.6 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;padding:0 1.25rem 5rem}
.wrap{max-width:44rem;margin:0 auto;padding-top:3.5rem}
h1{font-size:1.7rem;letter-spacing:-.02em;margin:0 0 .4rem}
.lede{color:var(--soft);margin:0 0 1.75rem}
.card{background:var(--card);border:1px solid var(--line);padding:1.5rem;margin-bottom:1.25rem}
label{display:block;font-weight:600;font-size:.9rem;margin-bottom:.3rem}
.hint{color:var(--soft);font-size:.82rem;margin:.3rem 0 0}
input,select{width:100%;padding:.55rem .7rem;border:1px solid var(--line);
background:var(--bg);color:var(--ink);font:inherit;font-size:.95rem}
.row{display:grid;grid-template-columns:1fr 1fr;gap:1rem}
.field{margin-bottom:1.1rem}
.actions{display:flex;gap:.75rem;flex-wrap:wrap;margin-top:1.5rem}
button{padding:.6rem 1.1rem;border:1px solid var(--accent);background:var(--accent);
color:var(--card);font:inherit;font-weight:600;cursor:pointer}
button.ghost{background:transparent;color:var(--accent)}
button:focus-visible,input:focus-visible,select:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.msg{padding:.75rem 1rem;margin-bottom:1rem;font-size:.92rem;border-left:3px solid}
.msg.ok{background:var(--okbg);color:var(--ok);border-color:var(--ok)}
.msg.bad{background:var(--badbg);color:var(--bad);border-color:var(--bad)}
.msg.warn{background:var(--warnbg);color:var(--warn);border-color:var(--warn)}
pre{background:var(--bg);border:1px solid var(--line);padding:.8rem;overflow-x:auto;
font:.8rem/1.5 ui-monospace,Menlo,Consolas,monospace;color:var(--soft);margin:.75rem 0 0}
.why{border-left:3px solid var(--line);padding-left:1rem;color:var(--soft);font-size:.9rem}
</style>
</head><body>
<div class="wrap">
<h1><?= $h($setup_title) ?></h1>
<p class="lede">
Falta configurar o envio de email. A plataforma fica retida aqui até estar feito.
</p>
<?php foreach ($errors as $e): ?>
<div class="msg bad"><?= $h($e) ?></div>
<?php endforeach; ?>
<?php if ($saved && $state['smtp_ok']): ?>
<div class="msg ok">Configuração guardada. A plataforma já pode enviar email.</div>
<?php elseif ($saved): ?>
<div class="msg warn">Guardado, mas ainda falta: <?= $h(implode(', ', $state['missing'])) ?>.</div>
<?php endif; ?>
<?php if (!$state['secrets_ok']): ?>
<div class="msg bad">
<strong>APP_SECRET_KEY não está definida no <code>.env</code>.</strong>
A palavra-passe de SMTP seria guardada em claro na base de dados — e a base
de dados vai para dentro de cada backup. Defina a chave antes de continuar.
</div>
<?php endif; ?>
<?php if ($test !== null): ?>
<div class="msg <?= !empty($test['ok']) ? 'ok' : 'bad' ?>">
<?= !empty($test['ok'])
? 'Ligação e autenticação bem-sucedidas.'
: $h((string) ($test['error'] ?? 'Não foi possível ligar/autenticar.')) ?>
</div>
<?php if (!empty($test['log'])): ?><pre><?= $h($test['log']) ?></pre><?php endif; ?>
<?php endif; ?>
<form method="post" class="card">
<?= platform_csrf_field() ?>
<div class="row">
<div class="field">
<label for="smtp_host">Servidor SMTP</label>
<input id="smtp_host" name="smtp_host" required value="<?= $h($c['host'] ?? '') ?>"
autocomplete="off" placeholder="smtp.exemplo.pt">
</div>
<div class="field">
<label for="smtp_port">Porta</label>
<input id="smtp_port" name="smtp_port" type="number" min="1" max="65535"
value="<?= $h($c['port'] ?: 587) ?>">
<p class="hint">587 para TLS, 465 para SSL.</p>
</div>
</div>
<div class="field">
<label for="smtp_secure">Encriptação</label>
<select id="smtp_secure" name="smtp_secure">
<?php foreach (['tls' => 'TLS (StartTLS)', 'ssl' => 'SSL', '' => 'Nenhuma'] as $v => $lbl): ?>
<option value="<?= $h($v) ?>" <?= ($c['secure'] ?? 'tls') === $v ? 'selected' : '' ?>><?= $h($lbl) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="row">
<div class="field">
<label for="smtp_username">Utilizador</label>
<input id="smtp_username" name="smtp_username" value="<?= $h($c['username'] ?? '') ?>"
autocomplete="off">
</div>
<div class="field">
<label for="smtp_password">Palavra-passe</label>
<input id="smtp_password" name="smtp_password" type="password" autocomplete="new-password"
placeholder="<?= ($c['password'] ?? '') !== '' ? '•••••••• (definida)' : '' ?>">
<p class="hint">
<?= ($c['password'] ?? '') !== ''
? 'Deixe vazio para manter a atual.'
: 'Guardada cifrada (AES-256-GCM); a chave vive só no .env.' ?>
</p>
</div>
</div>
<div class="row">
<div class="field">
<label for="email_from">Remetente</label>
<input id="email_from" name="email_from" type="email" required
value="<?= $h($c['from'] ?? '') ?>" placeholder="noreply@exemplo.pt">
</div>
<div class="field">
<label for="email_from_name">Nome do remetente</label>
<input id="email_from_name" name="email_from_name" value="<?= $h($c['from_name'] ?? '') ?>">
</div>
</div>
<div class="actions">
<button type="submit" name="_action" value="save_test">Guardar e testar ligação</button>
<button type="submit" name="_action" value="save" class="ghost">Guardar</button>
</div>
</form>
<p class="why">
Porque é obrigatório: sem SMTP ninguém recupera a palavra-passe, nenhum aviso
chega e nenhum formulário de contacto é entregue — e nada disso falha de
forma visível. Falha como silêncio, semanas depois.
</p>
</div>
</body></html>
-312
View File
@@ -1,312 +0,0 @@
<?php
require_once __DIR__ . '/functions_settings.php'; // platform_admin_url()
// ─── Shared SMTP status view ─────────────────────────────────────────────────
//
// Bootstrap contract — the stub at <host>/views/sysadmin/smtp_status.php must:
// 1. Load $pdo 2. Run the sysadmin guard 3. Set $repo_root
// Optional overrides, same as backups.php / platform_update.php:
// $platform_layout_header / _navbar / _footer, $platform_dashboard_url
//
// Read-only by default: it reports what the transport WOULD do. The two actions
// (connection test, send a test message) are explicit and CSRF-guarded, because
// one opens a socket to a third party and the other actually sends mail.
require_once __DIR__ . '/functions_mail.php';
require_once __DIR__ . '/functions_csrf.php';
if (!isset($repo_root) || !is_string($repo_root)) {
$repo_root = realpath(dirname(__DIR__, 2)) ?: dirname(__DIR__, 2);
}
$repo_root = rtrim($repo_root, '/');
// ── Actions (POST to self, PRG) ─────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
if (!platform_csrf_ok()) {
$_SESSION['smtp_flash'] = ['type' => 'danger', 'message' => 'Pedido rejeitado (token CSRF ausente ou inválido).'];
} elseif ($action === 'test_connection') {
$r = platform_mail_test($repo_root);
$_SESSION['smtp_flash'] = $r['ok']
? ['type' => 'success', 'message' => 'Ligação SMTP estabelecida e fechada com sucesso.', 'log' => $r['log']]
: ['type' => 'danger', 'message' => 'Falha na ligação: ' . $r['error'], 'log' => $r['log']];
} elseif ($action === 'save') {
// Editar aqui, e não só diagnosticar.
//
// A página nasceu só de leitura, o que fazia sentido enquanto cada site
// tivesse a sua página de Comunicações. Não têm: no hub2, no silver e no
// quack esta é a única página de SMTP que existe, e a única forma de
// mudar o servidor era o setup.php -- uma página chamada "Configuração
// inicial", que fecha assim que houver um sysadmin. Uma página que
// mostra "Servidor: — vazio —" sem dar maneira de o preencher é um beco.
//
// Os sites que TÊM Comunicações próprias redirigem daqui para lá, para
// não haver dois editores da mesma coisa.
if (!isset($pdo) || !$pdo instanceof PDO) {
// Sem base não há onde gravar. O stub deste site só forneceu
// $repo_root, por isso a página fica de leitura.
$_SESSION['smtp_flash'] = ['type' => 'danger',
'message' => 'Esta instalação não expõe a base de dados a esta página; configure pelo .env.'];
header('Location: ' . $_SERVER['REQUEST_URI'], true, 303);
exit;
}
$uid = isset($_SESSION['user']['id']) ? (int) $_SESSION['user']['id'] : null;
$port = (int) ($_POST['smtp_port'] ?? 587);
$secure = strtolower(trim((string) ($_POST['smtp_secure'] ?? 'tls')));
if (!in_array($secure, ['tls', 'ssl', ''], true)) {
$secure = 'tls';
}
$from = trim((string) ($_POST['email_from'] ?? ''));
if ($from !== '' && !filter_var($from, FILTER_VALIDATE_EMAIL)) {
// Validado porque já se perdeu tempo com isto: o campo do remetente
// ficou uma vez com o NOME do remetente lá dentro, e o erro só
// apareceu como "Invalid address (From)" numa fila presa, horas
// depois. Um formulário que aceita tudo empurra a falha para longe
// de quem a pode corrigir.
$_SESSION['smtp_flash'] = ['type' => 'danger',
'message' => 'O remetente tem de ser um endereço de email — o nome vai no campo abaixo.'];
header('Location: ' . $_SERVER['REQUEST_URI'], true, 303);
exit;
}
platform_settings_set($pdo, 'smtp_host', trim((string) ($_POST['smtp_host'] ?? '')), $uid);
platform_settings_set($pdo, 'smtp_port', (string) ($port > 0 ? $port : 587), $uid);
platform_settings_set($pdo, 'smtp_secure', $secure, $uid);
platform_settings_set($pdo, 'smtp_username', trim((string) ($_POST['smtp_username'] ?? '')), $uid);
platform_settings_set($pdo, 'email_from', $from, $uid);
platform_settings_set($pdo, 'email_from_name', trim((string) ($_POST['email_from_name'] ?? '')), $uid);
// A password nunca é pré-preenchida, por isso vazio significa "mantém a
// que está" e não "apaga". Apagar é uma caixa explícita.
if (!empty($_POST['smtp_password_clear'])) {
platform_settings_set($pdo, 'smtp_password', '', $uid);
} elseif (trim((string) ($_POST['smtp_password'] ?? '')) !== '') {
platform_settings_set($pdo, 'smtp_password', trim((string) $_POST['smtp_password']), $uid);
}
if (function_exists('platform_audit')) {
platform_audit($pdo, 'settings.smtp', ['target_type' => 'settings', 'summary' => 'Configuração SMTP guardada',
'changes' => ['smtp_host' => $_POST['smtp_host'] ?? null, 'smtp_port' => $_POST['smtp_port'] ?? null,
'smtp_username' => $_POST['smtp_username'] ?? null, 'email_from' => $_POST['email_from'] ?? null,
'smtp_password' => ($_POST['smtp_password'] ?? '') !== '' ? 'alterada' : null]]);
}
$_SESSION['smtp_flash'] = ['type' => 'success', 'message' => 'Configuração guardada.'];
} elseif ($action === 'send_test') {
$to = trim((string) ($_POST['to'] ?? ''));
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) {
$_SESSION['smtp_flash'] = ['type' => 'danger', 'message' => 'Endereço de destino inválido.'];
} else {
$r = platform_mail_send([
'to' => $to,
'to_name' => 'Teste',
'subject' => 'Teste de SMTP — ' . (string) ($_SERVER['HTTP_HOST'] ?? 'plataforma'),
'body' => '<p>Mensagem de teste enviada a partir do painel de sistema.</p>',
'alt_body' => 'Mensagem de teste enviada a partir do painel de sistema.',
], $repo_root);
if (!empty($r['skipped'])) {
$_SESSION['smtp_flash'] = ['type' => 'warning',
'message' => 'Não enviado: EMAIL_DEV_PRINT está activo e APP_ENV não é prod, por isso a mensagem foi apenas registada no log.'];
} elseif (!empty($r['ok'])) {
$_SESSION['smtp_flash'] = ['type' => 'success', 'message' => 'Mensagem entregue ao servidor SMTP para ' . $to . '.'];
} else {
$_SESSION['smtp_flash'] = ['type' => 'danger', 'message' => 'Falha no envio: ' . $r['error']];
}
}
}
header('Location: ' . $_SERVER['REQUEST_URI'], true, 303);
exit;
}
// Editar exige base de dados: é lá que as definições ficam. Um stub que só
// forneça $repo_root continua a ver a página, em modo de leitura.
$canEdit = isset($pdo) && $pdo instanceof PDO;
$flash = $_SESSION['smtp_flash'] ?? null;
unset($_SESSION['smtp_flash']);
// A configuração que realmente envia, e não a do ambiente: com $pdo, é a da
// base que ganha. Era daqui que vinha o formulário mostrar campos vazios num
// site cujo email funcionava.
$cfg = (isset($pdo) && $pdo instanceof PDO && function_exists('platform_smtp_config'))
? platform_mail_config(platform_smtp_config($pdo))
: platform_mail_config();
$hasMailer = platform_mail_available($repo_root);
$isProd = ($cfg['env'] === 'prod');
// Veredicto e valores em vigor: uma implementação só, em functions_mail.php.
// Estavam aqui em linha, e quando o comms.php passou a mostrar o mesmo passaram
// a ser duas cópias da mesma lista de casos -- a que ficasse para trás mentiria
// a alguém a meio de uma avaria.
$verdict = platform_mail_diagnosis($repo_root, null, $pdo ?? null);
$rows = platform_mail_config_rows(null, $pdo ?? null);
$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('base_url') ? platform_admin_url('index.php') : 'index.php');
$pageTitle = 'Estado SMTP';
if (is_file($layout_header)) include $layout_header;
if (is_file($layout_navbar)) include $layout_navbar;
?>
<div class="container py-4" style="max-width:900px;">
<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-envelope-check me-2"></i>Estado SMTP</h2>
<p class="text-muted small mb-0">Configuração do transporte de email partilhado e testes de ligação.</p>
</div>
<a href="<?= htmlspecialchars($dashboard_url) ?>" class="btn btn-outline-secondary btn-sm">Voltar</a>
</div>
<?php if (is_array($flash)): ?>
<div class="alert alert-<?= htmlspecialchars($flash['type']) ?> alert-dismissible fade show" role="alert">
<?= htmlspecialchars((string) $flash['message']) ?>
<?php if (!empty($flash['log'])): ?>
<pre class="small mt-2 mb-0" style="max-height:220px;overflow:auto;white-space:pre-wrap;"><?= htmlspecialchars((string) $flash['log']) ?></pre>
<?php endif; ?>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
<?php endif; ?>
<div class="card shadow-sm mb-3 border-<?= htmlspecialchars($verdict[0]) ?>">
<div class="card-body d-flex align-items-start gap-3">
<span class="badge text-bg-<?= htmlspecialchars($verdict[0]) ?> mt-1"><?= htmlspecialchars($verdict[1]) ?></span>
<div class="small mb-0"><?= htmlspecialchars($verdict[2]) ?></div>
</div>
</div>
<div class="card shadow-sm mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<strong><i class="bi bi-sliders me-1"></i>Configuração</strong>
<span class="badge text-bg-<?= $hasMailer ? 'success' : 'danger' ?>">
PHPMailer <?= $hasMailer ? 'disponível' : 'em falta' ?>
</span>
</div>
<div class="table-responsive">
<table class="table table-sm mb-0 small align-middle">
<tbody>
<?php foreach ($rows as [$k, $v, $ok]): ?>
<tr>
<td class="font-monospace text-muted" style="width:220px;"><?= htmlspecialchars($k) ?></td>
<td class="font-monospace text-break"><?= htmlspecialchars($v) ?></td>
<td class="text-end" style="width:40px;">
<i class="bi bi-<?= $ok ? 'check-circle text-success' : 'exclamation-circle text-warning' ?>"></i>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="card-footer small text-muted">
Valores lidos por ordem: constante definida pela aplicação &rarr; <code>.env</code>.
Onde a aplicação guarda definições em base de dados, essas ganham sobre ambas.
</div>
</div>
<?php if ($canEdit): ?>
<div class="card shadow-sm mb-3">
<div class="card-header"><strong><i class="bi bi-pencil-square me-1"></i>Alterar</strong></div>
<div class="card-body">
<form method="post" class="row g-3">
<?= platform_csrf_field() ?>
<input type="hidden" name="action" value="save">
<div class="col-md-8">
<label class="form-label small">Servidor SMTP</label>
<input type="text" name="smtp_host" class="form-control form-control-sm"
value="<?= htmlspecialchars($cfg['host']) ?>" placeholder="mail.exemplo.pt">
<div class="form-text">No cPanel é tipicamente <code>mail.&lt;dominio&gt;</code>.</div>
</div>
<div class="col-md-2">
<label class="form-label small">Porta</label>
<input type="number" name="smtp_port" class="form-control form-control-sm"
value="<?= (int) $cfg['port'] ?>">
</div>
<div class="col-md-2">
<label class="form-label small">Cifra</label>
<select name="smtp_secure" class="form-select form-select-sm">
<?php foreach (['tls' => 'TLS', 'ssl' => 'SSL', '' => 'Nenhuma'] as $v => $lbl): ?>
<option value="<?= $v ?>" <?= $cfg['secure'] === $v ? 'selected' : '' ?>><?= $lbl ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-6">
<label class="form-label small">Utilizador</label>
<input type="text" name="smtp_username" class="form-control form-control-sm"
value="<?= htmlspecialchars($cfg['username']) ?>" autocomplete="off">
<div class="form-text">Normalmente o endereço de email completo.</div>
</div>
<div class="col-md-6">
<label class="form-label small">Palavra-passe</label>
<input type="password" name="smtp_password" class="form-control form-control-sm"
placeholder="<?= $cfg['password'] !== '' ? '•••••••• (definida — deixe vazio para manter)' : 'por definir' ?>"
autocomplete="new-password">
<?php if ($cfg['password'] !== ''): ?>
<div class="form-check mt-1">
<input class="form-check-input" type="checkbox" name="smtp_password_clear" id="pwClear" value="1">
<label class="form-check-label small text-muted" for="pwClear">Apagar a palavra-passe guardada</label>
</div>
<?php endif; ?>
</div>
<div class="col-md-6">
<label class="form-label small">Remetente (From)</label>
<input type="email" name="email_from" class="form-control form-control-sm"
value="<?= htmlspecialchars($cfg['from']) ?>" placeholder="noreply@exemplo.pt">
<div class="form-text">Tem de ser a conta autenticada acima, senão SPF/DMARC rejeitam.</div>
</div>
<div class="col-md-6">
<label class="form-label small">Nome do remetente</label>
<input type="text" name="email_from_name" class="form-control form-control-sm"
value="<?= htmlspecialchars($cfg['from_name']) ?>" placeholder="A Minha Plataforma">
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-check-lg me-1"></i>Guardar
</button>
<span class="small text-muted ms-2">
Guardado em base de dados, que ganha ao <code>.env</code>. Teste a ligação a seguir.
</span>
</div>
</form>
</div>
</div>
<?php endif; ?>
<div class="card shadow-sm">
<div class="card-header"><strong><i class="bi bi-activity me-1"></i>Testes</strong></div>
<div class="card-body d-flex flex-wrap gap-3 align-items-end">
<form method="POST" class="m-0">
<?= platform_csrf_field() ?><input type="hidden" name="action" value="test_connection">
<button type="submit" class="btn btn-outline-primary btn-sm" <?= $cfg['host'] === '' ? 'disabled' : '' ?>>
<i class="bi bi-plug me-1"></i>Testar ligação
</button>
<div class="form-text">Abre e fecha uma ligação. Não envia nada.</div>
</form>
<form method="POST" class="m-0 d-flex gap-2 align-items-end flex-wrap">
<?= platform_csrf_field() ?><input type="hidden" name="action" value="send_test">
<div>
<label class="form-label small mb-1" for="smtp_to">Enviar mensagem de teste para</label>
<input type="email" name="to" id="smtp_to" class="form-control form-control-sm"
style="min-width:260px;" placeholder="alguem@exemplo.pt" required>
</div>
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-send me-1"></i>Enviar
</button>
</form>
</div>
<div class="card-footer small text-muted">
<i class="bi bi-info-circle me-1"></i>
Um teste bem sucedido significa que o servidor SMTP <em>aceitou</em> a mensagem —
não que ela chegou à caixa de entrada. Rejeições por SPF, DKIM ou reputação acontecem depois.
</div>
</div>
</div>
<?php if (is_file($layout_footer)) include $layout_footer; ?>
-159
View File
@@ -1,159 +0,0 @@
/* Social-post export engine — generalization of the pipeline proven in
fotografia.andmore.pt/views/sessions/open_spots_ig.php.
Responsibilities: render a live-preview element to a high-resolution JPG
via html2canvas, pre-baking CSS blur (html2canvas can't render
`filter: blur()`), waiting for webfonts, and bundling multi-slide exports
into a ZIP (JSZip). Pure mechanism — all template/DOM knowledge lives in
the page that uses it. */
window.SocialEngine = (function () {
'use strict';
function chunk(arr, size) {
size = Math.max(1, size | 0);
var out = [];
for (var i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
return out;
}
/* html2canvas does not render CSS `filter: blur(...)`. Pre-bake the blur
into the photo with Canvas 2D, swap the bg-image to that bitmap, drop
the inline filter/transform, and return a restore() callback. The blur
radius is multiplied by the export scale so the upscaled bitmap matches
what's on screen. Resolves null when there is nothing to bake. */
function bakeBlur(bgEl, photoUrl, blurAmount, scale) {
return new Promise(function (resolve) {
if (!bgEl || !photoUrl || !(blurAmount > 0)) return resolve(null);
var rect = bgEl.getBoundingClientRect();
var W = Math.max(1, Math.round(rect.width * scale));
var H = Math.max(1, Math.round(rect.height * scale));
var blurPx = blurAmount * scale;
var img = new Image();
// Photos go through the same-origin media proxy (or are local hub
// uploads), so no crossOrigin attribute is needed and the canvas
// stays untainted.
img.onload = function () {
try {
var canvas = document.createElement('canvas');
canvas.width = W;
canvas.height = H;
var ctx = canvas.getContext('2d');
ctx.filter = 'blur(' + blurPx + 'px) brightness(0.85)';
// Draw slightly larger than the canvas so the soft edges
// don't reveal blank pixels — same trick as the CSS scale().
var overscan = 1.1 + (blurAmount / 200);
var ratio = Math.max(W / img.width, H / img.height) * overscan;
var dw = img.width * ratio;
var dh = img.height * ratio;
ctx.drawImage(img, (W - dw) / 2, (H - dh) / 2, dw, dh);
var dataUrl = canvas.toDataURL('image/jpeg', 0.9);
var original = {
backgroundImage: bgEl.style.backgroundImage,
filter: bgEl.style.filter,
transform: bgEl.style.transform,
};
bgEl.style.backgroundImage = "url('" + dataUrl + "')";
bgEl.style.filter = 'none';
bgEl.style.transform = 'none';
resolve(function () {
bgEl.style.backgroundImage = original.backgroundImage;
bgEl.style.filter = original.filter;
bgEl.style.transform = original.transform;
});
} catch (e) {
console.warn('Blur bake failed; exporting without baked blur.', e);
resolve(null);
}
};
img.onerror = function () {
console.warn('Failed to load photo for blur bake; exporting without baked blur.');
resolve(null);
};
img.src = photoUrl;
});
}
/* Render `el` (the live preview, at its on-screen size — its CSS uses
viewport-relative clamp()s, so rendering at another width would re-flow)
and let html2canvas upscale the pixel buffer to `targetW` via `scale`.
opts.blur = { bgEl, photoUrl, amount } enables the bake step.
Returns a JPEG Blob. */
async function renderBlob(el, targetW, opts) {
opts = opts || {};
if (typeof html2canvas !== 'function') {
throw new Error('A biblioteca de exportação ainda está a carregar. Tenta novamente em segundos.');
}
var rect = el.getBoundingClientRect();
var actualW = rect.width || el.offsetWidth || targetW;
var actualH = rect.height || el.offsetHeight || targetW;
var scale = actualW > 0 ? (targetW / actualW) : 1;
if (document.fonts && document.fonts.ready) {
await document.fonts.ready;
}
var restore = null;
if (opts.blur) {
restore = await bakeBlur(opts.blur.bgEl, opts.blur.photoUrl, opts.blur.amount, scale);
}
try {
var canvas = await html2canvas(el, {
backgroundColor: '#000',
scale: scale,
useCORS: true,
logging: false,
width: actualW,
height: actualH,
windowWidth: document.documentElement.clientWidth,
windowHeight: document.documentElement.clientHeight,
});
return await new Promise(function (resolve, reject) {
canvas.toBlob(function (blob) {
if (!blob) { reject(new Error('Falha ao gerar a imagem.')); return; }
resolve(blob);
}, 'image/jpeg', 0.95);
});
} finally {
if (restore) restore();
}
}
function saveBlob(blob, fileName) {
var url = URL.createObjectURL(blob);
var link = document.createElement('a');
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setTimeout(function () { URL.revokeObjectURL(url); }, 1500);
}
/* files: [{ blob, name }]. One file downloads directly; several are
bundled into a single ZIP. */
async function saveAll(files, zipName) {
if (!files.length) return;
if (files.length === 1) { saveBlob(files[0].blob, files[0].name); return; }
if (typeof JSZip !== 'function') {
// ZIP library missing — degrade to sequential downloads.
files.forEach(function (f) { saveBlob(f.blob, f.name); });
return;
}
var zip = new JSZip();
files.forEach(function (f) { zip.file(f.name, f.blob); });
var blob = await zip.generateAsync({ type: 'blob' });
saveBlob(blob, zipName);
}
return {
chunk: chunk,
renderBlob: renderBlob,
saveBlob: saveBlob,
saveAll: saveAll,
};
})();
@@ -1,117 +0,0 @@
<?php
// ─── Publications engine (shared, per-platform) ──────────────────────────────
//
// Moved out of the hub (PLANO_SIMPLIFICACAO_HUB.md §4.3): each platform now
// generates its own social posts from LOCAL data, so there is no content pull and
// no media proxy — photos are same-origin in the app itself. Pure client-side
// render (social/engine.js → html2canvas → JPG), driven by the CFG this file
// assembles and social/page.js consumes.
//
// A platform mounts views/admin/publicacoes.php (thin stub) that builds the items
// (the generic envelope) from its own queries, sets the platform colour, and
// requires social/publications.php. This file ships the formats + template
// catalogue (was the hub's SOCIAL_FORMATS const + social_templates table) and the
// CFG builder, so the contract that page.js expects stays byte-identical.
const PUB_FORMATS = [
'1:1' => ['label' => '1:1 Quadrado', 'w' => 2160, 'h' => 2160, 'group' => 'digital'],
'4:5' => ['label' => '4:5 Retrato', 'w' => 2160, 'h' => 2700, 'group' => 'digital'],
'9:16' => ['label' => '9:16 Story', 'w' => 2160, 'h' => 3840, 'group' => 'digital'],
'16:9' => ['label' => '16:9 Horizontal', 'w' => 3840, 'h' => 2160, 'group' => 'digital'],
'card' => ['label' => 'Cartão de visita', 'w' => 1004, 'h' => 650, 'group' => 'paper'],
'a4' => ['label' => 'A4', 'w' => 2480, 'h' => 3508, 'group' => 'paper'],
'a3' => ['label' => 'A3', 'w' => 3508, 'h' => 4961, 'group' => 'paper'],
'a2' => ['label' => 'A2', 'w' => 4961, 'h' => 7016, 'group' => 'paper'],
];
const PUB_MAX_BACKGROUNDS = 20;
// Template catalogue (was the social_templates table). multi = several items per
// slide; maxItems keyed by format id with a "default" fallback.
function pub_templates(): array {
return [
['slug' => 'event-list', 'name' => 'Lista de eventos', 'multi' => true,
'maxItems' => ['default' => 4, '9:16' => 6, '4:5' => 5, '16:9' => 3, 'a4' => 8, 'a3' => 8, 'a2' => 8, 'card' => 2],
'itemTypes' => ['event', 'manual']],
['slug' => 'single-announcement', 'name' => 'Anúncio único', 'multi' => false,
'maxItems' => ['default' => 1], 'itemTypes' => ['event', 'manual']],
['slug' => 'photo-highlight', 'name' => 'Foto em destaque', 'multi' => false,
'maxItems' => ['default' => 1], 'itemTypes' => ['photo_session']],
['slug' => 'photo-grid', 'name' => 'Grelha de fotos', 'multi' => true,
'maxItems' => ['default' => 4, '9:16' => 4, '1:1' => 4], 'itemTypes' => ['photo_session']],
];
}
// Keep only the universal envelope fields the templates rely on.
function pub_normalize_items(array $items): array {
$out = [];
foreach ($items as $it) {
if (!is_array($it)) continue;
$out[] = [
'id' => (string) ($it['id'] ?? uniqid('it_')),
'type' => (string) ($it['type'] ?? 'event'),
'title' => (string) ($it['title'] ?? ''),
'subtitle' => (string) ($it['subtitle'] ?? ''),
'date_start' => (string) ($it['date_start'] ?? ''),
'date_end' => (string) ($it['date_end'] ?? ''),
'url' => (string) ($it['url'] ?? ''),
'category' => (string) ($it['category'] ?? ''),
'category_label' => (string) ($it['category_label'] ?? ''),
'fields' => is_array($it['fields'] ?? null) ? $it['fields'] : [],
];
}
return $out;
}
// Lighten (+) / darken (-) a hex colour by a percentage. Mirrors branding_shade.
function pub_shade(string $hex, int $pct): string {
if (!preg_match('/^#[0-9a-fA-F]{6}$/', $hex)) return $hex;
$ch = function (int $c) use ($pct): int {
$delta = ($pct / 100) * ($pct < 0 ? $c : 255 - $c);
return max(0, min(255, (int) round($c + $delta)));
};
return sprintf('#%02x%02x%02x',
$ch(hexdec(substr($hex, 1, 2))),
$ch(hexdec(substr($hex, 3, 2))),
$ch(hexdec(substr($hex, 5, 2)))
);
}
// Colour presets for the picker, derived from the platform's single primary
// colour. Same {gradients, solids} shape the old branding presets produced.
function pub_presets(string $primary): array {
$primary = preg_match('/^#[0-9a-fA-F]{6}$/', $primary) ? $primary : '#5A7A62';
return [
'gradients' => [
['name' => 'Primária escura', 'css' => "linear-gradient(160deg, #101312, " . pub_shade($primary, -35) . ")"],
['name' => 'Primária', 'css' => "linear-gradient(160deg, " . pub_shade($primary, -55) . ", {$primary})"],
['name' => 'Noite', 'css' => 'linear-gradient(160deg, #0a0a0a, #2a2a2a)'],
],
'solids' => [
['name' => 'Primária', 'css' => $primary],
['name' => 'Preto', 'css' => '#0A0A0A'],
['name' => 'Branco', 'css' => '#FFFFFF'],
],
];
}
// Assemble the CFG that social/page.js consumes. $opts:
// slug, primary (#hex), items[] (envelope), photos[] (photo cfg), logo,
// watermark, category_label, preselected[]
function pub_build_config(array $opts): array {
$primary = (string) ($opts['primary'] ?? '#5A7A62');
return [
'app' => (string) ($opts['slug'] ?? ''),
'primary' => $primary,
'csrf' => function_exists('csrf_token') ? csrf_token() : '',
'formats' => PUB_FORMATS,
'templates' => pub_templates(),
'presets' => pub_presets($primary),
'logo' => ((string) ($opts['logo'] ?? '')) ?: null,
'watermark' => ((string) ($opts['watermark'] ?? '')) ?: null,
'dynamic' => pub_normalize_items($opts['items'] ?? []),
'dynamicPhoto' => array_values($opts['photos'] ?? []),
'categoryLabel' => (string) ($opts['category_label'] ?? 'Categoria'),
'presel' => array_values($opts['preselected'] ?? []),
];
}
-745
View File
@@ -1,745 +0,0 @@
'use strict';
const CFG = JSON.parse(document.getElementById('engineConfig').textContent);
const CSRF_TOKEN = CFG.csrf || '';
const DAY_NAMES = ['DOM','SEG','TER','QUA','QUI','SEX','SÁB'];
const MONTH_NAMES = ['JAN','FEV','MAR','ABR','MAI','JUN','JUL','AGO','SET','OUT','NOV','DEZ'];
/* ── State ─────────────────────────────────────────────────── */
let items = []; // [{id, title, subtitle, date}]
let nextItemId = 1;
let slides = []; // arrays of items (chunked by template max)
let currentSlide = 0;
let currentBg = null; // gradient/solid CSS or image URL
let bgIsImage = false;
let blurAmount = 0;
let overlaySource = 'none';
let overlayValue = '';
let overlayOpacity = 50;
function esc(str) {
return String(str ?? '')
.replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
/* ── Dynamic items (platform content endpoint) ─────────────── */
let dynSelected = new Set((CFG.presel || []).filter(id => (CFG.dynamic || []).some(d => d.id === id)));
let dynCatValue = ''; // active category filter ('' = all)
// Items visible under the current category filter (selection persists across
// filter changes — hidden-but-selected items stay in the post).
function dynVisible() {
return (CFG.dynamic || []).filter(d => !dynCatValue || (d.category || '') === dynCatValue);
}
// Build the category dropdown from the distinct categories present in the
// dynamic items (first-seen order = the platform's order). Hidden when <2.
function buildCatFilter() {
const sel = document.getElementById('dynCatFilter');
const bar = document.getElementById('dynFilterBar');
if (!sel || !bar) return;
const seen = new Map();
(CFG.dynamic || []).forEach(d => {
const k = d.category || '';
if (k && !seen.has(k)) seen.set(k, d.category_label || k);
});
if (seen.size < 2) { bar.style.display = 'none'; return; }
let html = `<option value="">Todos · ${esc(CFG.categoryLabel || 'Categoria')}</option>`;
seen.forEach((lbl, k) => { html += `<option value="${esc(k)}">${esc(lbl)}</option>`; });
sel.innerHTML = html;
sel.value = dynCatValue;
bar.style.display = '';
}
function onCatFilter(v) { dynCatValue = v; renderDynList(); }
function renderDynList() {
const list = document.getElementById('dynList');
if (!list) return;
list.innerHTML = dynVisible().map(d => {
const dp = d.date_start ? dateParts(d.date_start.slice(0, 10)) : null;
return `<label class="list-group-item list-group-item-action d-flex align-items-center gap-3 py-2" style="cursor:pointer;">
<input type="checkbox" class="form-check-input flex-shrink-0 mt-0" data-dyn-id="${esc(d.id)}"
${dynSelected.has(d.id) ? 'checked' : ''}>
<div class="flex-grow-1 min-w-0">
<div class="small fw-medium text-truncate">${esc(d.title)}</div>
<div class="text-muted" style="font-size:.78rem;">
${dp ? `${dp.dayName}, ${dp.dayNum} ${dp.month}` : ''}${d.subtitle ? ` · ${esc(d.subtitle)}` : ''}
</div>
</div>
</label>`;
}).join('');
list.querySelectorAll('input[data-dyn-id]').forEach(cb => {
cb.addEventListener('change', () => {
cb.checked ? dynSelected.add(cb.dataset.dynId) : dynSelected.delete(cb.dataset.dynId);
rebuildSlides();
});
});
}
function dynToggleAll() {
// Operates on the currently-visible (filtered) items only.
const visible = dynVisible();
const everyOn = visible.length > 0 && visible.every(d => dynSelected.has(d.id));
visible.forEach(d => everyOn ? dynSelected.delete(d.id) : dynSelected.add(d.id));
document.getElementById('dynSelectAllBtn').textContent = everyOn ? 'Selecionar todos' : 'Limpar';
renderDynList();
rebuildSlides();
}
// Selected dynamic items, normalized to the same shape as manual items.
function dynamicItems() {
return (CFG.dynamic || [])
.filter(d => dynSelected.has(d.id))
.map(d => ({
id: 'dyn:' + d.id,
title: d.title,
subtitle: d.subtitle || '',
date: d.date_start ? d.date_start.slice(0, 10) : '',
}));
}
/* ── Lazy gallery browser (photo_session content, Fase 4) ──── */
// Sessions render collapsed (title + count only); the thumbnail images are
// only attached on first expand, so a platform with many galleries doesn't
// fetch hundreds of photos on page load. The same accordion is used in two
// places: the Fundo card (mode 'bg': click = use photo as background) and
// the photo-templates picker (mode 'multi': numbered multi-select).
let selectedPhotos = []; // [{url, title, subtitle, date}], in selection order
function initGalleryAccordion(containerId, mode) {
const box = document.getElementById(containerId);
if (!box) return;
box.innerHTML = (CFG.dynamicPhoto || []).map(s => `
<div class="border rounded mb-1">
<button type="button" class="ig-gal-head" data-session="${esc(s.id)}">
<span class="text-truncate">${esc(s.title)}${s.date ? ' · ' + esc(s.date) : ''}</span>
<span class="text-muted flex-shrink-0">${s.media.length} <i class="bi bi-chevron-down"></i></span>
</button>
<div class="ig-gal-body d-flex gap-2 flex-wrap p-2 d-none" data-loaded="0"></div>
</div>`).join('');
box.querySelectorAll('.ig-gal-head').forEach(head => {
head.addEventListener('click', () => {
const body = head.nextElementSibling;
const opening = body.classList.contains('d-none');
if (opening && body.dataset.loaded !== '1') {
const s = (CFG.dynamicPhoto || []).find(x => String(x.id) === head.dataset.session);
body.innerHTML = (s ? s.media : []).map(m => `
<div class="ig-photo-pick" data-session="${esc(s.id)}" data-url="${esc(m.url)}"
style="background-image:url('${esc(m.thumb)}')">
<span class="ig-photo-pick-num"></span>
</div>`).join('');
body.dataset.loaded = '1';
body.querySelectorAll('.ig-photo-pick').forEach(el => {
el.addEventListener('click', () =>
mode === 'multi' ? togglePhoto(el) : pickPhotoAsBg(el));
});
if (mode === 'multi') syncPhotoPicker();
}
body.classList.toggle('d-none', !opening);
const icon = head.querySelector('.bi');
if (icon) icon.className = opening ? 'bi bi-chevron-up' : 'bi bi-chevron-down';
});
});
}
// Fundo card: a session photo as the post background (full-res via proxy).
function pickPhotoAsBg(el) {
clearBgActive();
el.classList.add('active');
currentBg = el.dataset.url;
bgIsImage = true;
applyStyle();
}
function togglePhoto(el) {
const url = el.dataset.url;
const idx = selectedPhotos.findIndex(p => p.url === url);
if (idx >= 0) {
selectedPhotos.splice(idx, 1);
} else {
const session = (CFG.dynamicPhoto || []).find(s => String(s.id) === el.dataset.session);
selectedPhotos.push({
url,
title: session?.title || '',
subtitle: session?.subtitle || '',
date: session?.date || '',
});
}
syncPhotoPicker();
rebuildSlides();
}
function syncPhotoPicker() {
document.querySelectorAll('#photoPicker .ig-photo-pick').forEach(el => {
const idx = selectedPhotos.findIndex(p => p.url === el.dataset.url);
el.classList.toggle('active', idx >= 0);
el.querySelector('.ig-photo-pick-num').textContent = idx >= 0 ? String(idx + 1) : '';
});
const cnt = document.getElementById('photoSelCount');
if (cnt) cnt.textContent = selectedPhotos.length + ' selecionada' + (selectedPhotos.length === 1 ? '' : 's');
}
/* ── Manual items ──────────────────────────────────────────── */
function addItem(data) {
items.push(Object.assign({ id: nextItemId++, title: '', subtitle: '', date: '' }, data || {}));
renderItemList();
rebuildSlides();
}
function removeItem(id) {
items = items.filter(i => i.id !== id);
renderItemList();
rebuildSlides();
}
function onItemField(id, field, value) {
const it = items.find(i => i.id === id);
if (it) { it[field] = value; rebuildSlides(); }
}
function renderItemList() {
const box = document.getElementById('itemList');
document.getElementById('itemsEmpty').style.display = items.length ? 'none' : '';
box.innerHTML = items.map(it => `
<div class="border rounded p-2">
<div class="d-flex gap-2 mb-1">
<input type="text" class="form-control form-control-sm" placeholder="Título *"
value="${esc(it.title)}" oninput="onItemField(${it.id},'title',this.value)">
<button type="button" class="btn btn-sm btn-outline-danger py-0 px-2"
onclick="removeItem(${it.id})" title="Remover"><i class="bi bi-x-lg"></i></button>
</div>
<div class="d-flex gap-2">
<input type="text" class="form-control form-control-sm" placeholder="Subtítulo / local"
value="${esc(it.subtitle)}" oninput="onItemField(${it.id},'subtitle',this.value)">
<input type="date" class="form-control form-control-sm" style="max-width:150px;"
value="${esc(it.date)}" oninput="onItemField(${it.id},'date',this.value)">
</div>
</div>`).join('');
}
function dateParts(iso) {
if (!iso) return null;
const d = new Date(iso + 'T12:00:00');
if (isNaN(d)) return null;
return {
dayName: DAY_NAMES[d.getDay()],
dayNum: String(d.getDate()),
month: MONTH_NAMES[d.getMonth()],
year: String(d.getFullYear()),
};
}
/* ── Template / format ─────────────────────────────────────── */
function currentTemplate() {
const slug = document.querySelector('input[name="tplPick"]:checked')?.value;
return CFG.templates.find(t => t.slug === slug) || CFG.templates[0];
}
function currentFormatId() {
return document.querySelector('input[name="postFormat"]:checked').value;
}
// Export dimensions for the active format, honoring the paper orientation swap.
function formatDims() {
const id = currentFormatId();
const f = CFG.formats[id];
let w = f.w, h = f.h;
if (f.group === 'paper' && document.getElementById('orientSwap').checked) {
[w, h] = [h, w];
}
return { id, w, h, group: f.group };
}
function maxItemsPerSlide() {
const tpl = currentTemplate();
const id = currentFormatId();
return tpl.maxItems[id] || tpl.maxItems['default'] || 1;
}
function isPhotoTemplate(tpl) {
return (tpl.itemTypes || []).includes('photo_session');
}
function toggleCard(id, show) {
const el = document.getElementById(id);
if (el) el.style.display = show ? '' : 'none';
}
function onTemplateChange() {
const tpl = currentTemplate();
const photo = isPhotoTemplate(tpl);
const isGrid = tpl.slug === 'photo-grid';
// The heading/tagline inputs only apply to the list template; the single
// announcement and photo templates take their caption from the item itself.
const single = !tpl.multi || photo;
document.getElementById('headingWrap').style.display = single ? 'none' : '';
document.getElementById('taglineWrap').style.display = single ? 'none' : '';
toggleCard('dynContentCard', !photo);
toggleCard('manualContentCard', !photo);
toggleCard('photoCard', photo);
toggleCard('fundoCard', !isGrid);
toggleCard('estiloCard', !isGrid);
toggleCard('textoCard', !isGrid);
rebuildSlides();
}
function onFormatChange() {
const { w, h, group } = formatDims();
document.getElementById('orientWrap').style.display = group === 'paper' ? '' : 'none';
const post = document.getElementById('postPreview');
post.style.aspectRatio = w + ' / ' + h;
document.getElementById('previewWrapper').classList.toggle('is-wide', w > h);
rebuildSlides();
}
/* ── Slides (overflow pagination) ──────────────────────────── */
function rebuildSlides() {
const tpl = currentTemplate();
const usable = isPhotoTemplate(tpl)
? selectedPhotos
: dynamicItems().concat(items.filter(i => i.title.trim() !== ''));
slides = SocialEngine.chunk(usable, maxItemsPerSlide());
if (!slides.length) slides = [[]];
if (currentSlide >= slides.length) currentSlide = slides.length - 1;
renderSlideNav();
updateSlideVisuals();
}
function renderSlideNav() {
const bar = document.getElementById('slideToolbar');
const dots = document.getElementById('slideDots');
bar.style.display = slides.length > 1 ? '' : 'none';
dots.innerHTML = '';
slides.forEach((_, i) => {
const dot = document.createElement('button');
dot.type = 'button';
dot.className = 'ig-slide-dot' + (i === currentSlide ? ' active' : '');
dot.addEventListener('click', () => { currentSlide = i; renderSlideNav(); updateSlideVisuals(); });
dots.appendChild(dot);
});
}
function slidePrev() { if (currentSlide > 0) { currentSlide--; renderSlideNav(); updateSlideVisuals(); } }
function slideNext() { if (currentSlide < slides.length - 1) { currentSlide++; renderSlideNav(); updateSlideVisuals(); } }
/* ── Slide rendering (templates) ───────────────────────────── */
function logoRowHtml() {
if (!CFG.logo || !document.getElementById('showLogo')?.checked) return '';
return `<div class="ig-logo-row"><img src="${esc(CFG.logo)}" alt=""></div>`;
}
function itemCardHtml(it) {
const dp = dateParts(it.date);
return `<div class="ig-session-card">
${dp ? `<div class="ig-date-block">
<div class="ig-date-dayname">${dp.dayName}</div>
<div class="ig-date-num">${dp.dayNum}</div>
<div class="ig-date-month">${dp.month}</div>
</div>
<div class="ig-card-divider"></div>` : ''}
<div class="ig-session-info">
<div class="ig-session-type">${esc(it.title)}</div>
${it.subtitle ? `<div class="ig-session-avail">${esc(it.subtitle)}</div>` : ''}
</div>
</div>`;
}
function renderSlide() {
const content = document.getElementById('postContent');
const tpl = currentTemplate();
const slide = slides[currentSlide] || [];
const cta = document.getElementById('ctaText').value.trim();
let html = logoRowHtml();
if (tpl.slug === 'photo-grid') {
content.innerHTML = '';
return;
}
if (tpl.slug === 'photo-highlight') {
const it = slide[0];
if (!it) {
html += '<p class="ig-empty-msg">Seleciona uma foto à esquerda</p>';
} else {
const dp = dateParts(it.date);
html += `<div class="ig-announce">
${dp ? `<p class="ig-announce-date">${dp.dayName} · ${dp.dayNum} ${dp.month} ${dp.year}</p>` : ''}
<h2 class="ig-announce-title">${esc(it.title)}</h2>
${it.subtitle ? `<p class="ig-announce-subtitle">${esc(it.subtitle)}</p>` : ''}
<div class="ig-heading-divider"></div>
</div>`;
}
} else if (tpl.slug === 'single-announcement') {
const it = slide[0];
if (!it) {
html += '<p class="ig-empty-msg">Adiciona um item à esquerda</p>';
} else {
const dp = dateParts(it.date);
html += `<div class="ig-announce">
${dp ? `<p class="ig-announce-date">${dp.dayName} · ${dp.dayNum} ${dp.month} ${dp.year}</p>` : ''}
<h2 class="ig-announce-title">${esc(it.title)}</h2>
${it.subtitle ? `<p class="ig-announce-subtitle">${esc(it.subtitle)}</p>` : ''}
<div class="ig-heading-divider"></div>
</div>`;
}
} else { // event-list
const tagline = document.getElementById('tagline').value.trim();
const heading = document.getElementById('headingText').value.trim();
html += `<div class="ig-title-block">
${tagline ? `<p class="ig-tagline">${esc(tagline)}</p>` : ''}
${heading ? `<h2 class="ig-heading">${esc(heading)}</h2><div class="ig-heading-divider"></div>` : ''}
</div>
<div class="ig-sessions">
${slide.length ? slide.map(itemCardHtml).join('') : '<p class="ig-empty-msg">Adiciona itens à esquerda</p>'}
</div>`;
}
if (cta) html += `<div class="ig-cta-row"><span class="ig-cta">${esc(cta)}</span></div>`;
content.innerHTML = html;
}
/* ── Background / style ────────────────────────────────────── */
function luminance(hex) {
const m = /^#([0-9a-f]{6})$/i.exec(hex || '');
if (!m) return 0;
const n = parseInt(m[1], 16);
return (0.2126 * (n >> 16) + 0.7152 * ((n >> 8) & 255) + 0.0722 * (n & 255)) / 255;
}
// The displayed background: the user's pick, except for photo-highlight where
// the current slide's selected photo always wins.
function effectiveBg() {
const tpl = currentTemplate();
if (tpl.slug === 'photo-highlight') {
const it = (slides[currentSlide] || [])[0];
return { bg: it ? it.url : null, isImage: !!it };
}
return { bg: currentBg, isImage: bgIsImage };
}
function applyStyle() {
const post = document.getElementById('postPreview');
const bg = document.getElementById('photoBg');
const { bg: effBg, isImage: effIsImage } = effectiveBg();
if (effIsImage && effBg) {
bg.style.display = '';
bg.style.backgroundImage = "url('" + effBg.replace(/'/g, "\\'") + "')";
bg.style.filter = blurAmount > 0 ? `blur(${blurAmount}px) brightness(.85)` : '';
bg.style.transform = blurAmount > 0 ? `scale(${1 + blurAmount / 280})` : '';
post.style.background = '';
post.classList.add('is-photo');
post.classList.remove('is-light');
} else {
bg.style.display = 'none';
bg.style.backgroundImage = '';
bg.style.filter = '';
bg.style.transform = '';
post.style.background = effBg || '';
post.classList.remove('is-photo');
// Solid light colors flip the text palette + drop the dark wash.
post.classList.toggle('is-light', !!effBg && luminance(effBg) > 0.72);
}
document.getElementById('blurWrap').style.display = effIsImage ? '' : 'none';
const ov = document.getElementById('customOverlay');
if ((overlaySource === 'gradient' || overlaySource === 'solid') && overlayValue) {
ov.style.background = overlayValue;
ov.style.opacity = (overlayOpacity / 100).toString();
} else {
ov.style.background = '';
ov.style.opacity = '0';
}
post.classList.toggle('has-watermark', !!document.getElementById('showWatermark')?.checked);
const content = document.getElementById('postContent');
content.dataset.alignH = document.querySelector('input[name="alignH"]:checked').value;
content.dataset.alignV = document.querySelector('input[name="alignV"]:checked').value;
renderSlide(); // logo toggle lives inside slide markup
}
// Switches the canvas between the photo-grid layout and the normal
// background+content layout, then applies the current style.
function updateSlideVisuals() {
const tpl = currentTemplate();
const grid = document.getElementById('photoGrid');
const content = document.getElementById('postContent');
const overlay = document.querySelector('.ig-overlay');
if (tpl.slug === 'photo-grid') {
const slide = slides[currentSlide] || [];
grid.style.display = 'grid';
content.style.display = 'none';
overlay.style.display = 'none';
document.getElementById('photoBg').style.display = 'none';
document.getElementById('customOverlay').style.opacity = '0';
const cols = slide.length <= 1 ? 1 : (slide.length <= 4 ? 2 : 3);
grid.style.gridTemplateColumns = `repeat(${cols}, 1fr)`;
grid.innerHTML = slide.length
? slide.map(p => `<div class="ig-grid-cell" style="background-image:url('${esc(p.url)}')"></div>`).join('')
: '<div class="ig-grid-cell ig-grid-empty">Seleciona fotos à esquerda</div>';
return;
}
grid.style.display = 'none';
content.style.display = '';
overlay.style.display = '';
applyStyle();
}
function onBlurChange() {
blurAmount = parseInt(document.getElementById('blurRange').value, 10) || 0;
document.getElementById('blurValue').textContent = blurAmount + ' px';
applyStyle();
}
function onAlignChange() { applyStyle(); }
function onOverlayChange() {
overlaySource = document.querySelector('input[name="overlaySource"]:checked').value;
const cur = overlayValue || '';
if (overlaySource === 'gradient' && !cur.startsWith('linear-gradient') && !cur.startsWith('radial-gradient')) {
overlayValue = CFG.presets.gradients[0].css;
} else if (overlaySource === 'solid' && (cur === '' || cur.includes('gradient'))) {
overlayValue = CFG.presets.solids[0].css;
}
const hasPreset = overlaySource !== 'none';
document.getElementById('ovGradientWrap').style.display = overlaySource === 'gradient' ? '' : 'none';
document.getElementById('ovSolidWrap').style.display = overlaySource === 'solid' ? '' : 'none';
document.getElementById('ovOpacityWrap').style.display = hasPreset ? '' : 'none';
markActive('#ovGradientList .ig-preset, #ovSolidList .ig-preset', overlayValue);
applyStyle();
}
function onOverlayOpacityChange() {
overlayOpacity = parseInt(document.getElementById('ovOpacity').value, 10) || 0;
document.getElementById('ovOpacityValue').textContent = overlayOpacity + ' %';
applyStyle();
}
function markActive(selector, css) {
document.querySelectorAll(selector).forEach(b => b.classList.toggle('active', b.dataset.bg === css));
}
function clearBgActive() {
document.querySelectorAll('#gradientList .ig-preset, #solidList .ig-preset, .ig-img-preset, #bgGalleryList .ig-photo-pick')
.forEach(b => b.classList.remove('active'));
}
function renderPresets(containerId, presets, onPick) {
const el = document.getElementById(containerId);
el.innerHTML = presets.map(p => `
<button type="button" class="ig-preset" data-bg="${esc(p.css)}"
style="background:${esc(p.css)}" title="${esc(p.name)}"></button>`).join('');
el.querySelectorAll('.ig-preset').forEach(b => b.addEventListener('click', () => onPick(b)));
}
function pickBgPreset(btn) {
clearBgActive();
btn.classList.add('active');
currentBg = btn.dataset.bg;
bgIsImage = false;
applyStyle();
}
function pickOverlayPreset(btn) {
overlayValue = btn.dataset.bg;
markActive('#ovGradientList .ig-preset, #ovSolidList .ig-preset', overlayValue);
applyStyle();
}
/* Image backgrounds (gallery + local file) */
function bindImgPreset(btn) {
btn.addEventListener('click', () => {
clearBgActive();
btn.classList.add('active');
currentBg = btn.dataset.img;
bgIsImage = true;
applyStyle();
});
}
document.getElementById('imageInput').addEventListener('change', function () {
if (this.files[0]) readLocalImage(this.files[0]);
});
const dropZone = document.getElementById('dropZone');
['dragenter','dragover'].forEach(ev => dropZone.addEventListener(ev, e => {
e.preventDefault(); dropZone.classList.add('dragging');
}));
['dragleave','drop'].forEach(ev => dropZone.addEventListener(ev, () => dropZone.classList.remove('dragging')));
dropZone.addEventListener('drop', e => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith('image/')) readLocalImage(file);
});
function readLocalImage(file) {
const reader = new FileReader();
reader.onload = e => {
clearBgActive();
currentBg = e.target.result;
bgIsImage = true;
applyStyle();
};
reader.readAsDataURL(file);
}
/* Gallery upload / delete */
document.getElementById('bgUploadInput').addEventListener('change', function () {
const file = this.files[0];
if (!file) return;
const icon = document.getElementById('bgUploadIcon');
icon.className = 'bi bi-arrow-clockwise ig-spin';
const fd = new FormData();
fd.append('action', 'upload_bg');
fd.append('app', CFG.app);
fd.append('bg_image', file);
fetch(window.location.pathname + '?app=' + encodeURIComponent(CFG.app), {
method: 'POST', headers: { 'X-CSRF-Token': CSRF_TOKEN }, body: fd,
})
.then(r => r.json())
.then(data => {
if (data.status !== 'success') { alert(data.message); return; }
document.getElementById('noPresetsHint')?.remove();
const list = document.getElementById('bgPresetList');
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'ig-img-preset';
btn.dataset.img = data.url;
btn.dataset.id = data.id;
btn.style.backgroundImage = "url('" + data.url + "')";
btn.innerHTML = '<span class="ig-img-del" title="Eliminar" onclick="deleteBg(event,' + data.id + ')">&times;</span>';
bindImgPreset(btn);
list.appendChild(btn);
const count = document.getElementById('bgCount');
count.textContent = (parseInt(count.textContent, 10) || 0) + 1;
btn.click();
})
.catch(() => alert('Erro ao guardar a imagem.'))
.finally(() => { icon.className = 'bi bi-plus-circle'; this.value = ''; });
});
function deleteBg(ev, id) {
ev.stopPropagation();
if (!confirm('Eliminar este fundo da galeria?')) return;
const fd = new FormData();
fd.append('action', 'delete_bg');
fd.append('app', CFG.app);
fd.append('bg_id', id);
fetch(window.location.pathname + '?app=' + encodeURIComponent(CFG.app), {
method: 'POST', headers: { 'X-CSRF-Token': CSRF_TOKEN }, body: fd,
}).then(() => {
document.querySelector('.ig-img-preset[data-id="' + id + '"]')?.remove();
const count = document.getElementById('bgCount');
count.textContent = Math.max(0, (parseInt(count.textContent, 10) || 1) - 1);
});
}
/* ── Export ────────────────────────────────────────────────── */
function exportFileName(n) {
const tpl = currentTemplate().slug;
const date = new Date().toISOString().slice(0, 10);
return `${CFG.app}_${tpl}_${date}_${n}.jpg`;
}
function blurOpts() {
const { bg, isImage } = effectiveBg();
if (!isImage || !(blurAmount > 0)) return null;
return { bgEl: document.getElementById('photoBg'), photoUrl: bg, amount: blurAmount };
}
async function exportCurrent(btn) {
const { w } = formatDims();
btn.disabled = true;
try {
const blob = await SocialEngine.renderBlob(document.getElementById('postPreview'), w, { blur: blurOpts() });
SocialEngine.saveBlob(blob, exportFileName(currentSlide + 1));
} catch (err) {
console.error(err);
alert('Erro ao gerar a imagem: ' + (err?.message || err));
} finally {
btn.disabled = false;
}
}
async function exportAll(btn) {
const { w } = formatDims();
const original = currentSlide;
btn.disabled = true;
try {
const files = [];
for (let i = 0; i < slides.length; i++) {
currentSlide = i;
renderSlideNav();
updateSlideVisuals();
// Give the DOM a frame to settle before capturing.
await new Promise(r => requestAnimationFrame(() => setTimeout(r, 60)));
const blob = await SocialEngine.renderBlob(document.getElementById('postPreview'), w, { blur: blurOpts() });
files.push({ blob, name: exportFileName(i + 1) });
}
const date = new Date().toISOString().slice(0, 10);
await SocialEngine.saveAll(files, `${CFG.app}_${currentTemplate().slug}_${date}.zip`);
} catch (err) {
console.error(err);
alert('Erro ao exportar: ' + (err?.message || err));
} finally {
currentSlide = original;
renderSlideNav();
updateSlideVisuals();
btn.disabled = false;
}
}
/* ── Screenshot mode ───────────────────────────────────────── */
function enterScreenshotMode() {
const { w, h } = formatDims();
const dest = document.getElementById('screenshotPost');
const vw = window.innerWidth, vh = window.innerHeight;
if (vh * w / h <= vw) {
dest.style.height = '100dvh';
dest.style.width = `calc(100dvh * ${w} / ${h})`;
} else {
dest.style.width = '100vw';
dest.style.height = `calc(100vw * ${h} / ${w})`;
}
dest.innerHTML = '';
const clone = document.getElementById('postPreview').cloneNode(true);
clone.removeAttribute('id');
clone.style.aspectRatio = w + ' / ' + h;
dest.appendChild(clone);
const overlay = document.getElementById('screenshotOverlay');
const closeBtn = document.getElementById('screenshotCloseBtn');
overlay.style.display = 'flex';
document.body.style.overflow = 'hidden';
closeBtn.style.opacity = '1';
clearTimeout(window._scTimer);
window._scTimer = setTimeout(() => { closeBtn.style.opacity = '0'; }, 2000);
}
function exitScreenshotMode() {
document.getElementById('screenshotOverlay').style.display = 'none';
document.body.style.overflow = '';
}
document.getElementById('screenshotOverlay').addEventListener('click', function (e) {
if (e.target === this) exitScreenshotMode();
});
/* ── Init ──────────────────────────────────────────────────── */
renderPresets('gradientList', CFG.presets.gradients, pickBgPreset);
renderPresets('solidList', CFG.presets.solids, pickBgPreset);
renderPresets('ovGradientList', CFG.presets.gradients, pickOverlayPreset);
renderPresets('ovSolidList', CFG.presets.solids, pickOverlayPreset);
document.querySelectorAll('#bgPresetList .ig-img-preset').forEach(bindImgPreset);
// Default background: first brand gradient.
const firstGrad = document.querySelector('#gradientList .ig-preset');
if (firstGrad) { firstGrad.classList.add('active'); currentBg = firstGrad.dataset.bg; }
buildCatFilter();
renderDynList();
initGalleryAccordion('bgGalleryList', 'bg');
initGalleryAccordion('photoPicker', 'multi');
if (!(CFG.dynamic || []).length) addItem(); // empty manual row only without dynamic source
onTemplateChange(); // sets heading visibility + builds slides
onFormatChange(); // sets aspect ratio
applyStyle();
@@ -1,504 +0,0 @@
/* ── Social-post generator (port of fotografia's ig_spots.css) ───────────────
Class names match the proven fotografia builder; the color/font tokens are
driven by the platform's app_branding row via --brand-* vars injected on
.ig-post by the generator page. Fallbacks keep posts legible for platforms
without branding configured. */
/* Local aliases resolved from branding */
.ig-post {
--sp-accent: var(--brand-accent, var(--brand-primary, #C9A96E));
--sp-on-accent: #1a1713;
--sp-font-heading: var(--brand-font-heading, 'Cormorant Garamond', serif);
--sp-font-body: var(--brand-font-body, 'Poppins', system-ui, sans-serif);
--sp-font-sub: var(--brand-font-body, 'Montserrat', sans-serif);
}
/* Dropzone */
.ig-dropzone {
border: 2px dashed #ced4da;
border-radius: 8px;
padding: 1.25rem;
text-align: center;
cursor: pointer;
transition: border-color .15s, background .15s;
}
.ig-dropzone:hover,
.ig-dropzone.dragging {
border-color: var(--am-accent, #0d6efd);
background: rgba(13,110,253,.04);
}
/* Preset swatches */
.ig-preset {
width: 36px;
height: 36px;
border-radius: 8px;
border: 2px solid transparent;
cursor: pointer;
flex-shrink: 0;
transition: transform .15s, outline .15s;
}
.ig-preset:hover { transform: scale(1.12); }
.ig-preset.active { border-color: #fff; outline: 2px solid var(--am-accent, #0d6efd); }
.ig-img-preset {
position: relative;
width: 52px;
height: 52px;
border-radius: 8px;
border: 2px solid transparent;
cursor: pointer;
background-size: cover;
background-position: center;
flex-shrink: 0;
transition: transform .15s, outline .15s;
}
.ig-img-preset:hover { transform: scale(1.1); }
.ig-img-preset.active { border-color: #fff; outline: 2px solid var(--am-accent, #0d6efd); }
.ig-img-preset .ig-img-del {
position: absolute;
top: -7px;
right: -7px;
width: 18px;
height: 18px;
border-radius: 50%;
border: none;
background: #dc3545;
color: #fff;
font-size: 10px;
line-height: 1;
display: none;
align-items: center;
justify-content: center;
cursor: pointer;
}
.ig-img-preset:hover .ig-img-del { display: flex; }
/* Lazy gallery accordion (Fase 4: photo_session content) */
.ig-gal-head {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
gap: .5rem;
background: none;
border: 0;
padding: .45rem .65rem;
font-size: .82rem;
text-align: left;
cursor: pointer;
}
.ig-gal-head:hover { background: rgba(0,0,0,.04); }
.ig-gal-body { border-top: 1px solid rgba(0,0,0,.08); }
/* Photo picker thumbnails (Fase 4: photo_session content) */
.ig-photo-pick {
position: relative;
width: 64px;
height: 64px;
border-radius: 6px;
border: 2px solid transparent;
background-size: cover;
background-position: center;
background-color: #e9ecef;
cursor: pointer;
flex-shrink: 0;
transition: transform .15s, outline .15s;
}
.ig-photo-pick:hover { transform: scale(1.06); }
.ig-photo-pick.active { border-color: #fff; outline: 2px solid var(--am-accent, #0d6efd); }
.ig-photo-pick-num {
position: absolute;
top: -6px;
right: -6px;
min-width: 18px;
height: 18px;
padding: 0 4px;
border-radius: 50%;
background: var(--am-accent, #0d6efd);
color: #fff;
font-size: 10px;
font-weight: 700;
line-height: 18px;
text-align: center;
}
.ig-photo-pick-num:empty { display: none; }
/* Photo-grid template (Fase 4): selected photos tile the whole canvas */
.ig-post .ig-photo-grid {
position: absolute;
inset: 0;
display: none;
gap: 4px;
}
.ig-photo-grid .ig-grid-cell {
background-size: cover;
background-position: center;
background-color: rgba(255,255,255,.06);
}
.ig-photo-grid .ig-grid-empty {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255,255,255,.4);
font-family: var(--sp-font-body);
font-size: 11px;
text-align: center;
padding: 1rem;
}
/* Preview panel */
.ig-preview-sticky { position: sticky; top: 80px; }
.ig-preview-wrapper {
height: min(72vh, 580px);
display: flex;
justify-content: center;
}
/* The post canvas */
.ig-post {
position: relative;
aspect-ratio: 1 / 1;
height: 100%;
width: auto;
max-width: 100%;
background: linear-gradient(160deg, #1a1a2e, #16213e);
background-size: cover;
background-position: center;
overflow: hidden;
box-shadow: 0 24px 64px rgba(0,0,0,.35);
font-family: var(--sp-font-body);
}
/* Wide/landscape formats fit by width instead of height */
.ig-preview-wrapper.is-wide { height: auto; }
.ig-preview-wrapper.is-wide .ig-post { width: 100%; height: auto; }
.ig-overlay {
position: absolute;
inset: 0;
background: linear-gradient(
to bottom,
rgba(0,0,0,.15) 0%,
rgba(0,0,0,.42) 40%,
rgba(0,0,0,.72) 100%
);
}
.ig-post.is-photo .ig-overlay {
background: linear-gradient(
to bottom,
rgba(0,0,0,.05) 0%,
rgba(0,0,0,.25) 55%,
rgba(0,0,0,.78) 100%
);
}
/* Light/paper rendering (e.g. white solid backgrounds) drops the dark wash */
.ig-post.is-light .ig-overlay { background: none; }
.ig-overlay-custom {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0;
}
.ig-post .ig-photo-bg {
position: absolute;
inset: 0;
background-size: cover;
background-position: center;
transition: filter .25s, transform .25s;
}
/* Watermark (optional, from app_branding_assets type=watermark) */
.ig-watermark {
position: absolute;
right: 4%;
bottom: 3%;
width: 14%;
opacity: .5;
pointer-events: none;
display: none;
}
.ig-post.has-watermark .ig-watermark { display: block; }
.ig-content {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
gap: clamp(12px, 3.5%, 26px);
padding: clamp(16px,6%,32px) clamp(20px,8%,40px);
}
.ig-content[data-align-v="top"] { justify-content: flex-start; }
.ig-content[data-align-v="middle"] { justify-content: center; }
.ig-content[data-align-v="bottom"] { justify-content: flex-end; }
.ig-content[data-align-h="left"] { align-items: flex-start; text-align: left; }
.ig-content[data-align-h="center"] { align-items: stretch; text-align: center; }
.ig-content[data-align-h="right"] { align-items: flex-end; text-align: right; }
/* Brand logo row */
.ig-logo-row {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.ig-logo-row img {
max-height: 44px;
max-width: 55%;
object-fit: contain;
}
/* Title block */
.ig-title-block { flex-shrink: 0; }
.ig-tagline {
color: rgba(255,255,255,.7);
font-family: var(--sp-font-sub);
font-size: 9px;
text-transform: uppercase;
letter-spacing: 2.5px;
margin: 0 0 6px;
line-height: 1.4;
}
.ig-heading {
color: #fff;
font-family: var(--sp-font-heading);
font-size: clamp(22px, 6.5%, 34px);
font-weight: 400;
margin: 0 0 12px;
line-height: 1.1;
letter-spacing: .02em;
}
.ig-heading-divider {
width: 48px;
height: 1px;
background: var(--sp-accent);
display: inline-block;
}
.ig-content[data-align-h="center"] .ig-heading-divider { margin: 0 auto; display: block; }
/* Item cards (event-list) */
.ig-sessions {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 7px;
}
.ig-empty-msg {
color: rgba(255,255,255,.4);
font-size: 11px;
text-align: center;
margin: 0;
}
.ig-session-card {
background: rgba(255,255,255,.12);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border: 1px solid rgba(255,255,255,.2);
border-radius: 10px;
padding: 9px 12px;
display: flex;
align-items: center;
gap: 12px;
text-align: left;
}
.ig-date-block {
flex-shrink: 0;
text-align: center;
min-width: 30px;
}
.ig-date-dayname {
color: rgba(255,255,255,.55);
font-size: 8px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
line-height: 1;
margin-bottom: 2px;
}
.ig-date-num {
color: #fff;
font-family: var(--sp-font-heading);
font-size: 26px;
font-weight: 400;
line-height: 1;
}
.ig-date-month {
color: rgba(255,255,255,.55);
font-size: 8px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .8px;
margin-top: 2px;
}
.ig-card-divider {
width: 1px;
height: 36px;
flex-shrink: 0;
opacity: .85;
background: var(--sp-accent);
}
.ig-session-info { flex: 1; min-width: 0; }
.ig-session-type {
color: #fff;
font-family: var(--sp-font-heading);
font-size: 15px;
font-weight: 400;
line-height: 1.2;
letter-spacing: .02em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ig-session-avail {
color: var(--sp-accent);
font-family: var(--sp-font-sub);
font-size: 9px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 1.2px;
margin-top: 3px;
}
/* Single announcement */
.ig-announce {
flex-shrink: 0;
max-width: 100%;
}
.ig-announce-date {
color: var(--sp-accent);
font-family: var(--sp-font-sub);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 2px;
margin: 0 0 10px;
}
.ig-announce-title {
color: #fff;
font-family: var(--sp-font-heading);
font-size: clamp(26px, 9%, 44px);
font-weight: 400;
line-height: 1.08;
margin: 0 0 8px;
letter-spacing: .02em;
}
.ig-announce-subtitle {
color: rgba(255,255,255,.85);
font-family: var(--sp-font-sub);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 1.8px;
margin: 0 0 14px;
}
.ig-announce-extra {
color: rgba(255,255,255,.75);
font-family: var(--sp-font-body);
font-size: 12px;
line-height: 1.5;
margin: 0;
}
/* CTA row */
.ig-cta-row {
flex-shrink: 0;
padding-top: 10px;
border-top: 1px solid rgba(255,255,255,.18);
text-align: center;
}
.ig-cta {
color: var(--sp-accent);
font-family: var(--sp-font-sub);
font-size: 11px;
font-weight: 600;
letter-spacing: 1.6px;
text-transform: uppercase;
}
/* Light/paper variant: dark text on light backgrounds */
.ig-post.is-light .ig-tagline,
.ig-post.is-light .ig-announce-extra { color: rgba(20,20,20,.6); }
.ig-post.is-light .ig-heading,
.ig-post.is-light .ig-date-num,
.ig-post.is-light .ig-session-type,
.ig-post.is-light .ig-announce-title { color: #141414; }
.ig-post.is-light .ig-announce-subtitle { color: rgba(20,20,20,.75); }
.ig-post.is-light .ig-empty-msg { color: rgba(20,20,20,.4); }
.ig-post.is-light .ig-date-dayname,
.ig-post.is-light .ig-date-month { color: rgba(20,20,20,.55); }
.ig-post.is-light .ig-session-card {
background: rgba(0,0,0,.05);
border-color: rgba(0,0,0,.12);
}
.ig-post.is-light .ig-cta-row { border-top-color: rgba(0,0,0,.15); }
/* Slide navigator */
.ig-slide-nav {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.ig-slide-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: #d0d5dc;
border: none;
padding: 0;
cursor: pointer;
transition: background .15s, transform .15s;
}
.ig-slide-dot.active { background: var(--am-accent, #0d6efd); transform: scale(1.25); }
.ig-slide-toolbar {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
flex-wrap: wrap;
}
/* Screenshot overlay */
.ig-screenshot-overlay {
position: fixed;
inset: 0;
background: #000;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
#screenshotPost { position: relative; overflow: hidden; }
#screenshotPost .ig-post {
width: 100%;
height: 100%;
aspect-ratio: unset;
border-radius: 0;
box-shadow: none;
max-width: unset;
}
.ig-screenshot-close {
position: fixed;
top: 16px;
right: 16px;
z-index: 10000;
background: rgba(255,255,255,.18);
border: none;
color: #fff;
border-radius: 50%;
width: 44px;
height: 44px;
font-size: 20px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
cursor: pointer;
transition: opacity .4s;
}
@keyframes ig-spin { to { transform: rotate(360deg); } }
.ig-spin { display: inline-block; animation: ig-spin .7s linear infinite; }
-206
View File
@@ -1,206 +0,0 @@
<?php
// ─── Página de origem das visitas ───────────────────────────────────────────
//
// Contrato do stub, como o backups.php: carregar $pdo, correr a guarda e
// definir $repo_root.
//
// A leitura útil é a coluna das conversões, não a das visitas. Mil visitas de
// uma origem que nunca produz um pedido valem menos do que trinta de um cartaz
// — e é a diferença entre as duas que diz onde vale a pena gastar dinheiro.
if (!isset($pdo) || !$pdo instanceof PDO) {
http_response_code(500);
exit('tracking.php: sem PDO — o arranque do site (config/db.php) tem de abrir a ligação primeiro.');
}
require_once __DIR__ . '/functions_tracking.php';
// As tabelas nascem com a primeira visita registada, porque só as funções de
// escrita as criavam. Numa plataforma acabada de ligar isso punha esta página
// a rebentar com "Table 'visit_sources' doesn't exist" -- um 500 exactamente no
// sítio onde se ia ver se a analítica estava a funcionar, e que se lê como
// avaria e não como "ainda não houve visitas". A chamada é idempotente e esta
// página é de sysadmin, por isso o custo do DDL não conta para nada.
platform_tracking_ensure_tables($pdo);
$days = (int) ($_GET['days'] ?? 30);
if (!in_array($days, [7, 30, 90, 365], true)) {
$days = 30;
}
// O próprio caminho, e não um base_url('views/sysadmin/...') fixo: o design
// põe as páginas partilhadas em views/admin/, e um caminho cravado aqui mandava
// os links desta página para um 404 nesse site.
$selfUrl = strtok((string) ($_SERVER['REQUEST_URI'] ?? ''), '?') ?: '';
$report = platform_tracking_report($pdo, $days);
$byForm = platform_tracking_conversions_by_form($pdo, $days);
$devices = platform_tracking_breakdown($pdo, 'device_type', $days);
$os = platform_tracking_breakdown($pdo, 'os', $days);
$browsers = platform_tracking_breakdown($pdo, 'browser', $days);
$countries = platform_tracking_breakdown($pdo, 'country', $days);
$referrers = platform_tracking_breakdown($pdo, 'referrer_host', $days, 10);
$hasGeoIndex = geo_country_index_available();
$totalVisits = array_sum(array_column($report, 'visits'));
$totalConv = array_sum(array_column($report, 'conversions'));
$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');
$pageTitle = 'Origem das visitas';
if (is_file($layout_header)) include $layout_header;
if (is_file($layout_navbar)) include $layout_navbar;
$h = fn($s) => htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
// Barra proporcional — comparar números numa coluna é mais lento do que
// comparar comprimentos, e a pergunta aqui é sempre "qual é o maior".
$bar = function (int $n, int $max) use ($h): string {
$pct = $max > 0 ? max(2, (int) round($n / $max * 100)) : 0;
return '<div style="background:rgba(125,125,125,.15);border-radius:3px;height:6px;overflow:hidden;">'
. '<div style="width:' . $pct . '%;height:100%;background:currentColor;opacity:.55;"></div></div>';
};
$table = function (string $title, array $rows, callable $label) use ($h, $bar): string {
if (!$rows) {
return '<div class="card shadow-sm h-100"><div class="card-body">'
. '<h6 class="fw-semibold mb-2">' . $h($title) . '</h6>'
. '<p class="text-muted small mb-0">Sem dados no período.</p></div></div>';
}
$max = max(array_map(fn($r) => (int) $r['visits'], $rows));
$out = '<div class="card shadow-sm h-100"><div class="card-body">'
. '<h6 class="fw-semibold mb-3">' . $h($title) . '</h6>'
. '<table class="table table-sm align-middle mb-0"><tbody>';
foreach ($rows as $r) {
$out .= '<tr>'
. '<td style="width:45%">' . $label($r) . '</td>'
. '<td class="text-end text-muted small" style="width:4rem">' . (int) $r['visits'] . '</td>'
. '<td>' . $bar((int) $r['visits'], $max) . '</td>'
. '</tr>';
}
return $out . '</tbody></table></div></div>';
};
?>
<div class="container py-4" style="max-width:1000px;">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-4">
<div>
<h3 class="fw-bold mb-1"><i class="bi bi-graph-up-arrow me-2"></i>Origem das visitas</h3>
<p class="text-muted mb-0">De onde vêm as pessoas, com que dispositivo e de que país.</p>
</div>
<div class="btn-group btn-group-sm">
<?php foreach ([7 => '7 dias', 30 => '30 dias', 90 => '90 dias', 365 => '1 ano'] as $d => $lbl): ?>
<a href="<?= $h($selfUrl) ?>?days=<?= $d ?>"
class="btn btn-outline-secondary <?= $days === $d ? 'active' : '' ?>"><?= $h($lbl) ?></a>
<?php endforeach; ?>
</div>
</div>
<?php if (!$hasGeoIndex): ?>
<div class="alert alert-warning py-2 small">
<strong>Sem índice de países.</strong> A coluna geográfica fica vazia até correr
<code>shared/platform-tools/bin/geo-build-index.php</code>. O resto funciona na mesma.
</div>
<?php endif; ?>
<div class="row g-3 mb-4">
<div class="col-sm-4">
<div class="card shadow-sm h-100"><div class="card-body">
<div class="text-muted small">Visitas</div>
<div class="fs-3 fw-bold"><?= (int) $totalVisits ?></div>
</div></div>
</div>
<div class="col-sm-4">
<div class="card shadow-sm h-100"><div class="card-body">
<div class="text-muted small">Conversões</div>
<div class="fs-3 fw-bold"><?= (int) $totalConv ?></div>
</div></div>
</div>
<div class="col-sm-4">
<div class="card shadow-sm h-100"><div class="card-body">
<div class="text-muted small">Taxa</div>
<div class="fs-3 fw-bold">
<?= $totalVisits > 0 ? number_format($totalConv / $totalVisits * 100, 1) . '%' : '—' ?>
</div>
</div></div>
</div>
</div>
<div class="card shadow-sm mb-4">
<div class="card-header fw-semibold"><i class="bi bi-signpost-split me-1"></i>Por origem</div>
<div class="card-body">
<?php if (!$report): ?>
<p class="text-muted mb-0">Ainda não há visitas registadas no período.</p>
<?php else: ?>
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Origem</th>
<th class="text-end">Visitas</th>
<th class="text-end">Visitantes</th>
<th class="text-end">Conversões</th>
<th class="text-end">Taxa</th>
<th>Última</th>
</tr>
</thead>
<tbody>
<?php foreach ($report as $r): ?>
<tr>
<td><code><?= $h($r['source']) ?></code></td>
<td class="text-end"><?= (int) $r['visits'] ?></td>
<td class="text-end text-muted"><?= (int) $r['visitors'] ?></td>
<td class="text-end fw-semibold"><?= (int) $r['conversions'] ?></td>
<td class="text-end text-muted">
<?= $r['visits'] > 0 ? number_format($r['conversions'] / $r['visits'] * 100, 1) . '%' : '—' ?>
</td>
<td class="text-muted small"><?= $h($r['last_seen'] ? substr((string) $r['last_seen'], 0, 16) : '—') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<p class="form-text mb-0 mt-2">
A origem é a de <strong>primeiro toque</strong>: quem chega por um QR code, sai e
volta pelo Google continua creditado ao QR code. Bots excluídos.
</p>
<?php endif; ?>
</div>
</div>
<?php if ($byForm): ?>
<div class="card shadow-sm mb-4">
<div class="card-header fw-semibold"><i class="bi bi-ui-checks me-1"></i>Conversões por formulário</div>
<div class="card-body">
<table class="table table-sm align-middle mb-0">
<tbody>
<?php foreach ($byForm as $f): ?>
<tr>
<td><code><?= $h($f['form']) ?></code></td>
<td class="text-end fw-semibold" style="width:5rem"><?= (int) $f['n'] ?></td>
<td class="text-muted small text-end"><?= $h(substr((string) $f['last_seen'], 0, 16)) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<div class="row g-3">
<div class="col-md-6"><?= $table('País', $countries, fn($r) => '<code>' . $h($r['label']) . '</code>') ?></div>
<div class="col-md-6"><?= $table('Dispositivo', $devices, fn($r) => $h($r['label'])) ?></div>
<div class="col-md-6"><?= $table('Sistema', $os, fn($r) => $h($r['label'])) ?></div>
<div class="col-md-6"><?= $table('Browser', $browsers, fn($r) => $h($r['label'])) ?></div>
<div class="col-12"><?= $table('Sites que enviaram tráfego', $referrers, fn($r) => '<code>' . $h($r['label']) . '</code>') ?></div>
</div>
<p class="text-muted small mt-4 mb-0">
Nenhum endereço IP é guardado. O <code>ip_hash</code> serve só para aproximar visitantes
únicos e é salgado com um segredo do servidor, por isso não se reverte.
</p>
</div>
<?php if (is_file($layout_footer)) include $layout_footer; ?>