Skip to content
23 changes: 20 additions & 3 deletions lib/BackgroundJob/AdvisoryRefreshJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@

namespace OCA\AppVersions\BackgroundJob;

use OCA\AppVersions\Service\Advisory\AdvisoryDigestNotifier;
use OCA\AppVersions\Service\Advisory\AdvisoryNotifier;
use OCA\AppVersions\Service\Advisory\AdvisoryResultStore;
use OCA\AppVersions\Service\Advisory\AdvisoryService;
use OCA\AppVersions\Service\Advisory\AdvisorySettingsStore;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\TimedJob;
use Psr\Log\LoggerInterface;
Expand All @@ -29,8 +31,6 @@
* @psalm-api
*/
class AdvisoryRefreshJob extends TimedJob {
/** Re-resolve advisories every 6 hours. */
private const INTERVAL_SECONDS = 6 * 60 * 60;

/**
* Wall-clock ceiling for the sweep, in seconds.
Expand All @@ -50,11 +50,19 @@ public function __construct(
ITimeFactory $time,
private AdvisoryService $advisoryService,
private AdvisoryNotifier $advisoryNotifier,
private AdvisoryDigestNotifier $digestNotifier,
private AdvisoryResultStore $resultStore,
// NOT promoted to a property: the interval is read exactly once, here.
// TimedJob fixes its interval at construction, so keeping a reference
// would suggest the job can re-read the setting mid-life, which it
// cannot — the next run after a change picks up the new value because
// the job is constructed afresh.
AdvisorySettingsStore $settings,
private LoggerInterface $logger,
) {
parent::__construct($time);
$this->setInterval(self::INTERVAL_SECONDS);
// Administrator-settable: 6h default, 1–24 supported.
$this->setInterval($settings->getIntervalSeconds());
}

/**
Expand Down Expand Up @@ -98,6 +106,15 @@ protected function run($argument): void {
if ($fired > 0) {
$this->logger->info('AdvisoryRefreshJob: raised advisory notifications', ['count' => $fired]);
}

// The weekly digest of everything that is NOT urgent. It rate-
// limits itself, so calling it on every sweep is correct — the
// sweep runs up to 24 times a day and the digest still sends once
// a week.
$digested = $this->digestNotifier->sendIfDue($correlations, $this->time->getTime());
if ($digested > 0) {
$this->logger->info('AdvisoryRefreshJob: sent the weekly advisory digest', ['recipients' => $digested]);
}
} catch (\Throwable $error) {
$this->logger->error('AdvisoryRefreshJob: refresh failed', ['message' => $error->getMessage()]);
}
Expand Down
94 changes: 94 additions & 0 deletions lib/Controller/ApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use OCA\AppVersions\Db\Pat;
use OCA\AppVersions\Db\PatMapper;
use OCA\AppVersions\Service\Advisory\AdvisoryResultStore;
use OCA\AppVersions\Service\Advisory\AdvisorySettingsStore;
use OCA\AppVersions\Service\AutoUpdate\AutoUpdateSettingsStore;
use OCA\AppVersions\Service\AutoUpdate\AutoUpdateWindow;
use OCA\AppVersions\Service\Cache\ArtifactCache;
Expand Down Expand Up @@ -63,6 +64,7 @@ public function __construct(
private PatExpiryEvaluator $patExpiryEvaluator,
private DiscoveryAggregator $discoveryAggregator,
private AdvisoryResultStore $advisoryResultStore,
private AdvisorySettingsStore $advisorySettingsStore,
private AuditEntryMapper $auditEntryMapper,
private PinStore $pinStore,
private IAppManager $appManager,
Expand Down Expand Up @@ -801,6 +803,98 @@ public function updateAutoUpdateSettings(?string $enabled = null): DataResponse
]);
}

/**
* Returns the advisory check settings: how often the sweep runs and
* whether the weekly digest is sent (admin-only).
*
* The supported bounds travel WITH the values. A client that has to
* hardcode the range in order to build a control will drift from the
* server the first time the range changes.
*
* @return DataResponse<Http::STATUS_OK, array{intervalHours: int, minIntervalHours: int, maxIntervalHours: int, digestEnabled: bool}, array{}>|DataResponse<Http::STATUS_FORBIDDEN, array{message: string}, array{}>
*
* 200: Advisory settings returned
* 403: Caller is not an administrator
*
* @spec openspec/specs/security-advisory-correlation/spec.md
*/
#[ApiRoute(verb: 'GET', url: '/api/advisory/settings')]
public function advisorySettings(): DataResponse {
if (!$this->isAdmin()) {
return new DataResponse(['message' => 'Forbidden'], Http::STATUS_FORBIDDEN);
}

return new DataResponse([
'intervalHours' => $this->advisorySettingsStore->getIntervalHours(),
'minIntervalHours' => AdvisorySettingsStore::MIN_INTERVAL_HOURS,
'maxIntervalHours' => AdvisorySettingsStore::MAX_INTERVAL_HOURS,
'digestEnabled' => $this->advisorySettingsStore->isDigestEnabled(),
]);
}

/**
* Updates the advisory check settings (admin-only).
*
* An out-of-range interval is REJECTED here rather than silently clamped,
* because a UI that asks for 48 hours and is answered "200 OK" while the
* server stores 24 has been lied to. The store still clamps, for values
* that arrive by other routes such as `occ config:app:set`.
*
* @param ?string $intervalHours How often the sweep runs, in hours. Omitted leaves it unchanged.
* @param ?string $digestEnabled Whether the weekly digest is sent ('1'/'0'). Omitted leaves it unchanged.
* @return DataResponse<Http::STATUS_OK, array{intervalHours: int, minIntervalHours: int, maxIntervalHours: int, digestEnabled: bool}, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{message: string}, array{}>|DataResponse<Http::STATUS_FORBIDDEN, array{message: string}, array{}>
*
* 200: Advisory settings updated
* 400: intervalHours outside the supported range
* 403: Caller is not an administrator
*
* @spec openspec/specs/security-advisory-correlation/spec.md
*/
#[ApiRoute(verb: 'PUT', url: '/api/advisory/settings')]
#[PasswordConfirmationRequired]
public function updateAdvisorySettings(?string $intervalHours = null, ?string $digestEnabled = null): DataResponse {
if (!$this->isAdmin()) {
return new DataResponse(['message' => 'Forbidden'], Http::STATUS_FORBIDDEN);
}

if ($intervalHours !== null && $intervalHours !== '') {
if (!is_numeric($intervalHours)) {
return new DataResponse(
['message' => 'intervalHours must be a number.'],
Http::STATUS_BAD_REQUEST
);
}
$hours = (int)$intervalHours;
if ($hours < AdvisorySettingsStore::MIN_INTERVAL_HOURS || $hours > AdvisorySettingsStore::MAX_INTERVAL_HOURS) {
return new DataResponse(
['message' => sprintf(
'intervalHours must be between %d and %d.',
AdvisorySettingsStore::MIN_INTERVAL_HOURS,
AdvisorySettingsStore::MAX_INTERVAL_HOURS,
)],
Http::STATUS_BAD_REQUEST
);
}
$this->advisorySettingsStore->setIntervalHours($hours);
}

// Same empty-string-is-an-explicit-false handling as the auto-update
// kill switch above: PHP casts a JSON `false` to "", not "0".
if ($digestEnabled !== null) {
$digestParam = ($digestEnabled === '') ? '0' : $digestEnabled;
$this->advisorySettingsStore->setDigestEnabled(
$this->readBinaryBool($digestParam, $this->advisorySettingsStore->isDigestEnabled()),
);
}

return new DataResponse([
'intervalHours' => $this->advisorySettingsStore->getIntervalHours(),
'minIntervalHours' => AdvisorySettingsStore::MIN_INTERVAL_HOURS,
'maxIntervalHours' => AdvisorySettingsStore::MAX_INTERVAL_HOURS,
'digestEnabled' => $this->advisorySettingsStore->isDigestEnabled(),
]);
}

/**
* Lists PATs visible to the current admin, redacted, with derived
* `expiryState`/`daysRemaining`; see "PAT management API" and
Expand Down
144 changes: 144 additions & 0 deletions lib/Service/Advisory/AdvisoryDigestNotifier.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
<?php

declare(strict_types=1);
/**
* @license EUPL-1.2
* @copyright Copyright (c) 2025, Conduction B.V. <info@conduction.nl>
*
* SPDX-FileCopyrightText: 2025 Conduction B.V. <info@conduction.nl>
* SPDX-License-Identifier: EUPL-1.2
*/


namespace OCA\AppVersions\Service\Advisory;

use OCA\AppVersions\AppInfo\Application;
use OCP\IAppConfig;
use OCP\IGroupManager;
use OCP\Notification\IManager;
use Psr\Log\LoggerInterface;
use Throwable;

/**
* A weekly summary of advisories that are NOT urgent — apps with a security
* history whose installed version is already safe.
*
* WHY A DIGEST RATHER THAN MORE NOTIFICATIONS. {@see AdvisoryNotifier} fires
* immediately when an installed version is actually inside an affected range,
* and that must stay rare enough to be read. Informational advisories are far
* more numerous — the published feed averages several new records a month
* across 53 packages — so notifying on each would train administrators to
* dismiss the channel that carries the urgent ones.
*
* Like the urgent notifier, this class has NO dependency on any installer or
* version-mutation service: it can only inform.
*
* @psalm-api
*/
class AdvisoryDigestNotifier {
private const CONFIG_LAST_SENT = 'advisory.digest_last_sent';

/** Seven days. */
private const DIGEST_INTERVAL_SECONDS = 7 * 24 * 60 * 60;

public function __construct(
private IManager $notificationManager,
private IGroupManager $groupManager,
private IAppConfig $config,
private AdvisorySettingsStore $settings,
private LoggerInterface $logger,
) {
}

/**
* Sends the digest if one is due, and reports how many admins received it.
*
* Returns 0 — without sending — when the digest is disabled, when one was
* sent inside the last seven days, or when there is nothing informational
* to report. A digest that says "nothing to report" every week is how a
* channel stops being read.
*
* @spec openspec/specs/security-advisory-correlation/spec.md
* @param array<string, array{appId: string, installedVersion: ?string, state: string, advisories: list<array{id: string, severity: string, summary: string}>, recommendedVersion: ?string, error: ?string}> $correlations
*/
public function sendIfDue(array $correlations, int $now): int {
if (!$this->settings->isDigestEnabled()) {
return 0;
}

$lastSent = $this->config->getValueInt(Application::APP_ID, self::CONFIG_LAST_SENT, 0);
if ($lastSent > 0 && ($now - $lastSent) < self::DIGEST_INTERVAL_SECONDS) {
return 0;
}

$informational = array_values(array_filter(
$correlations,
static fn (array $entry): bool => $entry['state'] === AdvisoryService::STATE_AVAILABLE
&& $entry['advisories'] !== [],
));
if ($informational === []) {
// Nothing to say. The clock is NOT advanced, so the first week
// with something to report sends immediately rather than waiting
// out a window that was consumed by silence.
return 0;
}

$appCount = count($informational);
$advisoryCount = array_sum(array_map(
static fn (array $entry): int => count($entry['advisories']),
$informational,
));

$fired = 0;
foreach ($this->adminUids() as $uid) {
if ($this->fire($uid, $appCount, $advisoryCount)) {
$fired++;
}
}

// Only record a send that actually reached someone. Advancing the
// clock on a failed dispatch would suppress the next seven days of
// digests as well.
if ($fired > 0) {
$this->config->setValueInt(Application::APP_ID, self::CONFIG_LAST_SENT, $now);
}

return $fired;
}

private function fire(string $uid, int $appCount, int $advisoryCount): bool {
try {
$notification = $this->notificationManager->createNotification();
$notification->setApp(Application::APP_ID)
->setDateTime(new \DateTime())
->setUser($uid)
->setObject('advisory_digest', (string)$appCount)
->setSubject('advisory_digest', [
'apps' => $appCount,
'advisories' => $advisoryCount,
]);
$this->notificationManager->notify($notification);

return true;
} catch (Throwable $error) {
$this->logger->warning('AdvisoryDigestNotifier: could not notify admin', [
'user' => $uid,
'message' => $error->getMessage(),
]);

return false;
}
}

/**
* @return list<string>
*/
private function adminUids(): array {
$uids = [];
foreach ($this->groupManager->get('admin')?->getUsers() ?? [] as $user) {
$uids[] = $user->getUID();
}

return $uids;
}
}
Loading
Loading