mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 09:05:08 +00:00
da1f432d87
Every instance reached projectsend.org twice a day and an operator could stop neither. The news feed had no switch of any kind — FetchNewsCommand went straight to the request, touching Settings only to write results back. The update check had one, but its default is on, and a managed fleet had been setting PROJECTSEND_CHECK_FOR_UPDATES=false for months against code that reads no such variable: check_for_updates is a database setting, so the environment never touched it and updates were enabled fleet-wide the whole time. They look like one problem and are two, which is why they are fixed differently. **The news feed gets a Setting**, its own key, default on. A Cloud client with view_news sees that card today — DashboardController gates it on the permission alone, with a comment saying in as many words that it is both editions and carries no capability. So switching it off is an operator's choice rather than an edition's, and it must stay reachable everywhere. Its own key rather than riding on check_for_updates because they are two different wants: "do not tell me about releases" and "do not show me the project's news" are asked separately, and an installation with no outbound access at all wants both. **The update check gets a capability guard**, ahead of the setting it already had, and deliberately not a Setting of its own. On a managed installation the result is unreachable rather than unwanted: the dashboard's System card and the update UI are both gated on Capability::SystemUpdates, which is Community-only, and the image is chosen by whoever provisioned the instance. A Setting would encode a fact about the edition as a preference — leaving it switchable back on per tenant, buying a nightly call for a number no screen can draw, and putting the reason in a provisioning script rather than beside the code. A self-hosted install holds the capability and loses nothing: its own setting still decides. Both guards return success rather than failure. A scheduled task that was asked not to run has not failed, and reporting it as one would put a red line in the scheduler history every night for an installation behaving exactly as configured. The news switch is on the General settings screen, outside the can_manage_updates block that hides the update toggle where the capability is absent — a setting only reachable by editing a database row is a row, not a switch. Seven tests, and the two that matter go red when either guard is removed. Sixteen locales translated in the same commit rather than left for the pass, since a release is close.
176 lines
7.3 KiB
PHP
176 lines
7.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Modules\Platform\Http\Controllers;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Modules\Audit\Action;
|
|
use App\Modules\Audit\ActivityLogger;
|
|
use App\Modules\Platform\Capabilities\Capability;
|
|
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
|
use App\Modules\Platform\Localization\TimezoneRegistry;
|
|
use App\Modules\Platform\Settings\Setting;
|
|
use App\Modules\Platform\Settings\Settings;
|
|
use App\Modules\Platform\Updates\CheckForUpdates;
|
|
use Carbon\CarbonImmutable;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Validation\Rule;
|
|
use Inertia\Inertia;
|
|
use Inertia\Response;
|
|
|
|
/**
|
|
* System-wide settings (v1's "options"), staff-only. Fine-grained
|
|
* permissions arrive with the Phase 1 role system; until then any staff
|
|
* account may edit.
|
|
*/
|
|
class SystemSettingsController extends Controller
|
|
{
|
|
/**
|
|
* How long an answer from the release feed is treated as still true.
|
|
* Short enough that "check now" means now, long enough that a room
|
|
* full of administrators cannot spend the server's whole allowance.
|
|
*/
|
|
private const CHECK_COOLDOWN_MINUTES = 5;
|
|
|
|
public function __construct(
|
|
private readonly Settings $settings,
|
|
private readonly ActivityLogger $activity,
|
|
private readonly CapabilityRegistry $capabilities,
|
|
private readonly TimezoneRegistry $timezones,
|
|
) {}
|
|
|
|
public function edit(Request $request): Response
|
|
{
|
|
$canManageUpdates = $this->capabilities->has(Capability::SystemUpdates)
|
|
&& $request->user()?->can('manage_updates') === true;
|
|
|
|
return Inertia::render('system/settings/general', [
|
|
'site_name' => $this->settings->get(Setting::SiteName),
|
|
// Resolved rather than raw: the stored value is empty on a
|
|
// fresh install ("whatever APP_TIMEZONE says"), and a picker
|
|
// showing nothing selected would invite an administrator to
|
|
// believe no zone is in effect.
|
|
'timezone' => $this->timezones->default(),
|
|
'timezones' => $this->timezones->options(),
|
|
// The administrator's own zone, when they have chosen one —
|
|
// null otherwise. Sent so the page can warn that this setting
|
|
// will not change what *they* see: their preference outranks
|
|
// it, and without being told they will change the setting,
|
|
// watch nothing move, and conclude it is broken.
|
|
'viewer_timezone' => $request->user()?->timezone,
|
|
'can_manage_updates' => $canManageUpdates,
|
|
'check_for_updates' => $canManageUpdates ? $this->settings->get(Setting::CheckForUpdates) : null,
|
|
// Not behind $canManageUpdates. The news card is both editions
|
|
// and gated on view_news alone (DashboardController), so the
|
|
// switch that turns it off belongs to anyone who may edit
|
|
// settings — including on an installation where the update
|
|
// block above is absent entirely.
|
|
'fetch_news' => $this->settings->get(Setting::FetchNews),
|
|
'last_checked_at' => $canManageUpdates ? $this->lastCheckedAt()?->toIso8601String() : null,
|
|
'check_result' => $request->session()->get('update_check_result'),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Ask the release feed now, rather than waiting for tonight's run.
|
|
*
|
|
* Deliberately not gated on Setting::CheckForUpdates: that switches
|
|
* off the unattended daily call, which is not the same as refusing to
|
|
* answer a question somebody has just asked out loud.
|
|
*/
|
|
public function checkForUpdates(Request $request, CheckForUpdates $check): RedirectResponse
|
|
{
|
|
$canManageUpdates = $this->capabilities->has(Capability::SystemUpdates)
|
|
&& $request->user()?->can('manage_updates') === true;
|
|
|
|
abort_unless($canManageUpdates, 403);
|
|
|
|
// A second throttle, and not a redundant one. The route's bucket is
|
|
// per user; GitHub's unauthenticated rate limit is per server
|
|
// address, so two administrators each within their own allowance
|
|
// can still exhaust the installation's. This one is the whole
|
|
// installation's, and it costs no new setting — the timestamp it
|
|
// reads is the one every check already writes.
|
|
$lastCheckedAt = $this->lastCheckedAt();
|
|
|
|
if ($lastCheckedAt !== null && $lastCheckedAt->gt(now()->subMinutes(self::CHECK_COOLDOWN_MINUTES))) {
|
|
return back()->with('update_check_result', [
|
|
'ok' => true,
|
|
'message' => __('Checked a moment ago, so this is the answer from then.'),
|
|
]);
|
|
}
|
|
|
|
$result = $check->run();
|
|
|
|
return back()->with('update_check_result', [
|
|
'ok' => $result['ok'],
|
|
'message' => $result['message'],
|
|
]);
|
|
}
|
|
|
|
private function lastCheckedAt(): ?CarbonImmutable
|
|
{
|
|
$value = $this->settings->get(Setting::LatestVersionCheckedAt);
|
|
|
|
if (! is_string($value) || $value === '') {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return CarbonImmutable::parse($value);
|
|
} catch (\Throwable) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public function update(Request $request): RedirectResponse
|
|
{
|
|
$canManageUpdates = $this->capabilities->has(Capability::SystemUpdates)
|
|
&& $request->user()?->can('manage_updates') === true;
|
|
|
|
$rules = [
|
|
'site_name' => ['required', 'string', 'max:255'],
|
|
// `sometimes` rather than `required`, same convention as
|
|
// check_for_updates below: a caller that does not send it
|
|
// leaves the zone alone instead of being rejected. There is
|
|
// no such thing as clearing it — the empty stored value means
|
|
// "follow APP_TIMEZONE", and only a fresh install has that.
|
|
'timezone' => ['sometimes', 'string', 'timezone', Rule::in($this->timezones->all())],
|
|
// No capability behind it, unlike check_for_updates below.
|
|
'fetch_news' => ['sometimes', 'boolean'],
|
|
];
|
|
if ($canManageUpdates) {
|
|
// Omitting the field (any caller not sending it, not just this
|
|
// page's own form) leaves the current value alone rather than
|
|
// erroring — same convention as FoldersController::update()'s
|
|
// optional public/slug fields.
|
|
$rules['check_for_updates'] = ['sometimes', 'boolean'];
|
|
}
|
|
|
|
$validated = $request->validate($rules);
|
|
|
|
$this->settings->set(Setting::SiteName, $validated['site_name']);
|
|
|
|
if (array_key_exists('timezone', $validated)) {
|
|
$this->settings->set(Setting::Timezone, $validated['timezone']);
|
|
}
|
|
|
|
// Simply never read from the request when the capability/permission
|
|
// is absent — a hand-crafted PATCH can't smuggle this on for a
|
|
// cloud install or a staff member without manage_updates either.
|
|
if ($canManageUpdates && array_key_exists('check_for_updates', $validated)) {
|
|
$this->settings->set(Setting::CheckForUpdates, $validated['check_for_updates']);
|
|
}
|
|
|
|
if (array_key_exists('fetch_news', $validated)) {
|
|
$this->settings->set(Setting::FetchNews, $validated['fetch_news']);
|
|
}
|
|
|
|
$this->activity->log(Action::SettingsUpdated, context: ['section' => 'general']);
|
|
|
|
return back();
|
|
}
|
|
}
|