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. * * A site on the front controller (`'router' => true` in platform_connect()) * gets platform.php?tool=… for every shared page; its own pages keep the dir. */ function platform_admin_url(string $page = ''): string { if (function_exists('platform_router_enabled') && platform_router_enabled()) { [$file, $query] = array_pad(explode('?', $page, 2), 2, ''); $tool = $file === '' ? 'index' : preg_replace('/\.php$/', '', basename($file)); if (isset(platform_routes()[$tool])) return platform_route_url($tool, $query); } $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; } /** * A stable HMAC key for a named purpose. * * Signed form tokens (anti-spam stamps, captcha challenges) are issued in one * request and verified in another, so the key behind them must be **the same * across requests** -- a per-request key rejects every visitor with "invalid * token" and the failure looks like a bot problem rather than a bug. * * Three sources, in order, all stable: * * 1. platform_secret_key() -- the .env key, the normal case; * 2. a random value stored in system_settings, when there is no .env key; * 3. derived from the install's own configuration, for an install with * neither. It is a weaker secret than the other two, but it is constant, * and a defence that locks every form is worse than a weaker signature. * * Always domain-separated by $purpose, so a stamp minted for one feature can * never validate in another. Note that functions_antispam_forms.php keeps its * own older derivation on purpose: changing the key of a live site invalidates * the forms that are open at that instant, and those submissions are refused * silently -- leads lost for the sake of tidiness. New callers use this one. * * Pass the PDO wherever you have one. Source 3 is a last resort, and a site * that reaches it from one call site with a PDO and another without would sign * with two different keys. */ function platform_derived_key(string $purpose, ?PDO $pdo = null, string $settingKey = 'shared_hmac_key'): string { static $bases = []; if (!isset($bases[$settingKey])) { $base = ''; $envKey = platform_secret_key(); if ($envKey !== null) { $base = bin2hex($envKey); } elseif ($pdo instanceof PDO) { $base = (string) platform_settings_get($pdo, $settingKey, ''); if ($base === '') { $fresh = bin2hex(random_bytes(32)); try { // Stored in the clear on purpose: encrypting it would need // the key we do not have (that is why we are here), and a // value that decrypts differently on each read means every // token minted before this request stops verifying. platform_settings_set($pdo, $settingKey, $fresh, null, false); $base = $fresh; } catch (Throwable $e) { error_log('[settings] ' . $settingKey . ' not persisted (' . $e->getMessage() . '); deriving from install config'); } } } if ($base === '') { $read = static function (string $k): string { if (defined($k)) return (string) constant($k); if (function_exists('env')) return (string) env($k, ''); return (string) (getenv($k) ?: ($_ENV[$k] ?? '')); }; // Not cached: this branch is the fallback, and a later call that // does carry a PDO must be free to find the real key instead of // inheriting this one. return hash('sha256', 'am-key|' . $purpose . '|derived|' . $read('DB_NAME') . '|' . $read('DB_USER') . '|' . $read('DB_PASS') . '|' . $read('APP_BASE_URL'), true); } $bases[$settingKey] = $base; } return hash('sha256', 'am-key|' . $purpose . '|' . $bases[$settingKey], true); }