Files
ignacionelson 997debc6a3 Let somebody ask for an update instead of waiting for tonight
The check ran daily and there was no other way to run it. An administrator
who has just read that a release fixes the thing bothering them had to
reach a terminal — or wait until tomorrow to be told what the project
announced this morning.

There is now a Check now button beside the setting that schedules it. It
says what came back: the version waiting, or that this installation is
already on the newest. The time of the last check sits next to it, because
the notice on the dashboard is only as good as when it was last refreshed
and nothing said when that was.

Deliberately not gated on the daily-check setting. Switching that off says
"do not have my server phone out unattended", which is not the same
sentence as "refuse to answer when I ask" — so the button works either way
and the setting keeps governing only the schedule.

The work moved out of the command into CheckForUpdates, because the part
that must not drift between the two callers is the part with consequences:
which staff get notified, and the guard that stops them being notified
again for a release they already know about. A second copy of that in a
controller would have been found wrong six months later by somebody
receiving the same notification every time a colleague pressed a button.

Two throttles, and the second is not redundant. The route's bucket is per
user; GitHub's limit is per server address, so two administrators each
within their own allowance can still exhaust the installation's. The
cooldown is installation-wide and costs no new setting — it reads the
timestamp every check already writes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:35:48 -03:00

125 lines
5.5 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Platform\Updates;
use App\Models\User;
use App\Modules\Identity\Permissions\Permission;
use App\Modules\Identity\Permissions\PermissionChecker;
use App\Modules\Identity\UserType;
use App\Modules\Notifications\Notifier;
use App\Modules\Platform\Capabilities\Capability;
use App\Modules\Platform\Capabilities\CapabilityRegistry;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Support\Facades\Http;
/**
* Ask the public repository what the newest release is, and remember the
* answer.
*
* There is no in-app self-updater and this is not one: it reads a feed
* and writes settings. Applying an update is always somebody running
* `update.sh` or pulling a new image.
*
* This lives in a class rather than in the command because there are two
* callers now — the daily scheduled run and the button in the settings —
* and the part that must not drift between them is the part with
* consequences: which staff get notified, and the guard that stops them
* being notified twice for the same release. A second copy of that in a
* controller would be found wrong six months later, by a staff member
* receiving the same notification every time somebody pressed a button.
*/
class CheckForUpdates
{
public function __construct(
private readonly CapabilityRegistry $capabilities,
private readonly Settings $settings,
private readonly PermissionChecker $permissions,
private readonly Notifier $notifier,
) {}
/**
* @return array{ok: bool, outcome: 'unavailable'|'unreachable'|'unrecognised'|'checked', message: string, latest_version: string, update_available: bool}
*/
public function run(): array
{
if (! $this->capabilities->has(Capability::SystemUpdates)) {
return $this->result(true, 'unavailable', __('Update checks are not available on this edition.'));
}
$response = Http::withHeaders(['User-Agent' => 'ProjectSend'])
->timeout(10)
->get('https://api.github.com/repos/projectsend/projectsend/releases/latest');
if (! $response->successful()) {
return $this->result(false, 'unreachable', __('Could not reach the update feed.'));
}
$tag = (string) ($response->json('tag_name') ?? '');
$latestVersion = ltrim($tag, 'v');
// The public repo's tags are still v1's r-number scheme (r2029,
// not SemVer) until a real Community v2 release ships there —
// skip rather than misreport an "update" against a tag we can't
// meaningfully compare.
if (! preg_match('/^\d+\.\d+\.\d+/', $latestVersion)) {
return $this->result(true, 'unrecognised', __('The latest release (:tag) is not a version this can compare.', ['tag' => $tag]));
}
$currentVersion = (string) config('projectsend.version');
$previouslyKnownVersion = $this->settings->get(Setting::LatestKnownVersion);
$this->settings->set(Setting::LatestKnownVersion, $latestVersion);
$this->settings->set(Setting::LatestVersionCheckedAt, now()->toIso8601String());
$this->settings->set(Setting::LatestReleaseTitle, (string) ($response->json('name') ?? $tag));
$this->settings->set(Setting::LatestReleaseNotes, (string) ($response->json('body') ?? ''));
$this->settings->set(Setting::LatestReleaseUrl, (string) ($response->json('html_url') ?? ''));
$this->settings->set(Setting::LatestReleasePublishedAt, (string) ($response->json('published_at') ?? ''));
$updateAvailable = version_compare($latestVersion, $currentVersion, '>');
$alreadyKnown = $previouslyKnownVersion === $latestVersion;
// Only the first time a genuinely newer release is seen. This is
// what keeps the settings button from notifying every staff member
// again on every press, and it belongs here rather than in either
// caller for exactly that reason.
if ($updateAvailable && ! $alreadyKnown) {
$recipients = array_values(User::query()->where('type', UserType::Staff)->get()
->filter(fn (User $staff): bool => $this->permissions->allows($staff, Permission::ManageUpdates))
->all());
$this->notifier->send('update_available', $recipients, data: [
'latestVersion' => $latestVersion,
'currentVersion' => $currentVersion,
]);
}
return $this->result(
true,
'checked',
$updateAvailable
? __('Version :version is available. You are running :current.', ['version' => $latestVersion, 'current' => $currentVersion])
: __('You are up to date, running version :current.', ['current' => $currentVersion]),
$latestVersion,
$updateAvailable,
);
}
/**
* @param 'unavailable'|'unreachable'|'unrecognised'|'checked' $outcome
* @return array{ok: bool, outcome: 'unavailable'|'unreachable'|'unrecognised'|'checked', message: string, latest_version: string, update_available: bool}
*/
private function result(bool $ok, string $outcome, string $message, string $latestVersion = '', bool $updateAvailable = false): array
{
return [
'ok' => $ok,
'outcome' => $outcome,
'message' => $message,
'latest_version' => $latestVersion,
'update_available' => $updateAvailable,
];
}
}