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.', + )} +

+