mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-16 16:45:07 +00:00
9af0d643b1
MailConfigApplier and ExternalStorageConfigApplier read their settings through the `encrypted` casts -- decrypted -- and wrote the result into the cache store with rememberForever(). The SMTP password, the S3 secret access key and the whole GCS service account key file, private key included, went in as plain text under a key that never expires. The cache store encrypts nothing. On the store INSTALL.md documents for a manual install (CACHE_STORE=database) and config/cache.php defaults to, that is the `cache` table of the same database whose dump the `encrypted` cast exists to survive. On redis it is the redis dump. The rule already exists, two files away. MailOAuthConnection states it: Transports read this row fresh at send time -- tokens must never travel through the boot-config cache (see MailConfigApplier, which caches only readiness and the account address). MailConfigApplier's own cache-key comment says the same thing about the same array: what is deliberately NOT in the cached shape is tokens, because neither readiness nor an address is a credential. The SMTP password was in it anyway. SocialSettings::available() names both classes outright as making the mistake. So the credentials are read the way the tokens already are: from the row, at the point that uses them. The cached array keeps everything that is not a credential, and each applier reads its secret inside the branch that configures a transport -- an installation on OAuth, on cloud, or one that has never opened the Email or Storage screen reads nothing extra. BootSettingsCache grows a second entry point rather than the callers restating its rule. The cached read already survives a database with no tables, because booting must not require this application's own database; an uncached credential read on the same path needs exactly that guarantee and nothing else, since resolve() can hand back a warm "configured" from a database that has since stopped answering. Both cache keys are bumped, as their comments require on a shape change. Tests: five for the absence, two of them against the database cache store read as the raw rows an operator would find in a dump, since phpunit.xml runs the suite on the array store and the cache path was structurally invisible -- which is why GoogleCloudStorageTest could assert that the private key is not in the column while it sat in the cache. All five were run against the unfixed appliers and fail there. The three "still configures what it no longer caches" tests deliberately pass either way: they pin the behaviour the fix must not break.
114 lines
4.3 KiB
PHP
114 lines
4.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Modules\Platform\Settings;
|
|
|
|
use Closure;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Log;
|
|
use PDOException;
|
|
|
|
/**
|
|
* Cache::rememberForever() for the handful of settings that are read while
|
|
* the application boots, rather than while it serves a request.
|
|
*
|
|
* The rule it exists to enforce: **booting must never require this
|
|
* application's own database.** Serving a request may — nothing useful can
|
|
* happen without the database anyway — but booting must not, because the
|
|
* commands that create the database in the first place have to boot too.
|
|
*
|
|
* Without this, a manual install is impossible on any cache store that
|
|
* lives in the database. `PlatformServiceProvider::boot()` applies the
|
|
* stored mail and storage settings on every process start; on the database
|
|
* cache store that read is itself a query against a `cache` table that
|
|
* `php artisan migrate` has not created yet, so the very first command
|
|
* INSTALL.md asks for — `php artisan key:generate` — dies with "Table
|
|
* 'cache' doesn't exist", and so does `migrate`, the one command that would
|
|
* fix it. The application cannot be installed at all. Same for a database
|
|
* that simply is not reachable yet: `key:generate` should not need one.
|
|
*
|
|
* The failure is swallowed rather than cached, so nothing has to be flushed
|
|
* once the install completes — the next boot simply reads for real.
|
|
*
|
|
* Deliberately NOT used by request-path settings readers (Settings,
|
|
* EmailTemplateResolver). A database failure there should stay loud: those
|
|
* run long after the install, where "quietly fell back to defaults" hides a
|
|
* real outage instead of enabling a legitimate first run.
|
|
*
|
|
* Two entry points, same rule. rememberForever() is for values worth
|
|
* keeping; read() is for the ones that must not be kept — a credential
|
|
* read on the boot path needs the identical "the database may not answer
|
|
* yet" guarantee, and stating it twice is how the two drift apart.
|
|
*/
|
|
final class BootSettingsCache
|
|
{
|
|
private static bool $warned = false;
|
|
|
|
/**
|
|
* @template TValue of array<array-key, mixed>
|
|
*
|
|
* @param Closure(): TValue $read Reads the real value from the database.
|
|
* @param TValue $whenUnavailable Returned as-is when the database cannot answer.
|
|
* @return TValue
|
|
*/
|
|
public static function rememberForever(string $key, Closure $read, array $whenUnavailable): array
|
|
{
|
|
try {
|
|
return Cache::rememberForever($key, $read);
|
|
} catch (PDOException $e) {
|
|
// QueryException extends PDOException, so this covers both a
|
|
// missing table and a connection that could not be opened.
|
|
self::warnOnce($key, $e);
|
|
|
|
return $whenUnavailable;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The same protection for a value that is deliberately *not* cached.
|
|
*
|
|
* A credential must not sit in the cache store, so it is read on each
|
|
* boot that actually needs it — but that read lands on the same path
|
|
* as the cached ones and must survive the same missing database. The
|
|
* caller has already been handed a cached array saying the feature is
|
|
* configured; that array can be warm while the database is, right now,
|
|
* unreachable.
|
|
*
|
|
* @template TValue
|
|
*
|
|
* @param Closure(): TValue $read Reads the real value from the database.
|
|
* @param TValue $whenUnavailable Returned as-is when the database cannot answer.
|
|
* @return TValue
|
|
*/
|
|
public static function read(Closure $read, mixed $whenUnavailable = null): mixed
|
|
{
|
|
try {
|
|
return $read();
|
|
} catch (PDOException $e) {
|
|
self::warnOnce('(uncached credential read)', $e);
|
|
|
|
return $whenUnavailable;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Once per process: an install that has not been migrated yet would
|
|
* otherwise log this on every artisan command, and a genuine database
|
|
* outage would log it on every request.
|
|
*/
|
|
private static function warnOnce(string $key, PDOException $e): void
|
|
{
|
|
if (self::$warned) {
|
|
return;
|
|
}
|
|
|
|
self::$warned = true;
|
|
|
|
Log::warning('Falling back to default settings: the database could not be read during boot.', [
|
|
'key' => $key,
|
|
'reason' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|