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:
andreandClaude Opus 5 committed 2026-09-13 23:06:23 +01:00
1 parent 0e034232e9
commit 4d40adbf64
67 files changed
+17478 -5

No files matched your search

+36
View File
@@ -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.*
+80
View File
@@ -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.
+255
View File
@@ -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.
+937
View File
@@ -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
```
+45
View File
@@ -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.
+22
View File
@@ -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);
+88
View File
@@ -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;
+70
View File
@@ -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'] ?? ''),
]);
+159
View File
@@ -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>',
],
]);
+141
View File
@@ -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);
}
+47
View File
@@ -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'),
]);
+436
View File
@@ -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');
+95
View File
@@ -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";
}
}
+187
View File
@@ -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;
}
+95
View File
@@ -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
+59
View File
@@ -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();
}
}
+449
View File
@@ -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
+120
View File
@@ -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';
+474
View File
@@ -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">&times;</button>
</div>
<?php if (!$ligado): ?>
<div class="ptfb-body">
<p class="ptfb-warn">
Esta plataforma não está ligada ao balcão de apoio — nada seria enviado.
Só tu vês esta bolha.
</p>
<p class="ptfb-warn-more">
Liga-a em <a href="<?= $h($hubPage) ?>">Integração com o hub</a>: falta o
endereço do hub, a chave de emparelhamento, ou ambos.
</p>
</div>
<?php else: ?>
<form class="ptfb-body" id="ptfb-form" novalidate>
<input type="hidden" name="_pt_csrf" value="<?= $h(platform_csrf_token()) ?>">
<input type="hidden" name="page_url" id="ptfb-page">
<label class="ptfb-label" for="ptfb-cat">Tipo</label>
<select class="ptfb-input" name="category" id="ptfb-cat">
<?php foreach ($cats as $key => $label): ?>
<option value="<?= $h((string) $key) ?>"><?= $h((string) $label) ?></option>
<?php endforeach; ?>
</select>
<label class="ptfb-label" for="ptfb-subject">Assunto</label>
<input type="text" class="ptfb-input" name="title" id="ptfb-subject"
maxlength="150" placeholder="Numa linha, o que se passa">
<label class="ptfb-label" for="ptfb-desc">O que aconteceu</label>
<textarea class="ptfb-input ptfb-area" name="description" id="ptfb-desc" rows="5"
maxlength="5000"
placeholder="O que estavas a fazer, o que esperavas, e o que aconteceu."></textarea>
<label class="ptfb-check">
<input type="checkbox" name="blocking" value="1" id="ptfb-block">
<span>Está a impedir-me de trabalhar</span>
</label>
<p class="ptfb-note">
Vai junto o contexto: a página onde estás, a versão da plataforma, a tua
conta e o browser. Não precisas de escrever nada disso.
</p>
<button type="submit" class="ptfb-send" id="ptfb-send">Enviar</button>
<p class="ptfb-msg" id="ptfb-msg" role="status" hidden></p>
</form>
<?php endif; ?>
</div>
<?php if ($inline): ?>
<button type="button" class="ptfb-linkbtn" id="ptfb-open"
aria-expanded="false" aria-controls="ptfb-panel">
<svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true" focusable="false">
<path fill="currentColor" d="M20 2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h4v4l5-4h7a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2z"/>
</svg>
Reportar um problema
</button>
<?php else: ?>
<button type="button" class="ptfb-bubble" id="ptfb-open"
aria-expanded="false" aria-controls="ptfb-panel"
title="Reportar à andmore">
<svg viewBox="0 0 24 24" width="26" height="26" aria-hidden="true" focusable="false">
<path fill="currentColor" d="M20 2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h4v4l5-4h7a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2z"/>
</svg>
<span class="ptfb-sr">Reportar à andmore</span>
</button>
<?php endif; ?>
</div>
<style>
.ptfb {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 1035; /* ver a nota no topo do ficheiro */
display: flex;
flex-direction: column;
align-items: flex-end;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
line-height: 1.45;
}
.ptfb-sr {
position: absolute; width: 1px; height: 1px;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap;
}
.ptfb-bubble {
display: flex; align-items: center; justify-content: center;
width: 52px; height: 52px;
border: none;
border-radius: 50%;
background: #1f2937;
color: #fff;
box-shadow: 0 6px 20px rgba(0, 0, 0, .28);
cursor: pointer;
transition: transform .15s ease, background-color .15s ease;
}
.ptfb-bubble:hover { background: #111827; transform: translateY(-2px); }
.ptfb-bubble:focus-visible { outline: 3px solid #60a5fa; outline-offset: 2px; }
.ptfb.is-open .ptfb-bubble { background: #111827; }
.ptfb-panel {
width: 340px;
max-width: calc(100vw - 36px);
/* Num portátil com o browser a meia altura o painel passava do ecrã e o
botão de enviar ficava fora de alcance. */
max-height: calc(100vh - 36px);
overflow-y: auto;
margin-bottom: 12px;
background: #fff;
color: #1f2937;
border: 1px solid rgba(0, 0, 0, .12);
border-radius: 14px;
box-shadow: 0 18px 48px rgba(0, 0, 0, .28);
overflow: hidden;
text-align: left;
}
.ptfb-panel[hidden] { display: none; }
.ptfb-head {
display: flex; align-items: flex-start; gap: .5rem;
padding: .85rem 1rem;
background: #1f2937;
color: #fff;
}
.ptfb-title { margin: 0; font-size: .95rem; font-weight: 600; }
.ptfb-sub { margin: .1rem 0 0; font-size: .78rem; opacity: .75; }
.ptfb-x {
margin-left: auto;
background: none; border: none; color: #fff;
font-size: 1.4rem; line-height: 1;
padding: 0 .1rem; cursor: pointer; opacity: .8;
}
.ptfb-x:hover { opacity: 1; }
.ptfb-body { padding: .9rem 1rem 1rem; }
.ptfb-label {
display: block;
margin: .6rem 0 .25rem;
font-size: .72rem; font-weight: 600;
letter-spacing: .04em; text-transform: uppercase;
color: #6b7280;
}
.ptfb-label:first-of-type { margin-top: 0; }
.ptfb-input {
display: block; width: 100%;
box-sizing: border-box;
padding: .5rem .6rem;
font: inherit; font-size: .88rem;
color: #1f2937;
background: #fff;
border: 1px solid #d1d5db;
border-radius: 8px;
}
.ptfb-input:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, .18);
}
.ptfb-area { resize: vertical; min-height: 92px; }
.ptfb-check {
display: flex; align-items: center; gap: .45rem;
margin: .7rem 0 0;
font-size: .83rem;
cursor: pointer;
}
.ptfb-note {
margin: .7rem 0 .85rem;
font-size: .74rem;
color: #6b7280;
}
.ptfb-send {
width: 100%;
padding: .6rem 1rem;
font: inherit; font-size: .88rem; font-weight: 600;
color: #fff; background: #2563eb;
border: none; border-radius: 8px;
cursor: pointer;
}
.ptfb-send:hover:not(:disabled) { background: #1d4ed8; }
.ptfb-send:disabled { opacity: .6; cursor: default; }
.ptfb-msg {
margin: .7rem 0 0;
padding: .55rem .7rem;
font-size: .82rem;
border-radius: 8px;
}
.ptfb-msg[hidden] { display: none; }
.ptfb-msg.is-ok { background: #ecfdf5; color: #065f46; border: 1px solid #a7f3d0; }
.ptfb-msg.is-bad { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }
.ptfb-msg a { color: inherit; }
.ptfb-warn { margin: 0; font-size: .85rem; }
.ptfb-warn-more { margin: .6rem 0 0; font-size: .8rem; color: #6b7280; }
@media (max-width: 480px) {
.ptfb { right: 12px; bottom: 12px; left: 12px; align-items: stretch; }
.ptfb-panel { width: 100%; max-width: none; }
.ptfb-bubble { margin-left: auto; }
}
@media (prefers-reduced-motion: reduce) {
.ptfb-bubble { transition: none; }
}
/* ─── Modo rodapé ───────────────────────────────────────────────────────────
Nada a flutuar: fica a ligação na linha onde o include foi posto, a herdar
a cor e o tipo de letra do rodapé que a recebe — o da fotografia é escuro, o
do dance é claro, e um botão com cores próprias destoava num dos dois. O
painel continua fixo no canto, e vem depois destas regras no ficheiro
precisamente para ganhar às de cima. */
.ptfb--inline {
position: static;
display: inline-flex;
align-items: center;
z-index: auto;
font-family: inherit;
}
.ptfb-linkbtn {
display: inline-flex; align-items: center; gap: .4rem;
margin: 0; padding: 0;
border: none; background: none;
color: inherit; font: inherit; font-size: .85em;
line-height: inherit;
opacity: .75;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 3px;
}
.ptfb-linkbtn:hover { opacity: 1; }
.ptfb-linkbtn:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; }
.ptfb-linkbtn svg { flex: 0 0 auto; }
/* Cor medida, não herdada — ver a nota no JS. Só entram quando o script
conseguiu medir o fundo; se não conseguiu, fica o `color: inherit` de cima. */
.ptfb-on-dark .ptfb-linkbtn { color: rgba(255, 255, 255, .62); opacity: 1; }
.ptfb-on-dark .ptfb-linkbtn:hover { color: #fff; }
.ptfb-on-light .ptfb-linkbtn { color: rgba(0, 0, 0, .66); opacity: 1; }
.ptfb-on-light .ptfb-linkbtn:hover { color: #000; }
.ptfb--inline .ptfb-panel {
position: fixed;
right: 18px;
bottom: 18px;
margin-bottom: 0;
z-index: 1035;
/* O `font-family: inherit` acima é para a LIGAÇÃO se fundir no rodapé; o
painel é um formulário e volta ao tipo de letra do sistema. Sem isto, o
rodapé da fotografia (Georgia) punha um formulário inteiro em serifada. */
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
line-height: 1.45;
}
@media (max-width: 480px) {
.ptfb--inline { left: auto; right: auto; bottom: auto; }
.ptfb--inline .ptfb-panel { left: 12px; right: 12px; bottom: 12px; width: auto; }
}
</style>
<script>
(function () {
var root = document.getElementById('ptfb');
if (!root) return;
var openBtn = document.getElementById('ptfb-open');
var panel = document.getElementById('ptfb-panel');
var closeBtn = document.getElementById('ptfb-close');
var form = document.getElementById('ptfb-form');
/* ─── A cor da ligação do rodapé é medida, não herdada ──────────────────
`color: inherit` assume que o rodapé define uma cor legível para si
próprio. Há rodapés que não definem nenhuma e colorem os filhos um a um:
o do dance tem `background: var(--text)` (#1a1a1a) e cor herdada #1a1a1a,
o que dava uma ligação invisível — contraste 1:1.
Em vez de adivinhar a paleta de cada site da frota, procura-se o primeiro
antepassado com fundo opaco e escolhe-se claro ou escuro pela luminância.
Não se encontrando nenhum, não se toca em nada e fica o herdado. */
function tuneContrast() {
if (!root.classList.contains('ptfb--inline')) return;
var el = root, rgb = null;
while (el && el !== document.documentElement && !rgb) {
var m = getComputedStyle(el).backgroundColor.match(/rgba?\(([\d.,\s]+)\)/);
if (m) {
var p = m[1].split(',').map(parseFloat);
if (p.length < 4 || p[3] > 0.5) rgb = p;
}
el = el.parentElement;
}
if (!rgb) return;
// Luminância aproximada (sem correcção gama): chega de sobra para decidir
// entre texto claro e texto escuro.
var lum = (0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]) / 255;
root.classList.remove('ptfb-on-dark', 'ptfb-on-light');
root.classList.add(lum < 0.5 ? 'ptfb-on-dark' : 'ptfb-on-light');
}
tuneContrast();
// Outra vez com tudo carregado: um site que sirva a folha de estilos no fim
// do body ainda não a tinha aplicada na primeira medição.
window.addEventListener('load', tuneContrast);
function open() {
panel.hidden = false;
root.classList.add('is-open');
openBtn.setAttribute('aria-expanded', 'true');
var first = panel.querySelector('select, input[type=text], a');
if (first) first.focus();
}
function close() {
panel.hidden = true;
root.classList.remove('is-open');
openBtn.setAttribute('aria-expanded', 'false');
openBtn.focus();
}
openBtn.addEventListener('click', function () { panel.hidden ? open() : close(); });
closeBtn.addEventListener('click', close);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && !panel.hidden) close();
});
if (!form) return; // painel de aviso: não há formulário para submeter
var msg = document.getElementById('ptfb-msg');
var send = document.getElementById('ptfb-send');
// O URL da página é lido aqui e não no servidor: o rodapé pode ser incluído
// por uma página que não é a que o browser mostra na barra de endereço.
document.getElementById('ptfb-page').value = window.location.href;
// Texto por textContent e a ligação como elemento: a referência e o URL vêm
// de outro serviço, e nada do que vem de fora entra por innerHTML.
function say(text, ok, link) {
msg.className = 'ptfb-msg ' + (ok ? 'is-ok' : 'is-bad');
msg.textContent = text;
if (link && /^https?:\/\//i.test(link)) {
var a = document.createElement('a');
a.href = link;
a.target = '_blank';
a.rel = 'noopener';
a.textContent = 'Acompanhar';
msg.appendChild(document.createTextNode(' '));
msg.appendChild(a);
}
msg.hidden = false;
}
form.addEventListener('submit', function (e) {
e.preventDefault();
send.disabled = true;
send.textContent = 'A enviar…';
msg.hidden = true;
fetch(root.dataset.endpoint, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Accept': 'application/json' },
body: new FormData(form)
})
.then(function (r) {
return r.json().catch(function () {
return { ok: false, error: 'O servidor respondeu de forma inesperada (HTTP ' + r.status + ').' };
});
})
.then(function (res) {
if (!res.ok) {
say(res.error || 'Não foi possível enviar.', false);
return;
}
say('Enviado. Ficou como ' + (res.ref || '—') + '.', true, res.track_url);
document.getElementById('ptfb-subject').value = '';
document.getElementById('ptfb-desc').value = '';
document.getElementById('ptfb-block').checked = false;
})
.catch(function () {
say('Sem ligação ao servidor. Tenta outra vez daqui a bocado.', false);
})
.finally(function () {
send.disabled = false;
send.textContent = 'Enviar';
});
});
})();
</script>
<?php
})();
+226
View File
@@ -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());
}
}
+419
View File
@@ -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
}
+226
View File
@@ -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);
}
+38
View File
@@ -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 : '';
}
+415
View File
@@ -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');
}
+406
View File
@@ -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)];
}
+668
View File
@@ -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() !== '';
}
+262
View File
@@ -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 [];
}
}
+334
View File
@@ -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();
}
+355
View File
@@ -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;
}
+159
View File
@@ -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;
}
+183
View File
@@ -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);
}
+336
View File
@@ -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; }
+77
View File
@@ -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'];
+355
View File
@@ -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: &lt;chave&gt;</code> — a de leitura no estado, a de operações no resto</dd>
</dl>
<p class="small text-muted mt-2 mb-0">
O <code>Authorization: Bearer</code> também é aceite, mas o proxy à frente dos contentores
remove esse cabeçalho — daí o <code>X-Hub-Key</code> ser o caminho principal.
</p>
</div></div>
</div>
<div class="col-lg-5">
<div class="card mb-3"><div class="card-body">
<h6 class="text-muted small text-uppercase">O que o hub vai ver</h6>
<ul class="list-unstyled small mb-0">
<li class="py-1 border-bottom border-opacity-10"><i class="bi bi-activity me-1"></i>Base de dados, SMTP, fila de envios, migrações por aplicar, espaço em disco</li>
<li class="py-1 border-bottom border-opacity-10"><i class="bi bi-archive me-1"></i>Última cópia de ficheiros e da base de dados, com a idade</li>
<li class="py-1"><i class="bi bi-git me-1"></i>Ramo e último commit, quando esta instalação é um repositório git</li>
</ul>
</div></div>
<div class="card mb-3"><div class="card-body">
<h6 class="text-muted small text-uppercase">O que o hub vai poder disparar</h6>
<ul class="list-unstyled small mb-2">
<li class="py-1"><code>backup.trigger</code> — cópia de ficheiros ou da base de dados</li>
<li class="py-1"><code>git.pull</code> + <code>migrations.apply_pending</code> — atualização da plataforma</li>
</ul>
<p class="small text-muted mb-0">
Num alojamento cPanel o <code>git.pull</code> costuma não estar disponível. Isso está previsto:
a resposta explica-o e <strong>as cópias continuam a funcionar</strong>, que é a parte que
protege o cliente.
</p>
</div></div>
<div class="card"><div class="card-body">
<h6 class="text-muted small text-uppercase">Cópias agora</h6>
<?php if (empty($backups['available'])): ?>
<p class="small text-muted mb-0">Cópias não disponíveis nesta instalação.</p>
<?php else: ?>
<dl class="row small mb-0">
<dt class="col-5">Base de dados</dt>
<dd class="col-7"><?= $backups['db']
? $h($backups['db']['size_human']) . ' · há ' . $h((string) $backups['db']['age_hours']) . ' h'
: '<span class="text-warning">nenhuma</span>' ?></dd>
<dt class="col-5">Ficheiros</dt>
<dd class="col-7"><?= $backups['files']
? $h($backups['files']['size_human']) . ' · há ' . $h((string) $backups['files']['age_hours']) . ' h'
: '<span class="text-warning">nenhuma</span>' ?></dd>
<dt class="col-5">Total</dt>
<dd class="col-7"><?= (int) $backups['count'] ?> ficheiro(s)</dd>
</dl>
<?php endif; ?>
</div></div>
</div>
</div>
</div>
<script>
document.getElementById('copyKey')?.addEventListener('click', function () {
var i = document.getElementById('freshKey');
i.select();
navigator.clipboard.writeText(i.value).then(function () {
if (window.AdminUX) AdminUX.toast('Chave copiada.');
}).catch(function () {
if (window.AdminUX) AdminUX.toast('Copia com Ctrl+C.', 'warning');
});
});
</script>
<?php
$layout_footer = $platform_layout_footer ?? ($repo_root . '/includes/footer.php');
if (is_file($layout_footer)) include $layout_footer;
+128
View File
@@ -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; ?>
+219
View File
@@ -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
+250
View File
@@ -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 &rarr; <?= (int) $report['tables_after'] ?> depois</td></tr>
<?php endif; ?>
<?php if (!empty($report['dropped_extra'])): ?>
<tr><td class="text-muted">Tabelas removidas</td>
<td><code><?= $h(implode(', ', $report['dropped_extra'])) ?></code>
<div class="form-text">Existiam aqui e não no artefacto.</div></td></tr>
<?php endif; ?>
<?php if (!empty($report['error'])): ?>
<tr><td class="text-muted">Erro</td>
<td class="text-danger"><?= $h($report['error']) ?></td></tr>
<?php endif; ?>
</tbody>
</table>
<?php if (!$report['ok'] && $report['safety_backup']): ?>
<p class="form-text mb-0 mt-2">
O estado anterior está em <code><?= $h($report['safety_backup']) ?></code> —
restaurá-lo devolve a base ao que era antes desta tentativa.
</p>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<div class="alert alert-warning d-flex gap-2 align-items-start">
<i class="bi bi-exclamation-triangle-fill mt-1"></i>
<div>
<strong>Isto é destrutivo.</strong> A base de dados actual
(<?= count($tables) ?> tabelas) é substituída pela do artefacto. Antes de tocar
em nada é tirado um backup de segurança do estado actual, cujo nome aparece no
relatório — mas planeie o restauro para uma altura em que ninguém esteja a usar
a plataforma.
</div>
</div>
<div class="card shadow-sm mb-4">
<div class="card-header fw-semibold"><i class="bi bi-list-ul me-1"></i>Artefactos de base de dados</div>
<div class="card-body">
<?php if (!$dbFiles): ?>
<p class="text-muted mb-0">
Não há backups de base de dados em <code>backups/</code>.
Crie um a partir da página de <a href="<?= $h($backupsUrl) ?>">Backups</a>.
</p>
<?php else: ?>
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead><tr><th>Ficheiro</th><th class="text-end">Tamanho</th><th>Criado</th><th></th></tr></thead>
<tbody>
<?php foreach ($dbFiles as $b): ?>
<tr<?= $b['filename'] === $selected ? ' class="table-active"' : '' ?>>
<td><code class="small"><?= $h($b['filename']) ?></code></td>
<td class="text-end text-muted small"><?= $h($b['size_human']) ?></td>
<td class="text-muted small"><?= $h(date('Y-m-d H:i', (int) $b['created_at'])) ?></td>
<td class="text-end">
<a class="btn btn-outline-secondary btn-sm"
href="<?= $h($selfUrl) ?>?f=<?= urlencode($b['filename']) ?>">Inspeccionar</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
<?php if ($inspect !== null): ?>
<div class="card shadow-sm">
<div class="card-header fw-semibold"><i class="bi bi-search me-1"></i>Inspecção</div>
<div class="card-body">
<?php if (!$inspect['ok']): ?>
<div class="alert alert-danger py-2 mb-0"><?= $h($inspect['error']) ?></div>
<?php elseif ($inspect['type'] !== 'db'): ?>
<div class="alert alert-warning py-2 mb-0">
<code><?= $h($inspect['filename']) ?></code> é um backup de ficheiros, não de base de dados.
</div>
<?php else: ?>
<table class="table table-sm align-middle">
<tbody>
<tr><td class="text-muted" style="width:14rem;">Ficheiro</td>
<td><code><?= $h($inspect['filename']) ?></code></td></tr>
<tr><td class="text-muted">Tamanho</td><td><?= $h($inspect['size_human']) ?></td></tr>
<tr><td class="text-muted">Entradas no ZIP</td><td><?= (int) $inspect['entries'] ?></td></tr>
<?php if (!empty($inspect['sql_entry'])): ?>
<tr><td class="text-muted">Dump</td><td><code><?= $h($inspect['sql_entry']) ?></code></td></tr>
<?php endif; ?>
</tbody>
</table>
<hr>
<form method="post" class="d-flex flex-column gap-3">
<?= platform_csrf_field() ?>
<input type="hidden" name="filename" value="<?= $h($inspect['filename']) ?>">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="dropExtra" name="drop_extra" value="1" checked>
<label class="form-check-label" for="dropExtra">
Remover tabelas que o artefacto não conhece
<span class="d-block text-muted small">
Sem isto, uma tabela criada depois deste backup sobrevive e deixa o
esquema num estado que o artefacto nunca descreveu. Desligue só se
souber que quer manter alguma.
</span>
</label>
</div>
<div>
<label class="form-label small fw-semibold" for="confirmName">
Para confirmar, escreva o nome do ficheiro
</label>
<input type="text" class="form-control font-monospace" id="confirmName"
name="confirm_name" autocomplete="off" spellcheck="false"
placeholder="<?= $h($inspect['filename']) ?>">
<div class="form-text">
É escrito à mão de propósito: obriga a olhar para qual artefacto está
prestes a aplicar.
</div>
</div>
<div>
<button type="submit" class="btn btn-danger">
<i class="bi bi-arrow-counterclockwise me-1"></i>Restaurar esta base de dados
</button>
</div>
</form>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
</div>
<?php if (is_file($layout_footer)) include $layout_footer; ?>
+235
View File
@@ -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>
+306
View File
@@ -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 &rarr; <code>.env</code>.
Onde a aplicação guarda definições em base de dados, essas ganham sobre ambas.
</div>
</div>
<?php if ($canEdit): ?>
<div class="card shadow-sm mb-3">
<div class="card-header"><strong><i class="bi bi-pencil-square me-1"></i>Alterar</strong></div>
<div class="card-body">
<form method="post" class="row g-3">
<?= platform_csrf_field() ?>
<input type="hidden" name="action" value="save">
<div class="col-md-8">
<label class="form-label small">Servidor SMTP</label>
<input type="text" name="smtp_host" class="form-control form-control-sm"
value="<?= htmlspecialchars($cfg['host']) ?>" placeholder="mail.exemplo.pt">
<div class="form-text">No cPanel é tipicamente <code>mail.&lt;dominio&gt;</code>.</div>
</div>
<div class="col-md-2">
<label class="form-label small">Porta</label>
<input type="number" name="smtp_port" class="form-control form-control-sm"
value="<?= (int) $cfg['port'] ?>">
</div>
<div class="col-md-2">
<label class="form-label small">Cifra</label>
<select name="smtp_secure" class="form-select form-select-sm">
<?php foreach (['tls' => 'TLS', 'ssl' => 'SSL', '' => 'Nenhuma'] as $v => $lbl): ?>
<option value="<?= $v ?>" <?= $cfg['secure'] === $v ? 'selected' : '' ?>><?= $lbl ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-6">
<label class="form-label small">Utilizador</label>
<input type="text" name="smtp_username" class="form-control form-control-sm"
value="<?= htmlspecialchars($cfg['username']) ?>" autocomplete="off">
<div class="form-text">Normalmente o endereço de email completo.</div>
</div>
<div class="col-md-6">
<label class="form-label small">Palavra-passe</label>
<input type="password" name="smtp_password" class="form-control form-control-sm"
placeholder="<?= $cfg['password'] !== '' ? '•••••••• (definida — deixe vazio para manter)' : 'por definir' ?>"
autocomplete="new-password">
<?php if ($cfg['password'] !== ''): ?>
<div class="form-check mt-1">
<input class="form-check-input" type="checkbox" name="smtp_password_clear" id="pwClear" value="1">
<label class="form-check-label small text-muted" for="pwClear">Apagar a palavra-passe guardada</label>
</div>
<?php endif; ?>
</div>
<div class="col-md-6">
<label class="form-label small">Remetente (From)</label>
<input type="email" name="email_from" class="form-control form-control-sm"
value="<?= htmlspecialchars($cfg['from']) ?>" placeholder="noreply@exemplo.pt">
<div class="form-text">Tem de ser a conta autenticada acima, senão SPF/DMARC rejeitam.</div>
</div>
<div class="col-md-6">
<label class="form-label small">Nome do remetente</label>
<input type="text" name="email_from_name" class="form-control form-control-sm"
value="<?= htmlspecialchars($cfg['from_name']) ?>" placeholder="A Minha Plataforma">
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-check-lg me-1"></i>Guardar
</button>
<span class="small text-muted ms-2">
Guardado em base de dados, que ganha ao <code>.env</code>. Teste a ligação a seguir.
</span>
</div>
</form>
</div>
</div>
<?php endif; ?>
<div class="card shadow-sm">
<div class="card-header"><strong><i class="bi bi-activity me-1"></i>Testes</strong></div>
<div class="card-body d-flex flex-wrap gap-3 align-items-end">
<form method="POST" class="m-0">
<?= platform_csrf_field() ?><input type="hidden" name="action" value="test_connection">
<button type="submit" class="btn btn-outline-primary btn-sm" <?= $cfg['host'] === '' ? 'disabled' : '' ?>>
<i class="bi bi-plug me-1"></i>Testar ligação
</button>
<div class="form-text">Abre e fecha uma ligação. Não envia nada.</div>
</form>
<form method="POST" class="m-0 d-flex gap-2 align-items-end flex-wrap">
<?= platform_csrf_field() ?><input type="hidden" name="action" value="send_test">
<div>
<label class="form-label small mb-1" for="smtp_to">Enviar mensagem de teste para</label>
<input type="email" name="to" id="smtp_to" class="form-control form-control-sm"
style="min-width:260px;" placeholder="alguem@exemplo.pt" required>
</div>
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-send me-1"></i>Enviar
</button>
</form>
</div>
<div class="card-footer small text-muted">
<i class="bi bi-info-circle me-1"></i>
Um teste bem sucedido significa que o servidor SMTP <em>aceitou</em> a mensagem —
não que ela chegou à caixa de entrada. Rejeições por SPF, DKIM ou reputação acontecem depois.
</div>
</div>
</div>
<?php if (is_file($layout_footer)) include $layout_footer; ?>
+159
View File
@@ -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'] ?? []),
];
}
+745
View File
@@ -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,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
/* ── Dynamic items (platform content endpoint) ─────────────── */
let dynSelected = new Set((CFG.presel || []).filter(id => (CFG.dynamic || []).some(d => d.id === id)));
let dynCatValue = ''; // active category filter ('' = all)
// Items visible under the current category filter (selection persists across
// filter changes — hidden-but-selected items stay in the post).
function dynVisible() {
return (CFG.dynamic || []).filter(d => !dynCatValue || (d.category || '') === dynCatValue);
}
// Build the category dropdown from the distinct categories present in the
// dynamic items (first-seen order = the platform's order). Hidden when <2.
function buildCatFilter() {
const sel = document.getElementById('dynCatFilter');
const bar = document.getElementById('dynFilterBar');
if (!sel || !bar) return;
const seen = new Map();
(CFG.dynamic || []).forEach(d => {
const k = d.category || '';
if (k && !seen.has(k)) seen.set(k, d.category_label || k);
});
if (seen.size < 2) { bar.style.display = 'none'; return; }
let html = `<option value="">Todos · ${esc(CFG.categoryLabel || 'Categoria')}</option>`;
seen.forEach((lbl, k) => { html += `<option value="${esc(k)}">${esc(lbl)}</option>`; });
sel.innerHTML = html;
sel.value = dynCatValue;
bar.style.display = '';
}
function onCatFilter(v) { dynCatValue = v; renderDynList(); }
function renderDynList() {
const list = document.getElementById('dynList');
if (!list) return;
list.innerHTML = dynVisible().map(d => {
const dp = d.date_start ? dateParts(d.date_start.slice(0, 10)) : null;
return `<label class="list-group-item list-group-item-action d-flex align-items-center gap-3 py-2" style="cursor:pointer;">
<input type="checkbox" class="form-check-input flex-shrink-0 mt-0" data-dyn-id="${esc(d.id)}"
${dynSelected.has(d.id) ? 'checked' : ''}>
<div class="flex-grow-1 min-w-0">
<div class="small fw-medium text-truncate">${esc(d.title)}</div>
<div class="text-muted" style="font-size:.78rem;">
${dp ? `${dp.dayName}, ${dp.dayNum} ${dp.month}` : ''}${d.subtitle ? ` · ${esc(d.subtitle)}` : ''}
</div>
</div>
</label>`;
}).join('');
list.querySelectorAll('input[data-dyn-id]').forEach(cb => {
cb.addEventListener('change', () => {
cb.checked ? dynSelected.add(cb.dataset.dynId) : dynSelected.delete(cb.dataset.dynId);
rebuildSlides();
});
});
}
function dynToggleAll() {
// Operates on the currently-visible (filtered) items only.
const visible = dynVisible();
const everyOn = visible.length > 0 && visible.every(d => dynSelected.has(d.id));
visible.forEach(d => everyOn ? dynSelected.delete(d.id) : dynSelected.add(d.id));
document.getElementById('dynSelectAllBtn').textContent = everyOn ? 'Selecionar todos' : 'Limpar';
renderDynList();
rebuildSlides();
}
// Selected dynamic items, normalized to the same shape as manual items.
function dynamicItems() {
return (CFG.dynamic || [])
.filter(d => dynSelected.has(d.id))
.map(d => ({
id: 'dyn:' + d.id,
title: d.title,
subtitle: d.subtitle || '',
date: d.date_start ? d.date_start.slice(0, 10) : '',
}));
}
/* ── Lazy gallery browser (photo_session content, Fase 4) ──── */
// Sessions render collapsed (title + count only); the thumbnail images are
// only attached on first expand, so a platform with many galleries doesn't
// fetch hundreds of photos on page load. The same accordion is used in two
// places: the Fundo card (mode 'bg': click = use photo as background) and
// the photo-templates picker (mode 'multi': numbered multi-select).
let selectedPhotos = []; // [{url, title, subtitle, date}], in selection order
function initGalleryAccordion(containerId, mode) {
const box = document.getElementById(containerId);
if (!box) return;
box.innerHTML = (CFG.dynamicPhoto || []).map(s => `
<div class="border rounded mb-1">
<button type="button" class="ig-gal-head" data-session="${esc(s.id)}">
<span class="text-truncate">${esc(s.title)}${s.date ? ' · ' + esc(s.date) : ''}</span>
<span class="text-muted flex-shrink-0">${s.media.length} <i class="bi bi-chevron-down"></i></span>
</button>
<div class="ig-gal-body d-flex gap-2 flex-wrap p-2 d-none" data-loaded="0"></div>
</div>`).join('');
box.querySelectorAll('.ig-gal-head').forEach(head => {
head.addEventListener('click', () => {
const body = head.nextElementSibling;
const opening = body.classList.contains('d-none');
if (opening && body.dataset.loaded !== '1') {
const s = (CFG.dynamicPhoto || []).find(x => String(x.id) === head.dataset.session);
body.innerHTML = (s ? s.media : []).map(m => `
<div class="ig-photo-pick" data-session="${esc(s.id)}" data-url="${esc(m.url)}"
style="background-image:url('${esc(m.thumb)}')">
<span class="ig-photo-pick-num"></span>
</div>`).join('');
body.dataset.loaded = '1';
body.querySelectorAll('.ig-photo-pick').forEach(el => {
el.addEventListener('click', () =>
mode === 'multi' ? togglePhoto(el) : pickPhotoAsBg(el));
});
if (mode === 'multi') syncPhotoPicker();
}
body.classList.toggle('d-none', !opening);
const icon = head.querySelector('.bi');
if (icon) icon.className = opening ? 'bi bi-chevron-up' : 'bi bi-chevron-down';
});
});
}
// Fundo card: a session photo as the post background (full-res via proxy).
function pickPhotoAsBg(el) {
clearBgActive();
el.classList.add('active');
currentBg = el.dataset.url;
bgIsImage = true;
applyStyle();
}
function togglePhoto(el) {
const url = el.dataset.url;
const idx = selectedPhotos.findIndex(p => p.url === url);
if (idx >= 0) {
selectedPhotos.splice(idx, 1);
} else {
const session = (CFG.dynamicPhoto || []).find(s => String(s.id) === el.dataset.session);
selectedPhotos.push({
url,
title: session?.title || '',
subtitle: session?.subtitle || '',
date: session?.date || '',
});
}
syncPhotoPicker();
rebuildSlides();
}
function syncPhotoPicker() {
document.querySelectorAll('#photoPicker .ig-photo-pick').forEach(el => {
const idx = selectedPhotos.findIndex(p => p.url === el.dataset.url);
el.classList.toggle('active', idx >= 0);
el.querySelector('.ig-photo-pick-num').textContent = idx >= 0 ? String(idx + 1) : '';
});
const cnt = document.getElementById('photoSelCount');
if (cnt) cnt.textContent = selectedPhotos.length + ' selecionada' + (selectedPhotos.length === 1 ? '' : 's');
}
/* ── Manual items ──────────────────────────────────────────── */
function addItem(data) {
items.push(Object.assign({ id: nextItemId++, title: '', subtitle: '', date: '' }, data || {}));
renderItemList();
rebuildSlides();
}
function removeItem(id) {
items = items.filter(i => i.id !== id);
renderItemList();
rebuildSlides();
}
function onItemField(id, field, value) {
const it = items.find(i => i.id === id);
if (it) { it[field] = value; rebuildSlides(); }
}
function renderItemList() {
const box = document.getElementById('itemList');
document.getElementById('itemsEmpty').style.display = items.length ? 'none' : '';
box.innerHTML = items.map(it => `
<div class="border rounded p-2">
<div class="d-flex gap-2 mb-1">
<input type="text" class="form-control form-control-sm" placeholder="Título *"
value="${esc(it.title)}" oninput="onItemField(${it.id},'title',this.value)">
<button type="button" class="btn btn-sm btn-outline-danger py-0 px-2"
onclick="removeItem(${it.id})" title="Remover"><i class="bi bi-x-lg"></i></button>
</div>
<div class="d-flex gap-2">
<input type="text" class="form-control form-control-sm" placeholder="Subtítulo / local"
value="${esc(it.subtitle)}" oninput="onItemField(${it.id},'subtitle',this.value)">
<input type="date" class="form-control form-control-sm" style="max-width:150px;"
value="${esc(it.date)}" oninput="onItemField(${it.id},'date',this.value)">
</div>
</div>`).join('');
}
function dateParts(iso) {
if (!iso) return null;
const d = new Date(iso + 'T12:00:00');
if (isNaN(d)) return null;
return {
dayName: DAY_NAMES[d.getDay()],
dayNum: String(d.getDate()),
month: MONTH_NAMES[d.getMonth()],
year: String(d.getFullYear()),
};
}
/* ── Template / format ─────────────────────────────────────── */
function currentTemplate() {
const slug = document.querySelector('input[name="tplPick"]:checked')?.value;
return CFG.templates.find(t => t.slug === slug) || CFG.templates[0];
}
function currentFormatId() {
return document.querySelector('input[name="postFormat"]:checked').value;
}
// Export dimensions for the active format, honoring the paper orientation swap.
function formatDims() {
const id = currentFormatId();
const f = CFG.formats[id];
let w = f.w, h = f.h;
if (f.group === 'paper' && document.getElementById('orientSwap').checked) {
[w, h] = [h, w];
}
return { id, w, h, group: f.group };
}
function maxItemsPerSlide() {
const tpl = currentTemplate();
const id = currentFormatId();
return tpl.maxItems[id] || tpl.maxItems['default'] || 1;
}
function isPhotoTemplate(tpl) {
return (tpl.itemTypes || []).includes('photo_session');
}
function toggleCard(id, show) {
const el = document.getElementById(id);
if (el) el.style.display = show ? '' : 'none';
}
function onTemplateChange() {
const tpl = currentTemplate();
const photo = isPhotoTemplate(tpl);
const isGrid = tpl.slug === 'photo-grid';
// The heading/tagline inputs only apply to the list template; the single
// announcement and photo templates take their caption from the item itself.
const single = !tpl.multi || photo;
document.getElementById('headingWrap').style.display = single ? 'none' : '';
document.getElementById('taglineWrap').style.display = single ? 'none' : '';
toggleCard('dynContentCard', !photo);
toggleCard('manualContentCard', !photo);
toggleCard('photoCard', photo);
toggleCard('fundoCard', !isGrid);
toggleCard('estiloCard', !isGrid);
toggleCard('textoCard', !isGrid);
rebuildSlides();
}
function onFormatChange() {
const { w, h, group } = formatDims();
document.getElementById('orientWrap').style.display = group === 'paper' ? '' : 'none';
const post = document.getElementById('postPreview');
post.style.aspectRatio = w + ' / ' + h;
document.getElementById('previewWrapper').classList.toggle('is-wide', w > h);
rebuildSlides();
}
/* ── Slides (overflow pagination) ──────────────────────────── */
function rebuildSlides() {
const tpl = currentTemplate();
const usable = isPhotoTemplate(tpl)
? selectedPhotos
: dynamicItems().concat(items.filter(i => i.title.trim() !== ''));
slides = SocialEngine.chunk(usable, maxItemsPerSlide());
if (!slides.length) slides = [[]];
if (currentSlide >= slides.length) currentSlide = slides.length - 1;
renderSlideNav();
updateSlideVisuals();
}
function renderSlideNav() {
const bar = document.getElementById('slideToolbar');
const dots = document.getElementById('slideDots');
bar.style.display = slides.length > 1 ? '' : 'none';
dots.innerHTML = '';
slides.forEach((_, i) => {
const dot = document.createElement('button');
dot.type = 'button';
dot.className = 'ig-slide-dot' + (i === currentSlide ? ' active' : '');
dot.addEventListener('click', () => { currentSlide = i; renderSlideNav(); updateSlideVisuals(); });
dots.appendChild(dot);
});
}
function slidePrev() { if (currentSlide > 0) { currentSlide--; renderSlideNav(); updateSlideVisuals(); } }
function slideNext() { if (currentSlide < slides.length - 1) { currentSlide++; renderSlideNav(); updateSlideVisuals(); } }
/* ── Slide rendering (templates) ───────────────────────────── */
function logoRowHtml() {
if (!CFG.logo || !document.getElementById('showLogo')?.checked) return '';
return `<div class="ig-logo-row"><img src="${esc(CFG.logo)}" alt=""></div>`;
}
function itemCardHtml(it) {
const dp = dateParts(it.date);
return `<div class="ig-session-card">
${dp ? `<div class="ig-date-block">
<div class="ig-date-dayname">${dp.dayName}</div>
<div class="ig-date-num">${dp.dayNum}</div>
<div class="ig-date-month">${dp.month}</div>
</div>
<div class="ig-card-divider"></div>` : ''}
<div class="ig-session-info">
<div class="ig-session-type">${esc(it.title)}</div>
${it.subtitle ? `<div class="ig-session-avail">${esc(it.subtitle)}</div>` : ''}
</div>
</div>`;
}
function renderSlide() {
const content = document.getElementById('postContent');
const tpl = currentTemplate();
const slide = slides[currentSlide] || [];
const cta = document.getElementById('ctaText').value.trim();
let html = logoRowHtml();
if (tpl.slug === 'photo-grid') {
content.innerHTML = '';
return;
}
if (tpl.slug === 'photo-highlight') {
const it = slide[0];
if (!it) {
html += '<p class="ig-empty-msg">Seleciona uma foto à esquerda</p>';
} else {
const dp = dateParts(it.date);
html += `<div class="ig-announce">
${dp ? `<p class="ig-announce-date">${dp.dayName} · ${dp.dayNum} ${dp.month} ${dp.year}</p>` : ''}
<h2 class="ig-announce-title">${esc(it.title)}</h2>
${it.subtitle ? `<p class="ig-announce-subtitle">${esc(it.subtitle)}</p>` : ''}
<div class="ig-heading-divider"></div>
</div>`;
}
} else if (tpl.slug === 'single-announcement') {
const it = slide[0];
if (!it) {
html += '<p class="ig-empty-msg">Adiciona um item à esquerda</p>';
} else {
const dp = dateParts(it.date);
html += `<div class="ig-announce">
${dp ? `<p class="ig-announce-date">${dp.dayName} · ${dp.dayNum} ${dp.month} ${dp.year}</p>` : ''}
<h2 class="ig-announce-title">${esc(it.title)}</h2>
${it.subtitle ? `<p class="ig-announce-subtitle">${esc(it.subtitle)}</p>` : ''}
<div class="ig-heading-divider"></div>
</div>`;
}
} else { // event-list
const tagline = document.getElementById('tagline').value.trim();
const heading = document.getElementById('headingText').value.trim();
html += `<div class="ig-title-block">
${tagline ? `<p class="ig-tagline">${esc(tagline)}</p>` : ''}
${heading ? `<h2 class="ig-heading">${esc(heading)}</h2><div class="ig-heading-divider"></div>` : ''}
</div>
<div class="ig-sessions">
${slide.length ? slide.map(itemCardHtml).join('') : '<p class="ig-empty-msg">Adiciona itens à esquerda</p>'}
</div>`;
}
if (cta) html += `<div class="ig-cta-row"><span class="ig-cta">${esc(cta)}</span></div>`;
content.innerHTML = html;
}
/* ── Background / style ────────────────────────────────────── */
function luminance(hex) {
const m = /^#([0-9a-f]{6})$/i.exec(hex || '');
if (!m) return 0;
const n = parseInt(m[1], 16);
return (0.2126 * (n >> 16) + 0.7152 * ((n >> 8) & 255) + 0.0722 * (n & 255)) / 255;
}
// The displayed background: the user's pick, except for photo-highlight where
// the current slide's selected photo always wins.
function effectiveBg() {
const tpl = currentTemplate();
if (tpl.slug === 'photo-highlight') {
const it = (slides[currentSlide] || [])[0];
return { bg: it ? it.url : null, isImage: !!it };
}
return { bg: currentBg, isImage: bgIsImage };
}
function applyStyle() {
const post = document.getElementById('postPreview');
const bg = document.getElementById('photoBg');
const { bg: effBg, isImage: effIsImage } = effectiveBg();
if (effIsImage && effBg) {
bg.style.display = '';
bg.style.backgroundImage = "url('" + effBg.replace(/'/g, "\\'") + "')";
bg.style.filter = blurAmount > 0 ? `blur(${blurAmount}px) brightness(.85)` : '';
bg.style.transform = blurAmount > 0 ? `scale(${1 + blurAmount / 280})` : '';
post.style.background = '';
post.classList.add('is-photo');
post.classList.remove('is-light');
} else {
bg.style.display = 'none';
bg.style.backgroundImage = '';
bg.style.filter = '';
bg.style.transform = '';
post.style.background = effBg || '';
post.classList.remove('is-photo');
// Solid light colors flip the text palette + drop the dark wash.
post.classList.toggle('is-light', !!effBg && luminance(effBg) > 0.72);
}
document.getElementById('blurWrap').style.display = effIsImage ? '' : 'none';
const ov = document.getElementById('customOverlay');
if ((overlaySource === 'gradient' || overlaySource === 'solid') && overlayValue) {
ov.style.background = overlayValue;
ov.style.opacity = (overlayOpacity / 100).toString();
} else {
ov.style.background = '';
ov.style.opacity = '0';
}
post.classList.toggle('has-watermark', !!document.getElementById('showWatermark')?.checked);
const content = document.getElementById('postContent');
content.dataset.alignH = document.querySelector('input[name="alignH"]:checked').value;
content.dataset.alignV = document.querySelector('input[name="alignV"]:checked').value;
renderSlide(); // logo toggle lives inside slide markup
}
// Switches the canvas between the photo-grid layout and the normal
// background+content layout, then applies the current style.
function updateSlideVisuals() {
const tpl = currentTemplate();
const grid = document.getElementById('photoGrid');
const content = document.getElementById('postContent');
const overlay = document.querySelector('.ig-overlay');
if (tpl.slug === 'photo-grid') {
const slide = slides[currentSlide] || [];
grid.style.display = 'grid';
content.style.display = 'none';
overlay.style.display = 'none';
document.getElementById('photoBg').style.display = 'none';
document.getElementById('customOverlay').style.opacity = '0';
const cols = slide.length <= 1 ? 1 : (slide.length <= 4 ? 2 : 3);
grid.style.gridTemplateColumns = `repeat(${cols}, 1fr)`;
grid.innerHTML = slide.length
? slide.map(p => `<div class="ig-grid-cell" style="background-image:url('${esc(p.url)}')"></div>`).join('')
: '<div class="ig-grid-cell ig-grid-empty">Seleciona fotos à esquerda</div>';
return;
}
grid.style.display = 'none';
content.style.display = '';
overlay.style.display = '';
applyStyle();
}
function onBlurChange() {
blurAmount = parseInt(document.getElementById('blurRange').value, 10) || 0;
document.getElementById('blurValue').textContent = blurAmount + ' px';
applyStyle();
}
function onAlignChange() { applyStyle(); }
function onOverlayChange() {
overlaySource = document.querySelector('input[name="overlaySource"]:checked').value;
const cur = overlayValue || '';
if (overlaySource === 'gradient' && !cur.startsWith('linear-gradient') && !cur.startsWith('radial-gradient')) {
overlayValue = CFG.presets.gradients[0].css;
} else if (overlaySource === 'solid' && (cur === '' || cur.includes('gradient'))) {
overlayValue = CFG.presets.solids[0].css;
}
const hasPreset = overlaySource !== 'none';
document.getElementById('ovGradientWrap').style.display = overlaySource === 'gradient' ? '' : 'none';
document.getElementById('ovSolidWrap').style.display = overlaySource === 'solid' ? '' : 'none';
document.getElementById('ovOpacityWrap').style.display = hasPreset ? '' : 'none';
markActive('#ovGradientList .ig-preset, #ovSolidList .ig-preset', overlayValue);
applyStyle();
}
function onOverlayOpacityChange() {
overlayOpacity = parseInt(document.getElementById('ovOpacity').value, 10) || 0;
document.getElementById('ovOpacityValue').textContent = overlayOpacity + ' %';
applyStyle();
}
function markActive(selector, css) {
document.querySelectorAll(selector).forEach(b => b.classList.toggle('active', b.dataset.bg === css));
}
function clearBgActive() {
document.querySelectorAll('#gradientList .ig-preset, #solidList .ig-preset, .ig-img-preset, #bgGalleryList .ig-photo-pick')
.forEach(b => b.classList.remove('active'));
}
function renderPresets(containerId, presets, onPick) {
const el = document.getElementById(containerId);
el.innerHTML = presets.map(p => `
<button type="button" class="ig-preset" data-bg="${esc(p.css)}"
style="background:${esc(p.css)}" title="${esc(p.name)}"></button>`).join('');
el.querySelectorAll('.ig-preset').forEach(b => b.addEventListener('click', () => onPick(b)));
}
function pickBgPreset(btn) {
clearBgActive();
btn.classList.add('active');
currentBg = btn.dataset.bg;
bgIsImage = false;
applyStyle();
}
function pickOverlayPreset(btn) {
overlayValue = btn.dataset.bg;
markActive('#ovGradientList .ig-preset, #ovSolidList .ig-preset', overlayValue);
applyStyle();
}
/* Image backgrounds (gallery + local file) */
function bindImgPreset(btn) {
btn.addEventListener('click', () => {
clearBgActive();
btn.classList.add('active');
currentBg = btn.dataset.img;
bgIsImage = true;
applyStyle();
});
}
document.getElementById('imageInput').addEventListener('change', function () {
if (this.files[0]) readLocalImage(this.files[0]);
});
const dropZone = document.getElementById('dropZone');
['dragenter','dragover'].forEach(ev => dropZone.addEventListener(ev, e => {
e.preventDefault(); dropZone.classList.add('dragging');
}));
['dragleave','drop'].forEach(ev => dropZone.addEventListener(ev, () => dropZone.classList.remove('dragging')));
dropZone.addEventListener('drop', e => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith('image/')) readLocalImage(file);
});
function readLocalImage(file) {
const reader = new FileReader();
reader.onload = e => {
clearBgActive();
currentBg = e.target.result;
bgIsImage = true;
applyStyle();
};
reader.readAsDataURL(file);
}
/* Gallery upload / delete */
document.getElementById('bgUploadInput').addEventListener('change', function () {
const file = this.files[0];
if (!file) return;
const icon = document.getElementById('bgUploadIcon');
icon.className = 'bi bi-arrow-clockwise ig-spin';
const fd = new FormData();
fd.append('action', 'upload_bg');
fd.append('app', CFG.app);
fd.append('bg_image', file);
fetch(window.location.pathname + '?app=' + encodeURIComponent(CFG.app), {
method: 'POST', headers: { 'X-CSRF-Token': CSRF_TOKEN }, body: fd,
})
.then(r => r.json())
.then(data => {
if (data.status !== 'success') { alert(data.message); return; }
document.getElementById('noPresetsHint')?.remove();
const list = document.getElementById('bgPresetList');
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'ig-img-preset';
btn.dataset.img = data.url;
btn.dataset.id = data.id;
btn.style.backgroundImage = "url('" + data.url + "')";
btn.innerHTML = '<span class="ig-img-del" title="Eliminar" onclick="deleteBg(event,' + data.id + ')">&times;</span>';
bindImgPreset(btn);
list.appendChild(btn);
const count = document.getElementById('bgCount');
count.textContent = (parseInt(count.textContent, 10) || 0) + 1;
btn.click();
})
.catch(() => alert('Erro ao guardar a imagem.'))
.finally(() => { icon.className = 'bi bi-plus-circle'; this.value = ''; });
});
function deleteBg(ev, id) {
ev.stopPropagation();
if (!confirm('Eliminar este fundo da galeria?')) return;
const fd = new FormData();
fd.append('action', 'delete_bg');
fd.append('app', CFG.app);
fd.append('bg_id', id);
fetch(window.location.pathname + '?app=' + encodeURIComponent(CFG.app), {
method: 'POST', headers: { 'X-CSRF-Token': CSRF_TOKEN }, body: fd,
}).then(() => {
document.querySelector('.ig-img-preset[data-id="' + id + '"]')?.remove();
const count = document.getElementById('bgCount');
count.textContent = Math.max(0, (parseInt(count.textContent, 10) || 1) - 1);
});
}
/* ── Export ────────────────────────────────────────────────── */
function exportFileName(n) {
const tpl = currentTemplate().slug;
const date = new Date().toISOString().slice(0, 10);
return `${CFG.app}_${tpl}_${date}_${n}.jpg`;
}
function blurOpts() {
const { bg, isImage } = effectiveBg();
if (!isImage || !(blurAmount > 0)) return null;
return { bgEl: document.getElementById('photoBg'), photoUrl: bg, amount: blurAmount };
}
async function exportCurrent(btn) {
const { w } = formatDims();
btn.disabled = true;
try {
const blob = await SocialEngine.renderBlob(document.getElementById('postPreview'), w, { blur: blurOpts() });
SocialEngine.saveBlob(blob, exportFileName(currentSlide + 1));
} catch (err) {
console.error(err);
alert('Erro ao gerar a imagem: ' + (err?.message || err));
} finally {
btn.disabled = false;
}
}
async function exportAll(btn) {
const { w } = formatDims();
const original = currentSlide;
btn.disabled = true;
try {
const files = [];
for (let i = 0; i < slides.length; i++) {
currentSlide = i;
renderSlideNav();
updateSlideVisuals();
// Give the DOM a frame to settle before capturing.
await new Promise(r => requestAnimationFrame(() => setTimeout(r, 60)));
const blob = await SocialEngine.renderBlob(document.getElementById('postPreview'), w, { blur: blurOpts() });
files.push({ blob, name: exportFileName(i + 1) });
}
const date = new Date().toISOString().slice(0, 10);
await SocialEngine.saveAll(files, `${CFG.app}_${currentTemplate().slug}_${date}.zip`);
} catch (err) {
console.error(err);
alert('Erro ao exportar: ' + (err?.message || err));
} finally {
currentSlide = original;
renderSlideNav();
updateSlideVisuals();
btn.disabled = false;
}
}
/* ── Screenshot mode ───────────────────────────────────────── */
function enterScreenshotMode() {
const { w, h } = formatDims();
const dest = document.getElementById('screenshotPost');
const vw = window.innerWidth, vh = window.innerHeight;
if (vh * w / h <= vw) {
dest.style.height = '100dvh';
dest.style.width = `calc(100dvh * ${w} / ${h})`;
} else {
dest.style.width = '100vw';
dest.style.height = `calc(100vw * ${h} / ${w})`;
}
dest.innerHTML = '';
const clone = document.getElementById('postPreview').cloneNode(true);
clone.removeAttribute('id');
clone.style.aspectRatio = w + ' / ' + h;
dest.appendChild(clone);
const overlay = document.getElementById('screenshotOverlay');
const closeBtn = document.getElementById('screenshotCloseBtn');
overlay.style.display = 'flex';
document.body.style.overflow = 'hidden';
closeBtn.style.opacity = '1';
clearTimeout(window._scTimer);
window._scTimer = setTimeout(() => { closeBtn.style.opacity = '0'; }, 2000);
}
function exitScreenshotMode() {
document.getElementById('screenshotOverlay').style.display = 'none';
document.body.style.overflow = '';
}
document.getElementById('screenshotOverlay').addEventListener('click', function (e) {
if (e.target === this) exitScreenshotMode();
});
/* ── Init ──────────────────────────────────────────────────── */
renderPresets('gradientList', CFG.presets.gradients, pickBgPreset);
renderPresets('solidList', CFG.presets.solids, pickBgPreset);
renderPresets('ovGradientList', CFG.presets.gradients, pickOverlayPreset);
renderPresets('ovSolidList', CFG.presets.solids, pickOverlayPreset);
document.querySelectorAll('#bgPresetList .ig-img-preset').forEach(bindImgPreset);
// Default background: first brand gradient.
const firstGrad = document.querySelector('#gradientList .ig-preset');
if (firstGrad) { firstGrad.classList.add('active'); currentBg = firstGrad.dataset.bg; }
buildCatFilter();
renderDynList();
initGalleryAccordion('bgGalleryList', 'bg');
initGalleryAccordion('photoPicker', 'multi');
if (!(CFG.dynamic || []).length) addItem(); // empty manual row only without dynamic source
onTemplateChange(); // sets heading visibility + builds slides
onFormatChange(); // sets aspect ratio
applyStyle();
@@ -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; }
+198
View File
@@ -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; ?>