mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-16 16:45:07 +00:00
530f30606d
Groundwork for moving Branding out of the private package. Two changes, both about who decides what an installation may do. An edition grants capabilities; an operator may now take some away, via PROJECTSEND_CAPABILITIES_DISABLED. Subtractive only, and that asymmetry is the whole design: a variable that could *add* would put the hosted edition's proprietary screens one line of .env away on every self-hosted install, which is not a gate at all. So the list is intersected with what the edition already allows and can only make the answer smaller. This is not the plan tier core has always refused to invent. There are still no billing tiers here to key off -- the objection config/api.php makes about rate limits stands. It is the operator stating a fact about this installation, exactly as PROJECTSEND_PLATFORM_MAX_STAFF_USERS does for seats: the platform knows what it sold, the installation is told and enforces. Unknown keys are ignored rather than fatal, because the variable outlives both the plan that wrote it and the release that named the key, and refusing to boot over a stale one would be an outage on upgrade day. The registry takes the list as a constructor argument rather than reading config itself, which keeps it a value object testable without an application -- the failure that surfaced it was a unit test with no container. And branding.customize is now both editions, with the white-label half split into attribution.hide, which stays Cloud-only. Dressing an installation in its own logo is not a hosted concern; taking ProjectSend's name off somebody's public pages is what a hosted customer pays for. The gate on the second is not the key but that the only code able to answer "hide it" ships in the private package, so flipping an edition variable buys nothing. EnsureCapabilityMiddlewareTest had to pick a new Cloud-only example for the second time -- branding after users.manage. It now uses storage.managed, and records what to ask if it ever needs a third. The code move itself is the next commit; nothing user-visible changes yet, because the screens still live in cloud-modules.
197 lines
11 KiB
PHP
197 lines
11 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\Mail\Console\RefreshMailOAuthTokensCommand;
|
|
use App\Modules\Platform\Mail\GmailTransport;
|
|
use App\Modules\Platform\Mail\MicrosoftGraphTransport;
|
|
use App\Modules\Platform\News\Console\FetchNewsCommand;
|
|
use App\Modules\Platform\Notifications\ThemedMailChannel;
|
|
use App\Modules\Platform\Scheduling\Console\PurgeFailedJobsCommand;
|
|
use App\Modules\Platform\Installation\Console\StatusCommand;
|
|
use App\Modules\Platform\Settings\Console\SeedSettingsCommand;
|
|
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\Facades\Mail;
|
|
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),
|
|
// Read on every resolve rather than once, for the same
|
|
// reason the edition is: a test that sets it expects the
|
|
// next resolve to honour it.
|
|
config('projectsend.capabilities_disabled'),
|
|
);
|
|
});
|
|
|
|
// 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,
|
|
SeedSettingsCommand::class,
|
|
StatusCommand::class,
|
|
RefreshMailOAuthTokensCommand::class,
|
|
]);
|
|
}
|
|
}
|
|
|
|
public function boot(): void
|
|
{
|
|
// Registered before apply() below can select one as the default
|
|
// mailer. The closures resolve lazily on first send, so booting
|
|
// never pays for a transport nobody uses.
|
|
Mail::extend('microsoft-graph', fn (): MicrosoftGraphTransport => $this->app->make(MicrosoftGraphTransport::class));
|
|
Mail::extend('gmail-api', fn (): GmailTransport => $this->app->make(GmailTransport::class));
|
|
|
|
// 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'),
|
|
));
|
|
|
|
// In-app only, like update_available — deliberately not mail:
|
|
// this fires precisely when outgoing mail is broken, so an email
|
|
// companion would either vanish into the dead transport or (on
|
|
// the scheduled check) fail the very job reporting the problem.
|
|
$this->app->make(NotificationTypeRegistry::class)->register(new NotificationTypeDefinition(
|
|
key: 'mail_oauth_connection_broken',
|
|
label: 'The connected mailbox can no longer send email',
|
|
template: 'The :provider mailbox connection (:account) stopped working and needs to be reconnected',
|
|
url: fn (array $data) => route('system-settings.email.edit'),
|
|
));
|
|
|
|
// 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')],
|
|
);
|
|
}
|
|
}
|