1
1
Fork 0
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:
J. King 2025-03-08 20:20:12 -05:00
parent 86c834030a
commit 6e65f288a7
13 changed files with 40 additions and 61 deletions
lib
Misc
REST.php
REST
Fever
Miniflux
NextcloudNews
TinyTinyRSS
tests

View file

@ -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 ?? []);
}

View file

@ -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();

View file

@ -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);
}

View file

@ -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);

View file

@ -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);

View file

@ -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) {

View file

@ -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],
];
}

View file

@ -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"));
}
}

View file

@ -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));
}

View file

@ -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));
}

View file

@ -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"])],

View file

@ -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", "", ""));
}

View file

@ -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;