Skip to content

Commit d5cd289

Browse files
committed
feat(setup-checks): let ExApps surface setup checks in the admin overview
Signed-off-by: Oleksander Piskun <oleksandr2088@icloud.com>
1 parent 33ba5a7 commit d5cd289

17 files changed

Lines changed: 1605 additions & 0 deletions

appinfo/info.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ See the [admin documentation](https://docs.nextcloud.com/server/latest/admin_man
6262
</dependencies>
6363
<background-jobs>
6464
<job>OCA\AppAPI\BackgroundJob\ExAppInitStatusCheckJob</job>
65+
<job>OCA\AppAPI\BackgroundJob\ExAppSetupChecksRefreshJob</job>
6566
</background-jobs>
6667
<repair-steps>
6768
<post-migration>

appinfo/routes.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,5 +145,9 @@
145145
['name' => 'taskProcessing#registerProvider', 'url' => '/api/v1/ai_provider/task_processing', 'verb' => 'POST'],
146146
['name' => 'taskProcessing#unregisterProvider', 'url' => '/api/v1/ai_provider/task_processing', 'verb' => 'DELETE'],
147147
['name' => 'taskProcessing#getProvider', 'url' => '/api/v1/ai_provider/task_processing', 'verb' => 'GET'],
148+
149+
// Setup checks (admin "Security & setup warnings" panel)
150+
['name' => 'SetupCheck#registerChecks', 'url' => '/api/v1/setup_check', 'verb' => 'POST'],
151+
['name' => 'SetupCheck#unregisterChecks', 'url' => '/api/v1/setup_check', 'verb' => 'DELETE'],
148152
],
149153
];

lib/AppInfo/Application.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
use OCA\AppAPI\Notifications\ExAppNotifier;
2626
use OCA\AppAPI\PublicCapabilities;
2727
use OCA\AppAPI\SetupChecks\DaemonCheck;
28+
use OCA\AppAPI\SetupChecks\ExAppsErrorSetupCheck;
29+
use OCA\AppAPI\SetupChecks\ExAppsWarningSetupCheck;
2830
use OCA\AppAPI\SetupChecks\HarpVersionCheck;
2931
use OCA\DAV\Events\SabrePluginAddEvent;
3032
use OCA\DAV\Events\SabrePluginAuthInitEvent;
@@ -74,6 +76,8 @@ public function register(IRegistrationContext $context): void {
7476

7577
$context->registerSetupCheck(DaemonCheck::class);
7678
$context->registerSetupCheck(HarpVersionCheck::class);
79+
$context->registerSetupCheck(ExAppsErrorSetupCheck::class);
80+
$context->registerSetupCheck(ExAppsWarningSetupCheck::class);
7781
}
7882

7983
public function boot(IBootContext $context): void {
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\AppAPI\BackgroundJob;
11+
12+
use OCA\AppAPI\Service\ExAppSetupCheckRefreshService;
13+
use OCP\AppFramework\Utility\ITimeFactory;
14+
use OCP\BackgroundJob\TimedJob;
15+
16+
/**
17+
* Baseline refresh of ExApp setup-check results, so they stay reasonably fresh even when nobody opens
18+
* the admin Overview (e.g. for `occ setupchecks` / monitoring). An admin visiting the page also fires
19+
* an immediate one-off refresh ({@see ExAppSetupChecksRefreshOnceJob}).
20+
*/
21+
class ExAppSetupChecksRefreshJob extends TimedJob {
22+
private const REFRESH_INTERVAL_SECONDS = 600; // 10 minutes
23+
24+
public function __construct(
25+
ITimeFactory $time,
26+
private readonly ExAppSetupCheckRefreshService $refreshService,
27+
) {
28+
parent::__construct($time);
29+
$this->setInterval(self::REFRESH_INTERVAL_SECONDS);
30+
}
31+
32+
protected function run($argument): void {
33+
$this->refreshService->refresh();
34+
}
35+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\AppAPI\BackgroundJob;
11+
12+
use OCA\AppAPI\Service\ExAppSetupCheckRefreshService;
13+
use OCP\AppFramework\Utility\ITimeFactory;
14+
use OCP\BackgroundJob\QueuedJob;
15+
16+
/**
17+
* One-off refresh of ExApp setup-check results, enqueued by {@see \OCA\AppAPI\SetupChecks\ExAppsSetupCheck}
18+
* when an admin opens the "Security & setup warnings" page (stale-while-revalidate). Enqueued via
19+
* IJobList::add(), which is idempotent, so visiting repeatedly never piles up duplicate jobs.
20+
*/
21+
class ExAppSetupChecksRefreshOnceJob extends QueuedJob {
22+
public function __construct(
23+
ITimeFactory $time,
24+
private readonly ExAppSetupCheckRefreshService $refreshService,
25+
) {
26+
parent::__construct($time);
27+
}
28+
29+
protected function run($argument): void {
30+
$this->refreshService->refresh();
31+
}
32+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\AppAPI\Controller;
11+
12+
use OCA\AppAPI\AppInfo\Application;
13+
use OCA\AppAPI\Attribute\AppAPIAuth;
14+
use OCA\AppAPI\Service\ExAppSetupCheckService;
15+
use OCP\AppFramework\Http;
16+
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
17+
use OCP\AppFramework\Http\Attribute\PublicPage;
18+
use OCP\AppFramework\Http\DataResponse;
19+
use OCP\AppFramework\OCSController;
20+
use OCP\IRequest;
21+
22+
class SetupCheckController extends OCSController {
23+
protected $request;
24+
25+
public function __construct(
26+
IRequest $request,
27+
private readonly ExAppSetupCheckService $setupCheckService,
28+
) {
29+
parent::__construct(Application::APP_ID, $request);
30+
31+
$this->request = $request;
32+
}
33+
34+
/**
35+
* Opt the calling ExApp in to setup checks
36+
*
37+
* The ExApp is identified from the authenticated `ex-app-id` header, so it can only ever opt
38+
* itself in. Its live results are then fetched from the ExApp's `/setup_checks` endpoint by a
39+
* background job and surfaced in the admin "Security & setup warnings" panel.
40+
*
41+
* @return DataResponse<Http::STATUS_OK, list<empty>, array{}>
42+
*
43+
* 200: ExApp opted in to setup checks
44+
*/
45+
#[AppAPIAuth]
46+
#[NoCSRFRequired]
47+
#[PublicPage]
48+
public function registerChecks(): DataResponse {
49+
$this->setupCheckService->optIn($this->request->getHeader('ex-app-id'));
50+
return new DataResponse();
51+
}
52+
53+
/**
54+
* Opt the calling ExApp out of setup checks
55+
*
56+
* @return DataResponse<Http::STATUS_OK, list<empty>, array{}>
57+
*
58+
* 200: ExApp opted out of setup checks
59+
*/
60+
#[AppAPIAuth]
61+
#[NoCSRFRequired]
62+
#[PublicPage]
63+
public function unregisterChecks(): DataResponse {
64+
$this->setupCheckService->optOut($this->request->getHeader('ex-app-id'));
65+
return new DataResponse();
66+
}
67+
}

lib/Service/ExAppService.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ public function __construct(
5656
private readonly SettingsService $settingsService,
5757
private readonly ExAppOccService $occService,
5858
private readonly ExAppDeployOptionsService $deployOptionsService,
59+
private readonly ExAppSetupCheckService $setupCheckService,
5960
private readonly IConfig $config,
6061
) {
6162
if ($cacheFactory->isAvailable()) {
@@ -119,6 +120,7 @@ public function unregisterExApp(string $appId): bool {
119120
$this->stylesService->deleteExAppStyles($appId);
120121
$this->taskProcessingService->unregisterExAppTaskProcessingProviders($appId);
121122
$this->settingsService->unregisterExAppForms($appId);
123+
$this->setupCheckService->optOut($appId);
122124
$this->exAppArchiveFetcher->removeExAppFolder($appId);
123125
$this->occService->unregisterExAppOccCommands($appId);
124126
$this->deployOptionsService->removeExAppDeployOptions($appId);
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\AppAPI\Service;
11+
12+
use OCA\AppAPI\Db\ExApp;
13+
use OCP\Http\Client\IResponse;
14+
use OCP\IL10N;
15+
use Psr\Log\LoggerInterface;
16+
17+
/**
18+
* Polls each declaring ExApp's `/setup_checks` endpoint and stores the computed issues, so the
19+
* admin "Security & setup warnings" page (see {@see \OCA\AppAPI\SetupChecks\ExAppsSetupCheck}) never
20+
* does any HTTP itself. Runs only in the background (a TimedJob + an on-demand QueuedJob fired when an
21+
* admin opens the page), so a slow/down/errored ExApp can never slow the page - it only costs time
22+
* here. It catches all its own errors and never throws.
23+
*
24+
* Text produced here is in the server-default language (there is no viewer in a background job);
25+
* ExApp-provided text is monolingual either way. All ExApp-provided values are length-capped and the
26+
* link URL is validated; HTML-escaping happens at render time in the SetupCheck.
27+
*/
28+
class ExAppSetupCheckRefreshService {
29+
private const PER_APP_TIMEOUT_SECONDS = 5;
30+
private const MAX_RESPONSE_BYTES = 262144; // 256 KiB
31+
private const MAX_RESPONSE_ENTRIES = 1000;
32+
private const MAX_TEXT_LENGTH = 4096;
33+
34+
public function __construct(
35+
private readonly IL10N $l10n,
36+
private readonly LoggerInterface $logger,
37+
private readonly ExAppSetupCheckService $setupCheckService,
38+
private readonly ExAppService $exAppService,
39+
private readonly AppAPIService $appAPIService,
40+
// wall-clock budget for the whole sweep; injectable so tests can force a partial sweep
41+
private readonly float $totalBudgetSeconds = 120.0,
42+
) {
43+
}
44+
45+
public function refresh(): void {
46+
try {
47+
// Carry-over source: a partial sweep (time budget) must NOT clear the issues of apps it did
48+
// not reach this run, or the admin Overview would briefly flip them to healthy.
49+
$previous = $this->setupCheckService->getState()['apps'];
50+
$appsState = [];
51+
$start = microtime(true);
52+
$budgetHit = false;
53+
foreach ($this->setupCheckService->getOptedInAppIds() as $appId) {
54+
$exApp = $this->exAppService->getExApp($appId);
55+
if ($exApp === null || $exApp->getEnabled() !== 1 || $this->isInitializing($exApp)) {
56+
continue; // disabled / deploying -> dropped (down-ness shown on the management page)
57+
}
58+
if ($budgetHit || (microtime(true) - $start) > $this->totalBudgetSeconds) {
59+
if (!$budgetHit) {
60+
$this->logger->info('ExApp setup-check refresh budget exceeded; unvisited apps keep their previous results this run');
61+
$budgetHit = true;
62+
}
63+
if (isset($previous[$appId]) && is_array($previous[$appId])) {
64+
$appsState[$appId] = $previous[$appId]; // stale-but-present beats vanishing
65+
}
66+
continue;
67+
}
68+
$issues = $this->probe($exApp);
69+
if ($issues !== []) {
70+
$appsState[$appId] = $issues;
71+
}
72+
}
73+
$this->setupCheckService->storeState($appsState);
74+
} catch (\Throwable $e) {
75+
$this->logger->error('ExApp setup-check refresh failed', ['exception' => $e]);
76+
}
77+
}
78+
79+
private function isInitializing(ExApp $exApp): bool {
80+
$status = $exApp->getStatus();
81+
return ((int)($status['init'] ?? 100)) < 100 || (($status['action'] ?? '') === 'init');
82+
}
83+
84+
/**
85+
* @return list<array{severity: string, appName: string, text: string, linkUrl: string, linkLabel: string}>
86+
*/
87+
private function probe(ExApp $exApp): array {
88+
try {
89+
$result = $this->appAPIService->requestToExApp(
90+
$exApp,
91+
'/setup_checks',
92+
null,
93+
'GET',
94+
[],
95+
['timeout' => self::PER_APP_TIMEOUT_SECONDS],
96+
);
97+
} catch (\Throwable $e) {
98+
$this->logger->warning('ExApp setup-check: error probing ExApp ' . $exApp->getAppid(), ['exception' => $e]);
99+
return [$this->notRespondingIssue($exApp)];
100+
}
101+
102+
if (is_array($result)) {
103+
return [$this->notRespondingIssue($exApp)]; // transport error -> ['error' => ...]
104+
}
105+
/** @var IResponse $result */
106+
$status = $result->getStatusCode();
107+
if ($status < 200 || $status >= 300) {
108+
return [$this->notRespondingIssue($exApp)];
109+
}
110+
// Require a sane, numeric Content-Length and refuse to read the body otherwise. A missing /
111+
// forged / non-numeric length is treated as not-responding: it is the only way to keep an
112+
// opted-in ExApp from streaming an unbounded (e.g. chunked) body that getBody() would buffer
113+
// whole and OOM the cron worker. With a declared length the HTTP client reads at most that
114+
// many bytes, so the materialization below is bounded.
115+
$contentLength = $result->getHeader('Content-Length');
116+
// ctype_digit (not is_numeric, which accepts -1 / 12.5 / 1e3, and not '' which is false here):
117+
// Content-Length must be digits only.
118+
if (!ctype_digit($contentLength) || (int)$contentLength > self::MAX_RESPONSE_BYTES) {
119+
$this->logger->warning('ExApp setup-check: missing or oversized Content-Length from ExApp ' . $exApp->getAppid());
120+
return [$this->notRespondingIssue($exApp)];
121+
}
122+
$bodyStr = (string)$result->getBody();
123+
if (strlen($bodyStr) > self::MAX_RESPONSE_BYTES) {
124+
$this->logger->warning('ExApp setup-check: oversized response body from ExApp ' . $exApp->getAppid());
125+
return [$this->notRespondingIssue($exApp)];
126+
}
127+
$body = json_decode($bodyStr, true);
128+
if (!is_array($body)) {
129+
return [$this->notRespondingIssue($exApp)];
130+
}
131+
return $this->parseResponse($exApp, $body);
132+
}
133+
134+
/**
135+
* @param array<array-key, mixed> $body map of `{checkId: {status, text, link_url?, link_label?}}`
136+
* @return list<array{severity: string, appName: string, text: string, linkUrl: string, linkLabel: string}>
137+
*/
138+
private function parseResponse(ExApp $exApp, array $body): array {
139+
$issues = [];
140+
$scanned = 0;
141+
foreach ($body as $result) {
142+
if (count($issues) >= ExAppSetupCheckService::MAX_CHECKS || $scanned >= self::MAX_RESPONSE_ENTRIES) {
143+
break;
144+
}
145+
$scanned++;
146+
if (!is_array($result)) {
147+
continue;
148+
}
149+
$severity = $this->mapSeverity(is_string($result['status'] ?? null) ? $result['status'] : '');
150+
if ($severity === null) {
151+
continue; // success / unknown -> not an issue
152+
}
153+
$text = $this->capLength(is_string($result['text'] ?? null) ? $result['text'] : '');
154+
$issues[] = [
155+
'severity' => $severity,
156+
'appName' => $this->appName($exApp),
157+
'text' => $text !== '' ? $text : $this->l10n->t('reported a problem'),
158+
'linkUrl' => $this->safeUrl(is_string($result['link_url'] ?? null) ? $result['link_url'] : ''),
159+
'linkLabel' => $this->capLength(is_string($result['link_label'] ?? null) ? $result['link_label'] : ''),
160+
];
161+
}
162+
return $issues;
163+
}
164+
165+
/**
166+
* @return array{severity: string, appName: string, text: string, linkUrl: string, linkLabel: string}
167+
*/
168+
private function notRespondingIssue(ExApp $exApp): array {
169+
return [
170+
'severity' => 'warning',
171+
'appName' => $this->appName($exApp),
172+
'text' => $this->l10n->t('not responding'),
173+
'linkUrl' => '',
174+
'linkLabel' => '',
175+
];
176+
}
177+
178+
private function appName(ExApp $exApp): string {
179+
$name = $exApp->getName();
180+
return $name !== '' ? $name : $exApp->getAppid();
181+
}
182+
183+
private function mapSeverity(string $status): ?string {
184+
return match (strtolower(trim($status))) {
185+
'warning' => 'warning',
186+
'error' => 'error',
187+
default => null, // success / info / ok / empty / unknown -> not surfaced
188+
};
189+
}
190+
191+
private function safeUrl(string $url): string {
192+
if (preg_match('#^https?://#i', $url) !== 1) {
193+
return '';
194+
}
195+
if (preg_match('/[\x00-\x20\x7f"\'<>]/', $url) === 1) {
196+
return '';
197+
}
198+
return $url;
199+
}
200+
201+
private function capLength(string $text): string {
202+
return mb_strlen($text) > self::MAX_TEXT_LENGTH ? mb_substr($text, 0, self::MAX_TEXT_LENGTH) : $text;
203+
}
204+
}

0 commit comments

Comments
 (0)