mirror of
https://code.mensbeam.com/MensBeam/Arsse.git
synced 2025-04-23 13:55:51 +00:00
Use dedicated MIME parser; add WWW-Autheticate to OPTIONS
This commit is contained in:
parent
86c834030a
commit
6e65f288a7
13 changed files with 40 additions and 61 deletions
lib
tests
cases
Misc
REST
lib
|
@ -10,23 +10,25 @@ namespace JKingWeb\Arsse\Misc;
|
|||
use Psr\Http\Message\MessageInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use JKingWeb\Arsse\Arsse;
|
||||
use MensBeam\Mime\MimeType;
|
||||
|
||||
class HTTP {
|
||||
public static function matchType(MessageInterface $msg, string ...$type): bool {
|
||||
$header = $msg->getHeaderLine("Content-Type") ?? "";
|
||||
foreach ($type as $t) {
|
||||
if (($t[0] ?? "") === "+") {
|
||||
$pattern = "/^[^+;,\s]*".preg_quote(trim($t), "/")."\s*($|;|,)/Di";
|
||||
} else {
|
||||
$pattern = "/^".preg_quote(trim($t), "/")."\s*($|;|,)/Di";
|
||||
}
|
||||
if (preg_match($pattern, $header)) {
|
||||
return true;
|
||||
}
|
||||
public static function matchType(MessageInterface $msg, array $types, bool $allowEmpty = true): bool {
|
||||
$header = MimeType::extract($msg->getHeaderLine("Content-Type"));
|
||||
if (!$header) {
|
||||
return $allowEmpty;
|
||||
} elseif (MimeType::negotiate([(string) $header], $types) !== null) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function challenge(ResponseInterface $res): ResponseInterface {
|
||||
$realm = Arsse::$conf ? Arsse::$conf->httpRealm : "The Advanced RSS Environment";
|
||||
return $res->withAddedHeader("WWW-Authenticate", 'Basic realm="'.$realm.'", charset="UTF-8"');
|
||||
}
|
||||
|
||||
public static function respEmpty(int $status, ?array $headers = []): ResponseInterface {
|
||||
return new Response($status, $headers ?? []);
|
||||
}
|
||||
|
|
|
@ -69,7 +69,7 @@ class REST {
|
|||
// Selfoss https://github.com/SSilence/selfoss/wiki/Restful-API-for-Apps-or-any-other-external-access
|
||||
// NewsBlur http://www.newsblur.com/api
|
||||
// Unclear if clients exist:
|
||||
// Nextcloud News v2 https://github.com/nextcloud/news/blob/master/docs/externalapi/External-Api.md
|
||||
// Nextcloud News v2 https://github.com/nextcloud/news/blob/master/docs/api/api-v2.md
|
||||
// BirdReader https://github.com/glynnbird/birdreader/blob/master/API.md
|
||||
// Feedbin v1 https://github.com/feedbin/feedbin-api/commit/86da10aac5f1a57531a6e17b08744e5f9e7db8a9
|
||||
// Proprietary (centralized) entities:
|
||||
|
@ -164,15 +164,10 @@ class REST {
|
|||
return $req;
|
||||
}
|
||||
|
||||
public function challenge(ResponseInterface $res, ?string $realm = null): ResponseInterface {
|
||||
$realm = $realm ?? Arsse::$conf->httpRealm;
|
||||
return $res->withAddedHeader("WWW-Authenticate", 'Basic realm="'.$realm.'", charset="UTF-8"');
|
||||
}
|
||||
|
||||
public function normalizeResponse(ResponseInterface $res, ?RequestInterface $req = null): ResponseInterface {
|
||||
// if the response code is 401, issue an HTTP authentication challenge
|
||||
if ($res->getStatusCode() == 401) {
|
||||
$res = $this->challenge($res);
|
||||
$res = HTTP::challenge($res);
|
||||
}
|
||||
// set or clear the Content-Length header field
|
||||
$body = $res->getBody();
|
||||
|
|
|
@ -64,10 +64,10 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
|
|||
}
|
||||
switch ($req->getMethod()) {
|
||||
case "OPTIONS":
|
||||
return HTTP::respEmpty(204, [
|
||||
return HTTP::challenge(HTTP::respEmpty(204, [
|
||||
'Allow' => "POST",
|
||||
'Accept' => implode(", ", self::ACCEPTED_TYPES),
|
||||
]);
|
||||
]));
|
||||
case "GET": // HTTP violation required for client "Unread" on iOS
|
||||
case "POST":
|
||||
$out = [
|
||||
|
@ -180,7 +180,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
|
|||
if ($xml) {
|
||||
$d = new \DOMDocument("1.0", "utf-8");
|
||||
$d->appendChild($this->makeXMLAssoc($data, $d->createElement("response")));
|
||||
return HTTP::respXml($d->saveXML());
|
||||
return HTTP::respXml($d->saveXML($d->documentElement, \LIBXML_NOEMPTYTAG));
|
||||
} else {
|
||||
return HTTP::respJson($data, 200, [], \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
|
|
@ -458,10 +458,10 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
|
|||
if (in_array("GET", $allowed)) {
|
||||
array_unshift($allowed, "HEAD");
|
||||
}
|
||||
return HTTP::respEmpty(204, [
|
||||
return HTTP::challenge(HTTP::respEmpty(204, [
|
||||
'Allow' => implode(", ", $allowed),
|
||||
'Accept' => implode(", ", $url === "/import" ? self::ACCEPTED_TYPES_OPML : self::ACCEPTED_TYPES_JSON),
|
||||
]);
|
||||
]));
|
||||
} else {
|
||||
// if the path is not supported, return 404
|
||||
return HTTP::respEmpty(404);
|
||||
|
|
|
@ -91,7 +91,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
|
|||
$data = (string) $req->getBody();
|
||||
if ($data) {
|
||||
// if the entity body is not JSON according to content type, return "415 Unsupported Media Type"
|
||||
if (!HTTP::matchType($req, "", self::ACCEPTED_TYPE)) {
|
||||
if (!HTTP::matchType($req, [self::ACCEPTED_TYPE])) {
|
||||
return HTTP::respEmpty(415, ['Accept' => self::ACCEPTED_TYPE]);
|
||||
}
|
||||
$data = @json_decode($data, true);
|
||||
|
@ -269,10 +269,10 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
|
|||
if (in_array("GET", $allowed)) {
|
||||
array_unshift($allowed, "HEAD");
|
||||
}
|
||||
return HTTP::respEmpty(204, [
|
||||
return HTTP::challenge(HTTP::respEmpty(204, [
|
||||
'Allow' => implode(",", $allowed),
|
||||
'Accept' => self::ACCEPTED_TYPE,
|
||||
]);
|
||||
]));
|
||||
} else {
|
||||
// if the path is not supported, return 404
|
||||
return HTTP::respEmpty(404);
|
||||
|
|
|
@ -100,10 +100,10 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
|
|||
}
|
||||
if ($req->getMethod() === "OPTIONS") {
|
||||
// respond to OPTIONS rquests; the response is a fib, as we technically accept any type or method
|
||||
return HTTP::respEmpty(204, [
|
||||
return HTTP::challenge(HTTP::respEmpty(204, [
|
||||
'Allow' => "POST",
|
||||
'Accept' => implode(", ", self::ACCEPTED_TYPES),
|
||||
]);
|
||||
]));
|
||||
}
|
||||
$data = (string) $req->getBody();
|
||||
if ($data) {
|
||||
|
|
|
@ -20,9 +20,9 @@ class TestHTTP extends \JKingWeb\Arsse\Test\AbstractTest {
|
|||
#[DataProvider('provideMediaTypes')]
|
||||
public function testMatchMediaType(string $header, array $types, bool $exp): void {
|
||||
$msg = (new Request("POST", "/"))->withHeader("Content-Type", $header);
|
||||
$this->assertSame($exp, HTTP::matchType($msg, ...$types));
|
||||
$this->assertSame($exp, HTTP::matchType($msg, $types));
|
||||
$msg = (new Response)->withHeader("Content-Type", $header);
|
||||
$this->assertSame($exp, HTTP::matchType($msg, ...$types));
|
||||
$this->assertSame($exp, HTTP::matchType($msg, $types));
|
||||
}
|
||||
|
||||
public static function provideMediaTypes(): array {
|
||||
|
@ -31,11 +31,8 @@ class TestHTTP extends \JKingWeb\Arsse\Test\AbstractTest {
|
|||
["APPLICATION/JSON", ["application/json"], true],
|
||||
["text/JSON", ["application/json", "text/json"], true],
|
||||
["text/json; charset=utf-8", ["application/json", "text/json"], true],
|
||||
["", ["application/json"], false],
|
||||
["", ["application/json", ""], true],
|
||||
["", ["application/json"], true],
|
||||
["application/json ;", ["application/json"], true],
|
||||
["application/feed+json", ["application/json", "+json"], true],
|
||||
["application/xhtml+xml", ["application/json", "+json"], false],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
@ -492,10 +492,10 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest {
|
|||
}
|
||||
|
||||
public function testAnswerOptionsRequest(): void {
|
||||
$exp = HTTP::respEmpty(204, [
|
||||
$exp = HTTP::challenge(HTTP::respEmpty(204, [
|
||||
'Allow' => "POST",
|
||||
'Accept' => "application/x-www-form-urlencoded, multipart/form-data",
|
||||
]);
|
||||
]));
|
||||
$this->assertMessage($exp, $this->req("api", "", "OPTIONS"));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -147,10 +147,10 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
|
|||
|
||||
#[DataProvider("provideOptionsRequests")]
|
||||
public function testRespondToOptionsRequests(string $url, string $allow, string $accept): void {
|
||||
$exp = HTTP::respEmpty(204, [
|
||||
$exp = HTTP::challenge(HTTP::respEmpty(204, [
|
||||
'Allow' => $allow,
|
||||
'Accept' => $accept,
|
||||
]);
|
||||
]));
|
||||
$this->assertMessage($exp, $this->req("OPTIONS", $url));
|
||||
}
|
||||
|
||||
|
|
|
@ -388,10 +388,10 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
|
|||
|
||||
#[DataProvider("provideOptionsRequests")]
|
||||
public function testRespondToOptionsRequests(string $url, string $allow, string $accept): void {
|
||||
$exp = HTTP::respEmpty(204, [
|
||||
$exp = HTTP::challenge(HTTP::respEmpty(204, [
|
||||
'Allow' => $allow,
|
||||
'Accept' => $accept,
|
||||
]);
|
||||
]));
|
||||
$this->assertMessage($exp, $this->req("OPTIONS", $url));
|
||||
}
|
||||
|
||||
|
|
|
@ -95,18 +95,6 @@ class TestREST extends \JKingWeb\Arsse\Test\AbstractTest {
|
|||
];
|
||||
}
|
||||
|
||||
public function testSendAuthenticationChallenges(): void {
|
||||
self::setConf();
|
||||
$r = new REST;
|
||||
$in = HTTP::respEmpty(401);
|
||||
$exp = $in->withHeader("WWW-Authenticate", 'Basic realm="OOK", charset="UTF-8"');
|
||||
$act = $r->challenge($in, "OOK");
|
||||
$this->assertMessage($exp, $act);
|
||||
$exp = $in->withHeader("WWW-Authenticate", 'Basic realm="'.Arsse::$conf->httpRealm.'", charset="UTF-8"');
|
||||
$act = $r->challenge($in);
|
||||
$this->assertMessage($exp, $act);
|
||||
}
|
||||
|
||||
|
||||
#[DataProvider('provideUnnormalizedOrigins')]
|
||||
public function testNormalizeOrigins(string $origin, string $exp, ?array $ports = null): void {
|
||||
|
@ -260,9 +248,6 @@ class TestREST extends \JKingWeb\Arsse\Test\AbstractTest {
|
|||
public function testNormalizeHttpResponses(ResponseInterface $res, ResponseInterface $exp, ?RequestInterface $req = null): void {
|
||||
$rMock = \Phake::partialMock(REST::class);
|
||||
\Phake::when($rMock)->corsNegotiate->thenReturn(true);
|
||||
\Phake::when($rMock)->challenge->thenReturnCallback(function($res) {
|
||||
return $res->withHeader("WWW-Authenticate", "Fake Value");
|
||||
});
|
||||
\Phake::when($rMock)->corsApply->thenReturnCallback(function($res) {
|
||||
return $res;
|
||||
});
|
||||
|
@ -275,7 +260,7 @@ class TestREST extends \JKingWeb\Arsse\Test\AbstractTest {
|
|||
fwrite($stream, "ook");
|
||||
return [
|
||||
[HTTP::respEmpty(204), HTTP::respEmpty(204)],
|
||||
[HTTP::respEmpty(401), HTTP::respEmpty(401, ['WWW-Authenticate' => "Fake Value"])],
|
||||
[HTTP::respEmpty(401), HTTP::challenge(HTTP::respEmpty(401))],
|
||||
[HTTP::respEmpty(204, ['Allow' => "PUT"]), HTTP::respEmpty(204, ['Allow' => "PUT, OPTIONS"])],
|
||||
[HTTP::respEmpty(204, ['Allow' => "PUT, OPTIONS"]), HTTP::respEmpty(204, ['Allow' => "PUT, OPTIONS"])],
|
||||
[HTTP::respEmpty(204, ['Allow' => "PUT,OPTIONS"]), HTTP::respEmpty(204, ['Allow' => "PUT, OPTIONS"])],
|
||||
|
|
|
@ -192,10 +192,10 @@ LONG_STRING;
|
|||
}
|
||||
|
||||
public function testHandleOptionsRequest(): void {
|
||||
$exp = HTTP::respEmpty(204, [
|
||||
$exp = HTTP::challenge(HTTP::respEmpty(204, [
|
||||
'Allow' => "POST",
|
||||
'Accept' => "application/json, text/json",
|
||||
]);
|
||||
]));
|
||||
$this->assertMessage($exp, $this->req(null, "OPTIONS", "", ""));
|
||||
}
|
||||
|
||||
|
|
|
@ -189,13 +189,13 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase {
|
|||
$this->assertSame($exp->getMethod(), $act->getMethod(), $text);
|
||||
$this->assertSame($exp->getRequestTarget(), $act->getRequestTarget(), $text);
|
||||
}
|
||||
if ($exp instanceof ResponseInterface && HTTP::matchType($exp, "application/json", "text/json", "+json")) {
|
||||
if ($exp instanceof ResponseInterface && HTTP::matchType($exp, ["application/json", "text/json"], false)) {
|
||||
$expBody = @json_decode((string) $exp->getBody(), true);
|
||||
$actBody = @json_decode((string) $act->getBody(), true);
|
||||
$this->assertSame(\JSON_ERROR_NONE, json_last_error(), "Response body is not valid JSON");
|
||||
$this->assertEquals($expBody, $actBody, $text);
|
||||
$this->assertSame($expBody, $actBody, $text);
|
||||
} elseif ($exp instanceof ResponseInterface && HTTP::matchType($exp, "application/xml", "text/xml", "+xml")) {
|
||||
} elseif ($exp instanceof ResponseInterface && HTTP::matchType($exp, ["application/xml", "text/xml"], false)) {
|
||||
$this->assertXmlStringEqualsXmlString((string) $exp->getBody(), (string) $act->getBody(), $text);
|
||||
} else {
|
||||
$this->assertSame((string) $exp->getBody(), (string) $act->getBody(), $text);
|
||||
|
@ -204,7 +204,7 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase {
|
|||
}
|
||||
|
||||
protected function extractMessageJson(MessageInterface $msg) {
|
||||
if (HTTP::matchType($msg, "application/json", "text/json", "+json")) {
|
||||
if (HTTP::matchType($msg, ["application/json", "text/json"], false)) {
|
||||
$json = @json_decode((string) $msg->getBody(), true);
|
||||
if (json_last_error() === \JSON_ERROR_NONE) {
|
||||
return $json;
|
||||
|
|
Loading…
Add table
Reference in a new issue