1
1
Fork 0
mirror of https://code.mensbeam.com/MensBeam/Arsse.git synced 2025-04-12 14:55:51 +00:00

Accept x-www-form-urlencoded in NCNv1

This commit is contained in:
J. King 2025-03-18 16:19:48 -04:00
parent 8ab8eecc19
commit 9d6eb2cb45
3 changed files with 119 additions and 22 deletions
lib/REST/NextcloudNews
tests/cases/REST/NextcloudNews

View file

@ -16,6 +16,7 @@ use JKingWeb\Arsse\Db\ExceptionInput;
use JKingWeb\Arsse\Feed\Exception as FeedException;
use JKingWeb\Arsse\Misc\HTTP;
use JKingWeb\Arsse\REST\Exception;
use MensBeam\Mime\MimeType;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
@ -87,16 +88,51 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
} else {
return HTTP::respEmpty(401);
}
// normalize the input
// parse the input
$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])) {
return HTTP::respEmpty(415, ['Accept' => self::ACCEPTED_TYPE]);
}
$data = @json_decode($data, true);
if (json_last_error() !== \JSON_ERROR_NONE) {
// if the body could not be parsed as JSON, return "400 Bad Request"
// Officially the body, if any, should be JSON. In practice the
// server must also accept application/x-www-form-urlencoded;
// it's also possible that input is mislabelled, so we'll try
// different combinations till something works, or return an
// error status in the end
$type = MimeType::extract($req->getHeaderLine("Content-Type"));
try {
switch ($type->essence ?? "") {
case "application/json":
case "text/json":
$data = $this->parseJson($data);
break;
case "application/x-www-form-urlencoded":
if ($this->guessForm($data)) {
$data = $this->parseForm($data);
} else {
$data = $this->parseJson($data);
}
break;
case "":
if ($this->guessJson($data)) {
$data = $this->parseJson($data);
} elseif ($this->guessForm($data)) {
$data = $this->parseForm($data);
} else {
return HTTP::respEmpty(400);
}
break;
default:
// other media types would normally be rejected, but
// if it happens to be mislabelled JSON we can accept
// it; we will not try form data here, though,
// because input is really expected to be JSON
if ($this->guessJson($data)) {
try {
$data = $this->parseJson($data);
break;
} catch (\JsonException $e) {}
}
return HTTP::respEmpty(415, ['Accept' => self::ACCEPTED_TYPE]);
}
} catch (\JsonException $e) {
return HTTP::respEmpty(400);
}
} else {
@ -124,6 +160,28 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
// @codeCoverageIgnoreEnd
}
protected function guessJson(string $data): bool {
return (bool) preg_match('/^\s*\{\s*"[a-zA-Z]+"\s*:/s', $data);
}
protected function parseJson(string $data): array {
$out = json_decode($data, true, 512, \JSON_THROW_ON_ERROR);
if (!is_array($out)) {
throw new \JsonException("JSON input must be an object");
}
return $out;
}
protected function guessForm(string $data): bool {
return (bool) preg_match('/^\s*[a-zA-Z]+=/s', $data);
}
protected function parseForm(string $data): array {
// we assume that, as PHP application, Nextcloud News uses PHP logic for interpreting form data
parse_str($data, $out); // this cannot fail as any string can be interpreted into some sort of array
return $out;
}
protected function normalizePathIds(string $url): string {
$path = explode("/", $url);
// any path components which are database IDs (integers greater than zero) should be replaced with "1", for easier comparison (we don't care about the specific ID)

View file

@ -26,6 +26,7 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
protected $h;
protected $transaction;
protected $userId;
protected $prefix = "/index.php/apps/news/api/v1-2";
protected static $feeds = [ // expected sample output of a feed list from the database, and the resultant expected transformation by the REST handler
'db' => [
[
@ -307,20 +308,6 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
],
];
protected function req(string $method, string $target, $data = "", array $headers = [], bool $authenticated = true, bool $body = true): ResponseInterface {
Arsse::$user->id = $this->userId;
$prefix = "/index.php/apps/news/api/v1-2";
$url = $prefix.$target;
if ($body) {
$params = [];
} else {
$params = $data;
$data = [];
}
$req = $this->serverRequest($method, $url, $prefix, $headers, [], $data, "application/json", $params, $authenticated ? "john.doe@example.com" : "");
return $this->h->dispatch($req);
}
public function setUp(): void {
parent::setUp();
self::setConf();
@ -329,6 +316,7 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
Arsse::$user = \Phake::mock(User::class);
\Phake::when(Arsse::$user)->auth->thenReturn(true);
\Phake::when(Arsse::$user)->propertiesGet->thenReturn(['admin' => true]);
Arsse::$user->id = $this->userId;
// create a mock database interface
Arsse::$db = \Phake::mock(Database::class);
\Phake::when(Arsse::$db)->begin->thenReturn(\Phake::mock(Transaction::class));
@ -340,6 +328,24 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
return $value;
}
protected function req(string $method, string $target, $data = "", array $headers = [], bool $authenticated = true, bool $body = true): ResponseInterface {
$url = $this->prefix.$target;
if ($body) {
$params = [];
} else {
$params = $data;
$data = [];
}
$req = $this->serverRequest($method, $url, $this->prefix, $headers, [], $data, "application/json", $params, $authenticated ? $this->userId : "");
return $this->h->dispatch($req);
}
protected function reqText(string $method, string $target, string $data, string $type, array $headers = [], bool $authenticated = true): ResponseInterface {
$url = $this->prefix.$target;
$req = $this->serverRequest($method, $url, $this->prefix, $headers, [], $data, $type, [], $authenticated ? $this->userId : "");
return $this->h->dispatch($req);
}
public function testSendAuthenticationChallenge(): void {
$exp = HTTP::respEmpty(401);
$this->assertMessage($exp, $this->req("GET", "/", "", [], false));
@ -552,6 +558,38 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
];
}
#[DataProvider("provideNewSubscriptionsWithType")]
public function testAddSubscriptionsWithDifferentMediaTypes(string $input, string $type, ResponseInterface $exp): void {
\Phake::when(Arsse::$db)->subscriptionAdd->thenReturn(42);
\Phake::when(Arsse::$db)->subscriptionPropertiesSet->thenReturn(true);
\Phake::when(Arsse::$db)->subscriptionPropertiesGet->thenReturn(self::v(self::$feeds['db'][1]));
\Phake::when(Arsse::$db)->editionLatest->thenReturn(4758915);
$act = $this->reqText("POST", "/feeds", $input, $type);
$this->assertMessage($exp, $act);
}
public static function provideNewSubscriptionsWithType(): iterable {
$success = HTTP::respJson(['feeds' => [self::$feeds['rest'][1]], 'newestItemId' => 4758915]);
$badType = HTTP::respEmpty(415, ['Accept' => "application/json"]);
return [
['{"url":"http://example.org/news.atom","folderId":8}', "application/json", $success],
['{"url":"http://example.org/news.atom","folderId":8}', "text/json", $success],
['{"url":"http://example.org/news.atom","folderId":8}', "", $success],
['{"url":"http://example.org/news.atom","folderId":8}', "/", $success],
['{"url":"http://example.org/news.atom","folderId":8}', "application/x-www-form-urlencoded", $success],
['{"url":"http://example.org/news.atom","folderId":8}', "application/octet-stream", $success],
['url=http://example.org/news.atom&folderId=8', "application/x-www-form-urlencoded", $success],
['url=http://example.org/news.atom&folderId=8', "", $success],
['{"url":', "application/json", HTTP::respEmpty(400)],
['{"url":', "text/json", HTTP::respEmpty(400)],
['{"url":', "", HTTP::respEmpty(400)],
['{"url":', "application/x-www-form-urlencoded", HTTP::respEmpty(400)],
['{"url":', "application/octet-stream", $badType],
['null', "application/json", HTTP::respEmpty(400)],
['null', "text/json", HTTP::respEmpty(400)],
];
}
public function testRemoveASubscription(): void {
\Phake::when(Arsse::$db)->subscriptionRemove($this->userId, 1)->thenReturn(true)->thenThrow(new ExceptionInput("subjectMissing"));
$exp = HTTP::respEmpty(204);

View file

@ -17,6 +17,7 @@ use PHPUnit\Framework\Attributes\CoversClass;
#[CoversClass(V1_3::class)]
class TestV1_3 extends TestV1_2 {
protected $prefix = "/index.php/apps/news/api/v1-3";
public function setUp(): void {
parent::setUp();