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>
209 lines
7.5 KiB
PHP
209 lines
7.5 KiB
PHP
<?php
|
|
// ─── DB migrations runner ────────────────────────────────────────────────────
|
|
//
|
|
// Lightweight, dependency-free runner over the `database/migration_*.sql`
|
|
// files in the host repo. State is tracked in `applied_migrations` (auto-created
|
|
// on first call). Files are matched by basename only — path traversal is
|
|
// blocked by an explicit regex.
|
|
//
|
|
// Public API:
|
|
// db_ensure_migrations_table(PDO $pdo): void
|
|
// migrations_dir(): string — host repo migrations dir
|
|
// migrations_list_files(): array — basenames, sorted
|
|
// migrations_status(PDO $pdo): array — [{filename, applied_at|null}]
|
|
// migrations_apply_file(PDO, string $filename, ?int $userId): array
|
|
// migrations_mark_all_applied(PDO, ?int $userId): int
|
|
// migrations_split_sql(string $sql): array — string/comment-aware splitter
|
|
//
|
|
// Migrations directory resolution:
|
|
// 1. $GLOBALS['MIGRATIONS_DIR'] if set (set this in the stub for non-standard layouts)
|
|
// 2. Otherwise: dirname(__DIR__, 2) . '/database'
|
|
// Assumes this file lives at <host_repo>/shared/platform-tools/functions_migrations.php
|
|
// → resolves to <host_repo>/database.
|
|
|
|
function migrations_dir(): string {
|
|
$dir = $GLOBALS['MIGRATIONS_DIR'] ?? (dirname(__DIR__, 2) . '/database');
|
|
return rtrim($dir, '/');
|
|
}
|
|
|
|
function db_ensure_migrations_table(PDO $pdo): void {
|
|
// FK on applied_by is intentionally omitted: tenants vary in whether
|
|
// they have a `users` table by this name. The application enforces
|
|
// referential semantics already (we only ever insert the caller's
|
|
// user_id; deletion of that user is rare and orphan rows are harmless).
|
|
$pdo->exec(
|
|
"CREATE TABLE IF NOT EXISTS `applied_migrations` (
|
|
`filename` VARCHAR(150) NOT NULL,
|
|
`applied_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
`applied_by` INT DEFAULT NULL,
|
|
PRIMARY KEY (`filename`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
|
);
|
|
}
|
|
|
|
function migrations_list_files(): array {
|
|
$files = glob(migrations_dir() . '/migration_*.sql') ?: [];
|
|
sort($files);
|
|
return array_map('basename', $files);
|
|
}
|
|
|
|
function migrations_status(PDO $pdo): array {
|
|
db_ensure_migrations_table($pdo);
|
|
$applied = $pdo->query("SELECT filename, applied_at FROM applied_migrations")
|
|
->fetchAll(PDO::FETCH_KEY_PAIR);
|
|
$applied = $applied ?: [];
|
|
$rows = [];
|
|
foreach (migrations_list_files() as $file) {
|
|
$rows[] = [
|
|
'filename' => $file,
|
|
'applied_at' => $applied[$file] ?? null,
|
|
];
|
|
}
|
|
return $rows;
|
|
}
|
|
|
|
// Split a SQL blob into individual statements, respecting strings, escapes,
|
|
// line comments (-- … and # …) and block comments (/* … */). Comments are
|
|
// recognised inside the same state machine that tracks string literals, so a
|
|
// value like 'use -- with care' or 'a /* b */ c' passes through untouched.
|
|
// DELIMITER changes are not supported (project migrations don't use stored
|
|
// procedures).
|
|
function migrations_split_sql(string $sql): array {
|
|
$statements = [];
|
|
$current = '';
|
|
$in_string = false;
|
|
$quote = null;
|
|
$len = strlen($sql);
|
|
|
|
for ($i = 0; $i < $len; $i++) {
|
|
$ch = $sql[$i];
|
|
|
|
if ($in_string) {
|
|
// Backslash escapes apply inside '…' and "…", never inside `…`.
|
|
if ($ch === '\\' && $quote !== '`' && $i + 1 < $len) {
|
|
$current .= $ch . $sql[$i + 1];
|
|
$i++;
|
|
continue;
|
|
}
|
|
if ($ch === $quote) {
|
|
if ($i + 1 < $len && $sql[$i + 1] === $quote) {
|
|
$current .= $ch . $sql[$i + 1];
|
|
$i++;
|
|
continue;
|
|
}
|
|
$in_string = false;
|
|
$quote = null;
|
|
}
|
|
$current .= $ch;
|
|
continue;
|
|
}
|
|
|
|
// Block comment /* … */ — dropped (including /*! … */ version hints).
|
|
if ($ch === '/' && $i + 1 < $len && $sql[$i + 1] === '*') {
|
|
$end = strpos($sql, '*/', $i + 2);
|
|
$i = ($end === false) ? $len : $end + 1;
|
|
continue;
|
|
}
|
|
|
|
// Line comment: `#`, or `--` followed by whitespace/EOL (MySQL rule —
|
|
// `a--b` is arithmetic, not a comment).
|
|
if ($ch === '#'
|
|
|| ($ch === '-' && $i + 1 < $len && $sql[$i + 1] === '-'
|
|
&& ($i + 2 >= $len || strpbrk($sql[$i + 2], " \t\r\n") !== false))) {
|
|
$nl = strpos($sql, "\n", $i);
|
|
if ($nl === false) break; // comment runs to end of input
|
|
$i = $nl - 1; // keep the \n as statement whitespace
|
|
continue;
|
|
}
|
|
|
|
if ($ch === "'" || $ch === '"' || $ch === '`') {
|
|
$in_string = true;
|
|
$quote = $ch;
|
|
$current .= $ch;
|
|
continue;
|
|
}
|
|
|
|
if ($ch === ';') {
|
|
$stmt = trim($current);
|
|
if ($stmt !== '') $statements[] = $stmt;
|
|
$current = '';
|
|
continue;
|
|
}
|
|
|
|
$current .= $ch;
|
|
}
|
|
|
|
$stmt = trim($current);
|
|
if ($stmt !== '') $statements[] = $stmt;
|
|
|
|
return $statements;
|
|
}
|
|
|
|
function migrations_apply_file(PDO $pdo, string $filename, ?int $userId = null): array {
|
|
if (!preg_match('/^migration_[a-z0-9_]+\.sql$/i', $filename)) {
|
|
return ['ok' => false, 'error' => 'Nome de migration inválido.'];
|
|
}
|
|
$path = migrations_dir() . '/' . $filename;
|
|
if (!is_file($path)) {
|
|
return ['ok' => false, 'error' => "Ficheiro não encontrado: {$filename}"];
|
|
}
|
|
|
|
db_ensure_migrations_table($pdo);
|
|
|
|
$stmt = $pdo->prepare("SELECT 1 FROM applied_migrations WHERE filename = ?");
|
|
$stmt->execute([$filename]);
|
|
if ($stmt->fetchColumn()) {
|
|
return ['ok' => false, 'error' => 'Já aplicada anteriormente.'];
|
|
}
|
|
|
|
$sql = file_get_contents($path);
|
|
if ($sql === false) {
|
|
return ['ok' => false, 'error' => 'Não foi possível ler o ficheiro.'];
|
|
}
|
|
|
|
$statements = migrations_split_sql($sql);
|
|
if (!$statements) {
|
|
return ['ok' => false, 'error' => 'O ficheiro não contém statements SQL.'];
|
|
}
|
|
|
|
$applied = 0;
|
|
foreach ($statements as $statement) {
|
|
try {
|
|
$pdo->exec($statement);
|
|
$applied++;
|
|
} catch (PDOException $e) {
|
|
return [
|
|
'ok' => false,
|
|
'error' => 'Erro no statement #' . ($applied + 1) . ': ' . $e->getMessage(),
|
|
'applied' => $applied,
|
|
'total' => count($statements),
|
|
];
|
|
}
|
|
}
|
|
|
|
$stmt = $pdo->prepare(
|
|
"INSERT INTO applied_migrations (filename, applied_by) VALUES (?, ?)"
|
|
);
|
|
$stmt->execute([$filename, $userId]);
|
|
|
|
return ['ok' => true, 'applied' => $applied, 'total' => count($statements)];
|
|
}
|
|
|
|
function migrations_mark_all_applied(PDO $pdo, ?int $userId = null): int {
|
|
db_ensure_migrations_table($pdo);
|
|
$existing = $pdo->query("SELECT filename FROM applied_migrations")
|
|
->fetchAll(PDO::FETCH_COLUMN);
|
|
$existing = array_flip($existing ?: []);
|
|
$stmt = $pdo->prepare(
|
|
"INSERT INTO applied_migrations (filename, applied_by) VALUES (?, ?)"
|
|
);
|
|
$count = 0;
|
|
foreach (migrations_list_files() as $file) {
|
|
if (!isset($existing[$file])) {
|
|
$stmt->execute([$file, $userId]);
|
|
$count++;
|
|
}
|
|
}
|
|
return $count;
|
|
}
|