mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-16 08:35:07 +00:00
997debc6a3
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>
125 lines
5.5 KiB
PHP
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,
|
|
];
|
|
}
|
|
}
|