mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-23 20:06:20 +00:00
4905be8e32
Reported by @veenone (#1788). Setting "folder inside the bucket" on S3 or an S3-compatible backend made every page that touches storage answer 500, including the orphan-files screen. Reproduced against MinIO: the disk resolved into Class "League\Flysystem\PathPrefixing\PathPrefixedAdapter" not found One folder on the screen, two names underneath: Laravel's own drivers read `root`, and this application's GCS driver reads `prefix` and builds its adapter with it. Setting both looked like a harmless way to serve both, and was not — FilesystemManager wraps any disk carrying a non-empty `prefix` in that adapter, which lives in an optional package nobody installs. So the key meant for GCS broke S3, and only once somebody set a folder. `prefix` now goes to the GCS driver alone, and both keys are written on every apply rather than only when there is a folder — a process that switched provider or cleared the field kept a stale one otherwise. Verified against MinIO with a folder set: the orphans screen loads, and an upload lands inside the folder rather than at the root of the bucket.
237 lines
11 KiB
PHP
237 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Modules\Platform\Settings;
|
|
|
|
use App\Modules\Files\Storage\ResolvingUploadDisk;
|
|
use App\Modules\Platform\Capabilities\Capability;
|
|
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Config;
|
|
use Illuminate\Support\Facades\Schema;
|
|
|
|
/**
|
|
* Overrides the inert 'files_external' disk stub (config/filesystems.php)
|
|
* with the admin-configured ExternalStorageSettings row, when one exists
|
|
* and is fully filled in — otherwise config/filesystems.php's blank
|
|
* defaults stand untouched (fresh installs, or any install that hasn't
|
|
* visited the Storage settings page yet, behave exactly as before this
|
|
* feature existed).
|
|
*
|
|
* Community-only (Capability::StorageConfigure) — cloud operates its own
|
|
* S3 and must never honor a stored bucket/credentials, even a stray one
|
|
* left over from a downgrade. That check is deliberately made fresh on
|
|
* every call, outside the cached resolve() below (same shape as
|
|
* MailConfigApplier) — resolve()'s cache only ever holds the
|
|
* edition-independent fact of what's stored in the DB row. Baking the
|
|
* capability check into the cached value instead would let a value
|
|
* cached while running as Community keep applying after a switch to
|
|
* Cloud, since rememberForever() never expires and the only thing that
|
|
* calls flush() is saving the settings form — an edition change on its
|
|
* own wouldn't invalidate it.
|
|
*
|
|
* Called on every process boot (PlatformServiceProvider::boot(), so both
|
|
* web requests and a freshly (re)started queue worker pick it up) and
|
|
* once more immediately after a save. Also the ResolvingUploadDisk
|
|
* listener registered from PlatformServiceProvider::boot() — the only
|
|
* thing that ever redirects a new upload away from the local 'files'
|
|
* disk (see docs/extension-points-architecture.md for why this is an
|
|
* event listener rather than an interface binding).
|
|
*/
|
|
class ExternalStorageConfigApplier
|
|
{
|
|
// Bumped on any shape change to the resolved array below — a stale
|
|
// rememberForever value under an old key would otherwise crash every
|
|
// boot with "Undefined array key" (apply() calls resolve() unconditionally).
|
|
// v3: the S3 secret and the GCS key file left the shape. The cache
|
|
// store encrypts nothing and rememberForever never expires, so on the
|
|
// documented CACHE_STORE=database they sat in clear — the service
|
|
// account's private key included — in the same database whose dump
|
|
// the `encrypted` cast exists to survive. Both are now read straight
|
|
// from the row, by the provider branch that uses them.
|
|
// v4: use_instance_role joined the shape. Not a credential — it is
|
|
// the fact that there isn't one — so it caches like the rest.
|
|
private const CACHE_KEY = 'platform.external_storage_settings.v4';
|
|
|
|
public function __construct(
|
|
private readonly CapabilityRegistry $capabilities,
|
|
) {}
|
|
|
|
public function apply(): void
|
|
{
|
|
if (! $this->isActive()) {
|
|
return;
|
|
}
|
|
|
|
$resolved = $this->resolve();
|
|
$provider = StorageProvider::from($resolved['provider']);
|
|
|
|
// The driver is part of what gets overwritten, not a constant:
|
|
// config/filesystems.php ships the disk as an inert 's3' stub, and
|
|
// this is the only thing that ever makes it anything else.
|
|
Config::set('filesystems.disks.files_external.driver', $provider->driver());
|
|
Config::set('filesystems.disks.files_external.bucket', $resolved['bucket']);
|
|
|
|
match ($provider) {
|
|
StorageProvider::S3 => $this->applyS3($resolved),
|
|
// No $resolved: everything GCS needs from the row is the key
|
|
// file, and that is a credential the cache no longer holds.
|
|
StorageProvider::Gcs => $this->applyGcs(),
|
|
};
|
|
|
|
// One "folder inside the bucket" on the screen, two names in the
|
|
// adapters: Laravel's own drivers read 'root', and the GCS driver
|
|
// in this application reads 'prefix' and builds its adapter with
|
|
// it. Setting both looked harmless and was not — Laravel's
|
|
// FilesystemManager wraps any disk carrying a non-empty 'prefix'
|
|
// in League\Flysystem\PathPrefixing\PathPrefixedAdapter, which
|
|
// lives in an optional package nobody installs, so an S3 disk with
|
|
// a folder set died with "class not found" the moment it was
|
|
// touched (#1788). Only the driver that reads it gets it.
|
|
//
|
|
// Written on every apply, not only when there is a folder, so a
|
|
// process that switched provider or cleared the folder cannot keep
|
|
// a stale one: config lives for the length of the process.
|
|
Config::set('filesystems.disks.files_external.root', $resolved['root']);
|
|
Config::set(
|
|
'filesystems.disks.files_external.prefix',
|
|
$provider === StorageProvider::Gcs ? $resolved['root'] : null,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $resolved
|
|
*/
|
|
private function applyS3(array $resolved): void
|
|
{
|
|
// Left null on purpose when the machine's own role is doing the
|
|
// authenticating. Laravel's FilesystemManager::formatS3Config()
|
|
// only builds a `credentials` entry when both a key and a secret
|
|
// are non-empty, and the AWS SDK falls back to its default
|
|
// credential provider chain — ECS task role, EC2 instance
|
|
// profile, EKS/IRSA, environment — whenever none is supplied.
|
|
// Passing an empty string instead of nothing would be a
|
|
// credential, and would fail rather than fall through.
|
|
Config::set('filesystems.disks.files_external.key', $resolved['use_instance_role'] ? null : $resolved['key']);
|
|
Config::set('filesystems.disks.files_external.secret', $resolved['use_instance_role'] ? null : $this->credential('secret'));
|
|
Config::set('filesystems.disks.files_external.region', $resolved['region']);
|
|
Config::set('filesystems.disks.files_external.endpoint', $resolved['endpoint']);
|
|
Config::set('filesystems.disks.files_external.use_path_style_endpoint', $resolved['use_path_style']);
|
|
}
|
|
|
|
private function applyGcs(): void
|
|
{
|
|
// Decoded here rather than stored decoded: the column holds the
|
|
// key file verbatim, exactly as Google issued it, so that what an
|
|
// administrator pasted is what can be handed back to them and
|
|
// compared against the console.
|
|
//
|
|
// Read from the row rather than from $resolved: it is a private
|
|
// key, and the cached array no longer carries one.
|
|
$keyFile = json_decode((string) $this->credential('key_file'), true);
|
|
|
|
Config::set('filesystems.disks.files_external.key_file', is_array($keyFile) ? $keyFile : null);
|
|
|
|
// Left over from the S3 stub in config/filesystems.php, and
|
|
// meaningless to the GCS adapter — cleared rather than left
|
|
// sitting there looking like configuration.
|
|
Config::set('filesystems.disks.files_external.key', null);
|
|
Config::set('filesystems.disks.files_external.secret', null);
|
|
Config::set('filesystems.disks.files_external.endpoint', null);
|
|
}
|
|
|
|
public function flush(): void
|
|
{
|
|
Cache::forget(self::CACHE_KEY);
|
|
}
|
|
|
|
/**
|
|
* One credential column, read from the row rather than from the cache.
|
|
*
|
|
* The same rule MailOAuthConnection states for tokens — "must never
|
|
* travel through the boot-config cache" — applied to the two columns
|
|
* on this row that are credentials: the S3 secret access key and the
|
|
* GCS service account key file. Reached only from the provider branch
|
|
* that uses one, and only when isActive() has already said the disk is
|
|
* configured and permitted, so nothing is read on an installation that
|
|
* stores files locally.
|
|
*
|
|
* Guarded like the cached read beside it: resolve() can hand back a
|
|
* warm "configured" from a database that has since stopped answering,
|
|
* and booting must survive that.
|
|
*/
|
|
private function credential(string $column): ?string
|
|
{
|
|
return BootSettingsCache::read(
|
|
fn (): ?string => ExternalStorageSettings::current()->{$column},
|
|
);
|
|
}
|
|
|
|
public function resolveDisk(ResolvingUploadDisk $event): void
|
|
{
|
|
if ($this->isActive()) {
|
|
$event->disk = 'files_external';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether 'files_external' is both fully configured (the DB row) and
|
|
* permitted (the edition's capability) — the single live check every
|
|
* caller in this class needs, kept in one place. Deliberately
|
|
* uncached (see class docblock) so an edition change or a capability
|
|
* flip is never one process-boot stale.
|
|
*/
|
|
public function isActive(): bool
|
|
{
|
|
return $this->resolve()['configured'] && $this->capabilities->has(Capability::StorageConfigure);
|
|
}
|
|
|
|
/**
|
|
* Deliberately edition-independent: whether the DB row itself is fully
|
|
* filled in and active, nothing more. Callers AND the capability check
|
|
* live and uncached — see class docblock.
|
|
*
|
|
* @return array{configured: bool, provider: string, use_instance_role: bool, key: string|null, region: string|null, bucket: string|null, endpoint: string|null, use_path_style: bool, root: string|null}
|
|
*/
|
|
private function resolve(): array
|
|
{
|
|
$blank = [
|
|
'configured' => false,
|
|
'provider' => StorageProvider::S3->value,
|
|
'use_instance_role' => false,
|
|
'key' => null,
|
|
'region' => null, 'bucket' => null,
|
|
'endpoint' => null, 'use_path_style' => false, 'root' => null,
|
|
];
|
|
|
|
// Through BootSettingsCache, not Cache directly: this runs on every
|
|
// process boot, including the artisan commands that install the
|
|
// application, and must survive a database that has no tables yet
|
|
// (or none at all). See that class for the full story.
|
|
return BootSettingsCache::rememberForever(self::CACHE_KEY, function () use ($blank): array {
|
|
if (! Schema::hasTable('external_storage_settings')) {
|
|
return $blank;
|
|
}
|
|
|
|
$settings = ExternalStorageSettings::current();
|
|
|
|
if (! $settings->isConfigured()) {
|
|
return $blank;
|
|
}
|
|
|
|
return [
|
|
'configured' => true,
|
|
'provider' => $settings->provider->value,
|
|
'use_instance_role' => $settings->use_instance_role,
|
|
'key' => $settings->key,
|
|
'region' => $settings->region,
|
|
'bucket' => $settings->bucket,
|
|
'endpoint' => $settings->endpoint,
|
|
'use_path_style' => $settings->use_path_style,
|
|
'root' => $settings->root,
|
|
];
|
|
}, $blank);
|
|
}
|
|
}
|