Vendor platform-tools: everything this site runs is now in one repo
shared/platform-tools was a git submodule. On this branch it is a plain
directory of tracked files, frozen at ea3616abae30106df283e73a3e2f9e5bb0e6ae90
— the same commit fotografia.andmore.pt pins. Contents are byte-identical to
that tree, verified blob by blob.
The submodule caused every deployment failure this site has had:
- .gitmodules named no `branch`, so the update fell back to
refs/remotes/origin/HEAD — a ref a plain server-side clone never writes.
"Atualizar submódulo" failed with "Unable to find refs/remotes/origin/HEAD
revision in submodule path".
- Its URL was http://, which the origin answers with 523.
- This install is deployed by uploading files, and a submodule is invisible
to that. The tree moved forward while shared/platform-tools stayed on the
old pin, config/functions_secrets.php died on a missing compat_secrets.php,
and the whole site — public pages included — went down.
Each of those was separately fixable. Together they mean a second repository
nobody can see from the deployment path, gating whether the first one boots. A
plain directory deploys like every other file and cannot fall out of step.
The cost is that platform-tools updates no longer arrive by bumping a pin; they
are a copy-over-and-commit. shared/platform-tools/VENDORED.md records the
upstream SHA, the procedure, and which four files differ from upstream in line
endings only (social/*.js, .css, functions_publications.php — CRLF upstream,
normalised to LF here; content otherwise identical).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0e034232e9
commit
4d40adbf64
67 files changed
+17478
-5
No files matched your search
@@ -1,4 +0,0 @@
|
||||
[submodule "shared/platform-tools"]
|
||||
path = shared/platform-tools
|
||||
url = ../platform-tools.git
|
||||
branch = main
|
||||
Submodule shared/platform-tools deleted from ea3616abae.
@@ -0,0 +1,36 @@
|
||||
# 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.*
|
||||
@@ -0,0 +1,80 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,255 @@
|
||||
# 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.
|
||||
|
||||
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
|
||||
`auth_schema()` 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 is a thin, explicit stub in `public/api/`.
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,937 @@
|
||||
# platform-tools
|
||||
|
||||
Shared sysadmin tooling — "Atualizar plataforma" page, "Backups" page, and related helpers — distributed as a git submodule across every andmore platform.
|
||||
|
||||
The goal: edit one file, every platform gets the update.
|
||||
|
||||
## What's in here
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `index.php` | Sysadmin hub — single landing page with cards linking to the other tools. Extensible via `$sysadmin_tools` array in the stub. Loaded by each platform's `views/sysadmin/index.php` stub. |
|
||||
| `platform_update.php` | Shared sysadmin page. A status header (version, branch, commit, state pills, the update button) over four tabs: **Atualizar** (restore point, git, submodules), **Automação** (auto-update webhook, pre-update backups), **Base de dados** (migrations, schema export), **Servidor** (OPcache, composer, error log, pre-requisites). Loaded by each platform's `views/sysadmin/platform_update.php` stub. |
|
||||
| `functions_git.php` | `git_run(array $args, string $repo)` — runs git via `proc_open` array form, bypasses `safe.directory`, supports `GIT_HTTP_USERNAME` / `GIT_HTTP_TOKEN` for non-interactive HTTPS pulls. |
|
||||
| `functions_migrations.php` | `db_ensure_migrations_table()`, `migrations_list_files()`, `migrations_status()`, `migrations_apply_file()`, `migrations_mark_all_applied()`, `migrations_split_sql()` — quote/comment-aware SQL splitter, tracks state in `applied_migrations`. |
|
||||
| `functions_schema.php` | `schema_dump_path()`, `schema_build(PDO)`, `schema_dump_to_file(PDO)` — deterministic, structure-only dump of the host DB to `database/schema.sql` over PDO (`SHOW CREATE TABLE/VIEW`, no mysqldump). Strips timestamps, `AUTO_INCREMENT`, and view `DEFINER`s, sorts objects — so a git diff on `schema.sql` is a real changelog. Surfaced as the "Exportar esquema" button in the Migrations card. |
|
||||
| `backups.php` | Shared sysadmin page — lists existing backups, buttons to trigger files-ZIP / DB-dump backups, download link per row. Loaded by each platform's `views/sysadmin/backups.php` stub. |
|
||||
| `functions_backup.php` | `backup_dir()`, `backup_dir_protect()`, `backup_list()`, `backup_trigger($type)`, `backup_filename_is_safe()`, label/badge helpers. |
|
||||
| `functions_csrf.php` | Shared CSRF token over the host session — `platform_csrf_token()`, `platform_csrf_field()`, `platform_csrf_ok()`. Every POST form rendered by `platform_update.php` / `backups.php` carries the token; the handlers reject POSTs without it. |
|
||||
| `functions_settings.php` | DB-backed settings and secret storage — `platform_settings_get/set/all()`, `platform_settings_is_secret()`, `platform_secret_encrypt/decrypt/mask()`, `platform_smtp_config(PDO)`. The single implementation of secret encryption for the whole estate; see [Settings and secrets](#settings-and-secrets). |
|
||||
| `functions_auth.php` | Shared authentication mechanics — `auth_session_start()` (HttpOnly/SameSite/Secure/strict mode), `auth_attempt()` (lookup + verify + transparent rehash + timing defence), `auth_login()`, `auth_schema()`, the ranked role registry, the guards, and a proxy-aware login throttle. See [Authentication](#authentication). |
|
||||
| `functions_ip_ban.php` | IP banning with memory — `platform_ip_guard()`, `platform_ip_record_offence()`, `platform_ip_ban/unban()`. The estate's three per-IP defences all forget within the hour; this is the piece that remembers. See [IP banning](#ip-banning). |
|
||||
| `ip_bans.php` | Shared sysadmin page for the above — active bans, addresses climbing toward one, and a manual ban form. |
|
||||
| `functions_mail_rate.php` | Send ceiling — `platform_mail_rate_blocked()`, `platform_mail_rate_record()`. Two windows (per minute, per hour), enforced inside `platform_mail_send()` when a PDO is passed. Fails **closed**. |
|
||||
| `functions_client_ip.php` | `platform_client_ip()` — the visitor's address behind the proxy, honouring `X-Forwarded-For` only when `TRUSTED_PROXY` allows. No dependencies and nothing executed on include, so a site that declares its own `auth_login()` can include it without colliding with `functions_auth.php`. `auth_client_ip()` is an alias for it. |
|
||||
| `functions_tracking.php` | Visit-source analytics — `platform_track_visit()`, `platform_track_conversion()`, the report helpers. Records where a visitor came from (URL/campaign/referrer), on what device, and from which country. Came from fotografia; see [Visit tracking](#visit-tracking). |
|
||||
| `tracking.php` | Shared sysadmin page for the above — sources with conversion rate, conversions per form, and breakdowns by country, device, OS, browser and referrer. |
|
||||
| `bin/geo-build-index.php` | Compiles the all-countries IP→country index used by the analytics. Not versioned (~5 MB); regenerate from cron. |
|
||||
| `functions_geo.php` | Country gating — `geo_mode()`, `geo_block_reason()`, `geo_form_block_reason()`, `geo_guard_site()`, `geo_ip_in_country()`. Refuses public form POSTs (or the whole site) from outside an allow-list of countries. See [Country gating](#country-gating). |
|
||||
| `geo.php` | Shared sysadmin page — mode, country list, fail-closed toggle, data-file status, a "refresh lists" button and an IP probe. Loaded by each platform's `views/sysadmin/geo.php` stub. |
|
||||
| `bin/geo-update.sh` | Refreshes the per-country CIDR lists in `data/geo/` from ipdeny.com. Validates before overwriting, so a bad download keeps the previous file. |
|
||||
| `data/geo/<cc>.v4`, `<cc>.v6` | Committed CIDR allocations per country, read by `functions_geo.php`. Committed so a deploy never depends on ipdeny being reachable. |
|
||||
| `functions_password_reset.php` | Password recovery over the shared SMTP transport — `password_reset_request/validate/consume/prune()`. Stores only the SHA-256 of the token. |
|
||||
| `compat_secrets.php` | Re-exports the short names (`secret_encrypt`, `secret_decrypt`, `secret_is_encrypted`, `secret_mask`, `secrets_available` / `secrets_key_available`, `SECRET_PREFIX`) that existing site call sites use. A site includes this instead of writing its own cipher. |
|
||||
| `bin/encrypt_secrets.php` | CLI — encrypts any secret-valued row still stored in plaintext. Idempotent, safe to call from `deploy.sh`, refuses to run unless connected to the schema `DB_NAME` names. |
|
||||
| `api/backup_trigger.php` | Self-bootstrapping POST handler — auth + token + curl-fire-and-forget to the worker. Per-platform stub at `<host>/api/backup_trigger.php` is a 1-line require. |
|
||||
| `api/backup_download.php` | Self-bootstrapping download handler — auth + filename validation + `readfile()`. Per-platform stub same shape. |
|
||||
| `backups/config.php` | Loads host's `.env`, defines `BACKUP_TOKEN`, `BACKUP_KEEP_DAYS`, `BACKUP_FILES_DIR`, DB constants, `cleanOldBackups()`. |
|
||||
| `backups/backup_maker.php` | Files ZIP worker — archives ONLY `<host>/uploads/` (the one folder with external data; code is reconstructible from git). Source folder overridable via `BACKUP_FILES_DIR` in `.env`. Writes to `<host>/backups/<file>.zip` and rotates old ZIPs. |
|
||||
| `backups/db_backup.php` | DB dump worker. Writes to `<host>/backups/db_backup_<date>.zip`, **AES-256 encrypted** when `APP_SECRET_KEY` is set. See [Backup encryption](#backup-encryption). |
|
||||
| `functions_agent.php` | Remote-ops agent helpers — `agent_verify_or_die($secret)` (HMAC over method+path+sha256(body)+date, ±5 min), `agent_status(PDO, $repo_root)` (git/migrations/schema-drift/last-backup/php/disk, all `function_exists()`-guarded), `agent_json()`. |
|
||||
| `api/agent.php` | Self-bootstrapping remote-ops endpoint for the **id.andmore.pt fleet control plane**. Verifies the HMAC, dispatches an action allowlist (Fase 0: `status`). Per-platform stub at `<host>/api/agent.php` is a 1-line require. |
|
||||
| `functions_auto_update.php` | Deploy-on-push — `auto_update_run(PDO, $repo_root, ?$ref)` (optional pre-update backup + pull + pending migrations + cache invalidation, behind a per-repo lock), `platform_pre_update_backup()` (triggers and *waits for* the configured backups; a failure aborts the update), `platform_respond_and_continue()`, `auto_update_verify()` (HMAC-SHA256 of the raw body, accepts `X-Gitea-Signature` and `X-Hub-Signature-256`), the on/off settings, and the recorded last-run state. Also `platform_status_summary()` / `platform_status_strip()` — the git-free, one-line platform state a host dashboard can render. See [Auto-update](#auto-update). |
|
||||
| `api/auto_update.php` | Self-bootstrapping deploy webhook. Point a Gitea push webhook at `<host>/api/auto_update.php`; per-platform stub is a 1-line require. Secret in `.env` as `AUTO_UPDATE_SECRET`. Replies `202` immediately and updates with the connection closed — Gitea's delivery timeout is 5 s. |
|
||||
| `functions_hub.php` | The hub client — API/ops keys (generated here, stored AES-encrypted, `.env` wins), the health/ops/pair endpoint auth, the pairing announce, and `hub_ticket_create()` / `hub_ticket_ready()`, which post to the hub's `/api/tickets.php`. Portable across stacks: every host helper is reached through `hub_env()`/`hub_setting()`/`hub_base_url()`. |
|
||||
| `hub_integration.php` | Shared sysadmin page for the above — hub URL, key rotation, pairing state. Loaded by each platform's `views/sysadmin/hub_integration.php` stub. |
|
||||
| `functions_feedback.php` | Feedback from the people who run the platform — reads the account from any of the fleet's three session shapes (`current_user()`, `$_SESSION['user']`, or loose `user_id`/`username`/`role` keys), the ticket categories, the context block (page, version, commit, account, browser) and `platform_feedback_submit()`. See [Feedback bubble](#feedback-bubble-fleet--hub-tickets). |
|
||||
| `feedback_widget.php` | The bubble itself, bottom-right. Included from the host's backoffice footer; renders nothing for anyone who isn't an admin. Zero dependencies — no Bootstrap, no icon font, no jQuery. |
|
||||
| `feedback_footer_link.php` | The same widget as a footer link instead of a floating bubble — for the public site, where the bottom-right corner is already taken (WhatsApp). A file rather than a flag so the host include stays one bare line either way. |
|
||||
| `api/feedback.php` | The JSON endpoint the bubble posts to. Cookie-authenticated (not key-authenticated, unlike the rest of `api/`), so it needs the host's session — which it gets from `hub_bootstrap.php`, same as the others; the per-platform stub is a 1-line require. Every POST carries the `platform_csrf_ok()` token. |
|
||||
|
||||
## Auto-update
|
||||
|
||||
A push becomes a deploy. Gitea POSTs the push payload to
|
||||
`<host>/api/auto_update.php`; the platform verifies the signature, pulls itself,
|
||||
applies the migrations that arrived in the same commit, and invalidates the
|
||||
OPcache and the settings caches. It is the "Atualizar plataforma" button with
|
||||
nobody standing in front of it.
|
||||
|
||||
### Wiring it up
|
||||
|
||||
1. `AUTO_UPDATE_SECRET=<random>` in the host's `.env`. Generate one with
|
||||
`openssl rand -hex 32`. It lives in `.env`, not `system_settings`, for the
|
||||
same reason `GIT_HTTP_TOKEN` does: you need it in order to *repair* a site,
|
||||
so it must not depend on the database being readable.
|
||||
2. Create the stub `<host>/api/auto_update.php`:
|
||||
`<?php require __DIR__ . '/../shared/platform-tools/api/auto_update.php';`
|
||||
3. Gitea → repository → Settings → Webhooks → **Gitea**:
|
||||
* Target URL: `https://<host>/api/auto_update.php`
|
||||
* HTTP method `POST`, content type `application/json`
|
||||
* Secret: the same value as `AUTO_UPDATE_SECRET`
|
||||
* Trigger: **Push events**
|
||||
4. Turn it on in the backoffice: *Atualizar plataforma* → **Auto-update** →
|
||||
*Ligar*. Off by default — pulling code unattended is a decision, not a
|
||||
default. "Correr agora" on that card runs the identical path by hand, which
|
||||
is how you test the setup without pushing.
|
||||
|
||||
### What it will and will not do
|
||||
|
||||
It **fast-forwards only**. `git_safe_update()` tolerates mode-bit and submodule
|
||||
drift, but real local commits or uncommitted edits stop the run and are reported
|
||||
untouched — an unattended `reset --hard` is how you lose the hotfix somebody
|
||||
applied on the server at 2 a.m. Force-syncing stays a deliberate, confirmed
|
||||
click on the platform page.
|
||||
|
||||
It **ignores pushes to other branches**: the webhook fires for every branch, and
|
||||
the payload's `ref` is compared to the branch this server has checked out.
|
||||
|
||||
It **runs one at a time**. A push burst delivers several webhooks for what is
|
||||
really one state; a per-repo lock file makes the extras no-ops (HTTP 200,
|
||||
`status: busy`).
|
||||
|
||||
Migrations on deploy are a second switch (`auto_update_migrations`, on by
|
||||
default). Leaving it on is usually right — code and the schema it needs travel
|
||||
in the same commit, so applying one without the other leaves a window where the
|
||||
site is broken. Turn it off when a release needs a human to run the SQL.
|
||||
|
||||
It **answers before it works**. Gitea allows a delivery 5 seconds by default,
|
||||
which a git pull alone exceeds, so the endpoint replies `202 accepted` and does
|
||||
the work with the connection already closed (`fastcgi_finish_request()`, or
|
||||
`Content-Length` + `Connection: close` on mod_php). The response body therefore
|
||||
carries no outcome — that is what `auto_update_last_*` and the UI are for.
|
||||
|
||||
### Backup before updating
|
||||
|
||||
Two more switches, both **off** by default, in the *Backup antes de atualizar*
|
||||
card: `update_backup_db` and `update_backup_files`. They apply to **both** ways
|
||||
of updating — the *Atualizar* button and the webhook.
|
||||
|
||||
The backup runs before anything moves, and the update **waits for it**. That
|
||||
wait is the whole point: `backup_trigger()` is fire-and-forget, so pulling
|
||||
straight after it would race the dump and a migration could apply halfway
|
||||
through — producing a "restore point" that is 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 genuine completion signal.
|
||||
|
||||
**A backup that fails or times out aborts the update** (900 s cap, nothing
|
||||
touched, reported). A toggle that promises a restore point and then quietly
|
||||
proceeds without one is worse than no toggle at all.
|
||||
|
||||
Which to turn on:
|
||||
|
||||
| | Worth it? |
|
||||
|---|---|
|
||||
| `update_backup_db` | Usually yes. It is what lets you undo a migration that went wrong, and a dump is seconds on these sites. |
|
||||
| `update_backup_files` | Usually no. Git never touches `uploads/`, so it is rarely what is missing after a deploy — and on a site with galleries it adds minutes and gigabytes to *every* update. |
|
||||
|
||||
The card shows the size and date of the last backup of each type, so the cost of
|
||||
turning one on is visible before you turn it on rather than after the first push.
|
||||
|
||||
### Reading the result
|
||||
|
||||
Every run — applied, skipped or refused — writes `auto_update_last_*` settings.
|
||||
The card on *Atualizar plataforma* shows the outcome and the full output; the
|
||||
one-line strip from `platform_status_strip()` surfaces the same state on a host
|
||||
dashboard. Response codes are meaningful in Gitea's delivery list: `200` updated
|
||||
or skipped, `202` disabled, `401` bad signature, `409` update refused, `503` no
|
||||
secret configured.
|
||||
|
||||
## Reverting an update
|
||||
|
||||
Every update that actually moves `HEAD` — button or webhook — records where it
|
||||
came from. The *Ponto de restauro* card then offers one click back to that
|
||||
commit, which is what makes "update, test, undo if it went wrong" a safe loop
|
||||
rather than a gamble.
|
||||
|
||||
It is a `reset --hard` to the recorded commit, **not** a `git 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 reproduces exactly the tree
|
||||
that was running before. A `revert` would invent new commits that then have to
|
||||
be pushed, leaving this server diverged from every other one. Afterwards the
|
||||
platform is simply "behind" again, and *Atualizar* fast-forwards it right back.
|
||||
|
||||
Guard rails:
|
||||
|
||||
* The target SHA is read from our own settings row and re-validated as a real
|
||||
commit in this repository. A forged POST cannot aim it anywhere.
|
||||
* It refuses while there are uncommitted content changes, unless *Descartar
|
||||
alterações locais* is ticked — `reset --hard` would destroy them, and whoever
|
||||
is debugging a bad deploy is exactly the person likely to have edited a file
|
||||
on the server.
|
||||
* Submodules are realigned to the restored gitlink; OPcache and the settings
|
||||
caches are dropped.
|
||||
* The restore point is consumed on use. The next update records a fresh one.
|
||||
|
||||
**The database does not come back with the code.** Migrations applied 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 immediately before, and both the card and the output say
|
||||
so — with the backup filename to restore from, when `update_backup_db` was on.
|
||||
That pairing is the reason to leave the DB backup toggle on.
|
||||
|
||||
## Settings and secrets
|
||||
|
||||
Configuration resolves through three layers. In precedence order, highest first:
|
||||
|
||||
1. the `system_settings` row, if one exists;
|
||||
2. a `define()`d constant, where the site defines one;
|
||||
3. `.env`.
|
||||
|
||||
`platform_settings_get()` returns its default — which is where `.env` enters —
|
||||
only when the row is **absent or NULL**. An empty string is neither, so a blank
|
||||
row is a real value that outranks everything below it. Delete the row to fall
|
||||
back; do not blank it.
|
||||
|
||||
### Which layer a setting belongs in
|
||||
|
||||
The rule is: *can the system read this after a cold start with an empty
|
||||
database, and would I still need it if the app were broken?*
|
||||
|
||||
| Answer | Home | Examples |
|
||||
|---|---|---|
|
||||
| Yes — bootstrap or break-glass | `.env` only | `DB_*` (needed to reach the DB), `APP_SECRET_KEY` (decrypts the table), `BACKUP_TOKEN`, `GIT_HTTP_*`, `HUB_OPS_SECRET` |
|
||||
| No — runtime configuration | `system_settings` | `smtp_*`, `email_from*`, feature flags, limits, retention, integration keys |
|
||||
|
||||
Runtime configuration belongs in the database because an operator has to be able
|
||||
to change it without shell access. Bootstrap and recovery credentials belong in
|
||||
`.env` because a credential you need in order to *repair* a site must not depend
|
||||
on the site being healthy.
|
||||
|
||||
`APP_SECRET_KEY` is `.env`-only for a stronger reason: it is the key that
|
||||
decrypts `system_settings`, so storing it there would be circular.
|
||||
|
||||
### Secrets at rest
|
||||
|
||||
Secret-valued settings are encrypted with AES-256-GCM, stored as
|
||||
`enc:v1:<base64(iv|tag|ciphertext)>`. The key is derived
|
||||
`hash('sha256', 'am-secret|' . APP_SECRET_KEY)`, falling back to `BACKUP_TOKEN`
|
||||
only when no `APP_SECRET_KEY` is set.
|
||||
|
||||
The point is the backups: the nightly job keeps many copies of the database, so
|
||||
a plaintext password would sit in every one of them. The key lives only in
|
||||
`.env` — never in the database, never in git — so a leaked dump on its own is
|
||||
not enough to recover the credentials.
|
||||
|
||||
**Which keys are secret is decided by `platform_settings_is_secret()`, not by
|
||||
the caller.** Any key ending in `_password`, `_secret`, `_token`, `_api_key` or
|
||||
`_private_key` qualifies, plus an explicit list for names that do not follow the
|
||||
convention. This used to be a per-site array — fotografia had
|
||||
`['smtp_password','twilio_token']` hardcoded in `comms.php`, which is how
|
||||
`ig_app_secret` came to be stored in the clear. A caller can no longer forget.
|
||||
|
||||
`platform_settings_set()` therefore encrypts on its own judgement. Pass `true`
|
||||
or `false` explicitly only to override it. It also:
|
||||
|
||||
- refuses to double-wrap a value already carrying the `enc:v1:` prefix;
|
||||
- stores `''` as `''`, since callers use empty to mean *cleared*, not *encrypt
|
||||
the empty string*;
|
||||
- logs loudly rather than quietly writing a secret in plaintext when no key is
|
||||
available.
|
||||
|
||||
`platform_settings_all()` masks secrets unless asked not to. Passwords render as
|
||||
`•••••••• (definida)` with no prefix at all — eight leading characters in a
|
||||
screenshot is a real leak — while API keys keep a recognisable head and tail.
|
||||
|
||||
### One implementation, deliberately
|
||||
|
||||
Three sites had each grown their own copy of this cipher with three different
|
||||
key derivations: dance used the domain `'dsa-secret|'`, fotografia
|
||||
`'am-secret|'`, hub2 a plain `sha256` with no domain separation. All three wrote
|
||||
the same `enc:v1:` prefix, so the values looked interchangeable while being
|
||||
mutually unreadable, and nothing surfaced the difference until something tried
|
||||
to read across sites.
|
||||
|
||||
There is now one implementation, here. A site that wants the short names
|
||||
includes `compat_secrets.php`; it must not define its own.
|
||||
|
||||
Note that each site keeps its **own** `APP_SECRET_KEY`. A secret encrypted on
|
||||
one site deliberately does not decrypt on another — a leaked dump from one must
|
||||
not unlock the rest.
|
||||
|
||||
## Authentication
|
||||
|
||||
`functions_auth.php` holds the mechanics; the site keeps the policy. Wire it in
|
||||
the bootstrap, after the PDO is open:
|
||||
|
||||
```php
|
||||
require_once __DIR__ . '/../shared/platform-tools/functions_auth.php';
|
||||
auth_schema(['password' => 'password', 'name' => 'full_name',
|
||||
'active' => null, 'deleted_at' => 'deleted_at']); // surf's columns
|
||||
auth_roles_register(['teacher' => 40, 'student' => 20, 'parent' => 20]);
|
||||
auth_session_start(['name' => 'dsa_session']);
|
||||
```
|
||||
|
||||
### The schema map
|
||||
|
||||
`auth_schema()` exists so the shared code can run against tables that already
|
||||
differ — surf stores the hash in `password` and the name in `full_name`, the
|
||||
others use `password_hash` and `name`; three sites deactivate with `is_active`
|
||||
and surf with `deleted_at`. Renaming a column with 147 references in the site is
|
||||
a migration; adapting is a function call. A site whose columns already match the
|
||||
defaults need not call it at all.
|
||||
|
||||
| key | default | meaning |
|
||||
|---|---|---|
|
||||
| `table` | `users` | |
|
||||
| `id` | `id` | |
|
||||
| `email` | `email` | where password-recovery mail is sent |
|
||||
| `login` | *null* | column the login form matches on; null means "use `email`" |
|
||||
| `password` | `password_hash` | |
|
||||
| `name` | `name` | |
|
||||
| `role` | `role` | |
|
||||
| `active` | `is_active` | null when the site has no active flag |
|
||||
| `deleted_at` | *null* | set when the site soft-deletes instead |
|
||||
| `last_login` / `last_ip` | `last_login_at` / `last_login_ip` | null to skip the stamp |
|
||||
|
||||
`login` and `email` are separate keys because they are separate questions.
|
||||
quack signs in with `username` and still mails recovery links to `email`;
|
||||
folding them into one key would either break its login or send its reset links
|
||||
to a username. Everywhere else `login` stays null and `auth_attempt()` falls
|
||||
back to `email`, which is the behaviour those sites already had.
|
||||
|
||||
### Roles
|
||||
|
||||
Three roles are core and mean the same everywhere: `sysadmin` (100), `admin`
|
||||
(80), `user` (10). A site registers its own on top with a rank, and they sit in
|
||||
the same ordering:
|
||||
|
||||
| site | registers |
|
||||
|---|---|
|
||||
| dance | `teacher` 40, `student` 20, `parent` 20 |
|
||||
| design | `client` 10 |
|
||||
| quack | `formador` 40, `aluno` 20 |
|
||||
| silver | `student` 20 |
|
||||
|
||||
`auth_at_least('admin')` is then expressible without hardcoding a list, and
|
||||
`auth_is_any(['teacher','parent'])` covers roles that are peers rather than
|
||||
ranked. An unregistered role ranks **0** — below everything — so a typo in a
|
||||
role name cannot grant access.
|
||||
|
||||
### What it fixes, and why it is worth knowing
|
||||
|
||||
Every item below was a real gap found when the separate implementations were
|
||||
compared, and each existed because the sites solved the same problem apart:
|
||||
|
||||
- **Sessions.** dance ran a bare `session_start()` — no HttpOnly, no SameSite,
|
||||
no Secure, no `use_strict_mode` — and never called `session_regenerate_id()`,
|
||||
so a session fixed before login stayed valid after it.
|
||||
- **Password hashes never improved.** `password_needs_rehash()` appeared twice
|
||||
in the entire estate. `auth_attempt()` rehashes on login, the only moment the
|
||||
plaintext exists. dance also pinned `PASSWORD_BCRYPT` while the others used
|
||||
`PASSWORD_DEFAULT`, so the day DEFAULT moves, three sites migrate and one
|
||||
silently does not.
|
||||
- **Throttling.** dance had none. The shared one keys on identifier **and** IP:
|
||||
on the identifier alone an attacker locks a real user out of their own
|
||||
account; on IP alone a NAT'd office locks itself out. `X-Forwarded-For` is
|
||||
honoured only when `TRUSTED_PROXY` says so — every site sits behind NPM, so
|
||||
`REMOTE_ADDR` is always the proxy, and trusting the header unconditionally
|
||||
would let a caller forge their throttle identity. That rule now lives in
|
||||
`functions_client_ip.php` as `platform_client_ip()`; `auth_client_ip()` is an
|
||||
alias. It moved out because the antispam in fotografia and mstudio needed the
|
||||
same calculation and could not include this module — both declare their own
|
||||
`auth_login()`, so the include died on "Cannot redeclare function".
|
||||
- **Account enumeration.** `auth_attempt()` spends comparable time when the
|
||||
account does not exist, so response timing does not reveal which addresses are
|
||||
registered.
|
||||
- **Open redirect.** `auth_intended_url()` accepts only a local absolute path,
|
||||
so the login page cannot be used as a phishing hop.
|
||||
|
||||
Two tables are created on demand and declared in each site's migrations:
|
||||
`auth_attempts` and `password_resets`. The throttle tolerates the pre-existing
|
||||
`auth_attempts` on surf and fotografia, which carry a `success` column and log
|
||||
successful logins too — those rows are excluded from the count, or signing in
|
||||
successfully would move you toward being locked out.
|
||||
|
||||
### Password recovery
|
||||
|
||||
`password_reset_request()` delivers through `platform_mail_send()` with
|
||||
`platform_smtp_config()`, so recovery works wherever the platform can already
|
||||
send mail. Only the SHA-256 of the token is stored — a leaked dump cannot reset
|
||||
anyone's password. Consuming a token burns every outstanding token for that
|
||||
user, inside a transaction, and regenerates the session.
|
||||
|
||||
The public result is deliberately identical for a known address, an unknown one,
|
||||
and a send failure. A transport error is reported separately in
|
||||
`delivery_error`, for an operator only: folding it into `ok` would let the form
|
||||
enumerate accounts whenever SMTP is down.
|
||||
|
||||
## Bootstrap contract — what the shared file expects
|
||||
|
||||
`platform_update.php` is a **view fragment** that expects the calling stub to have already:
|
||||
|
||||
1. Bootstrapped the host's DB so a global `$pdo` PDO is in scope.
|
||||
2. Run the sysadmin auth guard (so the request only reaches this point if authorized).
|
||||
3. Set `$repo_root` to the absolute path of the host repo (the directory containing `.git`).
|
||||
|
||||
Optionally, the stub may set any of these BEFORE the `require`:
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `$platform_layout_header` | `$repo_root/includes/header.php` | Top-of-page chrome |
|
||||
| `$platform_layout_navbar` | `$repo_root/includes/navbar.php` | Nav |
|
||||
| `$platform_layout_footer` | `$repo_root/includes/footer.php` | Footer |
|
||||
| `$platform_dashboard_url` | `base_url('views/admin/dashboard.php')` if defined | "Voltar" button target |
|
||||
| `$platform_error_log_file` | `ERROR_LOG_FILE` constant if defined, else card hidden | JSONL PHP error log path |
|
||||
| `$GLOBALS['MIGRATIONS_DIR']` | `$repo_root/database` | Migrations folder |
|
||||
|
||||
The view also picks up these host helpers if they exist (via `function_exists`):
|
||||
- `settings_cache_clear()` — invalidated after pull and after applying migrations
|
||||
- `base_url($path)` — used for dashboard link default
|
||||
- `env($key)` — used by `functions_git.php` for HTTPS credentials (falls back to `getenv()`)
|
||||
|
||||
## Wiring up a platform (standard `config/`-based stack)
|
||||
|
||||
This applies to **dsa**, **fotografia**, **moremio**, **tickets** — they share the same convention (`config/db.php` opens `$pdo`, `config/functions.php` loads helpers + `require_sysadmin()`).
|
||||
|
||||
**1. Add as a submodule** (run inside the platform repo):
|
||||
|
||||
```bash
|
||||
git submodule add ssh://git@<gitea-host>/<org>/platform-tools.git shared/platform-tools
|
||||
git commit -m "Add platform-tools submodule"
|
||||
git push
|
||||
```
|
||||
|
||||
**2. Replace [views/sysadmin/platform_update.php](views/sysadmin/platform_update.php):**
|
||||
|
||||
```php
|
||||
<?php
|
||||
$repo_root = dirname(__DIR__, 2);
|
||||
require_once $repo_root . '/config/db.php';
|
||||
require_once $repo_root . '/config/functions.php';
|
||||
require_sysadmin();
|
||||
require $repo_root . '/shared/platform-tools/platform_update.php';
|
||||
```
|
||||
|
||||
**3. Replace [config/functions_git.php](config/functions_git.php)** (create if missing):
|
||||
|
||||
```php
|
||||
<?php
|
||||
require_once __DIR__ . '/../shared/platform-tools/functions_git.php';
|
||||
```
|
||||
|
||||
**4. Replace [config/functions_migrations.php](config/functions_migrations.php):**
|
||||
|
||||
```php
|
||||
<?php
|
||||
require_once __DIR__ . '/../shared/platform-tools/functions_migrations.php';
|
||||
```
|
||||
|
||||
These two `config/` stubs keep the existing `config/functions.php` loader working unchanged — it does `require_once 'functions_migrations.php'` and just gets the submodule version transparently.
|
||||
|
||||
**5. On every prod host, after pulling:**
|
||||
|
||||
```bash
|
||||
git pull
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
> Since the "Atualizar (git pull)" button in `platform_update.php` now runs
|
||||
> `git submodule update --init --recursive` itself after the pull, the second
|
||||
> command is only needed for the **very first** checkout of a host whose
|
||||
> `platform_update.php` page can't load yet (because the submodule it lives in
|
||||
> isn't populated). Once the page loads once, the UI "Atualizar" keeps the
|
||||
> submodule in sync — including pulling in a submodule freshly added upstream.
|
||||
|
||||
**6. To bump to a newer shared version** (run on dev, push, then `git pull` on prod):
|
||||
|
||||
```bash
|
||||
git submodule update --remote shared/platform-tools
|
||||
git add shared/platform-tools
|
||||
git commit -m "Bump platform-tools"
|
||||
git push
|
||||
```
|
||||
|
||||
## Wiring up the sysadmin hub (`views/sysadmin/index.php`)
|
||||
|
||||
The hub is the single entry point — point the platform navbar at `views/sysadmin/index.php` and every other sysadmin tool is reachable from there. Cards link to the platform's own stubs (`base_url('views/sysadmin/platform_update.php')`, etc.), so the URLs stay stable even if you move the submodule.
|
||||
|
||||
**1. Create [views/sysadmin/index.php](views/sysadmin/index.php):**
|
||||
|
||||
```php
|
||||
<?php
|
||||
$repo_root = dirname(__DIR__, 2);
|
||||
require_once $repo_root . '/config/db.php';
|
||||
require_once $repo_root . '/config/functions.php';
|
||||
require_sysadmin();
|
||||
require $repo_root . '/shared/platform-tools/index.php';
|
||||
```
|
||||
|
||||
**2. Point the navbar at the hub.** In `includes/navbar.php` (or wherever the sysadmin link lives), change the entry that currently points to `platform_update.php` (and any other individual tools) so a single "Sistema" link goes to `views/sysadmin/index.php`. Example pattern:
|
||||
|
||||
```php
|
||||
<a href="<?= base_url('views/sysadmin/index.php') ?>">Sistema</a>
|
||||
```
|
||||
|
||||
**3. Adding more tools later** — set `$sysadmin_tools` in the stub before the `require`:
|
||||
|
||||
```php
|
||||
$sysadmin_tools = [
|
||||
[
|
||||
'title' => 'Mensagens',
|
||||
'description' => 'Fila de email/SMS, retries, cancelamentos.',
|
||||
'icon' => 'bi-envelope-paper',
|
||||
'url' => base_url('views/sysadmin/messages.php'),
|
||||
'color' => 'info',
|
||||
'badge' => '3 falhadas', // optional
|
||||
],
|
||||
];
|
||||
require $repo_root . '/shared/platform-tools/index.php';
|
||||
```
|
||||
|
||||
The entry is appended to the default two cards (Atualizar plataforma + Backups).
|
||||
|
||||
## Wiring up the backups feature (standard `config/`-based stack)
|
||||
|
||||
The backups feature has a wider surface than `platform_update.php` — it needs a view, helpers, two API endpoints, and three worker scripts that run in separate HTTP requests (curl-triggered, token-protected). The submodule contains all of these; the platform just needs 4 thin stubs plus a writable `<host>/backups/` directory.
|
||||
|
||||
**Prerequisites:**
|
||||
- `<host>/.env` defines `BACKUP_TOKEN=<random_string>` (used to authenticate the worker URLs). Optionally `BACKUP_KEEP_DAYS=7` and `BACKUP_FILES_DIR=uploads` (folder the files backup archives, relative to the host root — only uploads hold external data; everything else is reconstructible from git).
|
||||
- `<host>/backups/` exists and is writable by PHP-FPM. The workers will `mkdir` it if missing but the parent must be writable.
|
||||
- Add `backups/*.zip` to `.gitignore` (the workers write ZIP files into it).
|
||||
- `<host>/config/env.php` defines `env_load()` + `env()` (already present on dsa, fotografia, moremio, tickets).
|
||||
|
||||
> **Under a `public/` docroot, the two worker wrappers are mandatory.** The
|
||||
> workers live in `shared/platform-tools/backups/`, which is *not* reachable
|
||||
> over HTTP once the web root is `public/`. Backups are triggered by the site
|
||||
> calling itself, so it needs an entry point that exists: set
|
||||
> `BACKUP_TRIGGER_URL_PREFIX=api/` in `.env` and create
|
||||
> `public/api/db_backup.php` and `public/api/backup_maker.php` as one-line
|
||||
> requires of the workers.
|
||||
>
|
||||
> Both halves are required, and getting one right is not enough. dance had the
|
||||
> `.env` line and no wrappers, so the self-call fetched the site's own 404 page
|
||||
> — which arrives with `curl_errno = 0` and therefore looks like a successful
|
||||
> request. It produced no backups at all for as long as it ran.
|
||||
|
||||
**1. Replace [views/sysadmin/backups.php](views/sysadmin/backups.php):**
|
||||
|
||||
```php
|
||||
<?php
|
||||
$repo_root = dirname(__DIR__, 2);
|
||||
require_once $repo_root . '/config/db.php';
|
||||
require_once $repo_root . '/config/functions.php';
|
||||
require_once $repo_root . '/config/functions_backup.php';
|
||||
require_sysadmin();
|
||||
require $repo_root . '/shared/platform-tools/backups.php';
|
||||
```
|
||||
|
||||
**2. Replace [config/functions_backup.php](config/functions_backup.php)** (create if missing):
|
||||
|
||||
```php
|
||||
<?php
|
||||
require_once __DIR__ . '/../shared/platform-tools/functions_backup.php';
|
||||
```
|
||||
|
||||
**3. Replace [api/backup_trigger.php](api/backup_trigger.php)** (create if missing):
|
||||
|
||||
```php
|
||||
<?php
|
||||
require __DIR__ . '/../shared/platform-tools/api/backup_trigger.php';
|
||||
```
|
||||
|
||||
**4. Replace [api/backup_download.php](api/backup_download.php)** (create if missing):
|
||||
|
||||
```php
|
||||
<?php
|
||||
require __DIR__ . '/../shared/platform-tools/api/backup_download.php';
|
||||
```
|
||||
|
||||
That's it. The workers live entirely in the submodule at `<host>/shared/platform-tools/backups/`. They're reached via HTTP at `base_url('shared/platform-tools/backups/backup_maker.php?token=…')` — the URL prefix is hardcoded in `functions_backup.php` but can be overridden by defining `BACKUP_TRIGGER_URL_PREFIX` before the file loads.
|
||||
|
||||
**.htaccess hardening (automatic)**: the ZIPs contain the full host tree including `.env`, so `<host>/backups/` must never be directly web-readable. `backup_dir()` and both workers write a deny-all `<host>/backups/.htaccess` automatically on first use (Apache 2.2 and 2.4 syntax). On nginx, add the equivalent `location ^~ /backups/ { deny all; }` yourself. Backup filenames also carry seconds + a random suffix (`backup_DD_MM_YYYY_HH_MM_SS_xxxxxx.zip`), so they can't be guessed even if the deny rule is missing; ZIPs are served exclusively through `api/backup_download.php`, which sets an attachment header. Files backups exclude `.git/`, `node_modules/`, `backups/` and `*.zip`.
|
||||
|
||||
## Wiring up the remote-ops agent (hub fleet control plane)
|
||||
|
||||
Lets the hub read this platform's status and (later phases) pull git, apply
|
||||
migrations and take backups **remotely**, from one dashboard. Server-to-server,
|
||||
HMAC-authenticated with a **dedicated** secret (never the content/notify ones).
|
||||
|
||||
> **Naming.** The control plane used to be `id.andmore.pt`. That service is gone
|
||||
> — its SSO and notification relay were removed from every site — and **hub2 is
|
||||
> the plane that connects to the apps now**. The endpoint, the HMAC scheme and
|
||||
> `HUB_OPS_SECRET` are unchanged; only the operator moved. Older comments in
|
||||
> this file and in the sites may still say "id".
|
||||
|
||||
**1. Submodule:** same as above (already present if backups/platform_update are wired).
|
||||
|
||||
**2. Create [api/agent.php](api/agent.php)** (1-line stub):
|
||||
|
||||
```php
|
||||
<?php
|
||||
require __DIR__ . '/../shared/platform-tools/api/agent.php';
|
||||
```
|
||||
|
||||
**3. Add the secret to `<host>/.env`:**
|
||||
|
||||
```
|
||||
HUB_OPS_SECRET=
|
||||
```
|
||||
|
||||
Generate the value on `id.andmore.pt` → **Plataformas → <app> → "Gerar segredo de
|
||||
operações"**, paste it here, then flip **"Ativar operações remotas"** on the same
|
||||
screen. Empty secret → the agent answers `503` (disabled).
|
||||
|
||||
The agent derives nothing from config beyond `$repo_root = dirname(__DIR__, 3)`;
|
||||
it reuses the same `functions_git/migrations/schema/backup` already in the
|
||||
submodule, so a host wired for `platform_update.php` + backups needs only the
|
||||
stub + the `.env` line. The hub reaches it at `<base_url>/api/agent.php` (URL
|
||||
convention; no DB column).
|
||||
|
||||
## Feedback bubble (fleet → hub tickets)
|
||||
|
||||
The people who run these platforms every day are the first to notice what's
|
||||
broken, and until now they had nowhere to say it without leaving their work,
|
||||
opening another domain and signing in again. Most of that never got reported;
|
||||
what did arrive came as a loose message with no page, no version and no account
|
||||
attached.
|
||||
|
||||
The bubble sits bottom-right on every backoffice page. An admin writes a line
|
||||
and a description; the platform attaches the page URL, the version and commit,
|
||||
the account and the browser, and posts it to the hub's ticket desk through
|
||||
`hub_ticket_create()`. It reaches the hub already tagged with the right app,
|
||||
because the key that authenticates the call *is* the app's identity.
|
||||
|
||||
**Prerequisite:** the platform must be paired with the hub (`HUB_URL` +
|
||||
`hub_api_key`, from *Integração com o hub*). Unpaired, the bubble renders for
|
||||
sysadmins only, saying exactly that — a report channel that vanishes silently
|
||||
when a key rotates loses precisely the reports that mattered.
|
||||
|
||||
**1. Endpoint stub** — `<host>/public/api/feedback.php` (or `<host>/api/` where
|
||||
the docroot is the repo root, as on quack). One line, like every other endpoint
|
||||
in `api/`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
require __DIR__ . '/../../shared/platform-tools/api/feedback.php';
|
||||
```
|
||||
|
||||
The endpoint is cookie-authenticated rather than key-authenticated, so it needs
|
||||
the host's session — `hub_bootstrap.php` opens it, and knows both fleet stacks
|
||||
(`config/db.php`, or `src/config.php` + `App\Database`).
|
||||
|
||||
**2. The bubble** — one include at the end of the backoffice footer
|
||||
(`includes/footer.php`), just before `</body>`. It guards itself: not an admin,
|
||||
no HTML.
|
||||
|
||||
```php
|
||||
<?php include __DIR__ . '/../shared/platform-tools/feedback_widget.php'; ?>
|
||||
```
|
||||
|
||||
**3. The public site** — a footer link instead of a floating bubble, because
|
||||
the bottom-right corner there is already taken by the WhatsApp button. Include
|
||||
it **inside** the `<footer>`, on the line where the link belongs:
|
||||
|
||||
```php
|
||||
<?php include dirname(__DIR__, 2) . '/shared/platform-tools/feedback_footer_link.php'; ?>
|
||||
```
|
||||
|
||||
Same widget, same panel, same endpoint — only the trigger changes, and the
|
||||
link inherits the footer's colour and font so it fits a dark footer and a light
|
||||
one without either being styled for. The choice lives in the module, not in the
|
||||
host: both entry points are one bare include, and the day the decision changes
|
||||
it changes for the whole fleet at once.
|
||||
|
||||
Two footers in one response render one widget, not two (`$GLOBALS` one-shot
|
||||
flag). If the sysadmin hub page doesn't live at
|
||||
`/views/sysadmin/hub_integration.php` on this platform, set
|
||||
`$GLOBALS['platform_feedback_hub_url']` before the include — that's the only
|
||||
link the widget emits.
|
||||
|
||||
**Checks after wiring:** open any backoffice page as an admin → bubble bottom
|
||||
right; send one → green line with the ticket ref and a tracking link, and the
|
||||
ticket in the hub carries the context block. Open the same page signed out, or
|
||||
as a non-admin role → `view-source` has no `ptfb` markup at all. `GET
|
||||
/api/feedback.php` answers `{"ok":false,"error":"Usa POST."}` once deployed —
|
||||
a 404 there means the push never reached the server, and a 500 means the
|
||||
submodule didn't come with it.
|
||||
|
||||
**Session shapes.** Nothing needs configuring per site: the module reads the
|
||||
account from `current_user()`, from `$_SESSION['user']`, or from loose
|
||||
`user_id`/`username`/`role` keys, in that order. Only the third needs a
|
||||
database round-trip (for the e-mail, which the ticket desk requires and that
|
||||
shape doesn't carry) and it is cached in the session.
|
||||
|
||||
## Wiring up surf (different stack — `src/` + `Database::getInstance()`)
|
||||
|
||||
Surf uses `src/` instead of `config/`, has no `require_sysadmin()`, no `setting()`, no settings cache. The shared page handles this gracefully (all those integrations are guarded with `function_exists()`), but the stub needs to adapt.
|
||||
|
||||
**1. Submodule:** same as above (`git submodule add … shared/platform-tools`).
|
||||
|
||||
**2. Replace [views/sysadmin/platform_update.php](views/sysadmin/platform_update.php):**
|
||||
|
||||
```php
|
||||
<?php
|
||||
require_once __DIR__ . '/../../src/config.php'; // session + .env + autoload
|
||||
require_once __DIR__ . '/../../src/db.php';
|
||||
|
||||
// Auth guard — surf checks $_SESSION['user']['role']
|
||||
if (($_SESSION['user']['role'] ?? '') !== 'sysadmin') {
|
||||
header('Location: /views/auth/login.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
$pdo = App\Database::getConnection();
|
||||
|
||||
$repo_root = dirname(__DIR__, 2);
|
||||
$platform_layout_header = $repo_root . '/views/partials/head.php';
|
||||
$platform_layout_navbar = $repo_root . '/views/partials/navbar.php';
|
||||
$platform_layout_footer = $repo_root . '/views/partials/footer.php';
|
||||
$platform_dashboard_url = '/views/admin/dashboard.php';
|
||||
|
||||
require $repo_root . '/shared/platform-tools/platform_update.php';
|
||||
```
|
||||
|
||||
That's it. Surf has no `migration_*.sql` files (its schema is `database/install/ssc_school.sql`), so the migrations card will show empty. If surf later introduces migrations under `database/`, the card lights up automatically. To point at a non-default migrations directory, set `$GLOBALS['MIGRATIONS_DIR']` in the stub before the `require`.
|
||||
|
||||
The surf admin dashboard already links to `platform_update.php` from `views/admin/sysadmin.php` and `views/partials/navbar.php`, so no nav changes are needed.
|
||||
|
||||
## Country gating
|
||||
|
||||
Some platforms 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
|
||||
abroad is spam by definition rather than a lost sale. In August 2026 fourteen bot
|
||||
accounts were created there over five days, each one firing a confirmation email
|
||||
at a third party's mailbox — the content filters never had a chance, because the
|
||||
bots write plausible Portuguese names and use real addresses.
|
||||
|
||||
Two levers, because they carry very different risk:
|
||||
|
||||
| `GEO_MODE` | Effect |
|
||||
|---|---|
|
||||
| `off` | Module inert. The default when the variable is unset. |
|
||||
| `forms` | Public POSTs are refused from outside the allow-list. Reading the site stays open, so search engines, link previews and an emigrant checking the timetable are unaffected. **Start here.** |
|
||||
| `site` | The whole platform answers 403. Also hides the site from any crawler hosted abroad, which is rarely what anyone wants. |
|
||||
|
||||
```ini
|
||||
GEO_MODE=forms
|
||||
GEO_ALLOW_COUNTRIES=PT # ISO 3166-1 alpha-2, comma or space separated
|
||||
GEO_FAIL_CLOSED=false # see below
|
||||
```
|
||||
|
||||
Settings resolve **database first, then `.env`, then the default**, so the
|
||||
sysadmin page can change behaviour without recreating the container — the `.env`
|
||||
stays as the boot value, and a platform that never opens the page behaves exactly
|
||||
as before. An empty stored row counts as "unset" and falls through to `.env`;
|
||||
that is deliberate, because `platform_settings_get()` lets an empty row silently
|
||||
outrank a populated `.env`, and that has cost this estate real debugging time.
|
||||
|
||||
Wire the page up with a stub at `views/sysadmin/geo.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
$repo_root = dirname(__DIR__, 3);
|
||||
require_once $repo_root . '/config/db.php';
|
||||
require_once $repo_root . '/config/functions.php';
|
||||
require_sysadmin();
|
||||
require $repo_root . '/shared/platform-tools/geo.php';
|
||||
```
|
||||
|
||||
The page refuses to save `site` mode together with fail-closed while no lists are
|
||||
downloaded — that combination locks everyone out, including whoever is at the
|
||||
keyboard, and cannot be undone from a browser.
|
||||
|
||||
Wire a form up the same way you wire `antispam_check()` — it returns the reason,
|
||||
or null to carry on, and the caller decides what to do with it:
|
||||
|
||||
```php
|
||||
if (($geoReason = geo_form_block_reason()) !== null) {
|
||||
db_log_blocked_submission($pdo, 'register', $geoReason, 0, platform_client_ip() ?? '');
|
||||
$error = 'Não foi possível criar a conta a partir desta ligação.';
|
||||
}
|
||||
```
|
||||
|
||||
For `GEO_MODE=site`, call `geo_guard_site()` once from the bootstrap. It is a
|
||||
no-op under the other modes, so it is safe to leave wired in permanently and
|
||||
switch behaviour from the env alone.
|
||||
|
||||
**Failure posture is fail-open, deliberately.** A missing or corrupt data file
|
||||
logs loudly and lets the visitor through. Inverting that turns a stale file into
|
||||
a total outage, while the worst case of failing open is the spam that already
|
||||
gets through today. `GEO_FAIL_CLOSED=true` inverts it for a platform that would
|
||||
rather refuse.
|
||||
|
||||
Private, loopback and link-local addresses always pass — behind NPM the
|
||||
container talks to itself for cron and health checks, and a platform whose own
|
||||
cron is geo-blocked fails in ways that take a long time to understand.
|
||||
|
||||
### The data
|
||||
|
||||
Per-country CIDR lists from ipdeny.com, one file per family, committed to the
|
||||
repo. Refresh them with:
|
||||
|
||||
```bash
|
||||
shared/platform-tools/bin/geo-update.sh pt
|
||||
```
|
||||
|
||||
Allocations move slowly, so monthly is plenty:
|
||||
|
||||
```
|
||||
17 4 3 * * /srv/sites/<site>/shared/platform-tools/bin/geo-update.sh pt
|
||||
```
|
||||
|
||||
The script refuses to overwrite a good file with a short list, a non-CIDR body,
|
||||
or an HTML error page served with 200 — any of which would otherwise mean "no
|
||||
visitor is in Portugal", and with `GEO_FAIL_CLOSED` would lock the country out
|
||||
of its own site.
|
||||
|
||||
Lookup packs the address with `inet_pton()` and binary-searches sorted ranges,
|
||||
comparing the raw bytes. The same code path serves IPv4 and IPv6, and it steps
|
||||
around the signed-32-bit trap `ip2long()` sets for anything above
|
||||
`127.255.255.255`.
|
||||
|
||||
## Visit tracking
|
||||
|
||||
Answers "where did this person come from?" on three axes: **URL** (`?source=montra`,
|
||||
`utm_*`, or the referring domain), **device** (mobile/tablet/desktop, OS, browser,
|
||||
language) and **geography** (country, via the geo index). It grew on fotografia
|
||||
and moved here so the other platforms could use it.
|
||||
|
||||
Counting visits per source is the easy half and the least useful. What tells you
|
||||
whether a QR code is worth reprinting is how many *requests* it produced, so the
|
||||
first-touch source is remembered in a cookie for the attribution window and
|
||||
stamped on whatever the visitor eventually submits.
|
||||
|
||||
```php
|
||||
platform_track_visit($pdo); // once, on a public page
|
||||
platform_track_conversion($pdo, 'contacto'); // after a submission is accepted
|
||||
```
|
||||
|
||||
Conversions go to one `tracking_conversions` table keyed by form name, rather
|
||||
than a `source` column on each destination table. A site with contact, trial and
|
||||
registration forms would otherwise need three migrations and three places where
|
||||
attribution can be lost; this way it is one line per handler and the report is
|
||||
the same across the estate. Both tables are created on demand — no migration
|
||||
needed to adopt it.
|
||||
|
||||
Call `platform_track_conversion()` **after** the submission is accepted, never
|
||||
before. A request the antispam or the country gate refused is not a conversion,
|
||||
and counting it would make a campaign look better for attracting bots.
|
||||
|
||||
Wire the page up with a stub at `views/sysadmin/tracking.php`, same shape as the
|
||||
other shared pages.
|
||||
|
||||
### Country attribution
|
||||
|
||||
`geo_ip_country()` reads `data/geo/country-index.v4|.v6`, built by
|
||||
`bin/geo-build-index.php` from ipdeny's all-zones archives — 240 countries,
|
||||
~263k ranges, about 5 MB compiled.
|
||||
|
||||
That index is **not** versioned, unlike the small allow-lists the country gate
|
||||
uses. A binary that size refreshed monthly would bloat the history for nothing,
|
||||
and a missing analytics country is a fine degradation — the gate's data is a
|
||||
security decision, this is a statistic. Rebuild it from cron:
|
||||
|
||||
```
|
||||
41 5 3 * * docker exec <site> php /var/www/html/shared/platform-tools/bin/geo-build-index.php
|
||||
```
|
||||
|
||||
Two of ipdeny's zones are not countries and are excluded: `zz.zone` is a
|
||||
catch-all of whole `/8` blocks that overlap real countries — leaving it in made
|
||||
`8.8.8.8` resolve as "ZZ" instead of US — and `eu.zone` is a continent
|
||||
aggregate. Without them, 2 overlapping ranges remain out of 262k, and those are
|
||||
dropped at build time: binary search assumes disjoint ranges, and with overlaps
|
||||
the answer depends on where the bisection happened to land.
|
||||
|
||||
No IP address is ever stored. The `ip_hash` exists only to approximate unique
|
||||
visitors and is salted with a server-side secret. It is computed from
|
||||
`platform_client_ip()` — the original used `REMOTE_ADDR`, which behind NPM is
|
||||
always the proxy, so every visitor shared one hash and the report's "visitors"
|
||||
column was permanently 1.
|
||||
|
||||
## IP banning
|
||||
|
||||
The estate had three per-IP defences and none of them blocked: the login
|
||||
throttle forgets in fifteen minutes, the antispam counter resets on the hour,
|
||||
and the country gate judges origin rather than behaviour. An address that comes
|
||||
back every night at 3am looked like a new visitor to all three.
|
||||
|
||||
```php
|
||||
platform_ip_guard($pdo, 'info@example.pt'); // once, early in the bootstrap
|
||||
platform_ip_record_offence($pdo, 'antispam:contact'); // from each existing defence
|
||||
```
|
||||
|
||||
Offences accumulate; the ladder decides the sentence:
|
||||
|
||||
| Offences in 24h | Ban |
|
||||
|---|---|
|
||||
| 3 | 1 hour |
|
||||
| 5 | 24 hours |
|
||||
| 8 | 7 days |
|
||||
| 12 | permanent |
|
||||
|
||||
The climb is slow on purpose. An IP is often shared — a school, an office, a
|
||||
mobile carrier's CGNAT — and banning permanently on the third strike catches far
|
||||
more people than bots.
|
||||
|
||||
Two failure postures, opposite and deliberate. `platform_ip_banned()` fails
|
||||
**open**: with no database nobody gets locked out, because an infrastructure
|
||||
fault must not become an inaccessible site. `platform_mail_rate_blocked()` fails
|
||||
**closed**: the choice there is between not sending one message and burning the
|
||||
domain's sending reputation, and only the first recovers by itself.
|
||||
|
||||
Private, loopback and link-local addresses are exempt from both offences and
|
||||
bans — the container talks to itself for cron and health checks, and a platform
|
||||
that bans itself fails in ways that take a long time to understand.
|
||||
|
||||
Lifting a ban does **not** clear the offence history. If it did, the address
|
||||
would drop back to rung zero and the ladder would restart, which would make every
|
||||
administrator's mistake an invitation to try again.
|
||||
|
||||
## Backup encryption
|
||||
|
||||
A database dump sitting in the clear is the most sensitive file one of these
|
||||
platforms produces: the `users` table with its hashes, every contact, and on
|
||||
dance the signed contracts — which carry minors' data.
|
||||
|
||||
Dumps are now AES-256 encrypted inside the ZIP. The key is derived from
|
||||
`APP_SECRET_KEY`, domain-separated (`am-backup|`) so the same secret is never
|
||||
used for two purposes, and it only ever exists in `.env` — never in a dump.
|
||||
|
||||
**That has a consequence worth saying out loud: losing `APP_SECRET_KEY` means
|
||||
losing every backup encrypted with it.** Keep it wherever the server credentials
|
||||
are kept, not only in `.env`.
|
||||
|
||||
Without a key the dump is written in the clear and says so — on the page and in
|
||||
the log. Refusing to back up for want of a key would be worse than an
|
||||
unprotected backup: the alternative to a readable file is not a safe file, it is
|
||||
no file.
|
||||
|
||||
Restore handles both: `restore_open_zip()` applies the key when there is one,
|
||||
and `setPassword()` on an unencrypted archive is simply ignored — so artefacts
|
||||
made before this change still restore without special handling.
|
||||
|
||||
## Tested assumptions
|
||||
|
||||
- The `applied_migrations` table uses `filename` as PK (VARCHAR 150). Platforms that already have a different schema (older DSA-style with `id` PK + `filename` UNIQUE) are compatible — `CREATE TABLE IF NOT EXISTS` is a no-op for the existing table, and the queries (`SELECT/INSERT … WHERE filename=?`) work against both shapes.
|
||||
- All POST actions are idempotent or guarded with confirms, and every one requires the shared CSRF token (`functions_csrf.php`) on top of the host's sysadmin auth guard.
|
||||
- `git_run` uses `proc_open` array form (no shell), so format strings with `%cd`, `%h`, `|` etc. pass through cleanly.
|
||||
|
||||
## Verification after wiring (per platform)
|
||||
|
||||
```bash
|
||||
# 1. Syntax — platform_update stubs
|
||||
php -l views/sysadmin/platform_update.php config/functions_git.php config/functions_migrations.php
|
||||
|
||||
# 2. Syntax — backups stubs (if wired)
|
||||
php -l views/sysadmin/backups.php config/functions_backup.php api/backup_trigger.php api/backup_download.php
|
||||
|
||||
# 3. Submodule populated
|
||||
ls shared/platform-tools/ # expect: platform_update.php, functions_git.php, functions_migrations.php, functions_schema.php, backups.php, functions_backup.php, api/, backups/, README.md
|
||||
|
||||
# 4. End-to-end platform_update (in browser, as sysadmin)
|
||||
# - Open /views/sysadmin/platform_update.php
|
||||
# - Confirm git state panel shows branch + SHA + commit history
|
||||
# - Click "Verificar atualizações (fetch)" — see fetch output
|
||||
# - Click "Aplicar" on an idempotent test migration — confirm applied_migrations row appears
|
||||
# - Click "Exportar esquema" — success flash with table count; <host>/database/schema.sql created/updated
|
||||
# - Re-click "Exportar esquema" without schema changes — `git diff database/schema.sql` shows NO change (determinism check)
|
||||
|
||||
# 5. End-to-end backups (in browser, as sysadmin)
|
||||
# - Open /views/sysadmin/backups.php
|
||||
# - Click "Backup de uploads" — page reloads with success flash and polls until <host>/backups/backup_DD_MM_YYYY_HH_MM_SS_xxxxxx.zip appears (ZIP contains only uploads/)
|
||||
# - Click "Backup da base de dados" — same; db_backup_*.zip appears
|
||||
# - Click the download icon on a row — browser downloads the ZIP
|
||||
# - On the upstream platform-tools repo: edit a comment, push
|
||||
# - On the platform: `git submodule update --remote && git -C shared/platform-tools log -1`
|
||||
# → confirm new commit + visible on the page
|
||||
```
|
||||
@@ -0,0 +1,45 @@
|
||||
# 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:** `ea3616abae30106df283e73a3e2f9e5bb0e6ae90`
|
||||
(`feat: o feedback passa a servir os três stacks da frota` — the same commit
|
||||
`fotografia.andmore.pt` pins.)
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,22 @@
|
||||
<?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.
|
||||
//
|
||||
// Hosts on a different stack (surf: src/ + App\Database) write their own thin
|
||||
// stub that bootstraps their way and calls agent_dispatch() directly — the
|
||||
// action set lives in functions_agent.php so both paths share 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);
|
||||
@@ -0,0 +1,88 @@
|
||||
<?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);
|
||||
@@ -0,0 +1,43 @@
|
||||
<?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;
|
||||
@@ -0,0 +1,55 @@
|
||||
<?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;
|
||||
@@ -0,0 +1,70 @@
|
||||
<?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 é quem sabe distinguir
|
||||
// os dois stacks da frota (config/db.php ou src/config.php + App\Database) 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'] ?? ''),
|
||||
]);
|
||||
@@ -0,0 +1,159 @@
|
||||
<?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;
|
||||
|
||||
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>',
|
||||
],
|
||||
]);
|
||||
@@ -0,0 +1,141 @@
|
||||
<?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'], true)) {
|
||||
hub_json(['ok' => false, 'error' => 'plataforma sem base de dados — não há migrações a aplicar'], 400);
|
||||
}
|
||||
|
||||
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.
|
||||
$sub = git_run(['submodule', 'update', '--init', '--recursive'], $root);
|
||||
if (trim($sub['output']) !== '') $output .= "\n\n[submodules]\n" . $sub['output'];
|
||||
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]);
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?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'),
|
||||
]);
|
||||
@@ -0,0 +1,436 @@
|
||||
<?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)) {
|
||||
$_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: $pdo não foi carregado pelo stub.',
|
||||
];
|
||||
} 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>
|
||||
Os backups com mais de <?= (int) BACKUP_KEEP_DAYS ?> dias são eliminados automaticamente.
|
||||
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; ?>
|
||||
@@ -0,0 +1,185 @@
|
||||
<?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";
|
||||
|
||||
cleanOldBackups($backupDir, 'backup_*.zip');
|
||||
@@ -0,0 +1,95 @@
|
||||
<?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/ directory at all -- src/config.php plus
|
||||
// vlucas/phpdotenv, which has already populated $_ENV and
|
||||
// getenv() by the time a web request reaches here.
|
||||
// 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'));
|
||||
define('BACKUP_KEEP_DAYS', (int) env('BACKUP_KEEP_DAYS', 7));
|
||||
|
||||
// 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');
|
||||
|
||||
/**
|
||||
* Apaga backups mais antigos que BACKUP_KEEP_DAYS na pasta indicada.
|
||||
* $pattern — glob pattern para identificar os ficheiros (ex: 'backup_*.zip')
|
||||
*/
|
||||
function cleanOldBackups(string $dir, string $pattern): void
|
||||
{
|
||||
$cutoff = time() - (BACKUP_KEEP_DAYS * 86400);
|
||||
$files = glob($dir . '/' . $pattern) ?: [];
|
||||
$deleted = 0;
|
||||
|
||||
foreach ($files as $file) {
|
||||
if (filemtime($file) < $cutoff) {
|
||||
if (@unlink($file)) {
|
||||
$deleted++;
|
||||
echo "Apagado (antigo): " . basename($file) . "\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($deleted === 0) {
|
||||
echo "Rotação: nenhum backup antigo para apagar.\n";
|
||||
} else {
|
||||
echo "Rotação: $deleted ficheiro(s) apagado(s).\n";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
<?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";
|
||||
|
||||
cleanOldBackups($backupDir, 'db_backup_*.zip');
|
||||
// Varre também .sql órfãos de execuções antigas interrompidas a meio.
|
||||
cleanOldBackups($backupDir, 'db_backup_*.sql');
|
||||
@@ -0,0 +1,126 @@
|
||||
<?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);
|
||||
@@ -0,0 +1,89 @@
|
||||
<?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.
|
||||
//
|
||||
// Bootstrapping differs per site: the config/ stack (dance, fotografia, hub2)
|
||||
// opens $pdo in config/db.php; surf/silver builds it through App\Database. Try
|
||||
// the common path first and fall back, rather than assuming either.
|
||||
|
||||
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';
|
||||
}
|
||||
} elseif (is_file($repoRoot . '/src/db.php')) {
|
||||
// surf/silver: config.php loads the environment that db.php then reads.
|
||||
if (is_file($repoRoot . '/src/config.php')) require_once $repoRoot . '/src/config.php';
|
||||
require_once $repoRoot . '/src/db.php';
|
||||
}
|
||||
|
||||
if (!$pdo instanceof PDO) {
|
||||
foreach (['App\\Database::getConnection', 'App\\Database::pdo', 'db', 'get_pdo'] as $factory) {
|
||||
if (is_callable($factory)) { $pdo = $factory(); break; }
|
||||
}
|
||||
}
|
||||
|
||||
if (!$pdo instanceof PDO) {
|
||||
fwrite(STDERR, "encrypt_secrets: could not open a PDO connection for {$repoRoot}\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
// src/db.php carries hardcoded development fallbacks (a 192.168.x host, a
|
||||
// *_dev schema). If the environment did not reach it we would connect
|
||||
// somewhere harmless-looking 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);
|
||||
@@ -0,0 +1,169 @@
|
||||
#!/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;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,59 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
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
|
||||
@@ -0,0 +1,120 @@
|
||||
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
|
||||
@@ -0,0 +1,25 @@
|
||||
<?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';
|
||||
@@ -0,0 +1,474 @@
|
||||
<?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.
|
||||
$hubPage = (string) ($GLOBALS['platform_feedback_hub_url'] ?? '/views/sysadmin/hub_integration.php');
|
||||
|
||||
$inline = ($GLOBALS['platform_feedback_mode'] ?? 'bubble') === 'footer';
|
||||
?>
|
||||
<div class="ptfb<?= $inline ? ' ptfb--inline' : '' ?>" id="ptfb" data-endpoint="<?= $h($endpoint) ?>">
|
||||
<div class="ptfb-panel" id="ptfb-panel" role="dialog" aria-labelledby="ptfb-title" hidden>
|
||||
<div class="ptfb-head">
|
||||
<div>
|
||||
<p class="ptfb-title" id="ptfb-title">Reportar à andmore</p>
|
||||
<p class="ptfb-sub">Alguma coisa errada na plataforma? Conta aqui.</p>
|
||||
</div>
|
||||
<button type="button" class="ptfb-x" id="ptfb-close" aria-label="Fechar">×</button>
|
||||
</div>
|
||||
|
||||
<?php if (!$ligado): ?>
|
||||
<div class="ptfb-body">
|
||||
<p class="ptfb-warn">
|
||||
Esta plataforma não está ligada ao balcão de apoio — nada seria enviado.
|
||||
Só tu vês esta bolha.
|
||||
</p>
|
||||
<p class="ptfb-warn-more">
|
||||
Liga-a em <a href="<?= $h($hubPage) ?>">Integração com o hub</a>: falta o
|
||||
endereço do hub, a chave de emparelhamento, ou ambos.
|
||||
</p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<form class="ptfb-body" id="ptfb-form" novalidate>
|
||||
<input type="hidden" name="_pt_csrf" value="<?= $h(platform_csrf_token()) ?>">
|
||||
<input type="hidden" name="page_url" id="ptfb-page">
|
||||
|
||||
<label class="ptfb-label" for="ptfb-cat">Tipo</label>
|
||||
<select class="ptfb-input" name="category" id="ptfb-cat">
|
||||
<?php foreach ($cats as $key => $label): ?>
|
||||
<option value="<?= $h((string) $key) ?>"><?= $h((string) $label) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
|
||||
<label class="ptfb-label" for="ptfb-subject">Assunto</label>
|
||||
<input type="text" class="ptfb-input" name="title" id="ptfb-subject"
|
||||
maxlength="150" placeholder="Numa linha, o que se passa">
|
||||
|
||||
<label class="ptfb-label" for="ptfb-desc">O que aconteceu</label>
|
||||
<textarea class="ptfb-input ptfb-area" name="description" id="ptfb-desc" rows="5"
|
||||
maxlength="5000"
|
||||
placeholder="O que estavas a fazer, o que esperavas, e o que aconteceu."></textarea>
|
||||
|
||||
<label class="ptfb-check">
|
||||
<input type="checkbox" name="blocking" value="1" id="ptfb-block">
|
||||
<span>Está a impedir-me de trabalhar</span>
|
||||
</label>
|
||||
|
||||
<p class="ptfb-note">
|
||||
Vai junto o contexto: a página onde estás, a versão da plataforma, a tua
|
||||
conta e o browser. Não precisas de escrever nada disso.
|
||||
</p>
|
||||
|
||||
<button type="submit" class="ptfb-send" id="ptfb-send">Enviar</button>
|
||||
<p class="ptfb-msg" id="ptfb-msg" role="status" hidden></p>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($inline): ?>
|
||||
<button type="button" class="ptfb-linkbtn" id="ptfb-open"
|
||||
aria-expanded="false" aria-controls="ptfb-panel">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true" focusable="false">
|
||||
<path fill="currentColor" d="M20 2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h4v4l5-4h7a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2z"/>
|
||||
</svg>
|
||||
Reportar um problema
|
||||
</button>
|
||||
<?php else: ?>
|
||||
<button type="button" class="ptfb-bubble" id="ptfb-open"
|
||||
aria-expanded="false" aria-controls="ptfb-panel"
|
||||
title="Reportar à andmore">
|
||||
<svg viewBox="0 0 24 24" width="26" height="26" aria-hidden="true" focusable="false">
|
||||
<path fill="currentColor" d="M20 2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h4v4l5-4h7a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2z"/>
|
||||
</svg>
|
||||
<span class="ptfb-sr">Reportar à andmore</span>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.ptfb {
|
||||
position: fixed;
|
||||
right: 18px;
|
||||
bottom: 18px;
|
||||
z-index: 1035; /* ver a nota no topo do ficheiro */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.ptfb-sr {
|
||||
position: absolute; width: 1px; height: 1px;
|
||||
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap;
|
||||
}
|
||||
.ptfb-bubble {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 52px; height: 52px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: #1f2937;
|
||||
color: #fff;
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, .28);
|
||||
cursor: pointer;
|
||||
transition: transform .15s ease, background-color .15s ease;
|
||||
}
|
||||
.ptfb-bubble:hover { background: #111827; transform: translateY(-2px); }
|
||||
.ptfb-bubble:focus-visible { outline: 3px solid #60a5fa; outline-offset: 2px; }
|
||||
.ptfb.is-open .ptfb-bubble { background: #111827; }
|
||||
|
||||
.ptfb-panel {
|
||||
width: 340px;
|
||||
max-width: calc(100vw - 36px);
|
||||
/* Num portátil com o browser a meia altura o painel passava do ecrã e o
|
||||
botão de enviar ficava fora de alcance. */
|
||||
max-height: calc(100vh - 36px);
|
||||
overflow-y: auto;
|
||||
margin-bottom: 12px;
|
||||
background: #fff;
|
||||
color: #1f2937;
|
||||
border: 1px solid rgba(0, 0, 0, .12);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 18px 48px rgba(0, 0, 0, .28);
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
}
|
||||
.ptfb-panel[hidden] { display: none; }
|
||||
|
||||
.ptfb-head {
|
||||
display: flex; align-items: flex-start; gap: .5rem;
|
||||
padding: .85rem 1rem;
|
||||
background: #1f2937;
|
||||
color: #fff;
|
||||
}
|
||||
.ptfb-title { margin: 0; font-size: .95rem; font-weight: 600; }
|
||||
.ptfb-sub { margin: .1rem 0 0; font-size: .78rem; opacity: .75; }
|
||||
.ptfb-x {
|
||||
margin-left: auto;
|
||||
background: none; border: none; color: #fff;
|
||||
font-size: 1.4rem; line-height: 1;
|
||||
padding: 0 .1rem; cursor: pointer; opacity: .8;
|
||||
}
|
||||
.ptfb-x:hover { opacity: 1; }
|
||||
|
||||
.ptfb-body { padding: .9rem 1rem 1rem; }
|
||||
.ptfb-label {
|
||||
display: block;
|
||||
margin: .6rem 0 .25rem;
|
||||
font-size: .72rem; font-weight: 600;
|
||||
letter-spacing: .04em; text-transform: uppercase;
|
||||
color: #6b7280;
|
||||
}
|
||||
.ptfb-label:first-of-type { margin-top: 0; }
|
||||
.ptfb-input {
|
||||
display: block; width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: .5rem .6rem;
|
||||
font: inherit; font-size: .88rem;
|
||||
color: #1f2937;
|
||||
background: #fff;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.ptfb-input:focus {
|
||||
outline: none;
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, .18);
|
||||
}
|
||||
.ptfb-area { resize: vertical; min-height: 92px; }
|
||||
.ptfb-check {
|
||||
display: flex; align-items: center; gap: .45rem;
|
||||
margin: .7rem 0 0;
|
||||
font-size: .83rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ptfb-note {
|
||||
margin: .7rem 0 .85rem;
|
||||
font-size: .74rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
.ptfb-send {
|
||||
width: 100%;
|
||||
padding: .6rem 1rem;
|
||||
font: inherit; font-size: .88rem; font-weight: 600;
|
||||
color: #fff; background: #2563eb;
|
||||
border: none; border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ptfb-send:hover:not(:disabled) { background: #1d4ed8; }
|
||||
.ptfb-send:disabled { opacity: .6; cursor: default; }
|
||||
|
||||
.ptfb-msg {
|
||||
margin: .7rem 0 0;
|
||||
padding: .55rem .7rem;
|
||||
font-size: .82rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.ptfb-msg[hidden] { display: none; }
|
||||
.ptfb-msg.is-ok { background: #ecfdf5; color: #065f46; border: 1px solid #a7f3d0; }
|
||||
.ptfb-msg.is-bad { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }
|
||||
.ptfb-msg a { color: inherit; }
|
||||
|
||||
.ptfb-warn { margin: 0; font-size: .85rem; }
|
||||
.ptfb-warn-more { margin: .6rem 0 0; font-size: .8rem; color: #6b7280; }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.ptfb { right: 12px; bottom: 12px; left: 12px; align-items: stretch; }
|
||||
.ptfb-panel { width: 100%; max-width: none; }
|
||||
.ptfb-bubble { margin-left: auto; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ptfb-bubble { transition: none; }
|
||||
}
|
||||
|
||||
/* ─── Modo rodapé ───────────────────────────────────────────────────────────
|
||||
Nada a flutuar: fica a ligação na linha onde o include foi posto, a herdar
|
||||
a cor e o tipo de letra do rodapé que a recebe — o da fotografia é escuro, o
|
||||
do dance é claro, e um botão com cores próprias destoava num dos dois. O
|
||||
painel continua fixo no canto, e vem depois destas regras no ficheiro
|
||||
precisamente para ganhar às de cima. */
|
||||
.ptfb--inline {
|
||||
position: static;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
z-index: auto;
|
||||
font-family: inherit;
|
||||
}
|
||||
.ptfb-linkbtn {
|
||||
display: inline-flex; align-items: center; gap: .4rem;
|
||||
margin: 0; padding: 0;
|
||||
border: none; background: none;
|
||||
color: inherit; font: inherit; font-size: .85em;
|
||||
line-height: inherit;
|
||||
opacity: .75;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.ptfb-linkbtn:hover { opacity: 1; }
|
||||
.ptfb-linkbtn:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; }
|
||||
.ptfb-linkbtn svg { flex: 0 0 auto; }
|
||||
|
||||
/* Cor medida, não herdada — ver a nota no JS. Só entram quando o script
|
||||
conseguiu medir o fundo; se não conseguiu, fica o `color: inherit` de cima. */
|
||||
.ptfb-on-dark .ptfb-linkbtn { color: rgba(255, 255, 255, .62); opacity: 1; }
|
||||
.ptfb-on-dark .ptfb-linkbtn:hover { color: #fff; }
|
||||
.ptfb-on-light .ptfb-linkbtn { color: rgba(0, 0, 0, .66); opacity: 1; }
|
||||
.ptfb-on-light .ptfb-linkbtn:hover { color: #000; }
|
||||
|
||||
.ptfb--inline .ptfb-panel {
|
||||
position: fixed;
|
||||
right: 18px;
|
||||
bottom: 18px;
|
||||
margin-bottom: 0;
|
||||
z-index: 1035;
|
||||
/* O `font-family: inherit` acima é para a LIGAÇÃO se fundir no rodapé; o
|
||||
painel é um formulário e volta ao tipo de letra do sistema. Sem isto, o
|
||||
rodapé da fotografia (Georgia) punha um formulário inteiro em serifada. */
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
line-height: 1.45;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.ptfb--inline { left: auto; right: auto; bottom: auto; }
|
||||
.ptfb--inline .ptfb-panel { left: 12px; right: 12px; bottom: 12px; width: auto; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var root = document.getElementById('ptfb');
|
||||
if (!root) return;
|
||||
|
||||
var openBtn = document.getElementById('ptfb-open');
|
||||
var panel = document.getElementById('ptfb-panel');
|
||||
var closeBtn = document.getElementById('ptfb-close');
|
||||
var form = document.getElementById('ptfb-form');
|
||||
|
||||
/* ─── A cor da ligação do rodapé é medida, não herdada ──────────────────
|
||||
`color: inherit` assume que o rodapé define uma cor legível para si
|
||||
próprio. Há rodapés que não definem nenhuma e colorem os filhos um a um:
|
||||
o do dance tem `background: var(--text)` (#1a1a1a) e cor herdada #1a1a1a,
|
||||
o que dava uma ligação invisível — contraste 1:1.
|
||||
Em vez de adivinhar a paleta de cada site da frota, procura-se o primeiro
|
||||
antepassado com fundo opaco e escolhe-se claro ou escuro pela luminância.
|
||||
Não se encontrando nenhum, não se toca em nada e fica o herdado. */
|
||||
function tuneContrast() {
|
||||
if (!root.classList.contains('ptfb--inline')) return;
|
||||
var el = root, rgb = null;
|
||||
while (el && el !== document.documentElement && !rgb) {
|
||||
var m = getComputedStyle(el).backgroundColor.match(/rgba?\(([\d.,\s]+)\)/);
|
||||
if (m) {
|
||||
var p = m[1].split(',').map(parseFloat);
|
||||
if (p.length < 4 || p[3] > 0.5) rgb = p;
|
||||
}
|
||||
el = el.parentElement;
|
||||
}
|
||||
if (!rgb) return;
|
||||
// Luminância aproximada (sem correcção gama): chega de sobra para decidir
|
||||
// entre texto claro e texto escuro.
|
||||
var lum = (0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]) / 255;
|
||||
root.classList.remove('ptfb-on-dark', 'ptfb-on-light');
|
||||
root.classList.add(lum < 0.5 ? 'ptfb-on-dark' : 'ptfb-on-light');
|
||||
}
|
||||
tuneContrast();
|
||||
// Outra vez com tudo carregado: um site que sirva a folha de estilos no fim
|
||||
// do body ainda não a tinha aplicada na primeira medição.
|
||||
window.addEventListener('load', tuneContrast);
|
||||
|
||||
function open() {
|
||||
panel.hidden = false;
|
||||
root.classList.add('is-open');
|
||||
openBtn.setAttribute('aria-expanded', 'true');
|
||||
var first = panel.querySelector('select, input[type=text], a');
|
||||
if (first) first.focus();
|
||||
}
|
||||
function close() {
|
||||
panel.hidden = true;
|
||||
root.classList.remove('is-open');
|
||||
openBtn.setAttribute('aria-expanded', 'false');
|
||||
openBtn.focus();
|
||||
}
|
||||
|
||||
openBtn.addEventListener('click', function () { panel.hidden ? open() : close(); });
|
||||
closeBtn.addEventListener('click', close);
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape' && !panel.hidden) close();
|
||||
});
|
||||
|
||||
if (!form) return; // painel de aviso: não há formulário para submeter
|
||||
|
||||
var msg = document.getElementById('ptfb-msg');
|
||||
var send = document.getElementById('ptfb-send');
|
||||
|
||||
// O URL da página é lido aqui e não no servidor: o rodapé pode ser incluído
|
||||
// por uma página que não é a que o browser mostra na barra de endereço.
|
||||
document.getElementById('ptfb-page').value = window.location.href;
|
||||
|
||||
// Texto por textContent e a ligação como elemento: a referência e o URL vêm
|
||||
// de outro serviço, e nada do que vem de fora entra por innerHTML.
|
||||
function say(text, ok, link) {
|
||||
msg.className = 'ptfb-msg ' + (ok ? 'is-ok' : 'is-bad');
|
||||
msg.textContent = text;
|
||||
if (link && /^https?:\/\//i.test(link)) {
|
||||
var a = document.createElement('a');
|
||||
a.href = link;
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener';
|
||||
a.textContent = 'Acompanhar';
|
||||
msg.appendChild(document.createTextNode(' '));
|
||||
msg.appendChild(a);
|
||||
}
|
||||
msg.hidden = false;
|
||||
}
|
||||
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
send.disabled = true;
|
||||
send.textContent = 'A enviar…';
|
||||
msg.hidden = true;
|
||||
|
||||
fetch(root.dataset.endpoint, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Accept': 'application/json' },
|
||||
body: new FormData(form)
|
||||
})
|
||||
.then(function (r) {
|
||||
return r.json().catch(function () {
|
||||
return { ok: false, error: 'O servidor respondeu de forma inesperada (HTTP ' + r.status + ').' };
|
||||
});
|
||||
})
|
||||
.then(function (res) {
|
||||
if (!res.ok) {
|
||||
say(res.error || 'Não foi possível enviar.', false);
|
||||
return;
|
||||
}
|
||||
say('Enviado. Ficou como ' + (res.ref || '—') + '.', true, res.track_url);
|
||||
document.getElementById('ptfb-subject').value = '';
|
||||
document.getElementById('ptfb-desc').value = '';
|
||||
document.getElementById('ptfb-block').checked = false;
|
||||
})
|
||||
.catch(function () {
|
||||
say('Sem ligação ao servidor. Tenta outra vez daqui a bocado.', false);
|
||||
})
|
||||
.finally(function () {
|
||||
send.disabled = false;
|
||||
send.textContent = 'Enviar';
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<?php
|
||||
})();
|
||||
@@ -0,0 +1,226 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
<?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.
|
||||
*/
|
||||
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;
|
||||
$html .= '<script>document.querySelectorAll(\'input[data-js-check]\').forEach('
|
||||
. 'function(i){i.value=i.dataset.jsCheck;});</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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
<?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';
|
||||
|
||||
// ─── 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.
|
||||
|
||||
function auth_schema(?array $map = null): array {
|
||||
static $schema = [
|
||||
'table' => 'users',
|
||||
'id' => 'id',
|
||||
'email' => 'email',
|
||||
// The column the login form matches on. null => use `email`, which is
|
||||
// what five of the six sites do. quack authenticates by `username`
|
||||
// instead, and still needs `email` to stay the address password
|
||||
// recovery mails to -- so the two cannot be the same key.
|
||||
'login' => null,
|
||||
'password' => 'password_hash',
|
||||
'name' => 'name',
|
||||
'role' => 'role',
|
||||
'active' => 'is_active', // null => no active flag on this site
|
||||
'deleted_at' => null, // set when the site soft-deletes instead
|
||||
'last_login' => 'last_login_at',
|
||||
'last_ip' => 'last_login_ip',
|
||||
];
|
||||
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;
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
$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 {
|
||||
$_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;
|
||||
}
|
||||
@@ -0,0 +1,631 @@
|
||||
<?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 {
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
<?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
|
||||
|
||||
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 (surf).
|
||||
$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/');
|
||||
// Host base-URL helpers differ: fotografia and the config/-stack sites
|
||||
// expose base_url(); surf exposes getBaseUrl(). Calling base_url()
|
||||
// unconditionally fataled with "undefined function" on the latter, before
|
||||
// any backup could be triggered. Fall back to APP_BASE_URL from .env so a
|
||||
// host with neither helper still works.
|
||||
$path = ltrim($prefix . $script, '/');
|
||||
if (function_exists('base_url')) {
|
||||
$url = base_url($path);
|
||||
} elseif (function_exists('getBaseUrl')) {
|
||||
$url = rtrim(getBaseUrl(), '/') . '/' . $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 getBaseUrl(), 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;
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?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;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?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];
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?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.
|
||||
// O X-Forwarded-For tem o valor certo, mas é um cabeçalho -- qualquer cliente o
|
||||
// escreve. Só se acredita nele quando TRUSTED_PROXY=true diz que há mesmo um
|
||||
// proxy à frente a reescrevê-lo; caso contrário era uma identidade forjável, e
|
||||
// qualquer limite por IP passava a valer zero.
|
||||
|
||||
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);
|
||||
|
||||
if ($trusted && !empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
|
||||
// O primeiro da lista é o cliente; os seguintes são os proxies por
|
||||
// onde passou, e o NPM acrescenta-se ao fim.
|
||||
$first = trim(explode(',', (string) $_SERVER['HTTP_X_FORWARDED_FOR'])[0]);
|
||||
if (filter_var($first, FILTER_VALIDATE_IP)) {
|
||||
return $first;
|
||||
}
|
||||
}
|
||||
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? null;
|
||||
return ($ip && filter_var($ip, FILTER_VALIDATE_IP)) ? $ip : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
<?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);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?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'] ?? '');
|
||||
return $sent !== '' && $have !== '' && hash_equals($have, $sent);
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
<?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 : '';
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
<?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
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
// 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');
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
<?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.
|
||||
*
|
||||
* Safe and quiet when there are no submodules.
|
||||
*
|
||||
* @return array{ok:bool,log:array<string>}
|
||||
*/
|
||||
function git_sync_submodules(string $repo): array {
|
||||
if (git_submodule_paths($repo) === []) {
|
||||
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'];
|
||||
|
||||
$upd = git_run(['submodule', 'update', '--init', '--recursive', '--force'], $repo);
|
||||
$log[] = 'git submodule update --init --recursive --force → ' . ($upd['ok'] ? 'OK' : 'FALHOU');
|
||||
if (trim($upd['output']) !== '') $log[] = $upd['output'];
|
||||
|
||||
return ['ok' => $sync['ok'] && $upd['ok'], 'log' => $log];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)];
|
||||
}
|
||||
@@ -0,0 +1,668 @@
|
||||
<?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 ──────────────────────────────────────────────
|
||||
//
|
||||
// A frota não tem um só bootstrap: dance/design/fotografia/mstudio/quack usam o
|
||||
// stack `config/` (env(), setting(), base_url(), APP_NAME), a silver usa `src/`
|
||||
// com $_ENV e getBaseUrl(). 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. Os endpoints abrem-no no bootstrap e deixam-no global.
|
||||
*
|
||||
* A surf/silver não tem $pdo global — a ligação pede-se à App\Database. Os
|
||||
* endpoints do hub passam pelo hub_bootstrap.php, que já resolve isso, mas o
|
||||
* widget do feedback é incluído no meio de uma página qualquer e não passa por
|
||||
* lá: sem esta saída, lê as definições todas vazias e conclui que o site não
|
||||
* está emparelhado. Não escreve em $GLOBALS: o site é que manda no seu escopo.
|
||||
*/
|
||||
function hub_pdo(): ?PDO {
|
||||
$pdo = $GLOBALS['pdo'] ?? null;
|
||||
if ($pdo instanceof PDO) return $pdo;
|
||||
|
||||
if (is_callable(['\App\Database', 'getConnection'])) {
|
||||
try {
|
||||
$c = \App\Database::getConnection();
|
||||
if ($c instanceof PDO) return $c;
|
||||
} catch (Throwable $e) {
|
||||
error_log('hub_pdo: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
return 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. A silver não tem base_url(); tem getBaseUrl().
|
||||
* O APP_BASE_URL é o último recurso e está em todos os .env da frota.
|
||||
*/
|
||||
function hub_base_url(string $path = ''): string {
|
||||
if (function_exists('base_url')) return (string) base_url($path);
|
||||
$base = function_exists('getBaseUrl') ? (string) getBaseUrl() : 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', 'migrations.list', 'migrations.apply_pending'];
|
||||
}
|
||||
|
||||
// ─── 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;
|
||||
foreach ([__DIR__ . '/functions_backup.php',
|
||||
hub_root() . '/config/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'];
|
||||
// A silver não tem config/functions.php; tem src/config.php. Sem isto a
|
||||
// versão vinha vazia justamente na plataforma que mais precisa de a mostrar.
|
||||
foreach (['/config/functions.php', '/src/config.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() !== '';
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
<?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.
|
||||
|
||||
require_once __DIR__ . '/functions_client_ip.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'));
|
||||
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);
|
||||
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)]);
|
||||
|
||||
$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
|
||||
{
|
||||
$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 [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
<?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);
|
||||
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],
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
<?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;
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
<?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']] ?? ''));
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
<?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;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
<?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),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
<?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.
|
||||
*/
|
||||
function platform_admin_url(string $page = ''): string {
|
||||
$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;
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
<?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));
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
<?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);
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
<?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 stub tem de abrir a ligação primeiro.');
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/functions_geo.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'] ?? ''), '?') ?: '';
|
||||
|
||||
$user = function_exists('current_user') ? current_user() : null;
|
||||
$uid = isset($user['id']) ? (int) $user['id'] : null;
|
||||
|
||||
$probe = trim((string) ($_GET['probe'] ?? ''));
|
||||
$probeResult = null;
|
||||
|
||||
// ─── 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 {
|
||||
$script = __DIR__ . '/bin/geo-update.sh';
|
||||
$out = [];
|
||||
$code = 1;
|
||||
exec(escapeshellcmd($script) . ' ' . implode(' ', array_map('escapeshellarg', $countries)) . ' 2>&1', $out, $code);
|
||||
$_SESSION['flash'] = $code === 0
|
||||
? ['type' => 'success', 'msg' => 'Listas actualizadas. ' . implode(' · ', array_slice($out, 0, 6))]
|
||||
: ['type' => 'danger', 'msg' => 'A actualização falhou (as listas anteriores mantêm-se). ' . implode(' · ', array_slice($out, 0, 6))];
|
||||
}
|
||||
header('Location: ' . $selfUrl);
|
||||
exit;
|
||||
}
|
||||
|
||||
$mode = strtolower(trim((string) ($_POST['geo_mode'] ?? 'off')));
|
||||
if (!in_array($mode, ['off', 'forms', 'site'], true)) {
|
||||
$mode = 'off';
|
||||
}
|
||||
|
||||
// Aceita "PT, ES" ou "pt es"; guarda normalizado e sem repetições. 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".
|
||||
$rejected = [];
|
||||
$valid = [];
|
||||
foreach (preg_split('/[\s,;]+/', (string) ($_POST['geo_countries'] ?? ''), -1, PREG_SPLIT_NO_EMPTY) ?: [] 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;
|
||||
}
|
||||
|
||||
db_update_setting($pdo, 'geo_mode', $mode, $uid);
|
||||
db_update_setting($pdo, 'geo_allow_countries', $countries, $uid);
|
||||
db_update_setting($pdo, 'geo_fail_closed', $failClosed, $uid);
|
||||
|
||||
$_SESSION['flash'] = $rejected
|
||||
? ['type' => 'warning', 'msg' => 'Guardado, mas isto não são códigos de país válidos e foi ignorado: ' . implode(', ', $rejected)]
|
||||
: ['type' => 'success', 'msg' => 'Configuração guardada.'];
|
||||
header('Location: ' . $selfUrl);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ─── Estado actual ───────────────────────────────────────────────────────────
|
||||
$mode = geo_mode();
|
||||
$countries = geo_allowed_countries();
|
||||
$failClosed = geo_fail_closed();
|
||||
|
||||
// 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) {
|
||||
foreach (['v4', 'v6'] as $fam) {
|
||||
$f = GEO_DATA_DIR . '/' . strtolower($cc) . '.' . $fam;
|
||||
$dataFiles[] = [
|
||||
'cc' => $cc,
|
||||
'fam' => $fam,
|
||||
'exists' => is_file($f),
|
||||
'ranges' => is_file($f) ? count(geo_ranges($cc, $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';
|
||||
$hdr = dirname(__DIR__, 2) . '/includes/header.php';
|
||||
if (is_file($hdr)) { include $hdr; }
|
||||
|
||||
$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 if ($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">
|
||||
<label for="geo_countries" class="form-label fw-semibold">Países permitidos</label>
|
||||
<input type="text" class="form-control" id="geo_countries" name="geo_countries"
|
||||
value="<?= $h(implode(', ', $countries)) ?>" placeholder="PT">
|
||||
<div class="form-text">
|
||||
Códigos ISO de duas letras, separados por vírgula (<code>PT</code>, <code>PT, ES</code>).
|
||||
Valor activo vem de: <strong><?= $h($origin('GEO_ALLOW_COUNTRIES')) ?></strong>.
|
||||
</div>
|
||||
</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>Família</th><th class="text-end">Ranges</th><th>Actualizado</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($dataFiles as $d): ?>
|
||||
<tr>
|
||||
<td><?= $h($d['cc']) ?></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">
|
||||
Origem: ipdeny.com. O cron refresca-as mensalmente; as alocações movem-se devagar.
|
||||
Um download curto ou inválido é recusado e a lista anterior mantém-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>
|
||||
<?php
|
||||
$ftr = dirname(__DIR__, 2) . '/includes/footer.php';
|
||||
if (is_file($ftr)) { include $ftr; }
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
// ─── Arranque partilhado dos endpoints do hub ────────────────────────────────
|
||||
//
|
||||
// Os três endpoints (health, ops, pair) são iguais em todas as plataformas, mas
|
||||
// o arranque não é: a frota tem dois stacks. O `config/` (dance, design,
|
||||
// fotografia, mstudio, quack, hub) abre a ligação em config/db.php e tem os
|
||||
// helpers em config/functions.php; a silver usa `src/` com App\Database.
|
||||
//
|
||||
// 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:
|
||||
//
|
||||
// nem config/db.php nem src/db.php existem → não há camada de dados nenhuma,
|
||||
// é o desenho do site. Segue sem.
|
||||
// um deles 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')
|
||||
|| is_file($repo_root . '/src/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';
|
||||
}
|
||||
} elseif (is_file($repo_root . '/src/db.php')) {
|
||||
require_once $repo_root . '/src/config.php';
|
||||
require_once $repo_root . '/src/db.php';
|
||||
$pdo = \App\Database::getConnection();
|
||||
}
|
||||
}
|
||||
|
||||
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'];
|
||||
@@ -0,0 +1,355 @@
|
||||
<?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 stub 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') {
|
||||
$csrfOk = function_exists('csrf_verify')
|
||||
? csrf_verify($_POST['csrf_token'] ?? '')
|
||||
: 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'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$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: <chave></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;
|
||||
@@ -0,0 +1,128 @@
|
||||
<?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' => is_file($repo_root . '/public/views/sysadmin/hub_integration.php')
|
||||
|| is_file($repo_root . '/public/views/admin/hub_integration.php'),
|
||||
],
|
||||
];
|
||||
|
||||
// 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']
|
||||
);
|
||||
|
||||
$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; ?>
|
||||
@@ -0,0 +1,219 @@
|
||||
<?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 stub 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);
|
||||
$_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);
|
||||
$_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>
|
||||
</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-<?= $b['source'] === 'manual' ? 'primary' : 'secondary' ?>"><?= $h($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
@@ -0,0 +1,250 @@
|
||||
<?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 stub 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]);
|
||||
$_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 → <?= (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; ?>
|
||||
@@ -0,0 +1,235 @@
|
||||
<?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 stub 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>
|
||||
@@ -0,0 +1,306 @@
|
||||
<?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);
|
||||
}
|
||||
|
||||
$_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 → <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.<dominio></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; ?>
|
||||
@@ -0,0 +1,159 @@
|
||||
/* 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,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,117 @@
|
||||
<?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'] ?? []),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,745 @@
|
||||
'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,'&').replace(/</g,'<')
|
||||
.replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
/* ── 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 + ')">×</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();
|
||||
@@ -0,0 +1,504 @@
|
||||
/* ── 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; }
|
||||
@@ -0,0 +1,198 @@
|
||||
<?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 stub tem de abrir a ligação primeiro.');
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/functions_tracking.php';
|
||||
|
||||
$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; ?>
|
||||
Reference in new issue
Block a user