Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
Prev Previous commit
Next Next commit
Add proxy handlers for external routing services
This adds three handlers for OSRM, GraphHopper and Mapbox, which
essentially proxy the requests made to the routing APIs and inject the
required API keys if needed.

The reason for doing this is to offer protection for both the user and
administrator, because first of all, external services can then no
longer track usage down to individual users but essentially get requests
from one source only. Second, the administrator no longer has his/her
API keys exposed to all of the users.

Note that this change only adds the routes and handlers for proxying and
it's not yet integrated into the frontend.

Unfortunately, when passing along and injecting API keys into the query
string we need to parse query string arguments by ourselves because
GraphHopper uses the "point" query string argument multiple times and
PHP by default treats them as unique keys.

Signed-off-by: aszlig <[email protected]>
  • Loading branch information
aszlig committed Oct 4, 2020
commit fa543fd5fb72c8553c773ebdb9500d46d4a10721
12 changes: 12 additions & 0 deletions appinfo/application.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use OCA\Maps\DB\FavoriteShareMapper;
use \OCP\AppFramework\App;
use \OCP\IServerContainer;
use OCA\Maps\Controller\RoutingProxyController;
use OCA\Maps\Hooks\FileHooks;
use OCA\Maps\Service\PhotofilesService;
use OCA\Maps\Service\TracksService;
Expand All @@ -40,6 +41,17 @@ public function __construct (array $urlParams=array()) {

$this->getContainer()->query('FileHooks')->register();

$container->registerService(
'RoutingProxyController', function ($c) {
return new RoutingProxyController(
$c->query('AppName'),
$c->query('Request'),
$c->query('ServerContainer')->getLogger(),
$c->query('ServerContainer')->getConfig()
);
}
);

$this->registerFeaturePolicy();
}

Expand Down
5 changes: 5 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@
['name' => 'contacts#deleteContactAddress', 'url' => '/contacts/{bookid}/{uri}', 'verb' => 'DELETE'],
['name' => 'contacts#getContactLetterAvatar', 'url' => '/contacts-avatar', 'verb' => 'GET'],

// routing API proxies
['name' => 'routing_proxy#requestOsrmRoute', 'url' => '/api/requestRoute/osrm/{profile}/{path<.*>}', 'verb' => 'GET'],
['name' => 'routing_proxy#requestGraphHopperRoute', 'url' => '/api/requestRoute/graphhopper/{path<.*>}', 'verb' => 'GET'],
['name' => 'routing_proxy#requestMapboxRoute', 'url' => '/api/requestRoute/mapbox/{path<.*>}', 'verb' => 'GET'],

// routing
['name' => 'routing#exportRoute', 'url' => '/exportRoute', 'verb' => 'POST'],

Expand Down
148 changes: 148 additions & 0 deletions lib/Controller/RoutingProxyController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
<?php declare(strict_types=1);
/**
* Nextcloud - Maps
*
* This file is licensed under the Affero General Public License version 3 or
* later. See the COPYING file.
*
* @author aszlig <[email protected]>
* @copyright aszlig 2019
*/

namespace OCA\Maps\Controller;

use OCP\IConfig;
use OCP\ILogger;
use OCP\IRequest;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Response;

use OCA\Maps\Http\ProxyResponse;

class RoutingProxyController extends Controller {
private $logger;
private $config;

public function __construct(string $appname, IRequest $request,
ILogger $logger, IConfig $config) {
parent::__construct($appname, $request);
$this->logger = $logger;
$this->config = $config;
}

/**
* Build a query string from the current request combined with $extraQuery
* and return it in a way that can be directly appended to an URL (eg. with
* a leading '?').
*/
private function buildQueryStringArg(array $extraQuery = []): string {
// Unfortunately, we can't use $this->request->getParams() here,
// because some services like GraphHopper use the same query string
// arguments twice, like eg.: point=12.34,56.78&point=43.21,87.65
$queryComponents = explode('&', $_SERVER['QUERY_STRING'] ?? '');

if (count($queryComponents) == 0) {
return '';
}

$query = [];
foreach ($queryComponents as $comp) {
$keyval = explode('=', $comp, 2);
$key = rawurldecode($keyval[0]);
$val = rawurldecode($keyval[1] ?? '');
$query[$key][] = $val;
}

// XXX: PHP's array() "function" is *not* a ZEND_FUNCTION, so we can't
// simply do array_map('array', ...).
$toSingleton = function ($a) { return [$a]; };

$query = array_merge($query, array_map($toSingleton, $extraQuery));

$result = [];
foreach ($query as $key => $values) {
foreach ($values as $value) {
$keyEnc = rawurlencode($key);
if ($value === null) {
$result[] = $keyEnc;
} else {
$result[] = $keyEnc . '=' . rawurlencode($value);
}
}
}
return '?' . implode('&', $result);
}

/**
* Send a request to the service at $baseUrl with path $path and the
* current request query string params and return the response from the
* remote server.
*/
private function proxyResponse(string $baseUrl, string $path,
array $extraQuery = []): Response {
if ($baseUrl === '') {
$response = new Response();
$response->setStatus(Http::STATUS_NOT_ACCEPTABLE);
return $response;
}
$url = $baseUrl . '/' . ltrim($path, '/');
$url .= $this->buildQueryStringArg($extraQuery);
$proxy = new ProxyResponse($url);
$proxy->sendRequest($this->logger);
return $proxy;
}

/**
* Proxy routing request to either a configured OSRM instance or the demo
* instance.
*
* @NoAdminRequired
* @NoCSRFRequired
*/
public function requestOsrmRoute(string $profile, string $path): Response {
if ($profile === 'demo') {
$url = 'https://router.project-osrm.org/route/v1';
} elseif ($profile === 'car') {
$url = $this->config->getAppValue('maps', 'osrmCarURL');
} elseif ($profile === 'bicycle') {
$url = $this->config->getAppValue('maps', 'osrmBikeURL');
} elseif ($profile === 'foot') {
$url = $this->config->getAppValue('maps', 'osrmFootURL');
} else {
$this->logger->error(
'Unknown profile '.$profile.' selected for OSRM.'
);
$response = new Response();
$response->setStatus(Http::STATUS_BAD_REQUEST);
return $response;
}
return $this->proxyResponse($url, $path);
}

/**
* Proxy routing request to GraphHopper, injecting the API key.
*
* @NoAdminRequired
* @NoCSRFRequired
*/
public function requestGraphHopperRoute(string $path): Response {
$url = $this->config->getAppValue(
'maps', 'graphhopperURL', 'https://graphhopper.com/api/1/route'
);
$apiKey = $this->config->getAppValue('maps', 'graphhopperAPIKEY');
return $this->proxyResponse($url, $path, ['key' => $apiKey]);
}

/**
* Proxy routing request to Mapbox, injecting the API key.
*
* @NoAdminRequired
* @NoCSRFRequired
*/
public function requestMapboxRoute(string $path): Response {
$url = 'https://api.mapbox.com/directions/v5';
$apiKey = $this->config->getAppValue('maps', 'mapboxAPIKEY');
return $this->proxyResponse($url, $path, ['access_token' => $apiKey]);
}
}
76 changes: 76 additions & 0 deletions lib/Http/ProxyResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php declare(strict_types=1);
/**
* Nextcloud - Maps
*
* This file is licensed under the Affero General Public License version 3 or
* later. See the COPYING file.
*
* @author aszlig <[email protected]>
* @copyright aszlig 2019
*/

namespace OCA\Maps\Http;

use OCP\ILogger;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Response;

class ProxyResponse extends Response {
const USER_AGENT = 'Nextcloud Maps (https://github.com/nextcloud/maps)';
const REQUEST_TIMEOUT = 20;

// NOTE: These need to be lower-case!
const ALLOWED_HEADERS = ['content-type', 'content-length'];

private $url;
private $responseBody = '';

public function __construct(string $url) {
$this->url = $url;
}

/**
* Send the API request to the given URL and set headers for our response
* appropriately.
*/
public function sendRequest(ILogger $logger): bool {
if (($curl = curl_init()) === false) {
$logger->error('Unable to initialise cURL');
$this->setStatus(Http::STATUS_INTERNAL_SERVER_ERROR);
return false;
}

curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curl, CURLOPT_URL, $this->url);
curl_setopt($curl, CURLOPT_USERAGENT, self::USER_AGENT);
curl_setopt($curl, CURLOPT_TIMEOUT, self::REQUEST_TIMEOUT);

curl_setopt($curl, CURLOPT_HEADERFUNCTION, function ($_, string $hl) {
$keyval = explode(':', $hl, 2);
if (count($keyval) === 2 && in_array(strtolower($keyval[0]),
self::ALLOWED_HEADERS)) {
$this->addHeader(trim($keyval[0]), ltrim($keyval[1]));
}
return strlen($hl);
});

$response = curl_exec($curl);

if ($response === false) {
$logger->error('Error while proxying request to '.$this->url.': '.
curl_error($curl));
curl_close($curl);
$this->setStatus(Http::STATUS_INTERNAL_SERVER_ERROR);
return false;
}

$this->setStatus(curl_getinfo($curl, CURLINFO_RESPONSE_CODE));
$this->responseBody = $response;
curl_close($curl);
return true;
}

public function render(): string {
return $this->responseBody;
}
}