Skip to content

Commit 6f8c0a5

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 6f8c0a5

15 files changed

Lines changed: 1432 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: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
use OCA\AppAPI\Notifications\ExAppNotifier;
2626
use OCA\AppAPI\PublicCapabilities;
2727
use OCA\AppAPI\SetupChecks\DaemonCheck;
28+
use OCA\AppAPI\SetupChecks\ExAppsSetupCheck;
2829
use OCA\AppAPI\SetupChecks\HarpVersionCheck;
2930
use OCA\DAV\Events\SabrePluginAddEvent;
3031
use OCA\DAV\Events\SabrePluginAuthInitEvent;
@@ -74,6 +75,7 @@ public function register(IRegistrationContext $context): void {
7475

7576
$context->registerSetupCheck(DaemonCheck::class);
7677
$context->registerSetupCheck(HarpVersionCheck::class);
78+
$context->registerSetupCheck(ExAppsSetupCheck::class);
7779
}
7880

7981
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: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
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 TOTAL_BUDGET_SECONDS = 120.0;
31+
private const MAX_RESPONSE_BYTES = 262144; // 256 KiB
32+
private const MAX_RESPONSE_ENTRIES = 1000;
33+
private const MAX_TEXT_LENGTH = 4096;
34+
35+
public function __construct(
36+
private readonly IL10N $l10n,
37+
private readonly LoggerInterface $logger,
38+
private readonly ExAppSetupCheckService $setupCheckService,
39+
private readonly ExAppService $exAppService,
40+
private readonly AppAPIService $appAPIService,
41+
) {
42+
}
43+
44+
public function refresh(): void {
45+
try {
46+
$appsState = [];
47+
$start = microtime(true);
48+
foreach ($this->setupCheckService->getOptedInAppIds() as $appId) {
49+
$exApp = $this->exAppService->getExApp($appId);
50+
if ($exApp === null || $exApp->getEnabled() !== 1 || $this->isInitializing($exApp)) {
51+
continue; // disabled / deploying -> not probed (down-ness shown on the management page)
52+
}
53+
if ((microtime(true) - $start) > self::TOTAL_BUDGET_SECONDS) {
54+
$this->logger->info('ExApp setup-check refresh budget exceeded; remaining apps next run');
55+
break;
56+
}
57+
$issues = $this->probe($exApp);
58+
if ($issues !== []) {
59+
$appsState[$appId] = $issues;
60+
}
61+
}
62+
$this->setupCheckService->storeState($appsState);
63+
} catch (\Throwable $e) {
64+
$this->logger->error('ExApp setup-check refresh failed', ['exception' => $e]);
65+
}
66+
}
67+
68+
private function isInitializing(ExApp $exApp): bool {
69+
$status = $exApp->getStatus();
70+
return ((int)($status['init'] ?? 100)) < 100 || (($status['action'] ?? '') === 'init');
71+
}
72+
73+
/**
74+
* @return list<array{severity: string, appName: string, text: string, linkUrl: string, linkLabel: string}>
75+
*/
76+
private function probe(ExApp $exApp): array {
77+
try {
78+
$result = $this->appAPIService->requestToExApp(
79+
$exApp,
80+
'/setup_checks',
81+
null,
82+
'GET',
83+
[],
84+
['timeout' => self::PER_APP_TIMEOUT_SECONDS],
85+
);
86+
} catch (\Throwable $e) {
87+
$this->logger->warning('ExApp setup-check: error probing ExApp ' . $exApp->getAppid(), ['exception' => $e]);
88+
return [$this->notRespondingIssue($exApp)];
89+
}
90+
91+
if (is_array($result)) {
92+
return [$this->notRespondingIssue($exApp)]; // transport error -> ['error' => ...]
93+
}
94+
/** @var IResponse $result */
95+
$status = $result->getStatusCode();
96+
if ($status < 200 || $status >= 300) {
97+
return [$this->notRespondingIssue($exApp)];
98+
}
99+
// Require a sane, numeric Content-Length and refuse to read the body otherwise. A missing /
100+
// forged / non-numeric length is treated as not-responding: it is the only way to keep an
101+
// opted-in ExApp from streaming an unbounded (e.g. chunked) body that getBody() would buffer
102+
// whole and OOM the cron worker. With a declared length the HTTP client reads at most that
103+
// many bytes, so the materialization below is bounded.
104+
$contentLength = $result->getHeader('Content-Length');
105+
if (!is_numeric($contentLength) || (int)$contentLength > self::MAX_RESPONSE_BYTES) {
106+
$this->logger->warning('ExApp setup-check: missing or oversized Content-Length from ExApp ' . $exApp->getAppid());
107+
return [$this->notRespondingIssue($exApp)];
108+
}
109+
$bodyStr = (string)$result->getBody();
110+
if (strlen($bodyStr) > self::MAX_RESPONSE_BYTES) {
111+
$this->logger->warning('ExApp setup-check: oversized response body from ExApp ' . $exApp->getAppid());
112+
return [$this->notRespondingIssue($exApp)];
113+
}
114+
$body = json_decode($bodyStr, true);
115+
if (!is_array($body)) {
116+
return [$this->notRespondingIssue($exApp)];
117+
}
118+
return $this->parseResponse($exApp, $body);
119+
}
120+
121+
/**
122+
* @param array<array-key, mixed> $body map of `{checkId: {status, text, link_url?, link_label?}}`
123+
* @return list<array{severity: string, appName: string, text: string, linkUrl: string, linkLabel: string}>
124+
*/
125+
private function parseResponse(ExApp $exApp, array $body): array {
126+
$issues = [];
127+
$scanned = 0;
128+
foreach ($body as $result) {
129+
if (count($issues) >= ExAppSetupCheckService::MAX_CHECKS || $scanned >= self::MAX_RESPONSE_ENTRIES) {
130+
break;
131+
}
132+
$scanned++;
133+
if (!is_array($result)) {
134+
continue;
135+
}
136+
$severity = $this->mapSeverity(is_string($result['status'] ?? null) ? $result['status'] : '');
137+
if ($severity === null) {
138+
continue; // success / unknown -> not an issue
139+
}
140+
$text = $this->capLength(is_string($result['text'] ?? null) ? $result['text'] : '');
141+
$issues[] = [
142+
'severity' => $severity,
143+
'appName' => $this->appName($exApp),
144+
'text' => $text !== '' ? $text : $this->l10n->t('reported a problem'),
145+
'linkUrl' => $this->safeUrl(is_string($result['link_url'] ?? null) ? $result['link_url'] : ''),
146+
'linkLabel' => $this->capLength(is_string($result['link_label'] ?? null) ? $result['link_label'] : ''),
147+
];
148+
}
149+
return $issues;
150+
}
151+
152+
/**
153+
* @return array{severity: string, appName: string, text: string, linkUrl: string, linkLabel: string}
154+
*/
155+
private function notRespondingIssue(ExApp $exApp): array {
156+
return [
157+
'severity' => 'warning',
158+
'appName' => $this->appName($exApp),
159+
'text' => $this->l10n->t('not responding'),
160+
'linkUrl' => '',
161+
'linkLabel' => '',
162+
];
163+
}
164+
165+
private function appName(ExApp $exApp): string {
166+
$name = $exApp->getName();
167+
return $name !== '' ? $name : $exApp->getAppid();
168+
}
169+
170+
private function mapSeverity(string $status): ?string {
171+
return match (strtolower(trim($status))) {
172+
'info' => 'info',
173+
'warning' => 'warning',
174+
'error' => 'error',
175+
default => null, // success / ok / empty / unknown -> not surfaced
176+
};
177+
}
178+
179+
private function safeUrl(string $url): string {
180+
if (preg_match('#^https?://#i', $url) !== 1) {
181+
return '';
182+
}
183+
if (preg_match('/[\x00-\x20\x7f"\'<>]/', $url) === 1) {
184+
return '';
185+
}
186+
return $url;
187+
}
188+
189+
private function capLength(string $text): string {
190+
return mb_strlen($text) > self::MAX_TEXT_LENGTH ? mb_substr($text, 0, self::MAX_TEXT_LENGTH) : $text;
191+
}
192+
}

0 commit comments

Comments
 (0)