From daec0a877e45ccab048c51f94af6f712f6eebb34 Mon Sep 17 00:00:00 2001 From: ignacionelson Date: Mon, 24 Aug 2026 16:38:13 -0300 Subject: [PATCH] Offer Google Cloud Storage as a storage backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit External storage meant S3 and nothing else, which is an odd hole for a product whose users are as likely to be standing on Google Cloud as on AWS — and paying to move bytes between two clouds to use this. The Storage screen now asks which provider first, and the answer decides which fields it shows, which it validates, and which driver the files_external disk resolves to. One disk, not two. files.disk is a stored column, so a third disk name would fragment the data model and make every $file->disk consumer know three names instead of two; the driver is swapped instead. A service account key gets its own encrypted column rather than sharing `secret`, because the two are validated, labelled and displayed differently and one column meaning two things is how that goes wrong later. Three things do not work by simply adding the adapter, and all three fail quietly: Laravel's temporaryUrl() looks for getTemporaryUrl() on the adapter, while League's GCS adapter names it temporaryUrl(), so without the registered callback every download and preview is a 500. The two SDKs spell the signing options differently, and an unrecognised one is dropped in silence — the symptom is a download named after the storage key, not an exception. GoogleCloudStorageDriver translates, so callers keep speaking one vocabulary, and the test asserts on the URL's contents rather than on "a redirect happened", which is what would let it regress. That callback is also re-bound to the FilesystemAdapter before it runs, so the translation is captured before registering rather than called as $this-> `provider` is validated with 'sometimes', not 'required': absent means S3, which is what every payload written before this choice meant, and stops a browser holding a stale bundle from failing to save on a field it cannot see. Verified in a browser as well as in tests — which is how the null provider on an unmigrated row was found, since the suite migrates and never sees that state. --- CHANGELOG.md | 15 + .../ExternalStorageSettingsController.php | 176 ++++- .../Platform/PlatformServiceProvider.php | 10 +- .../Settings/ExternalStorageConfigApplier.php | 61 +- .../Settings/ExternalStorageSettings.php | 48 +- .../Platform/Settings/StorageProvider.php | 44 ++ .../Storage/GoogleCloudStorageDriver.php | 116 ++++ composer.json | 1 + composer.lock | 651 +++++++++++++++++- ...der_to_external_storage_settings_table.php | 35 + .../js/pages/system/settings/storage.tsx | 158 +++-- .../Platform/GoogleCloudStorageTest.php | 270 ++++++++ 12 files changed, 1483 insertions(+), 102 deletions(-) create mode 100644 app/Modules/Platform/Settings/StorageProvider.php create mode 100644 app/Modules/Platform/Storage/GoogleCloudStorageDriver.php create mode 100644 database/migrations/2026_09_03_090000_add_provider_to_external_storage_settings_table.php create mode 100644 tests/Feature/Platform/GoogleCloudStorageTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 18594149..8ca2f744 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,21 @@ Anything under **Upgrade notes** is something you have to do, not something we d This section collects changes as they land; the release process turns it into a numbered entry when a version is cut. +### Added + +- **Google Cloud Storage as a storage backend.** External storage used to mean S3 and nothing else. + The Storage settings screen now asks which provider you are using first, and offers Google Cloud + Storage alongside the S3-compatible option: choose it, paste a service account key with read and + write access to your bucket, and new uploads go there. The key is stored encrypted and never shown + again, and **Test connection** checks it can actually reach the bucket before you switch anything + over — using a probe that works with a least-privilege key, rather than one that needs permission + to read the bucket's own settings. Downloads and previews are handed to the visitor as a + short-lived signed link, exactly as they already were for S3. + + Nothing changes for an existing installation. Configurations saved before this release are S3, are + still S3, and are not asked to say so. Files already stored stay where they are — the setting + applies to new uploads, and there is still no migration between backends. + ### Fixed - **Downloads and thumbnails for installations using external storage.** Two places assumed every diff --git a/app/Modules/Platform/Http/Controllers/ExternalStorageSettingsController.php b/app/Modules/Platform/Http/Controllers/ExternalStorageSettingsController.php index 823c3140..fb850d16 100644 --- a/app/Modules/Platform/Http/Controllers/ExternalStorageSettingsController.php +++ b/app/Modules/Platform/Http/Controllers/ExternalStorageSettingsController.php @@ -9,12 +9,17 @@ use App\Modules\Audit\Action; use App\Modules\Audit\ActivityLogger; use App\Modules\Platform\Settings\ExternalStorageConfigApplier; use App\Modules\Platform\Settings\ExternalStorageSettings; +use App\Modules\Platform\Settings\StorageProvider; use Aws\S3\S3Client; +use Closure; +use Google\Cloud\Storage\StorageClient; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Artisan; +use Illuminate\Validation\Rule; use Inertia\Inertia; use Inertia\Response; +use RuntimeException; use Throwable; /** @@ -37,6 +42,7 @@ class ExternalStorageSettingsController extends Controller return Inertia::render('system/settings/storage', [ 'active' => $settings->active, + 'provider' => $settings->provider->value, // Never name a top-level Inertia prop "key" — Inertia's React // renderer spreads page props onto the component via // `{ key: , ...props }`, and a prop @@ -45,6 +51,10 @@ class ExternalStorageSettingsController extends Controller // the component as an actual prop (React always strips `key`). 'access_key' => $settings->key ?? '', 'has_secret' => $settings->secret !== null && $settings->secret !== '', + // Same treatment as the secret: never round-tripped, only + // whether one is stored. A service account key file is more + // sensitive than an access key, not less. + 'has_key_file' => $settings->key_file !== null && $settings->key_file !== '', 'bucket' => $settings->bucket ?? '', 'region' => $settings->region ?? '', 'endpoint' => $settings->endpoint ?? '', @@ -56,35 +66,56 @@ class ExternalStorageSettingsController extends Controller public function update(Request $request): RedirectResponse { + $request->merge(['provider' => $request->input('provider', StorageProvider::S3->value)]); + $validated = $request->validate([ 'active' => ['required', 'boolean'], - 'access_key' => ['required', 'string', 'max:255'], - 'secret' => ['nullable', 'string', 'max:255'], + // 'sometimes', not 'required': absent means S3, which is what + // every payload written before this choice existed meant, and + // stops a browser holding a stale bundle from failing to save + // on a field it cannot see. + 'provider' => ['sometimes', Rule::enum(StorageProvider::class)], 'bucket' => ['required', 'string', 'max:255'], - 'region' => ['required', 'string', 'max:255'], + 'root' => ['nullable', 'string', 'max:255'], + + // Required only for the provider that uses them, so switching + // to GCS does not demand an AWS region that means nothing. + 'access_key' => ['required_if:provider,s3', 'nullable', 'string', 'max:255'], + 'secret' => ['nullable', 'string', 'max:255'], + 'region' => ['required_if:provider,s3', 'nullable', 'string', 'max:255'], 'endpoint' => ['nullable', 'string', 'max:255'], 'use_path_style' => ['required', 'boolean'], - 'root' => ['nullable', 'string', 'max:255'], + + // Checked for shape here rather than left to fail at the first + // upload: a key file is pasted, and a paste that lost its last + // line is the likeliest way this goes wrong. + 'key_file' => ['nullable', 'string', self::serviceAccountKeyRule()], ]); $settings = ExternalStorageSettings::current(); $settings->fill([ 'active' => $validated['active'], - 'key' => $validated['access_key'], + 'provider' => $validated['provider'], + 'key' => $validated['access_key'] ?? null, 'bucket' => $validated['bucket'], - 'region' => $validated['region'], + 'region' => $validated['region'] ?? null, 'endpoint' => $validated['endpoint'] ?? null, 'use_path_style' => $validated['use_path_style'], 'root' => $validated['root'] ?? null, ]); - // A blank secret keeps whatever is already stored — the field is - // never round-tripped to the browser (only `has_secret` is). + // A blank credential keeps whatever is already stored — neither + // field is ever round-tripped to the browser (only the has_* + // flags are), so blank means "unchanged", not "cleared". if (is_string($validated['secret'] ?? null) && $validated['secret'] !== '') { $settings->secret = $validated['secret']; } + if (is_string($validated['key_file'] ?? null) && $validated['key_file'] !== '') { + $settings->key_file = $validated['key_file']; + } + $settings->save(); $this->configApplier->flush(); @@ -101,43 +132,31 @@ class ExternalStorageSettingsController extends Controller } /** - * Verifies the submitted (or, if the secret field was left blank, the - * already-stored) credentials can actually reach the bucket, mirroring - * v1's connection test — this exists specifically to catch a typo'd - * key/bucket/region before switching uploads over to it. + * Verifies the submitted (or, where a credential field was left + * blank, the already-stored) details can actually reach the bucket, + * mirroring v1's connection test — this exists specifically to catch + * a typo'd key/bucket/region before switching uploads over to it. */ public function testConnection(Request $request): RedirectResponse { + $request->merge(['provider' => $request->input('provider', StorageProvider::S3->value)]); + $validated = $request->validate([ - 'access_key' => ['required', 'string', 'max:255'], - 'secret' => ['nullable', 'string', 'max:255'], + 'provider' => ['sometimes', Rule::enum(StorageProvider::class)], 'bucket' => ['required', 'string', 'max:255'], - 'region' => ['required', 'string', 'max:255'], + 'access_key' => ['required_if:provider,s3', 'nullable', 'string', 'max:255'], + 'secret' => ['nullable', 'string', 'max:255'], + 'region' => ['required_if:provider,s3', 'nullable', 'string', 'max:255'], 'endpoint' => ['nullable', 'string', 'max:255'], 'use_path_style' => ['nullable', 'boolean'], + 'key_file' => ['nullable', 'string', self::serviceAccountKeyRule()], ]); - $settings = ExternalStorageSettings::current(); - $secret = (is_string($validated['secret'] ?? null) && $validated['secret'] !== '') - ? $validated['secret'] - : $settings->secret; - try { - $config = [ - 'version' => 'latest', - 'region' => $validated['region'], - 'credentials' => [ - 'key' => $validated['access_key'], - 'secret' => (string) $secret, - ], - 'use_path_style_endpoint' => (bool) ($validated['use_path_style'] ?? false), - ]; - - if (is_string($validated['endpoint'] ?? null) && $validated['endpoint'] !== '') { - $config['endpoint'] = $validated['endpoint']; - } - - (new S3Client($config))->headBucket(['Bucket' => $validated['bucket']]); + match (StorageProvider::from($validated['provider'])) { + StorageProvider::S3 => $this->probeS3($validated), + StorageProvider::Gcs => $this->probeGcs($validated), + }; $result = __('Success: connected to bucket ":bucket".', ['bucket' => $validated['bucket']]); } catch (Throwable $e) { @@ -146,4 +165,91 @@ class ExternalStorageSettingsController extends Controller return back()->with('storage_test_result', $result); } + + /** + * @param array $validated + */ + private function probeS3(array $validated): void + { + $config = [ + 'version' => 'latest', + 'region' => $validated['region'], + 'credentials' => [ + 'key' => $validated['access_key'], + 'secret' => (string) $this->storedIfBlank($validated, 'secret'), + ], + 'use_path_style_endpoint' => (bool) ($validated['use_path_style'] ?? false), + ]; + + if (is_string($validated['endpoint'] ?? null) && $validated['endpoint'] !== '') { + $config['endpoint'] = $validated['endpoint']; + } + + (new S3Client($config))->headBucket(['Bucket' => $validated['bucket']]); + } + + /** + * @param array $validated + */ + private function probeGcs(array $validated): void + { + $keyFile = json_decode((string) $this->storedIfBlank($validated, 'key_file'), true); + + if (! is_array($keyFile)) { + throw new RuntimeException(__('No service account key has been saved yet.')); + } + + $bucket = (new StorageClient(['keyFile' => $keyFile]))->bucket($validated['bucket']); + + // Listing one object rather than asking whether the bucket exists. + // A least-privilege key — roles/storage.objectAdmin scoped to this + // bucket, which is what the whole design rests on — can read and + // write objects but cannot read the bucket's own metadata, so + // $bucket->exists() reports failure for a key that works perfectly. + // An empty bucket is a valid answer here, and returns no rows. + iterator_to_array($bucket->objects(['maxResults' => 1]), false); + } + + /** + * A credential field left blank means "keep what is stored" on save, + * so the connection test has to read it the same way — otherwise + * testing an unchanged configuration would always fail. + * + * @param array $validated + */ + private function storedIfBlank(array $validated, string $field): ?string + { + $submitted = $validated[$field] ?? null; + + if (is_string($submitted) && $submitted !== '') { + return $submitted; + } + + return ExternalStorageSettings::current()->{$field}; + } + + /** + * A pasted service account key, checked for the parts that have to be + * there. Not a credential check — that is what Test connection is for. + */ + private static function serviceAccountKeyRule(): Closure + { + return function (string $attribute, mixed $value, Closure $fail): void { + $decoded = json_decode((string) $value, true); + + if (! is_array($decoded)) { + $fail(__('That does not look like a service account key file: it is not valid JSON.')); + + return; + } + + foreach (['client_email', 'private_key'] as $required) { + if (! isset($decoded[$required]) || ! is_string($decoded[$required]) || $decoded[$required] === '') { + $fail(__('That service account key file is missing its :field.', ['field' => $required])); + + return; + } + } + }; + } } diff --git a/app/Modules/Platform/PlatformServiceProvider.php b/app/Modules/Platform/PlatformServiceProvider.php index 5681e186..7337f888 100644 --- a/app/Modules/Platform/PlatformServiceProvider.php +++ b/app/Modules/Platform/PlatformServiceProvider.php @@ -15,14 +15,15 @@ use App\Modules\Platform\Localization\LocaleRegistry; use App\Modules\Platform\Localization\TimezoneRegistry; use App\Modules\Platform\News\Console\FetchNewsCommand; use App\Modules\Platform\Notifications\ThemedMailChannel; +use App\Modules\Platform\Scheduling\Console\PurgeFailedJobsCommand; use App\Modules\Platform\Scheduling\RecordsScheduledTaskRuns; use App\Modules\Platform\Settings\ExternalStorageConfigApplier; use App\Modules\Platform\Settings\MailConfigApplier; use App\Modules\Platform\Settings\Settings; +use App\Modules\Platform\Storage\GoogleCloudStorageDriver; use App\Modules\Platform\Theming\Console\GenerateThemePreviewDataCommand; use App\Modules\Platform\Theming\EmailThemeRegistry; use App\Modules\Platform\Theming\PublicThemeRegistry; -use App\Modules\Platform\Scheduling\Console\PurgeFailedJobsCommand; use App\Modules\Platform\Updates\Console\CheckForUpdatesCommand; use App\Modules\Platform\Updates\Console\UpdateCommand; use Illuminate\Console\Events\ScheduledTaskFailed; @@ -89,6 +90,13 @@ class PlatformServiceProvider extends ServiceProvider // any — a no-op until the Email settings page is actually saved. $this->app->make(MailConfigApplier::class)->apply(); + // Laravel ships no 'gcs' driver, so the disk config the applier + // is about to write would resolve to nothing without this. Cheap + // and inert on an install that never selects it: extend() only + // records a factory, and nothing calls it until something asks + // for a disk whose driver is 'gcs'. + $this->app->make(GoogleCloudStorageDriver::class)->register(); + // Same idea for the admin-configured external storage backend — // a no-op until the Storage settings page is actually saved. The // listener is what actually redirects new uploads away from the diff --git a/app/Modules/Platform/Settings/ExternalStorageConfigApplier.php b/app/Modules/Platform/Settings/ExternalStorageConfigApplier.php index ec9d1024..e4b19597 100644 --- a/app/Modules/Platform/Settings/ExternalStorageConfigApplier.php +++ b/app/Modules/Platform/Settings/ExternalStorageConfigApplier.php @@ -44,7 +44,7 @@ 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). - private const CACHE_KEY = 'platform.external_storage_settings.v1'; + private const CACHE_KEY = 'platform.external_storage_settings.v2'; public function __construct( private readonly CapabilityRegistry $capabilities, @@ -57,17 +57,60 @@ class ExternalStorageConfigApplier } $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), + StorageProvider::Gcs => $this->applyGcs($resolved), + }; + + if ($resolved['root'] !== null) { + // Two names for one idea, because the two adapters disagree: + // Laravel's S3 driver reads 'root', Flysystem's GCS adapter is + // constructed with a 'prefix'. Setting both keeps the settings + // screen able to speak of one "folder inside the bucket". + Config::set('filesystems.disks.files_external.root', $resolved['root']); + Config::set('filesystems.disks.files_external.prefix', $resolved['root']); + } + } + + /** + * @param array $resolved + */ + private function applyS3(array $resolved): void + { Config::set('filesystems.disks.files_external.key', $resolved['key']); Config::set('filesystems.disks.files_external.secret', $resolved['secret']); Config::set('filesystems.disks.files_external.region', $resolved['region']); - Config::set('filesystems.disks.files_external.bucket', $resolved['bucket']); Config::set('filesystems.disks.files_external.endpoint', $resolved['endpoint']); Config::set('filesystems.disks.files_external.use_path_style_endpoint', $resolved['use_path_style']); + } - if ($resolved['root'] !== null) { - Config::set('filesystems.disks.files_external.root', $resolved['root']); - } + /** + * @param array $resolved + */ + private function applyGcs(array $resolved): 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. + $keyFile = json_decode((string) $resolved['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 @@ -99,13 +142,15 @@ class ExternalStorageConfigApplier * filled in and active, nothing more. Callers AND the capability check * live and uncached — see class docblock. * - * @return array{configured: bool, key: string|null, secret: string|null, region: string|null, bucket: string|null, endpoint: string|null, use_path_style: bool, root: string|null} + * @return array{configured: bool, provider: string, key: string|null, secret: string|null, key_file: 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, - 'key' => null, 'secret' => null, 'region' => null, 'bucket' => null, + 'provider' => StorageProvider::S3->value, + 'key' => null, 'secret' => null, 'key_file' => null, + 'region' => null, 'bucket' => null, 'endpoint' => null, 'use_path_style' => false, 'root' => null, ]; @@ -126,8 +171,10 @@ class ExternalStorageConfigApplier return [ 'configured' => true, + 'provider' => $settings->provider->value, 'key' => $settings->key, 'secret' => $settings->secret, + 'key_file' => $settings->key_file, 'region' => $settings->region, 'bucket' => $settings->bucket, 'endpoint' => $settings->endpoint, diff --git a/app/Modules/Platform/Settings/ExternalStorageSettings.php b/app/Modules/Platform/Settings/ExternalStorageSettings.php index 2d0a4a0d..a49293d3 100644 --- a/app/Modules/Platform/Settings/ExternalStorageSettings.php +++ b/app/Modules/Platform/Settings/ExternalStorageSettings.php @@ -7,16 +7,19 @@ namespace App\Modules\Platform\Settings; use Illuminate\Database\Eloquent\Model; /** - * Admin-configured S3-compatible external storage backend, editable from - * the Storage settings page. Single row (id 1 in practice, never + * Admin-configured external storage backend — S3-compatible or Google + * Cloud Storage, see StorageProvider — editable from the Storage + * settings page. Single row (id 1 in practice, never * enforced) — same reasoning as MailProviderSettings: `secret` needs real * Eloquent encryption, which the generic settings table can't offer * per-key. * * @property int $id * @property bool $active + * @property StorageProvider $provider * @property string|null $key * @property string|null $secret + * @property string|null $key_file * @property string|null $bucket * @property string|null $region * @property string|null $endpoint @@ -29,8 +32,10 @@ class ExternalStorageSettings extends Model protected $fillable = [ 'active', + 'provider', 'key', 'secret', + 'key_file', 'bucket', 'region', 'endpoint', @@ -38,11 +43,28 @@ class ExternalStorageSettings extends Model 'root', ]; + /** + * current() builds this with firstOrNew(), which does not apply the + * column defaults — so on an install that has never opened the + * Storage screen, `provider` would be null and the match in + * isConfigured() would throw rather than answer. Defaults here are + * what make an unsaved row a coherent object. + * + * @var array + */ + protected $attributes = [ + 'active' => false, + 'provider' => 's3', + 'use_path_style' => false, + ]; + protected function casts(): array { return [ 'active' => 'boolean', + 'provider' => StorageProvider::class, 'secret' => 'encrypted', + 'key_file' => 'encrypted', 'use_path_style' => 'boolean', ]; } @@ -59,9 +81,23 @@ class ExternalStorageSettings extends Model */ public function isConfigured(): bool { - return $this->active - && $this->key !== null && $this->key !== '' - && $this->secret !== null && $this->secret !== '' - && $this->bucket !== null && $this->bucket !== ''; + if (! $this->active || ! $this->filled('bucket')) { + return false; + } + + // What counts as "filled in" is per provider, because the two + // authenticate with different things entirely: S3 wants a key and + // a secret, GCS wants a service account key file. + return match ($this->provider) { + StorageProvider::S3 => $this->filled('key') && $this->filled('secret'), + StorageProvider::Gcs => $this->filled('key_file'), + }; + } + + private function filled(string $attribute): bool + { + $value = $this->{$attribute}; + + return is_string($value) && $value !== ''; } } diff --git a/app/Modules/Platform/Settings/StorageProvider.php b/app/Modules/Platform/Settings/StorageProvider.php new file mode 100644 index 00000000..37144111 --- /dev/null +++ b/app/Modules/Platform/Settings/StorageProvider.php @@ -0,0 +1,44 @@ + 'S3-compatible', + self::Gcs => 'Google Cloud Storage', + }; + } + + /** The Laravel filesystem driver this provider is served by. */ + public function driver(): string + { + return match ($this) { + self::S3 => 's3', + self::Gcs => 'gcs', + }; + } +} diff --git a/app/Modules/Platform/Storage/GoogleCloudStorageDriver.php b/app/Modules/Platform/Storage/GoogleCloudStorageDriver.php new file mode 100644 index 00000000..3e96e661 --- /dev/null +++ b/app/Modules/Platform/Storage/GoogleCloudStorageDriver.php @@ -0,0 +1,116 @@ + Google option name, for the subset this + * application actually sends. Anything absent is passed through + * untouched, so a caller can still reach a Google-specific option by + * its real name. + */ + private const OPTION_NAMES = [ + 'ResponseContentDisposition' => 'responseDisposition', + 'ResponseContentType' => 'responseType', + ]; + + public function register(): void + { + Storage::extend('gcs', fn ($app, array $config): FilesystemAdapter => $this->make($config)); + } + + /** + * @param array $config + */ + public function make(array $config): FilesystemAdapter + { + $client = new StorageClient(array_filter([ + // The key file carries its own project_id, so there is + // nothing else to configure. Absent, the client falls back to + // Application Default Credentials — which is how a self-hosted + // install on a Google VM can work with no key at all, at the + // cost of an IAM round trip per signature. + 'keyFile' => is_array($config['key_file'] ?? null) ? $config['key_file'] : null, + ])); + + $adapter = new GoogleCloudStorageAdapter( + $client->bucket((string) ($config['bucket'] ?? '')), + (string) ($config['prefix'] ?? ''), + ); + + $disk = new FilesystemAdapter(new Filesystem($adapter), $adapter, $config); + + // Bound and captured before registering, not called as + // $this->signingOptions() inside the closure: Laravel re-binds the + // callback to the FilesystemAdapter before invoking it + // (bindTo($this, static::class)), so `$this` in there is the disk, + // not this class, and the call fails at the first download rather + // than here. + $signingOptions = $this->signingOptions(...); + + $disk->buildTemporaryUrlsUsing( + fn (string $path, DateTimeInterface $expiration, array $options): string => $adapter->temporaryUrl( + $path, + $expiration, + new Config(['gcp_signing_options' => $signingOptions($options)]), + ) + ); + + return $disk; + } + + /** + * @param array $options + * @return array + */ + private function signingOptions(array $options): array + { + $translated = []; + + foreach ($options as $name => $value) { + $translated[self::OPTION_NAMES[$name] ?? $name] = $value; + } + + // V4 explicitly rather than by default: v2 signatures are the + // library's historical default in some paths, they are deprecated, + // and the difference only shows up as a rejected URL at the moment + // somebody tries to download something. + return ['version' => 'v4', ...$translated]; + } +} diff --git a/composer.json b/composer.json index 4b0cf7ea..800d49c6 100644 --- a/composer.json +++ b/composer.json @@ -20,6 +20,7 @@ "laravel/socialite": "^5.29", "laravel/tinker": "^2.10.1", "league/flysystem-aws-s3-v3": "^3.29", + "league/flysystem-google-cloud-storage": "^3.34", "pragmarx/google2fa": "^9.0", "projectsend/community-modules": "^1.0", "stevebauman/purify": "^6.3", diff --git a/composer.lock b/composer.lock index b57060d5..31f3c6ff 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8aeea042b113838ea5a2a2dcfb0965c5", + "content-hash": "3d1b9f16a86d22643087b0b575807b6e", "packages": [ { "name": "aws/aws-crt-php", @@ -1168,6 +1168,450 @@ ], "time": "2025-12-03T09:33:47+00:00" }, + { + "name": "google/auth", + "version": "v1.53.0", + "source": { + "type": "git", + "url": "https://github.com/googleapis/google-auth-library-php.git", + "reference": "d677d0b0c4bd52ab222a85df8e74e0d491c0cc5a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/googleapis/google-auth-library-php/zipball/d677d0b0c4bd52ab222a85df8e74e0d491c0cc5a", + "reference": "d677d0b0c4bd52ab222a85df8e74e0d491c0cc5a", + "shasum": "" + }, + "require": { + "firebase/php-jwt": "^6.0||^7.0", + "guzzlehttp/guzzle": "^7.8.2||^8.0", + "guzzlehttp/psr7": "^2.6.3||^3.0", + "php": "^8.1", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-message": "^1.1||^2.0", + "psr/log": "^2.0||^3.0" + }, + "require-dev": { + "guzzlehttp/promises": "^2.0.3||^3.0", + "kelvinmo/simplejwt": "^1.1.0", + "phpseclib/phpseclib": "^3.0.35", + "phpspec/prophecy-phpunit": "^2.1", + "phpunit/phpunit": "^9.6", + "sebastian/comparator": ">=1.2.3", + "squizlabs/php_codesniffer": "^4.0", + "symfony/filesystem": "^6.3||^7.3", + "symfony/process": "^6.0||^7.0", + "webmozart/assert": "^1.11||^2.0" + }, + "suggest": { + "phpseclib/phpseclib": "May be used in place of OpenSSL for signing strings or for token management. Please require version ^2." + }, + "type": "library", + "autoload": { + "psr-4": { + "Google\\Auth\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "description": "Google Auth Library for PHP", + "homepage": "https://github.com/google/google-auth-library-php", + "keywords": [ + "Authentication", + "google", + "oauth2" + ], + "support": { + "docs": "https://cloud.google.com/php/docs/reference/auth/latest", + "issues": "https://github.com/googleapis/google-auth-library-php/issues", + "source": "https://github.com/googleapis/google-auth-library-php/tree/v1.53.0" + }, + "time": "2026-07-22T22:36:10+00:00" + }, + { + "name": "google/cloud-core", + "version": "v1.73.2", + "source": { + "type": "git", + "url": "https://github.com/googleapis/google-cloud-php-core.git", + "reference": "883bc97bdcd5e09552eb82cb47a752271a310c7a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/googleapis/google-cloud-php-core/zipball/883bc97bdcd5e09552eb82cb47a752271a310c7a", + "reference": "883bc97bdcd5e09552eb82cb47a752271a310c7a", + "shasum": "" + }, + "require": { + "google/auth": "^1.53", + "google/gax": "^1.38.0", + "guzzlehttp/guzzle": "^7.8.2||^8.0", + "guzzlehttp/promises": "^2.0.3||^3.0", + "guzzlehttp/psr7": "^2.6.3||^3.0", + "monolog/monolog": "^2.9||^3.0", + "php": "^8.1", + "psr/http-message": "^1.0||^2.0", + "rize/uri-template": "~0.3||~0.4" + }, + "require-dev": { + "erusev/parsedown": "^1.6", + "google/cloud-common-protos": "~0.5||^1.0", + "nikic/php-parser": "^5.6", + "opis/closure": "^3.7|^4.0", + "phpdocumentor/reflection": "^6.0", + "phpdocumentor/reflection-docblock": "^5.3.3||^6.0", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.0", + "squizlabs/php_codesniffer": "3.*" + }, + "suggest": { + "opis/closure": "May be used to serialize closures to process jobs in the batch daemon. Please require version ^3.", + "symfony/lock": "Required for the Spanner cached based session pool. Please require the following commit: 3.3.x-dev#1ba6ac9" + }, + "bin": [ + "bin/google-cloud-batch" + ], + "type": "library", + "extra": { + "component": { + "id": "cloud-core", + "path": "Core", + "entry": "src/ServiceBuilder.php", + "target": "googleapis/google-cloud-php-core.git" + } + }, + "autoload": { + "psr-4": { + "Google\\Cloud\\Core\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "description": "Google Cloud PHP shared dependency, providing functionality useful to all components.", + "support": { + "source": "https://github.com/googleapis/google-cloud-php-core/tree/v1.73.2" + }, + "time": "2026-08-14T23:32:22+00:00" + }, + { + "name": "google/cloud-storage", + "version": "v2.5.2", + "source": { + "type": "git", + "url": "https://github.com/googleapis/google-cloud-php-storage.git", + "reference": "1e90d4a1bebd8cef366addce9e57bc4d9ac9c760" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/googleapis/google-cloud-php-storage/zipball/1e90d4a1bebd8cef366addce9e57bc4d9ac9c760", + "reference": "1e90d4a1bebd8cef366addce9e57bc4d9ac9c760", + "shasum": "" + }, + "require": { + "google/cloud-core": "^1.72.0", + "php": "^8.1", + "ramsey/uuid": "^4.2.3" + }, + "require-dev": { + "erusev/parsedown": "^1.6", + "google/cloud-pubsub": "^2.0", + "nikic/php-parser": "^5", + "phpdocumentor/reflection": "^6.0", + "phpdocumentor/reflection-docblock": "^5.3.3", + "phpseclib/phpseclib": "^2.0||^3.0", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.0", + "squizlabs/php_codesniffer": "3.*" + }, + "suggest": { + "google/cloud-pubsub": "May be used to register a topic to receive bucket notifications.", + "phpseclib/phpseclib": "May be used in place of OpenSSL for creating signed Cloud Storage URLs. Please require version ^2." + }, + "type": "library", + "extra": { + "component": { + "id": "cloud-storage", + "path": "Storage", + "entry": "src/StorageClient.php", + "target": "googleapis/google-cloud-php-storage.git" + } + }, + "autoload": { + "psr-4": { + "Google\\Cloud\\Storage\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "description": "Cloud Storage Client for PHP", + "support": { + "source": "https://github.com/googleapis/google-cloud-php-storage/tree/v2.5.2" + }, + "time": "2026-08-14T23:32:22+00:00" + }, + { + "name": "google/common-protos", + "version": "4.14.1", + "source": { + "type": "git", + "url": "https://github.com/googleapis/common-protos-php.git", + "reference": "4eb6813b8068653e055fc8a63dbda3446f3e8869" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/googleapis/common-protos-php/zipball/4eb6813b8068653e055fc8a63dbda3446f3e8869", + "reference": "4eb6813b8068653e055fc8a63dbda3446f3e8869", + "shasum": "" + }, + "require": { + "google/protobuf": "^4.31||^5.0", + "php": "^8.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6" + }, + "type": "library", + "extra": { + "component": { + "id": "common-protos", + "path": "CommonProtos", + "entry": "README.md", + "target": "googleapis/common-protos-php.git" + } + }, + "autoload": { + "psr-4": { + "Google\\Api\\": "src/Api", + "Google\\Iam\\": "src/Iam", + "Google\\Rpc\\": "src/Rpc", + "Google\\Type\\": "src/Type", + "Google\\Cloud\\": "src/Cloud", + "GPBMetadata\\Google\\Api\\": "metadata/Api", + "GPBMetadata\\Google\\Iam\\": "metadata/Iam", + "GPBMetadata\\Google\\Rpc\\": "metadata/Rpc", + "GPBMetadata\\Google\\Type\\": "metadata/Type", + "GPBMetadata\\Google\\Cloud\\": "metadata/Cloud", + "GPBMetadata\\Google\\Logging\\": "metadata/Logging" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "description": "Google API Common Protos for PHP", + "homepage": "https://github.com/googleapis/common-protos-php", + "keywords": [ + "google" + ], + "support": { + "source": "https://github.com/googleapis/common-protos-php/tree/v4.14.1" + }, + "time": "2026-06-17T23:07:32+00:00" + }, + { + "name": "google/gax", + "version": "v1.48.0", + "source": { + "type": "git", + "url": "https://github.com/googleapis/gax-php.git", + "reference": "637096f2c70f6bc903ba24aee3a0d974d739aba8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/googleapis/gax-php/zipball/637096f2c70f6bc903ba24aee3a0d974d739aba8", + "reference": "637096f2c70f6bc903ba24aee3a0d974d739aba8", + "shasum": "" + }, + "require": { + "google/auth": "^1.53", + "google/common-protos": "^4.9", + "google/grpc-gcp": "^0.4", + "google/longrunning": "~0.4", + "google/protobuf": "^4.31||^5.34", + "grpc/grpc": "^1.13", + "guzzlehttp/promises": "^2.0.3||^3.0", + "guzzlehttp/psr7": "^2.6.3||^3.0", + "php": "^8.1", + "ramsey/uuid": "^4.0" + }, + "conflict": { + "ext-protobuf": "<4.31.0" + }, + "require-dev": { + "google/cloud-tools": "^0.16.1", + "phpspec/prophecy-phpunit": "^2.1", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^9.6" + }, + "type": "library", + "extra": { + "component": { + "id": "gax", + "path": "Gax", + "entry": "README.md", + "target": "googleapis/gax-php.git" + } + }, + "autoload": { + "psr-4": { + "Google\\ApiCore\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "Google API Core for PHP", + "homepage": "https://github.com/googleapis/gax-php", + "keywords": [ + "google" + ], + "support": { + "issues": "https://github.com/googleapis/gax-php/issues", + "source": "https://github.com/googleapis/gax-php/tree/v1.48.0" + }, + "time": "2026-08-14T23:32:22+00:00" + }, + { + "name": "google/grpc-gcp", + "version": "0.4.2", + "source": { + "type": "git", + "url": "https://github.com/GoogleCloudPlatform/grpc-gcp-php.git", + "reference": "1049c0c15b6a1789fdeb52af688a94d540932469" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/GoogleCloudPlatform/grpc-gcp-php/zipball/1049c0c15b6a1789fdeb52af688a94d540932469", + "reference": "1049c0c15b6a1789fdeb52af688a94d540932469", + "shasum": "" + }, + "require": { + "google/auth": "^1.3", + "google/protobuf": "^v3.25.3||^4.26.1||^5.0", + "grpc/grpc": "^v1.13.0", + "php": "^8.0", + "psr/cache": "^1.0.1||^2.0.0||^3.0.0" + }, + "require-dev": { + "google/cloud-spanner": "^1.7", + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Grpc\\Gcp\\": "src/" + }, + "classmap": [ + "src/generated/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "description": "gRPC GCP library for channel management", + "support": { + "issues": "https://github.com/GoogleCloudPlatform/grpc-gcp-php/issues", + "source": "https://github.com/GoogleCloudPlatform/grpc-gcp-php/tree/v0.4.2" + }, + "time": "2026-03-12T22:56:09+00:00" + }, + { + "name": "google/longrunning", + "version": "0.8.1", + "source": { + "type": "git", + "url": "https://github.com/googleapis/php-longrunning.git", + "reference": "309705016290679e6fe14b727f4b1a3e04ae52f4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/googleapis/php-longrunning/zipball/309705016290679e6fe14b727f4b1a3e04ae52f4", + "reference": "309705016290679e6fe14b727f4b1a3e04ae52f4", + "shasum": "" + }, + "require-dev": { + "google/gax": "^1.38.0", + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "component": { + "id": "longrunning", + "path": "LongRunning", + "entry": null, + "target": "googleapis/php-longrunning" + } + }, + "autoload": { + "psr-4": { + "Google\\LongRunning\\": "src/LongRunning", + "Google\\ApiCore\\LongRunning\\": "src/ApiCore/LongRunning", + "GPBMetadata\\Google\\Longrunning\\": "metadata/Longrunning" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "description": "Google LongRunning Client for PHP", + "support": { + "source": "https://github.com/googleapis/php-longrunning/tree/v0.8.1" + }, + "time": "2026-08-14T23:32:22+00:00" + }, + { + "name": "google/protobuf", + "version": "v5.36.0", + "source": { + "type": "git", + "url": "https://github.com/protocolbuffers/protobuf-php.git", + "reference": "9c105104b54709ecd902494ab340ed2122789b2d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/9c105104b54709ecd902494ab340ed2122789b2d", + "reference": "9c105104b54709ecd902494ab340ed2122789b2d", + "shasum": "" + }, + "require": { + "php": ">=8.2.0" + }, + "require-dev": { + "phpunit/phpunit": ">=11.5.50 <12.0.0" + }, + "suggest": { + "ext-bcmath": "Need to support JSON deserialization" + }, + "type": "library", + "autoload": { + "psr-4": { + "Google\\Protobuf\\": "src/Google/Protobuf", + "GPBMetadata\\Google\\Protobuf\\": "src/GPBMetadata/Google/Protobuf" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "proto library for PHP", + "homepage": "https://developers.google.com/protocol-buffers/", + "keywords": [ + "proto" + ], + "support": { + "source": "https://github.com/protocolbuffers/protobuf-php/tree/v5.36.0" + }, + "time": "2026-08-20T13:06:50+00:00" + }, { "name": "graham-campbell/result-type", "version": "v1.1.4", @@ -1230,6 +1674,50 @@ ], "time": "2025-12-27T19:43:20+00:00" }, + { + "name": "grpc/grpc", + "version": "1.82.0", + "source": { + "type": "git", + "url": "https://github.com/grpc/grpc-php.git", + "reference": "be984cb608f21e96453b3cfe54c748cc7b192250" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/grpc/grpc-php/zipball/be984cb608f21e96453b3cfe54c748cc7b192250", + "reference": "be984cb608f21e96453b3cfe54c748cc7b192250", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "google/auth": "^v1.3.0" + }, + "suggest": { + "ext-protobuf": "For better performance, install the protobuf C extension.", + "google/protobuf": "To get started using grpc quickly, install the native protobuf library." + }, + "type": "library", + "autoload": { + "psr-4": { + "Grpc\\": "src/lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "description": "gRPC library for PHP", + "homepage": "https://grpc.io", + "keywords": [ + "rpc" + ], + "support": { + "source": "https://github.com/grpc/grpc-php/tree/v1.82.0" + }, + "time": "2026-07-03T09:39:53+00:00" + }, { "name": "guzzlehttp/guzzle", "version": "7.15.2", @@ -2648,6 +3136,54 @@ }, "time": "2026-07-01T23:25:49+00:00" }, + { + "name": "league/flysystem-google-cloud-storage", + "version": "3.34.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-google-cloud-storage.git", + "reference": "7ae8cd9ec58dd4b387ee1f7349e728ed8c455b09" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-google-cloud-storage/zipball/7ae8cd9ec58dd4b387ee1f7349e728ed8c455b09", + "reference": "7ae8cd9ec58dd4b387ee1f7349e728ed8c455b09", + "shasum": "" + }, + "require": { + "google/cloud-storage": "^1.23 || ^2.0", + "league/flysystem": "^3.10.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\GoogleCloudStorage\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Google Cloud Storage adapter for Flysystem.", + "keywords": [ + "Flysystem", + "filesystem", + "gcs", + "google cloud storage" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-google-cloud-storage/tree/3.34.0" + }, + "time": "2026-05-12T08:30:57+00:00" + }, { "name": "league/flysystem-local", "version": "3.31.0", @@ -4098,6 +4634,55 @@ }, "time": "2026-08-16T18:45:51+00:00" }, + { + "name": "psr/cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/3.0.0" + }, + "time": "2021-02-03T23:26:27+00:00" + }, { "name": "psr/clock", "version": "1.0.0", @@ -4787,6 +5372,70 @@ }, "time": "2026-06-18T03:57:49+00:00" }, + { + "name": "rize/uri-template", + "version": "0.4.2", + "source": { + "type": "git", + "url": "https://github.com/rize/UriTemplate.git", + "reference": "7ad22944daede547b4542e1c977ec4a81aa20832" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/rize/UriTemplate/zipball/7ad22944daede547b4542e1c977ec4a81aa20832", + "reference": "7ad22944daede547b4542e1c977ec4a81aa20832", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.63", + "phpstan/phpstan": "^1.12", + "phpunit/phpunit": "~10.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Rize\\": "src/Rize" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marut K", + "homepage": "http://twitter.com/rezigned" + } + ], + "description": "PHP URI Template (RFC 6570) supports both expansion & extraction", + "keywords": [ + "RFC 6570", + "template", + "uri" + ], + "support": { + "issues": "https://github.com/rize/UriTemplate/issues", + "source": "https://github.com/rize/UriTemplate/tree/0.4.2" + }, + "funding": [ + { + "url": "https://www.paypal.me/rezigned", + "type": "custom" + }, + { + "url": "https://github.com/rezigned", + "type": "github" + }, + { + "url": "https://opencollective.com/rize-uri-template", + "type": "open_collective" + } + ], + "time": "2026-05-07T15:30:40+00:00" + }, { "name": "spatie/laravel-package-tools", "version": "1.93.1", diff --git a/database/migrations/2026_09_03_090000_add_provider_to_external_storage_settings_table.php b/database/migrations/2026_09_03_090000_add_provider_to_external_storage_settings_table.php new file mode 100644 index 00000000..21af41c1 --- /dev/null +++ b/database/migrations/2026_09_03_090000_add_provider_to_external_storage_settings_table.php @@ -0,0 +1,35 @@ +string('provider')->default('s3')->after('active'); + + // A service account key is a ~2 KB JSON document, not a + // password, so it gets its own encrypted column rather than + // sharing `secret` with S3. The two are validated + // differently, labelled differently and shown differently, + // and one column meaning two things is how that gets + // confusing later. + $table->text('key_file')->nullable()->after('secret'); + }); + } + + public function down(): void + { + Schema::table('external_storage_settings', function (Blueprint $table) { + $table->dropColumn(['provider', 'key_file']); + }); + } +}; diff --git a/resources/js/pages/system/settings/storage.tsx b/resources/js/pages/system/settings/storage.tsx index afdfac70..536c1e48 100644 --- a/resources/js/pages/system/settings/storage.tsx +++ b/resources/js/pages/system/settings/storage.tsx @@ -9,14 +9,17 @@ import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Textarea } from '@/components/ui/textarea'; import { useTranslation } from '@/hooks/use-translation'; import AppLayout from '@/layouts/app-layout'; interface StorageSettingsProps { active: boolean; + provider: string; access_key: string; has_secret: boolean; + has_key_file: boolean; bucket: string; region: string; endpoint: string; @@ -29,8 +32,10 @@ const FORM_ID = 'storage-settings-form'; export default function StorageSettings({ active, + provider, access_key, has_secret, + has_key_file, bucket, region, endpoint, @@ -48,8 +53,10 @@ export default function StorageSettings({ const { data, setData, patch, processing, recentlySuccessful, errors } = useForm({ active: active, + provider: provider, access_key: access_key, secret: '', + key_file: '', bucket: bucket, region: region, endpoint: endpoint, @@ -57,11 +64,16 @@ export default function StorageSettings({ root: root, }); + const isGcs = data.provider === 'gcs'; + const submit: FormEventHandler = (e) => { e.preventDefault(); patch(route('system-settings.storage.update'), { preserveScroll: true, - onSuccess: () => setData('secret', ''), + onSuccess: () => { + setData('secret', ''); + setData('key_file', ''); + }, }); }; @@ -70,8 +82,10 @@ export default function StorageSettings({ router.post( route('system-settings.storage.test'), { + provider: data.provider, access_key: data.access_key, secret: data.secret, + key_file: data.key_file, bucket: data.bucket, region: data.region, endpoint: data.endpoint, @@ -89,7 +103,7 @@ export default function StorageSettings({ @@ -111,67 +125,107 @@ export default function StorageSettings({ -
-
- - setData('access_key', e.target.value)} /> - -
-
- - setData('secret', e.target.value)} - /> - -
+
+ + +
+ {isGcs ? ( +
+ +

+ {t( + 'Paste the JSON key file for a service account with object read and write access to the bucket. It is stored encrypted and never shown again.', + )} +

+