diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..4fcd2e0 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "shared/platform-tools"] + path = shared/platform-tools + url = ../platform-tools.git + branch = main diff --git a/PLATFORM_TOOLS.md b/PLATFORM_TOOLS.md new file mode 100644 index 0000000..d663c2f --- /dev/null +++ b/PLATFORM_TOOLS.md @@ -0,0 +1,33 @@ +# shared/platform-tools is a git submodule (again) + +Was vendored as a plain committed directory from 2026-09-24 to 2026-09-25 +(`VENDORED.md`, since removed — its content is folded in below). Switched back +to a proper submodule on 2026-09-25 once the two concrete problems that broke +it the first time were fixed: + +- `.gitmodules` now pins `branch = main` explicitly. The old submodule had no + branch, so "Atualizar submódulo" fell back to `refs/remotes/origin/HEAD` — a + ref a plain server-side clone never creates — and failed with + `Unable to find refs/remotes/origin/HEAD revision`. +- The submodule URL is `../platform-tools.git` (relative, resolves to + `https://git.andmore.pt/andre/platform-tools.git`), not `http://`. The old + one used `http://`, which the origin answers with 523. + +The third original reason ("the site is deployed by uploading files, so a +submodule is invisible to it") doesn't apply to fitme's actual deploy method — +`deploy.sh` does `git pull --ff-only` — so it no longer justifies staying +vendored once the other two are fixed. `deploy.sh` now runs +`git submodule update --init --recursive` after the pull, matching every other +`config/`-based platform in the fleet (fotografia, dsa, moremio, tickets). + +**Before converting back (2026-09-25):** verified the vendored copy was +byte-identical to upstream `platform-tools@12f26fd` (the SHA it was frozen at) +— only difference was CRLF→LF normalization in four `social/` files, already +noted in the old `VENDORED.md`. Nothing site-specific was hand-patched into +the vendored copy, so nothing was lost by re-pointing at the submodule pinned +to that same commit. + +To bump the submodule to a newer platform-tools commit: `git submodule update +--remote shared/platform-tools && git add shared/platform-tools && git commit` +(see the platform-tools README, "Wiring up a platform", step 6) — same as +fotografia. diff --git a/shared/platform-tools b/shared/platform-tools new file mode 160000 index 0000000..12f26fd --- /dev/null +++ b/shared/platform-tools @@ -0,0 +1 @@ +Subproject commit 12f26fd1cba8ce3229c598ac2f2e9a788cd7cf75 diff --git a/shared/platform-tools/.gitignore b/shared/platform-tools/.gitignore deleted file mode 100644 index 947adba..0000000 --- a/shared/platform-tools/.gitignore +++ /dev/null @@ -1,36 +0,0 @@ -# OS -.DS_Store -Thumbs.db - -# Editor / IDE -.vscode/ -.idea/ -*.swp -*.swo - -# Secrets -.env -.env.local -.env.*.local - -# Logs -*.log - -# AI assistant files — not part of the project -CLAUDE.md -AGENTS.md -.claude/ -GEMINI.md -.gemini/ -.cursorrules -.cursorignore -.aider* -AIDER.md -COPILOT*.md - -# Índice de países da analítica: ~5 MB, gerado por bin/geo-build-index.php. -# Não é versionado -- um binário deste tamanho refrescado mensalmente inchava o -# histórico. Sem ele a analítica regista o país como desconhecido, que é um -# degradar aceitável; as listas do BLOQUEIO por país (pt.v4/pt.v6) essas ficam -# versionadas, porque aí dados em falta seriam uma decisão de segurança. -data/geo/country-index.* diff --git a/shared/platform-tools/ASSETS.md b/shared/platform-tools/ASSETS.md deleted file mode 100644 index 128a0c2..0000000 --- a/shared/platform-tools/ASSETS.md +++ /dev/null @@ -1,80 +0,0 @@ -# Assets de front-end na frota andmore - -Convenção fixada a 2026-09-01, quando os oito sites deixaram de carregar -JavaScript e CSS de CDNs. - -## A regra - -**Nada de front-end sai para a rede em produção.** Tudo é servido do próprio -domínio, está no git, e tem a versão no nome da pasta. - - public/vendor/-/… (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-/{css,js}/ - twbs/bootstrap-icons → bootstrap-icons-/ (+ fonts/) - components/jquery → jquery-/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 ` - - - diff --git a/shared/platform-tools/backups/backup_maker.php b/shared/platform-tools/backups/backup_maker.php deleted file mode 100644 index b95dbfe..0000000 --- a/shared/platform-tools/backups/backup_maker.php +++ /dev/null @@ -1,185 +0,0 @@ -/shared/platform-tools/backups/backup_maker.php - * → $sourceDir = / (default: uploads; ver config.php) - * → $backupDir = /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); // -$backupDir = $siteRoot . '/backups'; // /backups -if (!is_dir($backupDir)) @mkdir($backupDir, 0775, true); -$siteRoot = realpath($siteRoot) ?: $siteRoot; -$backupDir = realpath($backupDir) ?: $backupDir; -backup_dir_protect($backupDir); - -// BACKUP_FILES_DIR='' is an explicit opt-out, not a misconfiguration: some -// hosts keep no local user content at all (surf serves media from Nextcloud -// over WebDAV), so there is genuinely nothing to archive. Report and exit 0 -// rather than die() -- a scheduled run must not be marked failed for it. -if (trim(BACKUP_FILES_DIR) === '') { - echo "Backup de ficheiros desactivado (BACKUP_FILES_DIR vazio) — este host não guarda ficheiros localmente.\n"; - return; -} - -// BACKUP_FILES_DIR aceita uma LISTA separada por vírgulas, não só uma pasta. -// -// Era uma única pasta, e isso perdia dados em silêncio: o design guarda os -// modelos 3D em public/threed/ e o resto em public/uploads/, por isso um -// backup "de ficheiros" trazia metade do conteúdo do utilizador e ninguém -// reparava até tentar restaurar. Uma pasta continua a ser válido -- a maioria -// dos sites tem só uma. -$requestedDirs = array_values(array_filter(array_map( - fn($d) => trim($d, " \t/"), - explode(',', BACKUP_FILES_DIR) -), fn($d) => $d !== '')); - -$sourceDirs = []; // rótulo relativo => caminho absoluto -foreach ($requestedDirs as $rel) { - $abs = realpath($siteRoot . '/' . $rel) ?: ''; - if ($abs === '' || !is_dir($abs)) { - // Falhar alto: uma pasta configurada que não existe é quase sempre um - // erro de configuração, e continuar em silêncio produz exactamente o - // backup incompleto que isto existe para evitar. - die("Erro: pasta de uploads não encontrada: $siteRoot/$rel" - . " (verifica BACKUP_FILES_DIR no .env).\n"); - } - $sourceDirs[$rel] = $abs; -} -if (!$sourceDirs) { - die("Erro: BACKUP_FILES_DIR não resolveu nenhuma pasta válida.\n"); -} - -// Seconds + random suffix: no same-minute overwrite, and the download URL -// can't be guessed even if the folder deny rule is missing. -$filename = 'backup_' . date('d_m_Y_H_i_s') . '_' . bin2hex(random_bytes(3)) . '.zip'; -$outputPath = "$backupDir/$filename"; - -echo "Raiz do site : $siteRoot\n"; -echo "Pastas origem: " . implode(', ', array_keys($sourceDirs)) . "\n"; -echo "Pasta backup : $backupDir\n"; -echo "Destino ZIP : $outputPath\n\n"; - -if (!class_exists('ZipArchive')) { - die("Erro: extensão ZipArchive não disponível.\n"); -} - -if (!is_writable($backupDir)) { - die("Erro: sem permissão de escrita em: $backupDir\n"); -} - -// --- Criar ZIP -------------------------------------------------------------- -$zip = new ZipArchive(); -$result = $zip->open($outputPath, ZipArchive::CREATE | ZipArchive::OVERWRITE); - -if ($result !== true) { - die("Erro ao criar ZIP (código: $result)\n"); -} - -// Só a pasta de uploads é percorrida — código, vendor/ e afins são -// reconstituíveis a partir do git e ficam de fora por construção. Dentro dos -// uploads NADA é excluído por nome/extensão: tudo ali é conteúdo externo de -// utilizadores (um .zip carregado por um utilizador é dado legítimo). Única -// exclusão: a própria pasta backups/, caso algum host a tenha aninhado aqui. -$skipped = 0; -$added = 0; -$errors = 0; - -foreach ($sourceDirs as $relRoot => $sourceDir) { - -$filter = new RecursiveCallbackFilterIterator( - new RecursiveDirectoryIterator($sourceDir, RecursiveDirectoryIterator::SKIP_DOTS), - function ($file) use ($backupDir, &$skipped): bool { - $realPath = $file->getRealPath(); - if ($realPath === false) { $skipped++; return false; } - - if ($realPath === $backupDir || str_starts_with($realPath, $backupDir . DIRECTORY_SEPARATOR)) { - $skipped++; - return false; - } - return true; - } -); -$iterator = new RecursiveIteratorIterator($filter, RecursiveIteratorIterator::SELF_FIRST); - -// Entrada raiz explícita: o restore extrai na raiz do host, e um backup de -// uma pasta de uploads vazia continua a ser um ZIP válido. -$zip->addEmptyDir($relRoot); - -foreach ($iterator as $file) { - $realPath = $file->getRealPath(); - // Entradas prefixadas com uploads/... para o ZIP extraído na raiz do host - // repor os ficheiros no sítio certo. Ancorado em $sourceDir (não em - // $siteRoot) para funcionar mesmo quando uploads é um symlink para fora - // da raiz do site. - $relativePath = $relRoot . '/' - . str_replace(DIRECTORY_SEPARATOR, '/', substr($realPath, strlen($sourceDir) + 1)); - - if ($file->isDir()) { - $zip->addEmptyDir($relativePath); - } else { - if (!is_readable($realPath)) { - echo "AVISO: sem permissão de leitura — $realPath\n"; - $errors++; - continue; - } - // addFile() streams no close() — não carrega o ficheiro em memória. - if (!$zip->addFile($realPath, $relativePath)) { - echo "AVISO: falha ao adicionar — $realPath\n"; - $errors++; - continue; - } - $added++; - } -} - -} // fim do foreach por pasta configurada - -echo "Âmbito: " . implode('/, ', array_keys($sourceDirs)) . "/ (o código reconstitui-se a partir do git)\n"; - -if (!$zip->close()) { - die("Erro: falha ao finalizar o ficheiro ZIP.\n"); -} - -clearstatcache(true, $outputPath); - -if (!file_exists($outputPath)) { - die("Erro: o ficheiro ZIP não foi criado.\n"); -} - -// --- Resultado -------------------------------------------------------------- -$bytes = filesize($outputPath); -$size = $bytes >= 1024 * 1024 - ? round($bytes / 1024 / 1024, 2) . ' MB' - : round($bytes / 1024, 1) . ' KB'; - -echo "Backup concluído!\n"; -echo "Ficheiro : $outputPath\n"; -echo "Tamanho : $size\n"; -echo "Ficheiros: $added adicionados, $skipped ignorados" . ($errors ? ", $errors erros" : '') . "\n"; - -backup_prune_report(); diff --git a/shared/platform-tools/backups/config.php b/shared/platform-tools/backups/config.php deleted file mode 100644 index b7cb6c6..0000000 --- a/shared/platform-tools/backups/config.php +++ /dev/null @@ -1,82 +0,0 @@ -/shared/platform-tools/backups/config.php - * - * Valores vêm do .env na raiz do projecto (/.env, gitignored). Os - * fallbacks abaixo só são usados se o .env estiver ausente — em produção, - * mete sempre o .env preenchido. - */ - -$host_root = dirname(__DIR__, 3); - -// Three host patterns coexist: -// - fotografia-style : config/env.php exposes env_load(), needs an explicit call -// - dsa/moremio-style: config/env.php auto-loads .env via an IIFE; require is enough -// - surf-style : no config/env.php -- config/db.php loads .env with -// vlucas/phpdotenv, and the workers run without it, so -// they read .env themselves (the fallback below). -// Requiring config/env.php unconditionally fataled on the third layout. -if (is_file($host_root . '/config/env.php')) { - require_once $host_root . '/config/env.php'; - if (function_exists('env_load')) { - env_load($host_root . '/.env'); - } -} - -// Fallback reader for hosts with no config/env.php, and for CLI invocations -// that run before any framework bootstrap: parse .env directly rather than -// assuming something else already did. -if (!function_exists('env')) { - function env(string $key, $default = null) { - $v = getenv($key); - if ($v === false && array_key_exists($key, $_ENV)) $v = $_ENV[$key]; - if ($v !== false && $v !== null) return $v; - - static $parsed = null; - if ($parsed === null) { - $parsed = []; - $f = dirname(__DIR__, 3) . '/.env'; - if (is_file($f) && is_readable($f)) { - foreach (file($f, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { - $line = trim($line); - if ($line === '' || $line[0] === '#' || !str_contains($line, '=')) continue; - [$k, $val] = array_pad(explode('=', $line, 2), 2, ''); - $parsed[trim($k)] = trim(trim($val), " \"'"); - } - } - } - return array_key_exists($key, $parsed) ? $parsed[$key] : $default; - } -} - -define('BACKUP_TOKEN', env('BACKUP_TOKEN', 'replace_me')); -// Retenção avô-pai-filho — ver backup_gfs_keep() em functions_backup.php. -define('BACKUP_KEEP_DAYS', (int) env('BACKUP_KEEP_DAYS', 7)); -define('BACKUP_KEEP_WEEKS', (int) env('BACKUP_KEEP_WEEKS', 4)); -define('BACKUP_KEEP_MONTHS', (int) env('BACKUP_KEEP_MONTHS', 1)); - -// Pasta (relativa à raiz do host) que o backup de ficheiros arquiva. Só os -// uploads têm informação externa — todo o resto é reconstituível a partir do -// git — por isso o default é 'uploads'. Overridável no .env se um host -// guardar os uploads noutro sítio. -define('BACKUP_FILES_DIR', trim((string) env('BACKUP_FILES_DIR', 'uploads'), '/')); - -// Credenciais da base de dados — sem fallbacks reais: se o .env faltar, a -// ligação falha alto em vez de tentar um host/utilizador "mágico". -define('DB_HOST', env('DB_HOST', 'localhost')); -define('DB_PORT', (int) env('DB_PORT', 3306)); -define('DB_NAME', env('DB_NAME', '')); -define('DB_USER', env('DB_USER', '')); -define('DB_PASS', env('DB_PASS', '')); -define('DB_CHARSET', 'utf8mb4'); - -/** Poda avô-pai-filho no fim de um worker (ver backup_prune()), com o relatório no output. */ -function backup_prune_report(): void -{ - $r = backup_prune(); - foreach ($r['deleted'] as $f) echo "Apagado (retenção): $f\n"; - foreach ($r['errors'] as $f) echo "ERRO a apagar: $f\n"; - echo 'Rotação: ' . count($r['deleted']) . ' apagado(s), ' . count($r['kept']) . ' mantido(s) — ' - . backup_retention_summary($r['policy']) . ".\n"; -} diff --git a/shared/platform-tools/backups/db_backup.php b/shared/platform-tools/backups/db_backup.php deleted file mode 100644 index bb13507..0000000 --- a/shared/platform-tools/backups/db_backup.php +++ /dev/null @@ -1,187 +0,0 @@ -/shared/platform-tools/backups/db_backup.php - * → $backupDir = /backups (where the ZIP lands) - * - * Via CLI : php shared/platform-tools/backups/db_backup.php - * Via HTTP: GET shared/platform-tools/backups/db_backup.php?token=SEU_TOKEN - */ - -require_once __DIR__ . '/config.php'; -require_once __DIR__ . '/../functions_backup.php'; - -// --- Autenticação ----------------------------------------------------------- -if (php_sapi_name() !== 'cli') { - $token = $_GET['token'] ?? $_POST['token'] ?? ''; - if (!hash_equals(BACKUP_TOKEN, $token)) { - http_response_code(403); - die(json_encode(['status' => 'error', 'message' => 'Acesso negado.'])); - } - ignore_user_abort(true); - set_time_limit(0); -} - -// --- Configuração ----------------------------------------------------------- -$siteRoot = dirname(__DIR__, 3); -$backupDir = $siteRoot . '/backups'; -if (!is_dir($backupDir)) @mkdir($backupDir, 0775, true); -$backupDir = realpath($backupDir) ?: $backupDir; -backup_dir_protect($backupDir); - -// Sem ZipArchive não há backup: um .sql em claro na pasta backups/ nunca pode -// ser o artefacto final (não aparece na UI, não roda, e é texto simples). -if (!class_exists('ZipArchive')) { - die("Erro: extensão ZipArchive não disponível — backup cancelado.\n"); -} - -// Seconds + random suffix: no same-minute overwrite, unguessable URL. -$sqlFile = 'db_backup_' . date('d_m_Y_H_i_s') . '_' . bin2hex(random_bytes(3)) . '.sql'; -$zipFile = str_replace('.sql', '.zip', $sqlFile); -$sqlPath = $backupDir . '/' . $sqlFile; -$zipPath = $backupDir . '/' . $zipFile; - -echo "Base de dados: " . DB_NAME . "\n"; -echo "Destino ZIP : $zipPath\n\n"; - -if (!is_writable($backupDir)) { - die("Erro: sem permissão de escrita em: $backupDir\n"); -} - -// --- Ligação PDO ------------------------------------------------------------ -try { - $dsn = sprintf('mysql:host=%s;port=%d;dbname=%s;charset=%s', DB_HOST, DB_PORT, DB_NAME, DB_CHARSET); - $pdo = new PDO($dsn, DB_USER, DB_PASS, [ - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, - PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, - ]); -} catch (PDOException $e) { - die("Erro de ligação à base de dados: " . $e->getMessage() . "\n"); -} - -// --- Gerar dump SQL (streaming) ---------------------------------------------- -// Escreve o dump diretamente para o .sql à medida que lê — nunca constrói a -// base de dados inteira numa string PHP. As leituras de dados usam queries -// não-bufferizadas (uma de cada vez, cursor fechado antes da query seguinte), -// por isso o pico de memória é ~1 batch de 500 linhas, independentemente do -// tamanho das tabelas. -function dumpToFile(PDO $pdo, string $sqlPath): void -{ - $fh = fopen($sqlPath, 'wb'); - if ($fh === false) { - die("Erro: não foi possível escrever o ficheiro SQL.\n"); - } - - fwrite($fh, "-- Backup: " . DB_NAME . "\n"); - fwrite($fh, "-- Data : " . date('Y-m-d H:i:s') . "\n\n"); - fwrite($fh, "SET NAMES utf8mb4;\n"); - fwrite($fh, "SET FOREIGN_KEY_CHECKS = 0;\n\n"); - - $tables = $pdo->query("SHOW FULL TABLES WHERE Table_type = 'BASE TABLE'")->fetchAll(PDO::FETCH_COLUMN); - - foreach ($tables as $table) { - $quoted = '`' . str_replace('`', '``', (string) $table) . '`'; - - // Estrutura - $create = $pdo->query("SHOW CREATE TABLE $quoted")->fetch(); - fwrite($fh, "DROP TABLE IF EXISTS $quoted;\n"); - fwrite($fh, $create['Create Table'] . ";\n\n"); - - // Dados — linha a linha, INSERTs em blocos de 500. - $pdo->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false); - $stmt = $pdo->query("SELECT * FROM $quoted"); - $columns = null; - $values = []; - $flush = function () use ($fh, $quoted, &$columns, &$values) { - if (!$values) return; - fwrite($fh, "INSERT INTO $quoted ($columns) VALUES\n" . implode(",\n", $values) . ";\n"); - $values = []; - }; - - while (($row = $stmt->fetch(PDO::FETCH_ASSOC)) !== false) { - if ($columns === null) { - $columns = '`' . implode('`, `', array_keys($row)) . '`'; - } - $escaped = array_map( - fn($v) => $v === null ? 'NULL' : $pdo->quote((string) $v), - array_values($row) - ); - $values[] = '(' . implode(', ', $escaped) . ')'; - if (count($values) >= 500) $flush(); - } - $flush(); - $stmt->closeCursor(); - $pdo->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, true); - - fwrite($fh, "\n"); - } - - fwrite($fh, "SET FOREIGN_KEY_CHECKS = 1;\n"); - fclose($fh); -} - -echo "A gerar dump SQL...\n"; -dumpToFile($pdo, $sqlPath); - -// --- Empacotar em ZIP ------------------------------------------------------- -$zip = new ZipArchive(); -$result = $zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE); - -if ($result !== true) { - @unlink($sqlPath); - die("Erro ao criar ZIP (código: $result)\n"); -} - -// addFile() streams no close() — o .sql não passa pela memória. -$zip->addFile($sqlPath, $sqlFile); - -// Encriptação AES-256 quando há chave. Um dump em claro no disco leva a tabela -// users com os hashes, os contactos, e os contratos assinados -- que no dance -// incluem dados de menores. -// -// Sem chave sai em claro, e diz-se. Recusar o backup por falta de chave seria -// pior: a alternativa a um ficheiro legível não é um ficheiro seguro, é -// ficheiro nenhum. -require_once dirname(__DIR__) . '/functions_backup.php'; -$bkKey = platform_backup_key(); -if ($bkKey !== null && method_exists($zip, 'setEncryptionName') && defined('ZipArchive::EM_AES_256')) { - $zip->setPassword($bkKey); - if ($zip->setEncryptionName($sqlFile, ZipArchive::EM_AES_256)) { - echo "Encriptação: AES-256\n"; - } else { - echo "AVISO: a encriptação falhou — o backup vai em claro.\n"; - error_log('[backup] setEncryptionName falhou; artefacto em claro'); - } -} else { - echo "AVISO: sem APP_SECRET_KEY — o backup vai em claro.\n"; - error_log('[backup] sem chave de encriptação; artefacto em claro'); -} - -if (!$zip->close()) { - @unlink($sqlPath); - die("Erro: falha ao finalizar o ficheiro ZIP.\n"); -} - -// Remover .sql temporário -@unlink($sqlPath); - -clearstatcache(true, $zipPath); - -if (!file_exists($zipPath)) { - die("Erro: o ficheiro ZIP não foi criado.\n"); -} - -// --- Resultado -------------------------------------------------------------- -$bytes = filesize($zipPath); -$size = $bytes >= 1024 * 1024 - ? round($bytes / 1024 / 1024, 2) . ' MB' - : round($bytes / 1024, 1) . ' KB'; - -echo "Backup da BD concluído!\n"; -echo "Ficheiro : $zipPath\n"; -echo "Tamanho : $size\n"; - -// Só se chega aqui com a cópia nova no disco: a poda nunca corre depois de uma -// cópia falhada, que é quando as antigas são as únicas que há. -backup_prune_report(); diff --git a/shared/platform-tools/backups/restore_runner.php b/shared/platform-tools/backups/restore_runner.php deleted file mode 100644 index fc0348b..0000000 --- a/shared/platform-tools/backups/restore_runner.php +++ /dev/null @@ -1,126 +0,0 @@ -/shared/platform-tools/backups/restore_runner.php - * - * Via CLI : - * php shared/platform-tools/backups/restore_runner.php [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=&file= - * 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); diff --git a/shared/platform-tools/bin/backup-prune.php b/shared/platform-tools/bin/backup-prune.php deleted file mode 100644 index eb903df..0000000 --- a/shared/platform-tools/bin/backup-prune.php +++ /dev/null @@ -1,30 +0,0 @@ - php \ -// /var/www/html/shared/platform-tools/bin/encrypt_secrets.php [--dry-run] -// -// Idempotent -- rows already carrying the enc:v1: prefix are left alone -- so it -// is safe to call from deploy.sh on every deploy. Exits non-zero if a row that -// should be encrypted could not be, so a deploy notices. -// -// Every site opens $pdo in its config/db.php; that is the whole bootstrap. - -declare(strict_types=1); - -$repoRoot = dirname(__DIR__, 3); -$dryRun = in_array('--dry-run', $argv, true); - -$pdo = null; -if (is_file($repoRoot . '/config/db.php')) { - require_once $repoRoot . '/config/db.php'; // defines $pdo - if (is_file($repoRoot . '/config/functions.php')) { - require_once $repoRoot . '/config/functions.php'; - } -} - -if (!$pdo instanceof PDO) { - fwrite(STDERR, "encrypt_secrets: could not open a PDO connection for {$repoRoot}\n"); - exit(2); -} - -// A bootstrap with a hardcoded fallback schema would connect somewhere -// harmless-looking when the environment did not reach it, and "encrypt" -// nothing, reporting success. Refuse unless the live connection is the schema -// DB_NAME actually names. -$expected = (string) ($_ENV['DB_NAME'] ?? getenv('DB_NAME') ?: ''); -$actual = (string) $pdo->query('SELECT DATABASE()')->fetchColumn(); -if ($expected !== '' && $actual !== $expected) { - fwrite(STDERR, "encrypt_secrets: connected to '{$actual}' but DB_NAME is '{$expected}' -- refusing to run.\n"); - exit(2); -} - -require_once dirname(__DIR__) . '/functions_settings.php'; - -if (!platform_settings_secret_available()) { - fwrite(STDERR, "encrypt_secrets: no APP_SECRET_KEY (or BACKUP_TOKEN) configured -- refusing to run.\n"); - exit(3); -} - -if ($dryRun) { - // Report without writing: list secret-named rows still in plaintext. - $rows = $pdo->query("SELECT setting_key, setting_value FROM system_settings")->fetchAll(PDO::FETCH_ASSOC); - $pending = []; - foreach ($rows as $row) { - $k = (string) $row['setting_key']; - $v = (string) $row['setting_value']; - if (platform_settings_is_secret($k) && $v !== '' && !str_starts_with($v, PLATFORM_SECRET_PREFIX)) { - $pending[] = $k; - } - } - echo $pending - ? "would encrypt: " . implode(', ', $pending) . "\n" - : "nothing to encrypt -- all secret rows are empty or already encrypted\n"; - exit(0); -} - -$report = platform_settings_encrypt_existing($pdo); - -foreach ($report['encrypted'] as $k) echo "encrypted: {$k}\n"; -foreach ($report['skipped'] as $k) echo "skipped: {$k}\n"; -foreach ($report['failed'] as $k) fwrite(STDERR, "FAILED: {$k}\n"); - -printf( - "%d encrypted, %d skipped, %d failed\n", - count($report['encrypted']), count($report['skipped']), count($report['failed']) -); - -exit($report['failed'] ? 1 : 0); diff --git a/shared/platform-tools/bin/geo-build-index.php b/shared/platform-tools/bin/geo-build-index.php deleted file mode 100644 index 38979a5..0000000 --- a/shared/platform-tools/bin/geo-build-index.php +++ /dev/null @@ -1,169 +0,0 @@ -#!/usr/bin/env php -&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; -} diff --git a/shared/platform-tools/bin/geo-relay-update.sh b/shared/platform-tools/bin/geo-relay-update.sh deleted file mode 100644 index 1dc9d73..0000000 --- a/shared/platform-tools/bin/geo-relay-update.sh +++ /dev/null @@ -1,100 +0,0 @@ -#!/usr/bin/env bash -# -# Refresh the iCloud Private Relay egress ranges that functions_geo.php reads. -# -# geo-relay-update.sh pt # one or more ISO country codes -# geo-relay-update.sh # falls back to $GEO_ALLOW_COUNTRIES -# -# Why this exists: Private Relay sends Safari traffic out through Apple's egress -# addresses, which are registered where Apple registered them and not where the -# person is. Against the ipdeny lists a subscriber in Aveiro looks foreign, gets -# refused, and never hears back. Apple publishes which country each egress range -# serves, so the gate can ask the right question about it. -# -# It writes relay-.v4 / relay-.v6 — same format as the ipdeny lists, read -# by the same code. **Only the countries you ask for**, and that is the point: -# the published list is 42k IPv4 ranges and 246k IPv6 (855 KB and 6.7 MB), while -# Portugal is 611 of them. The whole file would be a 6.7 MB commit every week and -# a 6.7 MB parse on every request, to answer a question about one country. -# -# Keeping it per-country is also what stops this becoming a Relay whitelist: a -# range serving France is simply never written, so a French relay user is -# refused by the normal path exactly like any other foreign address. -# -# Apple adds ranges as it adds capacity, so weekly is reasonable: -# -# 23 4 * * 1 /srv/sites//shared/platform-tools/bin/geo-relay-update.sh pt -# -# The files are committed, same as the ipdeny data and for the same reason: a -# deploy on a box that cannot reach Apple still has working data — as current as -# the last commit. - -set -euo pipefail - -DATA_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/data/geo" -URL="${ICLOUD_RELAY_URL:-https://mask-api.icloud.com/egress-ip-ranges.csv}" - -# Apple serves relay capacity for a country from at least a few dozen ranges. -# Fewer than this means the country code was wrong, the format changed, or the -# download was truncated — and overwriting a good file with it would quietly -# turn every relay visitor back into a foreigner. -MIN_ROWS=10 - -countries=("$@") -if [ ${#countries[@]} -eq 0 ]; then - read -r -a countries <<< "${GEO_ALLOW_COUNTRIES//[,;]/ }" -fi -if [ ${#countries[@]} -eq 0 ]; then - echo "uso: $(basename "$0") [cc...] (ou defina GEO_ALLOW_COUNTRIES)" >&2 - exit 2 -fi - -tmp="$(mktemp -d)" -trap 'rm -rf "$tmp"' EXIT - -echo "iCloud Private Relay: a descarregar $URL" -if ! curl -fsS --max-time 60 "$URL" -o "$tmp/raw.csv"; then - echo "FALHOU o download — os ficheiros anteriores mantêm-se." >&2 - exit 1 -fi - -failed=0 -for cc in "${countries[@]}"; do - cc="$(echo "$cc" | tr '[:upper:]' '[:lower:]')" - [ -n "$cc" ] || continue - up="$(echo "$cc" | tr '[:lower:]' '[:upper:]')" - - # O formato da Apple é: cidr,countryCode,region,city, — só as duas - # primeiras colunas interessam, e uma linha que não seja exactamente isso é - # descartada em vez de adivinhada: uma mudança de formato tem de aparecer - # como "poucas gamas" e parar o script, não como um ficheiro meio lido. - awk -F',' -v want="$up" ' - $1 ~ /\// && toupper($2) == want { - if ($1 ~ /:/) { print $1 > v6 } else { print $1 > v4 } - } - ' v4="$tmp/$cc.v4" v6="$tmp/$cc.v6" "$tmp/raw.csv" || true - touch "$tmp/$cc.v4" "$tmp/$cc.v6" - - rows4=$(wc -l < "$tmp/$cc.v4" | tr -d ' ') - rows6=$(wc -l < "$tmp/$cc.v6" | tr -d ' ') - total=$((rows4 + rows6)) - - if [ "$total" -lt "$MIN_ROWS" ]; then - echo "FALHOU $up: só $total gamas (mínimo $MIN_ROWS). Ficheiros anteriores mantidos." >&2 - failed=1 - continue - fi - - # Ordenados no disco para um git diff entre duas actualizações se conseguir - # ler. O módulo volta a ordená-los ao carregar, porque esta ordem é textual - # e a procura precisa da ordem dos bytes empacotados. - sort -u "$tmp/$cc.v4" -o "$tmp/$cc.v4" - sort -u "$tmp/$cc.v6" -o "$tmp/$cc.v6" - - mkdir -p "$DATA_DIR" - mv "$tmp/$cc.v4" "$DATA_DIR/relay-$cc.v4" - mv "$tmp/$cc.v6" "$DATA_DIR/relay-$cc.v6" - echo "OK $up: $rows4 gamas IPv4, $rows6 IPv6 → $DATA_DIR/relay-$cc.{v4,v6}" -done - -exit "$failed" diff --git a/shared/platform-tools/bin/geo-update.sh b/shared/platform-tools/bin/geo-update.sh deleted file mode 100644 index b3de88c..0000000 --- a/shared/platform-tools/bin/geo-update.sh +++ /dev/null @@ -1,95 +0,0 @@ -#!/usr/bin/env bash -# -# Refresh the per-country CIDR lists that functions_geo.php reads. -# -# geo-update.sh pt # one or more ISO country codes -# geo-update.sh # falls back to $GEO_ALLOW_COUNTRIES -# -# Source is ipdeny.com, which publishes one aggregated zone file per country -# from the RIR allocation data. Allocations move slowly, so monthly is plenty: -# -# 17 4 3 * * /srv/sites//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...] (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 diff --git a/shared/platform-tools/bin/hub-security-sync.php b/shared/platform-tools/bin/hub-security-sync.php deleted file mode 100644 index d37073b..0000000 --- a/shared/platform-tools/bin/hub-security-sync.php +++ /dev/null @@ -1,29 +0,0 @@ - true, 'skipped' => 'plataforma sem base de dados']), "\n"; - exit(0); -} - -$res = hub_security_sync($pdo, 50); -echo json_encode($res, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), "\n"; -exit(!empty($res['ok']) ? 0 : 1); diff --git a/shared/platform-tools/compat_secrets.php b/shared/platform-tools/compat_secrets.php deleted file mode 100644 index a9ea7a9..0000000 --- a/shared/platform-tools/compat_secrets.php +++ /dev/null @@ -1,59 +0,0 @@ - [ - 'PT' => 'Portugal', - 'ES' => 'Espanha', - 'FR' => 'França', - 'GB' => 'Reino Unido', - 'CH' => 'Suíça', - 'LU' => 'Luxemburgo', - 'DE' => 'Alemanha', - 'BR' => 'Brasil', - ], - 'Resto da Europa' => [ - 'AD' => 'Andorra', - 'AL' => 'Albânia', - 'AT' => 'Áustria', - 'BA' => 'Bósnia e Herzegovina', - 'BE' => 'Bélgica', - 'BG' => 'Bulgária', - 'BY' => 'Bielorrússia', - 'CY' => 'Chipre', - 'CZ' => 'Chéquia', - 'DK' => 'Dinamarca', - 'EE' => 'Estónia', - 'FI' => 'Finlândia', - 'GI' => 'Gibraltar', - 'GR' => 'Grécia', - 'HR' => 'Croácia', - 'HU' => 'Hungria', - 'IE' => 'Irlanda', - 'IS' => 'Islândia', - 'IT' => 'Itália', - 'LI' => 'Listenstaine', - 'LT' => 'Lituânia', - 'LV' => 'Letónia', - 'MC' => 'Mónaco', - 'MD' => 'Moldávia', - 'ME' => 'Montenegro', - 'MK' => 'Macedónia do Norte', - 'MT' => 'Malta', - 'NL' => 'Países Baixos', - 'NO' => 'Noruega', - 'PL' => 'Polónia', - 'RO' => 'Roménia', - 'RS' => 'Sérvia', - 'RU' => 'Rússia', - 'SE' => 'Suécia', - 'SI' => 'Eslovénia', - 'SK' => 'Eslováquia', - 'SM' => 'São Marinho', - 'UA' => 'Ucrânia', - 'VA' => 'Vaticano', - ], - 'Língua portuguesa' => [ - 'AO' => 'Angola', - 'CV' => 'Cabo Verde', - 'GQ' => 'Guiné Equatorial', - 'GW' => 'Guiné-Bissau', - 'MO' => 'Macau', - 'MZ' => 'Moçambique', - 'ST' => 'São Tomé e Príncipe', - 'TL' => 'Timor-Leste', - ], - 'Fora da Europa' => [ - 'AE' => 'Emirados Árabes Unidos', - 'AR' => 'Argentina', - 'AU' => 'Austrália', - 'CA' => 'Canadá', - 'CL' => 'Chile', - 'CN' => 'China', - 'CO' => 'Colômbia', - 'IL' => 'Israel', - 'IN' => 'Índia', - 'JP' => 'Japão', - 'MA' => 'Marrocos', - 'MX' => 'México', - 'NZ' => 'Nova Zelândia', - 'TR' => 'Turquia', - 'US' => 'Estados Unidos', - 'VE' => 'Venezuela', - 'ZA' => 'África do Sul', - ], -]; diff --git a/shared/platform-tools/data/geo/pt.v4 b/shared/platform-tools/data/geo/pt.v4 deleted file mode 100644 index 273507a..0000000 --- a/shared/platform-tools/data/geo/pt.v4 +++ /dev/null @@ -1,449 +0,0 @@ -2.80.0.0/14 -5.43.0.0/18 -5.158.0.0/18 -5.183.96.0/24 -5.183.97.0/24 -5.183.98.0/23 -5.206.224.0/21 -5.249.0.0/17 -5.253.128.0/22 -5.253.180.0/22 -31.22.128.0/17 -31.25.8.0/23 -37.28.192.0/18 -37.189.0.0/16 -45.92.68.0/23 -45.135.172.0/22 -45.154.64.0/22 -45.156.128.0/22 -46.50.0.0/17 -46.189.128.0/17 -62.28.0.0/16 -62.48.128.0/18 -62.48.192.0/18 -62.169.64.0/18 -62.233.41.0/24 -62.249.0.0/19 -66.33.88.0/24 -77.54.0.0/16 -77.91.200.0/21 -77.95.168.0/24 -78.29.128.0/18 -78.108.208.0/22 -78.130.0.0/17 -78.137.192.0/18 -79.168.0.0/15 -80.172.0.0/16 -80.243.80.0/20 -80.244.10.0/24 -81.20.240.0/20 -81.84.0.0/16 -81.90.48.0/20 -81.193.0.0/16 -82.102.4.0/24 -82.102.7.0/24 -82.154.0.0/15 -83.132.0.0/16 -83.144.128.0/18 -83.174.0.0/18 -83.223.160.0/19 -83.223.224.0/19 -83.240.128.0/17 -84.23.192.0/19 -84.90.0.0/15 -84.252.88.0/22 -85.8.226.0/24 -85.138.0.0/15 -85.240.0.0/13 -87.103.0.0/17 -87.196.0.0/16 -88.157.0.0/16 -88.210.64.0/18 -88.214.128.0/18 -89.26.128.0/17 -89.31.224.0/21 -89.109.64.0/18 -89.114.0.0/15 -89.152.0.0/14 -89.180.0.0/15 -89.214.0.0/16 -91.90.170.0/24 -91.194.170.0/23 -91.194.182.0/23 -91.198.47.0/24 -91.198.90.0/24 -91.198.182.0/24 -91.199.116.0/24 -91.205.112.0/22 -91.209.16.0/24 -91.212.59.0/24 -91.213.65.0/24 -91.216.117.0/24 -91.220.190.0/24 -91.227.100.0/24 -91.230.40.0/24 -91.231.216.0/23 -91.241.3.0/24 -92.118.109.0/24 -92.243.81.0/24 -92.250.0.0/17 -93.94.56.0/21 -93.102.0.0/16 -93.108.0.0/16 -94.46.12.0/22 -94.46.16.0/20 -94.46.128.0/21 -94.46.141.0/24 -94.46.142.0/24 -94.46.160.0/20 -94.46.176.0/21 -94.46.251.0/24 -94.60.0.0/14 -94.126.144.0/21 -94.126.168.0/21 -94.132.0.0/15 -94.231.208.0/22 -95.69.0.0/17 -95.92.0.0/14 -95.136.0.0/17 -95.172.160.0/19 -103.117.124.0/22 -103.243.124.0/22 -104.244.8.0/22 -109.48.0.0/14 -109.71.40.0/21 -109.105.208.0/21 -109.109.160.0/22 -128.65.224.0/19 -130.185.80.0/21 -139.83.0.0/16 -144.64.0.0/16 -146.19.95.0/24 -146.19.249.0/24 -146.193.0.0/16 -147.78.200.0/22 -148.63.0.0/16 -148.69.0.0/16 -148.71.0.0/16 -149.90.0.0/16 -150.237.126.0/23 -158.162.0.0/16 -159.255.48.0/20 -161.230.0.0/16 -168.182.128.0/17 -176.61.144.0/21 -176.78.0.0/15 -176.111.104.0/21 -176.119.193.0/24 -176.121.10.0/24 -176.121.255.0/24 -176.124.252.0/24 -176.221.32.0/21 -178.166.0.0/17 -185.0.25.0/24 -185.1.116.0/24 -185.2.84.0/22 -185.11.164.0/22 -185.12.116.0/22 -185.15.20.0/22 -185.17.228.0/22 -185.31.156.0/22 -185.32.36.0/22 -185.32.188.0/22 -185.36.101.0/24 -185.37.208.0/22 -185.38.140.0/22 -185.38.173.0/24 -185.39.208.0/22 -185.41.116.0/22 -185.42.162.0/24 -185.51.92.0/22 -185.58.80.0/22 -185.63.180.0/22 -185.72.152.0/22 -185.76.4.0/22 -185.79.224.0/22 -185.80.76.0/22 -185.83.20.0/22 -185.83.212.0/23 -185.83.215.0/24 -185.83.248.0/22 -185.88.16.0/22 -185.89.192.0/22 -185.90.56.0/22 -185.92.96.0/22 -185.95.220.0/22 -185.96.172.0/22 -185.98.248.0/22 -185.99.232.0/22 -185.101.176.0/22 -185.105.8.0/24 -185.112.251.0/24 -185.113.140.0/22 -185.113.156.0/22 -185.117.225.0/24 -185.118.112.0/22 -185.124.124.0/22 -185.126.88.0/22 -185.133.76.0/22 -185.133.136.0/22 -185.136.52.0/22 -185.142.131.0/24 -185.146.92.0/22 -185.160.115.0/24 -185.162.180.0/23 -185.162.182.0/24 -185.162.183.0/24 -185.165.104.0/22 -185.166.144.0/24 -185.169.96.0/22 -185.170.67.0/24 -185.175.91.0/24 -185.175.184.0/22 -185.180.140.0/22 -185.204.111.0/24 -185.208.203.0/24 -185.210.156.0/24 -185.214.32.0/22 -185.218.12.0/22 -185.219.129.0/24 -185.219.131.0/24 -185.222.8.0/22 -185.222.200.0/22 -185.224.164.0/22 -185.226.196.0/22 -185.229.60.0/22 -185.231.233.0/24 -185.236.228.0/22 -185.240.248.0/22 -185.248.35.0/24 -188.37.0.0/16 -188.80.0.0/14 -188.93.192.0/21 -188.93.224.0/21 -188.93.232.0/21 -188.125.192.0/19 -188.140.0.0/17 -188.214.159.0/24 -188.214.192.0/24 -188.250.0.0/15 -192.12.232.0/24 -192.26.231.0/24 -192.26.236.0/24 -192.26.239.0/24 -192.35.246.0/24 -192.67.76.0/24 -192.68.186.0/24 -192.68.209.0/24 -192.68.216.0/24 -192.68.221.0/24 -192.68.224.0/24 -192.76.242.0/24 -192.80.20.0/24 -192.80.21.0/24 -192.82.127.0/24 -192.82.214.0/24 -192.84.13.0/24 -192.84.15.0/24 -192.84.62.0/24 -192.86.138.0/24 -192.88.17.0/24 -192.88.123.0/24 -192.88.250.0/23 -192.88.252.0/23 -192.88.254.0/24 -192.91.177.0/24 -192.92.133.0/24 -192.92.135.0/24 -192.92.142.0/24 -192.92.143.0/24 -192.92.144.0/24 -192.92.145.0/24 -192.92.146.0/24 -192.92.147.0/24 -192.92.148.0/24 -192.92.149.0/24 -192.92.150.0/24 -192.92.151.0/24 -192.92.152.0/24 -192.92.153.0/24 -192.94.24.0/24 -192.101.118.0/24 -192.103.147.0/24 -192.104.35.0/24 -192.104.36.0/24 -192.104.37.0/24 -192.104.48.0/24 -192.107.122.0/24 -192.111.101.0/24 -192.112.30.0/24 -192.112.31.0/24 -192.112.32.0/24 -192.112.45.0/24 -192.122.238.0/23 -192.122.240.0/23 -192.122.242.0/24 -192.132.53.0/24 -192.132.55.0/24 -192.133.15.0/24 -192.133.54.0/24 -192.133.55.0/24 -192.133.108.0/24 -192.135.129.0/24 -192.135.187.0/24 -192.135.219.0/24 -192.136.31.0/24 -192.136.49.0/24 -192.136.52.0/24 -192.138.86.0/24 -192.138.204.0/24 -192.147.76.0/24 -192.147.77.0/24 -192.147.78.0/24 -192.147.155.0/24 -192.148.103.0/24 -192.150.192.0/24 -192.150.193.0/24 -192.152.47.0/24 -192.152.174.0/24 -192.153.13.0/24 -192.156.210.0/24 -192.160.245.0/24 -192.160.246.0/23 -192.160.248.0/23 -192.160.251.0/24 -192.162.17.0/24 -192.188.10.0/24 -192.190.174.0/24 -192.195.195.0/24 -192.199.16.0/20 -192.207.196.0/24 -193.16.103.0/24 -193.17.0.0/24 -193.25.164.0/23 -193.27.11.0/24 -193.36.190.0/24 -193.37.153.0/24 -193.39.195.0/24 -193.43.40.0/24 -193.47.185.0/24 -193.53.22.0/24 -193.58.118.0/24 -193.104.52.0/24 -193.107.13.0/24 -193.109.93.0/24 -193.109.184.0/21 -193.111.42.0/24 -193.126.0.0/16 -193.136.0.0/16 -193.137.0.0/16 -193.163.151.0/24 -193.164.0.0/24 -193.176.0.0/24 -193.192.4.0/22 -193.192.8.0/22 -193.200.216.0/24 -193.203.106.0/23 -193.218.156.0/22 -193.227.238.0/23 -193.236.0.0/19 -193.236.32.0/19 -193.236.64.0/19 -193.236.96.0/20 -193.236.112.0/21 -193.236.120.0/22 -193.236.124.0/22 -193.236.128.0/20 -193.236.144.0/21 -193.236.152.0/21 -193.236.160.0/20 -193.236.176.0/23 -193.236.178.0/23 -193.236.180.0/22 -193.236.184.0/21 -193.236.192.0/18 -193.242.124.0/24 -194.6.229.0/24 -194.8.30.0/24 -194.11.23.0/24 -194.38.128.0/19 -194.39.84.0/24 -194.39.124.0/22 -194.62.41.0/24 -194.62.240.0/22 -194.65.0.0/19 -194.65.32.0/19 -194.65.64.0/18 -194.65.128.0/17 -194.79.64.0/19 -194.107.127.0/24 -194.110.76.0/24 -194.117.0.0/19 -194.117.32.0/22 -194.117.36.0/22 -194.117.40.0/21 -194.117.48.0/23 -194.117.75.0/24 -194.117.93.0/24 -194.125.242.0/23 -194.140.195.0/24 -194.140.232.0/24 -194.140.238.0/24 -194.145.121.0/24 -194.153.132.0/24 -194.169.156.0/24 -194.169.215.0/24 -194.210.0.0/16 -195.8.0.0/19 -195.8.58.0/23 -195.22.0.0/19 -195.23.0.0/16 -195.35.66.0/24 -195.47.200.0/24 -195.54.168.0/23 -195.60.80.224/27 -195.66.127.0/24 -195.72.136.0/22 -195.85.221.0/24 -195.88.104.0/23 -195.138.0.0/19 -195.170.168.0/24 -195.184.240.0/22 -195.200.252.0/23 -195.225.180.0/22 -195.234.47.0/24 -195.234.134.0/24 -195.245.128.0/18 -195.245.197.0/24 -195.245.234.0/24 -195.246.238.0/23 -198.56.128.0/17 -203.171.64.0/19 -212.0.160.0/19 -212.13.32.0/19 -212.18.160.0/19 -212.52.23.0/24 -212.55.128.0/19 -212.55.160.0/20 -212.55.176.0/21 -212.104.208.0/23 -212.113.160.0/19 -213.13.0.0/16 -213.22.0.0/16 -213.30.0.0/17 -213.58.0.0/16 -213.63.0.0/17 -213.63.128.0/17 -213.134.26.0/24 -213.138.224.0/19 -213.141.0.0/19 -213.146.192.0/19 -213.190.192.0/19 -213.205.64.0/19 -213.228.128.0/19 -213.228.160.0/19 -217.28.143.0/24 -217.65.73.0/24 -217.70.64.0/20 -217.129.0.0/16 -217.168.112.0/21 diff --git a/shared/platform-tools/data/geo/pt.v6 b/shared/platform-tools/data/geo/pt.v6 deleted file mode 100644 index 4ac9601..0000000 --- a/shared/platform-tools/data/geo/pt.v6 +++ /dev/null @@ -1,120 +0,0 @@ -2a0a:5b00::/29 -2a0a:36c0::/29 -2a0a:9000::/29 -2a0a:e3c0::/29 -2a0b:94c0::/32 -2a0b:8400::/29 -2a0b:c1c0::/29 -2a0b:e640::/29 -2a0b:e740::/29 -2a0c:3840::/29 -2a0c:9740::/29 -2a0c:b800::/29 -2a0c:c2c0::/29 -2a0d:3b80::/29 -2a0d:82c0::/29 -2a0d:94c0::/29 -2a0d:2340::/29 -2a0d:7200::/29 -2a0d:e300::/29 -2a0d:f600::/29 -2a0e:1f40::/29 -2a0e:5f00::/29 -2a0e:48c0::/29 -2a0e:bc00::/29 -2a0e:ec00::/30 -2a0e:ec04::/31 -2a0f:7700::/29 -2a0f:8900::/29 -2a0f:c440::/29 -2a0f:c700::/29 -2a0f:d300::/29 -2a00:1c60::/32 -2a00:1650::/29 -2a00:bc20::/32 -2a00:c560::/29 -2a00:d180::/29 -2a00:fa00::/32 -2a01:8::/29 -2a01:10::/29 -2a01:b020::/32 -2a02:2a60::/29 -2a02:8f0::/32 -2a02:26c8::/32 -2a02:27e0::/32 -2a02:138::/32 -2a02:740::/32 -2a02:818::/29 -2a02:870::/29 -2a03:3f60::/32 -2a03:8bc0::/32 -2a03:9c80::/32 -2a03:73c0::/32 -2a03:84e0::/32 -2a03:6420::/32 -2a03:da0::/32 -2a03::/32 -2a04:6d80::/29 -2a04:53c0::/29 -2a04:8940::/29 -2a04:b040::/29 -2a04:e040::/29 -2a05:9f00::/29 -2a05:7540::/29 -2a05:7800::/29 -2a05:9640::/29 -2a05:a040::/29 -2a05:c800::/29 -2a05:df40::/29 -2a06:2000::/29 -2a06:cdc0::/29 -2a06:e640::/29 -2a07:4f40::/29 -2a09:5b00::/29 -2a09:58c0::/32 -2a09:2840::/29 -2a09:5640::/32 -2a09:be41::/32 -2a09:be42::/31 -2a09:be44::/30 -2a10:1f80::/29 -2a10:3c0::/29 -2a10:7b40::/29 -2a10:5000::/29 -2a10:a4c0::/29 -2a10:b280::/29 -2a10:e440::/32 -2a10:ee40::/29 -2a11:9ec0::/29 -2a11:6740::/29 -2a11:bd80::/30 -2a11:c00::/29 -2a11:e740::/32 -2a12:26c0::/29 -2a12:49c0::/29 -2a12:c1c0::/29 -2a13:3fc0::/29 -2a13:67c0::/29 -2a13:580::/29 -2a13:de80::/29 -2a13:f2c0::/29 -2a14:3840::/29 -2a14:da80::/29 -2001:4cc0::/32 -2001:7f8:a::/48 -2001:7f8:c7::/48 -2001:7f8:f9::/48 -2001:8a0::/29 -2001:15d8::/29 -2001:35c0::/29 -2001:67c:1b40::/48 -2001:67c:2d44::/48 -2001:67c:13ec::/48 -2001:67c:c0c::/48 -2001:678:12f0::/48 -2001:678:1094::/48 -2001:690::/29 -2001:818::/29 -2001:1588::/29 -2001:a40::/29 -2001:b18::/32 diff --git a/shared/platform-tools/data/geo/relay-pt.v4 b/shared/platform-tools/data/geo/relay-pt.v4 deleted file mode 100644 index 3ed2b3b..0000000 --- a/shared/platform-tools/data/geo/relay-pt.v4 +++ /dev/null @@ -1,76 +0,0 @@ -104.28.136.119/32 -104.28.136.120/32 -104.28.138.54/32 -104.28.138.55/32 -104.28.139.148/32 -104.28.139.149/32 -104.28.143.15/32 -104.28.146.45/32 -104.28.146.46/32 -104.28.146.47/32 -104.28.146.48/32 -104.28.146.49/32 -104.28.34.141/32 -104.28.34.142/32 -104.28.34.143/32 -104.28.34.144/32 -104.28.42.78/32 -104.28.42.79/32 -104.28.53.18/32 -104.28.53.19/32 -104.28.86.43/32 -104.28.86.44/32 -104.28.87.85/32 -104.28.87.86/32 -104.28.87.87/32 -104.28.87.88/32 -104.28.88.103/32 -104.28.88.104/32 -104.28.88.105/32 -104.28.88.106/32 -104.28.96.131/32 -104.28.96.132/32 -140.248.40.172/31 -140.248.40.174/31 -140.248.40.176/31 -140.248.40.178/31 -146.75.168.156/31 -146.75.168.158/31 -146.75.168.160/31 -146.75.168.162/31 -146.75.182.52/31 -146.75.182.54/31 -146.75.182.56/31 -146.75.182.58/31 -146.75.185.156/31 -146.75.185.158/31 -146.75.185.160/31 -146.75.185.162/31 -146.75.210.52/31 -146.75.210.54/31 -146.75.210.56/31 -146.75.210.58/31 -172.225.158.178/31 -172.225.158.180/30 -172.225.158.216/31 -172.225.159.64/28 -172.225.161.144/28 -172.225.162.192/28 -172.225.163.62/31 -172.225.163.80/30 -172.225.163.84/31 -172.225.178.176/28 -172.225.178.192/28 -172.225.179.50/31 -172.225.179.52/30 -172.225.179.64/31 -172.225.192.208/28 -172.225.193.58/31 -172.225.193.60/30 -172.225.193.64/31 -172.225.212.0/28 -172.225.212.16/30 -172.225.212.20/31 -172.225.212.22/31 -172.225.212.240/28 -172.225.243.64/28 diff --git a/shared/platform-tools/data/geo/relay-pt.v6 b/shared/platform-tools/data/geo/relay-pt.v6 deleted file mode 100644 index 3c6c0b6..0000000 --- a/shared/platform-tools/data/geo/relay-pt.v6 +++ /dev/null @@ -1,535 +0,0 @@ -2606:54c0:4fe8::/45 -2606:54c0:4ff0::/45 -2606:54c0:4ff8::/45 -2606:54c0:5000::/45 -2606:54c0:5008::/45 -2606:54c0:5010::/45 -2606:54c3:0:1152::/64 -2606:54c3:0:132a::/64 -2606:54c3:0:16a0::/64 -2606:54c3:0:1992::/64 -2606:54c3:0:857::/64 -2606:54c3:0:ac1::/64 -2a02:26f7:cb00:4000::/64 -2a02:26f7:cb00:400::/56 -2a02:26f7:cb00:500::/58 -2a02:26f7:cb00:5280::/64 -2a02:26f7:cb00:5281::/64 -2a02:26f7:cb00:5282::/64 -2a02:26f7:cb00:5283::/64 -2a02:26f7:cb00:5284::/64 -2a02:26f7:cb00:5285::/64 -2a02:26f7:cb00:5286::/64 -2a02:26f7:cb00:5287::/64 -2a02:26f7:cb00:5288::/64 -2a02:26f7:cb00:5289::/64 -2a02:26f7:cb00:528a::/64 -2a02:26f7:cb00:528b::/64 -2a02:26f7:cb00:528c::/64 -2a02:26f7:cb00:528d::/64 -2a02:26f7:cb00:528e::/64 -2a02:26f7:cb00:528f::/64 -2a02:26f7:cb00:5290::/64 -2a02:26f7:cb00:5291::/64 -2a02:26f7:cb00:5292::/64 -2a02:26f7:cb00:5293::/64 -2a02:26f7:cb00:5294::/64 -2a02:26f7:cb00:5295::/64 -2a02:26f7:cb00:5296::/64 -2a02:26f7:cb00:5297::/64 -2a02:26f7:cb00:5298::/64 -2a02:26f7:cb00:5299::/64 -2a02:26f7:cb00:529a::/64 -2a02:26f7:cb00:529b::/64 -2a02:26f7:cb00:529c::/64 -2a02:26f7:cb00:529d::/64 -2a02:26f7:cb00:529e::/64 -2a02:26f7:cb00:529f::/64 -2a02:26f7:cb00:52a0::/64 -2a02:26f7:cb00:52a1::/64 -2a02:26f7:cb00:52a2::/64 -2a02:26f7:cb00:52a3::/64 -2a02:26f7:cb00:52a4::/64 -2a02:26f7:cb00:52a5::/64 -2a02:26f7:cb00:52a6::/64 -2a02:26f7:cb00:52a7::/64 -2a02:26f7:cb00:52a8::/64 -2a02:26f7:cb00:52a9::/64 -2a02:26f7:cb00:52aa::/64 -2a02:26f7:cb00:52ab::/64 -2a02:26f7:cb00:52ac::/64 -2a02:26f7:cb00:52ad::/64 -2a02:26f7:cb00:52ae::/64 -2a02:26f7:cb00:52af::/64 -2a02:26f7:cb00:52b0::/64 -2a02:26f7:cb00:52b1::/64 -2a02:26f7:cb00:52b2::/64 -2a02:26f7:cb00:52b3::/64 -2a02:26f7:cb00:52b4::/64 -2a02:26f7:cb00:52b5::/64 -2a02:26f7:cb00:52b6::/64 -2a02:26f7:cb00:52b7::/64 -2a02:26f7:cb00:52b8::/64 -2a02:26f7:cb00:52b9::/64 -2a02:26f7:cb00:52ba::/64 -2a02:26f7:cb00:52bb::/64 -2a02:26f7:cb00:52bc::/64 -2a02:26f7:cb00:52bd::/64 -2a02:26f7:cb00:52be::/64 -2a02:26f7:cb00:52bf::/64 -2a02:26f7:cb00:52c0::/64 -2a02:26f7:cb00:52c1::/64 -2a02:26f7:cb00:52c2::/64 -2a02:26f7:cb00:52c3::/64 -2a02:26f7:cb00:52c4::/64 -2a02:26f7:cb00:52c5::/64 -2a02:26f7:cb00:52c6::/64 -2a02:26f7:cb00:52c7::/64 -2a02:26f7:cb00:52c8::/64 -2a02:26f7:cb00:52c9::/64 -2a02:26f7:cb00:52ca::/64 -2a02:26f7:cb00:52cb::/64 -2a02:26f7:cb00:52cc::/64 -2a02:26f7:cb00:52cd::/64 -2a02:26f7:cb00:52ce::/64 -2a02:26f7:cb00:52cf::/64 -2a02:26f7:cb00:52d0::/64 -2a02:26f7:cb00:52d1::/64 -2a02:26f7:cb00:52d2::/64 -2a02:26f7:cb00:52d3::/64 -2a02:26f7:cb00:52d4::/64 -2a02:26f7:cb00:52d5::/64 -2a02:26f7:cb00:52d6::/64 -2a02:26f7:cb00:52d7::/64 -2a02:26f7:cb00:52d8::/64 -2a02:26f7:cb00:52d9::/64 -2a02:26f7:cb00::/54 -2a02:26f7:cb01:4000::/64 -2a02:26f7:cb02:4000::/64 -2a02:26f7:cb02:400::/56 -2a02:26f7:cb02:500::/58 -2a02:26f7:cb02:5280::/64 -2a02:26f7:cb02:5281::/64 -2a02:26f7:cb02:5282::/64 -2a02:26f7:cb02:5283::/64 -2a02:26f7:cb02:5284::/64 -2a02:26f7:cb02:5285::/64 -2a02:26f7:cb02:5286::/64 -2a02:26f7:cb02:5287::/64 -2a02:26f7:cb02:5288::/64 -2a02:26f7:cb02:5289::/64 -2a02:26f7:cb02:528a::/64 -2a02:26f7:cb02:528b::/64 -2a02:26f7:cb02:528c::/64 -2a02:26f7:cb02:528d::/64 -2a02:26f7:cb02:528e::/64 -2a02:26f7:cb02:528f::/64 -2a02:26f7:cb02:5290::/64 -2a02:26f7:cb02:5291::/64 -2a02:26f7:cb02:5292::/64 -2a02:26f7:cb02:5293::/64 -2a02:26f7:cb02:5294::/64 -2a02:26f7:cb02:5295::/64 -2a02:26f7:cb02:5296::/64 -2a02:26f7:cb02:5297::/64 -2a02:26f7:cb02:5298::/64 -2a02:26f7:cb02:5299::/64 -2a02:26f7:cb02:529a::/64 -2a02:26f7:cb02:529b::/64 -2a02:26f7:cb02:529c::/64 -2a02:26f7:cb02:529d::/64 -2a02:26f7:cb02:529e::/64 -2a02:26f7:cb02:529f::/64 -2a02:26f7:cb02:52a0::/64 -2a02:26f7:cb02:52a1::/64 -2a02:26f7:cb02:52a2::/64 -2a02:26f7:cb02:52a3::/64 -2a02:26f7:cb02:52a4::/64 -2a02:26f7:cb02:52a5::/64 -2a02:26f7:cb02:52a6::/64 -2a02:26f7:cb02:52a7::/64 -2a02:26f7:cb02:52a8::/64 -2a02:26f7:cb02:52a9::/64 -2a02:26f7:cb02:52aa::/64 -2a02:26f7:cb02:52ab::/64 -2a02:26f7:cb02:52ac::/64 -2a02:26f7:cb02:52ad::/64 -2a02:26f7:cb02:52ae::/64 -2a02:26f7:cb02:52af::/64 -2a02:26f7:cb02:52b0::/64 -2a02:26f7:cb02:52b1::/64 -2a02:26f7:cb02:52b2::/64 -2a02:26f7:cb02:52b3::/64 -2a02:26f7:cb02:52b4::/64 -2a02:26f7:cb02:52b5::/64 -2a02:26f7:cb02:52b6::/64 -2a02:26f7:cb02:52b7::/64 -2a02:26f7:cb02:52b8::/64 -2a02:26f7:cb02:52b9::/64 -2a02:26f7:cb02:52ba::/64 -2a02:26f7:cb02:52bb::/64 -2a02:26f7:cb02:52bc::/64 -2a02:26f7:cb02:52bd::/64 -2a02:26f7:cb02:52be::/64 -2a02:26f7:cb02:52bf::/64 -2a02:26f7:cb02:52c0::/64 -2a02:26f7:cb02:52c1::/64 -2a02:26f7:cb02:52c2::/64 -2a02:26f7:cb02:52c3::/64 -2a02:26f7:cb02:52c4::/64 -2a02:26f7:cb02:52c5::/64 -2a02:26f7:cb02:52c6::/64 -2a02:26f7:cb02:52c7::/64 -2a02:26f7:cb02:52c8::/64 -2a02:26f7:cb02:52c9::/64 -2a02:26f7:cb02:52ca::/64 -2a02:26f7:cb02:52cb::/64 -2a02:26f7:cb02:52cc::/64 -2a02:26f7:cb02:52cd::/64 -2a02:26f7:cb02:52ce::/64 -2a02:26f7:cb02:52cf::/64 -2a02:26f7:cb02:52d0::/64 -2a02:26f7:cb02:52d1::/64 -2a02:26f7:cb02:52d2::/64 -2a02:26f7:cb02:52d3::/64 -2a02:26f7:cb02:52d4::/64 -2a02:26f7:cb02:52d5::/64 -2a02:26f7:cb02:52d6::/64 -2a02:26f7:cb02:52d7::/64 -2a02:26f7:cb02:52d8::/64 -2a02:26f7:cb02:52d9::/64 -2a02:26f7:cb02::/54 -2a02:26f7:cb03:4000::/64 -2a02:26f7:cb04:4000::/64 -2a02:26f7:cb04:400::/56 -2a02:26f7:cb04:500::/58 -2a02:26f7:cb04:5280::/64 -2a02:26f7:cb04:5281::/64 -2a02:26f7:cb04:5282::/64 -2a02:26f7:cb04:5283::/64 -2a02:26f7:cb04:5284::/64 -2a02:26f7:cb04:5285::/64 -2a02:26f7:cb04:5286::/64 -2a02:26f7:cb04:5287::/64 -2a02:26f7:cb04:5288::/64 -2a02:26f7:cb04:5289::/64 -2a02:26f7:cb04:528a::/64 -2a02:26f7:cb04:528b::/64 -2a02:26f7:cb04:528c::/64 -2a02:26f7:cb04:528d::/64 -2a02:26f7:cb04:528e::/64 -2a02:26f7:cb04:528f::/64 -2a02:26f7:cb04:5290::/64 -2a02:26f7:cb04:5291::/64 -2a02:26f7:cb04:5292::/64 -2a02:26f7:cb04:5293::/64 -2a02:26f7:cb04:5294::/64 -2a02:26f7:cb04:5295::/64 -2a02:26f7:cb04:5296::/64 -2a02:26f7:cb04:5297::/64 -2a02:26f7:cb04:5298::/64 -2a02:26f7:cb04:5299::/64 -2a02:26f7:cb04:529a::/64 -2a02:26f7:cb04:529b::/64 -2a02:26f7:cb04:529c::/64 -2a02:26f7:cb04:529d::/64 -2a02:26f7:cb04:529e::/64 -2a02:26f7:cb04:529f::/64 -2a02:26f7:cb04:52a0::/64 -2a02:26f7:cb04:52a1::/64 -2a02:26f7:cb04:52a2::/64 -2a02:26f7:cb04:52a3::/64 -2a02:26f7:cb04:52a4::/64 -2a02:26f7:cb04:52a5::/64 -2a02:26f7:cb04:52a6::/64 -2a02:26f7:cb04:52a7::/64 -2a02:26f7:cb04:52a8::/64 -2a02:26f7:cb04:52a9::/64 -2a02:26f7:cb04:52aa::/64 -2a02:26f7:cb04:52ab::/64 -2a02:26f7:cb04:52ac::/64 -2a02:26f7:cb04:52ad::/64 -2a02:26f7:cb04:52ae::/64 -2a02:26f7:cb04:52af::/64 -2a02:26f7:cb04:52b0::/64 -2a02:26f7:cb04:52b1::/64 -2a02:26f7:cb04:52b2::/64 -2a02:26f7:cb04:52b3::/64 -2a02:26f7:cb04:52b4::/64 -2a02:26f7:cb04:52b5::/64 -2a02:26f7:cb04:52b6::/64 -2a02:26f7:cb04:52b7::/64 -2a02:26f7:cb04:52b8::/64 -2a02:26f7:cb04:52b9::/64 -2a02:26f7:cb04:52ba::/64 -2a02:26f7:cb04:52bb::/64 -2a02:26f7:cb04:52bc::/64 -2a02:26f7:cb04:52bd::/64 -2a02:26f7:cb04:52be::/64 -2a02:26f7:cb04:52bf::/64 -2a02:26f7:cb04:52c0::/64 -2a02:26f7:cb04:52c1::/64 -2a02:26f7:cb04:52c2::/64 -2a02:26f7:cb04:52c3::/64 -2a02:26f7:cb04:52c4::/64 -2a02:26f7:cb04:52c5::/64 -2a02:26f7:cb04:52c6::/64 -2a02:26f7:cb04:52c7::/64 -2a02:26f7:cb04:52c8::/64 -2a02:26f7:cb04:52c9::/64 -2a02:26f7:cb04:52ca::/64 -2a02:26f7:cb04:52cb::/64 -2a02:26f7:cb04:52cc::/64 -2a02:26f7:cb04:52cd::/64 -2a02:26f7:cb04:52ce::/64 -2a02:26f7:cb04:52cf::/64 -2a02:26f7:cb04:52d0::/64 -2a02:26f7:cb04:52d1::/64 -2a02:26f7:cb04:52d2::/64 -2a02:26f7:cb04:52d3::/64 -2a02:26f7:cb04:52d4::/64 -2a02:26f7:cb04:52d5::/64 -2a02:26f7:cb04:52d6::/64 -2a02:26f7:cb04:52d7::/64 -2a02:26f7:cb04:52d8::/64 -2a02:26f7:cb04:52d9::/64 -2a02:26f7:cb04::/54 -2a02:26f7:cb05:4000::/64 -2a02:26f7:cb0c:4000::/64 -2a02:26f7:cb0c:400::/56 -2a02:26f7:cb0c:500::/58 -2a02:26f7:cb0c:5280::/64 -2a02:26f7:cb0c:5281::/64 -2a02:26f7:cb0c:5282::/64 -2a02:26f7:cb0c:5283::/64 -2a02:26f7:cb0c:5284::/64 -2a02:26f7:cb0c:5285::/64 -2a02:26f7:cb0c:5286::/64 -2a02:26f7:cb0c:5287::/64 -2a02:26f7:cb0c:5288::/64 -2a02:26f7:cb0c:5289::/64 -2a02:26f7:cb0c:528a::/64 -2a02:26f7:cb0c:528b::/64 -2a02:26f7:cb0c:528c::/64 -2a02:26f7:cb0c:528d::/64 -2a02:26f7:cb0c:528e::/64 -2a02:26f7:cb0c:528f::/64 -2a02:26f7:cb0c:5290::/64 -2a02:26f7:cb0c:5291::/64 -2a02:26f7:cb0c:5292::/64 -2a02:26f7:cb0c:5293::/64 -2a02:26f7:cb0c:5294::/64 -2a02:26f7:cb0c:5295::/64 -2a02:26f7:cb0c:5296::/64 -2a02:26f7:cb0c:5297::/64 -2a02:26f7:cb0c:5298::/64 -2a02:26f7:cb0c:5299::/64 -2a02:26f7:cb0c:529a::/64 -2a02:26f7:cb0c:529b::/64 -2a02:26f7:cb0c:529c::/64 -2a02:26f7:cb0c:529d::/64 -2a02:26f7:cb0c:529e::/64 -2a02:26f7:cb0c:529f::/64 -2a02:26f7:cb0c:52a0::/64 -2a02:26f7:cb0c:52a1::/64 -2a02:26f7:cb0c:52a2::/64 -2a02:26f7:cb0c:52a3::/64 -2a02:26f7:cb0c:52a4::/64 -2a02:26f7:cb0c:52a5::/64 -2a02:26f7:cb0c:52a6::/64 -2a02:26f7:cb0c:52a7::/64 -2a02:26f7:cb0c:52a8::/64 -2a02:26f7:cb0c:52a9::/64 -2a02:26f7:cb0c:52aa::/64 -2a02:26f7:cb0c:52ab::/64 -2a02:26f7:cb0c:52ac::/64 -2a02:26f7:cb0c:52ad::/64 -2a02:26f7:cb0c:52ae::/64 -2a02:26f7:cb0c:52af::/64 -2a02:26f7:cb0c:52b0::/64 -2a02:26f7:cb0c:52b1::/64 -2a02:26f7:cb0c:52b2::/64 -2a02:26f7:cb0c:52b3::/64 -2a02:26f7:cb0c:52b4::/64 -2a02:26f7:cb0c:52b5::/64 -2a02:26f7:cb0c:52b6::/64 -2a02:26f7:cb0c:52b7::/64 -2a02:26f7:cb0c:52b8::/64 -2a02:26f7:cb0c:52b9::/64 -2a02:26f7:cb0c:52ba::/64 -2a02:26f7:cb0c:52bb::/64 -2a02:26f7:cb0c:52bc::/64 -2a02:26f7:cb0c:52bd::/64 -2a02:26f7:cb0c:52be::/64 -2a02:26f7:cb0c:52bf::/64 -2a02:26f7:cb0c:52c0::/64 -2a02:26f7:cb0c:52c1::/64 -2a02:26f7:cb0c:52c2::/64 -2a02:26f7:cb0c:52c3::/64 -2a02:26f7:cb0c:52c4::/64 -2a02:26f7:cb0c:52c5::/64 -2a02:26f7:cb0c:52c6::/64 -2a02:26f7:cb0c:52c7::/64 -2a02:26f7:cb0c:52c8::/64 -2a02:26f7:cb0c:52c9::/64 -2a02:26f7:cb0c:52ca::/64 -2a02:26f7:cb0c:52cb::/64 -2a02:26f7:cb0c:52cc::/64 -2a02:26f7:cb0c:52cd::/64 -2a02:26f7:cb0c:52ce::/64 -2a02:26f7:cb0c:52cf::/64 -2a02:26f7:cb0c:52d0::/64 -2a02:26f7:cb0c:52d1::/64 -2a02:26f7:cb0c:52d2::/64 -2a02:26f7:cb0c:52d3::/64 -2a02:26f7:cb0c:52d4::/64 -2a02:26f7:cb0c:52d5::/64 -2a02:26f7:cb0c:52d6::/64 -2a02:26f7:cb0c:52d7::/64 -2a02:26f7:cb0c:52d8::/64 -2a02:26f7:cb0c:52d9::/64 -2a02:26f7:cb0c::/54 -2a02:26f7:cb0d:4000::/64 -2a02:26f7:cb10:4000::/64 -2a02:26f7:cb10:400::/56 -2a02:26f7:cb10:500::/58 -2a02:26f7:cb10:5280::/64 -2a02:26f7:cb10:5281::/64 -2a02:26f7:cb10:5282::/64 -2a02:26f7:cb10:5283::/64 -2a02:26f7:cb10:5284::/64 -2a02:26f7:cb10:5285::/64 -2a02:26f7:cb10:5286::/64 -2a02:26f7:cb10:5287::/64 -2a02:26f7:cb10:5288::/64 -2a02:26f7:cb10:5289::/64 -2a02:26f7:cb10:528a::/64 -2a02:26f7:cb10:528b::/64 -2a02:26f7:cb10:528c::/64 -2a02:26f7:cb10:528d::/64 -2a02:26f7:cb10:528e::/64 -2a02:26f7:cb10:528f::/64 -2a02:26f7:cb10:5290::/64 -2a02:26f7:cb10:5291::/64 -2a02:26f7:cb10:5292::/64 -2a02:26f7:cb10:5293::/64 -2a02:26f7:cb10:5294::/64 -2a02:26f7:cb10:5295::/64 -2a02:26f7:cb10:5296::/64 -2a02:26f7:cb10:5297::/64 -2a02:26f7:cb10:5298::/64 -2a02:26f7:cb10:5299::/64 -2a02:26f7:cb10:529a::/64 -2a02:26f7:cb10:529b::/64 -2a02:26f7:cb10:529c::/64 -2a02:26f7:cb10:529d::/64 -2a02:26f7:cb10:529e::/64 -2a02:26f7:cb10:529f::/64 -2a02:26f7:cb10:52a0::/64 -2a02:26f7:cb10:52a1::/64 -2a02:26f7:cb10:52a2::/64 -2a02:26f7:cb10:52a3::/64 -2a02:26f7:cb10:52a4::/64 -2a02:26f7:cb10:52a5::/64 -2a02:26f7:cb10:52a6::/64 -2a02:26f7:cb10:52a7::/64 -2a02:26f7:cb10:52a8::/64 -2a02:26f7:cb10:52a9::/64 -2a02:26f7:cb10:52aa::/64 -2a02:26f7:cb10:52ab::/64 -2a02:26f7:cb10:52ac::/64 -2a02:26f7:cb10:52ad::/64 -2a02:26f7:cb10:52ae::/64 -2a02:26f7:cb10:52af::/64 -2a02:26f7:cb10:52b0::/64 -2a02:26f7:cb10:52b1::/64 -2a02:26f7:cb10:52b2::/64 -2a02:26f7:cb10:52b3::/64 -2a02:26f7:cb10:52b4::/64 -2a02:26f7:cb10:52b5::/64 -2a02:26f7:cb10:52b6::/64 -2a02:26f7:cb10:52b7::/64 -2a02:26f7:cb10:52b8::/64 -2a02:26f7:cb10:52b9::/64 -2a02:26f7:cb10:52ba::/64 -2a02:26f7:cb10:52bb::/64 -2a02:26f7:cb10:52bc::/64 -2a02:26f7:cb10:52bd::/64 -2a02:26f7:cb10:52be::/64 -2a02:26f7:cb10:52bf::/64 -2a02:26f7:cb10:52c0::/64 -2a02:26f7:cb10:52c1::/64 -2a02:26f7:cb10:52c2::/64 -2a02:26f7:cb10:52c3::/64 -2a02:26f7:cb10:52c4::/64 -2a02:26f7:cb10:52c5::/64 -2a02:26f7:cb10:52c6::/64 -2a02:26f7:cb10:52c7::/64 -2a02:26f7:cb10:52c8::/64 -2a02:26f7:cb10:52c9::/64 -2a02:26f7:cb10:52ca::/64 -2a02:26f7:cb10:52cb::/64 -2a02:26f7:cb10:52cc::/64 -2a02:26f7:cb10:52cd::/64 -2a02:26f7:cb10:52ce::/64 -2a02:26f7:cb10:52cf::/64 -2a02:26f7:cb10:52d0::/64 -2a02:26f7:cb10:52d1::/64 -2a02:26f7:cb10:52d2::/64 -2a02:26f7:cb10:52d3::/64 -2a02:26f7:cb10:52d4::/64 -2a02:26f7:cb10:52d5::/64 -2a02:26f7:cb10:52d6::/64 -2a02:26f7:cb10:52d7::/64 -2a02:26f7:cb10:52d8::/64 -2a02:26f7:cb10:52d9::/64 -2a02:26f7:cb10::/54 -2a02:26f7:cb11:4000::/64 -2a02:26f7:ed00:4000::/64 -2a02:26f7:ed00:cbc0::/64 -2a02:26f7:ed00:cbc1::/64 -2a02:26f7:ed01:4000::/64 -2a02:26f7:ed02:4000::/64 -2a02:26f7:ed02:cbc0::/64 -2a02:26f7:ed02:cbc1::/64 -2a02:26f7:ed03:4000::/64 -2a02:26f7:ed04:4000::/64 -2a02:26f7:ed04:cbc0::/64 -2a02:26f7:ed04:cbc1::/64 -2a02:26f7:ed05:4000::/64 -2a02:26f7:ed0c:4000::/64 -2a02:26f7:ed0c:cbc0::/64 -2a02:26f7:ed0c:cbc1::/64 -2a02:26f7:ed0d:4000::/64 -2a04:4e41:1303:5000::/52 -2a04:4e41:1303:6000::/52 -2a04:4e41:1303:7000::/52 -2a04:4e41:1324::/48 -2a04:4e41:1603:6000::/52 -2a04:4e41:1603:7000::/52 -2a04:4e41:1603:8000::/52 -2a04:4e41:161a::/48 -2a04:4e41:4100:8000::/52 -2a04:4e41:4100:9000::/52 -2a04:4e41:4100:a000::/52 -2a04:4e41:4112::/48 -2a04:4e41:7403:6000::/52 -2a04:4e41:7403:7000::/52 -2a04:4e41:7403:8000::/52 -2a04:4e41:741a::/48 -2a04:4e41:7800:8000::/52 -2a04:4e41:7800:9000::/52 -2a04:4e41:7800:a000::/52 -2a04:4e41:7812::/48 -2a09:bac2:4fe8::/45 -2a09:bac2:4ff0::/45 -2a09:bac2:4ff8::/45 -2a09:bac2:5000::/45 -2a09:bac2:5008::/45 -2a09:bac2:5010::/45 -2a09:bac3:4fe8::/45 -2a09:bac3:4ff0::/45 -2a09:bac3:4ff8::/45 -2a09:bac3:5000::/45 -2a09:bac3:5008::/45 -2a09:bac3:5010::/45 diff --git a/shared/platform-tools/feedback_footer_link.php b/shared/platform-tools/feedback_footer_link.php deleted file mode 100644 index 30ee3d0..0000000 --- a/shared/platform-tools/feedback_footer_link.php +++ /dev/null @@ -1,25 +0,0 @@ -, na linha onde a ligação deve aparecer (e não antes -// do , 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'; diff --git a/shared/platform-tools/feedback_widget.php b/shared/platform-tools/feedback_widget.php deleted file mode 100644 index ae18636..0000000 --- a/shared/platform-tools/feedback_widget.php +++ /dev/null @@ -1,479 +0,0 @@ -. Aparece só a quem é -// administrador — para os restantes, e para visitantes, não sai daqui uma -// única linha de HTML. -// -// Não depende de nada: nem do Bootstrap (JS ou CSS), nem de ícones, nem de -// jQuery. É um ficheiro incluído no fim de páginas que já são pesadas, e a -// alternativa — assumir que o Bootstrap está carregado — partia-se no primeiro -// site da frota que o não tivesse. O ícone é SVG inline pela mesma razão. -// -// O z-index (1035) é deliberadamente ABAIXO do backdrop dos modais do -// Bootstrap (1050): com um modal aberto a bolha desaparece por baixo dele, em -// vez de ficar a flutuar por cima de um diálogo que pediu a atenção toda. -// -// ─── Dois modos ────────────────────────────────────────────────────────────── -// -// $GLOBALS['platform_feedback_mode'] = 'footer'; // antes do include -// -// 'bubble' (por omissão) é a bolha a flutuar no canto — para o backoffice, onde -// o canto está livre e quem trabalha quer o atalho sempre à vista. -// -// 'footer' é uma ligação dentro do rodapé, sem nada a flutuar — para o site -// público, onde o canto inferior direito JÁ ESTÁ OCUPADO: o dance tem lá o -// botão do WhatsApp (e uma barra fixa em telemóvel). Duas bolhas empilhadas no -// mesmo canto são duas coisas a disputar o mesmo clique, e a que interessa ao -// visitante é a outra. O painel continua a abrir fixo no canto, por cima de -// tudo isso — está aberto por um instante e tem botão de fechar. -// -// Ver functions_feedback.php para o que é enviado, e api/feedback.php para -// quem o recebe. -// ───────────────────────────────────────────────────────────────────────────── - -require_once __DIR__ . '/functions_feedback.php'; -require_once __DIR__ . '/functions_csrf.php'; - -(static function (): void { - - // Uma vez por resposta. O rodapé do backoffice e o do site público podem - // acabar ambos incluídos na mesma página, e dois formulários com os mesmos - // ids não é meio widget a mais: é um widget partido. - if (!empty($GLOBALS['__platform_feedback_rendered'])) { - return; - } - - $user = platform_feedback_user(); - if (!$user || !platform_feedback_is_admin($user)) { - return; - } - - $ligado = hub_ticket_ready(); - - // Sem emparelhamento não há para onde enviar. Ao administrador não se - // mostra nada — não é ele que resolve isto, e um botão que falha é pior do - // que botão nenhum. Ao sysadmin mostra-se, porque é ele que o liga: um - // canal de relatos que desaparece em silêncio quando a chave roda perde - // exactamente os relatos que mais interessavam. - if (!$ligado && !platform_feedback_is_sysadmin($user)) { - return; - } - - $GLOBALS['__platform_feedback_rendered'] = true; - - $h = static fn(string $s): string => htmlspecialchars($s, ENT_QUOTES, 'UTF-8'); - $cats = platform_feedback_categories(); - - // Caminho absoluto, e não base_url(): o site público da fotografia não - // carrega o config/functions.php, e ali base_url() não existe (ver - // ASSETS.md). O docroot é a raiz do domínio em toda a frota. - $endpoint = '/api/feedback.php'; - - // A página onde o sysadmin liga isto. Cada site tem a sua árvore de vistas, - // daí poder ser sobreposta pelo layout antes do include. - // Num site com router (platform_connect(['router' => true])) é o endereço - // dele; o stub antigo só reencaminha. - $hubPage = (string) ($GLOBALS['platform_feedback_hub_url'] - ?? (function_exists('platform_router_enabled') && platform_router_enabled() - ? platform_route_url('hub_integration') - : '/views/sysadmin/hub_integration.php')); - - $inline = ($GLOBALS['platform_feedback_mode'] ?? 'bubble') === 'footer'; -?> -
- - - - - - - -
- - - - - → "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); - } -} diff --git a/shared/platform-tools/functions_antispam.php b/shared/platform-tools/functions_antispam.php deleted file mode 100644 index 0addb4d..0000000 --- a/shared/platform-tools/functions_antispam.php +++ /dev/null @@ -1,274 +0,0 @@ -." 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 '' - . ''; -} - -/** - * 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, '= 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); - } -} diff --git a/shared/platform-tools/functions_antispam_forms.php b/shared/platform-tools/functions_antispam_forms.php deleted file mode 100644 index 877b928..0000000 --- a/shared/platform-tools/functions_antispam_forms.php +++ /dev/null @@ -1,502 +0,0 @@ - 'dsa-antispam|', // NÃO mudar num site a correr: ver abaixo -// 'log_table' => 'blocked_submissions', -// 'keywords' => [...], // vocabulário que não aparece no vosso negócio -// 'native' => '/\b(ola|gostaria|aulas?)\b/u', -// ]); -// -// `key_domain` entra no HMAC do carimbo. Mudá-lo num site a correr invalida os -// formulários que estiverem abertos nesse momento, e essas submissões são -// recusadas em silêncio — leads perdidas por uma questão de arrumação. Por isso -// é configurável e não fixo: cada site mantém o domínio com que já assina. - -require_once __DIR__ . '/functions_settings.php'; -require_once __DIR__ . '/functions_client_ip.php'; - -/** - * Configuração do módulo. Chamar com um array para definir; sem argumentos, lê. - * Os valores por omissão são conservadores de propósito — sem `keywords`, a - * camada de vocabulário simplesmente não pontua. - */ -function platform_antispam_config(?array $set = null): array -{ - static $cfg = [ - 'key_domain' => 'antispam|', - 'log_table' => 'blocked_submissions', - 'min_seconds' => 4, // ninguém preenche um formulário em menos - 'max_seconds' => 21600, // 6 h — separador aberto de manhã ainda serve - 'score_block' => 5, - 'ip_limit' => 5, - 'ip_window' => 60, // minutos - 'keywords' => [], - 'native' => '', // regex das marcas da língua local (ver looks_foreign) - 'setting_key' => 'antispam_key', - ]; - if ($set !== null) { - $cfg = array_merge($cfg, $set); - } - return $cfg; -} - -/** - * Um identificador SQL que podemos interpolar? - * - * Nomes de tabela não podem ir por placeholder, e estas funções recebem-nos de - * quem chama. **Falha aberto**: um identificador inválido é um erro de - * programação do site, e não é o anti-spam que há-de derrubar o formulário por - * causa dele — regista-se e deixa-se passar. - */ -function platform_antispam_ident_ok(string $ident): bool -{ - if (preg_match('/^[A-Za-z0-9_]+$/', $ident)) { - return true; - } - error_log('[antispam] identificador SQL inválido, ignorado: ' . $ident); - return false; -} - -/** - * Chave dos HMAC do carimbo. - * - * Tem de ser **a mesma entre pedidos**: o formulário é assinado num pedido e - * verificado noutro, e uma chave que mudasse a cada pedido recusava toda a - * gente com "carimbo inválido". Daí os três degraus, todos estáveis: - * - * 1. APP_SECRET_KEY / BACKUP_TOKEN do ambiente (o caso normal); - * 2. uma chave aleatória gravada em system_settings, quando não há a primeira; - * 3. derivada da configuração do install, para instalações sem chave e sem a - * tabela de settings. Não é um segredo tão bom como os outros, mas é - * constante — e uma defesa que tranca todos os formulários é pior do que - * uma assinatura mais fraca. - * - * A mesma escada vive agora em platform_derived_key() (functions_settings.php), - * que é o que os módulos novos usam — o captcha, por exemplo. Esta cópia fica - * porque separa os domínios de outra maneira (ver o comentário lá dentro sobre - * a chave do ambiente) e porque trocá-la num site a correr invalida os - * formulários abertos nesse instante. Quem a converter faz uma janela que - * aceite as duas assinaturas antes de tirar a antiga. - */ -function platform_antispam_key(?PDO $pdo = null): string -{ - static $key = null; - if ($key !== null) { - return $key; - } - $cfg = platform_antispam_config(); - - // Em bruto, e NÃO derivada com o domínio, quando vem do ambiente. - // - // Separar domínios seria melhor prática — a mesma chave assina carimbos e - // cifra backups. Mas a chave do HMAC não se muda num site a correr: os - // formulários abertos nesse instante passam a ter carimbo inválido e são - // recusados em silêncio, ou seja, leads perdidas por uma questão de - // arrumação. Fica como está, de propósito, e quem quiser trocar faz uma - // janela que aceite as duas assinaturas antes de tirar a antiga. - $fromEnv = platform_secret_key(); - if ($fromEnv !== null) { - return $key = $fromEnv; - } - - $stored = ''; - if ($pdo instanceof PDO) { - $stored = (string) platform_settings_get($pdo, $cfg['setting_key'], ''); - if ($stored === '') { - $fresh = bin2hex(random_bytes(32)); - try { - // Nunca cifrada: platform_settings_is_secret('antispam_key') é - // falso de propósito. Cifrá-la aqui e lê-la noutro sítio sem - // decifrar dava uma chave diferente a cada leitura, ou seja - // todos os formulários recusados. - platform_settings_set($pdo, $cfg['setting_key'], $fresh, null, false); - $stored = $fresh; - } catch (Throwable $e) { - error_log('[antispam] chave não persistida (' . $e->getMessage() . '); a derivar da configuração'); - } - } - } - - if ($stored === '') { - $env = function (string $k): string { - return function_exists('env') ? (string) env($k, '') : (string) (getenv($k) ?: ''); - }; - $stored = 'derived|' . $env('DB_NAME') . '|' . $env('DB_USER') . '|' . $env('DB_PASS') - . '|' . (defined('APP_BASE_URL') ? APP_BASE_URL : ''); - } - - return $key = hash('sha256', $cfg['key_domain'] . $stored, true); -} - -function platform_antispam_sign(string $form, int $ts, ?PDO $pdo = null): string -{ - return hash_hmac('sha256', $form . '|' . $ts, platform_antispam_key($pdo)); -} - -/** - * Campos-armadilha de um formulário público. `$form` entra na assinatura, para - * que um carimbo colhido no formulário de contacto não sirva no de inscrição. - * - * O '; - } - - 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 ' - . '' - . '
Durante ' . (int) $days . ' dias neste dispositivo. Não marque num computador partilhado.
' - . ''; -} diff --git a/shared/platform-tools/functions_mail.php b/shared/platform-tools/functions_mail.php deleted file mode 100644 index 97d5f43..0000000 --- a/shared/platform-tools/functions_mail.php +++ /dev/null @@ -1,336 +0,0 @@ - $first(['SMTP_HOST']), - 'port' => (int) ($get('SMTP_PORT', '587') ?: 587), - 'username' => $first(['SMTP_USERNAME', 'SMTP_USER']), - 'password' => $first(['SMTP_PASSWORD', 'SMTP_PASS']), - 'secure' => $first(['SMTP_SECURE'], 'tls'), - 'from' => $first(['EMAIL_FROM', 'SMTP_FROM_EMAIL', 'SMTP_FROM']), - 'from_name' => $first(['EMAIL_FROM_NAME', 'NOTIFY_FROM_NAME']), - // Safety nets. Only consulted when APP_ENV is not 'prod', so a - // misconfigured production install cannot silently swallow mail. - 'env' => $get('APP_ENV', 'prod'), - 'dev_print' => filter_var($get('EMAIL_DEV_PRINT', ''), FILTER_VALIDATE_BOOLEAN), - 'dev_redirect' => $get('EMAIL_DEV_REDIRECT'), - ]; - - if ($override) { - foreach ($override as $k => $v) { - // Only accept keys we know, and never let an override blank a value - // the host already resolved -- a partial array must not wipe the rest. - if (array_key_exists($k, $cfg) && $v !== null && $v !== '') $cfg[$k] = $v; - } - } - return $cfg; -} - -function platform_mail_is_configured(?array $override = null): bool { - return platform_mail_config($override)['host'] !== ''; -} - -/** - * Loads PHPMailer, preferring composer's autoloader and falling back to the - * hand-vendored copy some installs carry at vendor/PHPMailer/src/. - * Both layouts exist across the fleet, so neither can be assumed. - */ -function platform_mail_available(string $repo_root = ''): bool { - if (class_exists(\PHPMailer\PHPMailer\PHPMailer::class)) return true; - - $root = $repo_root !== '' ? rtrim($repo_root, '/') : dirname(__DIR__, 2); - foreach ([$root . '/vendor/autoload.php'] as $auto) { - if (is_file($auto)) { - require_once $auto; - if (class_exists(\PHPMailer\PHPMailer\PHPMailer::class)) return true; - } - } - $base = $root . '/vendor/PHPMailer/src/'; - if (is_file($base . 'PHPMailer.php')) { - require_once $base . 'Exception.php'; - require_once $base . 'PHPMailer.php'; - require_once $base . 'SMTP.php'; - return class_exists(\PHPMailer\PHPMailer\PHPMailer::class); - } - return false; -} - -/** A configured PHPMailer, or null when the class cannot be loaded. */ -function platform_mail_make(string $repo_root = '', ?array $override = null) { - if (!platform_mail_available($repo_root)) return null; - - $c = platform_mail_config($override); - $mail = new \PHPMailer\PHPMailer\PHPMailer(true); // true = throw on failure - $mail->CharSet = 'UTF-8'; - - if ($c['host'] !== '') { - $mail->isSMTP(); - $mail->Host = $c['host']; - $mail->Port = $c['port']; - $mail->SMTPAuth = ($c['username'] !== ''); - $mail->Username = $c['username']; - $mail->Password = $c['password']; - // '' and 'none' both mean plaintext; assigning either to SMTPSecure - // makes PHPMailer attempt a protocol it cannot negotiate. - if ($c['secure'] !== '' && strtolower($c['secure']) !== 'none') { - $mail->SMTPSecure = $c['secure']; - } - } else { - $mail->isMail(); // hand off to the local MTA if one exists - } - - if ($c['from'] !== '') { - $mail->setFrom($c['from'], $c['from_name']); - } - return $mail; -} - -/** - * Sends one message. - * - * $msg: to, to_name, subject, body (html), alt_body, reply_to, reply_to_name, - * attachments [[path, name], …] - * - * Returns ['ok' => bool, 'skipped' => bool, 'error' => ?string]. - * 'skipped' is true when a dev safety net swallowed the message on purpose -- - * distinct from a failure, so a queue worker can mark it sent rather than - * retrying forever against a dev box. - */ -function platform_mail_send(array $msg, string $repo_root = '', ?array $override = null, ?PDO $pdo = null): array { - $c = platform_mail_config($override); - $to = trim((string) ($msg['to'] ?? '')); - if ($to === '') { - return ['ok' => false, 'skipped' => false, 'error' => 'Destinatário vazio.']; - } - - $isProd = ($c['env'] === 'prod'); - - // Log instead of sending. Checked BEFORE loading PHPMailer so a host with - // no mailer installed can still run end to end in development. - if (!$isProd && $c['dev_print']) { - error_log(sprintf('[mail:dev_print] to=%s subject=%s', $to, (string) ($msg['subject'] ?? ''))); - return ['ok' => true, 'skipped' => true, 'error' => null]; - } - - // Tecto de envio. Com $pdo, e antes de se gastar tempo a montar o PHPMailer. - // Uma mensagem recusada aqui devolve rate_limited: quem chama de uma fila - // deve deixá-la pendente e voltar a tentar, não marcá-la como falhada. - if ($pdo instanceof PDO) { - require_once __DIR__ . '/functions_mail_rate.php'; - $capped = platform_mail_rate_blocked($pdo); - if ($capped !== null) { - error_log('[mail-rate] envio adiado: ' . $capped); - require_once __DIR__ . '/functions_security_events.php'; - platform_security_event($pdo, 'mail_rate_limited', $capped, ''); - return ['ok' => false, 'skipped' => false, 'rate_limited' => true, 'error' => $capped]; - } - } - - $mail = platform_mail_make($repo_root, $override); - if ($mail === null) { - return ['ok' => false, 'skipped' => false, - 'error' => 'PHPMailer indisponível — corre "composer install", ou activa EMAIL_DEV_PRINT fora de produção.']; - } - - try { - // Redirect every recipient to one inbox outside production, so testing - // against a copy of the live database cannot mail real clients. - if (!$isProd && $c['dev_redirect'] !== '') { - $mail->addAddress($c['dev_redirect']); - $mail->Subject = '[DEV] ' . (string) ($msg['subject'] ?? ''); - // Banner naming the real recipient. Without it a redirected mailbox - // fills with messages that all look addressed to itself, and it is - // impossible to tell who each one was actually for. - $orig = $to . (($msg['to_name'] ?? '') !== '' ? ' <' . $msg['to_name'] . '>' : ''); - $banner = '
' - . '[DEV redirect] Destinatário original: ' - . htmlspecialchars($orig, ENT_QUOTES, 'UTF-8') . '
'; - $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], - ]; -} diff --git a/shared/platform-tools/functions_mail_rate.php b/shared/platform-tools/functions_mail_rate.php deleted file mode 100644 index 8ec6879..0000000 --- a/shared/platform-tools/functions_mail_rate.php +++ /dev/null @@ -1,153 +0,0 @@ -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; - } -} diff --git a/shared/platform-tools/functions_migrations.php b/shared/platform-tools/functions_migrations.php deleted file mode 100644 index b0f7046..0000000 --- a/shared/platform-tools/functions_migrations.php +++ /dev/null @@ -1,208 +0,0 @@ -/shared/platform-tools/functions_migrations.php -// → resolves to /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; -} diff --git a/shared/platform-tools/functions_password_reset.php b/shared/platform-tools/functions_password_reset.php deleted file mode 100644 index 825b5da..0000000 --- a/shared/platform-tools/functions_password_reset.php +++ /dev/null @@ -1,214 +0,0 @@ -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 = "

Olá {$safeName},

" - . "

Recebemos um pedido para redefinir a palavra-passe da tua conta em {$safeApp}.

" - . "

Definir uma nova palavra-passe

" - . "

O link expira dentro de {$minutes} minutos e só pode ser usado uma vez.

" - . "

Se não foste tu, ignora este email — a palavra-passe atual mantém-se.

"; - - $text = "Olá {$name},\n\n" - . "Recebemos um pedido para redefinir a palavra-passe da tua conta em {$appName}.\n\n" - . "{$link}\n\n" - . "O link expira dentro de {$minutes} minutos e só pode ser usado uma vez.\n" - . "Se não foste tu, ignora este email — a palavra-passe atual mantém-se.\n"; - - $res = platform_mail_send( - [ - 'to' => $email, - 'to_name' => $name, - 'subject' => 'Redefinir a palavra-passe — ' . $appName, - 'body' => $html, - 'alt_body' => $text, - ], - (string) ($opts['repo_root'] ?? ''), - platform_smtp_config($pdo) - ); - - if (empty($res['ok'])) { - error_log('[password_reset] send failed: ' . (string) ($res['error'] ?? 'unknown')); - return ['ok' => true, 'sent' => false, 'error' => null, - 'delivery_error' => 'Não foi possível enviar o email. Verifica a configuração de SMTP.']; - } - return ['ok' => true, 'sent' => true, 'error' => null, 'delivery_error' => null]; -} - -/** Returns the user row a valid, unexpired, unused token belongs to. */ -function password_reset_validate(PDO $pdo, string $token): ?array { - password_reset_ensure_table($pdo); - $s = auth_schema(); - - $st = $pdo->prepare( - "SELECT r.id AS reset_id, r.user_id - FROM password_resets r - WHERE r.token_hash = ? AND r.used_at IS NULL AND r.expires_at > NOW() - LIMIT 1" - ); - $st->execute([password_reset_hash($token)]); - $row = $st->fetch(PDO::FETCH_ASSOC); - if (!$row) return null; - - $st = $pdo->prepare( - "SELECT * FROM `{$s['table']}` WHERE `{$s['id']}` = ? AND " . auth_active_sql() . " LIMIT 1" - ); - $st->execute([(int) $row['user_id']]); - $user = $st->fetch(PDO::FETCH_ASSOC) ?: null; - if ($user === null) return null; - - $user['_reset_id'] = (int) $row['reset_id']; - return $user; -} - -/** - * Set the new password and burn every outstanding token for that user. - * - * Wrapped in a transaction: a password changed without its token being - * invalidated would leave a working reset link behind. - */ -function password_reset_consume(PDO $pdo, string $token, string $newPassword, array $opts = []): array { - $minLength = (int) ($opts['min_length'] ?? 10); - if (mb_strlen($newPassword) < $minLength) { - return ['ok' => false, 'error' => "A palavra-passe tem de ter pelo menos {$minLength} caracteres."]; - } - - $user = password_reset_validate($pdo, $token); - if ($user === null) { - return ['ok' => false, 'error' => 'Link inválido ou expirado. Pede um novo.']; - } - - $s = auth_schema(); - try { - $pdo->beginTransaction(); - $pdo->prepare("UPDATE `{$s['table']}` SET `{$s['password']}` = ? WHERE `{$s['id']}` = ?") - ->execute([auth_password_hash($newPassword), (int) $user[$s['id']]]); - $pdo->prepare("UPDATE password_resets SET used_at = NOW() WHERE user_id = ? AND used_at IS NULL") - ->execute([(int) $user[$s['id']]]); - $pdo->commit(); - } catch (Throwable $e) { - if ($pdo->inTransaction()) $pdo->rollBack(); - error_log('[password_reset] consume failed: ' . $e->getMessage()); - return ['ok' => false, 'error' => 'Não foi possível guardar a nova palavra-passe.']; - } - - // A completed reset is a privilege change; no pre-existing session survives. - auth_session_regenerate(); - auth_throttle_clear($pdo, (string) ($user[$s['email']] ?? '')); - - // Uma palavra-passe reposta termina as sessões lembradas em todos os - // dispositivos: repor é o que se faz quando se suspeita de que alguém a tem. - // (O dance fazia-o à mão; passou a ser aqui, para toda a frota.) - require_once __DIR__ . '/functions_remember.php'; - try { auth_remember_forget_user($pdo, (int) $user[$s['id']]); } - catch (Throwable $e) { error_log('[password_reset] remember: ' . $e->getMessage()); } - - return ['ok' => true, 'error' => null, 'user_id' => (int) $user[$s['id']]]; -} - -/** Housekeeping for a cron job: drop spent and expired rows. */ -function password_reset_prune(PDO $pdo, int $keepDays = 7): int { - password_reset_ensure_table($pdo); - $st = $pdo->prepare( - "DELETE FROM password_resets - WHERE (used_at IS NOT NULL OR expires_at < NOW()) - AND created_at < (NOW() - INTERVAL ? DAY)" - ); - $st->execute([$keepDays]); - return $st->rowCount(); -} diff --git a/shared/platform-tools/functions_remember.php b/shared/platform-tools/functions_remember.php deleted file mode 100644 index 4cd4814..0000000 --- a/shared/platform-tools/functions_remember.php +++ /dev/null @@ -1,379 +0,0 @@ -:`. -// -// selector procura a linha. Está em claro na base de dados e é UNIQUE, por -// isso a consulta é um índice, não uma varredura. -// validator prova que o cookie é mesmo aquele. Na base de dados só existe o -// SHA-256 dele. -// -// A razão para os separar é a mesma que faz guardar palavras-passe com hash: -// quem leve um dump da tabela não consegue forjar um cookie a partir dele. Um -// esquema de token único obrigaria a procurar pelo próprio segredo — ou o -// segredo fica em claro na base de dados, ou não há forma de o indexar. A -// comparação é feita com hash_equals(), não com ===, para não dar o resultado -// por temporização. -// -// ROTAÇÃO E DETECÇÃO DE ROUBO -// -// Cada utilização gasta o validator e emite outro. Um cookie roubado vale, por -// isso, até à próxima visita do dono — e quando o dono volta, apresenta um -// validator que já não bate certo com um selector que existe. Isso não acontece -// por acidente: é a assinatura de uma cópia em circulação. Nesse caso apagam-se -// TODOS os tokens da conta, o que corta o atacante e obriga o dono a entrar de -// novo. Preferimos o incómodo à sessão silenciosamente partilhada. -// -// O QUE ISTO NÃO É -// -// Não é uma sessão privilegiada. `auth_remember_active()` diz se a sessão -// actual nasceu de um cookie em vez de uma palavra-passe escrita agora, para -// que uma operação sensível (mudar palavra-passe, apagar conta) possa exigir -// autenticação fresca. Cabe a cada página decidir — este ficheiro só regista -// a proveniência. - -require_once __DIR__ . '/functions_auth.php'; -require_once __DIR__ . '/functions_client_ip.php'; - -/** - * Nome do cookie. Prefixado por instalação para que dois sites no mesmo domínio - * (fotografia e formação são ambos *.andmore.pt) não se pisem: o cookie de um - * chegaria ao outro, que procuraria o selector na SUA tabela e não o - * encontraria — um logout sem explicação a cada troca de separador. - */ -function auth_remember_cookie_name(): string { - // Uma constante do site ganha ao .env: é como um site que já tinha cookie - // próprio antes deste módulo (o dance, `dsa_remember`) passa a usá-lo sem - // deitar fora as sessões lembradas que já existiam. - if (defined('AUTH_REMEMBER_COOKIE') && (string) AUTH_REMEMBER_COOKIE !== '') { - return (string) AUTH_REMEMBER_COOKIE; - } - $name = function_exists('env') ? (string) env('AUTH_REMEMBER_COOKIE', '') : ''; - return $name !== '' ? $name : 'am_remember'; -} - -/** Validade, em dias. Trinta é o costume; abaixo de 1 desliga o mecanismo. */ -function auth_remember_days(): int { - $d = function_exists('env') ? (int) env('AUTH_REMEMBER_DAYS', 30) : 30; - return $d > 0 ? $d : 30; -} - -function auth_remember_ensure_table(PDO $pdo): void { - // Sem chave estrangeira para users: o nome da tabela de contas varia de site - // para site (é o que auth_schema() existe para resolver) e o tipo da coluna - // id também — INT UNSIGNED nuns, BIGINT noutros. Uma FK aqui seria uma - // promessa que este ficheiro não pode cumprir em todo o lado. - $pdo->exec( - "CREATE TABLE IF NOT EXISTS `auth_remember_tokens` ( - `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - `user_id` BIGINT UNSIGNED NOT NULL, - `selector` CHAR(32) NOT NULL, - `validator_hash` CHAR(64) NOT NULL, - `expires_at` DATETIME NOT NULL, - `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - `last_used_at` DATETIME NULL, - `last_ip` VARCHAR(45) NULL, - `user_agent` VARCHAR(255) NULL, - PRIMARY KEY (`id`), - UNIQUE KEY `uq_remember_selector` (`selector`), - KEY `idx_remember_user` (`user_id`), - KEY `idx_remember_expires` (`expires_at`) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" - ); -} - -/** Escreve (ou apaga, com $value = null) o cookie com os parâmetros certos. */ -function auth_remember_set_cookie(?string $value, int $expires = 0): void { - if (headers_sent()) return; // nada a fazer, e um warning não ajudaria - - $https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') - || (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https'); - - setcookie(auth_remember_cookie_name(), (string) $value, [ - 'expires' => $expires, - 'path' => '/', - 'secure' => $https, - 'httponly' => true, - // Lax e não Strict: com Strict, quem chega ao site por um link de fora - // (um email com a ligação para a plataforma) não traz o cookie e vê o - // formulário de login — exactamente a situação que isto devia evitar. - 'samesite' => 'Lax', - ]); -} - -/** Parte o cookie nos seus dois campos. null se estiver ausente ou deformado. */ -function auth_remember_parse_cookie(): ?array { - $raw = (string) ($_COOKIE[auth_remember_cookie_name()] ?? ''); - if ($raw === '' || substr_count($raw, ':') !== 1) return null; - - [$selector, $validator] = explode(':', $raw, 2); - if (strlen($selector) !== 32 || !ctype_xdigit($selector)) return null; - if (strlen($validator) !== 64 || !ctype_xdigit($validator)) return null; - - return ['selector' => $selector, 'validator' => $validator]; -} - -/** - * Emite um token novo para $userId e manda o cookie. - * - * Chamado depois de uma autenticação bem sucedida, e só quando o utilizador - * pediu para ser lembrado. - */ -function auth_remember_issue(PDO $pdo, int $userId): void { - if ($userId <= 0) return; - - auth_remember_ensure_table($pdo); - - $selector = bin2hex(random_bytes(16)); // 32 hex - $validator = bin2hex(random_bytes(32)); // 64 hex - $expires = time() + auth_remember_days() * 86400; - - try { - $pdo->prepare( - "INSERT INTO `auth_remember_tokens` - (`user_id`, `selector`, `validator_hash`, `expires_at`, `last_ip`, `user_agent`) - VALUES (?, ?, ?, ?, ?, ?)" - )->execute([ - $userId, - $selector, - hash('sha256', $validator), - date('Y-m-d H:i:s', $expires), - platform_client_ip(), - mb_substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255), - ]); - } catch (Throwable $e) { - // Um login válido nunca deve falhar porque o "lembrar-me" não coube. - error_log('[auth/remember] issue failed: ' . $e->getMessage()); - return; - } - - auth_remember_set_cookie($selector . ':' . $validator, $expires); - - // Barato, e evita que a tabela cresça para sempre numa instalação sem cron. - auth_remember_prune($pdo); -} - -/** - * Tenta repor a sessão a partir do cookie. - * - * Devolve a linha do utilizador (e deixa a sessão iniciada) ou null. Não - * redirecciona: quem chama decide o que fazer com o null. - * - * $opts — as duas coisas que um site pode ter de dizer, e que este ficheiro não - * pode adivinhar. Sem elas o comportamento é o de sempre. - * - * 'allow' => fn(PDO $pdo, array $user): bool - * Uma regra do site sobre quem pode entrar, a somar ao auth_active_sql() - * (ex.: o dance recusa uma conta com o email por confirmar fora do prazo). - * false trata-se como a conta ter deixado de existir: caem todos os - * tokens dela, porque a mesma recusa valeria para qualquer outro. - * - * 'login' => fn(PDO $pdo, array $user): void - * O que abre a sessão, em vez do auth_login() nu. Um site que guarda mais - * na sessão do que o contrato partilhado (o dance põe lá a linha da conta - * e os papéis) tem de ser ele a abri-la — senão a sessão reposta pelo - * cookie seria diferente da que o login abre, e o portal via-a vazia. - * Tem de chamar auth_login() (ou algo que o chame). - */ -function auth_remember_resume(PDO $pdo, array $opts = []): ?array { - if (auth_check()) return auth_user(); // já há sessão; nada a repor - $cookie = auth_remember_parse_cookie(); - if ($cookie === null) return null; - - try { - auth_remember_ensure_table($pdo); - $st = $pdo->prepare( - "SELECT * FROM `auth_remember_tokens` WHERE `selector` = ? LIMIT 1" - ); - $st->execute([$cookie['selector']]); - $row = $st->fetch(PDO::FETCH_ASSOC) ?: null; - } catch (Throwable $e) { - error_log('[auth/remember] lookup failed: ' . $e->getMessage()); - return null; - } - - if ($row === null) { - // Selector desconhecido — token já gasto, expirado e podado, ou lixo. - auth_remember_forget_cookie(); - return null; - } - - if (!hash_equals((string) $row['validator_hash'], hash('sha256', $cookie['validator']))) { - // Selector certo, validator errado. Ver a nota sobre rotação no topo: - // isto é a assinatura de um cookie em circulação, não um engano. - error_log('[auth/remember] validator mismatch for user ' . (int) $row['user_id'] - . ' — a revogar todos os tokens da conta'); - auth_remember_forget_user($pdo, (int) $row['user_id']); - auth_remember_forget_cookie(); - return null; - } - - if (strtotime((string) $row['expires_at']) <= time()) { - auth_remember_delete($pdo, (string) $row['selector']); - auth_remember_forget_cookie(); - return null; - } - - // Token bom. Carregar a conta pelo mapa de colunas do site — e voltar a - // passar por auth_active_sql(), porque uma conta desactivada depois de o - // cookie ser emitido não pode entrar por esta porta. - $s = auth_schema(); - try { - $u = $pdo->prepare( - "SELECT * FROM `{$s['table']}` WHERE `{$s['id']}` = ? AND " . auth_active_sql() . " LIMIT 1" - ); - $u->execute([(int) $row['user_id']]); - $user = $u->fetch(PDO::FETCH_ASSOC) ?: null; - } catch (Throwable $e) { - error_log('[auth/remember] user load failed: ' . $e->getMessage()); - return null; - } - - if ($user !== null && isset($opts['allow']) && is_callable($opts['allow'])) { - try { - if (!($opts['allow'])($pdo, $user)) { - $user = null; - } - } catch (Throwable $e) { - // Falha aberta para o ecrã de login, não para dentro do portal: uma - // regra que não se conseguiu avaliar não deixa ninguém passar. - error_log('[auth/remember] allow hook failed: ' . $e->getMessage()); - return null; - } - } - - if ($user === null) { - auth_remember_forget_user($pdo, (int) $row['user_id']); - auth_remember_forget_cookie(); - return null; - } - - try { - if (isset($opts['login']) && is_callable($opts['login'])) { - ($opts['login'])($pdo, $user); - } else { - auth_login($pdo, $user); - } - } catch (Throwable $e) { - // Um cookie nunca pode partir a página — nem o site público, que também - // passa por aqui. Quem vier a seguir vê o ecrã de login. - error_log('[auth/remember] login failed: ' . $e->getMessage()); - return null; - } - $_SESSION['_auth_remembered'] = true; - - // Rotação: o validator que acabou de ser usado deixa de valer. - auth_remember_rotate($pdo, (string) $row['selector']); - - return $user; -} - -/** Gasta o validator actual e emite outro para o mesmo selector. */ -function auth_remember_rotate(PDO $pdo, string $selector): void { - $validator = bin2hex(random_bytes(32)); - $expires = time() + auth_remember_days() * 86400; - - try { - $pdo->prepare( - "UPDATE `auth_remember_tokens` - SET `validator_hash` = ?, `expires_at` = ?, `last_used_at` = NOW(), `last_ip` = ? - WHERE `selector` = ?" - )->execute([ - hash('sha256', $validator), - date('Y-m-d H:i:s', $expires), - platform_client_ip(), - $selector, - ]); - } catch (Throwable $e) { - error_log('[auth/remember] rotate failed: ' . $e->getMessage()); - return; - } - - auth_remember_set_cookie($selector . ':' . $validator, $expires); -} - -/** Apaga o cookie do browser, sem tocar na base de dados. */ -function auth_remember_forget_cookie(): void { - auth_remember_set_cookie('', time() - 42000); - unset($_COOKIE[auth_remember_cookie_name()]); -} - -function auth_remember_delete(PDO $pdo, string $selector): void { - try { - $pdo->prepare("DELETE FROM `auth_remember_tokens` WHERE `selector` = ?") - ->execute([$selector]); - } catch (Throwable $e) { - error_log('[auth/remember] delete failed: ' . $e->getMessage()); - } -} - -/** - * Esquece este dispositivo: apaga o token que o cookie actual identifica e o - * próprio cookie. É o que o logout deve chamar — sair numa máquina não pode - * terminar a sessão do telemóvel. - */ -function auth_remember_clear(PDO $pdo): void { - $cookie = auth_remember_parse_cookie(); - if ($cookie !== null) auth_remember_delete($pdo, $cookie['selector']); - auth_remember_forget_cookie(); -} - -/** - * Esquece TODOS os dispositivos de uma conta. Para a mudança de palavra-passe, - * para a suspeita de roubo, e para o botão "terminar sessão em todo o lado". - */ -function auth_remember_forget_user(PDO $pdo, int $userId): int { - try { - $st = $pdo->prepare("DELETE FROM `auth_remember_tokens` WHERE `user_id` = ?"); - $st->execute([$userId]); - return $st->rowCount(); - } catch (Throwable $e) { - error_log('[auth/remember] forget_user failed: ' . $e->getMessage()); - return 0; - } -} - -/** Remove tokens expirados. Idempotente, barato, seguro de chamar sempre. */ -function auth_remember_prune(PDO $pdo): void { - try { - $pdo->exec("DELETE FROM `auth_remember_tokens` WHERE `expires_at` <= NOW()"); - } catch (Throwable $e) { - error_log('[auth/remember] prune failed: ' . $e->getMessage()); - } -} - -/** - * A sessão actual veio de um cookie em vez de uma palavra-passe escrita agora? - * - * Quem for pedir uma operação sensível deve exigir autenticação fresca quando - * isto for verdade. - */ -function auth_remember_active(): bool { - return !empty($_SESSION['_auth_remembered']); -} - -/** Tokens activos de uma conta — para uma página "as minhas sessões". */ -function auth_remember_list(PDO $pdo, int $userId): array { - try { - $st = $pdo->prepare( - "SELECT `id`, `selector`, `created_at`, `last_used_at`, `last_ip`, `user_agent`, `expires_at` - FROM `auth_remember_tokens` - WHERE `user_id` = ? AND `expires_at` > NOW() - ORDER BY `last_used_at` DESC, `created_at` DESC" - ); - $st->execute([$userId]); - return $st->fetchAll(PDO::FETCH_ASSOC) ?: []; - } catch (Throwable $e) { - error_log('[auth/remember] list failed: ' . $e->getMessage()); - return []; - } -} diff --git a/shared/platform-tools/functions_restore.php b/shared/platform-tools/functions_restore.php deleted file mode 100644 index 9dd0202..0000000 --- a/shared/platform-tools/functions_restore.php +++ /dev/null @@ -1,355 +0,0 @@ -/shared/platform-tools/): -// backup_dir() → /backups/ (source of artefacts) -// BACKUP_FILES_DIR → subtree the files backup archives, relative to -// -// 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; -} diff --git a/shared/platform-tools/functions_routes.php b/shared/platform-tools/functions_routes.php deleted file mode 100644 index a973ffa..0000000 --- a/shared/platform-tools/functions_routes.php +++ /dev/null @@ -1,105 +0,0 @@ - ['file' => 'index.php'], - 'platform_update' => ['file' => 'platform_update.php'], - 'backups' => ['file' => 'backups.php', 'needs_db' => true, 'backup_helpers' => true], - 'restore' => ['file' => 'restore.php', 'needs_db' => true, 'backup_helpers' => true], - 'ip_bans' => ['file' => 'ip_bans.php', 'needs_db' => true], - 'geo' => ['file' => 'geo.php'], - 'tracking' => ['file' => 'tracking.php', 'needs_db' => true], - 'hub_integration' => ['file' => 'hub_integration.php'], - 'smtp_status' => ['file' => 'smtp_status.php', 'needs_db' => true], - 'audit' => ['file' => 'audit.php', 'needs_db' => true], - ]; -} - -/** - * Os endpoints partilhados que o public/api/platform.php de um site serve - * (api/router.php). Cada um continua a autenticar-se a si próprio — chave de - * saúde, chave de operações, HMAC, sessão + CSRF —; o router só despacha. - * - * Os URLs antigos (api/health.php, api/hub_ops.php, …) ficam onde estão: os - * webhooks do Gitea e os caminhos que o hub guarda por plataforma apontam para - * eles. Um endpoint NOVO entra só aqui e chega a todos os sites sem stub. - */ -function platform_api_routes(): array { - return [ - 'health' => 'api/hub_health.php', - 'hub_ops' => 'api/hub_ops.php', - 'hub_pair' => 'api/hub_pair.php', - 'feedback' => 'api/feedback.php', - 'agent' => 'api/agent.php', - 'auto_update' => 'api/auto_update.php', - 'backup_trigger' => 'api/backup_trigger.php', - 'backup_download' => 'api/backup_download.php', - ]; -} - -/** O site declarou `'router' => true` no platform_connect() (tem o public/platform.php). */ -function platform_router_enabled(): bool { - return function_exists('platform_config') && !empty(platform_config()['router']); -} - -/** - * URLs sem `.php`? Só quando o site o declara (`'clean_urls' => true`), porque - * precisa da regra do .htaccess que serve `/platform` a partir de - * `platform.php` — sem ela, o URL limpo dava 404. - */ -function platform_clean_urls(): bool { - return function_exists('platform_config') && !empty(platform_config()['clean_urls']); -} - -/** URL de uma ferramenta pelo router; $query sem o `?` (p. ex. 'tab=db'). */ -function platform_route_url(string $tool, string $query = ''): string { - $script = platform_clean_urls() ? 'platform' : 'platform.php'; - $path = $script . '?tool=' . rawurlencode($tool) . ($query !== '' ? '&' . ltrim($query, '?&') : ''); - return platform_route_absolute_url($path); -} - -/** URL de um endpoint pelo router da API. */ -function platform_api_url(string $endpoint, string $query = ''): string { - $script = platform_clean_urls() ? 'api/platform' : 'api/platform.php'; - $path = $script . '?endpoint=' . rawurlencode($endpoint) . ($query !== '' ? '&' . ltrim($query, '?&') : ''); - return platform_route_absolute_url($path); -} - -/** Um caminho relativo ao base_url passa a absoluto; um URL ou caminho absoluto fica. */ -function platform_route_absolute_url(string $url): string { - if ($url === '' || preg_match('#^([a-z][a-z0-9+.-]*:)?//#i', $url) || $url[0] === '/') return $url; - if (function_exists('base_url')) return (string) base_url($url); - if (function_exists('hub_base_url')) return hub_base_url($url); - return '/' . $url; -} - -/** - * As ferramentas que este site serve, com o URL de cada uma — anunciadas no - * health para o hub poder ligar para a página local. [] sem router: os stubs - * antigos variam de site para site, e anunciar um URL que dá 404 é pior do que - * não anunciar. - */ -function platform_routes_announced(): array { - if (!platform_router_enabled()) return []; - $out = []; - foreach (array_keys(platform_routes()) as $tool) $out[$tool] = platform_route_url($tool); - return $out; -} - -/** O mesmo para os endpoints, com a mesma condição. */ -function platform_api_routes_announced(): array { - if (!platform_router_enabled()) return []; - $out = []; - foreach (array_keys(platform_api_routes()) as $endpoint) $out[$endpoint] = platform_api_url($endpoint); - return $out; -} diff --git a/shared/platform-tools/functions_schema.php b/shared/platform-tools/functions_schema.php deleted file mode 100644 index df4e19a..0000000 --- a/shared/platform-tools/functions_schema.php +++ /dev/null @@ -1,159 +0,0 @@ -/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), - ]; -} diff --git a/shared/platform-tools/functions_security_events.php b/shared/platform-tools/functions_security_events.php deleted file mode 100644 index 939f26e..0000000 --- a/shared/platform-tools/functions_security_events.php +++ /dev/null @@ -1,398 +0,0 @@ -/api/security_events.php, autenticado com a -// MESMA chave com que o hub sonda esta plataforma (X-Hub-Key). A resposta traz -// os banimentos activos da frota, que entram no ip_bans local com -// source='fleet' — um IP banido na dance passa a ser recusado na fotografia. -// -// Porquê uma caixa de saída e não um POST por evento: um ataque gera centenas -// de recusas por minuto. Um pedido HTTP por recusa punha cada pedido do -// atacante à espera da rede e transformava o ataque a um site num ataque ao -// hub. Em lote, mil recusas são um pedido. -// -// Quando é que o lote sai: -// • no fim de um pedido qualquer (platform_ip_guard() regista-o), no máximo a -// cada HUB_SECURITY_FLUSH_EVERY s se há eventos e HUB_SECURITY_SYNC_EVERY s -// se não há — sem eventos continua a sincronizar, é assim que os -// banimentos dos OUTROS sites chegam aqui; -// • pelo cron: bin/hub-security-sync.php, que esvazia a caixa toda. -// -// Nada disto bloqueia nem rebenta. Sem hub emparelhado não se regista nada -// (senão a caixa crescia para sempre); com o hub em baixo os eventos esperam, -// com tecto, e os banimentos locais funcionam exactamente como antes. -// -// Origem de cada decisão sobre banimentos da frota: -// • um banimento chega ao hub só a partir do site que o decidiu — as cópias -// (source='fleet') nunca são reenviadas, senão ecoavam em ciclo; -// • levantar um banimento em QUALQUER site levanta-o na frota: as cópias -// desaparecem de todos na sincronização seguinte. Sem isto, um unban local -// de uma cópia era desfeito cinco minutos depois. O site de origem mantém -// o seu próprio banimento até expirar ou ser levantado lá. - -require_once __DIR__ . '/functions_client_ip.php'; - -const HUB_SECURITY_FLUSH_EVERY = 30; -const HUB_SECURITY_SYNC_EVERY = 300; -const HUB_SECURITY_BATCH = 200; -// Tecto da caixa com o hub em baixo. Os banimentos nunca são descartados; o -// resto sai do mais antigo para o mais recente. -const HUB_SECURITY_OUTBOX_MAX = 5000; - -function hub_security_enabled(): bool { - static $on = null; - if ($on !== null) return $on; - try { - if (!function_exists('hub_url')) require_once __DIR__ . '/functions_hub.php'; - $on = hub_url() !== '' && hub_api_key() !== ''; - } catch (Throwable $e) { - $on = false; - } - return $on; -} - -/** - * Carimbos em ficheiro, para o fim de cada pedido decidir «há sincronização a - * fazer?» com dois stat() e sem tocar na base de dados. - */ -function hub_security_stamp(string $name): string { - return rtrim(sys_get_temp_dir(), '/\\') . '/hub-security-' - . substr(md5(dirname(__DIR__, 2)), 0, 12) . '-' . $name; -} - -function hub_security_ensure_table(PDO $pdo): void { - static $done = false; - if ($done) return; - $done = true; - // occurred_at em epoch e não DATETIME: o hub corre noutro contentor, e um - // DATETIME sem fuso lido do outro lado seria uma hora errada em metade do ano. - $pdo->exec( - "CREATE TABLE IF NOT EXISTS `hub_security_outbox` ( - `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, - `kind` VARCHAR(40) NOT NULL, - `ip` VARCHAR(45) NULL, - `detail` VARCHAR(190) NULL, - `data` TEXT NULL, - `occurred_at` INT UNSIGNED NOT NULL, - `attempts` INT NOT NULL DEFAULT 0, - KEY `idx_hso_kind` (`kind`) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" - ); -} - -/** - * Destino alternativo dos eventos: fn(?PDO, kind, ?detail, ?ip, array data). - * É o hub que se regista aqui — ele é o registo da frota, e mandar os seus - * próprios eventos a si mesmo por HTTP exigia emparelhá-lo consigo próprio. - */ -function platform_security_event_sink(?callable $sink = null): ?callable { - static $current = null; - if ($sink !== null) $current = $sink; - return $current; -} - -/** - * Regista um evento para o hub. Nunca lança e nunca espera pela rede. - * - * $kind curto e estável (ip_ban, ip_unban, offence, login_failed, - * login_throttled, csrf_failed, mail_rate_limited): é por ele que o hub agrupa. - */ -function platform_security_event(?PDO $pdo, string $kind, ?string $detail = null, - ?string $ip = null, array $data = []): void { - try { - $ip ??= platform_client_ip(); - $ip = ($ip !== null && $ip !== '') ? $ip : null; - // Os detalhes das infracções trazem às vezes o email de quem submeteu - // (o antispam usa-o como assunto). Não tem de sair do site para o hub. - if ($detail !== null) { - $detail = preg_replace_callback('/[^\s@<>"\'(),;:]+@[^\s@<>"\'(),;:]+\.[a-z]{2,}/i', - static fn(array $m): string => hub_security_mask_identifier($m[0]), $detail); - } - - $sink = platform_security_event_sink(); - if ($sink !== null) { - $sink($pdo ?? (($GLOBALS['pdo'] ?? null) instanceof PDO ? $GLOBALS['pdo'] : null), $kind, $detail, $ip, $data); - return; - } - - if (!hub_security_enabled()) return; - $pdo ??= hub_pdo(); - if (!$pdo instanceof PDO) return; - hub_security_ensure_table($pdo); - $pdo->prepare( - 'INSERT INTO hub_security_outbox (kind, ip, detail, data, occurred_at) VALUES (?, ?, ?, ?, ?)' - )->execute([ - mb_substr($kind, 0, 40), - $ip, - $detail === null ? null : mb_substr($detail, 0, 190), - $data ? json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null, - time(), - ]); - @touch(hub_security_stamp('dirty')); - } catch (Throwable $e) { - error_log('[hub-security] evento não registado: ' . $e->getMessage()); - } -} - -/** «a***@exemplo.pt» — chega para ver um ataque a uma conta sem mandar o email ao hub. */ -function hub_security_mask_identifier(string $id): string { - $id = mb_strtolower(trim($id)); - $at = strrpos($id, '@'); - if ($at === false) return mb_substr($id, 0, 1) . '***'; - return mb_substr($id, 0, 1) . '***' . substr($id, $at); -} - -/** - * Chamado cedo em cada pedido (pelo platform_ip_guard). Só agenda: o trabalho - * corre no fim, depois de a resposta sair quando o SAPI o permite. - */ -function hub_security_maybe_sync(PDO $pdo): void { - static $registered = false; - if ($registered) return; - $registered = true; - - register_shutdown_function(static function () use ($pdo): void { - try { - if (!hub_security_due() || !hub_security_enabled()) return; - if (function_exists('fastcgi_finish_request')) { - @fastcgi_finish_request(); - } - hub_security_sync($pdo, 1); - } catch (Throwable $e) { - error_log('[hub-security] sincronização falhou: ' . $e->getMessage()); - } - }); -} - -function hub_security_due(): bool { - $attempt = hub_security_stamp('attempt'); - $last = @filemtime($attempt) ?: 0; - $every = is_file(hub_security_stamp('dirty')) && !is_file(hub_security_stamp('failing')) - ? HUB_SECURITY_FLUSH_EVERY - : HUB_SECURITY_SYNC_EVERY; - if (time() - $last < $every) return false; - // Marcar a tentativa ANTES de a fazer: os pedidos concorrentes desistem, e - // um hub em baixo não é tentado a cada pedido. Sem pasta temporária - // gravável não há como saber, e fica tudo para o cron. - return @touch($attempt); -} - -/** - * Envia a caixa de saída e aplica a lista da frota que vem na resposta. - * $maxBatches: 1 no fim de um pedido, mais no cron. - */ -function hub_security_sync(PDO $pdo, int $maxBatches = 25): array { - if (!hub_security_enabled()) return ['ok' => false, 'error' => 'plataforma não emparelhada com o hub']; - if (!function_exists('curl_init')) return ['ok' => false, 'error' => 'cURL indisponível']; - - hub_security_ensure_table($pdo); - @touch(hub_security_stamp('attempt')); - - // Um só sincronizador de cada vez, entre todos os processos PHP do site. O - // nome leva a base de dados: os locks do MariaDB são do servidor inteiro, e a - // frota partilha o servidor — sem isto a sincronização de um site fazia os - // outros desistir. - if ((int) $pdo->query("SELECT GET_LOCK(CONCAT('hub_security_sync:', DATABASE()), 0)")->fetchColumn() !== 1) { - return ['ok' => true, 'skipped' => 'já a correr']; - } - - $out = ['ok' => true, 'sent' => 0, 'fleet' => null, 'error' => null]; - try { - hub_security_trim_outbox($pdo); - - for ($i = 0; $i < max(1, $maxBatches); $i++) { - $rows = $pdo->query( - 'SELECT * FROM hub_security_outbox ORDER BY id LIMIT ' . HUB_SECURITY_BATCH - )->fetchAll(PDO::FETCH_ASSOC); - - $events = []; - foreach ($rows as $r) { - $events[] = [ - 'kind' => $r['kind'], - 'ip' => $r['ip'], - 'detail' => $r['detail'], - 'data' => $r['data'] !== null ? json_decode((string) $r['data'], true) : null, - 'ts' => (int) $r['occurred_at'], - ]; - } - - $res = hub_security_post(['events' => $events]); - if (!$res['ok']) { - if ($rows) { - $ids = implode(',', array_map('intval', array_column($rows, 'id'))); - $pdo->exec("UPDATE hub_security_outbox SET attempts = attempts + 1 WHERE id IN ($ids)"); - } - @touch(hub_security_stamp('failing')); - $out['ok'] = false; - $out['error'] = $res['error']; - error_log('[hub-security] envio falhou: ' . $res['error']); - break; - } - @unlink(hub_security_stamp('failing')); - - if ($rows) { - $ids = implode(',', array_map('intval', array_column($rows, 'id'))); - $pdo->exec("DELETE FROM hub_security_outbox WHERE id IN ($ids)"); - $out['sent'] += count($rows); - } - if (is_array($res['data']['fleet'] ?? null)) { - $out['fleet'] = hub_security_apply_fleet($pdo, $res['data']['fleet']); - } - @touch(hub_security_stamp('synced')); - - if (count($rows) < HUB_SECURITY_BATCH) { - // Contar de novo: um evento que entrou durante o envio tem de - // manter a marca, senão esperava pela sincronização lenta. - if ((int) $pdo->query('SELECT COUNT(*) FROM hub_security_outbox')->fetchColumn() === 0) { - @unlink(hub_security_stamp('dirty')); - } - break; - } - } - } finally { - $pdo->query("SELECT RELEASE_LOCK(CONCAT('hub_security_sync:', DATABASE()))"); - } - return $out; -} - -function hub_security_trim_outbox(PDO $pdo): void { - $n = (int) $pdo->query('SELECT COUNT(*) FROM hub_security_outbox')->fetchColumn(); - if ($n <= HUB_SECURITY_OUTBOX_MAX) return; - $excess = $n - HUB_SECURITY_OUTBOX_MAX; - $pdo->exec( - "DELETE FROM hub_security_outbox WHERE kind NOT IN ('ip_ban', 'ip_unban') - ORDER BY id LIMIT " . (int) $excess - ); - error_log("[hub-security] caixa de saída acima do tecto — $excess eventos antigos descartados"); -} - -/** POST ao hub. ['ok'=>bool, 'data'=>array|null, 'error'=>string]. */ -function hub_security_post(array $payload): array { - $ch = curl_init(hub_url() . '/api/security_events.php'); - curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_POST => true, - CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), - // Curto: no pior caso isto corre no fim de um pedido de um visitante. - CURLOPT_CONNECTTIMEOUT => 2, - CURLOPT_TIMEOUT => 5, - // X-Hub-Key e não Authorization: o proxy come o Authorization (ver functions_hub.php). - CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Accept: application/json', - 'X-Hub-Key: ' . hub_api_key()], - CURLOPT_USERAGENT => 'andmore-platform-security/1.0', - ]); - $raw = curl_exec($ch); - $http = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); - $err = curl_error($ch); - curl_close($ch); - - if ($raw === false || $err !== '') return ['ok' => false, 'data' => null, 'error' => $err ?: 'sem resposta']; - $data = json_decode((string) $raw, true); - if (!is_array($data)) return ['ok' => false, 'data' => null, 'error' => "resposta não-JSON (HTTP $http)"]; - if (empty($data['ok'])) return ['ok' => false, 'data' => $data, 'error' => (string) ($data['error'] ?? "HTTP $http")]; - return ['ok' => true, 'data' => $data, 'error' => '']; -} - -/** - * Aplica a lista da frota ao ip_bans local. - * - * Um banimento próprio activo ganha sempre à cópia — foi decidido aqui, com o - * motivo daqui. As cópias que o hub deixou de mandar (levantadas ou expiradas) - * saem, mas só quando a lista vem completa: uma lista truncada não diz nada - * sobre o que ficou de fora. - */ -function hub_security_apply_fleet(PDO $pdo, array $fleet): array { - require_once __DIR__ . '/functions_ip_ban.php'; - platform_ip_ban_ensure_tables($pdo); - - $local = []; - foreach ($pdo->query( - "SELECT ip, source, reason, expires_at, - (expires_at IS NULL OR expires_at > NOW()) AS active - FROM ip_bans - WHERE source = 'fleet' OR expires_at IS NULL OR expires_at > NOW()" - ) as $r) { - $local[$r['ip']] = $r; - } - - $upsert = $pdo->prepare( - "INSERT INTO ip_bans (ip, reason, source, expires_at, created_by) VALUES (?, ?, 'fleet', ?, NULL) - ON DUPLICATE KEY UPDATE reason = VALUES(reason), source = 'fleet', - expires_at = VALUES(expires_at), created_at = NOW(), created_by = NULL" - ); - - $seen = []; - $applied = 0; - foreach ((array) ($fleet['bans'] ?? []) as $b) { - $ip = trim((string) ($b['ip'] ?? '')); - if (!filter_var($ip, FILTER_VALIDATE_IP) || platform_ip_ban_exempt($ip)) continue; - $seen[$ip] = true; - - $cur = $local[$ip] ?? null; - if ($cur !== null && $cur['source'] !== 'fleet' && (int) $cur['active'] === 1) continue; - - $ts = isset($b['expires_ts']) ? (int) $b['expires_ts'] : null; - $expires = $ts ? date('Y-m-d H:i:s', $ts) : null; - $reason = mb_substr('frota · ' . (string) ($b['origin'] ?? '?') . ' · ' . (string) ($b['reason'] ?? ''), 0, 190); - - if ($cur !== null && $cur['source'] === 'fleet' - && $cur['reason'] === $reason && $cur['expires_at'] === $expires) { - continue; - } - try { - $upsert->execute([$ip, $reason, $expires]); - $applied++; - } catch (Throwable $e) { - error_log('[hub-security] cópia de banimento não aplicada: ' . $e->getMessage()); - } - } - - $removed = 0; - if (!empty($fleet['complete'])) { - $del = $pdo->prepare("DELETE FROM ip_bans WHERE ip = ? AND source = 'fleet'"); - foreach ($local as $ip => $r) { - if ($r['source'] === 'fleet' && !isset($seen[$ip])) { - $del->execute([$ip]); - $removed++; - } - } - } - return ['received' => count($seen), 'applied' => $applied, 'removed' => $removed]; -} - -/** - * `checks.security` para o health do hub. A pergunta é «o envio está a - * funcionar?»: eventos presos há mais de uma hora querem dizer que não. - */ -function hub_security_check(PDO $pdo): array { - if (!hub_security_enabled()) return []; - hub_security_ensure_table($pdo); - - $row = $pdo->query('SELECT COUNT(*) n, MIN(occurred_at) oldest FROM hub_security_outbox') - ->fetch(PDO::FETCH_ASSOC); - $pending = (int) ($row['n'] ?? 0); - $oldest = $row['oldest'] !== null ? time() - (int) $row['oldest'] : 0; - - $fleet = 0; - try { - $fleet = (int) $pdo->query( - "SELECT COUNT(*) FROM ip_bans WHERE source = 'fleet' AND (expires_at IS NULL OR expires_at > NOW())" - )->fetchColumn(); - } catch (Throwable) { /* ip_bans ainda por criar */ } - - $synced = @filemtime(hub_security_stamp('synced')) ?: null; - $detail = $pending . ' por enviar' - . ($synced ? ' · sincronizado há ' . max(0, (int) round((time() - $synced) / 60)) . ' min' : ' · nunca sincronizou') - . ' · ' . $fleet . ' banimento(s) da frota'; - - return [ - 'ok' => $oldest < 3600, - 'detail' => $detail, - 'pending' => $pending, - 'oldest_seconds' => $oldest, - 'fleet_bans' => $fleet, - 'synced_at' => $synced ? date('c', $synced) : null, - ]; -} diff --git a/shared/platform-tools/functions_settings.php b/shared/platform-tools/functions_settings.php deleted file mode 100644 index a285f02..0000000 --- a/shared/platform-tools/functions_settings.php +++ /dev/null @@ -1,412 +0,0 @@ - auto -// platform_settings_all(PDO, revealSecrets=false) -// platform_settings_is_secret(key): bool -// platform_settings_encrypt_existing(PDO): array -> idempotent migration -// platform_secret_encrypt(string) / platform_secret_decrypt(?string) -// platform_secret_mask(string) -// platform_smtp_config(PDO): array -> the shape functions_mail.php expects - -const PLATFORM_SECRET_PREFIX = 'enc:v1:'; - -/** - * Key used for the settings envelope. - * - * Derivation is domain-separated ('am-secret|') so the same APP_SECRET_KEY - * cannot produce the same key material for two different purposes. Falls back - * to BACKUP_TOKEN because some installs only ever set that one. - */ -function platform_secret_key(): ?string { - if (!extension_loaded('openssl')) return null; - foreach (['APP_SECRET_KEY', 'BACKUP_TOKEN'] as $k) { - $v = defined($k) ? (string) constant($k) : ''; - if ($v === '') { - $v = function_exists('env') ? (string) env($k, '') : (string) (getenv($k) ?: ($_ENV[$k] ?? '')); - } - if ($v !== '' && $v !== 'replace_me') { - return hash('sha256', 'am-secret|' . $v, true); - } - } - return null; -} - -function platform_secret_encrypt(string $plain): string { - if ($plain === '') return ''; - $key = platform_secret_key(); - // No key: store as-is rather than losing the value. The caller is warned by - // platform_settings_secret_available(). - if ($key === null) return $plain; - $iv = random_bytes(12); // GCM standard nonce - $tag = ''; - $ct = openssl_encrypt($plain, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag); - if ($ct === false) return $plain; - return PLATFORM_SECRET_PREFIX . base64_encode($iv . $tag . $ct); -} - -function platform_secret_decrypt(?string $stored): string { - $stored = (string) $stored; - if ($stored === '' || !str_starts_with($stored, PLATFORM_SECRET_PREFIX)) return $stored; - $raw = base64_decode(substr($stored, strlen(PLATFORM_SECRET_PREFIX)), true); - if ($raw === false || strlen($raw) < 29) return ''; - $iv = substr($raw, 0, 12); $tag = substr($raw, 12, 16); $ct = substr($raw, 28); - - $key = platform_secret_key(); - if ($key !== null) { - $out = openssl_decrypt($ct, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag); - if ($out !== false) return $out; - } - error_log('[settings] encrypted value present but the key does not decrypt it -- has APP_SECRET_KEY changed?'); - return ''; -} - -function platform_settings_secret_available(): bool { - return platform_secret_key() !== null; -} - -/** Creates the table when missing. Safe to call on every request. */ -function platform_settings_ensure_table(PDO $pdo): void { - $pdo->exec( - "CREATE TABLE IF NOT EXISTS `system_settings` ( - `setting_key` VARCHAR(100) NOT NULL, - `setting_value` TEXT NULL, - `updated_by` INT NULL, - `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (`setting_key`) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" - ); -} - -/** - * Per-request read cache, shared by get() and invalidated by set(). - * - * Returned by reference so the writers can actually mutate it. It used to be a - * `static` local inside platform_settings_get(), which nothing else could - * reach: a page that read a setting, wrote it, then read it back got the value - * from before its own write. The setup page does exactly that -- save, then - * re-check whether the platform is configured -- and would have reported the - * gate unsatisfied immediately after satisfying it. - */ -function &platform_settings_cache(): array { - static $cache = []; - return $cache; -} - -/** Drop one key, or the whole cache when $key is null. */ -function platform_settings_cache_clear(?string $key = null): void { - $cache = &platform_settings_cache(); - if ($key === null) { $cache = []; return; } - unset($cache[$key]); -} - -function platform_settings_get(PDO $pdo, string $key, $default = '') { - $cache = &platform_settings_cache(); - if (array_key_exists($key, $cache)) return $cache[$key]; - try { - $st = $pdo->prepare("SELECT setting_value FROM system_settings WHERE setting_key = ? LIMIT 1"); - $st->execute([$key]); - $v = $st->fetchColumn(); - $st->closeCursor(); - } catch (Throwable $e) { - return $default; // table absent on a host that has not run ensure_table yet - } - if ($v === false || $v === null) return $cache[$key] = $default; - return $cache[$key] = platform_secret_decrypt((string) $v); -} - -/** - * Is this setting a secret, and therefore encrypted at rest? - * - * The decision lives here, not in the callers. Each site used to keep its own - * list -- fotografia had ['smtp_password','twilio_token'] hardcoded in - * comms.php, hub2 encrypted only smtp_password -- so the same key could be - * ciphertext on one install and plaintext on another, and a newly added - * credential was secret only if whoever added it remembered. - * - * Suffix rules cover new keys automatically; the exact list catches the ones - * whose names do not follow the convention. - */ -function platform_settings_is_secret(string $key): bool { - static $exact = [ - 'smtp_password', 'twilio_token', 'twilio_auth_token', - 'ig_app_secret', 'ig_access_token', 'nextcloud_share_token', - 'hub_ops_secret', 'hub_content_secret', 'antispam_secret', - 'notify_api_key', - ]; - $k = strtolower(trim($key)); - if (in_array($k, $exact, true)) return true; - foreach (['_password', '_secret', '_token', '_api_key', '_apikey', '_private_key'] as $suffix) { - if (str_ends_with($k, $suffix)) return true; - } - return false; -} - -/** - * Write a setting. - * - * $encrypt defaults to null, meaning "decide from the key name". Pass true or - * false only to override that deliberately -- the old signature took a plain - * bool defaulting to false, so any existing caller passing true still forces - * encryption and one passing false still forces plaintext. - */ -function platform_settings_set(PDO $pdo, string $key, string $value, ?int $userId = null, ?bool $encrypt = null): void { - platform_settings_ensure_table($pdo); - - $wantEncrypted = $encrypt ?? platform_settings_is_secret($key); - - if (!$wantEncrypted || $value === '') { - // An empty secret is stored empty, not as ciphertext of "": callers use - // '' to mean "cleared", and encrypting it would hide that. - $stored = $value; - } elseif (str_starts_with($value, PLATFORM_SECRET_PREFIX)) { - // Already encrypted by a caller that does its own crypto (hub2 and - // fotografia both do). Wrapping it again would make it undecryptable. - $stored = $value; - } else { - $stored = platform_secret_encrypt($value); - if (!str_starts_with($stored, PLATFORM_SECRET_PREFIX)) { - // platform_secret_encrypt falls back to returning the plaintext when - // no key is configured. Storing it is still better than losing the - // value, but it must not happen quietly. - error_log("[settings] '{$key}' stored UNENCRYPTED: no APP_SECRET_KEY/BACKUP_TOKEN available"); - } - } - - $st = $pdo->prepare( - "INSERT INTO system_settings (setting_key, setting_value, updated_by) - VALUES (?, ?, ?) - ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), updated_by = VALUES(updated_by)" - ); - $st->execute([$key, $stored, $userId]); - - // Without this the caller reads its own stale value for the rest of the - // request -- including "did that save work?", which is the one question a - // save is usually followed by. - platform_settings_cache_clear($key); -} - -/** - * Renders a secret for display. - * - * An API key is shown as "amk_1a2b…9f4c" because operators identify keys by - * their prefix. A password gets no prefix at all -- there is nothing to - * recognise, and eight leading characters in a screenshot or a support ticket - * is a real leak. - */ -function platform_secret_mask(string $plain, string $key = ''): string { - if ($plain === '') return '—'; - - $isPassword = $key === '' || str_ends_with(strtolower($key), '_password'); - if ($isPassword) return '•••••••• (definida)'; - - if (strlen($plain) <= 12) return str_repeat('•', strlen($plain)); - return substr($plain, 0, 8) . '…' . substr($plain, -4); -} - -/** - * Every setting, decrypted. Secrets are masked unless $revealSecrets is true, - * so the common cases -- an admin listing, a diagnostics dump -- cannot leak a - * credential into a page or a log by accident. - */ -function platform_settings_all(PDO $pdo, bool $revealSecrets = false): array { - platform_settings_ensure_table($pdo); - $out = []; - $st = $pdo->query("SELECT setting_key, setting_value FROM system_settings ORDER BY setting_key"); - foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $row) { - $key = (string) $row['setting_key']; - $plain = platform_secret_decrypt((string) $row['setting_value']); - $out[$key] = (!$revealSecrets && platform_settings_is_secret($key)) - ? platform_secret_mask($plain, $key) - : $plain; - } - return $out; -} - -/** - * Encrypt any secret-named row still sitting in plaintext. - * - * Idempotent: rows already carrying the enc:v1: prefix are skipped, so this is - * safe to run on every deploy. Returns ['encrypted'=>[], 'skipped'=>[], 'failed'=>[]]. - */ -function platform_settings_encrypt_existing(PDO $pdo): array { - platform_settings_ensure_table($pdo); - $report = ['encrypted' => [], 'skipped' => [], 'failed' => []]; - - if (!platform_settings_secret_available()) { - $report['failed'][] = 'no encryption key (APP_SECRET_KEY / BACKUP_TOKEN)'; - return $report; - } - - $rows = $pdo->query("SELECT setting_key, setting_value FROM system_settings")->fetchAll(PDO::FETCH_ASSOC); - $upd = $pdo->prepare("UPDATE system_settings SET setting_value = ? WHERE setting_key = ?"); - - foreach ($rows as $row) { - $key = (string) $row['setting_key']; - $value = (string) $row['setting_value']; - - if (!platform_settings_is_secret($key)) continue; - if ($value === '') { $report['skipped'][] = "{$key} (empty)"; continue; } - if (str_starts_with($value, PLATFORM_SECRET_PREFIX)) { $report['skipped'][] = "{$key} (already encrypted)"; continue; } - - $enc = platform_secret_encrypt($value); - if (!str_starts_with($enc, PLATFORM_SECRET_PREFIX)) { $report['failed'][] = $key; continue; } - - $upd->execute([$enc, $key]); - platform_settings_cache_clear($key); - $report['encrypted'][] = $key; - } - return $report; -} - -/** - * The standard SMTP settings, read from the database with the .env value as - * fallback. - * - * Precedence is deliberate: a value set in the backoffice wins, because that is - * the one an operator can change without shell access. .env remains useful for - * bootstrapping an install before anyone can log in. - * - * Returns exactly the shape platform_mail_send()'s $override expects. - */ -function platform_smtp_config(PDO $pdo): array { - $env = function (string $k, string $d = '') { - if (defined($k)) { $v = (string) constant($k); if ($v !== '') return $v; } - if (function_exists('env')) { $v = (string) env($k, ''); if ($v !== '') return $v; } - $v = getenv($k); if ($v === false || $v === '') $v = (string) ($_ENV[$k] ?? ''); - return $v !== '' ? $v : $d; - }; - return [ - 'host' => platform_settings_get($pdo, 'smtp_host', $env('SMTP_HOST')), - 'port' => (int) platform_settings_get($pdo, 'smtp_port', $env('SMTP_PORT', '587')), - 'username' => platform_settings_get($pdo, 'smtp_username', $env('SMTP_USERNAME')), - 'password' => platform_settings_get($pdo, 'smtp_password', $env('SMTP_PASSWORD')), - 'secure' => platform_settings_get($pdo, 'smtp_secure', $env('SMTP_SECURE', 'tls')), - 'from' => platform_settings_get($pdo, 'email_from', $env('EMAIL_FROM')), - 'from_name' => platform_settings_get($pdo, 'email_from_name', $env('EMAIL_FROM_NAME')), - ]; -} - -/** - * URL of a page in this platform's backoffice. - * - * The shared pages and API endpoints used to hardcode `views/sysadmin/...`, - * which is right for four of the five platforms and wrong for design, whose - * backoffice lives at `views/admin/`. Clicking "Backup da base de dados" there - * ran the backup and then redirected to a page that does not exist -- the work - * succeeded and the operator saw a 404, which is the most confusing pair of - * outcomes available. - * - * A site overrides the segment by defining PLATFORM_ADMIN_DIR (or setting it in - * .env) to e.g. 'views/admin'. Default keeps the existing behaviour. - * - * A site on the front controller (`'router' => true` in platform_connect()) - * gets platform.php?tool=… for every shared page; its own pages keep the dir. - */ -function platform_admin_url(string $page = ''): string { - if (function_exists('platform_router_enabled') && platform_router_enabled()) { - [$file, $query] = array_pad(explode('?', $page, 2), 2, ''); - $tool = $file === '' ? 'index' : preg_replace('/\.php$/', '', basename($file)); - if (isset(platform_routes()[$tool])) return platform_route_url($tool, $query); - } - $dir = 'views/sysadmin'; - if (defined('PLATFORM_ADMIN_DIR') && constant('PLATFORM_ADMIN_DIR') !== '') { - $dir = (string) constant('PLATFORM_ADMIN_DIR'); - } elseif (function_exists('env') && (string) env('PLATFORM_ADMIN_DIR', '') !== '') { - $dir = (string) env('PLATFORM_ADMIN_DIR', ''); - } elseif (($e = getenv('PLATFORM_ADMIN_DIR')) !== false && $e !== '') { - $dir = $e; - } - $path = trim($dir, '/') . ($page !== '' ? '/' . ltrim($page, '/') : ''); - return function_exists('base_url') ? base_url($path) : '/' . $path; -} - -/** - * A stable HMAC key for a named purpose. - * - * Signed form tokens (anti-spam stamps, captcha challenges) are issued in one - * request and verified in another, so the key behind them must be **the same - * across requests** -- a per-request key rejects every visitor with "invalid - * token" and the failure looks like a bot problem rather than a bug. - * - * Three sources, in order, all stable: - * - * 1. platform_secret_key() -- the .env key, the normal case; - * 2. a random value stored in system_settings, when there is no .env key; - * 3. derived from the install's own configuration, for an install with - * neither. It is a weaker secret than the other two, but it is constant, - * and a defence that locks every form is worse than a weaker signature. - * - * Always domain-separated by $purpose, so a stamp minted for one feature can - * never validate in another. Note that functions_antispam_forms.php keeps its - * own older derivation on purpose: changing the key of a live site invalidates - * the forms that are open at that instant, and those submissions are refused - * silently -- leads lost for the sake of tidiness. New callers use this one. - * - * Pass the PDO wherever you have one. Source 3 is a last resort, and a site - * that reaches it from one call site with a PDO and another without would sign - * with two different keys. - */ -function platform_derived_key(string $purpose, ?PDO $pdo = null, string $settingKey = 'shared_hmac_key'): string { - static $bases = []; - - if (!isset($bases[$settingKey])) { - $base = ''; - - $envKey = platform_secret_key(); - if ($envKey !== null) { - $base = bin2hex($envKey); - } elseif ($pdo instanceof PDO) { - $base = (string) platform_settings_get($pdo, $settingKey, ''); - if ($base === '') { - $fresh = bin2hex(random_bytes(32)); - try { - // Stored in the clear on purpose: encrypting it would need - // the key we do not have (that is why we are here), and a - // value that decrypts differently on each read means every - // token minted before this request stops verifying. - platform_settings_set($pdo, $settingKey, $fresh, null, false); - $base = $fresh; - } catch (Throwable $e) { - error_log('[settings] ' . $settingKey . ' not persisted (' . $e->getMessage() - . '); deriving from install config'); - } - } - } - - if ($base === '') { - $read = static function (string $k): string { - if (defined($k)) return (string) constant($k); - if (function_exists('env')) return (string) env($k, ''); - return (string) (getenv($k) ?: ($_ENV[$k] ?? '')); - }; - // Not cached: this branch is the fallback, and a later call that - // does carry a PDO must be free to find the real key instead of - // inheriting this one. - return hash('sha256', 'am-key|' . $purpose . '|derived|' - . $read('DB_NAME') . '|' . $read('DB_USER') . '|' . $read('DB_PASS') - . '|' . $read('APP_BASE_URL'), true); - } - - $bases[$settingKey] = $base; - } - - return hash('sha256', 'am-key|' . $purpose . '|' . $bases[$settingKey], true); -} diff --git a/shared/platform-tools/functions_setup.php b/shared/platform-tools/functions_setup.php deleted file mode 100644 index f9dba77..0000000 --- a/shared/platform-tools/functions_setup.php +++ /dev/null @@ -1,183 +0,0 @@ - ['/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)); -} diff --git a/shared/platform-tools/functions_tracking.php b/shared/platform-tools/functions_tracking.php deleted file mode 100644 index d4b2646..0000000 --- a/shared/platform-tools/functions_tracking.php +++ /dev/null @@ -1,482 +0,0 @@ -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); -} diff --git a/shared/platform-tools/geo.php b/shared/platform-tools/geo.php deleted file mode 100644 index b446a2b..0000000 --- a/shared/platform-tools/geo.php +++ /dev/null @@ -1,641 +0,0 @@ - 'danger', 'msg' => 'Token inválido.']; - header('Location: ' . $selfUrl); - exit; - } - - if (($_POST['action'] ?? 'save') === 'refresh') { - // Corre o mesmo script que o cron corre. Não se duplica a lógica de - // validação aqui: é ela que impede um download mau de substituir uma - // lista boa, e duas cópias divergiriam. - $countries = geo_allowed_countries(); - if (!$countries) { - $_SESSION['flash'] = ['type' => 'warning', 'msg' => 'Defina primeiro os países permitidos.']; - } else { - $args = ' ' . implode(' ', array_map('escapeshellarg', $countries)) . ' 2>&1'; - $out = []; - $code = 1; - exec(escapeshellcmd(__DIR__ . '/bin/geo-update.sh') . $args, $out, $code); - - // O Relay corre a seguir e em separado: são duas fontes - // independentes, e a da Apple estar em baixo não pode impedir a - // actualização das listas de país (nem o contrário). - $relayOut = []; - $relayCode = 1; - exec(escapeshellcmd(__DIR__ . '/bin/geo-relay-update.sh') . $args, $relayOut, $relayCode); - - $msg = implode(' · ', array_slice(array_merge($out, $relayOut), 0, 8)); - $_SESSION['flash'] = ($code === 0 && $relayCode === 0) - ? ['type' => 'success', 'msg' => 'Listas actualizadas. ' . $msg] - : ['type' => ($code === 0 ? 'warning' : 'danger'), 'msg' => - ($code === 0 - ? 'Países actualizados; o iCloud Private Relay falhou (lista anterior mantida). ' - : 'A actualização falhou (as listas anteriores mantêm-se). ') . $msg]; - } - header('Location: ' . $selfUrl); - exit; - } - - // O botão do "acrescentar o meu endereço" grava só a lista de endereços e - // devolve a página. Parte do texto que veio no formulário, e não do que está - // gravado, para não deitar fora o que a pessoa tenha escrito antes de - // carregar -- que é a maneira mais rápida de alguém perder confiança numa - // página de configuração. - if (($_POST['action'] ?? 'save') === 'add_my_ip') { - $ip = platform_client_ip() ?? ''; - $lista = preg_split('/[\s,;]+/', (string) ($_POST['geo_allow_ips'] ?? ''), -1, PREG_SPLIT_NO_EMPTY) ?: []; - if ($ip !== '' && !in_array($ip, $lista, true)) { - $lista[] = $ip; - } - $saveSetting('geo_allow_ips', implode(',', $lista)); - $_SESSION['flash'] = ['type' => 'success', 'msg' => 'O endereço ' . $ip . ' passa sempre a partir de agora.']; - header('Location: ' . $selfUrl); - exit; - } - - $mode = strtolower(trim((string) ($_POST['geo_mode'] ?? 'off'))); - if (!in_array($mode, ['off', 'forms', 'site'], true)) { - $mode = 'off'; - } - - // As caixas mais o campo de escape, tratados da mesma maneira. Um código que - // não seja ISO-3166 alfa-2 é recusado com aviso, e não engolido: um país mal - // escrito na lista é indistinguível de "ninguém passa". - // - // O campo de escape existe porque a lista de caixas tem 72 países e não 249: - // ver o cabeçalho de data/countries-pt.php. - $rejected = []; - $valid = []; - $submetidos = array_merge( - array_map('strval', (array) ($_POST['geo_countries'] ?? [])), - preg_split('/[\s,;]+/', (string) ($_POST['geo_countries_extra'] ?? ''), -1, PREG_SPLIT_NO_EMPTY) ?: [] - ); - foreach ($submetidos as $cc) { - $up = strtoupper($cc); - if (preg_match('/^[A-Z]{2}$/', $up)) { - $valid[$up] = true; - } else { - $rejected[] = $cc; - } - } - $countries = implode(',', array_keys($valid)); - $failClosed = !empty($_POST['geo_fail_closed']) ? 'true' : 'false'; - - // Guarda-rede: ligar o modo `site` sem dados é tirar a plataforma do ar - // para toda a gente. Com fail-open não chega a acontecer, mas a combinação - // `site` + fail-closed + sem listas é irrecuperável pelo browser. - $haveData = false; - foreach (array_keys($valid) as $cc) { - if (geo_ranges($cc, false) || geo_ranges($cc, true)) { - $haveData = true; - break; - } - } - if ($mode !== 'off' && $failClosed === 'true' && !$haveData) { - $_SESSION['flash'] = ['type' => 'danger', 'msg' => - 'Recusado: com "falhar fechado" e sem listas descarregadas, ninguém ' - . 'conseguiria entrar — incluindo o utilizador. Actualize as listas primeiro.']; - header('Location: ' . $selfUrl); - exit; - } - - // Endereços que passam sempre. Validados aqui pela mesma função que o - // módulo usa para os ler, para que uma entrada inválida seja recusada com - // aviso em vez de ficar gravada a não fazer nada -- que é indistinguível de - // "guardei e continua bloqueado". - $badIps = []; - $validIps = []; - foreach (preg_split('/[\s,;]+/', (string) ($_POST['geo_allow_ips'] ?? ''), -1, PREG_SPLIT_NO_EMPTY) ?: [] as $entry) { - if (geo_cidr_to_range($entry) !== null) { - $validIps[$entry] = true; - } else { - $badIps[] = $entry; - } - } - $allowIps = implode(',', array_keys($validIps)); - $trustRelay = !empty($_POST['geo_trust_relay']) ? 'true' : 'false'; - - $saveSetting('geo_mode', $mode); - $saveSetting('geo_allow_countries', $countries); - $saveSetting('geo_fail_closed', $failClosed); - $saveSetting('geo_allow_ips', $allowIps); - $saveSetting('geo_trust_relay', $trustRelay); - if (function_exists('platform_audit')) { - platform_audit($pdo, 'settings.geo', ['target_type' => 'settings', 'summary' => 'Bloqueio por país guardado', - 'changes' => ['geo_mode' => $mode, 'geo_allow_countries' => $countries, 'geo_fail_closed' => $failClosed, - 'geo_allow_ips' => $allowIps, 'geo_trust_relay' => $trustRelay]]); - } - - $ignorado = array_merge( - $rejected ? ['códigos de país: ' . implode(', ', $rejected)] : [], - $badIps ? ['endereços: ' . implode(', ', $badIps)] : [] - ); - $_SESSION['flash'] = $ignorado - ? ['type' => 'warning', 'msg' => 'Guardado, mas isto não é válido e foi ignorado — ' . implode(' · ', $ignorado)] - : ['type' => 'success', 'msg' => 'Configuração guardada.']; - header('Location: ' . $selfUrl); - exit; -} - -// ─── Estado actual ─────────────────────────────────────────────────────────── -$mode = geo_mode(); -$countries = geo_allowed_countries(); -$failClosed = geo_fail_closed(); -$allowIps = preg_split('/[\s,;]+/', (string) geo_env('GEO_ALLOW_IPS', ''), -1, PREG_SPLIT_NO_EMPTY) ?: []; -$trustRelay = geo_trust_relay(); - -// A lista de caixas, e os códigos já guardados que não constam dela -- esses -// aparecem à mesma, num grupo à parte: uma página que perde silenciosamente um -// valor gravado é pior do que uma página feia. -$grupos = require __DIR__ . '/data/countries-pt.php'; -$conhecidos = array_merge(...array_map('array_keys', array_values($grupos))); -$foraDaLista = array_values(array_diff($countries, $conhecidos)); - -/** Há alguma lista de endereços para este país? (não abre os ficheiros) */ -$temDados = function (string $cc): bool { - $base = GEO_DATA_DIR . '/' . strtolower($cc); - return is_file($base . '.v4') || is_file($base . '.v6'); -}; - -$relayOk = geo_relay_available($countries); - -// De onde veio cada valor — é o que explica um "guardei e não mudou nada". -$origin = function (string $key) use ($pdo): string { - $db = trim((string) platform_settings_get($pdo, strtolower($key), '')); - if ($db !== '') return 'base de dados'; - return (function_exists('env') && (string) env($key, '') !== '') ? '.env' : 'omissão'; -}; - -$dataFiles = []; -foreach ($countries as $cc) { - // Duas fontes por país: a ipdeny (alocações nacionais) e a Apple (saídas do - // Private Relay ao serviço desse país). Lado a lado na mesma tabela porque - // a pergunta do operador é uma só -- "tenho dados para PT?". - foreach ([['', 'ipdeny'], ['relay-', 'iCloud Relay']] as [$prefix, $fonte]) { - foreach (['v4', 'v6'] as $fam) { - $key = $prefix . strtolower($cc); - $f = GEO_DATA_DIR . '/' . $key . '.' . $fam; - $dataFiles[] = [ - 'cc' => $cc, - 'fonte' => $fonte, - 'fam' => $fam, - 'exists' => is_file($f), - 'ranges' => is_file($f) ? count(geo_ranges($key, $fam === 'v6')) : 0, - 'mtime' => is_file($f) ? date('Y-m-d H:i', filemtime($f)) : '—', - ]; - } - } -} - -if ($probe !== '') { - $probeResult = filter_var($probe, FILTER_VALIDATE_IP) - ? ['ip' => $probe, 'reason' => geo_block_reason($probe), 'internal' => geo_ip_is_internal($probe)] - : ['ip' => $probe, 'invalid' => true]; -} - -$myIp = platform_client_ip() ?? ''; - -// O stub pode dizer onde fica o hub e a página de submissões bloqueadas; nem -// todos os sites os têm no mesmo caminho (o design usa views/admin/). -$hubUrl = $platform_dashboard_url - ?? (function_exists('platform_admin_url') ? platform_admin_url('index.php') : 'index.php'); -$blockedUrl = $platform_blocked_url - ?? (function_exists('base_url') ? base_url('views/admin/blocked_submissions.php') : null); - -$pageTitle = 'Bloqueio por país'; -// O layout do site, como nas outras páginas partilhadas (o router passa-o a -// partir do platform_connect()). Até 2026-09-23 esta página fixava -// includes/header.php e ignorava-o: num site com layout noutro sítio saía sem -// cabeçalho nenhum. -$layout_header = $platform_layout_header ?? (dirname(__DIR__, 2) . '/includes/header.php'); -$layout_navbar = $platform_layout_navbar ?? (dirname(__DIR__, 2) . '/includes/navbar.php'); -$layout_footer = $platform_layout_footer ?? (dirname(__DIR__, 2) . '/includes/footer.php'); -if (is_file($layout_header)) { include $layout_header; } -if (is_file($layout_navbar)) { include $layout_navbar; } - -$csrfField = function_exists('csrf_field') ? csrf_field() - : (function_exists('platform_csrf_field') ? platform_csrf_field() : ''); -$h = fn($s) => htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); -?> -
- -
-
-

Bloqueio por país

-

Recusa submissões — ou o site inteiro — a quem chega de fora da lista.

-
- - Sistema - -
- - -
- - - ['primary', 'Os formulários públicos só aceitam envios de ' . $h($paisesTxt) - . '. Ler o site continua aberto a toda a gente.'], - 'site' => ['danger', 'O site inteiro responde 403 a quem vier de fora de ' - . $h($paisesTxt) . '.'], - default => ['secondary', 'O filtro está desligado. Ninguém é recusado por causa do país.'], - }; - $extras = []; - if ($mode !== 'off' && $trustRelay && $relayOk) { - $extras[] = 'O iCloud Private Relay é atribuído ao país que serve.'; - } - if ($allowIps) { - $extras[] = count($allowIps) . ' endereço' . (count($allowIps) === 1 ? '' : 's') - . ' na lista de excepções, que passam sempre.'; - } - ?> -
- - -
- - -
- Filtro ligado sem países. Sem lista, o módulo fica inerte de propósito — - escolha pelo menos um país abaixo. -
- -
- Filtro ligado sem listas. Nada é bloqueado — o módulo falha aberto de - propósito. Carregue em "Actualizar listas" abaixo. -
- - -
-
-
- - -
- - ['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]): ?> -
- > - -
- -
Valor activo vem de: .
-
- -
-
- - - seleccionado - -
- - - - - $lista): ?> - -
> - - - () - -
- $nome): ?> -
-
- > - -
-
- -
-
- - - -
- - Guardados, fora da lista acima - -
- -
-
- - -
-
- -
-
- - -
-
- - -
-
-
- Para um país que não esteja nas caixas. Fica guardado e passa a aparecer aqui. - Valor activo vem de: . -
-
-
-
- -
-
- > - -
-
- - Defina primeiro os países. - - Listas do Relay instaladas. - - - - Ainda sem listas do Relay — carregue em Actualizar listas, mais abaixo. - Até lá esta opção não muda nada. - - -
-
- -
- - -
- Um por linha, endereço solto ou gama em CIDR. Passam antes de qualquer outra - verificação — incluindo quando as listas de país estão em falta, que é o caso em que - isto é a única defesa que ainda funciona. Para o cliente que ficou de fora hoje, para - a VPN do escritório, para um serviço de monitorização. - - Activos: , de - . - -
- - - -
- -
-
- > - -
-
- - -
-
-
- -
-
-
-
Listas de endereços
-
- - - -
-
- - -

Sem países definidos.

- -
- - - - - - - - - - - - - -
PaísOrigemFamíliaGamasActualizado
- - em falta -
-
-

- Alocações nacionais da ipdeny.com (mensal chega — movem-se devagar) e saídas do iCloud - Private Relay publicadas pela Apple (semanal, que é o ritmo a que aparecem gamas novas). - As duas são independentes: uma em baixo não impede a outra de actualizar, e um download - curto ou inválido é recusado com a lista anterior a manter-se. -

- -
-
- -
-
-
Testar um endereço
-
-
- - -
-
- -
-
- - -
- -
não é um endereço IP válido.
- -
- passa - -
- -
- seria recusado: -
- -
- - - -

- O seu endereço agora é - - — seria recusado: ' . $h($mine) . '' ?>. -

- -
-
- -

- As recusas ficam registadas com o motivo e o IP em - Submissões bloqueadas. -

- -
- - - 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']; diff --git a/shared/platform-tools/hub_integration.php b/shared/platform-tools/hub_integration.php deleted file mode 100644 index 41c34f7..0000000 --- a/shared/platform-tools/hub_integration.php +++ /dev/null @@ -1,371 +0,0 @@ - 'hub', 'summary' => (string) $notice[0]]); - } -} - -$key = hub_api_key(); -$fromEnv = hub_api_key_is_from_env(); -$opsKey = hub_ops_key(); -$opsFromEnv = hub_ops_key_is_from_env(); -$backups = hub_backup_summary(); -$hubUrl = $hubUrlJustSaved ?? hub_url(); -$pairing = hub_pairing_state(); -if ($justAnnounced) { - // Idem: o estado do emparelhamento acabou de mudar neste request. - $pairing['state'] = 'announced'; - $pairing['hub'] = $hubUrl; - $pairing['expired'] = false; -} - -$backupsUrl = function_exists('platform_admin_url') ? platform_admin_url('backups.php') : null; -$page_title = 'Integração com o hub'; -$pageTitle = $page_title; // os headers da frota não usam todos o mesmo nome - -$layout_header = $platform_layout_header ?? ($repo_root . '/includes/header.php'); -if (is_file($layout_header)) include $layout_header; - -$csrfField = function_exists('csrf_field') ? csrf_field() : platform_csrf_field(); -$h = fn($s) => htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); -?> -
-
-

Integração com o hub

- - Cópias de segurança - -
- - -
- - -
- - - -
- - Esta instalação não tem a tabela system_settings. - É 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 Sistema → Atualizar plataforma - e volta aqui. -
- - -
-
-
-
Chave de API
- - -
-
Chave de — copia-a agora
-
- - -
-
- Fica guardada cifrada. Esta é a única vez que aparece em claro — se a perderes, gera outra. -
-
- - - - -

Fixada no .env

-

- Esta instalação define no .env, e o - .env ganha. É a configuração mais segura — um valor no .env nunca entra - numa cópia da base de dados. Para gerir por aqui, remove-a de lá. -

- -

Configurada - guardada cifrada na base de dados

- -

Por configurar

-

- - - -
-
- - -
- -
- - -
- -
- - -

- Duas chaves de propósito: a de leitura é sondada de poucos em poucos minutos e só - devolve estado; a de operações dispara cópias e atualizações e usa-se à mão. - Assim, quem sonda não consegue disparar nada. -

- -
-
Leitura (estado)
- -
- -
- -
-
Operações (cópias e atualizações)
- -
-
- - - -
-
Ligação rápida ao hub
- - -
- - -
- - value=""> - -
-
- HUB_URL no .env desta instalação.' - : 'Em desenvolvimento aponta para o hub de desenvolvimento; em produção, para o de produção.' ?> -
-
- - -

Define o URL acima para poderes anunciar esta plataforma.

- - - -
- - Anunciada a — falta aprovar na - Frota do hub. As chaves só são geradas nesse momento. -
- -
- Emparelhada com . -
- -
- O anúncio anterior expirou sem ser aprovado. Anuncia outra vez. -
- - -
- - -
-

- O anúncio leva só o URL e um código de emparelhamento — nenhuma chave sai daqui. - Voltar a anunciar gera um código novo e invalida o anterior. -

- -
- -
-
O que dar ao hub
-

No hub, em Frota → esta app, cola o URL base e a chave.

-
-
Estado (leitura)
-
GET
-
Operações
-
POST
-
Cabeçalho
-
X-Hub-Key: <chave> — a de leitura no estado, a de operações no resto
-
-

- O Authorization: Bearer também é aceite, mas o proxy à frente dos contentores - remove esse cabeçalho — daí o X-Hub-Key ser o caminho principal. -

-
-
- -
-
-
O que o hub vai ver
-
    -
  • Base de dados, SMTP, fila de envios, migrações por aplicar, espaço em disco
  • -
  • Última cópia de ficheiros e da base de dados, com a idade
  • -
  • Ramo e último commit, quando esta instalação é um repositório git
  • -
-
- -
-
O que o hub vai poder disparar
-
    -
  • backup.trigger — cópia de ficheiros ou da base de dados
  • -
  • git.pull + migrations.apply_pending — atualização da plataforma
  • -
-

- Num alojamento cPanel o git.pull costuma não estar disponível. Isso está previsto: - a resposta explica-o e as cópias continuam a funcionar, que é a parte que - protege o cliente. -

-
- -
-
Cópias agora
- -

Cópias não disponíveis nesta instalação.

- -
-
Base de dados
-
nenhuma' ?>
-
Ficheiros
-
nenhuma' ?>
-
Total
-
ficheiro(s)
-
- -
-
-
-
- - -/views/sysadmin/index.php must: -// 1. Load $pdo (config/db.php + functions.php) -// 2. Run require_sysadmin() -// 3. Set $repo_root -// Optional overrides (same as platform_update.php / backups.php): -// $platform_layout_header / $platform_layout_navbar / $platform_layout_footer -// $sysadmin_tools — array of card definitions; merged with the defaults below. -// Each entry: ['title'=>str, 'description'=>str, 'icon'=>str (bi-*), -// 'url'=>str, 'badge'=>?str, 'color'=>str (primary/info/…)] - -if (!isset($repo_root) || !is_string($repo_root)) { - $repo_root = realpath(dirname(__DIR__, 2)) ?: dirname(__DIR__, 2); -} -$repo_root = rtrim($repo_root, '/'); - -$base = function (string $path): string { - return function_exists('base_url') ? base_url($path) : '/' . ltrim($path, '/'); -}; - -$default_tools = [ - [ - 'title' => 'Atualizar plataforma', - 'description' => 'Git pull/push, OPcache, migrations e log de erros.', - 'icon' => 'bi-arrow-repeat', - 'url' => platform_admin_url('platform_update.php'), - 'color' => 'primary', - ], - [ - 'title' => 'Backups', - 'description' => 'Criar e transferir cópias de segurança dos uploads e da base de dados.', - 'icon' => 'bi-archive', - 'url' => platform_admin_url('backups.php'), - 'color' => 'secondary', - ], - [ - 'title' => 'Estado SMTP', - 'description' => 'Configuração do envio de email, teste de ligação e mensagem de teste.', - 'icon' => 'bi-envelope-check', - 'url' => platform_admin_url('smtp_status.php'), - 'color' => 'info', - ], - // Por omissão e não site a site: a partir do momento em que o cliente do hub - // é partilhado, uma plataforma que o tenha instalado sem mostrar a página - // fica ligada ao hub sem ninguém saber por onde a desligar. - [ - 'title' => 'Integração com o hub', - 'description' => 'Anunciar esta plataforma à Frota e gerir as chaves de leitura e de operações.', - 'icon' => 'bi-hdd-network', - 'url' => platform_admin_url('hub_integration.php'), - 'color' => 'secondary', - // Um site que ainda não tenha o stub não deve mostrar um cartão para 404. - 'available' => (function_exists('platform_router_enabled') && platform_router_enabled()) - || is_file($repo_root . '/public/views/sysadmin/hub_integration.php') - || is_file($repo_root . '/public/views/admin/hub_integration.php'), - ], -]; - -// Com o router, todas as páginas partilhadas existem em todos os sites — por -// isso aparecem sozinhas, e uma ferramenta nova chega a toda a frota sem cartão -// nenhum a escrever site a site. Sem router ficam de fora: cada site tem só os -// stubs que tem, e um cartão para 404 é pior do que cartão nenhum. -$routed = function_exists('platform_router_enabled') && platform_router_enabled(); -$default_tools = array_merge($default_tools, [ - [ - 'title' => 'Bloqueio de IP', - 'description' => 'Endereços recusados à porta por atividade automatizada repetida, e quem está a caminho disso.', - 'icon' => 'bi-shield-slash', - 'url' => platform_admin_url('ip_bans.php'), - 'color' => 'warning', - 'available' => $routed, - ], - [ - 'title' => 'Bloqueio por país', - 'description' => 'Recusar formulários — ou o site — a quem chega de fora do país.', - 'icon' => 'bi-globe-americas', - 'url' => platform_admin_url('geo.php'), - 'color' => 'warning', - 'available' => $routed, - ], - [ - 'title' => 'Restaurar base de dados', - 'description' => 'Aplicar um backup de base de dados, com inspecção prévia e cópia de segurança automática do estado actual.', - 'icon' => 'bi-arrow-counterclockwise', - 'url' => platform_admin_url('restore.php'), - 'color' => 'danger', - 'available' => $routed, - ], - [ - 'title' => 'Origem das visitas', - 'description' => 'De onde vêm as pessoas, com que dispositivo e de que país — e quais dessas origens produzem pedidos.', - 'icon' => 'bi-graph-up-arrow', - 'url' => platform_admin_url('tracking.php'), - 'color' => 'success', - 'available' => $routed, - ], - [ - 'title' => 'Registo de auditoria', - 'description' => 'Quem fez o quê, a quê e quando — com o antes e o depois de cada alteração.', - 'icon' => 'bi-journal-text', - 'url' => platform_admin_url('audit.php'), - 'color' => 'secondary', - 'available' => $routed, - ], -]); - -// Um site pode esconder um cartão por omissão declarando -// $platform_hide_tools = ['smtp_status.php'] no seu stub. Serve para quando a -// plataforma tem uma página própria que já faz o mesmo -- o dance tem as -// Comunicações, que editam a configuração e correm os mesmos testes, e ter os -// dois cartões lado a lado só faz hesitar em qual clicar. -$hide = array_map('strval', $platform_hide_tools ?? []); -if ($hide) { - $default_tools = array_filter( - $default_tools, - fn($t) => !array_filter($hide, fn($h) => str_contains((string) $t['url'], $h)) - ); -} - -// Cards podem declarar 'available' => bool para serem escondidos quando o -// servidor não suporta a ferramenta (ex.: exige proc_open). Ausente = visível. -$tools = array_filter( - array_merge($default_tools, $sysadmin_tools ?? []), - fn($t) => !isset($t['available']) || $t['available'] -); -// Um cartão próprio que aponta para o mesmo sítio que um por omissão não o -// repete (um site que ainda declara «Bloqueio de IP» à mão, por exemplo). -$seen = []; -$tools = array_filter($tools, function ($t) use (&$seen) { - $url = (string) ($t['url'] ?? ''); - if ($url === '' || isset($seen[$url])) return $url === ''; - return $seen[$url] = true; -}); - -$layout_header = $platform_layout_header ?? ($repo_root . '/includes/header.php'); -$layout_navbar = $platform_layout_navbar ?? ($repo_root . '/includes/navbar.php'); -$layout_footer = $platform_layout_footer ?? ($repo_root . '/includes/footer.php'); - -$pageTitle = 'Sistema'; -if (is_file($layout_header)) include $layout_header; -if (is_file($layout_navbar)) include $layout_navbar; -?> -
-
-

Sistema

-

Ferramentas de administração da plataforma.

-
- - -
- diff --git a/shared/platform-tools/ip_bans.php b/shared/platform-tools/ip_bans.php deleted file mode 100644 index 52be123..0000000 --- a/shared/platform-tools/ip_bans.php +++ /dev/null @@ -1,228 +0,0 @@ - 'danger', 'msg' => 'Pedido rejeitado (token CSRF inválido).']; - header('Location: ' . $selfUrl, true, 303); - exit; - } - $uid = isset($_SESSION['user']['id']) ? (int) $_SESSION['user']['id'] : null; - $action = $_POST['action'] ?? ''; - $ip = trim((string) ($_POST['ip'] ?? '')); - - if ($action === 'unban' && $ip !== '') { - platform_ip_unban($pdo, $ip); - if (function_exists('platform_audit')) platform_audit($pdo, 'ip.unban', ['target_type' => 'ip', 'target_label' => $ip]); - $_SESSION['ipban_flash'] = ['type' => 'success', 'msg' => "Banimento de $ip levantado. O histórico de infracções mantém-se."]; - } elseif ($action === 'ban') { - if (!filter_var($ip, FILTER_VALIDATE_IP)) { - $_SESSION['ipban_flash'] = ['type' => 'warning', 'msg' => 'Endereço IP inválido.']; - } elseif (platform_ip_ban_exempt($ip)) { - $_SESSION['ipban_flash'] = ['type' => 'warning', 'msg' => - 'Endereços internos não podem ser banidos — o contentor fala consigo próprio para o cron e os health checks.']; - } else { - $dur = $_POST['duration'] ?? '60'; - $mins = $dur === 'perm' ? null : max(1, (int) $dur); - $reason = trim((string) ($_POST['reason'] ?? '')) ?: 'banido manualmente'; - platform_ip_ban($pdo, $ip, $reason, $mins, 'manual', $uid); - if (function_exists('platform_audit')) platform_audit($pdo, 'ip.ban', ['target_type' => 'ip', 'target_label' => $ip, - 'summary' => $reason, 'changes' => ['minutes' => $mins]]); - $_SESSION['ipban_flash'] = ['type' => 'success', 'msg' => "$ip banido."]; - } - } - header('Location: ' . $selfUrl, true, 303); - exit; -} - -$flash = $_SESSION['ipban_flash'] ?? null; -unset($_SESSION['ipban_flash']); - -$bans = platform_ip_ban_list($pdo); -$offenders = platform_ip_offenders($pdo, 7); -$activos = array_values(array_filter($bans, fn($b) => empty($b['expired']))); - -$layout_header = $platform_layout_header ?? ($repo_root . '/includes/header.php'); -$layout_navbar = $platform_layout_navbar ?? ($repo_root . '/includes/navbar.php'); -$layout_footer = $platform_layout_footer ?? ($repo_root . '/includes/footer.php'); - -$pageTitle = 'Bloqueio de IP'; -if (is_file($layout_header)) include $layout_header; -if (is_file($layout_navbar)) include $layout_navbar; - -$h = fn($s) => htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); -?> -
- -
-

Bloqueio de IP

-

- Endereços recusados à porta por atividade automatizada repetida. -

-
- - -
- - -
- Como a escalada funciona. Cada recusa do antispam, do portão de - país ou do throttle de login conta como uma infracção. A partir de - 3 infracções em 24 horas o endereço é banido 1 hora; às 5, um dia; - às 8, uma semana; às 12, permanentemente. - - 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. - - - Com o site emparelhado com o hub, os banimentos daqui passam aos outros sites da frota e os - deles chegam cá com a origem frota. Levantar um banimento retira as cópias de - todos os sites; o site onde ele nasceu mantém o seu até expirar ou ser levantado lá. - -
- -
-
- Banidos - activo -
-
- -

Nenhum endereço banido até agora.

- -
- - - - - - - - - - - - - - - -
IPMotivoOrigemAtéInfr.Tentat.
> - - permanente - - - - expirou - - - -
- - - - -
- -
-
-

- Tentat. é quantas vezes o endereço voltou depois de banido — - um número alto é o sinal mais claro de que não é uma pessoa. -

- -
-
- -
-
A subir — infracções sem banimento (7 dias)
-
- -

Nenhuma infracção registada no período.

- -
- - - - - - - - - - - - - -
IPInfracçõesTiposÚltima
-
- - - - - - -
-
-
- -
-
- -
-
Banir à mão
-
-
- - -
- - -
-
- - -
-
- - -
-
- -
-
-
-
- -
- diff --git a/shared/platform-tools/platform_update.php b/shared/platform-tools/platform_update.php deleted file mode 100644 index 64dfce8..0000000 --- a/shared/platform-tools/platform_update.php +++ /dev/null @@ -1,2209 +0,0 @@ - after the last fetch (empty if unfetched) - * pinned_info — "%cr|%s" of pinned commit (relative time + subject) - * remote_info — "%cr|%s" of remote head commit - * behind — int; commits between pinned and remote (0 = up to date) - * wt_dirty — bool; working tree of the submodule no longer at pinned (rare) - */ -function platform_submodules(string $repo_root): array { - $gm = $repo_root . '/.gitmodules'; - if (!is_file($gm)) return []; - - $raw = @file_get_contents($gm); - if ($raw === false) return []; - - // Parse `[submodule "name"]` blocks manually — parse_ini_file chokes on - // section names with quotes/spaces. - $sections = []; - $current = null; - foreach (preg_split('/\R/', $raw) as $line) { - $line = trim($line); - if ($line === '' || str_starts_with($line, '#')) continue; - if (preg_match('/^\[submodule\s+"(.+)"\]$/', $line, $m)) { - $current = $m[1]; - $sections[$current] = []; - continue; - } - if ($current && preg_match('/^([a-z]+)\s*=\s*(.+)$/i', $line, $m)) { - $sections[$current][strtolower($m[1])] = trim($m[2]); - } - } - - $subs = []; - foreach ($sections as $name => $vals) { - $path = $vals['path'] ?? $name; - $url = $vals['url'] ?? ''; - $branch = $vals['branch'] ?? ''; - $full = $repo_root . '/' . $path; - - $entry = [ - 'name' => $name, 'path' => $path, 'url' => $url, - 'branch' => $branch ?: 'main', - 'initialized' => false, - 'pinned_sha' => '', 'wt_sha' => '', 'remote_sha' => '', - 'pinned_info' => '', 'remote_info' => '', - 'behind' => 0, 'wt_dirty' => false, - ]; - - // A submodule is "initialized" when it has a .git entry (file or dir). - if (!file_exists($full . '/.git')) { - $subs[] = $entry; - continue; - } - $entry['initialized'] = true; - - // Pinned SHA from the parent's HEAD tree (the gitlink). - $ls = git_run(['ls-tree', 'HEAD', $path], $repo_root); - if ($ls['ok'] && preg_match('/^\d{6}\s+commit\s+([0-9a-f]{40})\s/', $ls['output'], $m)) { - $entry['pinned_sha'] = $m[1]; - } - - // Working-tree SHA. - $rp = git_run(['rev-parse', 'HEAD'], $full); - if ($rp['ok'] && preg_match('/^[0-9a-f]{40}$/', trim($rp['output']))) { - $entry['wt_sha'] = trim($rp['output']); - } - - // Resolve the branch to track. If .gitmodules didn't say, fall back to - // origin/HEAD's symbolic ref (set by `git remote set-head`), else "main". - if (!$branch) { - $sh = git_run(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], $full); - if ($sh['ok'] && $sh['output'] !== '') { - $b = trim($sh['output']); - $entry['branch'] = str_starts_with($b, 'origin/') ? substr($b, 7) : $b; - } - } - - // Remote SHA (after fetch). Empty if origin/ doesn't exist yet. - $rs = git_run(['rev-parse', '--verify', '--quiet', 'origin/' . $entry['branch']], $full); - if ($rs['ok'] && preg_match('/^[0-9a-f]{40}$/', trim($rs['output']))) { - $entry['remote_sha'] = trim($rs['output']); - } - - // Commit details. - if ($entry['pinned_sha']) { - $li = git_run(['log', '-1', '--format=%cr|%s', $entry['pinned_sha']], $full); - if ($li['ok']) $entry['pinned_info'] = $li['output']; - } - if ($entry['remote_sha']) { - $li = git_run(['log', '-1', '--format=%cr|%s', $entry['remote_sha']], $full); - if ($li['ok']) $entry['remote_info'] = $li['output']; - } - - // Commits between pinned and remote. - if ($entry['pinned_sha'] && $entry['remote_sha'] && $entry['pinned_sha'] !== $entry['remote_sha']) { - $rc = git_run(['rev-list', '--count', $entry['pinned_sha'] . '..' . $entry['remote_sha']], $full); - if ($rc['ok']) $entry['behind'] = (int) trim($rc['output']); - } - - $entry['wt_dirty'] = $entry['pinned_sha'] && $entry['wt_sha'] - && $entry['pinned_sha'] !== $entry['wt_sha']; - - $subs[] = $entry; - } - - return $subs; -} - -@set_time_limit(0); - -if (!isset($repo_root) || !is_string($repo_root)) { - $repo_root = realpath(dirname(__DIR__, 2)) ?: dirname(__DIR__, 2); -} -$repo_root = rtrim($repo_root, '/'); - -// Tools that shell out (git, composer) need the proc_* family; shared hosts -// often kill it via disable_functions. When missing, those tools are hidden -// from the UI and their POSTs refused — migrations, esquema, OPcache e log de -// erros continuam totalmente funcionais. -$can_exec = platform_proc_open_available(); -$git_available = $can_exec && file_exists($repo_root . '/.git'); - -$action_output = null; -$action_label = null; -$action_ok = null; - -if ($_SERVER['REQUEST_METHOD'] === 'POST') { - $action = $_POST['action'] ?? ''; - $uid = (int) ($_SESSION['user_id'] ?? ($_SESSION['user']['id'] ?? 0)) ?: null; - - // Every action on this page mutates server state (reset --hard, composer - // update, migrations…) — none may run off a cross-site form post. - if (!platform_csrf_ok()) { - $action = ''; - $action_label = 'Pedido rejeitado'; - $action_ok = false; - $action_output = 'Token CSRF ausente ou inválido. Recarrega a página e repete a ação.'; - } - - // Defense-in-depth: a UI esconde estas ferramentas quando a capacidade - // falta, mas um POST direto tem de ser recusado na mesma — nunca fatal. - $git_actions = ['fetch', 'pull', 'sync_submodules', 'force_sync', 'discard_local', - 'fix_filemode', 'stash_push', 'stash_pop', 'stash_drop', 'push', - 'reset_upstream', 'submodule_fetch', 'submodule_update', 'switch_branch', - 'auto_update_run', 'restore_point_revert']; - if ($action !== '' && in_array($action, $git_actions, true) && !$git_available) { - $action_label = 'Ação indisponível'; - $action_ok = false; - $action_output = $can_exec - ? 'Esta pasta não é um repositório git — inicializa com git init + remote.' - : 'proc_open() está desativado no PHP deste servidor (disable_functions) — as operações git via web não estão disponíveis.'; - $action = ''; - } - if (in_array($action, ['composer_install', 'composer_update', 'composer_check', 'composer_update_pkg'], true) && !$can_exec) { - $action_label = 'Ação indisponível'; - $action_ok = false; - $action_output = 'proc_open() está desativado no PHP deste servidor (disable_functions) — o composer via web não está disponível.'; - $action = ''; - } - - switch ($action) { - case 'fetch': - $res = git_run(['fetch', '--all', '--prune'], $repo_root); - $action_label = 'git fetch'; - $action_ok = $res['ok']; - $action_output = $res['output'] ?: ($res['ok'] ? '(sem alterações remotas)' : '(sem saída)'); - break; - - case 'pull': - // Robust, submodule-aware, mode-bit-tolerant update. Hardens - // core.fileMode first, tries a fast-forward, and only force-syncs - // when the divergence is provably nothing but mode/submodule drift. - // Real edits/commits are never discarded — it stops and reports them. - // - // O backup pré-atualização corre primeiro e é bloqueante: se estiver - // ligado, ou existe ponto de restauro ou não se mexe em nada. É o - // mesmo caminho que o webhook percorre. - $action_label = 'Atualizar plataforma'; - $bk = platform_pre_update_backup($pdo, $repo_root); - if (!$bk['ok']) { - $action_ok = false; - $action_output = implode("\n", $bk['log']) - . "\n\nNada foi atualizado."; - break; - } - $before_sha = update_head_sha($repo_root); - $res = git_safe_update($repo_root); - $action_ok = $res['ok']; - $action_output = ($bk['log'] ? implode("\n", $bk['log']) . "\n\n" : '') - . ($res['output'] ?: '(sem saída)'); - // Ponto de restauro só quando HEAD mexeu mesmo — ver - // update_restore_point_store(). Migrations aqui são um passo à parte - // (têm botões próprios), por isso a lista vai vazia. - $after_sha = update_head_sha($repo_root); - if ($res['ok'] && $before_sha !== '' && $after_sha !== '' && $before_sha !== $after_sha) { - update_restore_point_store($pdo, $repo_root, $before_sha, [], $bk['created']); - $action_output .= "\n\nPonto de restauro guardado: " . substr($before_sha, 0, 8) - . ' — podes voltar atrás no cartão "Ponto de restauro".'; - } - // Invalidate bytecode cache so updated PHP files take effect - // immediately (only matters when opcache.validate_timestamps=0). - if ($res['ok'] && function_exists('opcache_reset')) { - @opcache_reset(); - $action_output .= "\n\n(OPcache invalidado)"; - } - // Settings cache (DSA/fotografia-style): drop so a setting that - // changed upstream is read fresh from the DB on next request. - if ($res['ok'] && function_exists('settings_cache_clear')) settings_cache_clear(); - break; - - case 'sync_submodules': - // Normalize the mode bit everywhere, then realign every submodule - // working tree to the SHA pinned by the parent (discarding only mode - // drift). Safe no-op when there are no submodules. - $hard = git_harden_repo($repo_root); - $sync = git_sync_submodules($repo_root); - $action_label = 'Sincronizar submódulos'; - $action_ok = $sync['ok']; - $action_output = implode("\n", array_merge( - ['── Normalização (core.fileMode) ──'], - $hard['log'], - ['', '── Submódulos ──'], - $sync['log'] - )); - if ($sync['ok'] && function_exists('opcache_reset')) @opcache_reset(); - if ($sync['ok'] && function_exists('settings_cache_clear')) settings_cache_clear(); - break; - - case 'force_sync': - // Destructive escape hatch: discard local state and hard-reset to - // origin/. Gated behind an explicit confirmation checkbox so - // it can never run by accident. - if (empty($_POST['confirm_force'])) { - $action_label = 'Sincronização forçada'; - $action_ok = false; - $action_output = 'Recusado: marca "Confirmo descartar o estado local" para alinhar à força com o remoto.'; - break; - } - $res = git_force_sync($repo_root); - $action_label = 'Sincronização forçada com o remoto'; - $action_ok = $res['ok']; - $action_output = $res['output'] ?: '(sem saída)'; - if ($res['ok'] && function_exists('opcache_reset')) @opcache_reset(); - if ($res['ok'] && function_exists('settings_cache_clear')) settings_cache_clear(); - break; - - case 'switch_branch': - $target = trim((string) ($_POST['branch'] ?? '')); - if ($target === '' || !preg_match('#^[a-zA-Z0-9._/-]+$#', $target)) { - $action_label = 'Mudar de ramo'; - $action_ok = false; - $action_output = 'Nome de ramo inválido.'; - break; - } - $fetch = git_run(['fetch', 'origin', $target], $repo_root); - if (!$fetch['ok']) { - $action_label = 'Mudar de ramo: ' . $target; - $action_ok = false; - $action_output = 'fetch falhou: ' . trim((string) $fetch['output']); - break; - } - $checkout = git_run(['checkout', '-B', $target, 'origin/' . $target], $repo_root); - if (!$checkout['ok']) { - $action_label = 'Mudar de ramo: ' . $target; - $action_ok = false; - $action_output = 'checkout falhou: ' . trim((string) $checkout['output']); - break; - } - $sub = git_run(['submodule', 'update', '--init', '--recursive'], $repo_root); - $action_label = 'Mudar de ramo: ' . $target; - $action_ok = $sub['ok']; - $action_output = 'checkout ' . $target . ': ok' . PHP_EOL - . ($sub['ok'] ? 'submódulos: ok' : 'submódulos falharam: ' . trim((string) $sub['output'])); - if ($sub['ok'] && function_exists('opcache_reset')) @opcache_reset(); - if ($sub['ok'] && function_exists('settings_cache_clear')) settings_cache_clear(); - break; - - case 'composer_check': - // Network call -- only ever on explicit request, never on page load. - $out = composer_outdated($repo_root, true); - $_SESSION['platform_composer_outdated'] = $out + ['checked_at' => time()]; - $action_label = 'composer outdated --direct'; - $action_ok = $out['ok']; - if (!$out['ok']) { - $action_output = $out['error']; - } elseif (!$out['packages']) { - $action_output = $out['note'] ?? 'Todas as dependências diretas estão atualizadas.'; - } else { - $lines = []; - foreach ($out['packages'] as $pk) { - $lines[] = sprintf('%-34s %-12s -> %-12s %s', $pk['name'], $pk['version'], $pk['latest'], - $pk['updatable'] - ? '(atualizável agora)' - : '(bloqueado pela restrição ' . ($pk['constraint'] ?: '?') . ')'); - } - $action_output = implode("\n", $lines); - } - break; - - case 'composer_update_pkg': - // The package name arrives from a POST. Validate it against what - // composer.lock actually contains before it becomes a shell - // argument -- never hand a request-supplied string to composer. - $want = (string) ($_POST['package'] ?? ''); - $known = composer_known_packages($repo_root); - if ($want === '' || !in_array($want, $known, true)) { - $action_label = 'Atualizar pacote'; - $action_ok = false; - $action_output = 'Pacote desconhecido: ' . $want; - break; - } - $res = composer_update_packages([$want], $repo_root); - $action_label = 'composer update ' . $want . ' --with-dependencies'; - $action_ok = $res['ok']; - $action_output = $res['output'] ?: '(sem saída)'; - // The cached list is now stale for this package -- drop it so the - // page does not keep offering an update that already happened. - unset($_SESSION['platform_composer_outdated']); - break; - - case 'composer_install': - // Rebuild vendor/ from composer.json + composer.lock (reproducible). - $res = composer_run(['install', '--no-dev', '--optimize-autoloader'], $repo_root); - $action_label = 'composer install --no-dev --optimize-autoloader'; - $action_ok = $res['ok']; - $action_output = $res['output']; - if ($res['ok'] && function_exists('opcache_reset')) @opcache_reset(); - break; - - case 'composer_update': - // Resolve newer versions within the constraints + rewrite composer.lock. - unset($_SESSION['platform_composer_outdated']); - $res = composer_run(['update', '--no-dev', '--optimize-autoloader'], $repo_root); - $action_label = 'composer update --no-dev --optimize-autoloader'; - $action_ok = $res['ok']; - $action_output = $res['output']; - if ($res['ok'] && function_exists('opcache_reset')) @opcache_reset(); - break; - - case 'discard_local': - // `git reset --hard HEAD` forcefully realigns the working tree - // with HEAD for all tracked files. Stronger than `git restore .`, - // which can silently no-op on diffs caused by CRLF normalization - // or filesystem-mode quirks visible to PHP-FPM but not the CLI - // shell user. Gitignored content (.env, config/cache, uploads/…) - // is left alone. - $res = git_run(['reset', '--hard', 'HEAD'], $repo_root); - $action_label = 'Descartar alterações locais'; - $action_ok = $res['ok']; - $combined = $res['output']; - - // If a stubborn diff survives (e.g. CRLF mismatch with a permissive - // .gitattributes), force-extract every tracked file from the index. - if ($res['ok']) { - $check = git_run(['status', '--porcelain'], $repo_root); - if ($check['ok'] && $check['output'] !== '') { - $force = git_run(['checkout-index', '--force', '-a'], $repo_root); - $combined .= ($combined !== '' ? "\n" : '') . 'Estado ainda sujo após reset --hard — re-extracção forçada do index:'; - if ($force['output'] !== '') $combined .= "\n" . $force['output']; - $action_ok = $force['ok']; - } - } - - $action_output = $combined !== '' ? $combined : ($action_ok ? 'Alterações descartadas.' : '(sem saída)'); - break; - - case 'fix_filemode': - // Stop git from treating filesystem executable-bit differences as - // a diff. Common cause of "Descartar alterações locais" doing - // nothing — `git reset --hard` doesn't touch the local config, so - // the same phantom diff reappears immediately. - // - // Only per-repo config is written (main repo + every submodule via - // git_harden_repo). A `--global` write would be pointless here: - // git_run points GIT_CONFIG_GLOBAL at a throwaway temp file, so it - // would never persist. - $harden = git_harden_repo($repo_root); - $lines = $harden['log']; - - $check = git_run(['status', '--porcelain'], $repo_root); - $lines[] = ''; - $lines[] = 'git status --porcelain:'; - $lines[] = $check['output'] !== '' ? $check['output'] : ' (working tree limpo)'; - - $action_label = 'Corrigir filemode'; - $action_ok = $harden['ok']; - $action_output = implode("\n", $lines); - break; - - case 'stash_push': - $msg = 'platform_update ' . date('Y-m-d H:i'); - $res = git_run(['stash', 'push', '-u', '-m', $msg], $repo_root); - $action_label = 'git stash push'; - $action_ok = $res['ok']; - $action_output = $res['output'] ?: ($res['ok'] ? 'Alterações guardadas no stash.' : '(sem saída)'); - break; - - case 'stash_pop': - $idx = (string) ($_POST['stash_ref'] ?? 'stash@{0}'); - if (!preg_match('/^stash@\{\d+\}$/', $idx)) $idx = 'stash@{0}'; - $res = git_run(['stash', 'pop', $idx], $repo_root); - $action_label = 'git stash pop ' . $idx; - $action_ok = $res['ok']; - $action_output = $res['output'] ?: ($res['ok'] ? 'Stash aplicado.' : '(sem saída)'); - break; - - case 'stash_drop': - $idx = (string) ($_POST['stash_ref'] ?? ''); - if (!preg_match('/^stash@\{\d+\}$/', $idx)) { - $action_label = 'git stash drop'; - $action_ok = false; - $action_output = 'Referência de stash inválida.'; - break; - } - $res = git_run(['stash', 'drop', $idx], $repo_root); - $action_label = 'git stash drop ' . $idx; - $action_ok = $res['ok']; - $action_output = $res['output'] ?: ($res['ok'] ? 'Stash removido.' : '(sem saída)'); - break; - - case 'push': - $force_protected = !empty($_POST['force_protected']); - $branch_now = trim(git_run(['rev-parse', '--abbrev-ref', 'HEAD'], $repo_root)['output']); - $is_protected = in_array(strtolower($branch_now), ['main', 'master'], true); - if ($is_protected && !$force_protected) { - $action_label = 'git push'; - $action_ok = false; - $action_output = "Recusado: o branch atual ({$branch_now}) é protegido. Marca \"Confirmo push de branch protegido\" se realmente queres empurrar a partir do servidor."; - break; - } - $res = git_run(['push'], $repo_root); - $action_label = 'git push'; - $action_ok = $res['ok']; - $action_output = $res['output'] ?: ($res['ok'] ? 'Push concluído.' : '(sem saída)'); - break; - - case 'reset_upstream': - $res = git_run(['reset', '--hard', '@{u}'], $repo_root); - $action_label = 'git reset --hard @{u}'; - $action_ok = $res['ok']; - $action_output = $res['output'] ?: ($res['ok'] ? 'Working tree alinhado com o upstream.' : '(sem saída)'); - if ($res['ok']) { - if (function_exists('opcache_reset')) @opcache_reset(); - if (function_exists('settings_cache_clear')) settings_cache_clear(); - $action_output .= "\n\n(OPcache + settings cache invalidados)"; - } - break; - - case 'reset_opcache': - if (function_exists('opcache_reset')) { - @opcache_reset(); - $action_label = 'opcache_reset()'; - $action_ok = true; - $action_output = 'OPcache invalidado.'; - } else { - $action_label = 'opcache_reset()'; - $action_ok = false; - $action_output = 'OPcache não está disponível neste servidor (extensão não instalada ou desativada).'; - } - break; - - case 'apply_migration': - $file = $_POST['filename'] ?? ''; - $res = migrations_apply_file($pdo, $file, $uid); - $action_label = 'Aplicar migration: ' . $file; - $action_ok = $res['ok']; - $action_output = $res['ok'] - ? "OK — {$res['applied']}/{$res['total']} statements aplicados." - : ($res['error'] . (isset($res['applied']) ? "\n(falhou após {$res['applied']}/{$res['total']} statements)" : '')); - // Migrations may seed new rows into system_settings. - if ($res['ok'] && function_exists('settings_cache_clear')) settings_cache_clear(); - break; - - case 'apply_pending': - $applied = []; $errors = []; - foreach (migrations_status($pdo) as $row) { - if ($row['applied_at']) continue; - $res = migrations_apply_file($pdo, $row['filename'], $uid); - if ($res['ok']) { - $applied[] = $row['filename'] . " ({$res['applied']}/{$res['total']})"; - } else { - $errors[] = $row['filename'] . ': ' . $res['error']; - break; // stop on first failure — manual intervention needed - } - } - $action_label = 'Aplicar migrations pendentes'; - $action_ok = empty($errors); - $lines = []; - if ($applied) $lines[] = "Aplicadas:\n " . implode("\n ", $applied); - if ($errors) $lines[] = "Erros:\n " . implode("\n ", $errors); - if (!$lines) $lines[] = 'Nada pendente.'; - $action_output = implode("\n\n", $lines); - if ($applied && function_exists('settings_cache_clear')) settings_cache_clear(); - break; - - case 'dump_schema': - // Structure-only, deterministic export to database/schema.sql. No git - // (nem proc_open) needed to produce it — funciona mesmo em alojamento - // partilhado onde as ferramentas git estão ocultas. - $res = schema_dump_to_file($pdo); - $action_label = 'Exportar esquema → ' . basename(schema_dump_path()); - $action_ok = $res['ok']; - $action_output = $res['ok'] - ? sprintf( - "OK — %d tabela(s)%s escritas em:\n%s\n(%s)", - $res['tables'], - $res['views'] ? " + {$res['views']} view(s)" : '', - $res['path'], - number_format($res['bytes']) . ' bytes' - ) - : $res['error']; - break; - - case 'auto_update_toggle': - $on = !auto_update_enabled($pdo); - platform_settings_set($pdo, 'auto_update_enabled', $on ? '1' : '0', $uid); - $action_label = $on ? 'Auto-update ligado' : 'Auto-update desligado'; - $action_ok = true; - $action_output = $on - ? "Cada push para este ramo passa a atualizar a plataforma.\nConfirma que o webhook está criado no Gitea e que AUTO_UPDATE_SECRET está no .env." - : 'Os pedidos do webhook passam a ser respondidos com "desligado", sem tocar no código.'; - break; - - case 'auto_update_migrations_toggle': - $on = !auto_update_migrations_enabled($pdo); - platform_settings_set($pdo, 'auto_update_migrations', $on ? '1' : '0', $uid); - $action_label = $on ? 'Migrations automáticas ligadas' : 'Migrations automáticas desligadas'; - $action_ok = true; - $action_output = $on - ? 'Um deploy passa a aplicar as migrations que vieram no mesmo commit.' - : 'O deploy passa a trazer só código — as migrations ficam pendentes para aplicares aqui.'; - break; - - case 'restore_point_revert': - // O SHA vem das definições, nunca do pedido: um POST forjado não - // consegue apontar isto para lado nenhum. - $res = update_restore_point_apply($pdo, $repo_root, !empty($_POST['confirm_discard'])); - $action_label = 'Reverter atualização'; - $action_ok = $res['ok']; - $action_output = $res['output']; - break; - - case 'restore_point_forget': - update_restore_point_clear($pdo); - $action_label = 'Ponto de restauro descartado'; - $action_ok = true; - $action_output = 'A versão atual passa a ser o ponto de partida. ' - . 'A próxima atualização volta a criar um ponto de restauro.'; - break; - - case 'update_backup_db_toggle': - case 'update_backup_files_toggle': - $which = $action === 'update_backup_db_toggle' ? 'db' : 'files'; - $key = $which === 'db' ? 'update_backup_db' : 'update_backup_files'; - $what = $which === 'db' ? 'da base de dados' : 'dos ficheiros (uploads)'; - $on = !update_backup_settings($pdo)[$which]; - platform_settings_set($pdo, $key, $on ? '1' : '0', $uid); - $action_label = 'Backup ' . $what . ' antes de atualizar: ' . ($on ? 'ligado' : 'desligado'); - $action_ok = true; - $action_output = $on - ? "Cada atualização — botão ou webhook — passa a esperar por este backup antes de tocar em código.\nSe o backup falhar ou demorar de mais, a atualização é cancelada e nada muda." - : 'As atualizações deixam de esperar por este backup.'; - break; - - case 'auto_update_run': - // O mesmo caminho que o webhook percorre, disparado à mão. É assim - // que se testa a configuração sem ter de fazer um push a sério. - $res = auto_update_run($pdo, $repo_root, null); - $action_label = 'Auto-update (execução manual)'; - $action_ok = $res['ok']; - $action_output = $res['summary'] . "\n\n" . $res['output']; - break; - - case 'mark_all_applied': - $n = migrations_mark_all_applied($pdo, $uid); - $action_label = 'Marcar todas como aplicadas'; - $action_ok = true; - $action_output = $n > 0 ? "{$n} migration(s) marcada(s) como aplicada(s)." : 'Nenhuma migration nova para marcar.'; - break; - - case 'submodule_fetch': - $sub_path = (string) ($_POST['submodule_path'] ?? ''); - $valid = git_submodule_paths($repo_root); - if (!in_array($sub_path, $valid, true)) { - $action_label = 'Fetch submódulo'; - $action_ok = false; - $action_output = 'Caminho de submódulo inválido.'; - break; - } - $full = $repo_root . '/' . $sub_path; - $res = git_run(['fetch', 'origin', '--prune'], $full); - $action_label = 'git fetch (submódulo: ' . $sub_path . ')'; - $action_ok = $res['ok']; - $action_output = $res['output'] ?: ($res['ok'] ? '(sem alterações remotas)' : '(sem saída)'); - break; - - case 'submodule_update': - $sub_path = (string) ($_POST['submodule_path'] ?? ''); - $valid = git_submodule_paths($repo_root); - if (!in_array($sub_path, $valid, true)) { - $action_label = 'Atualizar submódulo'; - $action_ok = false; - $action_output = 'Caminho de submódulo inválido.'; - break; - } - // 1. Move the gitlink in the working tree to the latest remote ref. - $r1 = git_run(['submodule', 'update', '--remote', $sub_path], $repo_root); - if (!$r1['ok']) { - $action_label = 'Atualizar submódulo (' . $sub_path . ')'; - $action_ok = false; - $action_output = $r1['output'] ?: '(sem saída)'; - break; - } - $new_sha = trim(git_run(['rev-parse', 'HEAD'], $repo_root . '/' . $sub_path)['output']); - $short_sha = $new_sha ? substr($new_sha, 0, 7) : '???????'; - - // 2. Stage the gitlink change in the parent. - $r2 = git_run(['add', $sub_path], $repo_root); - if (!$r2['ok']) { - $action_label = 'Atualizar submódulo (' . $sub_path . ')'; - $action_ok = false; - $action_output = "Bump OK, mas git add falhou:\n" . ($r2['output'] ?: '(sem saída)'); - break; - } - - // 3. If nothing was actually staged, we were already at HEAD. - $diff = git_run(['diff', '--cached', '--quiet', '--', $sub_path], $repo_root); - if ($diff['ok']) { - $action_label = 'Atualizar submódulo (' . $sub_path . ')'; - $action_ok = true; - $action_output = 'Já estava no commit mais recente (' . $short_sha . '). Nada para committar.'; - break; - } - - // 4. Commit the bump. Inject identity flags so this works even - // when the server has no user.email/user.name configured. - $r3 = git_run([ - '-c', 'user.email=platform-update@local', - '-c', 'user.name=Platform Update', - 'commit', '-m', "Bump {$sub_path} to {$short_sha}" - ], $repo_root); - $action_label = 'Atualizar submódulo (' . $sub_path . ')'; - $action_ok = $r3['ok']; - $action_output = ($r3['ok'] - ? "Submódulo {$sub_path} atualizado para {$short_sha}." - : 'Bump aplicado, mas o commit falhou:') - . ($r3['output'] !== '' ? "\n\n" . $r3['output'] : ''); - - // 5. Publish it in the same action, by default. A bump that stays - // local is exactly what makes the NEXT update (this sysadmin's or - // anyone else's) refuse to fast-forward: the parent repo now has a - // commit the remote doesn't, and platform_update's own divergence - // guard stops rather than guess how to reconcile it. Unticking - // "publicar" leaves the old two-step behaviour (bump, then the - // git push button below) for whoever wants to review first. - if ($r3['ok'] && !empty($_POST['also_push'])) { - $push = git_run(['push'], $repo_root); - $action_output .= "\n\n" . ($push['ok'] - ? "Publicado (git push)." - : "git push falhou — o bump ficou comitado localmente, publica-o com o botão git push abaixo:") - . ($push['output'] !== '' ? "\n\n" . $push['output'] : ''); - $action_ok = $action_ok && $push['ok']; - } elseif ($r3['ok']) { - $action_output .= "\nCommit criado — usa o botão git push para publicar."; - } - if ($r3['ok'] && function_exists('opcache_reset')) @opcache_reset(); - if ($r3['ok'] && function_exists('settings_cache_clear')) settings_cache_clear(); - break; - - case 'clear_error_log': - $logFile = $platform_error_log_file - ?? (defined('ERROR_LOG_FILE') ? ERROR_LOG_FILE : null); - if ($logFile) { - @file_put_contents($logFile, ''); - $action_label = 'Limpar log de erros'; - $action_ok = true; - $action_output = 'Log de erros PHP limpo.'; - } else { - $action_label = 'Limpar log de erros'; - $action_ok = false; - $action_output = 'Sem ficheiro de log configurado (define ERROR_LOG_FILE ou $platform_error_log_file).'; - } - break; - } - - // Auditoria: cada ação desta página mexe no servidor. As que só leem - // (fetch, composer_check) ficam de fora — registá-las era só ruído. - if ($action !== '' && $action_label !== null && !in_array($action, ['fetch', 'composer_check'], true) - && function_exists('platform_audit')) { - $post = $_POST; unset($post['action'], $post['_pt_csrf'], $post['csrf_token']); - platform_audit($pdo instanceof PDO ? $pdo : null, 'platform.' . $action, [ - 'target_type' => 'platform', - 'summary' => $action_label . ' — ' . ($action_ok ? 'ok' : 'falhou'), - 'changes' => $post ?: null, - ]); - } - - // ── Post/Redirect/Get ──────────────────────────────────────────────────── - // Stash the result in the session and redirect to a GET of the same URL, so - // a browser refresh re-issues the GET instead of re-POSTing the action - // (no "Confirm form resubmission?" prompt, no accidental double-apply). - if ($action_label !== null) { - $_SESSION['platform_update_flash'] = [ - 'label' => $action_label, - 'ok' => $action_ok, - 'output' => $action_output, - ]; - } - header('Location: ' . $_SERVER['REQUEST_URI'], true, 303); - exit; -} - -// On the GET that follows a POST, surface the flash the action left behind. -if ($action_label === null && !empty($_SESSION['platform_update_flash'])) { - $flash = $_SESSION['platform_update_flash']; - unset($_SESSION['platform_update_flash']); - $action_label = $flash['label'] ?? null; - $action_ok = $flash['ok'] ?? null; - $action_output = $flash['output'] ?? null; -} - -// ─── Gather state for the page ─────────────────────────────────────────────── -$git_branch = $git_available ? git_run(['rev-parse', '--abbrev-ref', 'HEAD'], $repo_root)['output'] : '—'; -$remote_branches = $git_available ? git_remote_branches($repo_root) : []; -$git_sha = $git_available ? git_run(['rev-parse', '--short', 'HEAD'], $repo_root)['output'] : '—'; -$git_remote = $git_available ? git_run(['config', '--get', 'remote.origin.url'], $repo_root)['output'] : ''; -$git_last = $git_available - ? git_run(['log', '-1', '--format=%cd|%s', '--date=format:%Y-%m-%d %H:%M:%S'], $repo_root)['output'] - : ''; -[$last_date, $last_msg] = $git_last !== '' ? array_pad(explode('|', $git_last, 2), 2, '') : ['', '']; - -// Harden BEFORE reading status: setting core.fileMode=false in the main repo and -// every submodule makes the phantom `old mode → new mode` diffs (and the -// resulting `modified: `) disappear, so the status shown below -// reflects only real changes. Idempotent — must run on every load because -// core.fileMode is local config that doesn't travel in commits. -$filemode_normalized = null; -$filemode_log = []; -if ($git_available) { - $harden = git_harden_repo($repo_root); - $filemode_normalized = $harden['normalized']; - $filemode_log = $harden['log']; -} - -$git_dirty = false; -$git_dirty_files = ''; -if ($git_available) { - $status = git_run(['status', '--porcelain'], $repo_root); - $git_dirty_files = $status['ok'] ? $status['output'] : ''; - $git_dirty = $git_dirty_files !== ''; -} - -$git_ahead = 0; $git_behind = 0; -if ($git_available) { - $rev = git_run(['rev-list', '--left-right', '--count', '@{u}...HEAD'], $repo_root); - if ($rev['ok'] && preg_match('/^(\d+)\s+(\d+)$/', $rev['output'], $m)) { - $git_behind = (int) $m[1]; - $git_ahead = (int) $m[2]; - } -} - -$git_stash_list = []; -if ($git_available) { - $sl = git_run(['stash', 'list', '--format=%gd|%s|%cr'], $repo_root); - if ($sl['ok'] && $sl['output'] !== '') { - foreach (explode("\n", $sl['output']) as $line) { - $parts = explode('|', $line, 3); - if (count($parts) >= 2) { - $git_stash_list[] = [ - 'ref' => $parts[0], - 'desc' => $parts[1], - 'when' => $parts[2] ?? '', - ]; - } - } - } -} - -$git_log = ''; -if ($git_available) { - $lg = git_run( - ['log', '-n', '20', '--format=%h %cd %s', '--date=format:%Y-%m-%d %H:%M'], - $repo_root - ); - if ($lg['ok']) $git_log = $lg['output']; -} - -$git_diff = ''; -if ($git_available && $git_dirty) { - $df = git_run(['diff', '--no-color'], $repo_root); - if ($df['ok']) $git_diff = $df['output']; -} - -$git_protected_branch = in_array(strtolower((string) $git_branch), ['main', 'master'], true); - -// Divergence that a plain fast-forward can't resolve: the remote has commits we -// don't, AND we have either local commits or uncommitted changes in the way. -// "Atualizar" handles the mode-only case automatically; this only gates the -// manual destructive escape hatch (force sync) for the real-divergence case. -$git_diverged = $git_available && $git_behind > 0 && ($git_ahead > 0 || $git_dirty); - -$migrations = migrations_status($pdo); -$pending = array_filter($migrations, fn($r) => $r['applied_at'] === null); -$applied_n = count($migrations) - count($pending); - -// Pendentes primeiro. A lista só cresce, e com dezenas de ficheiros aplicados a -// única linha que interessa — a que ainda não correu — acabava fora do ecrã. -// usort mantém a ordem por nome dentro de cada grupo (ordenação estável no PHP 8). -usort($migrations, fn($a, $b) => ($a['applied_at'] === null ? 0 : 1) <=> ($b['applied_at'] === null ? 0 : 1)); - -$submodules = $git_available ? platform_submodules($repo_root) : []; - -// ─── Auto-update (webhook de deploy) ───────────────────────────────────────── -$au_on = auto_update_enabled($pdo); -$au_migrations = auto_update_migrations_enabled($pdo); -$au_secret_set = auto_update_secret() !== ''; -$au_last = auto_update_last($pdo); -// O URL que se cola no Gitea. base_url() aponta para a docroot pública, que é -// exactamente onde vive o stub api/auto_update.php. -$au_hook_url = function_exists('base_url') ? base_url('api/auto_update.php') : '/api/auto_update.php'; - -$restore_point = update_restore_point($pdo); - -// ─── Backup antes de atualizar ─────────────────────────────────────────────── -$ub = update_backup_settings($pdo); -// O último backup de cada tipo serve de estimativa do custo: "ligar isto vai -// juntar 4 GB e uns minutos a cada deploy" é uma coisa que se deve ver ANTES de -// ligar, não descobrir no primeiro push. -$ub_last = ['db' => null, 'files' => null]; -if (platform_backup_bootstrap($repo_root)) { - foreach (backup_list() as $b) { - if ($ub_last[$b['type']] === null) $ub_last[$b['type']] = $b; - } -} - -$composer = composer_state($repo_root); -// Only probe the binary when the card will actually render (and when the -// server can spawn processes at all). -$composer_avail = ($composer['has_json'] && $can_exec) ? composer_available($repo_root) : null; -// Populated only by the explicit "Verificar atualizações" action -- composer -// outdated hits the network and must not run on every page load. -$composer_outdated = $_SESSION['platform_composer_outdated'] ?? null; - -// ─── Error log (optional) ──────────────────────────────────────────────────── -$error_log_file = $platform_error_log_file - ?? (defined('ERROR_LOG_FILE') ? ERROR_LOG_FILE : null); -$error_log_entries = []; -$error_log_total = 0; -if ($error_log_file && is_readable($error_log_file)) { - $lines = file($error_log_file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); - $error_log_total = count($lines); - foreach (array_reverse(array_slice($lines, -200)) as $line) { - $entry = json_decode($line, true); - if (is_array($entry)) $error_log_entries[] = $entry; - } -} - -// ─── Layout resolution ─────────────────────────────────────────────────────── -$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') ? base_url('views/admin/dashboard.php') : 'dashboard.php'); - -$pageTitle = 'Atualizar plataforma'; -if (is_file($layout_header)) include $layout_header; -if (is_file($layout_navbar)) include $layout_navbar; -?> - -
- 0) { - $hero_pills[] = ['warning text-dark', 'bi-cloud-arrow-down', $git_behind . ' por atualizar']; - } elseif (!$git_dirty && $git_ahead === 0) { - $hero_pills[] = ['success', 'bi-check2-circle', 'atualizado']; - } - if ($git_dirty) $hero_pills[] = ['danger', 'bi-pencil', 'alterações locais']; - if ($git_ahead > 0) $hero_pills[] = ['info', 'bi-cloud-arrow-up', $git_ahead . ' por enviar']; - } - if ($pending) $hero_pills[] = ['warning text-dark', 'bi-database', count($pending) . ' migration(s)']; - if ($au_on) $hero_pills[] = ['success', 'bi-arrow-repeat', 'auto-update']; - if ($ub['db'] || $ub['files']) $hero_pills[] = ['secondary', 'bi-shield-check', 'backup pré-update']; - ?> -
-
-
-
Atualizar plataforma
-
- - - - - - - - - -
- -
- - - -
- -
- -
- -
- - -
- - - - Reverter - - - Voltar -
-
- -
-
-
    - -
  • - -
-
-
- - -
- - -
- Execução de processos indisponível neste servidor. - O PHP tem proc_open() desativado (disable_functions no php.ini), por isso as - ferramentas git e composer estão ocultas. Migrations, esquema, OPcache e log de erros continuam disponíveis. -
- -
- Esta pasta não é um repositório git. - Inicializa com git init + remote para o Gitea. A actualização via web só funciona quando existe um .git. -
- - - -
- -
-
- - - - -
- Stashes guardados (): -
    - -
  • - - - () - -
    - - - -
    -
    - - - -
    -
    -
  • - -
-
Pop aplica o stash ao working tree e remove-o; drop apaga sem aplicar.
-
- - - ['fetch', 'pull', 'sync_submodules', 'force_sync', 'switch_branch', 'discard_local', - 'fix_filemode', 'stash_push', 'stash_pop', 'stash_drop', 'push', 'reset_upstream', - 'submodule_fetch', 'submodule_update', 'restore_point_revert', 'restore_point_forget'], - 'automacao' => ['auto_update_toggle', 'auto_update_migrations_toggle', 'auto_update_run', - 'update_backup_db_toggle', 'update_backup_files_toggle'], - 'dados' => ['apply_migration', 'apply_pending', 'mark_all_applied', 'dump_schema'], - 'servidor' => ['composer_check', 'composer_install', 'composer_update', 'composer_update_pkg', - 'reset_opcache', 'clear_error_log'], - ]; - $active_tab = 'atualizar'; - $posted = (string) ($_POST['action'] ?? ''); - foreach ($tab_of_action as $tab => $acts) { - if ($posted !== '' && in_array($posted, $acts, true)) { $active_tab = $tab; break; } - } - $tabs = [ - 'atualizar' => ['Atualizar', 'bi-arrow-down-circle', $git_available ? null : 'indisponível'], - 'automacao' => ['Automação', 'bi-robot', $au_on ? 'on' : null], - 'dados' => ['Base de dados', 'bi-database', $pending ? (string) count($pending) : null], - 'servidor' => ['Servidor', 'bi-hdd-stack', $error_log_entries ? (string) count($error_log_entries) : null], - ]; - ?> - - -
-
- - -
-
- Ponto de restauro - disponível -
-
-

- A última atualização partiu daqui. Voltar atrás põe o servidor exactamente no código que - estava antes — os commits novos ficam no remote e podem ser puxados outra vez a seguir. -

-
-
- Commit: - - - - -
- -
- -
- - -
- Atualizado em -
- -
- - -
- A base de dados não recua com o código. - Estas migrations correram nesta atualização e continuam aplicadas: -
    - -
  • - -
- - Backup tirado imediatamente antes: - - - - — restaura-o pela página Restaurar base de dados se o esquema novo for o problema. - - Não há backup pré-atualização deste deploy; liga-o no separador Automação para o próximo. - -
- -
- -
- - - - -
-
- Git - - "> - - core.fileMode normalizado - - core.fileMode não normalizado - - - -
-
-
-
-
Branch:
-
- - - - -
-
Commit actual:
- -
Remote:
- -
-
- -
Último commit:
-
- - - -
- - commit(s) por puxar - - - commit(s) local não-empurrado(s) - -
- -
-
- - - - - - -
- Atenção: há alterações locais não-comitidas. git pull --ff-only vai falhar até que sejam comitidas ou descartadas. -
-
-
- - -
- - - -
- - -
-
- - -
-
-
- -
- -
- - - - -
-
- Submódulos - - - - 0): ?> - · commit(s) por puxar - - · tudo atualizado - - -
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - -
SubmóduloPinnedRemoto (origin/<branch>)EstadoAções
-
-
- - - · - -
-
- Não inicializado. Corre git submodule update --init no servidor. - - - -
- · -
- -
- - - -
- · -
- - - Faz fetch primeiro - -
- - Por verificar - 0): ?> - behind - - Atualizado - - - WT difere - - -
-
- - - -
- 0): ?> -
- - - -
- - -
- -
- -
-
-
-
- -
- - -
- -
- -
-
- Auto-update - - Ligado - - Desligado - -
-
-

- Um push para atualiza a plataforma - sozinha: git pull, migrations que vieram no mesmo commit, e caches invalidadas. - Nunca descarta trabalho local — se houver alterações por comitar no servidor, pára e diz. -

- -
-
-
URL do webhook (Gitea → Definições → Webhooks → Gitea):
- -
- Segredo: - - AUTO_UPDATE_SECRET definido - - falta AUTO_UPDATE_SECRET no .env - -
-
- O mesmo valor vai no campo "Secret" do webhook. Sem ele o endpoint recusa tudo com 401. -
-
- -
-
Última execução:
- -
Ainda nunca correu.
- -
- - OK - - Falhou - - - - - -
-
- - - - -
-
- - -
-
-
- -
- -
- - -
-
- Backup antes de atualizar - - - - - - Sem backup - -
-
-

- Aplica-se às duas vias de atualização — o botão Atualizar e o webhook. O backup corre - antes de o código se mexer e a atualização espera por ele: se falhar - ou demorar de mais, nada é atualizado. Um interruptor que promete ponto de restauro e depois avança - sem ele seria pior do que não existir. -

- -
- 'db', 'on' => $ub['db'], 'action' => 'update_backup_db_toggle', - 'icon' => 'bi-database', 'title' => 'Base de dados', - 'note' => 'Dump completo, encriptado quando há APP_SECRET_KEY. É o que permite voltar atrás de uma migration que correu mal.'], - ['key' => 'files', 'on' => $ub['files'], 'action' => 'update_backup_files_toggle', - 'icon' => 'bi-folder', 'title' => 'Ficheiros (uploads)', - 'note' => 'ZIP de uploads/. O git nunca lhe toca, por isso raramente é o que falta a seguir a um deploy — e num site com fotografias é o que faz cada atualização demorar minutos.'], - ] as $row): ?> -
-
-
- - - Ligado - - Desligado - -
-
-
- - - Último: - · - - Ainda não há nenhum backup deste tipo. - -
-
- - - -
-
-
- -
- - -
- - Com o backup de ficheiros ligado, cada atualização espera que uploads/ seja arquivado. - Num site com galerias isso são minutos e vários GB por deploy — e o git nunca mexe em - uploads/, por isso este é quase sempre o interruptor a deixar desligado. -
- -
-
- -
- -
- -
-
- Migrations - - -
- - - -
-
-
- -
Sem migrations na pasta .
- -
- - - - - - - - - - $m): ?> - - - - - - - - - - - - - - -
FicheiroEstadoAção
- - Aplicada - - - Pendente - - - - -
- - - -
- - — - -
-
-
-
-
Nada nesta vista.
- - -
- -
- -
- -
-
-
- - -
-
- PHP OPcache -
- - Ativo - - Inativo - - Não disponível - - -
- - -
- -
-
- -
- -
-
Hit rate%
-
Scripts em cache /
-
Memória MB usados · MB livres
-
Hits
-
-
- -
- OPcache acelera o PHP em ~5–10× ao guardar o bytecode dos scripts em memória partilhada. - Para o ativar em produção, no php.ini: -
opcache.enable=1
-opcache.memory_consumption=128
-opcache.max_accelerated_files=10000
-opcache.validate_timestamps=0   ; em prod (em dev: 1)
- Com validate_timestamps=0, esta página invalida o cache automaticamente após cada git pull. -
- -
- -
-
- - -
-
- Composer - - - vendor/ instalado - - vendor/ em falta - - -
-
- - -
- - - (verificado ). -
- -
- - - - - - - - - - - - - - - - - - -
PacoteInstaladoDisponívelRestriçãoAção
- - - atualizável - - bloqueado - - - - -
- - - - -
- - - - restrição impede - - -
-
- - -
- Falha ao verificar: -
- -
- Pacotes declarados: - $ver): ?> - : - -
-

- Carrega em verificar atualizações para - comparar com o packagist. -

- -

composer.json sem pacotes em require.

- -

- install reconstrói o vendor/ a partir do lock (reproduzível); - update resolve versões novas e reescreve o lock. - Ambos com --no-dev --optimize-autoloader. -

-
- -
- - -
-
- - - -
-
- - - - - Log de erros PHP - -
- - -
-
- -
Nenhum erro registado.
- -
-
- - - - - - - - - - - - - $err): ?> - 'text-bg-danger', - str_contains(strtolower($err['type'] ?? ''), 'warning') => 'text-bg-warning', - str_contains(strtolower($err['type'] ?? ''), 'notice'), - str_contains(strtolower($err['type'] ?? ''), 'deprecat') => 'text-bg-secondary', - default => 'text-bg-secondary', - }; - $ts = isset($err['ts']) ? date('d/m/Y H:i:s', strtotime($err['ts'])) : '—'; - $uid = !empty($err['user_id']) - ? '(' . (int) $err['user_id'] . ') ' . htmlspecialchars($err['user_name'] ?? '') - : 'anónimo'; - ?> - - - - - - - - - - - - - - - -
Data/horaTipoMensagemUtilizadorURL
- - -
- : -
- -
- - - - - -
-
-
-
-
- -
- -
- -
-
-
    -
  • proc_open() ativo no PHP (fora de disable_functions) — necessário para as ferramentas git e composer; sem ele, esses cartões ficam ocultos.
  • -
  • O utilizador do Apache/PHP-FPM tem permissão de escrita nesta pasta.
  • -
  • Autenticação: para remotes HTTPS/HTTP (Gitea típico), define GIT_HTTP_USERNAME e GIT_HTTP_TOKEN (Personal Access Token) no .env. Para remotes SSH, autoriza a chave do utilizador no Gitea.
  • -
  • O dubious ownership guard do Git é tratado em-process via gitconfig temporário com safe.directory = .
  • -
  • Depois de git pull com mudanças a configurações em cache, as caches são invalidadas automaticamente quando o host define settings_cache_clear().
  • -
-
-
-
- -
- -
- -
- - - - - - - - - - diff --git a/shared/platform-tools/restore.php b/shared/platform-tools/restore.php deleted file mode 100644 index e9d3ccb..0000000 --- a/shared/platform-tools/restore.php +++ /dev/null @@ -1,257 +0,0 @@ - 'danger', 'msg' => 'Pedido rejeitado (token CSRF inválido).']; - header('Location: ' . $selfUrl, true, 303); - exit; - } - - $file = (string) ($_POST['filename'] ?? ''); - $typed = trim((string) ($_POST['confirm_name'] ?? '')); - $dropExtra = !empty($_POST['drop_extra']); - - if ($typed !== $file) { - $_SESSION['restore_flash'] = ['type' => 'warning', 'msg' => - 'O nome escrito não corresponde ao ficheiro escolhido — nada foi feito.']; - header('Location: ' . $selfUrl . '?f=' . urlencode($file), true, 303); - exit; - } - - // A partir daqui a base de dados vai ser substituída. - $report = restore_db($pdo, $file, ['safety' => true, 'drop_extra_tables' => $dropExtra]); - // Depois do restauro: a tabela do registo também foi reposta, e é na nova que - // tem de ficar escrito que isto aconteceu. - if (function_exists('platform_audit')) { - platform_audit($pdo, 'backup.restore', ['target_type' => 'backup', 'target_label' => $file, - 'summary' => !empty($report['ok']) ? 'Base de dados reposta' : 'Restauro falhou', - 'changes' => ['drop_extra_tables' => $dropExtra]]); - } - $_SESSION['restore_report'] = $report; - header('Location: ' . $selfUrl, true, 303); - exit; -} - -$flash = $_SESSION['restore_flash'] ?? null; -$report = $_SESSION['restore_report'] ?? null; -unset($_SESSION['restore_flash'], $_SESSION['restore_report']); - -$all = backup_list(); -$dbFiles = array_values(array_filter($all, fn($b) => ($b['type'] ?? '') === 'db')); -$selected = (string) ($_GET['f'] ?? ''); -$inspect = $selected !== '' ? restore_inspect($selected) : null; -$tables = restore_current_tables($pdo); - -$layout_header = $platform_layout_header ?? ($repo_root . '/includes/header.php'); -$layout_navbar = $platform_layout_navbar ?? ($repo_root . '/includes/navbar.php'); -$layout_footer = $platform_layout_footer ?? ($repo_root . '/includes/footer.php'); -$backupsUrl = $platform_backups_url - ?? (function_exists('platform_admin_url') ? platform_admin_url('backups.php') : 'backups.php'); - -$pageTitle = 'Restaurar base de dados'; -if (is_file($layout_header)) include $layout_header; -if (is_file($layout_navbar)) include $layout_navbar; - -$h = fn($s) => htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); -?> -
- -
-
-

Restaurar base de dados

-

Substitui o esquema e os dados actuais por um artefacto de backup.

-
- - Backups - -
- - -
- - - -
-
- - -
-
- - - - - - - - - - - - - - - - - - - - - -
Artefacto aplicado
Backup de segurança' . $h($report['safety_backup']) . '' - : 'nenhum' ?>
Statements aplicados de
Tabelas antes → depois
Tabelas removidas -
Existiam aqui e não no artefacto.
Erro
- -

- O estado anterior está em — - restaurá-lo devolve a base ao que era antes desta tentativa. -

- -
-
- - -
- -
- Isto é destrutivo. A base de dados actual - ( 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. -
-
- -
-
Artefactos de base de dados
-
- -

- Não há backups de base de dados em backups/. - Crie um a partir da página de Backups. -

- -
- - - - - > - - - - - - - -
FicheiroTamanhoCriado
- Inspeccionar -
-
- -
-
- - -
-
Inspecção
-
- -
- -
- é um backup de ficheiros, não de base de dados. -
- - - - - - - - - - - -
Ficheiro
Tamanho
Entradas no ZIP
Dump
- -
- -
- - - -
- - -
- -
- - -
- É escrito à mão de propósito: obriga a olhar para qual artefacto está - prestes a aplicar. -
-
- -
- -
-
- -
-
- - -
- diff --git a/shared/platform-tools/router.php b/shared/platform-tools/router.php deleted file mode 100644 index fb8193c..0000000 --- a/shared/platform-tools/router.php +++ /dev/null @@ -1,110 +0,0 @@ -. Uma ferramenta nova -// no platform-tools aparece em todos os sites no próximo update do submódulo, -// sem ficheiro nenhum a criar do lado de lá. -// -// O que os stubs faziam à mão vem agora do platform_connect() do site: -// -// guard quem pode entrar (obrigatório — sem ele isto recusa) -// guards guarda própria de uma ferramenta, p. ex. ['tracking' => 'require_admin'] -// replaced_tools ferramenta que o site substitui por página sua: -// ['smtp_status' => 'views/sysadmin/comms.php'] -// layout ['header' => …, 'navbar' => …, 'footer' => …] -// dashboard_url o «Voltar» das páginas (relativo ao base_url, ou absoluto); -// em array, por ferramenta — as outras voltam ao índice -// migrations_dir onde vivem as migrações, quando não é database/migrations/ -// sysadmin_tools cartões próprios do índice (array, ou callable que o devolve) -// hide_tools cartões por omissão a esconder, p. ex. ['smtp_status'] -// page_vars variáveis `platform_*` extra de uma página, p. ex. -// ['platform_error_log_file' => …] -// -// Query string (platform.php?tool=backups) e não /platform/backups: um caminho -// bonito precisa de regras de reescrita, e a frota tem Apache em Docker, cPanel -// e talvez nginx. Isto funciona em todos sem configurar nada. -// -// A tabela de rotas é uma lista fechada, e cada página continua a fazer as suas -// verificações (CSRF, confirmação escrita). O router só despacha e guarda a -// porta — nunca inclui um nome vindo do pedido. - -declare(strict_types=1); - -require_once __DIR__ . '/hub_bootstrap.php'; // $repo_root, $pdo, config/db.php + functions.php -require_once __DIR__ . '/functions_connector.php'; -require_once __DIR__ . '/functions_routes.php'; - -$tool = (string) ($_GET['tool'] ?? 'index'); -$route = platform_routes()[$tool] ?? null; -if ($route === null) { - http_response_code(404); - exit('Ferramenta desconhecida.'); -} - -$cfg = platform_config(); -// Uma guarda por ferramenta quando o site a declara (a Origem das visitas é de -// admin na fotografia, não só de sysadmin); senão a geral. -$guard = $cfg['guards'][$tool] ?? ($cfg['guard'] ?? null); -if ($cfg === [] || !is_callable($guard)) { - // Falha fechada: sem guarda declarada não há forma de saber quem pode entrar, - // e uma página de sistema aberta a qualquer um é pior do que nenhuma. - http_response_code(500); - exit('Este site ainda não declara uma guarda de sysadmin no platform_connect() — ' - . 'as páginas de sistema não abrem sem ela.'); -} -$guard(); - -// Um site pode ter página própria no lugar de uma partilhada (as Comunicações -// da fotografia em vez do Estado SMTP: dois editores da mesma configuração -// acabavam por mostrar um o que o outro não gravou). O URL partilhado continua -// a chegar lá. -if (!empty($cfg['replaced_tools'][$tool])) { - header('Location: ' . platform_route_absolute_url((string) $cfg['replaced_tools'][$tool]), true, 302); - exit; -} - -if (!$pdo instanceof PDO && !empty($route['needs_db'])) { - http_response_code(503); - exit('Esta ferramenta precisa de base de dados, e este site não tem.'); -} - -// O que os stubs definiam antes de incluir a página. -foreach (['header', 'navbar', 'footer'] as $part) { - if (!empty($cfg['layout'][$part])) ${'platform_layout_' . $part} = $cfg['layout'][$part]; -} -// Em array é por ferramenta, e as que lá não estão voltam ao índice do Sistema -// (é de lá que se chega a elas): ['index' => 'views/admin/users.php']. -if (is_array($cfg['dashboard_url'] ?? null)) { - $platform_dashboard_url = isset($cfg['dashboard_url'][$tool]) - ? platform_route_absolute_url((string) $cfg['dashboard_url'][$tool]) - : platform_route_url('index'); -} elseif (!empty($cfg['dashboard_url'])) { - $platform_dashboard_url = platform_route_absolute_url((string) $cfg['dashboard_url']); -} -if (!empty($cfg['migrations_dir'])) { - $GLOBALS['MIGRATIONS_DIR'] = (string) $cfg['migrations_dir']; -} -foreach ((array) ($cfg['page_vars'] ?? []) as $name => $value) { - if (is_string($name) && str_starts_with($name, 'platform_')) ${$name} = $value; -} -if ($tool === 'index') { - $tools = $cfg['sysadmin_tools'] ?? []; - $sysadmin_tools = is_callable($tools) ? (array) $tools() : (array) $tools; - foreach ($sysadmin_tools as &$card) { - if (isset($card['url'])) $card['url'] = platform_route_absolute_url((string) $card['url']); - } - unset($card); - // Sem o .php: o índice compara com str_contains() no URL do cartão, e o do - // router é …?tool=smtp_status, não …/smtp_status.php. - $platform_hide_tools = array_map(fn($h) => preg_replace('/\.php$/', '', (string) $h), (array) ($cfg['hide_tools'] ?? [])); -} -if (!empty($route['backup_helpers'])) { - hub_load_backup_helpers(); -} - -unset($cfg, $tools, $part, $name, $value); -require __DIR__ . '/' . $route['file']; diff --git a/shared/platform-tools/setup.php b/shared/platform-tools/setup.php deleted file mode 100644 index e0c134a..0000000 --- a/shared/platform-tools/setup.php +++ /dev/null @@ -1,235 +0,0 @@ -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'); -?> - - -<?= $h($setup_title) ?> - - -
- -

-

- Falta configurar o envio de email. A plataforma fica retida aqui até estar feito. -

- - -
- - - -
Configuração guardada. A plataforma já pode enviar email.
- -
Guardado, mas ainda falta: .
- - - -
- APP_SECRET_KEY não está definida no .env. - 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. -
- - - -
- -
-
- - -
- - -
-
- - -
-
- - -

587 para TLS, 465 para SSL.

-
-
- -
- - -
- -
-
- - -
-
- - -

- -

-
-
- -
-
- - -
-
- - -
-
- -
- - -
-
- -

- 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. -

- -
- diff --git a/shared/platform-tools/smtp_status.php b/shared/platform-tools/smtp_status.php deleted file mode 100644 index 8def886..0000000 --- a/shared/platform-tools/smtp_status.php +++ /dev/null @@ -1,312 +0,0 @@ -/views/sysadmin/smtp_status.php must: -// 1. Load $pdo 2. Run the sysadmin guard 3. Set $repo_root -// Optional overrides, same as backups.php / platform_update.php: -// $platform_layout_header / _navbar / _footer, $platform_dashboard_url -// -// Read-only by default: it reports what the transport WOULD do. The two actions -// (connection test, send a test message) are explicit and CSRF-guarded, because -// one opens a socket to a third party and the other actually sends mail. - -require_once __DIR__ . '/functions_mail.php'; -require_once __DIR__ . '/functions_csrf.php'; - -if (!isset($repo_root) || !is_string($repo_root)) { - $repo_root = realpath(dirname(__DIR__, 2)) ?: dirname(__DIR__, 2); -} -$repo_root = rtrim($repo_root, '/'); - -// ── Actions (POST to self, PRG) ───────────────────────────────────────────── -if ($_SERVER['REQUEST_METHOD'] === 'POST') { - $action = $_POST['action'] ?? ''; - if (!platform_csrf_ok()) { - $_SESSION['smtp_flash'] = ['type' => 'danger', 'message' => 'Pedido rejeitado (token CSRF ausente ou inválido).']; - } elseif ($action === 'test_connection') { - $r = platform_mail_test($repo_root); - $_SESSION['smtp_flash'] = $r['ok'] - ? ['type' => 'success', 'message' => 'Ligação SMTP estabelecida e fechada com sucesso.', 'log' => $r['log']] - : ['type' => 'danger', 'message' => 'Falha na ligação: ' . $r['error'], 'log' => $r['log']]; - } elseif ($action === 'save') { - // Editar aqui, e não só diagnosticar. - // - // A página nasceu só de leitura, o que fazia sentido enquanto cada site - // tivesse a sua página de Comunicações. Não têm: no hub2, no silver e no - // quack esta é a única página de SMTP que existe, e a única forma de - // mudar o servidor era o setup.php -- uma página chamada "Configuração - // inicial", que fecha assim que houver um sysadmin. Uma página que - // mostra "Servidor: — vazio —" sem dar maneira de o preencher é um beco. - // - // Os sites que TÊM Comunicações próprias redirigem daqui para lá, para - // não haver dois editores da mesma coisa. - if (!isset($pdo) || !$pdo instanceof PDO) { - // Sem base não há onde gravar. O stub deste site só forneceu - // $repo_root, por isso a página fica de leitura. - $_SESSION['smtp_flash'] = ['type' => 'danger', - 'message' => 'Esta instalação não expõe a base de dados a esta página; configure pelo .env.']; - header('Location: ' . $_SERVER['REQUEST_URI'], true, 303); - exit; - } - $uid = isset($_SESSION['user']['id']) ? (int) $_SESSION['user']['id'] : null; - - $port = (int) ($_POST['smtp_port'] ?? 587); - $secure = strtolower(trim((string) ($_POST['smtp_secure'] ?? 'tls'))); - if (!in_array($secure, ['tls', 'ssl', ''], true)) { - $secure = 'tls'; - } - - $from = trim((string) ($_POST['email_from'] ?? '')); - if ($from !== '' && !filter_var($from, FILTER_VALIDATE_EMAIL)) { - // Validado porque já se perdeu tempo com isto: o campo do remetente - // ficou uma vez com o NOME do remetente lá dentro, e o erro só - // apareceu como "Invalid address (From)" numa fila presa, horas - // depois. Um formulário que aceita tudo empurra a falha para longe - // de quem a pode corrigir. - $_SESSION['smtp_flash'] = ['type' => 'danger', - 'message' => 'O remetente tem de ser um endereço de email — o nome vai no campo abaixo.']; - header('Location: ' . $_SERVER['REQUEST_URI'], true, 303); - exit; - } - - platform_settings_set($pdo, 'smtp_host', trim((string) ($_POST['smtp_host'] ?? '')), $uid); - platform_settings_set($pdo, 'smtp_port', (string) ($port > 0 ? $port : 587), $uid); - platform_settings_set($pdo, 'smtp_secure', $secure, $uid); - platform_settings_set($pdo, 'smtp_username', trim((string) ($_POST['smtp_username'] ?? '')), $uid); - platform_settings_set($pdo, 'email_from', $from, $uid); - platform_settings_set($pdo, 'email_from_name', trim((string) ($_POST['email_from_name'] ?? '')), $uid); - - // A password nunca é pré-preenchida, por isso vazio significa "mantém a - // que está" e não "apaga". Apagar é uma caixa explícita. - if (!empty($_POST['smtp_password_clear'])) { - platform_settings_set($pdo, 'smtp_password', '', $uid); - } elseif (trim((string) ($_POST['smtp_password'] ?? '')) !== '') { - platform_settings_set($pdo, 'smtp_password', trim((string) $_POST['smtp_password']), $uid); - } - - if (function_exists('platform_audit')) { - platform_audit($pdo, 'settings.smtp', ['target_type' => 'settings', 'summary' => 'Configuração SMTP guardada', - 'changes' => ['smtp_host' => $_POST['smtp_host'] ?? null, 'smtp_port' => $_POST['smtp_port'] ?? null, - 'smtp_username' => $_POST['smtp_username'] ?? null, 'email_from' => $_POST['email_from'] ?? null, - 'smtp_password' => ($_POST['smtp_password'] ?? '') !== '' ? 'alterada' : null]]); - } - $_SESSION['smtp_flash'] = ['type' => 'success', 'message' => 'Configuração guardada.']; - } elseif ($action === 'send_test') { - $to = trim((string) ($_POST['to'] ?? '')); - if (!filter_var($to, FILTER_VALIDATE_EMAIL)) { - $_SESSION['smtp_flash'] = ['type' => 'danger', 'message' => 'Endereço de destino inválido.']; - } else { - $r = platform_mail_send([ - 'to' => $to, - 'to_name' => 'Teste', - 'subject' => 'Teste de SMTP — ' . (string) ($_SERVER['HTTP_HOST'] ?? 'plataforma'), - 'body' => '

Mensagem de teste enviada a partir do painel de sistema.

', - '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; -?> -
- -
-
-

Estado SMTP

-

Configuração do transporte de email partilhado e testes de ligação.

-
- Voltar -
- - - - - -
-
- -
-
-
- -
-
- Configuração - - PHPMailer - -
-
- - - - - - - - - - -
- -
-
- -
- - -
-
Alterar
-
-
- - - -
- - -
No cPanel é tipicamente mail.<dominio>.
-
-
- - -
-
- - -
- -
- - -
Normalmente o endereço de email completo.
-
-
- - - -
- - -
- -
- -
- - -
Tem de ser a conta autenticada acima, senão SPF/DMARC rejeitam.
-
-
- - -
- -
- - - Guardado em base de dados, que ganha ao .env. Teste a ligação a seguir. - -
-
-
-
- - -
-
Testes
-
-
- - -
Abre e fecha uma ligação. Não envia nada.
-
- -
- -
- - -
- -
-
- -
- -
- diff --git a/shared/platform-tools/social/engine.js b/shared/platform-tools/social/engine.js deleted file mode 100644 index ac7bbbe..0000000 --- a/shared/platform-tools/social/engine.js +++ /dev/null @@ -1,159 +0,0 @@ -/* Social-post export engine — generalization of the pipeline proven in - fotografia.andmore.pt/views/sessions/open_spots_ig.php. - - Responsibilities: render a live-preview element to a high-resolution JPG - via html2canvas, pre-baking CSS blur (html2canvas can't render - `filter: blur()`), waiting for webfonts, and bundling multi-slide exports - into a ZIP (JSZip). Pure mechanism — all template/DOM knowledge lives in - the page that uses it. */ - -window.SocialEngine = (function () { - 'use strict'; - - function chunk(arr, size) { - size = Math.max(1, size | 0); - var out = []; - for (var i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); - return out; - } - - /* html2canvas does not render CSS `filter: blur(...)`. Pre-bake the blur - into the photo with Canvas 2D, swap the bg-image to that bitmap, drop - the inline filter/transform, and return a restore() callback. The blur - radius is multiplied by the export scale so the upscaled bitmap matches - what's on screen. Resolves null when there is nothing to bake. */ - function bakeBlur(bgEl, photoUrl, blurAmount, scale) { - return new Promise(function (resolve) { - if (!bgEl || !photoUrl || !(blurAmount > 0)) return resolve(null); - - var rect = bgEl.getBoundingClientRect(); - var W = Math.max(1, Math.round(rect.width * scale)); - var H = Math.max(1, Math.round(rect.height * scale)); - var blurPx = blurAmount * scale; - - var img = new Image(); - // Photos go through the same-origin media proxy (or are local hub - // uploads), so no crossOrigin attribute is needed and the canvas - // stays untainted. - img.onload = function () { - try { - var canvas = document.createElement('canvas'); - canvas.width = W; - canvas.height = H; - var ctx = canvas.getContext('2d'); - ctx.filter = 'blur(' + blurPx + 'px) brightness(0.85)'; - - // Draw slightly larger than the canvas so the soft edges - // don't reveal blank pixels — same trick as the CSS scale(). - var overscan = 1.1 + (blurAmount / 200); - var ratio = Math.max(W / img.width, H / img.height) * overscan; - var dw = img.width * ratio; - var dh = img.height * ratio; - ctx.drawImage(img, (W - dw) / 2, (H - dh) / 2, dw, dh); - - var dataUrl = canvas.toDataURL('image/jpeg', 0.9); - var original = { - backgroundImage: bgEl.style.backgroundImage, - filter: bgEl.style.filter, - transform: bgEl.style.transform, - }; - bgEl.style.backgroundImage = "url('" + dataUrl + "')"; - bgEl.style.filter = 'none'; - bgEl.style.transform = 'none'; - - resolve(function () { - bgEl.style.backgroundImage = original.backgroundImage; - bgEl.style.filter = original.filter; - bgEl.style.transform = original.transform; - }); - } catch (e) { - console.warn('Blur bake failed; exporting without baked blur.', e); - resolve(null); - } - }; - img.onerror = function () { - console.warn('Failed to load photo for blur bake; exporting without baked blur.'); - resolve(null); - }; - img.src = photoUrl; - }); - } - - /* Render `el` (the live preview, at its on-screen size — its CSS uses - viewport-relative clamp()s, so rendering at another width would re-flow) - and let html2canvas upscale the pixel buffer to `targetW` via `scale`. - opts.blur = { bgEl, photoUrl, amount } enables the bake step. - Returns a JPEG Blob. */ - async function renderBlob(el, targetW, opts) { - opts = opts || {}; - if (typeof html2canvas !== 'function') { - throw new Error('A biblioteca de exportação ainda está a carregar. Tenta novamente em segundos.'); - } - var rect = el.getBoundingClientRect(); - var actualW = rect.width || el.offsetWidth || targetW; - var actualH = rect.height || el.offsetHeight || targetW; - var scale = actualW > 0 ? (targetW / actualW) : 1; - - if (document.fonts && document.fonts.ready) { - await document.fonts.ready; - } - var restore = null; - if (opts.blur) { - restore = await bakeBlur(opts.blur.bgEl, opts.blur.photoUrl, opts.blur.amount, scale); - } - try { - var canvas = await html2canvas(el, { - backgroundColor: '#000', - scale: scale, - useCORS: true, - logging: false, - width: actualW, - height: actualH, - windowWidth: document.documentElement.clientWidth, - windowHeight: document.documentElement.clientHeight, - }); - return await new Promise(function (resolve, reject) { - canvas.toBlob(function (blob) { - if (!blob) { reject(new Error('Falha ao gerar a imagem.')); return; } - resolve(blob); - }, 'image/jpeg', 0.95); - }); - } finally { - if (restore) restore(); - } - } - - function saveBlob(blob, fileName) { - var url = URL.createObjectURL(blob); - var link = document.createElement('a'); - link.href = url; - link.download = fileName; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - setTimeout(function () { URL.revokeObjectURL(url); }, 1500); - } - - /* files: [{ blob, name }]. One file downloads directly; several are - bundled into a single ZIP. */ - async function saveAll(files, zipName) { - if (!files.length) return; - if (files.length === 1) { saveBlob(files[0].blob, files[0].name); return; } - if (typeof JSZip !== 'function') { - // ZIP library missing — degrade to sequential downloads. - files.forEach(function (f) { saveBlob(f.blob, f.name); }); - return; - } - var zip = new JSZip(); - files.forEach(function (f) { zip.file(f.name, f.blob); }); - var blob = await zip.generateAsync({ type: 'blob' }); - saveBlob(blob, zipName); - } - - return { - chunk: chunk, - renderBlob: renderBlob, - saveBlob: saveBlob, - saveAll: saveAll, - }; -})(); diff --git a/shared/platform-tools/social/functions_publications.php b/shared/platform-tools/social/functions_publications.php deleted file mode 100644 index 3a70962..0000000 --- a/shared/platform-tools/social/functions_publications.php +++ /dev/null @@ -1,117 +0,0 @@ - ['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'] ?? []), - ]; -} diff --git a/shared/platform-tools/social/page.js b/shared/platform-tools/social/page.js deleted file mode 100644 index 42a232e..0000000 --- a/shared/platform-tools/social/page.js +++ /dev/null @@ -1,745 +0,0 @@ -'use strict'; - -const CFG = JSON.parse(document.getElementById('engineConfig').textContent); -const CSRF_TOKEN = CFG.csrf || ''; -const DAY_NAMES = ['DOM','SEG','TER','QUA','QUI','SEX','SÁB']; -const MONTH_NAMES = ['JAN','FEV','MAR','ABR','MAI','JUN','JUL','AGO','SET','OUT','NOV','DEZ']; - -/* ── State ─────────────────────────────────────────────────── */ -let items = []; // [{id, title, subtitle, date}] -let nextItemId = 1; -let slides = []; // arrays of items (chunked by template max) -let currentSlide = 0; - -let currentBg = null; // gradient/solid CSS or image URL -let bgIsImage = false; -let blurAmount = 0; -let overlaySource = 'none'; -let overlayValue = ''; -let overlayOpacity = 50; - -function esc(str) { - return String(str ?? '') - .replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); -} - -/* ── Dynamic items (platform content endpoint) ─────────────── */ -let dynSelected = new Set((CFG.presel || []).filter(id => (CFG.dynamic || []).some(d => d.id === id))); -let dynCatValue = ''; // active category filter ('' = all) - -// Items visible under the current category filter (selection persists across -// filter changes — hidden-but-selected items stay in the post). -function dynVisible() { - return (CFG.dynamic || []).filter(d => !dynCatValue || (d.category || '') === dynCatValue); -} - -// Build the category dropdown from the distinct categories present in the -// dynamic items (first-seen order = the platform's order). Hidden when <2. -function buildCatFilter() { - const sel = document.getElementById('dynCatFilter'); - const bar = document.getElementById('dynFilterBar'); - if (!sel || !bar) return; - const seen = new Map(); - (CFG.dynamic || []).forEach(d => { - const k = d.category || ''; - if (k && !seen.has(k)) seen.set(k, d.category_label || k); - }); - if (seen.size < 2) { bar.style.display = 'none'; return; } - let html = ``; - seen.forEach((lbl, k) => { html += ``; }); - 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 ``; - }).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 => ` -
- -
-
`).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 => ` -
- -
`).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 => ` -
-
- - -
-
- - -
-
`).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 `
`; -} - -function itemCardHtml(it) { - const dp = dateParts(it.date); - return `
- ${dp ? `
-
${dp.dayName}
-
${dp.dayNum}
-
${dp.month}
-
-
` : ''} -
-
${esc(it.title)}
- ${it.subtitle ? `
${esc(it.subtitle)}
` : ''} -
-
`; -} - -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 += '

Seleciona uma foto à esquerda

'; - } else { - const dp = dateParts(it.date); - html += `
- ${dp ? `

${dp.dayName} · ${dp.dayNum} ${dp.month} ${dp.year}

` : ''} -

${esc(it.title)}

- ${it.subtitle ? `

${esc(it.subtitle)}

` : ''} -
-
`; - } - } else if (tpl.slug === 'single-announcement') { - const it = slide[0]; - if (!it) { - html += '

Adiciona um item à esquerda

'; - } else { - const dp = dateParts(it.date); - html += `
- ${dp ? `

${dp.dayName} · ${dp.dayNum} ${dp.month} ${dp.year}

` : ''} -

${esc(it.title)}

- ${it.subtitle ? `

${esc(it.subtitle)}

` : ''} -
-
`; - } - } else { // event-list - const tagline = document.getElementById('tagline').value.trim(); - const heading = document.getElementById('headingText').value.trim(); - html += `
- ${tagline ? `

${esc(tagline)}

` : ''} - ${heading ? `

${esc(heading)}

` : ''} -
-
- ${slide.length ? slide.map(itemCardHtml).join('') : '

Adiciona itens à esquerda

'} -
`; - } - - if (cta) html += `
${esc(cta)}
`; - 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 => `
`).join('') - : '
Seleciona fotos à esquerda
'; - 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 => ` - `).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 = '×'; - 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(); diff --git a/shared/platform-tools/social/publications.css b/shared/platform-tools/social/publications.css deleted file mode 100644 index 4f3b2b2..0000000 --- a/shared/platform-tools/social/publications.css +++ /dev/null @@ -1,504 +0,0 @@ -/* ── Social-post generator (port of fotografia's ig_spots.css) ─────────────── - Class names match the proven fotografia builder; the color/font tokens are - driven by the platform's app_branding row via --brand-* vars injected on - .ig-post by the generator page. Fallbacks keep posts legible for platforms - without branding configured. */ - -/* Local aliases resolved from branding */ -.ig-post { - --sp-accent: var(--brand-accent, var(--brand-primary, #C9A96E)); - --sp-on-accent: #1a1713; - --sp-font-heading: var(--brand-font-heading, 'Cormorant Garamond', serif); - --sp-font-body: var(--brand-font-body, 'Poppins', system-ui, sans-serif); - --sp-font-sub: var(--brand-font-body, 'Montserrat', sans-serif); -} - -/* Dropzone */ -.ig-dropzone { - border: 2px dashed #ced4da; - border-radius: 8px; - padding: 1.25rem; - text-align: center; - cursor: pointer; - transition: border-color .15s, background .15s; -} -.ig-dropzone:hover, -.ig-dropzone.dragging { - border-color: var(--am-accent, #0d6efd); - background: rgba(13,110,253,.04); -} - -/* Preset swatches */ -.ig-preset { - width: 36px; - height: 36px; - border-radius: 8px; - border: 2px solid transparent; - cursor: pointer; - flex-shrink: 0; - transition: transform .15s, outline .15s; -} -.ig-preset:hover { transform: scale(1.12); } -.ig-preset.active { border-color: #fff; outline: 2px solid var(--am-accent, #0d6efd); } - -.ig-img-preset { - position: relative; - width: 52px; - height: 52px; - border-radius: 8px; - border: 2px solid transparent; - cursor: pointer; - background-size: cover; - background-position: center; - flex-shrink: 0; - transition: transform .15s, outline .15s; -} -.ig-img-preset:hover { transform: scale(1.1); } -.ig-img-preset.active { border-color: #fff; outline: 2px solid var(--am-accent, #0d6efd); } -.ig-img-preset .ig-img-del { - position: absolute; - top: -7px; - right: -7px; - width: 18px; - height: 18px; - border-radius: 50%; - border: none; - background: #dc3545; - color: #fff; - font-size: 10px; - line-height: 1; - display: none; - align-items: center; - justify-content: center; - cursor: pointer; -} -.ig-img-preset:hover .ig-img-del { display: flex; } - -/* Lazy gallery accordion (Fase 4: photo_session content) */ -.ig-gal-head { - width: 100%; - display: flex; - justify-content: space-between; - align-items: center; - gap: .5rem; - background: none; - border: 0; - padding: .45rem .65rem; - font-size: .82rem; - text-align: left; - cursor: pointer; -} -.ig-gal-head:hover { background: rgba(0,0,0,.04); } -.ig-gal-body { border-top: 1px solid rgba(0,0,0,.08); } - -/* Photo picker thumbnails (Fase 4: photo_session content) */ -.ig-photo-pick { - position: relative; - width: 64px; - height: 64px; - border-radius: 6px; - border: 2px solid transparent; - background-size: cover; - background-position: center; - background-color: #e9ecef; - cursor: pointer; - flex-shrink: 0; - transition: transform .15s, outline .15s; -} -.ig-photo-pick:hover { transform: scale(1.06); } -.ig-photo-pick.active { border-color: #fff; outline: 2px solid var(--am-accent, #0d6efd); } -.ig-photo-pick-num { - position: absolute; - top: -6px; - right: -6px; - min-width: 18px; - height: 18px; - padding: 0 4px; - border-radius: 50%; - background: var(--am-accent, #0d6efd); - color: #fff; - font-size: 10px; - font-weight: 700; - line-height: 18px; - text-align: center; -} -.ig-photo-pick-num:empty { display: none; } - -/* Photo-grid template (Fase 4): selected photos tile the whole canvas */ -.ig-post .ig-photo-grid { - position: absolute; - inset: 0; - display: none; - gap: 4px; -} -.ig-photo-grid .ig-grid-cell { - background-size: cover; - background-position: center; - background-color: rgba(255,255,255,.06); -} -.ig-photo-grid .ig-grid-empty { - grid-column: 1 / -1; - display: flex; - align-items: center; - justify-content: center; - color: rgba(255,255,255,.4); - font-family: var(--sp-font-body); - font-size: 11px; - text-align: center; - padding: 1rem; -} - -/* Preview panel */ -.ig-preview-sticky { position: sticky; top: 80px; } -.ig-preview-wrapper { - height: min(72vh, 580px); - display: flex; - justify-content: center; -} - -/* The post canvas */ -.ig-post { - position: relative; - aspect-ratio: 1 / 1; - height: 100%; - width: auto; - max-width: 100%; - background: linear-gradient(160deg, #1a1a2e, #16213e); - background-size: cover; - background-position: center; - overflow: hidden; - box-shadow: 0 24px 64px rgba(0,0,0,.35); - font-family: var(--sp-font-body); -} -/* Wide/landscape formats fit by width instead of height */ -.ig-preview-wrapper.is-wide { height: auto; } -.ig-preview-wrapper.is-wide .ig-post { width: 100%; height: auto; } - -.ig-overlay { - position: absolute; - inset: 0; - background: linear-gradient( - to bottom, - rgba(0,0,0,.15) 0%, - rgba(0,0,0,.42) 40%, - rgba(0,0,0,.72) 100% - ); -} -.ig-post.is-photo .ig-overlay { - background: linear-gradient( - to bottom, - rgba(0,0,0,.05) 0%, - rgba(0,0,0,.25) 55%, - rgba(0,0,0,.78) 100% - ); -} -/* Light/paper rendering (e.g. white solid backgrounds) drops the dark wash */ -.ig-post.is-light .ig-overlay { background: none; } - -.ig-overlay-custom { - position: absolute; - inset: 0; - pointer-events: none; - opacity: 0; -} - -.ig-post .ig-photo-bg { - position: absolute; - inset: 0; - background-size: cover; - background-position: center; - transition: filter .25s, transform .25s; -} - -/* Watermark (optional, from app_branding_assets type=watermark) */ -.ig-watermark { - position: absolute; - right: 4%; - bottom: 3%; - width: 14%; - opacity: .5; - pointer-events: none; - display: none; -} -.ig-post.has-watermark .ig-watermark { display: block; } - -.ig-content { - position: absolute; - inset: 0; - display: flex; - flex-direction: column; - gap: clamp(12px, 3.5%, 26px); - padding: clamp(16px,6%,32px) clamp(20px,8%,40px); -} -.ig-content[data-align-v="top"] { justify-content: flex-start; } -.ig-content[data-align-v="middle"] { justify-content: center; } -.ig-content[data-align-v="bottom"] { justify-content: flex-end; } -.ig-content[data-align-h="left"] { align-items: flex-start; text-align: left; } -.ig-content[data-align-h="center"] { align-items: stretch; text-align: center; } -.ig-content[data-align-h="right"] { align-items: flex-end; text-align: right; } - -/* Brand logo row */ -.ig-logo-row { - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; -} -.ig-logo-row img { - max-height: 44px; - max-width: 55%; - object-fit: contain; -} - -/* Title block */ -.ig-title-block { flex-shrink: 0; } -.ig-tagline { - color: rgba(255,255,255,.7); - font-family: var(--sp-font-sub); - font-size: 9px; - text-transform: uppercase; - letter-spacing: 2.5px; - margin: 0 0 6px; - line-height: 1.4; -} -.ig-heading { - color: #fff; - font-family: var(--sp-font-heading); - font-size: clamp(22px, 6.5%, 34px); - font-weight: 400; - margin: 0 0 12px; - line-height: 1.1; - letter-spacing: .02em; -} -.ig-heading-divider { - width: 48px; - height: 1px; - background: var(--sp-accent); - display: inline-block; -} -.ig-content[data-align-h="center"] .ig-heading-divider { margin: 0 auto; display: block; } - -/* Item cards (event-list) */ -.ig-sessions { - flex-shrink: 0; - display: flex; - flex-direction: column; - gap: 7px; -} -.ig-empty-msg { - color: rgba(255,255,255,.4); - font-size: 11px; - text-align: center; - margin: 0; -} -.ig-session-card { - background: rgba(255,255,255,.12); - backdrop-filter: blur(14px); - -webkit-backdrop-filter: blur(14px); - border: 1px solid rgba(255,255,255,.2); - border-radius: 10px; - padding: 9px 12px; - display: flex; - align-items: center; - gap: 12px; - text-align: left; -} -.ig-date-block { - flex-shrink: 0; - text-align: center; - min-width: 30px; -} -.ig-date-dayname { - color: rgba(255,255,255,.55); - font-size: 8px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 1px; - line-height: 1; - margin-bottom: 2px; -} -.ig-date-num { - color: #fff; - font-family: var(--sp-font-heading); - font-size: 26px; - font-weight: 400; - line-height: 1; -} -.ig-date-month { - color: rgba(255,255,255,.55); - font-size: 8px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: .8px; - margin-top: 2px; -} -.ig-card-divider { - width: 1px; - height: 36px; - flex-shrink: 0; - opacity: .85; - background: var(--sp-accent); -} -.ig-session-info { flex: 1; min-width: 0; } -.ig-session-type { - color: #fff; - font-family: var(--sp-font-heading); - font-size: 15px; - font-weight: 400; - line-height: 1.2; - letter-spacing: .02em; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.ig-session-avail { - color: var(--sp-accent); - font-family: var(--sp-font-sub); - font-size: 9px; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 1.2px; - margin-top: 3px; -} - -/* Single announcement */ -.ig-announce { - flex-shrink: 0; - max-width: 100%; -} -.ig-announce-date { - color: var(--sp-accent); - font-family: var(--sp-font-sub); - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 2px; - margin: 0 0 10px; -} -.ig-announce-title { - color: #fff; - font-family: var(--sp-font-heading); - font-size: clamp(26px, 9%, 44px); - font-weight: 400; - line-height: 1.08; - margin: 0 0 8px; - letter-spacing: .02em; -} -.ig-announce-subtitle { - color: rgba(255,255,255,.85); - font-family: var(--sp-font-sub); - font-size: 12px; - text-transform: uppercase; - letter-spacing: 1.8px; - margin: 0 0 14px; -} -.ig-announce-extra { - color: rgba(255,255,255,.75); - font-family: var(--sp-font-body); - font-size: 12px; - line-height: 1.5; - margin: 0; -} - -/* CTA row */ -.ig-cta-row { - flex-shrink: 0; - padding-top: 10px; - border-top: 1px solid rgba(255,255,255,.18); - text-align: center; -} -.ig-cta { - color: var(--sp-accent); - font-family: var(--sp-font-sub); - font-size: 11px; - font-weight: 600; - letter-spacing: 1.6px; - text-transform: uppercase; -} - -/* Light/paper variant: dark text on light backgrounds */ -.ig-post.is-light .ig-tagline, -.ig-post.is-light .ig-announce-extra { color: rgba(20,20,20,.6); } -.ig-post.is-light .ig-heading, -.ig-post.is-light .ig-date-num, -.ig-post.is-light .ig-session-type, -.ig-post.is-light .ig-announce-title { color: #141414; } -.ig-post.is-light .ig-announce-subtitle { color: rgba(20,20,20,.75); } -.ig-post.is-light .ig-empty-msg { color: rgba(20,20,20,.4); } -.ig-post.is-light .ig-date-dayname, -.ig-post.is-light .ig-date-month { color: rgba(20,20,20,.55); } -.ig-post.is-light .ig-session-card { - background: rgba(0,0,0,.05); - border-color: rgba(0,0,0,.12); -} -.ig-post.is-light .ig-cta-row { border-top-color: rgba(0,0,0,.15); } - -/* Slide navigator */ -.ig-slide-nav { - display: flex; - align-items: center; - justify-content: center; - gap: 6px; -} -.ig-slide-dot { - width: 9px; - height: 9px; - border-radius: 50%; - background: #d0d5dc; - border: none; - padding: 0; - cursor: pointer; - transition: background .15s, transform .15s; -} -.ig-slide-dot.active { background: var(--am-accent, #0d6efd); transform: scale(1.25); } -.ig-slide-toolbar { - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - flex-wrap: wrap; -} - -/* Screenshot overlay */ -.ig-screenshot-overlay { - position: fixed; - inset: 0; - background: #000; - z-index: 9999; - display: flex; - align-items: center; - justify-content: center; -} -#screenshotPost { position: relative; overflow: hidden; } -#screenshotPost .ig-post { - width: 100%; - height: 100%; - aspect-ratio: unset; - border-radius: 0; - box-shadow: none; - max-width: unset; -} -.ig-screenshot-close { - position: fixed; - top: 16px; - right: 16px; - z-index: 10000; - background: rgba(255,255,255,.18); - border: none; - color: #fff; - border-radius: 50%; - width: 44px; - height: 44px; - font-size: 20px; - line-height: 1; - display: flex; - align-items: center; - justify-content: center; - backdrop-filter: blur(10px); - -webkit-backdrop-filter: blur(10px); - cursor: pointer; - transition: opacity .4s; -} - -@keyframes ig-spin { to { transform: rotate(360deg); } } -.ig-spin { display: inline-block; animation: ig-spin .7s linear infinite; } diff --git a/shared/platform-tools/tracking.php b/shared/platform-tools/tracking.php deleted file mode 100644 index e081167..0000000 --- a/shared/platform-tools/tracking.php +++ /dev/null @@ -1,206 +0,0 @@ - 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 '
' - . '
'; -}; - -$table = function (string $title, array $rows, callable $label) use ($h, $bar): string { - if (!$rows) { - return '
' - . '
' . $h($title) . '
' - . '

Sem dados no período.

'; - } - $max = max(array_map(fn($r) => (int) $r['visits'], $rows)); - $out = '
' - . '
' . $h($title) . '
' - . ''; - foreach ($rows as $r) { - $out .= '' - . '' - . '' - . '' - . ''; - } - return $out . '
' . $label($r) . '' . (int) $r['visits'] . '' . $bar((int) $r['visits'], $max) . '
'; -}; -?> -
- -
-
-

Origem das visitas

-

De onde vêm as pessoas, com que dispositivo e de que país.

-
-
- '7 dias', 30 => '30 dias', 90 => '90 dias', 365 => '1 ano'] as $d => $lbl): ?> - - -
-
- - -
- Sem índice de países. A coluna geográfica fica vazia até correr - shared/platform-tools/bin/geo-build-index.php. O resto funciona na mesma. -
- - -
-
-
-
Visitas
-
-
-
-
-
-
Conversões
-
-
-
-
-
-
Taxa
-
- 0 ? number_format($totalConv / $totalVisits * 100, 1) . '%' : '—' ?> -
-
-
-
- -
-
Por origem
-
- -

Ainda não há visitas registadas no período.

- -
- - - - - - - - - - - - - - - - - - - - - - - -
OrigemVisitasVisitantesConversõesTaxaÚltima
- 0 ? number_format($r['conversions'] / $r['visits'] * 100, 1) . '%' : '—' ?> -
-
-

- A origem é a de primeiro toque: quem chega por um QR code, sai e - volta pelo Google continua creditado ao QR code. Bots excluídos. -

- -
-
- - -
-
Conversões por formulário
-
- - - - - - - - - - -
-
-
- - -
-
'' . $h($r['label']) . '') ?>
-
$h($r['label'])) ?>
-
$h($r['label'])) ?>
-
$h($r['label'])) ?>
-
'' . $h($r['label']) . '') ?>
-
- -

- Nenhum endereço IP é guardado. O ip_hash serve só para aproximar visitantes - únicos e é salgado com um segredo do servidor, por isso não se reverte. -

- -
-