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>
This commit is contained in:
andreandClaude Opus 5 committed 2026-09-13 23:06:23 +01:00
1 parent 0e034232e9
commit 4d40adbf64
67 files changed
+17478 -5

No files matched your search

@@ -0,0 +1,331 @@
<?php
// ─── Shared DB-backed settings + secret storage ──────────────────────────────
//
// One place for per-install configuration that must be editable from the
// backoffice rather than a file: SMTP credentials above all. fotografia and
// hub2 had already converged on the same table shape and the same AES-256-GCM
// envelope; this makes that the standard and gives silver (which had no
// settings table at all) the same thing.
//
// Table: system_settings(setting_key, setting_value, updated_by, updated_at)
//
// Secrets (smtp_password, API tokens, …) are encrypted at rest with AES-256-GCM
// under a key that lives only in .env, so a leaked database dump is not enough
// to recover them. Which keys count as secret is decided by
// platform_settings_is_secret() -- one policy for the whole estate, rather than
// a per-site list each caller has to remember to update.
//
// Public API:
// platform_settings_ensure_table(PDO)
// platform_settings_get(PDO, key, default)
// platform_settings_set(PDO, key, value, ?userId, ?encrypt) encrypt=null -> auto
// platform_settings_all(PDO, revealSecrets=false)
// platform_settings_is_secret(key): bool
// platform_settings_encrypt_existing(PDO): array -> idempotent migration
// platform_secret_encrypt(string) / platform_secret_decrypt(?string)
// platform_secret_mask(string)
// platform_smtp_config(PDO): array -> the shape functions_mail.php expects
const PLATFORM_SECRET_PREFIX = 'enc:v1:';
/**
* Key used for the settings envelope.
*
* Derivation is domain-separated ('am-secret|') so the same APP_SECRET_KEY
* cannot produce the same key material for two different purposes. Falls back
* to BACKUP_TOKEN because some installs only ever set that one.
*/
function platform_secret_key(): ?string {
if (!extension_loaded('openssl')) return null;
foreach (['APP_SECRET_KEY', 'BACKUP_TOKEN'] as $k) {
$v = defined($k) ? (string) constant($k) : '';
if ($v === '') {
$v = function_exists('env') ? (string) env($k, '') : (string) (getenv($k) ?: ($_ENV[$k] ?? ''));
}
if ($v !== '' && $v !== 'replace_me') {
return hash('sha256', 'am-secret|' . $v, true);
}
}
return null;
}
function platform_secret_encrypt(string $plain): string {
if ($plain === '') return '';
$key = platform_secret_key();
// No key: store as-is rather than losing the value. The caller is warned by
// platform_settings_secret_available().
if ($key === null) return $plain;
$iv = random_bytes(12); // GCM standard nonce
$tag = '';
$ct = openssl_encrypt($plain, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag);
if ($ct === false) return $plain;
return PLATFORM_SECRET_PREFIX . base64_encode($iv . $tag . $ct);
}
function platform_secret_decrypt(?string $stored): string {
$stored = (string) $stored;
if ($stored === '' || !str_starts_with($stored, PLATFORM_SECRET_PREFIX)) return $stored;
$raw = base64_decode(substr($stored, strlen(PLATFORM_SECRET_PREFIX)), true);
if ($raw === false || strlen($raw) < 29) return '';
$iv = substr($raw, 0, 12); $tag = substr($raw, 12, 16); $ct = substr($raw, 28);
$key = platform_secret_key();
if ($key !== null) {
$out = openssl_decrypt($ct, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag);
if ($out !== false) return $out;
}
error_log('[settings] encrypted value present but the key does not decrypt it -- has APP_SECRET_KEY changed?');
return '';
}
function platform_settings_secret_available(): bool {
return platform_secret_key() !== null;
}
/** Creates the table when missing. Safe to call on every request. */
function platform_settings_ensure_table(PDO $pdo): void {
$pdo->exec(
"CREATE TABLE IF NOT EXISTS `system_settings` (
`setting_key` VARCHAR(100) NOT NULL,
`setting_value` TEXT NULL,
`updated_by` INT NULL,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`setting_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
}
/**
* Per-request read cache, shared by get() and invalidated by set().
*
* Returned by reference so the writers can actually mutate it. It used to be a
* `static` local inside platform_settings_get(), which nothing else could
* reach: a page that read a setting, wrote it, then read it back got the value
* from before its own write. The setup page does exactly that -- save, then
* re-check whether the platform is configured -- and would have reported the
* gate unsatisfied immediately after satisfying it.
*/
function &platform_settings_cache(): array {
static $cache = [];
return $cache;
}
/** Drop one key, or the whole cache when $key is null. */
function platform_settings_cache_clear(?string $key = null): void {
$cache = &platform_settings_cache();
if ($key === null) { $cache = []; return; }
unset($cache[$key]);
}
function platform_settings_get(PDO $pdo, string $key, $default = '') {
$cache = &platform_settings_cache();
if (array_key_exists($key, $cache)) return $cache[$key];
try {
$st = $pdo->prepare("SELECT setting_value FROM system_settings WHERE setting_key = ? LIMIT 1");
$st->execute([$key]);
$v = $st->fetchColumn();
$st->closeCursor();
} catch (Throwable $e) {
return $default; // table absent on a host that has not run ensure_table yet
}
if ($v === false || $v === null) return $cache[$key] = $default;
return $cache[$key] = platform_secret_decrypt((string) $v);
}
/**
* Is this setting a secret, and therefore encrypted at rest?
*
* The decision lives here, not in the callers. Each site used to keep its own
* list -- fotografia had ['smtp_password','twilio_token'] hardcoded in
* comms.php, hub2 encrypted only smtp_password -- so the same key could be
* ciphertext on one install and plaintext on another, and a newly added
* credential was secret only if whoever added it remembered.
*
* Suffix rules cover new keys automatically; the exact list catches the ones
* whose names do not follow the convention.
*/
function platform_settings_is_secret(string $key): bool {
static $exact = [
'smtp_password', 'twilio_token', 'twilio_auth_token',
'ig_app_secret', 'ig_access_token', 'nextcloud_share_token',
'hub_ops_secret', 'hub_content_secret', 'antispam_secret',
'notify_api_key',
];
$k = strtolower(trim($key));
if (in_array($k, $exact, true)) return true;
foreach (['_password', '_secret', '_token', '_api_key', '_apikey', '_private_key'] as $suffix) {
if (str_ends_with($k, $suffix)) return true;
}
return false;
}
/**
* Write a setting.
*
* $encrypt defaults to null, meaning "decide from the key name". Pass true or
* false only to override that deliberately -- the old signature took a plain
* bool defaulting to false, so any existing caller passing true still forces
* encryption and one passing false still forces plaintext.
*/
function platform_settings_set(PDO $pdo, string $key, string $value, ?int $userId = null, ?bool $encrypt = null): void {
platform_settings_ensure_table($pdo);
$wantEncrypted = $encrypt ?? platform_settings_is_secret($key);
if (!$wantEncrypted || $value === '') {
// An empty secret is stored empty, not as ciphertext of "": callers use
// '' to mean "cleared", and encrypting it would hide that.
$stored = $value;
} elseif (str_starts_with($value, PLATFORM_SECRET_PREFIX)) {
// Already encrypted by a caller that does its own crypto (hub2 and
// fotografia both do). Wrapping it again would make it undecryptable.
$stored = $value;
} else {
$stored = platform_secret_encrypt($value);
if (!str_starts_with($stored, PLATFORM_SECRET_PREFIX)) {
// platform_secret_encrypt falls back to returning the plaintext when
// no key is configured. Storing it is still better than losing the
// value, but it must not happen quietly.
error_log("[settings] '{$key}' stored UNENCRYPTED: no APP_SECRET_KEY/BACKUP_TOKEN available");
}
}
$st = $pdo->prepare(
"INSERT INTO system_settings (setting_key, setting_value, updated_by)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), updated_by = VALUES(updated_by)"
);
$st->execute([$key, $stored, $userId]);
// Without this the caller reads its own stale value for the rest of the
// request -- including "did that save work?", which is the one question a
// save is usually followed by.
platform_settings_cache_clear($key);
}
/**
* Renders a secret for display.
*
* An API key is shown as "amk_1a2b…9f4c" because operators identify keys by
* their prefix. A password gets no prefix at all -- there is nothing to
* recognise, and eight leading characters in a screenshot or a support ticket
* is a real leak.
*/
function platform_secret_mask(string $plain, string $key = ''): string {
if ($plain === '') return '—';
$isPassword = $key === '' || str_ends_with(strtolower($key), '_password');
if ($isPassword) return '•••••••• (definida)';
if (strlen($plain) <= 12) return str_repeat('•', strlen($plain));
return substr($plain, 0, 8) . '…' . substr($plain, -4);
}
/**
* Every setting, decrypted. Secrets are masked unless $revealSecrets is true,
* so the common cases -- an admin listing, a diagnostics dump -- cannot leak a
* credential into a page or a log by accident.
*/
function platform_settings_all(PDO $pdo, bool $revealSecrets = false): array {
platform_settings_ensure_table($pdo);
$out = [];
$st = $pdo->query("SELECT setting_key, setting_value FROM system_settings ORDER BY setting_key");
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $row) {
$key = (string) $row['setting_key'];
$plain = platform_secret_decrypt((string) $row['setting_value']);
$out[$key] = (!$revealSecrets && platform_settings_is_secret($key))
? platform_secret_mask($plain, $key)
: $plain;
}
return $out;
}
/**
* Encrypt any secret-named row still sitting in plaintext.
*
* Idempotent: rows already carrying the enc:v1: prefix are skipped, so this is
* safe to run on every deploy. Returns ['encrypted'=>[], 'skipped'=>[], 'failed'=>[]].
*/
function platform_settings_encrypt_existing(PDO $pdo): array {
platform_settings_ensure_table($pdo);
$report = ['encrypted' => [], 'skipped' => [], 'failed' => []];
if (!platform_settings_secret_available()) {
$report['failed'][] = 'no encryption key (APP_SECRET_KEY / BACKUP_TOKEN)';
return $report;
}
$rows = $pdo->query("SELECT setting_key, setting_value FROM system_settings")->fetchAll(PDO::FETCH_ASSOC);
$upd = $pdo->prepare("UPDATE system_settings SET setting_value = ? WHERE setting_key = ?");
foreach ($rows as $row) {
$key = (string) $row['setting_key'];
$value = (string) $row['setting_value'];
if (!platform_settings_is_secret($key)) continue;
if ($value === '') { $report['skipped'][] = "{$key} (empty)"; continue; }
if (str_starts_with($value, PLATFORM_SECRET_PREFIX)) { $report['skipped'][] = "{$key} (already encrypted)"; continue; }
$enc = platform_secret_encrypt($value);
if (!str_starts_with($enc, PLATFORM_SECRET_PREFIX)) { $report['failed'][] = $key; continue; }
$upd->execute([$enc, $key]);
platform_settings_cache_clear($key);
$report['encrypted'][] = $key;
}
return $report;
}
/**
* The standard SMTP settings, read from the database with the .env value as
* fallback.
*
* Precedence is deliberate: a value set in the backoffice wins, because that is
* the one an operator can change without shell access. .env remains useful for
* bootstrapping an install before anyone can log in.
*
* Returns exactly the shape platform_mail_send()'s $override expects.
*/
function platform_smtp_config(PDO $pdo): array {
$env = function (string $k, string $d = '') {
if (defined($k)) { $v = (string) constant($k); if ($v !== '') return $v; }
if (function_exists('env')) { $v = (string) env($k, ''); if ($v !== '') return $v; }
$v = getenv($k); if ($v === false || $v === '') $v = (string) ($_ENV[$k] ?? '');
return $v !== '' ? $v : $d;
};
return [
'host' => platform_settings_get($pdo, 'smtp_host', $env('SMTP_HOST')),
'port' => (int) platform_settings_get($pdo, 'smtp_port', $env('SMTP_PORT', '587')),
'username' => platform_settings_get($pdo, 'smtp_username', $env('SMTP_USERNAME')),
'password' => platform_settings_get($pdo, 'smtp_password', $env('SMTP_PASSWORD')),
'secure' => platform_settings_get($pdo, 'smtp_secure', $env('SMTP_SECURE', 'tls')),
'from' => platform_settings_get($pdo, 'email_from', $env('EMAIL_FROM')),
'from_name' => platform_settings_get($pdo, 'email_from_name', $env('EMAIL_FROM_NAME')),
];
}
/**
* URL of a page in this platform's backoffice.
*
* The shared pages and API endpoints used to hardcode `views/sysadmin/...`,
* which is right for four of the five platforms and wrong for design, whose
* backoffice lives at `views/admin/`. Clicking "Backup da base de dados" there
* ran the backup and then redirected to a page that does not exist -- the work
* succeeded and the operator saw a 404, which is the most confusing pair of
* outcomes available.
*
* A site overrides the segment by defining PLATFORM_ADMIN_DIR (or setting it in
* .env) to e.g. 'views/admin'. Default keeps the existing behaviour.
*/
function platform_admin_url(string $page = ''): string {
$dir = 'views/sysadmin';
if (defined('PLATFORM_ADMIN_DIR') && constant('PLATFORM_ADMIN_DIR') !== '') {
$dir = (string) constant('PLATFORM_ADMIN_DIR');
} elseif (function_exists('env') && (string) env('PLATFORM_ADMIN_DIR', '') !== '') {
$dir = (string) env('PLATFORM_ADMIN_DIR', '');
} elseif (($e = getenv('PLATFORM_ADMIN_DIR')) !== false && $e !== '') {
$dir = $e;
}
$path = trim($dir, '/') . ($page !== '' ? '/' . ltrim($page, '/') : '');
return function_exists('base_url') ? base_url($path) : '/' . $path;
}