Files
projectsend/app/Modules/Platform/Settings/ExternalStorageSettings.php
ignacionelson daec0a877e 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.
2026-08-24 16:38:13 -03:00

104 lines
2.9 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Platform\Settings;
use Illuminate\Database\Eloquent\Model;
/**
* 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
* @property bool $use_path_style
* @property string|null $root
*/
class ExternalStorageSettings extends Model
{
protected $table = 'external_storage_settings';
protected $fillable = [
'active',
'provider',
'key',
'secret',
'key_file',
'bucket',
'region',
'endpoint',
'use_path_style',
'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',
];
}
public static function current(): self
{
return static::query()->firstOrNew([]);
}
/**
* Active isn't enough on its own — an admin could flip the toggle
* before ever filling in real credentials (a blank bucket/key would
* silently misroute every new upload to a broken disk).
*/
public function isConfigured(): bool
{
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 !== '';
}
}