Files
projectsend/app/Modules/Platform/Updates/UpdateInstallation.php
T
Ignacio Nelson dad8d21dc8 Translate the update strings into all sixteen locales (#1632)
The English for the updater, the stale-code banner and the activity-log
entry had settled, so this is their pass: 208 lines across sixteen
catalogues, every :placeholder intact, no existing entry reordered.

The activity row and its filter label were modelled on each catalogue's
own "Installed ProjectSend" / "ProjectSend was installed" pair, so the
new rows read like the ones beside them rather than like a different
translator wrote them.

One code change belongs to this pass rather than to the feature: the
update entry recorded "an unrecorded version" when the previous version
was unknown, and context values are substituted into the template
verbatim — never translated — so that sentence fragment would have sat
inside an otherwise Japanese or Polish row forever. It is a dash now.

Verified on a real install with the interface switched to Spanish:
"Sistema — Actualizó ProjectSend a 2.0.3, desde 2.0.2", and the action
filter reading "ProjectSend fue actualizado a una versión nueva".
Scan reports 0 missing in every locale; the Locale tests pass.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:13:17 -03:00

300 lines
12 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Platform\Updates;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Identity\Permissions\EnsureSystemRoles;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Console\OutputStyle;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Throwable;
/**
* Everything that has to happen after new code lands, and that the
* application can do to itself.
*
* One definition, three callers: the two container entrypoints run it on
* every boot, and update.sh runs it after unpacking a release on a server
* somebody administers by hand. Before this existed the sequence was
* written out in five places — both entrypoints, INSTALL.md, UPDATE.md and
* a code block in the dashboard — and they had already drifted: only the
* documents mentioned the queue, only the container relinked storage, and
* none of them agreed on which caches to clear.
*
* The order is the design. Each constraint below cost something to find:
*
* - migrate first, because everything after it assumes tables exist;
* - queue:restart last, because it writes its signal *into the cache*,
* so anything that clears the cache afterwards deletes the signal and
* leaves a worker running the old code forever;
* - the caches are cleared one command at a time rather than through
* optimize:clear, which also runs cache:clear — and Laravel's Redis
* cache store implements that as FLUSHDB. On the default config the
* cache sits on its own database and that is harmless; on a managed
* Redis offering one database, or any install that pointed cache and
* sessions at the same one, an update would sign every user out and
* delete the queue. Nothing here needs the data cache dropped:
* Settings::set() already forgets its own key, and cached values whose
* shape changes get a new key. A human who suspects a stale value has
* `php artisan cache:clear`, which UPDATE.md points at.
*
* What it deliberately does not do is maintenance mode. `php artisan down`
* writes storage/framework/maintenance.php, and in the official image
* storage/ is the persisted volume — a container that died between `down`
* and `up` would come back down, and stay down through every recreation.
* update.sh owns maintenance mode, where a trap can guarantee the site
* comes back. This is why the command is safe to run on every boot.
*/
class UpdateInstallation
{
public function __construct(
private readonly Application $app,
private readonly EnsureSystemRoles $roles,
private readonly Settings $settings,
private readonly ActivityLogger $activity,
) {}
/**
* @return array{
* from: string,
* to: string,
* migrated: bool,
* cleared: list<string>,
* rewarmed: list<string>,
* warnings: list<string>,
* ok: bool,
* }
*/
public function run(?OutputStyle $output = null): array
{
$running = (string) config('projectsend.version');
$result = [
'from' => '',
'to' => $running,
'migrated' => false,
'cleared' => [],
'rewarmed' => [],
'warnings' => [],
'ok' => false,
];
// Asked before migrating, because afterwards there is no way to tell
// a fresh installation from an existing one — and the difference
// decides whether this run is an update worth logging or the first
// boot of a brand new install.
$existingInstall = $this->hasRunMigrationsBefore();
// Caught rather than left to surface as a stack trace: the most
// likely failure here is a database that is unreachable or refusing
// the credentials, and forty frames of Laravel internals is a worse
// answer to that than one sentence naming it.
try {
$migrated = $this->artisan('migrate', ['--force' => true], $output) === 0;
} catch (Throwable $exception) {
$result['warnings'][] = 'The database migration failed: '.$exception->getMessage();
$result['warnings'][] = 'Nothing else was changed.';
return $result;
}
if (! $migrated) {
$result['warnings'][] = 'The database migration failed. Nothing else was changed.';
return $result;
}
$result['migrated'] = true;
$result['from'] = $this->previouslyApplied();
$this->roles->ensure();
// Not parity with the old entrypoint line — a fix. The release zip
// ships no public/storage symlink (the build refuses symlinks
// outright, they do not survive zipping), so an installation that
// followed UPDATE.md's "unpack beside it and swap the directories"
// advice loses the link entirely, and nothing in the documented
// sequence ever put it back.
$this->artisan('storage:link', ['--force' => true], $output);
// Read before anything is cleared: this is the only moment the
// question "was this installation using the optional caches?" can
// still be answered.
$warm = $this->warmCaches();
foreach (['config:clear', 'clear-compiled', 'event:clear', 'route:clear', 'view:clear'] as $command) {
if ($this->artisan($command, [], $output) === 0) {
$result['cleared'][] = $command;
}
}
if ($warm['config']) {
$result['warnings'][] = 'A cached configuration was found and cleared. Do not run config:cache on this'
.' application — it stops TRUSTED_PROXIES from being read at all. See INSTALL.md.';
}
foreach ($this->cachesToRewarm($warm) as $command) {
// A route table that will not compile is a slower site; a
// failed update is a broken one. Never fatal.
if ($this->artisan($command, [], $output) === 0) {
$result['rewarmed'][] = $command;
continue;
}
$result['warnings'][] = "{$command} failed, so that cache is not in place. The site runs without it.";
}
$this->settings->set(Setting::AppliedVersion, $running);
$this->settings->set(Setting::AppliedVersionAt, now()->toIso8601String());
$warning = $this->recordTheUpdate($existingInstall, $result['from'], $running);
if ($warning !== null) {
$result['warnings'][] = $warning;
}
// Last, and after every cache operation above — see the class
// docblock. A worker only learns to exit by reading this signal.
$this->artisan('queue:restart', [], $output);
$result['ok'] = true;
return $result;
}
/**
* Put the update in the activity log — the one place an administrator
* looks to answer "what changed on this installation, and when".
*
* Only a genuine version change is recorded. The container entrypoint
* runs this on every boot, so logging unconditionally would bury the
* log under an entry per restart; and a fresh installation is an
* install, not an update, which SetupCompleted already covers.
*
* "Fresh" is decided by whether migrations had ever run before this
* one, rather than by whether a version was recorded: the first update
* of any installation older than this command finds no recorded
* version, and that update is exactly the one worth logging.
*/
private function recordTheUpdate(bool $existingInstall, string $from, string $to): ?string
{
if (! $existingInstall || $from === $to) {
return null;
}
try {
$this->activity->logSystem(Action::ApplicationUpdated, [
// The previous version is unknown exactly once per
// installation: the first update after adopting this command.
// A dash rather than a sentence, because context values are
// substituted into the template verbatim and never
// translated — an English phrase here would sit inside an
// otherwise Japanese or Polish row forever.
'from' => $from !== '' ? $from : '—',
'to' => $to,
]);
} catch (Throwable $exception) {
// An update that worked must not report failure because its own
// paperwork did.
return 'The update could not be written to the activity log: '.$exception->getMessage();
}
return null;
}
/**
* Whether this database has been migrated before — i.e. whether there
* was an installation here at all before this run.
*/
protected function hasRunMigrationsBefore(): bool
{
try {
return Schema::hasTable('migrations') && DB::table('migrations')->exists();
} catch (Throwable) {
// No database yet is not an existing installation.
return false;
}
}
/**
* What the previous run of this recorded, or '' when there was none.
*
* Swallows failures on purpose: by this point the schema is current,
* but a cache store that is momentarily unreachable must not fail a
* container boot over a line of reporting.
*/
private function previouslyApplied(): string
{
try {
$applied = $this->settings->get(Setting::AppliedVersion);
return is_string($applied) ? $applied : '';
} catch (Throwable) {
return '';
}
}
/**
* Which optional caches this installation had in place.
*
* Asked through the framework's own path accessors rather than the
* filenames: `routes-v7.php` is an internal that changes with major
* versions. And by file_exists() rather than $app->routesAreCached(),
* which memoises at bootstrap and would still answer true after
* route:clear ran in this same process.
*
* @return array{route: bool, event: bool, config: bool}
*/
protected function warmCaches(): array
{
return [
'route' => file_exists($this->app->getCachedRoutesPath()),
'event' => file_exists($this->app->getCachedEventsPath()),
'config' => file_exists($this->app->getCachedConfigPath()),
];
}
/**
* Views have no honest signal of their own — storage/framework/views
* fills up from ordinary traffic, cached deliberately or not. So the
* route and event caches stand in for the set: their presence means
* this installation followed INSTALL.md's "Making it faster", which
* lists all three together. A container caches none of them and so
* rebuilds nothing, which keeps boot as fast as it is today.
*
* config:cache is never rebuilt, at any time, for any installation.
*
* @param array{route: bool, event: bool, config: bool} $warm
* @return list<string>
*/
private function cachesToRewarm(array $warm): array
{
if (! $warm['route'] && ! $warm['event']) {
return [];
}
return ['route:cache', 'event:cache', 'view:cache'];
}
/**
* Protected so a test can watch the sequence without running it. The
* ordering constraints in run() are invisible in its result and
* catastrophic when wrong, and an ordered list of calls is the only
* thing that can assert them.
*
* @param array<string, mixed> $parameters
*/
protected function artisan(string $command, array $parameters = [], ?OutputStyle $output = null): int
{
return Artisan::call($command, $parameters, $output);
}
}