|
| 1 | +<?php |
| 2 | + |
| 3 | +declare(strict_types=1); |
| 4 | +/** |
| 5 | + * @license EUPL-1.2 |
| 6 | + * @copyright Copyright (c) 2025, Conduction B.V. <info@conduction.nl> |
| 7 | + * |
| 8 | + * SPDX-FileCopyrightText: 2025 Conduction B.V. <info@conduction.nl> |
| 9 | + * SPDX-License-Identifier: EUPL-1.2 |
| 10 | + */ |
| 11 | + |
| 12 | + |
| 13 | +namespace OCA\AppVersions\Service\Advisory; |
| 14 | + |
| 15 | +use OCA\AppVersions\AppInfo\Application; |
| 16 | +use OCP\IAppConfig; |
| 17 | +use Psr\Log\LoggerInterface; |
| 18 | + |
| 19 | +/** |
| 20 | + * Persists the most recent full advisory correlation so the admin UI can read |
| 21 | + * a result instead of computing one. |
| 22 | + * |
| 23 | + * WHY THIS EXISTS. Correlating advisories makes two external calls per app |
| 24 | + * (`listAdvisories` + `listVersions`). On an instance with 88 enabled apps |
| 25 | + * that is 176 sequential external calls, which is not work a page-load |
| 26 | + * endpoint can do: measured on a live instance, `GET /api/advisories` did not |
| 27 | + * answer within 120s, twice, and while it held the PHP session lock the |
| 28 | + * sibling `/api/pins` request never ran at all (issue #160). |
| 29 | + * |
| 30 | + * The correlation therefore happens in {@see \OCA\AppVersions\BackgroundJob\AdvisoryRefreshJob}, |
| 31 | + * which writes here, and the endpoint reads. That keeps the feature's COVERAGE |
| 32 | + * — every enabled app is still correlated — and pays for it in staleness |
| 33 | + * rather than in a request that cannot return. The stored `checkedAt` is what |
| 34 | + * lets the UI say how old the answer is instead of implying it is live. |
| 35 | + * |
| 36 | + * @psalm-api |
| 37 | + */ |
| 38 | +class AdvisoryResultStore { |
| 39 | + /** App config key holding the JSON-encoded correlation snapshot. */ |
| 40 | + private const KEY = 'advisory.results'; |
| 41 | + |
| 42 | + /** App config key holding the unix time of the last completed sweep. */ |
| 43 | + private const KEY_CHECKED_AT = 'advisory.results.checkedAt'; |
| 44 | + |
| 45 | + public function __construct( |
| 46 | + private IAppConfig $config, |
| 47 | + private LoggerInterface $logger, |
| 48 | + ) { |
| 49 | + } |
| 50 | + |
| 51 | + /** |
| 52 | + * Stores a completed correlation snapshot and the moment it completed. |
| 53 | + * |
| 54 | + * Only ever called after a sweep finishes, so a half-written snapshot |
| 55 | + * never replaces a good one. |
| 56 | + * |
| 57 | + * @spec openspec/specs/security-advisory-correlation/spec.md |
| 58 | + * @param array<string, array{appId: string, installedVersion: ?string, state: string, advisories: list<array{id: string, severity: string, summary: string}>, recommendedVersion: ?string, error: ?string}> $correlations |
| 59 | + */ |
| 60 | + public function save(array $correlations, int $checkedAt): void { |
| 61 | + try { |
| 62 | + $encoded = json_encode($correlations, JSON_THROW_ON_ERROR); |
| 63 | + } catch (\JsonException $error) { |
| 64 | + // A snapshot that cannot be encoded must not clear the previous |
| 65 | + // one: a stale answer is worth more than no answer, and the UI |
| 66 | + // states its age either way. |
| 67 | + $this->logger->error('AdvisoryResultStore: could not encode correlation snapshot; keeping the previous one', [ |
| 68 | + 'message' => $error->getMessage(), |
| 69 | + ]); |
| 70 | + |
| 71 | + return; |
| 72 | + } |
| 73 | + |
| 74 | + $this->config->setValueString(Application::APP_ID, self::KEY, $encoded); |
| 75 | + $this->config->setValueInt(Application::APP_ID, self::KEY_CHECKED_AT, $checkedAt); |
| 76 | + } |
| 77 | + |
| 78 | + /** |
| 79 | + * Reads the stored snapshot. Returns an empty map with a null `checkedAt` |
| 80 | + * when no sweep has completed yet — which is a real state on a fresh |
| 81 | + * install and must be distinguishable from "swept, found nothing". |
| 82 | + * |
| 83 | + * @spec openspec/specs/security-advisory-correlation/spec.md |
| 84 | + * @return array{advisories: array<string, mixed>, checkedAt: ?int} |
| 85 | + */ |
| 86 | + public function read(): array { |
| 87 | + $raw = $this->config->getValueString(Application::APP_ID, self::KEY, ''); |
| 88 | + if ($raw === '') { |
| 89 | + return ['advisories' => [], 'checkedAt' => null]; |
| 90 | + } |
| 91 | + |
| 92 | + try { |
| 93 | + $decoded = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); |
| 94 | + } catch (\JsonException $error) { |
| 95 | + $this->logger->warning('AdvisoryResultStore: stored snapshot is not valid JSON; reporting as never checked', [ |
| 96 | + 'message' => $error->getMessage(), |
| 97 | + ]); |
| 98 | + |
| 99 | + return ['advisories' => [], 'checkedAt' => null]; |
| 100 | + } |
| 101 | + |
| 102 | + if (!is_array($decoded)) { |
| 103 | + return ['advisories' => [], 'checkedAt' => null]; |
| 104 | + } |
| 105 | + |
| 106 | + $checkedAt = $this->config->getValueInt(Application::APP_ID, self::KEY_CHECKED_AT, 0); |
| 107 | + |
| 108 | + // json_decode yields array-key keys; the keys we wrote are app ids. |
| 109 | + // Stated once here rather than re-derived by every caller. |
| 110 | + /** @var array<string, mixed> $advisories */ |
| 111 | + $advisories = $decoded; |
| 112 | + |
| 113 | + return [ |
| 114 | + 'advisories' => $advisories, |
| 115 | + // 0 means the key was absent. Reporting it as `null` keeps |
| 116 | + // "never checked" one value rather than two. |
| 117 | + 'checkedAt' => $checkedAt > 0 ? $checkedAt : null, |
| 118 | + ]; |
| 119 | + } |
| 120 | +} |
0 commit comments