Deploying the local-SMTP commit took fitme3070.pt down:
PHP Warning: require_once(.../shared/platform-tools/compat_secrets.php):
Failed to open stream: No such file or directory
in .../config/functions_secrets.php on line 20
config/functions_secrets.php is loaded on every request, so a hard require_once
on a submodule file is a site-wide fatal — public pages included — whenever
shared/platform-tools lags the code that needs it. It lagged: the server is
still on 603ff84, which predates compat_secrets.php, because "Atualizar
submódulo" was itself broken (fixed in 3626080, which the site had to be up to
deploy). A missing file should not be able to close that loop.
Three requires hardened, each to the failure the caller can actually report:
- config/functions_secrets.php: falls back to plaintext passthrough helpers
and logs loudly. secrets_available() then returns false, which is exactly
what the Comunicações page already renders a warning banner for — so
nothing silently pretends to be encrypted while the site keeps serving.
- config/functions_queue.php (_flush_email): throws instead of fataling. A
fatal killed the request outright, so flush_message()'s catch never ran and
the row sat in 'sending' forever with no explanation. It now lands in
last_error where the Monitor page shows it.
- views/sysadmin/comms.php: renders "Submódulo desactualizado" naming the
missing files, rather than a blank 500 on the page you would open to
diagnose mail in the first place.
Verified by loading config/functions_secrets.php with no shared/ directory
present: the bootstrap completes, the warning is logged once, secrets_available()
is false, and setting_secret()/setting_or_env() still return usable values.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
89 lines
5.0 KiB
PHP
89 lines
5.0 KiB
PHP
<?php
|
|
// ─── Secrets stored in system_settings ───────────────────────────────────────
|
|
//
|
|
// SMTP and Twilio credentials are configurable from the backoffice, which means
|
|
// they live in the database. That is a real tradeoff: the nightly job in
|
|
// cron.php keeps 30 database backups, so a plaintext password would sit in
|
|
// every one of them, and anyone who can read a dump would have the mail account.
|
|
//
|
|
// So secret-valued settings are encrypted at rest with AES-256-GCM. The key
|
|
// never goes in the database — it comes from .env, which is not in git and not
|
|
// in the DB dump. A dump on its own is therefore useless for recovering them.
|
|
//
|
|
// The cipher itself is no longer implemented here. It was one of two copies —
|
|
// hub2 had the same AES-256-GCM with a different key derivation, so a value
|
|
// written by one site would not open on the other. There is now a single
|
|
// implementation in shared/platform-tools; this file re-exports the short names
|
|
// (secret_encrypt, secret_decrypt, secret_is_encrypted, secret_mask,
|
|
// secrets_available, SECRET_PREFIX) that the sysadmin pages already call.
|
|
|
|
// Loaded on every request, so a hard require_once here takes the entire site
|
|
// down — public pages included — whenever shared/platform-tools is older than
|
|
// the code that needs it. That is not hypothetical: this install is deployed by
|
|
// uploading files, and the submodule lags behind the rest of the tree every
|
|
// time "Atualizar submódulo" fails. It did, and the site 500'd on a missing
|
|
// compat_secrets.php with nothing but a PHP warning to explain it.
|
|
//
|
|
// So: degrade instead. Without the shared implementation the site keeps
|
|
// serving, secrets are stored as-is, and secrets_available() reports false —
|
|
// which is what the Comunicações page already renders a warning banner for.
|
|
// Nothing silently pretends to be encrypted.
|
|
$_compat_secrets = dirname(__DIR__) . '/shared/platform-tools/compat_secrets.php';
|
|
if (is_file($_compat_secrets)) {
|
|
require_once $_compat_secrets;
|
|
} else {
|
|
error_log(
|
|
'[secrets] shared/platform-tools/compat_secrets.php is missing — the submodule is '
|
|
. 'behind the pinned commit. Settings secrets are NOT encrypted until it is updated.'
|
|
);
|
|
if (!defined('SECRET_PREFIX')) define('SECRET_PREFIX', 'enc:v1:');
|
|
if (!function_exists('secret_encrypt')) { function secret_encrypt(string $plain): string { return $plain; } }
|
|
if (!function_exists('secret_decrypt')) { function secret_decrypt(?string $stored): string { return (string) $stored; } }
|
|
if (!function_exists('secret_is_encrypted')){ function secret_is_encrypted(string $stored): bool { return str_starts_with($stored, SECRET_PREFIX); } }
|
|
if (!function_exists('secret_mask')) { function secret_mask(string $plain, string $key = ''): string { return $plain === '' ? '' : '••••••••'; } }
|
|
if (!function_exists('secrets_available')) { function secrets_available(): bool { return false; } }
|
|
if (!function_exists('secrets_key_available')) { function secrets_key_available(): bool { return false; } }
|
|
// Read paths used by the SMTP resolution below and by the Comunicações page.
|
|
if (!function_exists('platform_settings_secret_available')) { function platform_settings_secret_available(): bool { return false; } }
|
|
if (!function_exists('platform_settings_is_secret')) {
|
|
function platform_settings_is_secret(string $key): bool {
|
|
return (bool) preg_match('/_(password|secret|token|api_key)$/', $key);
|
|
}
|
|
}
|
|
}
|
|
unset($_compat_secrets);
|
|
|
|
// ─── Read helpers (site-specific, not crypto) ────────────────────────────────
|
|
//
|
|
// NOTE: these give .env precedence over the database, which is the opposite of
|
|
// platform_settings_get(). Deliberate here — an install can pin credentials in
|
|
// the environment and ignore the backoffice entirely — but it is a difference
|
|
// worth remembering when comparing behaviour with the other sites.
|
|
|
|
/**
|
|
* Read a secret-valued setting, decrypted.
|
|
*
|
|
* `.env` still wins when set, so an install can keep pinning credentials in the
|
|
* environment and ignore the DB entirely — that is the safer configuration and
|
|
* remains supported.
|
|
*/
|
|
function setting_secret(string $key, string $envKey = '', string $default = ''): string {
|
|
if ($envKey !== '') {
|
|
$fromEnv = (string) env($envKey, '');
|
|
if ($fromEnv !== '') return $fromEnv;
|
|
}
|
|
$stored = (string) setting($key, '');
|
|
return $stored !== '' ? secret_decrypt($stored) : $default;
|
|
}
|
|
|
|
/**
|
|
* Read a non-secret setting that may also be pinned in .env.
|
|
* Same precedence as setting_secret(), without the crypto.
|
|
*/
|
|
function setting_or_env(string $key, string $envKey, string $default = ''): string {
|
|
$fromEnv = (string) env($envKey, '');
|
|
if ($fromEnv !== '') return $fromEnv;
|
|
$stored = (string) setting($key, '');
|
|
return $stored !== '' ? $stored : $default;
|
|
}
|