Offer Google Cloud Storage as a storage backend

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.
This commit is contained in:
ignacionelson
2026-08-24 16:38:13 -03:00
parent 57540164fa
commit daec0a877e
12 changed files with 1483 additions and 102 deletions
+15
View File
@@ -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
@@ -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: <internal-remount-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<string, mixed> $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<string, mixed> $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<string, mixed> $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;
}
}
};
}
}
@@ -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
@@ -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<string, mixed> $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<string, mixed> $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,
@@ -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<string, mixed>
*/
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 !== '';
}
}
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Modules\Platform\Settings;
/**
* Which object store the external `files_external` disk is talking to.
*
* Unlike MailProvider, this is not a preset picker over one transport:
* the two cases are genuinely different Flysystem drivers, authenticated
* differently an access key and secret against an S3 API, a service
* account key against Google's. Which fields the Storage settings screen
* shows, which of them are validated, and what
* ExternalStorageConfigApplier writes into the disk config all follow
* from this.
*
* S3 keeps its endpoint and path-style settings because "S3" here means
* the whole S3-compatible family AWS itself, MinIO, Backblaze,
* Wasabi, and Google's own interoperability endpoint for anyone who
* would rather use HMAC keys than a service account.
*/
enum StorageProvider: string
{
case S3 = 's3';
case Gcs = 'gcs';
public function label(): string
{
return match ($this) {
self::S3 => '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',
};
}
}
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Modules\Platform\Storage;
use DateTimeInterface;
use Google\Cloud\Storage\StorageClient;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Support\Facades\Storage;
use League\Flysystem\Config;
use League\Flysystem\Filesystem;
use League\Flysystem\GoogleCloudStorage\GoogleCloudStorageAdapter;
/**
* The `gcs` filesystem driver, which Laravel does not ship.
*
* Two things here are not boilerplate, and both are the kind of thing
* that fails quietly rather than loudly.
*
* **Laravel will not find the adapter's own method.** FilesystemAdapter
* ::temporaryUrl() looks for a method named `getTemporaryUrl` on the
* adapter, falls back to a registered callback, and otherwise throws
* "This driver does not support creating temporary URLs". League's
* adapter implements Flysystem's TemporaryUrlGenerator and names the
* method `temporaryUrl`. The names do not meet, so without the
* buildTemporaryUrlsUsing() below every download and every preview of a
* GCS-stored file is a 500.
*
* **The two SDKs spell the signing options differently.** The callers
* StoredFileResponse, and anything else that hands options to
* temporaryUrl() speak the AWS vocabulary, because S3 came first and
* one vocabulary is better than two. GCS wants `responseDisposition`
* where S3 says `ResponseContentDisposition`, and an option it does not
* recognise is ignored in silence: no exception, just downloads that
* arrive named after the storage key and previews that download instead
* of displaying. Translating here is what keeps every caller
* provider-agnostic, and keeps the failure from being invisible.
*/
class GoogleCloudStorageDriver
{
/**
* AWS option name => 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<string, mixed> $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<string, mixed> $options
* @return array<string, mixed>
*/
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];
}
}
+1
View File
@@ -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",
Generated
+650 -1
View File
@@ -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",
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('external_storage_settings', function (Blueprint $table) {
// Every row that exists predates the choice, and every one of
// them is S3 — so the default is what keeps this migration
// invisible to anyone already using external storage.
$table->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']);
});
}
};
+106 -52
View File
@@ -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({
<Heading
title={t('Storage settings')}
description={t(
'Connect an external S3-compatible bucket (AWS S3, MinIO, or another S3-compatible service) as the storage backend for new uploads.',
'Connect an external bucket — S3-compatible (AWS S3, MinIO, Backblaze) or Google Cloud Storage — as the storage backend for new uploads.',
)}
/>
@@ -111,67 +125,107 @@ export default function StorageSettings({
</div>
</div>
<div className="flex gap-4">
<div className="grid flex-1 gap-2">
<Label htmlFor="storage_access_key">{t('Access key')}</Label>
<Input id="storage_access_key" value={data.access_key} onChange={(e) => setData('access_key', e.target.value)} />
<InputError message={errors.access_key} />
</div>
<div className="grid flex-1 gap-2">
<Label htmlFor="storage_secret">{t('Secret key')}</Label>
<Input
id="storage_secret"
type="password"
placeholder={has_secret ? t('Unchanged') : ''}
value={data.secret}
onChange={(e) => setData('secret', e.target.value)}
/>
<InputError message={errors.secret} />
</div>
<div className="grid gap-2">
<Label htmlFor="storage_provider">{t('Provider')}</Label>
<Select value={data.provider} onValueChange={(value) => setData('provider', value)}>
<SelectTrigger id="storage_provider" className="w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="s3">{t('S3-compatible')}</SelectItem>
<SelectItem value="gcs">{t('Google Cloud Storage')}</SelectItem>
</SelectContent>
</Select>
<InputError message={errors.provider} />
</div>
{isGcs ? (
<div className="grid gap-2">
<Label htmlFor="storage_key_file">{t('Service account key')}</Label>
<p className="text-muted-foreground text-sm">
{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.',
)}
</p>
<Textarea
id="storage_key_file"
rows={6}
className="font-mono text-xs"
placeholder={has_key_file ? t('Unchanged') : '{ "type": "service_account", ... }'}
value={data.key_file}
onChange={(e) => setData('key_file', e.target.value)}
/>
<InputError message={errors.key_file} />
</div>
) : (
<div className="flex gap-4">
<div className="grid flex-1 gap-2">
<Label htmlFor="storage_access_key">{t('Access key')}</Label>
<Input id="storage_access_key" value={data.access_key} onChange={(e) => setData('access_key', e.target.value)} />
<InputError message={errors.access_key} />
</div>
<div className="grid flex-1 gap-2">
<Label htmlFor="storage_secret">{t('Secret key')}</Label>
<Input
id="storage_secret"
type="password"
placeholder={has_secret ? t('Unchanged') : ''}
value={data.secret}
onChange={(e) => setData('secret', e.target.value)}
/>
<InputError message={errors.secret} />
</div>
</div>
)}
<div className="flex gap-4">
<div className="grid flex-1 gap-2">
<Label htmlFor="storage_bucket">{t('Bucket')}</Label>
<Input id="storage_bucket" value={data.bucket} onChange={(e) => setData('bucket', e.target.value)} />
<InputError message={errors.bucket} />
</div>
<div className="grid flex-1 gap-2">
<Label htmlFor="storage_region">{t('Region')}</Label>
<Input id="storage_region" value={data.region} onChange={(e) => setData('region', e.target.value)} />
<InputError message={errors.region} />
</div>
{!isGcs && (
<div className="grid flex-1 gap-2">
<Label htmlFor="storage_region">{t('Region')}</Label>
<Input id="storage_region" value={data.region} onChange={(e) => setData('region', e.target.value)} />
<InputError message={errors.region} />
</div>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="storage_endpoint">{t('Custom endpoint')}</Label>
<p className="text-muted-foreground text-sm">
{t('Leave blank for AWS S3. Set this to use an S3-compatible service such as MinIO or Backblaze.')}
</p>
<Input
id="storage_endpoint"
value={data.endpoint}
onChange={(e) => setData('endpoint', e.target.value)}
placeholder="https://s3.example.com"
/>
<InputError message={errors.endpoint} />
</div>
{!isGcs && (
<>
<div className="grid gap-2">
<Label htmlFor="storage_endpoint">{t('Custom endpoint')}</Label>
<p className="text-muted-foreground text-sm">
{t('Leave blank for AWS S3. Set this to use an S3-compatible service such as MinIO or Backblaze.')}
</p>
<Input
id="storage_endpoint"
value={data.endpoint}
onChange={(e) => setData('endpoint', e.target.value)}
placeholder="https://s3.example.com"
/>
<InputError message={errors.endpoint} />
</div>
<div className="flex items-start gap-2">
<Checkbox
id="use_path_style"
checked={data.use_path_style}
onCheckedChange={(checked) => setData('use_path_style', checked === true)}
/>
<div className="grid gap-1">
<Label htmlFor="use_path_style" className="font-normal">
{t('Use path-style addressing')}
</Label>
<p className="text-muted-foreground text-sm">
{t('Required by most S3-compatible services (MinIO, etc). Leave off for AWS S3.')}
</p>
</div>
</div>
<div className="flex items-start gap-2">
<Checkbox
id="use_path_style"
checked={data.use_path_style}
onCheckedChange={(checked) => setData('use_path_style', checked === true)}
/>
<div className="grid gap-1">
<Label htmlFor="use_path_style" className="font-normal">
{t('Use path-style addressing')}
</Label>
<p className="text-muted-foreground text-sm">
{t('Required by most S3-compatible services (MinIO, etc). Leave off for AWS S3.')}
</p>
</div>
</div>
</>
)}
<div className="grid gap-2">
<Label htmlFor="storage_root">{t('Path prefix')}</Label>
@@ -0,0 +1,270 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Files\Models\File;
use App\Modules\Files\Storage\ResolvingUploadDisk;
use App\Modules\Platform\Settings\ExternalStorageConfigApplier;
use App\Modules\Platform\Settings\ExternalStorageSettings;
use App\Modules\Platform\Settings\StorageProvider;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Storage;
use Inertia\Testing\AssertableInertia;
beforeEach(function () {
$this->admin = User::factory()->create();
});
/**
* A syntactically real service account key, generated per test.
*
* V4 signing is done locally with the private key no network, no
* project, no bucket needs to exist which is what makes the signed-URL
* assertions below real rather than mocked.
*
* @return array<string, string>
*/
function fakeServiceAccountKey(): array
{
$resource = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
openssl_pkey_export($resource, $privateKey);
return [
'type' => 'service_account',
'project_id' => 'projectsend-test',
'private_key_id' => 'test-key-id',
'private_key' => $privateKey,
'client_email' => 'projectsend@projectsend-test.iam.gserviceaccount.com',
'client_id' => '1234567890',
];
}
function configureGcs(array $overrides = []): void
{
ExternalStorageSettings::current()->fill([
'active' => true,
'provider' => StorageProvider::Gcs,
'bucket' => 'projectsend-files',
'key_file' => json_encode(fakeServiceAccountKey()),
...$overrides,
])->save();
app(ExternalStorageConfigApplier::class)->flush();
app(ExternalStorageConfigApplier::class)->apply();
}
test('choosing Google Cloud Storage points the external disk at the gcs driver', function () {
configureGcs();
expect(config('filesystems.disks.files_external.driver'))->toBe('gcs')
->and(config('filesystems.disks.files_external.bucket'))->toBe('projectsend-files')
// The key file is decoded for the client, and the S3 leftovers
// from the config stub are cleared rather than left looking like
// configuration.
->and(config('filesystems.disks.files_external.key_file'))->toBeArray()
->and(config('filesystems.disks.files_external.key_file')['client_email'])
->toBe('projectsend@projectsend-test.iam.gserviceaccount.com')
->and(config('filesystems.disks.files_external.key'))->toBeNull()
->and(config('filesystems.disks.files_external.secret'))->toBeNull();
});
test('a temporary url can be generated at all', function () {
// Not a tautology. Laravel's FilesystemAdapter::temporaryUrl() looks
// for a method named getTemporaryUrl() on the adapter; League's GCS
// adapter names its method temporaryUrl(). Without the callback
// registered by GoogleCloudStorageDriver the two never meet and this
// throws "This driver does not support creating temporary URLs" —
// which is every download and every preview on a GCS install.
configureGcs();
$url = Storage::disk('files_external')->temporaryUrl('2026/07/report.pdf', now()->addHour());
expect($url)->toStartWith('https://storage.googleapis.com/projectsend-files/2026/07/report.pdf?')
->and($url)->toContain('X-Goog-Algorithm=GOOG4-RSA-SHA256')
->and($url)->toContain('X-Goog-Signature=');
});
test('the download filename survives into the signed url', function () {
// The failure this covers is silent, which is why it is asserted on
// the URL's contents rather than on "a redirect happened": callers
// speak S3's ResponseContentDisposition, GCS wants responseDisposition,
// and an unrecognised option is dropped without complaint. The symptom
// is a download named after the storage key, and nothing in the logs.
configureGcs();
$url = Storage::disk('files_external')->temporaryUrl(
'2026/07/8f3a-uuid.pdf',
now()->addHour(),
['ResponseContentDisposition' => 'attachment; filename="Quarterly report.pdf"'],
);
expect($url)->toContain('response-content-disposition=')
->and(urldecode($url))->toContain('attachment; filename="Quarterly report.pdf"');
});
test('an option that is already a google name is passed through untranslated', function () {
configureGcs();
$url = Storage::disk('files_external')->temporaryUrl(
'2026/07/report.pdf',
now()->addHour(),
['responseType' => 'application/pdf'],
);
expect($url)->toContain('response-content-type=application%2Fpdf');
});
test('the folder setting prefixes the object path for gcs, the way root does for s3', function () {
configureGcs(['root' => 'projectsend']);
$url = Storage::disk('files_external')->temporaryUrl('2026/07/report.pdf', now()->addHour());
expect($url)->toStartWith('https://storage.googleapis.com/projectsend-files/projectsend/2026/07/report.pdf?');
});
test('new uploads are routed to the external disk once gcs is configured', function () {
configureGcs();
$event = new ResolvingUploadDisk($this->admin);
Event::dispatch($event);
expect($event->disk)->toBe('files_external');
});
test('gcs is judged configured by its key file, not by an access key and secret', function () {
$settings = ExternalStorageSettings::current();
// Everything S3 would need, and nothing GCS needs.
$settings->fill([
'active' => true,
'provider' => StorageProvider::Gcs,
'bucket' => 'projectsend-files',
'key' => 'AKIAEXAMPLE',
'secret' => 'shh',
])->save();
expect($settings->isConfigured())->toBeFalse();
$settings->fill(['key_file' => json_encode(fakeServiceAccountKey())])->save();
expect($settings->fresh()->isConfigured())->toBeTrue();
});
test('the service account key is encrypted at rest', function () {
$key = fakeServiceAccountKey();
ExternalStorageSettings::current()->fill(['key_file' => json_encode($key)])->save();
$raw = DB::table('external_storage_settings')->value('key_file');
expect($raw)->not->toContain('BEGIN PRIVATE KEY')
->and($raw)->not->toContain('iam.gserviceaccount.com')
->and(json_decode((string) ExternalStorageSettings::current()->key_file, true)['private_key'])
->toBe($key['private_key']);
});
test('a file stored on gcs downloads as a redirect to a signed url, not an nginx path', function () {
configureGcs();
$file = File::factory()->create([
'uploaded_by' => $this->admin->id,
'original_name' => 'contract.pdf',
'mime_type' => 'application/pdf',
'path' => '2026/07/contract.pdf',
'disk' => 'files_external',
]);
$response = $this->actingAs($this->admin)->get("/files/{$file->id}/download");
$response->assertRedirect();
$response->assertHeaderMissing('X-Accel-Redirect');
$target = urldecode((string) $response->headers->get('Location'));
expect($target)->toStartWith('https://storage.googleapis.com/projectsend-files/2026/07/contract.pdf?')
->and($target)->toContain('attachment; filename="contract.pdf"');
});
test('staff can save a Google Cloud Storage backend through the settings form', function () {
$key = json_encode(fakeServiceAccountKey());
$this->actingAs($this->admin)->patch('/system/settings/storage', [
'active' => true,
'provider' => 'gcs',
'bucket' => 'projectsend-files',
'key_file' => $key,
'use_path_style' => false,
])->assertRedirect();
$settings = ExternalStorageSettings::current();
expect($settings->provider)->toBe(StorageProvider::Gcs)
->and($settings->key_file)->toBe($key)
->and($settings->isConfigured())->toBeTrue();
});
test('switching to gcs does not demand an access key or a region', function () {
// The S3 fields are required_if, not required — otherwise selecting
// Google would insist on an AWS region that means nothing to it.
$this->actingAs($this->admin)->patch('/system/settings/storage', [
'active' => true,
'provider' => 'gcs',
'bucket' => 'projectsend-files',
'key_file' => json_encode(fakeServiceAccountKey()),
'use_path_style' => false,
])->assertSessionHasNoErrors();
});
test('a blank key file keeps the one already stored', function () {
$key = json_encode(fakeServiceAccountKey());
ExternalStorageSettings::current()->fill(['provider' => StorageProvider::Gcs, 'key_file' => $key])->save();
$this->actingAs($this->admin)->patch('/system/settings/storage', [
'active' => true,
'provider' => 'gcs',
'bucket' => 'a-different-bucket',
'key_file' => '',
'use_path_style' => false,
])->assertRedirect();
expect(ExternalStorageSettings::current()->key_file)->toBe($key)
->and(ExternalStorageSettings::current()->bucket)->toBe('a-different-bucket');
});
test('a key file that is not a service account key is rejected before it can be saved', function () {
// A paste that lost its last line is the likeliest way this goes
// wrong, and the alternative to catching it here is a 500 at the
// first upload with nothing pointing at the cause.
$this->actingAs($this->admin)->patch('/system/settings/storage', [
'active' => true,
'provider' => 'gcs',
'bucket' => 'projectsend-files',
'key_file' => '{"type": "service_account", "project_id": "demo"',
'use_path_style' => false,
])->assertSessionHasErrors('key_file');
$this->actingAs($this->admin)->patch('/system/settings/storage', [
'active' => true,
'provider' => 'gcs',
'bucket' => 'projectsend-files',
'key_file' => '{"type": "service_account", "project_id": "demo"}',
'use_path_style' => false,
])->assertSessionHasErrors('key_file');
});
test('the settings screen offers the provider choice and says whether a key is stored', function () {
ExternalStorageSettings::current()->fill([
'provider' => StorageProvider::Gcs,
'key_file' => json_encode(fakeServiceAccountKey()),
])->save();
$this->actingAs($this->admin)->get('/system/settings/storage')
->assertInertia(fn (AssertableInertia $page) => $page
->component('system/settings/storage')
->where('provider', 'gcs')
->where('has_key_file', true)
// The key itself is never sent back to the browser.
->missing('key_file'));
});