Skip to content

Commit dba8f14

Browse files
authored
Merge pull request #898 from nextcloud/fix/maintenance-mode-route-allowlist
fix(maintenance-mode): keep only HaRP and ExApp survival routes available in maintenance mode
2 parents 469063e + 6769836 commit dba8f14

7 files changed

Lines changed: 250 additions & 0 deletions

File tree

lib/AppInfo/Application.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
use OCA\AppAPI\Middleware\AppAPIAuthMiddleware;
2222
use OCA\AppAPI\Middleware\ExAppUIL10NMiddleware;
2323
use OCA\AppAPI\Middleware\ExAppUiMiddleware;
24+
use OCA\AppAPI\Middleware\MaintenanceModeMiddleware;
2425
use OCA\AppAPI\Notifications\ExAppNotifier;
2526
use OCA\AppAPI\PublicCapabilities;
2627
use OCA\AppAPI\SetupChecks\DaemonCheck;
@@ -60,6 +61,7 @@ public function register(IRegistrationContext $context): void {
6061
$context->registerEventListener(LoadAdditionalScriptsEvent::class, LoadFilesPluginListener::class);
6162
$context->registerCapability(Capabilities::class);
6263
$context->registerCapability(PublicCapabilities::class);
64+
$context->registerMiddleware(MaintenanceModeMiddleware::class);
6365
$context->registerMiddleware(AppAPIAuthMiddleware::class);
6466
$context->registerMiddleware(ExAppUiMiddleware::class);
6567
$context->registerMiddleware(ExAppUIL10NMiddleware::class, true);
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
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\Attribute;
11+
12+
use Attribute;
13+
14+
/**
15+
* Marks a controller method that stays reachable while the server is in maintenance mode.
16+
*
17+
* @since 35.0.0
18+
*/
19+
#[Attribute]
20+
class MaintenanceModeAvailable {
21+
}

lib/Controller/HarpController.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
namespace OCA\AppAPI\Controller;
1111

1212
use OCA\AppAPI\AppInfo\Application;
13+
use OCA\AppAPI\Attribute\MaintenanceModeAvailable;
1314
use OCA\AppAPI\Db\ExApp;
1415
use OCA\AppAPI\Service\ExAppService;
1516
use OCA\AppAPI\Service\HarpService;
@@ -69,6 +70,7 @@ private function validateHarpSharedKey(ExApp $exApp): bool {
6970

7071
#[PublicPage]
7172
#[NoCSRFRequired]
73+
#[MaintenanceModeAvailable]
7274
public function getExAppMetadata(string $appId): DataResponse {
7375
$exApp = $this->exAppService->getExApp($appId);
7476
if ($exApp === null) {

lib/Controller/OCSApiController.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
use OCA\AppAPI\AppInfo\Application;
1313
use OCA\AppAPI\Attribute\AppAPIAuth;
14+
use OCA\AppAPI\Attribute\MaintenanceModeAvailable;
1415
use OCA\AppAPI\Service\AppAPIService;
1516
use OCA\AppAPI\Service\ExAppService;
1617
use OCP\AppFramework\Http;
@@ -49,6 +50,7 @@ public function __construct(
4950
#[PublicPage]
5051
#[NoAdminRequired]
5152
#[NoCSRFRequired]
53+
#[MaintenanceModeAvailable]
5254
public function log(int $level, string $message): DataResponse {
5355
try {
5456
$this->logger->log($level, $message, [
@@ -71,6 +73,7 @@ public function getNCUsersList(): DataResponse {
7173
#[AppAPIAuth]
7274
#[PublicPage]
7375
#[NoCSRFRequired]
76+
#[MaintenanceModeAvailable]
7477
public function setAppInitProgressDeprecated(string $appId, int $progress, string $error = ''): DataResponse {
7578
$exApp = $this->exAppService->getExApp($appId);
7679
if (!$exApp) {
@@ -83,6 +86,7 @@ public function setAppInitProgressDeprecated(string $appId, int $progress, strin
8386
#[AppAPIAuth]
8487
#[PublicPage]
8588
#[NoCSRFRequired]
89+
#[MaintenanceModeAvailable]
8690
public function setAppInitProgress(int $progress, string $error = ''): DataResponse {
8791
$exApp = $this->exAppService->getExApp($this->request->getHeader('EX-APP-ID'));
8892
if (!$exApp) {
@@ -101,6 +105,7 @@ public function setAppInitProgress(int $progress, string $error = ''): DataRespo
101105
#[AppAPIAuth]
102106
#[PublicPage]
103107
#[NoCSRFRequired]
108+
#[MaintenanceModeAvailable]
104109
public function getEnabledState(): DataResponse {
105110
$exApp = $this->exAppService->getExApp($this->request->getHeader('EX-APP-ID'));
106111
if (!$exApp) {
@@ -112,6 +117,7 @@ public function getEnabledState(): DataResponse {
112117
#[AppAPIAuth]
113118
#[PublicPage]
114119
#[NoCSRFRequired]
120+
#[MaintenanceModeAvailable]
115121
public function getNextcloudAbsoluteUrl(string $url): DataResponse {
116122
return new DataResponse([
117123
'absolute_url' => rtrim($this->config->getSystemValueString('overwrite.cli.url'), '/') . '/' . ltrim($url, '/'),
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
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\Exceptions;
11+
12+
use Exception;
13+
use OCP\AppFramework\Http;
14+
15+
/**
16+
* @package OCA\AppAPI\Exceptions
17+
*/
18+
class MaintenanceModeException extends Exception {
19+
public function __construct($message = 'Service unavailable while the server is in maintenance mode', $code = Http::STATUS_SERVICE_UNAVAILABLE) {
20+
parent::__construct($message, $code);
21+
}
22+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
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\Middleware;
11+
12+
use Exception;
13+
use OCA\AppAPI\Attribute\MaintenanceModeAvailable;
14+
use OCA\AppAPI\Exceptions\MaintenanceModeException;
15+
use OCP\AppFramework\Http\JSONResponse;
16+
use OCP\AppFramework\Http\Response;
17+
use OCP\AppFramework\Middleware;
18+
use OCP\IConfig;
19+
use ReflectionMethod;
20+
21+
class MaintenanceModeMiddleware extends Middleware {
22+
23+
public function __construct(
24+
private IConfig $config,
25+
) {
26+
}
27+
28+
/**
29+
* @throws MaintenanceModeException when the server is in maintenance mode and the route is not allowed during it
30+
* @throws \ReflectionException
31+
*/
32+
public function beforeController($controller, $methodName) {
33+
if (!$this->config->getSystemValueBool('maintenance', false)) {
34+
return;
35+
}
36+
$reflectionMethod = new ReflectionMethod($controller, $methodName);
37+
if (!empty($reflectionMethod->getAttributes(MaintenanceModeAvailable::class))) {
38+
return;
39+
}
40+
throw new MaintenanceModeException();
41+
}
42+
43+
public function afterException($controller, $methodName, Exception $exception): Response {
44+
if ($exception instanceof MaintenanceModeException) {
45+
$response = new JSONResponse(['message' => $exception->getMessage()], $exception->getCode());
46+
$response->addHeader('X-Nextcloud-Maintenance-Mode', '1');
47+
$response->addHeader('Retry-After', '120');
48+
return $response;
49+
}
50+
51+
throw $exception;
52+
}
53+
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
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\Tests\php\Middleware;
11+
12+
use OCA\AppAPI\Attribute\MaintenanceModeAvailable;
13+
use OCA\AppAPI\Controller\AppConfigController;
14+
use OCA\AppAPI\Controller\ExAppsPageController;
15+
use OCA\AppAPI\Controller\HarpController;
16+
use OCA\AppAPI\Controller\OCSApiController;
17+
use OCA\AppAPI\Controller\OCSExAppController;
18+
use OCA\AppAPI\Controller\PreferencesController;
19+
use OCA\AppAPI\Controller\TalkBotController;
20+
use OCA\AppAPI\Exceptions\MaintenanceModeException;
21+
use OCA\AppAPI\Middleware\MaintenanceModeMiddleware;
22+
use OCP\AppFramework\Controller;
23+
use OCP\AppFramework\Http;
24+
use OCP\AppFramework\Http\JSONResponse;
25+
use OCP\IConfig;
26+
use OCP\IRequest;
27+
use PHPUnit\Framework\Attributes\DataProvider;
28+
use PHPUnit\Framework\TestCase;
29+
use ReflectionMethod;
30+
31+
class MaintenanceModeTestController extends Controller {
32+
#[MaintenanceModeAvailable]
33+
public function allowedRoute(): void {
34+
}
35+
36+
public function blockedRoute(): void {
37+
}
38+
}
39+
40+
class MaintenanceModeMiddlewareTest extends TestCase {
41+
private IConfig $config;
42+
private MaintenanceModeTestController $controller;
43+
44+
protected function setUp(): void {
45+
parent::setUp();
46+
$this->config = $this->createMock(IConfig::class);
47+
$this->controller = new MaintenanceModeTestController('app_api', $this->createMock(IRequest::class));
48+
}
49+
50+
private function middleware(bool $maintenance): MaintenanceModeMiddleware {
51+
$this->config->method('getSystemValueBool')->with('maintenance', false)->willReturn($maintenance);
52+
return new MaintenanceModeMiddleware($this->config);
53+
}
54+
55+
public function testPassesEveryRouteWhenNotInMaintenance(): void {
56+
$this->middleware(false)->beforeController($this->controller, 'blockedRoute');
57+
$this->addToAssertionCount(1);
58+
}
59+
60+
public function testPassesAllowedRouteDuringMaintenance(): void {
61+
$this->middleware(true)->beforeController($this->controller, 'allowedRoute');
62+
$this->addToAssertionCount(1);
63+
}
64+
65+
public function testBlocksUnmarkedRouteDuringMaintenance(): void {
66+
$this->expectException(MaintenanceModeException::class);
67+
$this->middleware(true)->beforeController($this->controller, 'blockedRoute');
68+
}
69+
70+
public function testExceptionIsAnsweredWithMaintenanceResponse(): void {
71+
$exception = new MaintenanceModeException();
72+
$response = $this->middleware(false)->afterException($this->controller, 'blockedRoute', $exception);
73+
74+
self::assertInstanceOf(JSONResponse::class, $response);
75+
self::assertSame(Http::STATUS_SERVICE_UNAVAILABLE, $response->getStatus());
76+
self::assertSame('1', $response->getHeaders()['X-Nextcloud-Maintenance-Mode']);
77+
self::assertSame('120', $response->getHeaders()['Retry-After']);
78+
self::assertSame(['message' => $exception->getMessage()], $response->getData());
79+
}
80+
81+
public function testBlockedRouteInMaintenanceYields503EndToEnd(): void {
82+
$middleware = $this->middleware(true);
83+
84+
try {
85+
$middleware->beforeController($this->controller, 'blockedRoute');
86+
self::fail('Expected MaintenanceModeException to be thrown');
87+
} catch (MaintenanceModeException $exception) {
88+
$response = $middleware->afterException($this->controller, 'blockedRoute', $exception);
89+
}
90+
91+
self::assertSame(Http::STATUS_SERVICE_UNAVAILABLE, $response->getStatus());
92+
self::assertSame('1', $response->getHeaders()['X-Nextcloud-Maintenance-Mode']);
93+
self::assertSame('120', $response->getHeaders()['Retry-After']);
94+
}
95+
96+
public function testUnrelatedExceptionIsRethrown(): void {
97+
$exception = new \RuntimeException('boom');
98+
$this->expectExceptionObject($exception);
99+
$this->middleware(false)->afterException($this->controller, 'blockedRoute', $exception);
100+
}
101+
102+
/**
103+
* @return list<array{class-string, string}>
104+
*/
105+
public static function allowlistedRoutes(): array {
106+
return [
107+
[HarpController::class, 'getExAppMetadata'],
108+
[OCSApiController::class, 'setAppInitProgress'],
109+
[OCSApiController::class, 'setAppInitProgressDeprecated'],
110+
[OCSApiController::class, 'getEnabledState'],
111+
[OCSApiController::class, 'getNextcloudAbsoluteUrl'],
112+
[OCSApiController::class, 'log'],
113+
];
114+
}
115+
116+
#[DataProvider('allowlistedRoutes')]
117+
public function testAllowlistedRouteCarriesAttribute(string $class, string $method): void {
118+
$attributes = (new ReflectionMethod($class, $method))->getAttributes(MaintenanceModeAvailable::class);
119+
self::assertNotEmpty($attributes, $class . '::' . $method . ' must stay reachable during maintenance');
120+
}
121+
122+
/**
123+
* @return list<array{class-string, string}>
124+
*/
125+
public static function blockedRoutes(): array {
126+
return [
127+
[HarpController::class, 'getUserInfo'],
128+
[OCSApiController::class, 'getNCUsersList'],
129+
[OCSExAppController::class, 'getExAppsList'],
130+
[AppConfigController::class, 'setAppConfigValue'],
131+
[AppConfigController::class, 'getAppConfigValues'],
132+
[PreferencesController::class, 'setUserConfigValue'],
133+
[PreferencesController::class, 'getUserConfigValues'],
134+
[ExAppsPageController::class, 'uninstallApp'],
135+
[TalkBotController::class, 'registerExAppTalkBot'],
136+
];
137+
}
138+
139+
#[DataProvider('blockedRoutes')]
140+
public function testNonAllowlistedRouteHasNoAttribute(string $class, string $method): void {
141+
$attributes = (new ReflectionMethod($class, $method))->getAttributes(MaintenanceModeAvailable::class);
142+
self::assertEmpty($attributes, $class . '::' . $method . ' must return 503 during maintenance');
143+
}
144+
}

0 commit comments

Comments
 (0)