From 4d40adbf646399edb087a1ad04b3000e1d8d1515 Mon Sep 17 00:00:00 2001 From: andre Date: Sun, 13 Sep 2026 23:06:23 +0100 Subject: [PATCH] Vendor platform-tools: everything this site runs is now in one repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shared/platform-tools was a git submodule. On this branch it is a plain directory of tracked files, frozen at ea3616abae30106df283e73a3e2f9e5bb0e6ae90 — the same commit fotografia.andmore.pt pins. Contents are byte-identical to that tree, verified blob by blob. The submodule caused every deployment failure this site has had: - .gitmodules named no `branch`, so the update fell back to refs/remotes/origin/HEAD — a ref a plain server-side clone never writes. "Atualizar submódulo" failed with "Unable to find refs/remotes/origin/HEAD revision in submodule path". - Its URL was http://, which the origin answers with 523. - This install is deployed by uploading files, and a submodule is invisible to that. The tree moved forward while shared/platform-tools stayed on the old pin, config/functions_secrets.php died on a missing compat_secrets.php, and the whole site — public pages included — went down. Each of those was separately fixable. Together they mean a second repository nobody can see from the deployment path, gating whether the first one boots. A plain directory deploys like every other file and cannot fall out of step. The cost is that platform-tools updates no longer arrive by bumping a pin; they are a copy-over-and-commit. shared/platform-tools/VENDORED.md records the upstream SHA, the procedure, and which four files differ from upstream in line endings only (social/*.js, .css, functions_publications.php — CRLF upstream, normalised to LF here; content otherwise identical). Co-Authored-By: Claude Opus 5 --- .gitmodules | 4 - shared/platform-tools | 1 - shared/platform-tools/.gitignore | 36 + shared/platform-tools/ASSETS.md | 80 + shared/platform-tools/PHILOSOPHY.md | 255 ++ shared/platform-tools/README.md | 937 +++++++ shared/platform-tools/VENDORED.md | 45 + shared/platform-tools/api/agent.php | 22 + shared/platform-tools/api/auto_update.php | 88 + shared/platform-tools/api/backup_download.php | 43 + shared/platform-tools/api/backup_trigger.php | 55 + shared/platform-tools/api/feedback.php | 70 + shared/platform-tools/api/hub_health.php | 159 ++ shared/platform-tools/api/hub_ops.php | 141 ++ shared/platform-tools/api/hub_pair.php | 47 + shared/platform-tools/backups.php | 436 ++++ .../platform-tools/backups/backup_maker.php | 185 ++ shared/platform-tools/backups/config.php | 95 + shared/platform-tools/backups/db_backup.php | 187 ++ .../platform-tools/backups/restore_runner.php | 126 + shared/platform-tools/bin/encrypt_secrets.php | 89 + shared/platform-tools/bin/geo-build-index.php | 169 ++ shared/platform-tools/bin/geo-update.sh | 95 + shared/platform-tools/compat_secrets.php | 59 + shared/platform-tools/data/geo/pt.v4 | 449 ++++ shared/platform-tools/data/geo/pt.v6 | 120 + .../platform-tools/feedback_footer_link.php | 25 + shared/platform-tools/feedback_widget.php | 474 ++++ shared/platform-tools/functions_agent.php | 226 ++ shared/platform-tools/functions_antispam.php | 274 +++ .../functions_antispam_forms.php | 490 ++++ shared/platform-tools/functions_auth.php | 419 ++++ .../platform-tools/functions_auto_update.php | 631 +++++ shared/platform-tools/functions_backup.php | 226 ++ .../platform-tools/functions_capabilities.php | 28 + shared/platform-tools/functions_changelog.php | 39 + shared/platform-tools/functions_client_ip.php | 43 + shared/platform-tools/functions_composer.php | 293 +++ shared/platform-tools/functions_csrf.php | 38 + shared/platform-tools/functions_feedback.php | 329 +++ shared/platform-tools/functions_geo.php | 415 ++++ shared/platform-tools/functions_git.php | 406 ++++ shared/platform-tools/functions_hub.php | 668 +++++ shared/platform-tools/functions_ip_ban.php | 262 ++ shared/platform-tools/functions_mail.php | 334 +++ shared/platform-tools/functions_mail_rate.php | 153 ++ .../platform-tools/functions_migrations.php | 208 ++ .../functions_password_reset.php | 207 ++ shared/platform-tools/functions_restore.php | 355 +++ shared/platform-tools/functions_schema.php | 159 ++ shared/platform-tools/functions_settings.php | 331 +++ shared/platform-tools/functions_setup.php | 183 ++ shared/platform-tools/functions_tracking.php | 482 ++++ shared/platform-tools/geo.php | 336 +++ shared/platform-tools/hub_bootstrap.php | 77 + shared/platform-tools/hub_integration.php | 355 +++ shared/platform-tools/index.php | 128 + shared/platform-tools/ip_bans.php | 219 ++ shared/platform-tools/platform_update.php | 2163 +++++++++++++++++ shared/platform-tools/restore.php | 250 ++ shared/platform-tools/setup.php | 235 ++ shared/platform-tools/smtp_status.php | 306 +++ shared/platform-tools/social/engine.js | 159 ++ .../social/functions_publications.php | 117 + shared/platform-tools/social/page.js | 745 ++++++ shared/platform-tools/social/publications.css | 504 ++++ shared/platform-tools/tracking.php | 198 ++ 67 files changed, 17478 insertions(+), 5 deletions(-) delete mode 100644 .gitmodules delete mode 160000 shared/platform-tools create mode 100644 shared/platform-tools/.gitignore create mode 100644 shared/platform-tools/ASSETS.md create mode 100644 shared/platform-tools/PHILOSOPHY.md create mode 100644 shared/platform-tools/README.md create mode 100644 shared/platform-tools/VENDORED.md create mode 100644 shared/platform-tools/api/agent.php create mode 100644 shared/platform-tools/api/auto_update.php create mode 100644 shared/platform-tools/api/backup_download.php create mode 100644 shared/platform-tools/api/backup_trigger.php create mode 100644 shared/platform-tools/api/feedback.php create mode 100644 shared/platform-tools/api/hub_health.php create mode 100644 shared/platform-tools/api/hub_ops.php create mode 100644 shared/platform-tools/api/hub_pair.php create mode 100644 shared/platform-tools/backups.php create mode 100644 shared/platform-tools/backups/backup_maker.php create mode 100644 shared/platform-tools/backups/config.php create mode 100644 shared/platform-tools/backups/db_backup.php create mode 100644 shared/platform-tools/backups/restore_runner.php create mode 100644 shared/platform-tools/bin/encrypt_secrets.php create mode 100644 shared/platform-tools/bin/geo-build-index.php create mode 100644 shared/platform-tools/bin/geo-update.sh create mode 100644 shared/platform-tools/compat_secrets.php create mode 100644 shared/platform-tools/data/geo/pt.v4 create mode 100644 shared/platform-tools/data/geo/pt.v6 create mode 100644 shared/platform-tools/feedback_footer_link.php create mode 100644 shared/platform-tools/feedback_widget.php create mode 100644 shared/platform-tools/functions_agent.php create mode 100644 shared/platform-tools/functions_antispam.php create mode 100644 shared/platform-tools/functions_antispam_forms.php create mode 100644 shared/platform-tools/functions_auth.php create mode 100644 shared/platform-tools/functions_auto_update.php create mode 100644 shared/platform-tools/functions_backup.php create mode 100644 shared/platform-tools/functions_capabilities.php create mode 100644 shared/platform-tools/functions_changelog.php create mode 100644 shared/platform-tools/functions_client_ip.php create mode 100644 shared/platform-tools/functions_composer.php create mode 100644 shared/platform-tools/functions_csrf.php create mode 100644 shared/platform-tools/functions_feedback.php create mode 100644 shared/platform-tools/functions_geo.php create mode 100644 shared/platform-tools/functions_git.php create mode 100644 shared/platform-tools/functions_hub.php create mode 100644 shared/platform-tools/functions_ip_ban.php create mode 100644 shared/platform-tools/functions_mail.php create mode 100644 shared/platform-tools/functions_mail_rate.php create mode 100644 shared/platform-tools/functions_migrations.php create mode 100644 shared/platform-tools/functions_password_reset.php create mode 100644 shared/platform-tools/functions_restore.php create mode 100644 shared/platform-tools/functions_schema.php create mode 100644 shared/platform-tools/functions_settings.php create mode 100644 shared/platform-tools/functions_setup.php create mode 100644 shared/platform-tools/functions_tracking.php create mode 100644 shared/platform-tools/geo.php create mode 100644 shared/platform-tools/hub_bootstrap.php create mode 100644 shared/platform-tools/hub_integration.php create mode 100644 shared/platform-tools/index.php create mode 100644 shared/platform-tools/ip_bans.php create mode 100644 shared/platform-tools/platform_update.php create mode 100644 shared/platform-tools/restore.php create mode 100644 shared/platform-tools/setup.php create mode 100644 shared/platform-tools/smtp_status.php create mode 100644 shared/platform-tools/social/engine.js create mode 100644 shared/platform-tools/social/functions_publications.php create mode 100644 shared/platform-tools/social/page.js create mode 100644 shared/platform-tools/social/publications.css create mode 100644 shared/platform-tools/tracking.php diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 4fcd2e0..0000000 --- a/.gitmodules +++ /dev/null @@ -1,4 +0,0 @@ -[submodule "shared/platform-tools"] - path = shared/platform-tools - url = ../platform-tools.git - branch = main diff --git a/shared/platform-tools b/shared/platform-tools deleted file mode 160000 index ea3616a..0000000 --- a/shared/platform-tools +++ /dev/null @@ -1 +0,0 @@ -Subproject commit ea3616abae30106df283e73a3e2f9e5bb0e6ae90 diff --git a/shared/platform-tools/.gitignore b/shared/platform-tools/.gitignore new file mode 100644 index 0000000..947adba --- /dev/null +++ b/shared/platform-tools/.gitignore @@ -0,0 +1,36 @@ +# OS +.DS_Store +Thumbs.db + +# Editor / IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Secrets +.env +.env.local +.env.*.local + +# Logs +*.log + +# AI assistant files — not part of the project +CLAUDE.md +AGENTS.md +.claude/ +GEMINI.md +.gemini/ +.cursorrules +.cursorignore +.aider* +AIDER.md +COPILOT*.md + +# Índice de países da analítica: ~5 MB, gerado por bin/geo-build-index.php. +# Não é versionado -- um binário deste tamanho refrescado mensalmente inchava o +# histórico. Sem ele a analítica regista o país como desconhecido, que é um +# degradar aceitável; as listas do BLOQUEIO por país (pt.v4/pt.v6) essas ficam +# versionadas, porque aí dados em falta seriam uma decisão de segurança. +data/geo/country-index.* diff --git a/shared/platform-tools/ASSETS.md b/shared/platform-tools/ASSETS.md new file mode 100644 index 0000000..128a0c2 --- /dev/null +++ b/shared/platform-tools/ASSETS.md @@ -0,0 +1,80 @@ +# Assets de front-end na frota andmore + +Convenção fixada a 2026-09-01, quando os oito sites deixaram de carregar +JavaScript e CSS de CDNs. + +## A regra + +**Nada de front-end sai para a rede em produção.** Tudo é servido do próprio +domínio, está no git, e tem a versão no nome da pasta. + + public/vendor/-/… (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 new file mode 100644 index 0000000..575ac3c --- /dev/null +++ b/shared/platform-tools/backups/backup_maker.php @@ -0,0 +1,185 @@ +/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"; + +cleanOldBackups($backupDir, 'backup_*.zip'); diff --git a/shared/platform-tools/backups/config.php b/shared/platform-tools/backups/config.php new file mode 100644 index 0000000..0ed5e71 --- /dev/null +++ b/shared/platform-tools/backups/config.php @@ -0,0 +1,95 @@ +/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/ directory at all -- src/config.php plus +// vlucas/phpdotenv, which has already populated $_ENV and +// getenv() by the time a web request reaches here. +// Requiring config/env.php unconditionally fataled on the third layout. +if (is_file($host_root . '/config/env.php')) { + require_once $host_root . '/config/env.php'; + if (function_exists('env_load')) { + env_load($host_root . '/.env'); + } +} + +// Fallback reader for hosts with no config/env.php, and for CLI invocations +// that run before any framework bootstrap: parse .env directly rather than +// assuming something else already did. +if (!function_exists('env')) { + function env(string $key, $default = null) { + $v = getenv($key); + if ($v === false && array_key_exists($key, $_ENV)) $v = $_ENV[$key]; + if ($v !== false && $v !== null) return $v; + + static $parsed = null; + if ($parsed === null) { + $parsed = []; + $f = dirname(__DIR__, 3) . '/.env'; + if (is_file($f) && is_readable($f)) { + foreach (file($f, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { + $line = trim($line); + if ($line === '' || $line[0] === '#' || !str_contains($line, '=')) continue; + [$k, $val] = array_pad(explode('=', $line, 2), 2, ''); + $parsed[trim($k)] = trim(trim($val), " \"'"); + } + } + } + return array_key_exists($key, $parsed) ? $parsed[$key] : $default; + } +} + +define('BACKUP_TOKEN', env('BACKUP_TOKEN', 'replace_me')); +define('BACKUP_KEEP_DAYS', (int) env('BACKUP_KEEP_DAYS', 7)); + +// Pasta (relativa à raiz do host) que o backup de ficheiros arquiva. Só os +// uploads têm informação externa — todo o resto é reconstituível a partir do +// git — por isso o default é 'uploads'. Overridável no .env se um host +// guardar os uploads noutro sítio. +define('BACKUP_FILES_DIR', trim((string) env('BACKUP_FILES_DIR', 'uploads'), '/')); + +// Credenciais da base de dados — sem fallbacks reais: se o .env faltar, a +// ligação falha alto em vez de tentar um host/utilizador "mágico". +define('DB_HOST', env('DB_HOST', 'localhost')); +define('DB_PORT', (int) env('DB_PORT', 3306)); +define('DB_NAME', env('DB_NAME', '')); +define('DB_USER', env('DB_USER', '')); +define('DB_PASS', env('DB_PASS', '')); +define('DB_CHARSET', 'utf8mb4'); + +/** + * Apaga backups mais antigos que BACKUP_KEEP_DAYS na pasta indicada. + * $pattern — glob pattern para identificar os ficheiros (ex: 'backup_*.zip') + */ +function cleanOldBackups(string $dir, string $pattern): void +{ + $cutoff = time() - (BACKUP_KEEP_DAYS * 86400); + $files = glob($dir . '/' . $pattern) ?: []; + $deleted = 0; + + foreach ($files as $file) { + if (filemtime($file) < $cutoff) { + if (@unlink($file)) { + $deleted++; + echo "Apagado (antigo): " . basename($file) . "\n"; + } + } + } + + if ($deleted === 0) { + echo "Rotação: nenhum backup antigo para apagar.\n"; + } else { + echo "Rotação: $deleted ficheiro(s) apagado(s).\n"; + } +} diff --git a/shared/platform-tools/backups/db_backup.php b/shared/platform-tools/backups/db_backup.php new file mode 100644 index 0000000..a856171 --- /dev/null +++ b/shared/platform-tools/backups/db_backup.php @@ -0,0 +1,187 @@ +/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"; + +cleanOldBackups($backupDir, 'db_backup_*.zip'); +// Varre também .sql órfãos de execuções antigas interrompidas a meio. +cleanOldBackups($backupDir, 'db_backup_*.sql'); diff --git a/shared/platform-tools/backups/restore_runner.php b/shared/platform-tools/backups/restore_runner.php new file mode 100644 index 0000000..fc0348b --- /dev/null +++ b/shared/platform-tools/backups/restore_runner.php @@ -0,0 +1,126 @@ +/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/encrypt_secrets.php b/shared/platform-tools/bin/encrypt_secrets.php new file mode 100644 index 0000000..290793f --- /dev/null +++ b/shared/platform-tools/bin/encrypt_secrets.php @@ -0,0 +1,89 @@ + php \ +// /var/www/html/shared/platform-tools/bin/encrypt_secrets.php [--dry-run] +// +// Idempotent -- rows already carrying the enc:v1: prefix are left alone -- so it +// is safe to call from deploy.sh on every deploy. Exits non-zero if a row that +// should be encrypted could not be, so a deploy notices. +// +// Bootstrapping differs per site: the config/ stack (dance, fotografia, hub2) +// opens $pdo in config/db.php; surf/silver builds it through App\Database. Try +// the common path first and fall back, rather than assuming either. + +declare(strict_types=1); + +$repoRoot = dirname(__DIR__, 3); +$dryRun = in_array('--dry-run', $argv, true); + +$pdo = null; +if (is_file($repoRoot . '/config/db.php')) { + require_once $repoRoot . '/config/db.php'; // defines $pdo + if (is_file($repoRoot . '/config/functions.php')) { + require_once $repoRoot . '/config/functions.php'; + } +} elseif (is_file($repoRoot . '/src/db.php')) { + // surf/silver: config.php loads the environment that db.php then reads. + if (is_file($repoRoot . '/src/config.php')) require_once $repoRoot . '/src/config.php'; + require_once $repoRoot . '/src/db.php'; +} + +if (!$pdo instanceof PDO) { + foreach (['App\\Database::getConnection', 'App\\Database::pdo', 'db', 'get_pdo'] as $factory) { + if (is_callable($factory)) { $pdo = $factory(); break; } + } +} + +if (!$pdo instanceof PDO) { + fwrite(STDERR, "encrypt_secrets: could not open a PDO connection for {$repoRoot}\n"); + exit(2); +} + +// src/db.php carries hardcoded development fallbacks (a 192.168.x host, a +// *_dev schema). If the environment did not reach it we would connect +// somewhere harmless-looking and "encrypt" nothing, reporting success. Refuse +// unless the live connection is the schema DB_NAME actually names. +$expected = (string) ($_ENV['DB_NAME'] ?? getenv('DB_NAME') ?: ''); +$actual = (string) $pdo->query('SELECT DATABASE()')->fetchColumn(); +if ($expected !== '' && $actual !== $expected) { + fwrite(STDERR, "encrypt_secrets: connected to '{$actual}' but DB_NAME is '{$expected}' -- refusing to run.\n"); + exit(2); +} + +require_once dirname(__DIR__) . '/functions_settings.php'; + +if (!platform_settings_secret_available()) { + fwrite(STDERR, "encrypt_secrets: no APP_SECRET_KEY (or BACKUP_TOKEN) configured -- refusing to run.\n"); + exit(3); +} + +if ($dryRun) { + // Report without writing: list secret-named rows still in plaintext. + $rows = $pdo->query("SELECT setting_key, setting_value FROM system_settings")->fetchAll(PDO::FETCH_ASSOC); + $pending = []; + foreach ($rows as $row) { + $k = (string) $row['setting_key']; + $v = (string) $row['setting_value']; + if (platform_settings_is_secret($k) && $v !== '' && !str_starts_with($v, PLATFORM_SECRET_PREFIX)) { + $pending[] = $k; + } + } + echo $pending + ? "would encrypt: " . implode(', ', $pending) . "\n" + : "nothing to encrypt -- all secret rows are empty or already encrypted\n"; + exit(0); +} + +$report = platform_settings_encrypt_existing($pdo); + +foreach ($report['encrypted'] as $k) echo "encrypted: {$k}\n"; +foreach ($report['skipped'] as $k) echo "skipped: {$k}\n"; +foreach ($report['failed'] as $k) fwrite(STDERR, "FAILED: {$k}\n"); + +printf( + "%d encrypted, %d skipped, %d failed\n", + count($report['encrypted']), count($report['skipped']), count($report['failed']) +); + +exit($report['failed'] ? 1 : 0); diff --git a/shared/platform-tools/bin/geo-build-index.php b/shared/platform-tools/bin/geo-build-index.php new file mode 100644 index 0000000..38979a5 --- /dev/null +++ b/shared/platform-tools/bin/geo-build-index.php @@ -0,0 +1,169 @@ +#!/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-update.sh b/shared/platform-tools/bin/geo-update.sh new file mode 100644 index 0000000..b3de88c --- /dev/null +++ b/shared/platform-tools/bin/geo-update.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# +# Refresh the per-country CIDR lists that functions_geo.php reads. +# +# geo-update.sh pt # one or more ISO country codes +# geo-update.sh # falls back to $GEO_ALLOW_COUNTRIES +# +# Source is ipdeny.com, which publishes one aggregated zone file per country +# from the RIR allocation data. Allocations move slowly, so monthly is plenty: +# +# 17 4 3 * * /srv/sites//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/compat_secrets.php b/shared/platform-tools/compat_secrets.php new file mode 100644 index 0000000..a9ea7a9 --- /dev/null +++ b/shared/platform-tools/compat_secrets.php @@ -0,0 +1,59 @@ +, 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 new file mode 100644 index 0000000..5e1f778 --- /dev/null +++ b/shared/platform-tools/feedback_widget.php @@ -0,0 +1,474 @@ +. Aparece só a quem é +// administrador — para os restantes, e para visitantes, não sai daqui uma +// única linha de HTML. +// +// Não depende de nada: nem do Bootstrap (JS ou CSS), nem de ícones, nem de +// jQuery. É um ficheiro incluído no fim de páginas que já são pesadas, e a +// alternativa — assumir que o Bootstrap está carregado — partia-se no primeiro +// site da frota que o não tivesse. O ícone é SVG inline pela mesma razão. +// +// O z-index (1035) é deliberadamente ABAIXO do backdrop dos modais do +// Bootstrap (1050): com um modal aberto a bolha desaparece por baixo dele, em +// vez de ficar a flutuar por cima de um diálogo que pediu a atenção toda. +// +// ─── Dois modos ────────────────────────────────────────────────────────────── +// +// $GLOBALS['platform_feedback_mode'] = 'footer'; // antes do include +// +// 'bubble' (por omissão) é a bolha a flutuar no canto — para o backoffice, onde +// o canto está livre e quem trabalha quer o atalho sempre à vista. +// +// 'footer' é uma ligação dentro do rodapé, sem nada a flutuar — para o site +// público, onde o canto inferior direito JÁ ESTÁ OCUPADO: o dance tem lá o +// botão do WhatsApp (e uma barra fixa em telemóvel). Duas bolhas empilhadas no +// mesmo canto são duas coisas a disputar o mesmo clique, e a que interessa ao +// visitante é a outra. O painel continua a abrir fixo no canto, por cima de +// tudo isso — está aberto por um instante e tem botão de fechar. +// +// Ver functions_feedback.php para o que é enviado, e api/feedback.php para +// quem o recebe. +// ───────────────────────────────────────────────────────────────────────────── + +require_once __DIR__ . '/functions_feedback.php'; +require_once __DIR__ . '/functions_csrf.php'; + +(static function (): void { + + // Uma vez por resposta. O rodapé do backoffice e o do site público podem + // acabar ambos incluídos na mesma página, e dois formulários com os mesmos + // ids não é meio widget a mais: é um widget partido. + if (!empty($GLOBALS['__platform_feedback_rendered'])) { + return; + } + + $user = platform_feedback_user(); + if (!$user || !platform_feedback_is_admin($user)) { + return; + } + + $ligado = hub_ticket_ready(); + + // Sem emparelhamento não há para onde enviar. Ao administrador não se + // mostra nada — não é ele que resolve isto, e um botão que falha é pior do + // que botão nenhum. Ao sysadmin mostra-se, porque é ele que o liga: um + // canal de relatos que desaparece em silêncio quando a chave roda perde + // exactamente os relatos que mais interessavam. + if (!$ligado && !platform_feedback_is_sysadmin($user)) { + return; + } + + $GLOBALS['__platform_feedback_rendered'] = true; + + $h = static fn(string $s): string => htmlspecialchars($s, ENT_QUOTES, 'UTF-8'); + $cats = platform_feedback_categories(); + + // Caminho absoluto, e não base_url(): o site público da fotografia não + // carrega o config/functions.php, e ali base_url() não existe (ver + // ASSETS.md). O docroot é a raiz do domínio em toda a frota. + $endpoint = '/api/feedback.php'; + + // A página onde o sysadmin liga isto. Cada site tem a sua árvore de vistas, + // daí poder ser sobreposta pelo layout antes do include. + $hubPage = (string) ($GLOBALS['platform_feedback_hub_url'] ?? '/views/sysadmin/hub_integration.php'); + + $inline = ($GLOBALS['platform_feedback_mode'] ?? 'bubble') === 'footer'; +?> +
+ + + + + + + +
+ + + + + → "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 new file mode 100644 index 0000000..0addb4d --- /dev/null +++ b/shared/platform-tools/functions_antispam.php @@ -0,0 +1,274 @@ +." 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 new file mode 100644 index 0000000..d81a64d --- /dev/null +++ b/shared/platform-tools/functions_antispam_forms.php @@ -0,0 +1,490 @@ + 'dsa-antispam|', // NÃO mudar num site a correr: ver abaixo +// 'log_table' => 'blocked_submissions', +// 'keywords' => [...], // vocabulário que não aparece no vosso negócio +// 'native' => '/\b(ola|gostaria|aulas?)\b/u', +// ]); +// +// `key_domain` entra no HMAC do carimbo. Mudá-lo num site a correr invalida os +// formulários que estiverem abertos nesse momento, e essas submissões são +// recusadas em silêncio — leads perdidas por uma questão de arrumação. Por isso +// é configurável e não fixo: cada site mantém o domínio com que já assina. + +require_once __DIR__ . '/functions_settings.php'; +require_once __DIR__ . '/functions_client_ip.php'; + +/** + * Configuração do módulo. Chamar com um array para definir; sem argumentos, lê. + * Os valores por omissão são conservadores de propósito — sem `keywords`, a + * camada de vocabulário simplesmente não pontua. + */ +function platform_antispam_config(?array $set = null): array +{ + static $cfg = [ + 'key_domain' => 'antispam|', + 'log_table' => 'blocked_submissions', + 'min_seconds' => 4, // ninguém preenche um formulário em menos + 'max_seconds' => 21600, // 6 h — separador aberto de manhã ainda serve + 'score_block' => 5, + 'ip_limit' => 5, + 'ip_window' => 60, // minutos + 'keywords' => [], + 'native' => '', // regex das marcas da língua local (ver looks_foreign) + 'setting_key' => 'antispam_key', + ]; + if ($set !== null) { + $cfg = array_merge($cfg, $set); + } + return $cfg; +} + +/** + * Um identificador SQL que podemos interpolar? + * + * Nomes de tabela não podem ir por placeholder, e estas funções recebem-nos de + * quem chama. **Falha aberto**: um identificador inválido é um erro de + * programação do site, e não é o anti-spam que há-de derrubar o formulário por + * causa dele — regista-se e deixa-se passar. + */ +function platform_antispam_ident_ok(string $ident): bool +{ + if (preg_match('/^[A-Za-z0-9_]+$/', $ident)) { + return true; + } + error_log('[antispam] identificador SQL inválido, ignorado: ' . $ident); + return false; +} + +/** + * Chave dos HMAC do carimbo. + * + * Tem de ser **a mesma entre pedidos**: o formulário é assinado num pedido e + * verificado noutro, e uma chave que mudasse a cada pedido recusava toda a + * gente com "carimbo inválido". Daí os três degraus, todos estáveis: + * + * 1. APP_SECRET_KEY / BACKUP_TOKEN do ambiente (o caso normal); + * 2. uma chave aleatória gravada em system_settings, quando não há a primeira; + * 3. derivada da configuração do install, para instalações sem chave e sem a + * tabela de settings. Não é um segredo tão bom como os outros, mas é + * constante — e uma defesa que tranca todos os formulários é pior do que + * uma assinatura mais fraca. + */ +function platform_antispam_key(?PDO $pdo = null): string +{ + static $key = null; + if ($key !== null) { + return $key; + } + $cfg = platform_antispam_config(); + + // Em bruto, e NÃO derivada com o domínio, quando vem do ambiente. + // + // Separar domínios seria melhor prática — a mesma chave assina carimbos e + // cifra backups. Mas a chave do HMAC não se muda num site a correr: os + // formulários abertos nesse instante passam a ter carimbo inválido e são + // recusados em silêncio, ou seja, leads perdidas por uma questão de + // arrumação. Fica como está, de propósito, e quem quiser trocar faz uma + // janela que aceite as duas assinaturas antes de tirar a antiga. + $fromEnv = platform_secret_key(); + if ($fromEnv !== null) { + return $key = $fromEnv; + } + + $stored = ''; + if ($pdo instanceof PDO) { + $stored = (string) platform_settings_get($pdo, $cfg['setting_key'], ''); + if ($stored === '') { + $fresh = bin2hex(random_bytes(32)); + try { + // Nunca cifrada: platform_settings_is_secret('antispam_key') é + // falso de propósito. Cifrá-la aqui e lê-la noutro sítio sem + // decifrar dava uma chave diferente a cada leitura, ou seja + // todos os formulários recusados. + platform_settings_set($pdo, $cfg['setting_key'], $fresh, null, false); + $stored = $fresh; + } catch (Throwable $e) { + error_log('[antispam] chave não persistida (' . $e->getMessage() . '); a derivar da configuração'); + } + } + } + + if ($stored === '') { + $env = function (string $k): string { + return function_exists('env') ? (string) env($k, '') : (string) (getenv($k) ?: ''); + }; + $stored = 'derived|' . $env('DB_NAME') . '|' . $env('DB_USER') . '|' . $env('DB_PASS') + . '|' . (defined('APP_BASE_URL') ? APP_BASE_URL : ''); + } + + return $key = hash('sha256', $cfg['key_domain'] . $stored, true); +} + +function platform_antispam_sign(string $form, int $ts, ?PDO $pdo = null): string +{ + return hash_hmac('sha256', $form . '|' . $ts, platform_antispam_key($pdo)); +} + +/** + * Campos-armadilha de um formulário público. `$form` entra na assinatura, para + * que um carimbo colhido no formulário de contacto não sirva no de inscrição. + * + * O '; + } + + 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 + + + + + + + + + + +

+ 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 new file mode 100644 index 0000000..2fe54f5 --- /dev/null +++ b/shared/platform-tools/platform_update.php @@ -0,0 +1,2163 @@ + 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}.\nCommit criado — usa o botão git push para publicar." + : 'Bump aplicado, mas o commit falhou:') + . ($r3['output'] !== '' ? "\n\n" . $r3['output'] : ''); + 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; + } + + // ── 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']; + ?> + + + +
+ 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 new file mode 100644 index 0000000..95acb08 --- /dev/null +++ b/shared/platform-tools/restore.php @@ -0,0 +1,250 @@ + 'danger', 'msg' => 'Pedido rejeitado (token CSRF inválido).']; + header('Location: ' . $selfUrl, true, 303); + exit; + } + + $file = (string) ($_POST['filename'] ?? ''); + $typed = trim((string) ($_POST['confirm_name'] ?? '')); + $dropExtra = !empty($_POST['drop_extra']); + + if ($typed !== $file) { + $_SESSION['restore_flash'] = ['type' => 'warning', 'msg' => + 'O nome escrito não corresponde ao ficheiro escolhido — nada foi feito.']; + header('Location: ' . $selfUrl . '?f=' . urlencode($file), true, 303); + exit; + } + + // A partir daqui a base de dados vai ser substituída. + $report = restore_db($pdo, $file, ['safety' => true, 'drop_extra_tables' => $dropExtra]); + $_SESSION['restore_report'] = $report; + header('Location: ' . $selfUrl, true, 303); + exit; +} + +$flash = $_SESSION['restore_flash'] ?? null; +$report = $_SESSION['restore_report'] ?? null; +unset($_SESSION['restore_flash'], $_SESSION['restore_report']); + +$all = backup_list(); +$dbFiles = array_values(array_filter($all, fn($b) => ($b['type'] ?? '') === 'db')); +$selected = (string) ($_GET['f'] ?? ''); +$inspect = $selected !== '' ? restore_inspect($selected) : null; +$tables = restore_current_tables($pdo); + +$layout_header = $platform_layout_header ?? ($repo_root . '/includes/header.php'); +$layout_navbar = $platform_layout_navbar ?? ($repo_root . '/includes/navbar.php'); +$layout_footer = $platform_layout_footer ?? ($repo_root . '/includes/footer.php'); +$backupsUrl = $platform_backups_url + ?? (function_exists('platform_admin_url') ? platform_admin_url('backups.php') : 'backups.php'); + +$pageTitle = 'Restaurar base de dados'; +if (is_file($layout_header)) include $layout_header; +if (is_file($layout_navbar)) include $layout_navbar; + +$h = fn($s) => htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); +?> +
+ +
+
+

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

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 new file mode 100644 index 0000000..ac7bbbe --- /dev/null +++ b/shared/platform-tools/social/engine.js @@ -0,0 +1,159 @@ +/* Social-post export engine — generalization of the pipeline proven in + fotografia.andmore.pt/views/sessions/open_spots_ig.php. + + Responsibilities: render a live-preview element to a high-resolution JPG + via html2canvas, pre-baking CSS blur (html2canvas can't render + `filter: blur()`), waiting for webfonts, and bundling multi-slide exports + into a ZIP (JSZip). Pure mechanism — all template/DOM knowledge lives in + the page that uses it. */ + +window.SocialEngine = (function () { + 'use strict'; + + function chunk(arr, size) { + size = Math.max(1, size | 0); + var out = []; + for (var i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); + return out; + } + + /* html2canvas does not render CSS `filter: blur(...)`. Pre-bake the blur + into the photo with Canvas 2D, swap the bg-image to that bitmap, drop + the inline filter/transform, and return a restore() callback. The blur + radius is multiplied by the export scale so the upscaled bitmap matches + what's on screen. Resolves null when there is nothing to bake. */ + function bakeBlur(bgEl, photoUrl, blurAmount, scale) { + return new Promise(function (resolve) { + if (!bgEl || !photoUrl || !(blurAmount > 0)) return resolve(null); + + var rect = bgEl.getBoundingClientRect(); + var W = Math.max(1, Math.round(rect.width * scale)); + var H = Math.max(1, Math.round(rect.height * scale)); + var blurPx = blurAmount * scale; + + var img = new Image(); + // Photos go through the same-origin media proxy (or are local hub + // uploads), so no crossOrigin attribute is needed and the canvas + // stays untainted. + img.onload = function () { + try { + var canvas = document.createElement('canvas'); + canvas.width = W; + canvas.height = H; + var ctx = canvas.getContext('2d'); + ctx.filter = 'blur(' + blurPx + 'px) brightness(0.85)'; + + // Draw slightly larger than the canvas so the soft edges + // don't reveal blank pixels — same trick as the CSS scale(). + var overscan = 1.1 + (blurAmount / 200); + var ratio = Math.max(W / img.width, H / img.height) * overscan; + var dw = img.width * ratio; + var dh = img.height * ratio; + ctx.drawImage(img, (W - dw) / 2, (H - dh) / 2, dw, dh); + + var dataUrl = canvas.toDataURL('image/jpeg', 0.9); + var original = { + backgroundImage: bgEl.style.backgroundImage, + filter: bgEl.style.filter, + transform: bgEl.style.transform, + }; + bgEl.style.backgroundImage = "url('" + dataUrl + "')"; + bgEl.style.filter = 'none'; + bgEl.style.transform = 'none'; + + resolve(function () { + bgEl.style.backgroundImage = original.backgroundImage; + bgEl.style.filter = original.filter; + bgEl.style.transform = original.transform; + }); + } catch (e) { + console.warn('Blur bake failed; exporting without baked blur.', e); + resolve(null); + } + }; + img.onerror = function () { + console.warn('Failed to load photo for blur bake; exporting without baked blur.'); + resolve(null); + }; + img.src = photoUrl; + }); + } + + /* Render `el` (the live preview, at its on-screen size — its CSS uses + viewport-relative clamp()s, so rendering at another width would re-flow) + and let html2canvas upscale the pixel buffer to `targetW` via `scale`. + opts.blur = { bgEl, photoUrl, amount } enables the bake step. + Returns a JPEG Blob. */ + async function renderBlob(el, targetW, opts) { + opts = opts || {}; + if (typeof html2canvas !== 'function') { + throw new Error('A biblioteca de exportação ainda está a carregar. Tenta novamente em segundos.'); + } + var rect = el.getBoundingClientRect(); + var actualW = rect.width || el.offsetWidth || targetW; + var actualH = rect.height || el.offsetHeight || targetW; + var scale = actualW > 0 ? (targetW / actualW) : 1; + + if (document.fonts && document.fonts.ready) { + await document.fonts.ready; + } + var restore = null; + if (opts.blur) { + restore = await bakeBlur(opts.blur.bgEl, opts.blur.photoUrl, opts.blur.amount, scale); + } + try { + var canvas = await html2canvas(el, { + backgroundColor: '#000', + scale: scale, + useCORS: true, + logging: false, + width: actualW, + height: actualH, + windowWidth: document.documentElement.clientWidth, + windowHeight: document.documentElement.clientHeight, + }); + return await new Promise(function (resolve, reject) { + canvas.toBlob(function (blob) { + if (!blob) { reject(new Error('Falha ao gerar a imagem.')); return; } + resolve(blob); + }, 'image/jpeg', 0.95); + }); + } finally { + if (restore) restore(); + } + } + + function saveBlob(blob, fileName) { + var url = URL.createObjectURL(blob); + var link = document.createElement('a'); + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + setTimeout(function () { URL.revokeObjectURL(url); }, 1500); + } + + /* files: [{ blob, name }]. One file downloads directly; several are + bundled into a single ZIP. */ + async function saveAll(files, zipName) { + if (!files.length) return; + if (files.length === 1) { saveBlob(files[0].blob, files[0].name); return; } + if (typeof JSZip !== 'function') { + // ZIP library missing — degrade to sequential downloads. + files.forEach(function (f) { saveBlob(f.blob, f.name); }); + return; + } + var zip = new JSZip(); + files.forEach(function (f) { zip.file(f.name, f.blob); }); + var blob = await zip.generateAsync({ type: 'blob' }); + saveBlob(blob, zipName); + } + + return { + chunk: chunk, + renderBlob: renderBlob, + saveBlob: saveBlob, + saveAll: saveAll, + }; +})(); diff --git a/shared/platform-tools/social/functions_publications.php b/shared/platform-tools/social/functions_publications.php new file mode 100644 index 0000000..3a70962 --- /dev/null +++ b/shared/platform-tools/social/functions_publications.php @@ -0,0 +1,117 @@ + ['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 new file mode 100644 index 0000000..42a232e --- /dev/null +++ b/shared/platform-tools/social/page.js @@ -0,0 +1,745 @@ +'use strict'; + +const CFG = JSON.parse(document.getElementById('engineConfig').textContent); +const CSRF_TOKEN = CFG.csrf || ''; +const DAY_NAMES = ['DOM','SEG','TER','QUA','QUI','SEX','SÁB']; +const MONTH_NAMES = ['JAN','FEV','MAR','ABR','MAI','JUN','JUL','AGO','SET','OUT','NOV','DEZ']; + +/* ── State ─────────────────────────────────────────────────── */ +let items = []; // [{id, title, subtitle, date}] +let nextItemId = 1; +let slides = []; // arrays of items (chunked by template max) +let currentSlide = 0; + +let currentBg = null; // gradient/solid CSS or image URL +let bgIsImage = false; +let blurAmount = 0; +let overlaySource = 'none'; +let overlayValue = ''; +let overlayOpacity = 50; + +function esc(str) { + return String(str ?? '') + .replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); +} + +/* ── 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 new file mode 100644 index 0000000..4f3b2b2 --- /dev/null +++ b/shared/platform-tools/social/publications.css @@ -0,0 +1,504 @@ +/* ── Social-post generator (port of fotografia's ig_spots.css) ─────────────── + Class names match the proven fotografia builder; the color/font tokens are + driven by the platform's app_branding row via --brand-* vars injected on + .ig-post by the generator page. Fallbacks keep posts legible for platforms + without branding configured. */ + +/* Local aliases resolved from branding */ +.ig-post { + --sp-accent: var(--brand-accent, var(--brand-primary, #C9A96E)); + --sp-on-accent: #1a1713; + --sp-font-heading: var(--brand-font-heading, 'Cormorant Garamond', serif); + --sp-font-body: var(--brand-font-body, 'Poppins', system-ui, sans-serif); + --sp-font-sub: var(--brand-font-body, 'Montserrat', sans-serif); +} + +/* Dropzone */ +.ig-dropzone { + border: 2px dashed #ced4da; + border-radius: 8px; + padding: 1.25rem; + text-align: center; + cursor: pointer; + transition: border-color .15s, background .15s; +} +.ig-dropzone:hover, +.ig-dropzone.dragging { + border-color: var(--am-accent, #0d6efd); + background: rgba(13,110,253,.04); +} + +/* Preset swatches */ +.ig-preset { + width: 36px; + height: 36px; + border-radius: 8px; + border: 2px solid transparent; + cursor: pointer; + flex-shrink: 0; + transition: transform .15s, outline .15s; +} +.ig-preset:hover { transform: scale(1.12); } +.ig-preset.active { border-color: #fff; outline: 2px solid var(--am-accent, #0d6efd); } + +.ig-img-preset { + position: relative; + width: 52px; + height: 52px; + border-radius: 8px; + border: 2px solid transparent; + cursor: pointer; + background-size: cover; + background-position: center; + flex-shrink: 0; + transition: transform .15s, outline .15s; +} +.ig-img-preset:hover { transform: scale(1.1); } +.ig-img-preset.active { border-color: #fff; outline: 2px solid var(--am-accent, #0d6efd); } +.ig-img-preset .ig-img-del { + position: absolute; + top: -7px; + right: -7px; + width: 18px; + height: 18px; + border-radius: 50%; + border: none; + background: #dc3545; + color: #fff; + font-size: 10px; + line-height: 1; + display: none; + align-items: center; + justify-content: center; + cursor: pointer; +} +.ig-img-preset:hover .ig-img-del { display: flex; } + +/* Lazy gallery accordion (Fase 4: photo_session content) */ +.ig-gal-head { + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + gap: .5rem; + background: none; + border: 0; + padding: .45rem .65rem; + font-size: .82rem; + text-align: left; + cursor: pointer; +} +.ig-gal-head:hover { background: rgba(0,0,0,.04); } +.ig-gal-body { border-top: 1px solid rgba(0,0,0,.08); } + +/* Photo picker thumbnails (Fase 4: photo_session content) */ +.ig-photo-pick { + position: relative; + width: 64px; + height: 64px; + border-radius: 6px; + border: 2px solid transparent; + background-size: cover; + background-position: center; + background-color: #e9ecef; + cursor: pointer; + flex-shrink: 0; + transition: transform .15s, outline .15s; +} +.ig-photo-pick:hover { transform: scale(1.06); } +.ig-photo-pick.active { border-color: #fff; outline: 2px solid var(--am-accent, #0d6efd); } +.ig-photo-pick-num { + position: absolute; + top: -6px; + right: -6px; + min-width: 18px; + height: 18px; + padding: 0 4px; + border-radius: 50%; + background: var(--am-accent, #0d6efd); + color: #fff; + font-size: 10px; + font-weight: 700; + line-height: 18px; + text-align: center; +} +.ig-photo-pick-num:empty { display: none; } + +/* Photo-grid template (Fase 4): selected photos tile the whole canvas */ +.ig-post .ig-photo-grid { + position: absolute; + inset: 0; + display: none; + gap: 4px; +} +.ig-photo-grid .ig-grid-cell { + background-size: cover; + background-position: center; + background-color: rgba(255,255,255,.06); +} +.ig-photo-grid .ig-grid-empty { + grid-column: 1 / -1; + display: flex; + align-items: center; + justify-content: center; + color: rgba(255,255,255,.4); + font-family: var(--sp-font-body); + font-size: 11px; + text-align: center; + padding: 1rem; +} + +/* Preview panel */ +.ig-preview-sticky { position: sticky; top: 80px; } +.ig-preview-wrapper { + height: min(72vh, 580px); + display: flex; + justify-content: center; +} + +/* The post canvas */ +.ig-post { + position: relative; + aspect-ratio: 1 / 1; + height: 100%; + width: auto; + max-width: 100%; + background: linear-gradient(160deg, #1a1a2e, #16213e); + background-size: cover; + background-position: center; + overflow: hidden; + box-shadow: 0 24px 64px rgba(0,0,0,.35); + font-family: var(--sp-font-body); +} +/* Wide/landscape formats fit by width instead of height */ +.ig-preview-wrapper.is-wide { height: auto; } +.ig-preview-wrapper.is-wide .ig-post { width: 100%; height: auto; } + +.ig-overlay { + position: absolute; + inset: 0; + background: linear-gradient( + to bottom, + rgba(0,0,0,.15) 0%, + rgba(0,0,0,.42) 40%, + rgba(0,0,0,.72) 100% + ); +} +.ig-post.is-photo .ig-overlay { + background: linear-gradient( + to bottom, + rgba(0,0,0,.05) 0%, + rgba(0,0,0,.25) 55%, + rgba(0,0,0,.78) 100% + ); +} +/* Light/paper rendering (e.g. white solid backgrounds) drops the dark wash */ +.ig-post.is-light .ig-overlay { background: none; } + +.ig-overlay-custom { + position: absolute; + inset: 0; + pointer-events: none; + opacity: 0; +} + +.ig-post .ig-photo-bg { + position: absolute; + inset: 0; + background-size: cover; + background-position: center; + transition: filter .25s, transform .25s; +} + +/* Watermark (optional, from app_branding_assets type=watermark) */ +.ig-watermark { + position: absolute; + right: 4%; + bottom: 3%; + width: 14%; + opacity: .5; + pointer-events: none; + display: none; +} +.ig-post.has-watermark .ig-watermark { display: block; } + +.ig-content { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + gap: clamp(12px, 3.5%, 26px); + padding: clamp(16px,6%,32px) clamp(20px,8%,40px); +} +.ig-content[data-align-v="top"] { justify-content: flex-start; } +.ig-content[data-align-v="middle"] { justify-content: center; } +.ig-content[data-align-v="bottom"] { justify-content: flex-end; } +.ig-content[data-align-h="left"] { align-items: flex-start; text-align: left; } +.ig-content[data-align-h="center"] { align-items: stretch; text-align: center; } +.ig-content[data-align-h="right"] { align-items: flex-end; text-align: right; } + +/* Brand logo row */ +.ig-logo-row { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} +.ig-logo-row img { + max-height: 44px; + max-width: 55%; + object-fit: contain; +} + +/* Title block */ +.ig-title-block { flex-shrink: 0; } +.ig-tagline { + color: rgba(255,255,255,.7); + font-family: var(--sp-font-sub); + font-size: 9px; + text-transform: uppercase; + letter-spacing: 2.5px; + margin: 0 0 6px; + line-height: 1.4; +} +.ig-heading { + color: #fff; + font-family: var(--sp-font-heading); + font-size: clamp(22px, 6.5%, 34px); + font-weight: 400; + margin: 0 0 12px; + line-height: 1.1; + letter-spacing: .02em; +} +.ig-heading-divider { + width: 48px; + height: 1px; + background: var(--sp-accent); + display: inline-block; +} +.ig-content[data-align-h="center"] .ig-heading-divider { margin: 0 auto; display: block; } + +/* Item cards (event-list) */ +.ig-sessions { + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 7px; +} +.ig-empty-msg { + color: rgba(255,255,255,.4); + font-size: 11px; + text-align: center; + margin: 0; +} +.ig-session-card { + background: rgba(255,255,255,.12); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + border: 1px solid rgba(255,255,255,.2); + border-radius: 10px; + padding: 9px 12px; + display: flex; + align-items: center; + gap: 12px; + text-align: left; +} +.ig-date-block { + flex-shrink: 0; + text-align: center; + min-width: 30px; +} +.ig-date-dayname { + color: rgba(255,255,255,.55); + font-size: 8px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1px; + line-height: 1; + margin-bottom: 2px; +} +.ig-date-num { + color: #fff; + font-family: var(--sp-font-heading); + font-size: 26px; + font-weight: 400; + line-height: 1; +} +.ig-date-month { + color: rgba(255,255,255,.55); + font-size: 8px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .8px; + margin-top: 2px; +} +.ig-card-divider { + width: 1px; + height: 36px; + flex-shrink: 0; + opacity: .85; + background: var(--sp-accent); +} +.ig-session-info { flex: 1; min-width: 0; } +.ig-session-type { + color: #fff; + font-family: var(--sp-font-heading); + font-size: 15px; + font-weight: 400; + line-height: 1.2; + letter-spacing: .02em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.ig-session-avail { + color: var(--sp-accent); + font-family: var(--sp-font-sub); + font-size: 9px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 1.2px; + margin-top: 3px; +} + +/* Single announcement */ +.ig-announce { + flex-shrink: 0; + max-width: 100%; +} +.ig-announce-date { + color: var(--sp-accent); + font-family: var(--sp-font-sub); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 2px; + margin: 0 0 10px; +} +.ig-announce-title { + color: #fff; + font-family: var(--sp-font-heading); + font-size: clamp(26px, 9%, 44px); + font-weight: 400; + line-height: 1.08; + margin: 0 0 8px; + letter-spacing: .02em; +} +.ig-announce-subtitle { + color: rgba(255,255,255,.85); + font-family: var(--sp-font-sub); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 1.8px; + margin: 0 0 14px; +} +.ig-announce-extra { + color: rgba(255,255,255,.75); + font-family: var(--sp-font-body); + font-size: 12px; + line-height: 1.5; + margin: 0; +} + +/* CTA row */ +.ig-cta-row { + flex-shrink: 0; + padding-top: 10px; + border-top: 1px solid rgba(255,255,255,.18); + text-align: center; +} +.ig-cta { + color: var(--sp-accent); + font-family: var(--sp-font-sub); + font-size: 11px; + font-weight: 600; + letter-spacing: 1.6px; + text-transform: uppercase; +} + +/* Light/paper variant: dark text on light backgrounds */ +.ig-post.is-light .ig-tagline, +.ig-post.is-light .ig-announce-extra { color: rgba(20,20,20,.6); } +.ig-post.is-light .ig-heading, +.ig-post.is-light .ig-date-num, +.ig-post.is-light .ig-session-type, +.ig-post.is-light .ig-announce-title { color: #141414; } +.ig-post.is-light .ig-announce-subtitle { color: rgba(20,20,20,.75); } +.ig-post.is-light .ig-empty-msg { color: rgba(20,20,20,.4); } +.ig-post.is-light .ig-date-dayname, +.ig-post.is-light .ig-date-month { color: rgba(20,20,20,.55); } +.ig-post.is-light .ig-session-card { + background: rgba(0,0,0,.05); + border-color: rgba(0,0,0,.12); +} +.ig-post.is-light .ig-cta-row { border-top-color: rgba(0,0,0,.15); } + +/* Slide navigator */ +.ig-slide-nav { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; +} +.ig-slide-dot { + width: 9px; + height: 9px; + border-radius: 50%; + background: #d0d5dc; + border: none; + padding: 0; + cursor: pointer; + transition: background .15s, transform .15s; +} +.ig-slide-dot.active { background: var(--am-accent, #0d6efd); transform: scale(1.25); } +.ig-slide-toolbar { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + flex-wrap: wrap; +} + +/* Screenshot overlay */ +.ig-screenshot-overlay { + position: fixed; + inset: 0; + background: #000; + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; +} +#screenshotPost { position: relative; overflow: hidden; } +#screenshotPost .ig-post { + width: 100%; + height: 100%; + aspect-ratio: unset; + border-radius: 0; + box-shadow: none; + max-width: unset; +} +.ig-screenshot-close { + position: fixed; + top: 16px; + right: 16px; + z-index: 10000; + background: rgba(255,255,255,.18); + border: none; + color: #fff; + border-radius: 50%; + width: 44px; + height: 44px; + font-size: 20px; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + cursor: pointer; + transition: opacity .4s; +} + +@keyframes ig-spin { to { transform: rotate(360deg); } } +.ig-spin { display: inline-block; animation: ig-spin .7s linear infinite; } diff --git a/shared/platform-tools/tracking.php b/shared/platform-tools/tracking.php new file mode 100644 index 0000000..bfc6472 --- /dev/null +++ b/shared/platform-tools/tracking.php @@ -0,0 +1,198 @@ + 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. +

+ +
+