Skip to content

Commit

Permalink
ExApp routes (public/user/admin) support (#327)
Browse files Browse the repository at this point in the history
This PR introduces new (mandatory if you use the ExApp proxy) registration of the routes that are allowed to call on ExApp via AppAPI ExApp proxy.

---------

Signed-off-by: Andrey Borysenko <[email protected]>
Co-authored-by: Alexander Piskun <[email protected]>
  • Loading branch information
andrey18106 and bigcat88 authored Aug 2, 2024
1 parent 1591de1 commit 65003cd
Show file tree
Hide file tree
Showing 13 changed files with 354 additions and 18 deletions.
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
2 changes: 1 addition & 1 deletion appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@
['name' => 'OCSUi#unregisterExAppMenuEntry', 'url' => '/api/v1/ui/top-menu', 'verb' => 'DELETE'],
['name' => 'OCSUi#getExAppMenuEntry', 'url' => '/api/v1/ui/top-menu', 'verb' => 'GET'],

//Common UI
// 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
45 changes: 45 additions & 0 deletions docs/tech_details/api/routes.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
.. _ex_app_routes:

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

Since AppAPI 3.0.0 ExApps have to declare their routes allowed to be accessed via the AppAPI ExApp proxy.

.. note::

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


Register
^^^^^^^^

During ExApp installation, the ExApp routes are registered automatically.
The routes must be declared in the ``external-app`` - ``routes`` tag of the ``info.xml`` file.

Example
*******

.. code-block::
<routes>
<route>
<url>.*</url>
<verb>GET,POST,PUT,DELETE</verb>
<access_level>USER</access_level>
<headers_to_exclude>[]</headers_to_exclude>
</route>
</routes>
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 name of the access level required to access the route, PUBLIC - public access without auth, USER - Nextcloud user auth required, ADMIN - 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
^^^^^^^^^^

ExApp routes are unregistered automatically when the ExApp is uninstalling, or during the ExApp update before registering the new routes.
5 changes: 5 additions & 0 deletions lib/Command/ExApp/Update.php
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,11 @@ private function updateExApp(InputInterface $input, OutputInterface $output, str
}
}

$this->exAppService->removeExAppRoutes($exApp);
if (isset($appInfo['external-app']['routes'])) {
$this->exAppService->registerExAppRoutes($exApp, $appInfo['external-app']['routes']);
}

if (!empty($appInfo['external-app']['translations_folder'])) {
$result = $this->exAppArchiveFetcher->installTranslations($appId, $appInfo['external-app']['translations_folder']);
if ($result) {
Expand Down
71 changes: 62 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,47 @@ 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 = array_map(function ($headerName) {
return strtolower($headerName);
}, json_decode($route['headers_to_exclude'], true));
break;
}
}
if (empty($headersToExclude)) {
return $headers;
}
foreach ($headers as $key => $value) {
if (in_array(strtolower($key), $headersToExclude)) {
unset($headers[$key]);
}
}
return $headers;
}
}
14 changes: 14 additions & 0 deletions lib/Db/ExApp.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
* @method array getApiScopes()
* @method array getDeployConfig()
* @method string getAcceptsDeployId()
* @method array getRoutes()
* @method void setAppid(string $appid)
* @method void setVersion(string $version)
* @method void setName(string $name)
Expand All @@ -42,6 +43,7 @@
* @method void setApiScopes(array $apiScopes)
* @method void setDeployConfig(array $deployConfig)
* @method void setAcceptsDeployId(string $acceptsDeployId)
* @method void setRoutes(array $routes)
*/
class ExApp extends Entity implements JsonSerializable {
protected $appid;
Expand All @@ -59,6 +61,7 @@ class ExApp extends Entity implements JsonSerializable {
protected $apiScopes;
protected $deployConfig;
protected $acceptsDeployId;
protected $routes;

/**
* @param array $params
Expand All @@ -79,6 +82,7 @@ public function __construct(array $params = []) {
$this->addType('apiScopes', 'json');
$this->addType('deployConfig', 'json');
$this->addType('acceptsDeployId', 'string');
$this->addType('routes', 'json');

if (isset($params['id'])) {
$this->setId($params['id']);
Expand Down Expand Up @@ -130,6 +134,9 @@ public function __construct(array $params = []) {
if (isset($params['accepts_deploy_id'])) {
$this->setAcceptsDeployId($params['accepts_deploy_id']);
}
if (isset($params['routes'])) {
$this->setRoutes($params['routes']);
}
}

public function jsonSerialize(): array {
Expand All @@ -150,6 +157,13 @@ public function jsonSerialize(): array {
'api_scopes' => $this->getApiScopes(),
'deploy_config' => $this->getDeployConfig(),
'accepts_deploy_id' => $this->getAcceptsDeployId(),
'routes' => $this->getRoutes(),
];
}
}

enum ExAppRouteAccessLevel: int {
case PUBLIC = 0;
case USER = 1;
case ADMIN = 2;
}
Loading

0 comments on commit 65003cd

Please sign in to comment.