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:
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;
|
||||
}
|
||||
Reference in new issue
Block a user