, AUTO_UPDATE_SECRET) // // The secret lives in .env, not in system_settings: it is a credential you need // in order to *repair* a site, so it must not depend on the database being // readable. The on/off switch is a setting, because that is runtime policy an // operator changes from the backoffice. // // Three properties matter more than convenience here, because nobody is // watching when it runs: // // * It never discards work. git_safe_update() fast-forwards, tolerates // mode-bit and submodule drift, and STOPS on real local commits or edits. // An unattended `reset --hard` is how you lose the hotfix someone applied // on the server at 2 a.m. // * One at a time. A push burst would otherwise start several pulls in the // same working tree; the lock makes the extras no-ops. // * It always leaves a trace. Every run — refused, skipped or applied — is // recorded in settings, which is what the platform page and the dashboard // strip read. // // Public API: // auto_update_enabled(PDO) / auto_update_migrations_enabled(PDO) // auto_update_secret(): string // auto_update_verify(string $body, string $secret): bool // auto_update_payload_branch(string $body): ?string // auto_update_run(PDO, string $repo_root, ?string $ref): array // auto_update_last(PDO): array // update_backup_settings(PDO): array // platform_pre_update_backup(PDO, string $repo_root): array // platform_respond_and_continue(int $code, array $data): void // update_restore_point(PDO): ?array // update_restore_point_store(PDO, string $repo_root, string $sha, array, array): void // update_restore_point_apply(PDO, string $repo_root, bool $discard_local): array // update_restore_point_clear(PDO): void // platform_status_summary(PDO, string $repo_root): array // platform_status_strip(PDO, string $repo_root, ?string $url): void require_once __DIR__ . '/functions_git.php'; require_once __DIR__ . '/functions_migrations.php'; require_once __DIR__ . '/functions_settings.php'; require_once __DIR__ . '/functions_changelog.php'; function auto_update_secret(): string { if (function_exists('env')) { $v = (string) env('AUTO_UPDATE_SECRET', ''); if ($v !== '') return $v; } $v = getenv('AUTO_UPDATE_SECRET'); return $v !== false ? (string) $v : ''; } function auto_update_enabled(PDO $pdo): bool { return platform_settings_get($pdo, 'auto_update_enabled', '0') === '1'; } /** Whether a deploy also applies the migrations that came with it. */ function auto_update_migrations_enabled(PDO $pdo): bool { return platform_settings_get($pdo, 'auto_update_migrations', '1') === '1'; } /** * Constant-time check of the webhook signature against the raw body. * * Gitea sends the bare hex digest in X-Gitea-Signature; GitHub (and newer * Gitea) send "sha256=" in X-Hub-Signature-256. Either satisfies us. */ function auto_update_verify(string $body, string $secret): bool { if ($secret === '') return false; $expected = hash_hmac('sha256', $body, $secret); $offered = []; if (!empty($_SERVER['HTTP_X_GITEA_SIGNATURE'])) { $offered[] = trim((string) $_SERVER['HTTP_X_GITEA_SIGNATURE']); } if (!empty($_SERVER['HTTP_X_HUB_SIGNATURE_256'])) { $sig = trim((string) $_SERVER['HTTP_X_HUB_SIGNATURE_256']); $offered[] = str_starts_with($sig, 'sha256=') ? substr($sig, 7) : $sig; } foreach ($offered as $sig) { if (hash_equals($expected, strtolower($sig))) return true; } return false; } /** Branch name from a push payload's `ref`, or null when it isn't a branch push. */ function auto_update_payload_branch(string $body): ?string { $data = json_decode($body, true); $ref = is_array($data) ? (string) ($data['ref'] ?? '') : ''; if ($ref === '' || !str_starts_with($ref, 'refs/heads/')) return null; $branch = substr($ref, 11); return $branch !== '' ? $branch : null; } /** Path of the per-repo lock file. Kept in the temp dir: always writable. */ function auto_update_lock_path(string $repo_root): string { return sys_get_temp_dir() . '/auto_update_' . substr(sha1($repo_root), 0, 12) . '.lock'; } /** Which backups to take before an update touches anything. */ function update_backup_settings(PDO $pdo): array { return [ 'db' => platform_settings_get($pdo, 'update_backup_db', '0') === '1', 'files' => platform_settings_get($pdo, 'update_backup_files', '0') === '1', ]; } /** * Load the host's backup wiring, if it has any. * * Order matters. The host wrapper (config/functions_backup.php) is tried FIRST * because it defines BACKUP_TRIGGER_URL_PREFIX before including the shared * file — on a public/ docroot, shared/ is not reachable over HTTP, and loading * the shared file directly would bake in a trigger URL that 404s. */ function platform_backup_bootstrap(string $repo_root): bool { if (function_exists('backup_trigger')) return true; foreach ([$repo_root . '/config/functions_backup.php', __DIR__ . '/functions_backup.php'] as $f) { if (is_file($f)) { require_once $f; if (function_exists('backup_trigger')) return true; } } return false; } /** Filenames currently in the backup folder, for one type. */ function _platform_backup_names(string $type): array { clearstatcache(); $names = []; foreach (backup_list() as $b) { if ($b['type'] === $type) $names[$b['filename']] = true; } return $names; } /** * Take the configured backups and WAIT for them to land, before the update is * allowed to touch anything. * * Waiting is the whole point. backup_trigger() is fire-and-forget — it starts a * worker in a separate request and returns in milliseconds. Pulling straight * after it would race the dump: migrations could apply halfway through it, and * the "restore point" would be a snapshot of neither the old schema nor the new * one. libzip writes the archive under a temp name and renames it on close, so * the final filename appearing is a real completion signal, not a partial file. * * A backup that fails or times out ABORTS the update. A toggle that promises a * restore point and then quietly proceeds without one is worse than no toggle. * * Returns ['ok' => bool, 'log' => string[], 'created' => string[]]. */ function platform_pre_update_backup(PDO $pdo, string $repo_root, int $waitSeconds = 900): array { $want = update_backup_settings($pdo); $types = []; if ($want['db']) $types[] = 'db'; if ($want['files']) $types[] = 'files'; if (!$types) return ['ok' => true, 'log' => [], 'created' => []]; $log = ['── Backup antes de atualizar ──']; if (!platform_backup_bootstrap($repo_root)) { $log[] = 'FALHA: esta plataforma não tem os helpers de backup instalados.'; return ['ok' => false, 'log' => $log, 'created' => []]; } // The wait can outlive any sane max_execution_time — a files archive of a // photo site is measured in gigabytes. @set_time_limit(0); $created = []; foreach ($types as $type) { $label = backup_type_label($type); $before = _platform_backup_names($type); if (!backup_trigger($type)) { $log[] = "FALHA: não foi possível arrancar o backup de {$label}."; return ['ok' => false, 'log' => $log, 'created' => $created]; } $deadline = time() + $waitSeconds; $new = null; while (time() < $deadline) { sleep(2); foreach (array_keys(_platform_backup_names($type)) as $name) { if (!isset($before[$name])) { $new = $name; break 2; } } } if ($new === null) { $log[] = "FALHA: o backup de {$label} não ficou pronto em {$waitSeconds}s." . ' Atualização cancelada — nada foi alterado.'; return ['ok' => false, 'log' => $log, 'created' => $created]; } $created[] = $new; $log[] = "OK {$label}: {$new}"; } return ['ok' => true, 'log' => $log, 'created' => $created]; } // ─── Ponto de restauro ─────────────────────────────────────────────────────── // // "Atualizei, testei, correu mal, quero voltar atrás." Every update that // actually moves HEAD records where it came from; the button then walks the // branch back to that commit. // // `reset --hard` and not `revert`: the pulled commits still exist on the remote // and in this repo's object store, so moving the branch pointer back loses // nothing and leaves the working tree in exactly the state it had before the // update — which is the state that was known to work. A `revert` would instead // invent new commits that then have to be pushed, and would leave this server // diverged from every other one. // // What it does NOT undo is the database. Migrations that ran during the update // stay applied, and old code against a new schema can be worse than new code // against it. So the restore point remembers which migrations ran and which // backup was taken just before, and the UI says so plainly instead of implying // a clean rewind. /** HEAD's full SHA, or '' when it cannot be read. */ function update_head_sha(string $repo_root): string { if (!function_exists('git_run')) return ''; $r = git_run(['rev-parse', 'HEAD'], $repo_root); $s = $r['ok'] ? trim($r['output']) : ''; return preg_match('/^[0-9a-f]{40}$/', $s) ? $s : ''; } /** * Remember the commit an update started from. * * Only ever called when HEAD actually moved: recording a restore point for an * update that changed nothing would offer a button that does nothing. */ function update_restore_point_store( PDO $pdo, string $repo_root, string $sha, array $migrations = [], array $backups = [] ): void { if (!preg_match('/^[0-9a-f]{40}$/', $sha)) return; $info = git_run(['log', '-1', '--format=%s', $sha], $repo_root); $when = git_run(['log', '-1', '--format=%cd', '--date=format:%Y-%m-%d %H:%M', $sha], $repo_root); $br = git_run(['rev-parse', '--abbrev-ref', 'HEAD'], $repo_root); try { platform_settings_set($pdo, 'update_restore_sha', $sha); platform_settings_set($pdo, 'update_restore_subject', $info['ok'] ? trim($info['output']) : ''); platform_settings_set($pdo, 'update_restore_commit_at', $when['ok'] ? trim($when['output']) : ''); platform_settings_set($pdo, 'update_restore_branch', $br['ok'] ? trim($br['output']) : ''); platform_settings_set($pdo, 'update_restore_at', gmdate('Y-m-d H:i:s')); platform_settings_set($pdo, 'update_restore_migrations', implode(',', $migrations)); platform_settings_set($pdo, 'update_restore_backups', implode(',', $backups)); } catch (Throwable $e) { error_log('[restore_point] ' . $e->getMessage()); } } /** The current restore point, or null when there is nothing to go back to. */ function update_restore_point(PDO $pdo): ?array { $sha = (string) platform_settings_get($pdo, 'update_restore_sha', ''); if (!preg_match('/^[0-9a-f]{40}$/', $sha)) return null; $mig = array_values(array_filter(explode(',', (string) platform_settings_get($pdo, 'update_restore_migrations', '')))); $bk = array_values(array_filter(explode(',', (string) platform_settings_get($pdo, 'update_restore_backups', '')))); return [ 'sha' => $sha, 'short' => substr($sha, 0, 8), 'subject' => (string) platform_settings_get($pdo, 'update_restore_subject', ''), 'commit_at' => (string) platform_settings_get($pdo, 'update_restore_commit_at', ''), 'branch' => (string) platform_settings_get($pdo, 'update_restore_branch', ''), 'updated_at' => (string) platform_settings_get($pdo, 'update_restore_at', ''), 'migrations' => $mig, 'backups' => $bk, ]; } function update_restore_point_clear(PDO $pdo): void { foreach (['sha', 'subject', 'commit_at', 'branch', 'at', 'migrations', 'backups'] as $k) { try { platform_settings_set($pdo, 'update_restore_' . $k, ''); } catch (Throwable $e) {} } } /** * Walk the branch back to the recorded commit. * * The SHA comes from our own settings row and is re-validated as a real commit * in this repository — a reset target is never read from the request, so a * forged POST cannot point this at anything. * * Refuses while there are uncommitted content changes unless $discard_local is * explicitly set: `reset --hard` would silently destroy them, and somebody * debugging a bad deploy is exactly the person likely to have edited a file on * the server. */ function update_restore_point_apply(PDO $pdo, string $repo_root, bool $discard_local = false): array { $rp = update_restore_point($pdo); if ($rp === null) { return ['ok' => false, 'output' => 'Não há ponto de restauro registado.']; } $exists = git_run(['cat-file', '-e', $rp['sha'] . '^{commit}'], $repo_root); if (!$exists['ok']) { return ['ok' => false, 'output' => "O commit {$rp['short']} já não existe neste repositório — não é possível voltar atrás por aqui."]; } $log = []; $state = git_local_state($repo_root); if ($state['content_dirty'] && !$discard_local) { return ['ok' => false, 'output' => implode("\n", [ '── Recusado: há alterações locais por comitar ──', $state['dirty_files'], '', 'Um reset --hard apagava-as. Comita, faz stash, ou marca', '"Confirmo descartar alterações locais" para avançar mesmo assim.', ])]; } $undo = git_run(['rev-list', '--count', $rp['sha'] . '..HEAD'], $repo_root); $n = $undo['ok'] ? (int) trim($undo['output']) : 0; $log[] = "── Voltar a {$rp['short']} — " . ($rp['subject'] ?: '(sem assunto)') . ' ──'; if ($n > 0) $log[] = "A desfazer {$n} commit(s) locais. Continuam no remote; isto só recua este servidor."; $reset = git_run(['reset', '--hard', $rp['sha']], $repo_root); $log[] = trim($reset['output']) !== '' ? $reset['output'] : '(reset --hard concluído)'; if (!$reset['ok']) { return ['ok' => false, 'output' => implode("\n", $log)]; } $log[] = ''; $log[] = '── Submódulos ──'; foreach (git_sync_submodules($repo_root)['log'] as $l) $log[] = $l; if (function_exists('opcache_reset')) @opcache_reset(); if (function_exists('settings_cache_clear')) settings_cache_clear(); platform_settings_cache_clear(); $log[] = ''; $log[] = '(OPcache + caches de definições invalidados)'; if ($rp['migrations']) { $log[] = ''; $log[] = '── ATENÇÃO: a base de dados NÃO recuou ──'; $log[] = 'Estas migrations correram nesta atualização e continuam aplicadas:'; foreach ($rp['migrations'] as $m) $log[] = ' · ' . $m; if ($rp['backups']) { $log[] = ''; $log[] = 'Backup tirado imediatamente antes da atualização:'; foreach ($rp['backups'] as $b) $log[] = ' · ' . $b; $log[] = 'Restaura-o pela página Restaurar base de dados se o esquema novo for o problema.'; } } // Consumed: the branch is back where it was, so there is nothing left to // undo. The next real update records a fresh one. update_restore_point_clear($pdo); try { platform_settings_set($pdo, 'update_reverted_at', gmdate('Y-m-d H:i:s')); platform_settings_set($pdo, 'update_reverted_to', $rp['short']); } catch (Throwable $e) {} return ['ok' => true, 'output' => implode("\n", $log), 'undone' => $n]; } /** * Send the response, then keep working after the client has gone. * * Gitea gives a webhook delivery 5 seconds by default. A git pull alone beats * that, and a pre-update backup of a media library beats it by minutes — so * holding the connection until the work finished would paint every successful * deploy red in Gitea's delivery list. Answer immediately, work afterwards, and * let the platform page and the dashboard strip carry the outcome. */ function platform_respond_and_continue(int $code, array $data): void { ignore_user_abort(true); @set_time_limit(0); $body = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); http_response_code($code); header('Content-Type: application/json; charset=utf-8'); header('Content-Length: ' . strlen($body)); header('Connection: close'); echo $body; while (ob_get_level() > 0) @ob_end_flush(); @flush(); // PHP-FPM hands the connection back here; on mod_php it is the // Content-Length + Connection: close above that releases the client. if (function_exists('fastcgi_finish_request')) @fastcgi_finish_request(); } /** * Pull, migrate, invalidate. Returns: * ['ok' => bool, 'status' => 'updated'|'busy'|'failed', 'summary' => string, * 'output' => string, 'migrations' => string[]] * * $ref is the branch the push was for, or null for a manual run. */ function auto_update_run(PDO $pdo, string $repo_root, ?string $ref = null): array { $lockFile = @fopen(auto_update_lock_path($repo_root), 'c'); if (!$lockFile || !flock($lockFile, LOCK_EX | LOCK_NB)) { if ($lockFile) fclose($lockFile); // Not an error: a push burst means several deliveries for one state, and // the run already in flight is picking up the same commits. return [ 'ok' => true, 'status' => 'busy', 'migrations' => [], 'summary' => 'Já havia uma atualização a decorrer — este pedido foi ignorado.', 'output' => '', ]; } @set_time_limit(300); $log = []; $log[] = '── Auto-update ' . gmdate('Y-m-d H:i:s') . ' UTC' . ($ref !== null ? ' · push para ' . $ref : ' · execução manual') . ' ──'; // First, and blocking: the restore point has to exist before anything moves. $bk = platform_pre_update_backup($pdo, $repo_root); foreach ($bk['log'] as $l) $log[] = $l; if (!$bk['ok']) { flock($lockFile, LOCK_UN); fclose($lockFile); $out = ['ok' => false, 'status' => 'failed', 'migrations' => [], 'summary' => 'Backup pré-atualização falhou — nada foi atualizado.', 'output' => implode("\n", $log)]; auto_update_record($pdo, $out, $ref); return $out; } if ($bk['log']) $log[] = ''; $before_sha = update_head_sha($repo_root); $res = git_safe_update($repo_root); $log[] = $res['output'] !== '' ? $res['output'] : '(sem saída)'; if (!$res['ok']) { flock($lockFile, LOCK_UN); fclose($lockFile); $out = ['ok' => false, 'status' => 'failed', 'migrations' => [], 'summary' => 'Atualização interrompida — há trabalho local por resolver no servidor.', 'output' => implode("\n", $log)]; auto_update_record($pdo, $out, $ref); return $out; } // Migrations arrive in the same commit as the code that needs them, so a // deploy that pulls one without the other leaves the site broken between // the two. Applied in filename order; the first failure stops the rest. $applied = []; if (auto_update_migrations_enabled($pdo)) { $log[] = ''; $log[] = '── Migrations pendentes ──'; $error = null; foreach (migrations_status($pdo) as $row) { if ($row['applied_at']) continue; $r = migrations_apply_file($pdo, $row['filename'], null); if (!empty($r['ok'])) { $applied[] = $row['filename']; $log[] = 'OK ' . $row['filename'] . ' (' . $r['applied'] . '/' . $r['total'] . ' statements)'; } else { $error = $row['filename'] . ': ' . ($r['error'] ?? 'erro desconhecido'); $log[] = 'FALHA ' . $error; break; } } if (!$applied && $error === null) $log[] = '(nenhuma pendente)'; if ($error !== null) { flock($lockFile, LOCK_UN); fclose($lockFile); $out = ['ok' => false, 'status' => 'failed', 'migrations' => $applied, 'summary' => 'Código atualizado, mas uma migration falhou: ' . $error, 'output' => implode("\n", $log)]; auto_update_record($pdo, $out, $ref); return $out; } } else { $log[] = ''; $log[] = '(migrations automáticas desligadas — aplicar à mão)'; } if (function_exists('opcache_reset')) @opcache_reset(); if (function_exists('settings_cache_clear')) settings_cache_clear(); platform_settings_cache_clear(); $log[] = ''; $log[] = '(OPcache + caches de definições invalidados)'; flock($lockFile, LOCK_UN); fclose($lockFile); // Só se HEAD mexeu mesmo. Um deploy que não trouxe nada não deve deixar um // botão "reverter" que não reverteria coisa nenhuma — e não apaga o ponto // de restauro da última atualização que trouxe. $after_sha = update_head_sha($repo_root); if ($before_sha !== '' && $after_sha !== '' && $before_sha !== $after_sha) { update_restore_point_store($pdo, $repo_root, $before_sha, $applied, $bk['created']); $log[] = 'Ponto de restauro: ' . substr($before_sha, 0, 8); } $summary = 'Plataforma atualizada' . ($applied ? ' · ' . count($applied) . ' migration(s) aplicada(s)' : '') . '.'; $out = ['ok' => true, 'status' => 'updated', 'migrations' => $applied, 'summary' => $summary, 'output' => implode("\n", $log)]; auto_update_record($pdo, $out, $ref); return $out; } /** Persist the outcome so the UI can show it without re-running anything. */ function auto_update_record(PDO $pdo, array $result, ?string $ref): void { try { platform_settings_set($pdo, 'auto_update_last_at', gmdate('Y-m-d H:i:s')); platform_settings_set($pdo, 'auto_update_last_ok', !empty($result['ok']) ? '1' : '0'); platform_settings_set($pdo, 'auto_update_last_status', (string) ($result['status'] ?? '')); platform_settings_set($pdo, 'auto_update_last_summary', (string) ($result['summary'] ?? '')); platform_settings_set($pdo, 'auto_update_last_ref', (string) ($ref ?? '')); // Bounded: this is a settings row, not a log file. platform_settings_set($pdo, 'auto_update_last_output', mb_substr((string) ($result['output'] ?? ''), -8000)); } catch (Throwable $e) { error_log('[auto_update] ' . $e->getMessage()); } } /** Last recorded run, with sane defaults when there has never been one. */ function auto_update_last(PDO $pdo): array { $at = (string) platform_settings_get($pdo, 'auto_update_last_at', ''); return [ 'at' => $at !== '' ? $at : null, 'ok' => platform_settings_get($pdo, 'auto_update_last_ok', '') === '1', 'status' => (string) platform_settings_get($pdo, 'auto_update_last_status', ''), 'summary' => (string) platform_settings_get($pdo, 'auto_update_last_summary', ''), 'ref' => (string) platform_settings_get($pdo, 'auto_update_last_ref', ''), 'output' => (string) platform_settings_get($pdo, 'auto_update_last_output', ''), ]; } /** * Everything the "at a glance" strip needs, and nothing that costs a process. * * Deliberately git-free: this runs on a dashboard that loads on every visit, * and a `git fetch` there would put a network round-trip in front of the page. * Whether the remote has moved is a question for the platform page. */ function platform_status_summary(PDO $pdo, string $repo_root): array { $pending = 0; try { foreach (migrations_status($pdo) as $r) { if ($r['applied_at'] === null) $pending++; } } catch (Throwable $e) { $pending = null; // table unreachable — say nothing rather than "0" } return [ 'version' => changelog_latest()['version'], 'migrations_pending' => $pending, 'auto_update' => auto_update_last($pdo), 'auto_update_on' => auto_update_enabled($pdo), ]; } /** * One discreet line of platform state, for a backoffice dashboard. * * A muted strip, not a card: this is ambient information for whoever * administers the site, and it must not compete with the day's work on that * page. It raises its voice only when something is actually pending. */ function platform_status_strip(PDO $pdo, string $repo_root, ?string $url = null): void { $s = platform_status_summary($pdo, $repo_root); $url = $url ?? platform_admin_url('platform_update.php'); $au = $s['auto_update']; $e = fn($v) => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); ?>
· migration(s) por aplicar · > auto-update falhou ligado · a última atualização automática falhou Plataforma