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>
332 lines
14 KiB
PHP
332 lines
14 KiB
PHP
<?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;
|
|
}
|