Files
fitme3070.pt/shared/platform-tools/functions_auto_update.php
T
andreandClaude Opus 5 4d40adbf64 Vendor platform-tools: everything this site runs is now in one repo
shared/platform-tools was a git submodule. On this branch it is a plain
directory of tracked files, frozen at ea3616abae30106df283e73a3e2f9e5bb0e6ae90
— the same commit fotografia.andmore.pt pins. Contents are byte-identical to
that tree, verified blob by blob.

The submodule caused every deployment failure this site has had:

  - .gitmodules named no `branch`, so the update fell back to
    refs/remotes/origin/HEAD — a ref a plain server-side clone never writes.
    "Atualizar submódulo" failed with "Unable to find refs/remotes/origin/HEAD
    revision in submodule path".
  - Its URL was http://, which the origin answers with 523.
  - This install is deployed by uploading files, and a submodule is invisible
    to that. The tree moved forward while shared/platform-tools stayed on the
    old pin, config/functions_secrets.php died on a missing compat_secrets.php,
    and the whole site — public pages included — went down.

Each of those was separately fixable. Together they mean a second repository
nobody can see from the deployment path, gating whether the first one boots. A
plain directory deploys like every other file and cannot fall out of step.

The cost is that platform-tools updates no longer arrive by bumping a pin; they
are a copy-over-and-commit. shared/platform-tools/VENDORED.md records the
upstream SHA, the procedure, and which four files differ from upstream in line
endings only (social/*.js, .css, functions_publications.php — CRLF upstream,
normalised to LF here; content otherwise identical).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 23:06:23 +01:00

632 lines
27 KiB
PHP

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