Files
projectsend/app/Modules/Platform/PlatformServiceProvider.php
T
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

167 lines
9.1 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Platform;
use App\Modules\Files\Storage\ResolvingUploadDisk;
use App\Modules\Notifications\NotificationTypeDefinition;
use App\Modules\Notifications\NotificationTypeRegistry;
use App\Modules\Platform\Capabilities\CapabilityRegistry;
use App\Modules\Platform\Capabilities\Edition;
use App\Modules\Platform\Captcha\Console\DisableCaptchaCommand;
use App\Modules\Platform\Captcha\Console\TestCaptchaCommand;
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\Updates\Console\CheckForUpdatesCommand;
use App\Modules\Platform\Updates\Console\UpdateCommand;
use Illuminate\Console\Events\ScheduledTaskFailed;
use Illuminate\Console\Events\ScheduledTaskFinished;
use Illuminate\Notifications\Channels\MailChannel;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
class PlatformServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(LocaleRegistry::class);
$this->app->singleton(TimezoneRegistry::class);
$this->app->singleton(Settings::class);
// Singletons: themes register into these once per process (here,
// and from the private cloud-modules package's ThemesServiceProvider
// when installed) — a fresh instance per resolution would lose
// whatever an earlier provider's boot() already registered.
$this->app->singleton(PublicThemeRegistry::class);
$this->app->singleton(EmailThemeRegistry::class);
// Not a singleton: `PROJECTSEND_EDITION` never changes within a
// running process (a real deployment recreates the container to
// switch editions), so re-reading config() on each resolution costs
// nothing there — but MailConfigApplier::apply() now resolves this
// during boot() below on every request, and a cached singleton
// instance would permanently bake in whatever edition was active at
// the first boot, making tests' config()->set('projectsend.edition',
// ...) (the established pattern — see EnsureCapabilityMiddlewareTest)
// silently no-op for the rest of that test.
$this->app->bind(CapabilityRegistry::class, function (): CapabilityRegistry {
$edition = config('projectsend.edition');
return new CapabilityRegistry(
$edition instanceof Edition ? $edition : Edition::from($edition),
);
});
// Every notification's mail rendering funnels through MailChannel —
// rebinding it is how Setting::EmailTheme reaches all of them
// without touching each Notification class individually.
$this->app->bind(MailChannel::class, ThemedMailChannel::class);
if ($this->app->runningInConsole()) {
$this->commands([
GenerateThemePreviewDataCommand::class,
CheckForUpdatesCommand::class,
UpdateCommand::class,
FetchNewsCommand::class,
DisableCaptchaCommand::class,
TestCaptchaCommand::class,
PurgeFailedJobsCommand::class,
]);
}
}
public function boot(): void
{
// Every process boot (a web request, or a freshly (re)started
// queue worker) picks up the admin-configured mail provider, if
// 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
// local 'files' disk (see ResolvingUploadDisk's docblock).
$this->app->make(ExternalStorageConfigApplier::class)->apply();
Event::listen(ResolvingUploadDisk::class, [ExternalStorageConfigApplier::class, 'resolveDisk']);
// Community-only observability (Capability::SchedulerMonitoring) —
// registered unconditionally since it's cheap and inert either way;
// the settings page/route reading these rows is what's actually
// capability-gated.
Event::listen(ScheduledTaskFinished::class, [RecordsScheduledTaskRuns::class, 'onFinished']);
Event::listen(ScheduledTaskFailed::class, [RecordsScheduledTaskRuns::class, 'onFailed']);
// In-app only — this is an internal "go check the dashboard"
// nudge, not a mail-worthy event on its own; the dashboard's
// System card is where the real external release link and
// upgrade instructions live. url points at the dashboard rather
// than the GitHub release page itself: notification clicks go
// through Inertia's router.visit(), which isn't safe for a
// cross-origin URL.
$this->app->make(NotificationTypeRegistry::class)->register(new NotificationTypeDefinition(
key: 'update_available',
label: 'A new ProjectSend version is available',
template: 'ProjectSend :latestVersion is available (you have :currentVersion)',
url: fn (array $data) => route('dashboard'),
));
// Core's free themes — available in every edition, gated by
// nothing. A genuinely edition-exclusive theme would instead
// register into these same singletons from a private package's
// own ThemesServiceProvider (cloud-modules or community-modules,
// whichever edition it's exclusive to), when installed — order
// between core and a package doesn't matter, they register
// distinct keys. `gallery`/`branded` lived in community-modules
// briefly (2026-07-31) under the mistaken assumption they were
// community-exclusive; they're free-for-everyone, so they belong
// here instead, not gated behind either package.
$publicThemes = $this->app->make(PublicThemeRegistry::class);
$publicThemes->register('default', 'Default', __('A clean, neutral layout that works well for any kind of file sharing.'));
$publicThemes->register('compact', 'Compact', __('A dense, spreadsheet-style list that fits more files on screen — best for large collections and frequent uploaders.'));
$publicThemes->register('drive', 'Drive', __('A spacious, colorful layout inspired by cloud storage apps, with clear file-type icons and generous spacing.'));
$publicThemes->register('gallery', 'Gallery', __('A full-width photo grid built for visual browsing — the best choice for photographers and image-heavy collections.'));
$emailThemes = $this->app->make(EmailThemeRegistry::class);
$emailThemes->register('default', 'Default', __("ProjectSend's classic email look — simple and neutral, and pairs well with any public/portal theme."));
$emailThemes->register('minimal', 'Minimal', __('A stripped-down, understated design with no extra styling — pairs with the Compact look.'));
// Same key as the public/portal 'drive' theme above — every
// theme should ship as a matched pair across both surfaces (see
// ThemeRegistry's docblock), so a tenant that picks one "look"
// gets it consistently everywhere, not a mismatched public site
// and inbox.
$emailThemes->register('drive', 'Drive', __('Blue accents and clean structure inspired by cloud storage apps — pairs with the Drive look.'));
// Paired with 'gallery' above (mismatched key names predate the
// same-key convention, see ThemeRegistry's docblock). No custom
// logo integration — just the stock ProjectSend mark; Cloud's
// Branding module (private cloud-modules package) is a
// completely separate, edition-exclusive feature.
$emailThemes->register(
'branded',
'Branded',
__('A bold header built around your logo — pairs with the Gallery look for a polished, on-brand inbox.'),
null,
fn (): array => ['logo_url' => asset('apple-touch-icon.png')],
);
}
}