mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-12 06:48:55 +00:00
ed0d36de25
Updating a server install cost nine artisan invocations plus a PHP-FPM
reload, written out in three places that had already drifted apart. One
of those steps is silently fatal to skip: with opcache.validate_timestamps
off — what production guides recommend and what our own image ships — the
database moves to the new version while every visitor keeps being served
the old code, and artisan reports the new version throughout.
`sudo ./update.sh` is now the whole procedure. It asks whether to check
GitHub, asks whether to download the release and verifies the checksum
published beside it, and asks whether there is a backup — offering to dump
the database when the answer is no. Then it takes the site down, replaces
the files, runs the update, reloads PHP-FPM, restarts the worker and
brings the site back. The application still has no self-updater: nothing
is fetched or applied unless somebody runs this and answers yes.
Underneath it is `php artisan projectsend:update`, which is everything an
update does that needs no root — and now the only definition of it. Both
container entrypoints call it instead of carrying their own copy of the
sequence, so the two paths cannot drift again.
Three findings worth keeping in the record, all from rehearsing rather
than reasoning:
- queue:restart has to come last. It writes its signal into the cache,
so clearing the cache afterwards deletes it and the worker runs old
code forever.
- optimize:clear is not safe to recommend. It runs cache:clear, which
on Redis is FLUSHDB — harmless on the default two-database layout,
but on a single-database Redis it takes the sessions and the queue
with it. The compiled caches are cleared individually instead.
- update.sh overwrites itself mid-run, because the zip contains it and
bash reads its own script lazily by byte offset. It re-execs from a
temporary copy before touching anything.
And when the reload is skipped anyway, the application now says so:
projectsend:update records the version it applied, and any staff page
compares that with what the running process actually compiled. The same
check catches the mirror image — new files in place, update never run.
Rehearsed end to end against real installs: a container upgrade (69 to 73
migrations, key and data intact, healthy), a scripted update on a real
nginx + php-fpm install with OPcache pinned (web process moved 2.1.0 to
2.1.1), the skipped-reload case (banner appears naming both versions, and
clears on reload), the refusals (downgrade, non-release zip, truncated
zip, URL passed to --zip, non-root), a database taken down mid-update
(site comes back out of maintenance mode by itself), and a real download
of the published 2.0.0 zip with its checksum verified.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
157 lines
8.5 KiB
PHP
157 lines
8.5 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\RecordsScheduledTaskRuns;
|
|
use App\Modules\Platform\Settings\ExternalStorageConfigApplier;
|
|
use App\Modules\Platform\Settings\MailConfigApplier;
|
|
use App\Modules\Platform\Settings\Settings;
|
|
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,
|
|
]);
|
|
}
|
|
}
|
|
|
|
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();
|
|
|
|
// 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')],
|
|
);
|
|
}
|
|
}
|