2017-03-14 03:22:20 +00:00
|
|
|
<?php
|
2017-11-17 01:23:18 +00:00
|
|
|
/** @license MIT
|
|
|
|
* Copyright 2017 J. King, Dustin Wilson et al.
|
|
|
|
* See LICENSE and AUTHORS files for details */
|
|
|
|
|
2017-03-14 03:22:20 +00:00
|
|
|
declare(strict_types=1);
|
2017-03-28 04:12:12 +00:00
|
|
|
namespace JKingWeb\Arsse;
|
2017-03-14 03:22:20 +00:00
|
|
|
|
2018-01-11 20:48:29 +00:00
|
|
|
use JKingWeb\Arsse\Arsse;
|
2018-01-06 17:02:45 +00:00
|
|
|
use Psr\Http\Message\RequestInterface;
|
2018-01-07 17:59:10 +00:00
|
|
|
use Psr\Http\Message\ServerRequestInterface;
|
2018-01-06 17:02:45 +00:00
|
|
|
use Psr\Http\Message\ResponseInterface;
|
|
|
|
use Zend\Diactoros\ServerRequest;
|
|
|
|
use Zend\Diactoros\ServerRequestFactory;
|
|
|
|
use Zend\Diactoros\Response\EmptyResponse;
|
|
|
|
|
2017-03-14 03:22:20 +00:00
|
|
|
class REST {
|
2018-01-06 17:02:45 +00:00
|
|
|
const API_LIST = [
|
2017-03-19 02:30:36 +00:00
|
|
|
// NextCloud News version enumerator
|
|
|
|
'ncn' => [
|
|
|
|
'match' => '/index.php/apps/news/api',
|
|
|
|
'strip' => '/index.php/apps/news/api',
|
|
|
|
'class' => REST\NextCloudNews\Versions::class,
|
|
|
|
],
|
|
|
|
// NextCloud News v1-2 https://github.com/nextcloud/news/blob/master/docs/externalapi/Legacy.md
|
|
|
|
'ncn_v1-2' => [
|
|
|
|
'match' => '/index.php/apps/news/api/v1-2/',
|
|
|
|
'strip' => '/index.php/apps/news/api/v1-2',
|
|
|
|
'class' => REST\NextCloudNews\V1_2::class,
|
|
|
|
],
|
2017-09-25 03:32:21 +00:00
|
|
|
'ttrss_api' => [ // Tiny Tiny RSS https://git.tt-rss.org/git/tt-rss/wiki/ApiReference
|
2018-01-06 17:02:45 +00:00
|
|
|
'match' => '/tt-rss/api',
|
2017-12-01 00:39:46 +00:00
|
|
|
'strip' => '/tt-rss/api',
|
2017-09-25 03:32:21 +00:00
|
|
|
'class' => REST\TinyTinyRSS\API::class,
|
|
|
|
],
|
2017-11-10 17:02:59 +00:00
|
|
|
'ttrss_icon' => [ // Tiny Tiny RSS feed icons
|
|
|
|
'match' => '/tt-rss/feed-icons/',
|
|
|
|
'strip' => '/tt-rss/feed-icons/',
|
|
|
|
'class' => REST\TinyTinyRSS\Icon::class,
|
|
|
|
],
|
2017-03-19 02:30:36 +00:00
|
|
|
// Other candidates:
|
2017-10-05 21:42:12 +00:00
|
|
|
// Google Reader http://feedhq.readthedocs.io/en/latest/api/index.html
|
2017-11-10 17:02:59 +00:00
|
|
|
// Fever https://feedafever.com/api
|
|
|
|
// Feedbin v2 https://github.com/feedbin/feedbin-api
|
2017-07-15 20:44:06 +00:00
|
|
|
// CommaFeed https://www.commafeed.com/api/
|
2018-10-26 20:27:18 +00:00
|
|
|
// Selfoss https://github.com/SSilence/selfoss/wiki/Restful-API-for-Apps-or-any-other-external-access
|
2018-01-08 22:11:38 +00:00
|
|
|
// Unclear if clients exist:
|
|
|
|
// Miniflux https://github.com/miniflux/miniflux/blob/master/docs/json-rpc-api.markdown
|
2017-11-10 17:02:59 +00:00
|
|
|
// NextCloud News v2 https://github.com/nextcloud/news/blob/master/docs/externalapi/External-Api.md
|
2017-10-05 21:42:12 +00:00
|
|
|
// BirdReader https://github.com/glynnbird/birdreader/blob/master/API.md
|
2018-01-08 22:11:38 +00:00
|
|
|
// Feedbin v1 https://github.com/feedbin/feedbin-api/commit/86da10aac5f1a57531a6e17b08744e5f9e7db8a9
|
2017-10-05 21:42:12 +00:00
|
|
|
// Proprietary (centralized) entities:
|
|
|
|
// NewsBlur http://www.newsblur.com/api
|
|
|
|
// Feedly https://developer.feedly.com/
|
2017-03-19 02:30:36 +00:00
|
|
|
];
|
2018-01-09 17:31:40 +00:00
|
|
|
const DEFAULT_PORTS = [
|
|
|
|
'http' => 80,
|
|
|
|
'https' => 443,
|
|
|
|
];
|
2018-01-06 17:02:45 +00:00
|
|
|
protected $apis = [];
|
2017-04-07 01:41:21 +00:00
|
|
|
|
2018-01-06 17:02:45 +00:00
|
|
|
public function __construct(array $apis = null) {
|
|
|
|
$this->apis = $apis ?? self::API_LIST;
|
2017-03-19 02:30:36 +00:00
|
|
|
}
|
2017-03-14 03:22:20 +00:00
|
|
|
|
2018-01-06 17:02:45 +00:00
|
|
|
public function dispatch(ServerRequestInterface $req = null): ResponseInterface {
|
|
|
|
// create a request object if not provided
|
|
|
|
$req = $req ?? ServerRequestFactory::fromGlobals();
|
2018-01-12 14:48:33 +00:00
|
|
|
// find the API to handle
|
2018-01-07 17:59:10 +00:00
|
|
|
try {
|
2018-01-12 14:48:33 +00:00
|
|
|
list($api, $target, $class) = $this->apiMatch($req->getRequestTarget(), $this->apis);
|
2018-01-11 20:48:29 +00:00
|
|
|
// authenticate the request pre-emptively
|
|
|
|
$req = $this->authenticateRequest($req);
|
2018-01-08 22:11:38 +00:00
|
|
|
// modify the request to have an uppercase method and a stripped target
|
|
|
|
$req = $req->withMethod(strtoupper($req->getMethod()))->withRequestTarget($target);
|
2018-01-07 17:59:10 +00:00
|
|
|
// fetch the correct handler
|
|
|
|
$drv = $this->getHandler($class);
|
|
|
|
// generate a response
|
|
|
|
if ($req->getMethod()=="HEAD") {
|
|
|
|
// if the request is a HEAD request, we act exactly as if it were a GET request, and simply remove the response body later
|
|
|
|
$res = $drv->dispatch($req->withMethod("GET"));
|
|
|
|
} else {
|
|
|
|
$res = $drv->dispatch($req);
|
|
|
|
}
|
|
|
|
} catch (REST\Exception501 $e) {
|
|
|
|
$res = new EmptyResponse(501);
|
|
|
|
}
|
2018-01-06 17:02:45 +00:00
|
|
|
// modify the response so that it has all the required metadata
|
2018-01-07 17:59:10 +00:00
|
|
|
return $this->normalizeResponse($res, $req);
|
2018-01-06 17:02:45 +00:00
|
|
|
}
|
|
|
|
|
2018-01-07 17:59:10 +00:00
|
|
|
public function getHandler(string $className): REST\Handler {
|
2018-01-06 17:02:45 +00:00
|
|
|
// instantiate the API handler
|
2018-01-07 17:59:10 +00:00
|
|
|
return new $className();
|
2017-03-19 02:30:36 +00:00
|
|
|
}
|
|
|
|
|
2018-01-06 17:02:45 +00:00
|
|
|
public function apiMatch(string $url): array {
|
|
|
|
$map = $this->apis;
|
2017-03-19 02:30:36 +00:00
|
|
|
// sort the API list so the longest URL prefixes come first
|
2017-08-29 14:50:31 +00:00
|
|
|
uasort($map, function ($a, $b) {
|
|
|
|
return (strlen($a['match']) <=> strlen($b['match'])) * -1;
|
|
|
|
});
|
2018-01-06 17:02:45 +00:00
|
|
|
// normalize the target URL
|
|
|
|
$url = REST\Target::normalize($url);
|
2017-03-19 02:30:36 +00:00
|
|
|
// find a match
|
2017-08-29 14:50:31 +00:00
|
|
|
foreach ($map as $id => $api) {
|
2018-01-06 17:02:45 +00:00
|
|
|
// first try a simple substring match
|
2017-08-29 14:50:31 +00:00
|
|
|
if (strpos($url, $api['match'])===0) {
|
2018-01-06 17:02:45 +00:00
|
|
|
// if it matches, perform a more rigorous match and then strip off any defined prefix
|
|
|
|
$pattern = "<^".preg_quote($api['match'])."([/\?#]|$)>";
|
|
|
|
if ($url==$api['match'] || in_array(substr($api['match'], -1, 1), ["/", "?", "#"]) || preg_match($pattern, $url)) {
|
|
|
|
$target = substr($url, strlen($api['strip']));
|
|
|
|
} else {
|
|
|
|
// if the match fails we are not able to handle the request
|
|
|
|
throw new REST\Exception501();
|
|
|
|
}
|
|
|
|
// return the API name, stripped URL, and API class name
|
|
|
|
return [$id, $target, $api['class']];
|
2017-07-21 02:40:09 +00:00
|
|
|
}
|
2017-03-19 02:30:36 +00:00
|
|
|
}
|
2018-01-12 14:48:33 +00:00
|
|
|
// or throw an exception otherwise
|
2017-07-15 20:44:06 +00:00
|
|
|
throw new REST\Exception501();
|
2017-03-19 02:30:36 +00:00
|
|
|
}
|
2018-01-06 17:02:45 +00:00
|
|
|
|
2018-01-11 20:48:29 +00:00
|
|
|
public function authenticateRequest(ServerRequestInterface $req): ServerRequestInterface {
|
|
|
|
$user = "";
|
|
|
|
$password = "";
|
|
|
|
$env = $req->getServerParams();
|
|
|
|
if (isset($env['PHP_AUTH_USER'])) {
|
|
|
|
$user = $env['PHP_AUTH_USER'];
|
|
|
|
if (isset($env['PHP_AUTH_PW'])) {
|
|
|
|
$password = $env['PHP_AUTH_PW'];
|
|
|
|
}
|
|
|
|
} elseif (isset($env['REMOTE_USER'])) {
|
|
|
|
$user = $env['REMOTE_USER'];
|
|
|
|
}
|
2018-10-26 18:40:20 +00:00
|
|
|
if (strlen($user)) {
|
|
|
|
if (Arsse::$user->auth($user, $password)) {
|
|
|
|
$req = $req->withAttribute("authenticated", true);
|
|
|
|
$req = $req->withAttribute("authenticatedUser", $user);
|
|
|
|
} else {
|
|
|
|
$req = $req->withAttribute("authenticationFailed", true);
|
|
|
|
}
|
2018-01-11 20:48:29 +00:00
|
|
|
}
|
|
|
|
return $req;
|
|
|
|
}
|
|
|
|
|
|
|
|
public function challenge(ResponseInterface $res, string $realm = null): ResponseInterface {
|
|
|
|
$realm = $realm ?? Arsse::$conf->httpRealm ?? "Default";
|
|
|
|
return $res->withAddedHeader("WWW-Authenticate", 'Basic realm="'.$realm.'"');
|
|
|
|
}
|
|
|
|
|
2018-01-06 17:02:45 +00:00
|
|
|
public function normalizeResponse(ResponseInterface $res, RequestInterface $req = null): ResponseInterface {
|
2018-01-11 20:48:29 +00:00
|
|
|
// if the response code is 401, issue an HTTP authentication challenge
|
|
|
|
if ($res->getStatusCode()==401) {
|
|
|
|
$res = $this->challenge($res);
|
|
|
|
}
|
2018-01-06 17:02:45 +00:00
|
|
|
// set or clear the Content-Length header field
|
|
|
|
$body = $res->getBody();
|
|
|
|
$bodySize = $body->getSize();
|
|
|
|
if ($bodySize || $res->getStatusCode()==200) {
|
|
|
|
// if there is a message body or the response is 200, make sure Content-Length is included
|
|
|
|
$res = $res->withHeader("Content-Length", (string) $bodySize);
|
|
|
|
} else {
|
|
|
|
// for empty responses of other statuses, omit it
|
|
|
|
$res = $res->withoutHeader("Content-Length");
|
|
|
|
}
|
|
|
|
// if the response is to a HEAD request, the body should be omitted
|
2018-01-07 17:59:10 +00:00
|
|
|
if ($req && $req->getMethod()=="HEAD") {
|
2018-01-06 17:02:45 +00:00
|
|
|
$res = new EmptyResponse($res->getStatusCode(), $res->getHeaders());
|
|
|
|
}
|
|
|
|
// if an Allow header field is present, normalize it
|
|
|
|
if ($res->hasHeader("Allow")) {
|
2018-01-07 17:59:10 +00:00
|
|
|
$methods = preg_split("<\s*,\s*>", strtoupper($res->getHeaderLine("Allow")));
|
2018-01-06 17:02:45 +00:00
|
|
|
// if GET is allowed, HEAD should be allowed as well
|
|
|
|
if (in_array("GET", $methods) && !in_array("HEAD", $methods)) {
|
|
|
|
$methods[] = "HEAD";
|
|
|
|
}
|
|
|
|
// OPTIONS requests are always allowed by our handlers
|
|
|
|
if (!in_array("OPTIONS", $methods)) {
|
|
|
|
$methods[] = "OPTIONS";
|
|
|
|
}
|
|
|
|
$res = $res->withHeader("Allow", implode(", ", $methods));
|
|
|
|
}
|
2018-01-09 17:31:40 +00:00
|
|
|
// add CORS header fields if the request origin is specified and allowed
|
|
|
|
if ($req && $this->corsNegotiate($req)) {
|
|
|
|
$res = $this->corsApply($res, $req);
|
|
|
|
}
|
2018-01-06 17:02:45 +00:00
|
|
|
return $res;
|
|
|
|
}
|
2018-01-09 17:31:40 +00:00
|
|
|
|
|
|
|
public function corsApply(ResponseInterface $res, RequestInterface $req = null): ResponseInterface {
|
|
|
|
if ($req && $req->getMethod()=="OPTIONS") {
|
|
|
|
if ($res->hasHeader("Allow")) {
|
|
|
|
$res = $res->withHeader("Access-Control-Allow-Methods", $res->getHeaderLine("Allow"));
|
|
|
|
}
|
|
|
|
if ($req->hasHeader("Access-Control-Request-Headers")) {
|
|
|
|
$res = $res->withHeader("Access-Control-Allow-Headers", $req->getHeaderLine("Access-Control-Request-Headers"));
|
|
|
|
}
|
2018-01-12 14:48:33 +00:00
|
|
|
$res = $res->withHeader("Access-Control-Max-Age", (string) (60 *60 *24)); // one day
|
2018-01-09 17:31:40 +00:00
|
|
|
}
|
|
|
|
$res = $res->withHeader("Access-Control-Allow-Origin", $req->getHeaderLine("Origin"));
|
|
|
|
$res = $res->withHeader("Access-Control-Allow-Credentials", "true");
|
|
|
|
return $res->withAddedHeader("Vary", "Origin");
|
|
|
|
}
|
|
|
|
|
|
|
|
public function corsNegotiate(RequestInterface $req, string $allowed = null, string $denied = null): bool {
|
|
|
|
$allowed = trim($allowed ?? Arsse::$conf->httpOriginsAllowed ?? "");
|
|
|
|
$denied = trim($denied ?? Arsse::$conf->httpOriginsDenied ?? "");
|
|
|
|
// continue if at least one origin is allowed
|
|
|
|
if ($allowed) {
|
|
|
|
// continue if the request has exactly one Origin header
|
|
|
|
$origin = $req->getHeader("Origin");
|
|
|
|
if (sizeof($origin)==1) {
|
|
|
|
// continue if the origin is syntactically valid
|
|
|
|
$origin = $this->corsNormalizeOrigin($origin[0]);
|
|
|
|
if ($origin) {
|
|
|
|
// the special "null" origin should not be matched by the wildcard origin
|
|
|
|
$null = ($origin=="null");
|
|
|
|
// pad all strings for simpler comparison
|
|
|
|
$allowed = " ".$allowed." ";
|
|
|
|
$denied = " ".$denied." ";
|
|
|
|
$origin = " ".$origin." ";
|
|
|
|
$any = " * ";
|
|
|
|
if (strpos($denied, $origin) !== false) {
|
|
|
|
// first check the denied list for the origin
|
|
|
|
return false;
|
|
|
|
} elseif (strpos($allowed, $origin) !== false) {
|
|
|
|
// next check the allowed list for the origin
|
|
|
|
return true;
|
|
|
|
} elseif (!$null && strpos($denied, $any) !== false) {
|
|
|
|
// next check the denied list for the wildcard origin
|
|
|
|
return false;
|
|
|
|
} elseif (!$null && strpos($allowed, $any) !== false) {
|
|
|
|
// finally check the allowed list for the wildcard origin
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
public function corsNormalizeOrigin(string $origin, array $ports = null): string {
|
|
|
|
$origin = trim($origin);
|
|
|
|
if ($origin=="null") {
|
|
|
|
// if the origin is the special value "null", use it
|
|
|
|
return "null";
|
|
|
|
}
|
|
|
|
if (preg_match("<^([^:]+)://(\[[^\]]+\]|[^\[\]:/\?#@]+)((?::.*)?)$>i", $origin, $match)) {
|
|
|
|
// if the origin sort-of matches the syntax in a general sense, continue
|
|
|
|
$scheme = $match[1];
|
|
|
|
$host = $match[2];
|
|
|
|
$port = $match[3];
|
|
|
|
// decode and normalize the scheme and port (the port may be blank)
|
|
|
|
$scheme = strtolower(rawurldecode($scheme));
|
|
|
|
$port = rawurldecode($port);
|
|
|
|
if (!preg_match("<^(?::[0-9]+)?$>", $port) || !preg_match("<^[a-z](?:[a-z0-9\+\-\.])*$>", $scheme)) {
|
|
|
|
// if the normalized port contains anything but numbers, or the scheme does not follow the generic URL syntax, the origin is invalid
|
|
|
|
return "";
|
|
|
|
}
|
|
|
|
if ($host[0]=="[") {
|
|
|
|
// if the host appears to be an IPv6 address, validate it
|
|
|
|
$host = rawurldecode(substr($host, 1, strlen($host) - 2));
|
|
|
|
if (!filter_var($host, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6)) {
|
|
|
|
return "";
|
|
|
|
} else {
|
|
|
|
$host = "[".inet_ntop(inet_pton($host))."]";
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
// if the host is a domain name or IP address, split it along dots and just perform URL decoding
|
|
|
|
$host = explode(".", $host);
|
|
|
|
$host = array_map(function ($segment) {
|
|
|
|
return str_replace(".", "%2E", rawurlencode(strtolower(rawurldecode($segment))));
|
|
|
|
}, $host);
|
|
|
|
$host = implode(".", $host);
|
|
|
|
}
|
|
|
|
// suppress default ports
|
|
|
|
if (strlen($port)) {
|
|
|
|
$port = (int) substr($port, 1);
|
|
|
|
$list = array_merge($ports ?? [], self::DEFAULT_PORTS);
|
|
|
|
if (isset($list[$scheme]) && $port==$list[$scheme]) {
|
|
|
|
$port = "";
|
|
|
|
} else {
|
|
|
|
$port = ":".$port;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
// return the reconstructed result
|
|
|
|
return $scheme."://".$host.$port;
|
|
|
|
} else {
|
|
|
|
return "";
|
|
|
|
}
|
|
|
|
}
|
2017-08-29 14:50:31 +00:00
|
|
|
}
|