Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ExApp routes (public/user/admin) support #327

Merged
merged 28 commits into from
Aug 2, 2024
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
226a0fb
WIP: ExApp routes draft
andrey18106 Jul 16, 2024
4e1ced6
WIP: fix typo
andrey18106 Jul 16, 2024
ef73130
WIP: adjustments, update changelog
andrey18106 Jul 19, 2024
9d84b59
WIP: add headers_to_exclude
andrey18106 Jul 22, 2024
814be1b
fix: headers_to_include -> headers_to_exclude
andrey18106 Jul 23, 2024
425d35f
fix: remove fallback set empty routes
andrey18106 Jul 23, 2024
0c7ba40
WIP: fix routes check, other adjustments
andrey18106 Jul 24, 2024
bfb0ffc
WIP: add routes to Basic api scope
andrey18106 Jul 24, 2024
2eb3249
WIP: minor corrections
andrey18106 Jul 24, 2024
3bcc649
remove unused parts, fix psalm
andrey18106 Jul 30, 2024
7547c08
fix: correct logic to not throw an exception
andrey18106 Jul 31, 2024
495e708
fix: add index by appid for routes table
andrey18106 Jul 31, 2024
1d736e8
fix: correct log level from debug to error
andrey18106 Jul 31, 2024
53fe2cb
fix(ci): suppress non-relevant psalm errors
andrey18106 Jul 31, 2024
e33c343
fix: we don't need unique index
andrey18106 Jul 31, 2024
2afa2a6
fix: remove old docstring
andrey18106 Jul 31, 2024
da6b956
fix: php-cs
andrey18106 Jul 31, 2024
7b0cb99
WIP: remove API for routes registration to use only info.xml
andrey18106 Aug 1, 2024
92bf7da
WIP: add update of ExApp routes during ExApp update
andrey18106 Aug 1, 2024
6051d5f
WIP: add pre-process of routes from info.xml, minor corrections
andrey18106 Aug 1, 2024
3f8636e
WIP: remove unnecessary routes check, it's already done in registerExApp
andrey18106 Aug 1, 2024
1741a27
WIP: use string access_level names and map to numeric during registra…
andrey18106 Aug 1, 2024
aae57e4
fix(ci): update baseline
andrey18106 Aug 1, 2024
5de629c
Merge branch 'main' into public-pages-routes-support
bigcat88 Aug 2, 2024
d05f0e4
fix: correct naming
andrey18106 Aug 2, 2024
9d24476
fix: correct naming
andrey18106 Aug 2, 2024
452cdb4
fix: minor corrections in headers_to_exclude
andrey18106 Aug 2, 2024
2f9a1fd
fix: minor corrections in headers_to_exclude
andrey18106 Aug 2, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,19 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).

## [2.8.0 - 2024-07-19]
## [3.0.0 - 2024-07-2x]

Note: Nextcloud 27 is no longer supported since this version.
**Breaking change**: new mandatory ExApp lifecycle endpoint to register ExApp routes allowed to be called from Nextcloud or other origins.

### Added

- [Breaking change] Added new ExApp lifecycle endpoint to register ExApp routes allowed to be called from Nextcloud or other origins.
- New OCS API endpoint to setAppInitProgress. The old one is marked as deprecated. #319
- Added default timeout for requestToExApp function set to 3s. #277
- Added new PublicFunction method `getExApp`. #326

### Changed

- Dropped support of Nextcloud 27. #322
- ExApp system flag is now deprecated and removed to optimize performance and simplicity. #323
- PublicFunctions changes: `exAppRequestWithUserInit` and `asyncExAppRequestWithUserInit` are now deprecated. #323

Expand Down
2 changes: 1 addition & 1 deletion appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ to join us in shaping a more versatile, stable, and secure app landscape.
*Your insights, suggestions, and contributions are invaluable to us.*

]]></description>
<version>2.8.0</version>
<version>3.0.0</version>
<licence>agpl</licence>
<author mail="[email protected]" homepage="https://github.com/andrey18106">Andrey Borysenko</author>
<author mail="[email protected]" homepage="https://github.com/bigcat88">Alexander Piskun</author>
Expand Down
7 changes: 6 additions & 1 deletion appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,12 @@
['name' => 'OCSUi#unregisterExAppMenuEntry', 'url' => '/api/v1/ui/top-menu', 'verb' => 'DELETE'],
['name' => 'OCSUi#getExAppMenuEntry', 'url' => '/api/v1/ui/top-menu', 'verb' => 'GET'],

//Common UI
// ExApp routes
['name' => 'ExAppRoutes#registerExAppRoutes', 'url' => '/api/v1/routes', 'verb' => 'POST'],
['name' => 'ExAppRoutes#unregisterExAppRoutes', 'url' => '/api/v1/routes', 'verb' => 'DELETE'],
['name' => 'ExAppRoutes#getExAppRoutes', 'url' => '/api/v1/routes', 'verb' => 'GET'],

// Common UI
['name' => 'OCSUi#setExAppInitialState', 'url' => '/api/v1/ui/initial-state', 'verb' => 'POST'],
['name' => 'OCSUi#deleteExAppInitialState', 'url' => '/api/v1/ui/initial-state', 'verb' => 'DELETE'],
['name' => 'OCSUi#getExAppInitialState', 'url' => '/api/v1/ui/initial-state', 'verb' => 'GET'],
Expand Down
1 change: 1 addition & 0 deletions docs/tech_details/api/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ AppAPI Nextcloud APIs
appconfig
preferences
exapp
routes
utils
fileactionsmenu
topmenu
Expand Down
48 changes: 48 additions & 0 deletions docs/tech_details/api/routes.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
.. _ex_app_routes:

======
Routes
======

This OCS API is mandatory for all ExApps to register their routes during the ExApp enable/disable step.

.. note::

Available and mandatory since AppAPI 3.0.0, requires AppAPIAuth.

This routes check applied only for ExApp proxy (``/apps/app_api/proxy/*``).


Register
^^^^^^^^

OCS endpoint: ``POST /apps/app_api/api/v1/routes``

Params
******

.. code-block:: json

{
"routes": [
{
"url": "/regex-route-on-ex-app-side",
"verb": "GET,POST,PUT,DELETE",
"access_level": "0/1/2",
"headers_to_exclude": "json_encoded string of array of strings ['headerName1', 'headerName2', ...]",
}
]
}

where the fields are:

- ``url``: the route to be registered on the ExApp side, can be a regex
- ``verb``: the HTTP verb that the route will accept, can be a comma separated list of verbs
- ``access_level``: the numeric access level required to access the route, 0 - public route, 1 - Nextcloud user auth required, 2 - admin user required
- ``headers_to_exclude``: a json encoded string of an array of strings, the headers that the ExApp wants to be excluded from the request to it


Unregister
^^^^^^^^^^

OCS endpoint: ``DELETE /apps/app_api/api/v1/routes``
10 changes: 10 additions & 0 deletions lib/Command/ExApp/Register.php
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,16 @@ protected function execute(InputInterface $input, OutputInterface $output): int
}
}

if (!empty($appInfo['external-app']['routes'])
&& count($exApp->getRoutes()) !== count($appInfo['external-app']['routes'])) {
$this->logger->error(sprintf('Error during registering ExApp %s routes.', $appId));
if ($outputConsole) {
$output->writeln(sprintf('Error during registering ExApp %s routes.', $appId));
}
$this->_unregisterExApp($appId, $isTestDeployMode);
return 3;
}

if (!empty($appInfo['external-app']['translations_folder'])) {
$result = $this->exAppArchiveFetcher->installTranslations($appId, $appInfo['external-app']['translations_folder']);
if ($result) {
Expand Down
69 changes: 60 additions & 9 deletions lib/Controller/ExAppProxyController.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,20 @@
use GuzzleHttp\RequestOptions;
use OC\Security\CSP\ContentSecurityPolicyNonceManager;
use OCA\AppAPI\AppInfo\Application;
use OCA\AppAPI\Db\ExApp;
use OCA\AppAPI\Db\ExAppRouteAccessLevel;
use OCA\AppAPI\ProxyResponse;
use OCA\AppAPI\Service\AppAPIService;
use OCA\AppAPI\Service\ExAppService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\NotFoundResponse;
use OCP\AppFramework\Http\Response;
use OCP\Files\IMimeTypeDetector;
use OCP\Http\Client\IResponse;
use OCP\IGroupManager;
use OCP\IRequest;

class ExAppProxyController extends Controller {
Expand All @@ -30,6 +34,7 @@ public function __construct(
private readonly IMimeTypeDetector $mimeTypeHelper,
private readonly ContentSecurityPolicyNonceManager $nonceManager,
private readonly ?string $userId,
private readonly IGroupManager $groupManager,
) {
parent::__construct(Application::APP_ID, $request);
}
Expand Down Expand Up @@ -73,17 +78,19 @@ private function createProxyResponse(string $path, IResponse $response, $cache =
return $proxyResponse;
}

#[PublicPage]
#[NoAdminRequired]
#[NoCSRFRequired]
public function ExAppGet(string $appId, string $other): Response {
$exApp = $this->exAppService->getExApp($appId);
if ($exApp === null || !$exApp->getEnabled()) {
if ($exApp === null || !$exApp->getEnabled() || !$this->passesExAppProxyRoutesChecks($exApp, $other)) {
return new NotFoundResponse();
}

$response = $this->service->requestToExApp2(
$exApp, '/' . $other, $this->userId, 'GET', queryParams: $_GET, options: [
RequestOptions::COOKIES => $this->buildProxyCookiesJar($_COOKIE, $this->service->getExAppDomain($exApp)),
RequestOptions::HEADERS => $this->buildHeadersWithExclude($exApp, $other, getallheaders()),
],
request: $this->request,
);
Expand All @@ -93,17 +100,18 @@ public function ExAppGet(string $appId, string $other): Response {
return $this->createProxyResponse($other, $response);
}

#[PublicPage]
#[NoAdminRequired]
#[NoCSRFRequired]
public function ExAppPost(string $appId, string $other): Response {
$exApp = $this->exAppService->getExApp($appId);
if ($exApp === null || !$exApp->getEnabled()) {
if ($exApp === null || !$exApp->getEnabled() || !$this->passesExAppProxyRoutesChecks($exApp, $other)) {
return new NotFoundResponse();
}

$options = [
RequestOptions::COOKIES => $this->buildProxyCookiesJar($_COOKIE, $this->service->getExAppDomain($exApp)),
'headers' => getallheaders(),
RequestOptions::HEADERS => $this->buildHeadersWithExclude($exApp, $other, getallheaders()),
];
if (str_starts_with($this->request->getHeader('Content-Type'), 'multipart/form-data') || count($_FILES) > 0) {
unset($options['headers']['Content-Type']);
Expand All @@ -127,19 +135,20 @@ public function ExAppPost(string $appId, string $other): Response {
return $this->createProxyResponse($other, $response);
}

#[PublicPage]
#[NoAdminRequired]
#[NoCSRFRequired]
public function ExAppPut(string $appId, string $other): Response {
$exApp = $this->exAppService->getExApp($appId);
if ($exApp === null || !$exApp->getEnabled()) {
if ($exApp === null || !$exApp->getEnabled() || !$this->passesExAppProxyRoutesChecks($exApp, $other)) {
return new NotFoundResponse();
}

$stream = fopen('php://input', 'r');
$options = [
RequestOptions::COOKIES => $this->buildProxyCookiesJar($_COOKIE, $this->service->getExAppDomain($exApp)),
'body' => $stream,
'headers' => getallheaders(),
RequestOptions::BODY => $stream,
RequestOptions::HEADERS => $this->buildHeadersWithExclude($exApp, $other, getallheaders()),
];
$response = $this->service->requestToExApp2(
$exApp, '/' . $other, $this->userId, 'PUT',
Expand All @@ -152,19 +161,20 @@ public function ExAppPut(string $appId, string $other): Response {
return $this->createProxyResponse($other, $response);
}

#[PublicPage]
#[NoAdminRequired]
#[NoCSRFRequired]
public function ExAppDelete(string $appId, string $other): Response {
$exApp = $this->exAppService->getExApp($appId);
if ($exApp === null || !$exApp->getEnabled()) {
if ($exApp === null || !$exApp->getEnabled() || !$this->passesExAppProxyRoutesChecks($exApp, $other)) {
return new NotFoundResponse();
}

$stream = fopen('php://input', 'r');
$options = [
RequestOptions::COOKIES => $this->buildProxyCookiesJar($_COOKIE, $this->service->getExAppDomain($exApp)),
'body' => $stream,
'headers' => getallheaders(),
RequestOptions::BODY => $stream,
RequestOptions::HEADERS => $this->buildHeadersWithExclude($exApp, $other, getallheaders()),
];
$response = $this->service->requestToExApp2(
$exApp, '/' . $other, $this->userId, 'DELETE',
Expand Down Expand Up @@ -212,4 +222,45 @@ private function buildMultipartFormData(array $bodyParams, array $files): array
}
return $multipart;
}

private function passesExAppProxyRoutesChecks(ExApp $exApp, string $exAppRoute): bool {
foreach ($exApp->getRoutes() as $route) {
$matchesUrlPattern = preg_match('/' . $route['url'] . '/i', $exAppRoute) === 1;
$matchesVerb = str_contains(strtolower($route['verb']), strtolower($this->request->getMethod()));
if ($matchesUrlPattern && $matchesVerb) {
return $this->passesExAppProxyRouteAccessLevelCheck($route['access_level']);
}
}
return false;
}

private function passesExAppProxyRouteAccessLevelCheck(int $accessLevel): bool {
return match ($accessLevel) {
ExAppRouteAccessLevel::PUBLIC->value => true,
ExAppRouteAccessLevel::USER->value => $this->userId !== null,
ExAppRouteAccessLevel::ADMIN->value => $this->userId !== null && $this->groupManager->isAdmin($this->userId),
default => false,
};
}

private function buildHeadersWithExclude(ExApp $exApp, string $exAppRoute, array $headers): array {
$headersToExclude = [];
foreach ($exApp->getRoutes() as $route) {
$matchesUrlPattern = preg_match('/' . $route['url'] . '/i', $exAppRoute) === 1;
$matchesVerb = str_contains(strtolower($route['verb']), strtolower($this->request->getMethod()));
if ($matchesUrlPattern && $matchesVerb) {
$headersToExclude = json_decode($route['headers_to_exclude'], true);
break;
}
}
if (empty($headersToExclude)) {
return $headers;
}
foreach ($headers as $key => $value) {
if (in_array($key, $headersToExclude)) {
unset($headers[$key]);
}
}
return $headers;
}
}
66 changes: 66 additions & 0 deletions lib/Controller/ExAppRoutesController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

declare(strict_types=1);

namespace OCA\AppAPI\Controller;

use OC\AppFramework\Http;
use OCA\AppAPI\AppInfo\Application;
use OCA\AppAPI\Attribute\AppAPIAuth;
use OCA\AppAPI\Service\ExAppService;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IRequest;

class ExAppRoutesController extends OCSController {

public function __construct(
IRequest $request,
private readonly ExAppService $exAppService,
) {
parent::__construct(Application::APP_ID, $request);
}

#[AppAPIAuth]
#[PublicPage]
#[NoCSRFRequired]
public function registerExAppRoutes(array $routes): DataResponse {
if (!$this->validateRoutes($routes)) {
return new DataResponse([], Http::STATUS_BAD_REQUEST);
}
$exApp = $this->exAppService->registerExAppRoutes($this->exAppService->getExApp($this->request->getHeader('EX-APP-ID')), $routes);
if ($exApp === null) {
return new DataResponse([], Http::STATUS_BAD_REQUEST);
}
return new DataResponse();
}

#[AppAPIAuth]
#[PublicPage]
#[NoCSRFRequired]
public function unregisterExAppRoutes(): DataResponse {
$exApp = $this->exAppService->removeExAppRoutes($this->exAppService->getExApp($this->request->getHeader('EX-APP-ID')));
if ($exApp === null) {
return new DataResponse([], Http::STATUS_BAD_REQUEST);
}
return new DataResponse();
}

#[AppAPIAuth]
#[PublicPage]
#[NoCSRFRequired]
public function getExAppRoutes(): array {
return $this->exAppService->getExApp($this->request->getHeader('EX-APP-ID'))->getRoutes();
}

private function validateRoutes(array $routes): bool {
foreach ($routes as $route) {
if (!isset($route['url']) || !isset($route['verb']) || !isset($route['access_level'])) {
return false;
}
}
return true;
}
}
Loading
Loading