The clean & modern RSS server that doesn't give you any crap. https://thearsse.com/
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

708 lines
28 KiB

<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\REST\NextcloudNews;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Service;
use JKingWeb\Arsse\Context\Context;
use JKingWeb\Arsse\Misc\ValueInfo;
use JKingWeb\Arsse\AbstractException;
use JKingWeb\Arsse\Db\ExceptionInput;
use JKingWeb\Arsse\Feed\Exception as FeedException;
use JKingWeb\Arsse\Misc\HTTP;
use JKingWeb\Arsse\REST\Exception;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use Laminas\Diactoros\Response\JsonResponse as Response;
class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
public const VERSION = "11.0.5";
protected const ACCEPTED_TYPE = "application/json";
protected $dateFormat = "unix";
protected $validInput = [
'name' => ValueInfo::T_STRING,
'url' => ValueInfo::T_STRING,
'folderId' => ValueInfo::T_INT,
'feedTitle' => ValueInfo::T_STRING,
'userId' => ValueInfo::T_STRING,
'feedId' => ValueInfo::T_INT,
'newestItemId' => ValueInfo::T_INT,
'batchSize' => ValueInfo::T_INT,
'offset' => ValueInfo::T_INT,
'type' => ValueInfo::T_INT,
'id' => ValueInfo::T_INT,
'getRead' => ValueInfo::T_BOOL,
'oldestFirst' => ValueInfo::T_BOOL,
'lastModified' => ValueInfo::T_DATE,
'items' => ValueInfo::T_MIXED | ValueInfo::M_ARRAY,
];
protected $paths = [
'/folders' => ['GET' => "folderList", 'POST' => "folderAdd"],
'/folders/1' => ['PUT' => "folderRename", 'DELETE' => "folderRemove"],
'/folders/1/read' => ['PUT' => "folderMarkRead"],
'/feeds' => ['GET' => "subscriptionList", 'POST' => "subscriptionAdd"],
'/feeds/1' => ['DELETE' => "subscriptionRemove"],
'/feeds/1/move' => ['PUT' => "subscriptionMove"],
'/feeds/1/rename' => ['PUT' => "subscriptionRename"],
'/feeds/1/read' => ['PUT' => "subscriptionMarkRead"],
'/feeds/all' => ['GET' => "feedListStale"],
'/feeds/update' => ['GET' => "feedUpdate"],
'/items' => ['GET' => "articleList"],
'/items/updated' => ['GET' => "articleList"],
'/items/read' => ['PUT' => "articleMarkReadAll"],
'/items/1/read' => ['PUT' => "articleMarkRead"],
'/items/1/unread' => ['PUT' => "articleMarkRead"],
'/items/read/multiple' => ['PUT' => "articleMarkReadMulti"],
'/items/unread/multiple' => ['PUT' => "articleMarkReadMulti"],
'/items/1/1/star' => ['PUT' => "articleMarkStarred"],
'/items/1/1/unstar' => ['PUT' => "articleMarkStarred"],
'/items/star/multiple' => ['PUT' => "articleMarkStarredMulti"],
'/items/unstar/multiple' => ['PUT' => "articleMarkStarredMulti"],
'/cleanup/before-update' => ['GET' => "cleanupBefore"],
'/cleanup/after-update' => ['GET' => "cleanupAfter"],
'/version' => ['GET' => "serverVersion"],
'/status' => ['GET' => "serverStatus"],
'/user' => ['GET' => "userStatus"],
];
public function __construct() {
}
public function dispatch(ServerRequestInterface $req): ResponseInterface {
// get the request path only; this is assumed to already be normalized
$target = parse_url($req->getRequestTarget())['path'] ?? "";
// handle HTTP OPTIONS requests
if ($req->getMethod() === "OPTIONS") {
return $this->handleHTTPOptions($target);
}
// try to authenticate
if ($req->getAttribute("authenticated", false)) {
Arsse::$user->id = $req->getAttribute("authenticatedUser");
} else {
return HTTP::respEmpty(401);
}
// normalize 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"
return HTTP::respEmpty(400);
}
} else {
$data = [];
}
// merge GET and POST data, and normalize it. POST parameters are preferred over GET parameters
$data = $this->normalizeInput(array_merge($req->getQueryParams(), $data), $this->validInput, "unix");
// check to make sure the requested function is implemented
$func = $this->chooseCall($target, $req->getMethod());
if ($func instanceof ResponseInterface) {
return $func;
}
// dispatch
try {
$path = explode("/", ltrim($target, "/"));
return $this->$func($path, $data);
// @codeCoverageIgnoreStart
} catch (Exception $e) {
// if there was a REST exception return 400
return HTTP::respEmpty(400);
} catch (AbstractException $e) {
// if there was any other Arsse exception return 500
return HTTP::respEmpty(500);
}
// @codeCoverageIgnoreEnd
}
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)
for ($a = 0; $a < sizeof($path); $a++) {
if (ValueInfo::id($path[$a])) {
$path[$a] = "1";
}
}
return implode("/", $path);
}
protected function normalizeInput(array $data, array $types, string $dateFormat = null, int $mode = 0): array {
$out = [];
foreach ($types as $key => $type) {
if (isset($data[$key])) {
$out[$key] = ValueInfo::normalize($data[$key], $type | $mode, $dateFormat);
} else {
$out[$key] = null;
}
}
return $out;
}
protected function chooseCall(string $url, string $method) {
// // normalize the URL path: change any IDs to 1 for easier comparison
$url = $this->normalizePathIds($url);
// normalize the HTTP method to uppercase
$method = strtoupper($method);
// we now evaluate the supplied URL against every supported path for the selected scope
if (isset($this->paths[$url])) {
// if the path is supported, make sure the method is allowed
if (isset($this->paths[$url][$method])) {
// if it is allowed, return the object method to run, assuming the method exists
assert(method_exists($this, $this->paths[$url][$method]), new \Exception("Method is not implemented"));
return $this->paths[$url][$method];
} else {
// otherwise return 405
return HTTP::respEmpty(405, ['Allow' => implode(", ", array_keys($this->paths[$url]))]);
}
} else {
// if the path is not supported, return 404
return HTTP::respEmpty(404);
}
}
protected function folderTranslate(array $folder): array {
// map fields to proper names
$folder = $this->fieldMapNames($folder, [
'id' => "id",
'name' => "name",
]);
// cast values
$folder = $this->fieldMapTypes($folder, [
'id' => "int",
'name' => "string",
], $this->dateFormat);
return $folder;
}
protected function feedTranslate(array $feed): array {
// map fields to proper names
$feed = $this->fieldMapNames($feed, [
'id' => "id",
'url' => "url",
'title' => "title",
'added' => "added",
'pinned' => "pinned",
'link' => "source",
'faviconLink' => "icon_url",
'folderId' => "top_folder",
'unreadCount' => "unread",
'ordering' => "order_type",
'updateErrorCount' => "err_count",
'lastUpdateError' => "err_msg",
]);
// cast values
$feed = $this->fieldMapTypes($feed, [
'id' => "int",
'url' => "string",
'title' => "string",
'added' => "datetime",
'pinned' => "bool",
'link' => "string",
'faviconLink' => "string",
'folderId' => "int",
'unreadCount' => "int",
'ordering' => "int",
'updateErrorCount' => "int",
'lastUpdateError' => "string",
], $this->dateFormat);
return $feed;
}
protected function articleTranslate(array $article): array {
// map fields to proper names
$article = $this->fieldMapNames($article, [
'id' => "edition",
'guid' => "guid",
'guidHash' => "id",
'url' => "url",
'title' => "title",
'author' => "author",
'pubDate' => "edited_date",
'body' => "content",
'enclosureMime' => "media_type",
'enclosureLink' => "media_url",
'feedId' => "subscription",
'unread' => "unread",
'starred' => "starred",
'lastModified' => "modified_date",
'fingerprint' => "fingerprint",
]);
// cast values
$article = $this->fieldMapTypes($article, [
'id' => "int",
'guid' => "string",
'guidHash' => "string",
'url' => "string",
'title' => "string",
'author' => "string",
'pubDate' => "datetime",
'body' => "string",
'enclosureMime' => "string",
'enclosureLink' => "string",
'feedId' => "int",
'unread' => "bool",
'starred' => "bool",
'lastModified' => "datetime",
'fingerprint' => "string",
], $this->dateFormat);
return $article;
}
protected function handleHTTPOptions(string $url): ResponseInterface {
// normalize the URL path: change any IDs to 1 for easier comparison
$url = $this->normalizePathIDs($url);
if (isset($this->paths[$url])) {
// if the path is supported, respond with the allowed methods and other metadata
$allowed = array_keys($this->paths[$url]);
// if GET is allowed, so is HEAD
if (in_array("GET", $allowed)) {
array_unshift($allowed, "HEAD");
}
return HTTP::respEmpty(204, [
'Allow' => implode(",", $allowed),
'Accept' => self::ACCEPTED_TYPE,
]);
} else {
// if the path is not supported, return 404
return HTTP::respEmpty(404);
}
}
// list folders
protected function folderList(array $url, array $data): ResponseInterface {
$folders = [];
foreach (Arsse::$db->folderList(Arsse::$user->id, null, false) as $folder) {
$folders[] = $this->folderTranslate($folder);
}
return new Response(['folders' => $folders]);
}
// create a folder
protected function folderAdd(array $url, array $data): ResponseInterface {
try {
$folder = Arsse::$db->folderAdd(Arsse::$user->id, ['name' => $data['name']]);
} catch (ExceptionInput $e) {
switch ($e->getCode()) {
// folder already exists
case 10236: return HTTP::respEmpty(409);
// folder name not acceptable
case 10231:
case 10232: return HTTP::respEmpty(422);
// other errors related to input
default: return HTTP::respEmpty(400); // @codeCoverageIgnore
}
}
$folder = $this->folderTranslate(Arsse::$db->folderPropertiesGet(Arsse::$user->id, $folder));
return new Response(['folders' => [$folder]]);
}
// delete a folder
protected function folderRemove(array $url, array $data): ResponseInterface {
// perform the deletion
try {
Arsse::$db->folderRemove(Arsse::$user->id, (int) $url[1]);
} catch (ExceptionInput $e) {
// folder does not exist
return HTTP::respEmpty(404);
}
return HTTP::respEmpty(204);
}
// rename a folder (also supports moving nesting folders, but this is not a feature of the API)
protected function folderRename(array $url, array $data): ResponseInterface {
try {
Arsse::$db->folderPropertiesSet(Arsse::$user->id, (int) $url[1], ['name' => $data['name']]);
} catch (ExceptionInput $e) {
switch ($e->getCode()) {
// folder does not exist
case 10239: return HTTP::respEmpty(404);
// folder already exists
case 10236: return HTTP::respEmpty(409);
// folder name not acceptable
case 10231:
case 10232: return HTTP::respEmpty(422);
// other errors related to input
default: return HTTP::respEmpty(400); // @codeCoverageIgnore
}
}
return HTTP::respEmpty(204);
}
// mark all articles associated with a folder as read
protected function folderMarkRead(array $url, array $data): ResponseInterface {
if (!ValueInfo::id($data['newestItemId'])) {
// if the item ID is invalid (i.e. not a positive integer), this is an error
return HTTP::respEmpty(422);
}
// build the context
$c = (new Context)->hidden(false);
$c->editionRange(null, (int) $data['newestItemId']);
$c->folder((int) $url[1]);
// perform the operation
try {
Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], $c);
} catch (ExceptionInput $e) {
// folder does not exist
return HTTP::respEmpty(404);
}
return HTTP::respEmpty(204);
}
// return list of feeds which should be refreshed
protected function feedListStale(array $url, array $data): ResponseInterface {
if (!$this->isAdmin()) {
return HTTP::respEmpty(403);
}
// list stale feeds which should be checked for updates
$feeds = Arsse::$db->feedListStale();
$out = [];
foreach ($feeds as $feed) {
// since in our implementation feeds don't belong the users, the 'userId' field will always be an empty string
$out[] = ['id' => (int) $feed, 'userId' => ""];
}
return new Response(['feeds' => $out]);
}
// refresh a feed
protected function feedUpdate(array $url, array $data): ResponseInterface {
if (!$this->isAdmin()) {
return HTTP::respEmpty(403);
}
try {
Arsse::$db->feedUpdate($data['feedId']);
} catch (ExceptionInput $e) {
switch ($e->getCode()) {
case 10239: // feed does not exist
return HTTP::respEmpty(404);
case 10237: // feed ID invalid
return HTTP::respEmpty(422);
default: // other errors related to input
return HTTP::respEmpty(400); // @codeCoverageIgnore
}
}
return HTTP::respEmpty(204);
}
// add a new feed
protected function subscriptionAdd(array $url, array $data): ResponseInterface {
// try to add the feed
$tr = Arsse::$db->begin();
try {
$id = Arsse::$db->subscriptionAdd(Arsse::$user->id, (string) $data['url']);
} catch (ExceptionInput $e) {
// feed already exists
return HTTP::respEmpty(409);
} catch (FeedException $e) {
// feed could not be retrieved
return HTTP::respEmpty(422);
}
// if a folder was specified, move the feed to the correct folder; silently ignore errors
if ($data['folderId']) {
try {
Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, $id, ['folder' => $data['folderId']]);
} catch (ExceptionInput $e) {
}
}
$tr->commit();
// fetch the feed's metadata and format it appropriately
$feed = Arsse::$db->subscriptionPropertiesGet(Arsse::$user->id, $id);
$feed = $this->feedTranslate($feed);
$out = ['feeds' => [$feed]];
$newest = Arsse::$db->editionLatest(Arsse::$user->id, (new Context)->subscription($id)->hidden(false));
if ($newest) {
$out['newestItemId'] = $newest;
}
return new Response($out);
}
// return list of feeds for the logged-in user
protected function subscriptionList(array $url, array $data): ResponseInterface {
$subs = Arsse::$db->subscriptionList(Arsse::$user->id);
$out = [];
foreach ($subs as $sub) {
$out[] = $this->feedTranslate($sub);
}
$out = ['feeds' => $out];
$out['starredCount'] = (int) Arsse::$db->articleStarred(Arsse::$user->id)['total'];
$newest = Arsse::$db->editionLatest(Arsse::$user->id);
if ($newest) {
$out['newestItemId'] = $newest;
}
return new Response($out);
}
// delete a feed
protected function subscriptionRemove(array $url, array $data): ResponseInterface {
try {
Arsse::$db->subscriptionRemove(Arsse::$user->id, (int) $url[1]);
} catch (ExceptionInput $e) {
// feed does not exist
return HTTP::respEmpty(404);
}
return HTTP::respEmpty(204);
}
// rename a feed
protected function subscriptionRename(array $url, array $data): ResponseInterface {
try {
Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, (int) $url[1], ['title' => (string) $data['feedTitle']]);
} catch (ExceptionInput $e) {
switch ($e->getCode()) {
// subscription does not exist
case 10239: return HTTP::respEmpty(404);
// name is invalid
case 10231:
case 10232: return HTTP::respEmpty(422);
// other errors related to input
default: return HTTP::respEmpty(400); // @codeCoverageIgnore
}
}
return HTTP::respEmpty(204);
}
// move a feed to a folder
protected function subscriptionMove(array $url, array $data): ResponseInterface {
// if no folder is specified this is an error
if (!isset($data['folderId'])) {
return HTTP::respEmpty(422);
}
// perform the move
try {
Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, (int) $url[1], ['folder' => $data['folderId']]);
} catch (ExceptionInput $e) {
switch ($e->getCode()) {
case 10239: // subscription does not exist
return HTTP::respEmpty(404);
case 10235: // folder does not exist
case 10237: // folder ID is invalid
return HTTP::respEmpty(422);
default: // other errors related to input
return HTTP::respEmpty(400); // @codeCoverageIgnore
}
}
return HTTP::respEmpty(204);
}
// mark all articles associated with a subscription as read
protected function subscriptionMarkRead(array $url, array $data): ResponseInterface {
if (!ValueInfo::id($data['newestItemId'])) {
// if the item ID is invalid (i.e. not a positive integer), this is an error
return HTTP::respEmpty(422);
}
// build the context
$c = (new Context)->hidden(false);
$c->editionRange(null, (int) $data['newestItemId']);
$c->subscription((int) $url[1]);
// perform the operation
try {
Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], $c);
} catch (ExceptionInput $e) {
// subscription does not exist
return HTTP::respEmpty(404);
}
return HTTP::respEmpty(204);
}
// list articles and their properties
protected function articleList(array $url, array $data): ResponseInterface {
// set the context options supplied by the client
$c = (new Context)->hidden(false);
// set the batch size
if ($data['batchSize'] > 0) {
$c->limit($data['batchSize']);
}
// set the order of returned items
$reverse = !$data['oldestFirst'];
// set the edition mark-off; the database uses an or-equal comparison for internal consistency, but the protocol does not, so we must adjust by one
if ($data['offset'] > 0) {
if ($reverse) {
$c->editionRange(null, $data['offset'] - 1);
} else {
$c->editionRange($data['offset'] + 1, null);
}
}
// set whether to only return unread
if (!ValueInfo::bool($data['getRead'], true)) {
$c->unread(true);
}
// if no type is specified assume 3 (All)
$data['type'] = $data['type'] ?? 3;
switch ($data['type']) {
case 0: // feed
if (isset($data['id'])) {
$c->subscription($data['id']);
}
break;
case 1: // folder
if (isset($data['id'])) {
$c->folder($data['id']);
}
break;
case 2: // starred
$c->starred(true);
break;
default: // @codeCoverageIgnore
// return all items
}
// whether to return only updated items
if ($data['lastModified']) {
$c->markedRange($data['lastModified'], null);
}
// perform the fetch
try {
$items = Arsse::$db->articleList(Arsse::$user->id, $c, [
"edition",
"guid",
"id",
"url",
"title",
"author",
"edited_date",
"content",
"media_type",
"media_url",
"subscription",
"unread",
"starred",
"modified_date",
"fingerprint",
], [$reverse ? "edition desc" : "edition"]);
} catch (ExceptionInput $e) {
// ID of subscription or folder is not valid
return HTTP::respEmpty(422);
}
$out = [];
foreach ($items as $item) {
$out[] = $this->articleTranslate($item);
}
$out = ['items' => $out];
return new Response($out);
}
// mark all articles as read
protected function articleMarkReadAll(array $url, array $data): ResponseInterface {
if (!ValueInfo::id($data['newestItemId'])) {
// if the item ID is invalid (i.e. not a positive integer), this is an error
return HTTP::respEmpty(422);
}
// build the context
$c = (new Context)->hidden(false);
$c->editionRange(null, (int) $data['newestItemId']);
// perform the operation
Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], $c);
return HTTP::respEmpty(204);
}
// mark a single article as read
protected function articleMarkRead(array $url, array $data): ResponseInterface {
// initialize the matching context
$c = new Context;
$c->edition((int) $url[1]);
// determine whether to mark read or unread
$set = ($url[2] === "read");
try {
Arsse::$db->articleMark(Arsse::$user->id, ['read' => $set], $c);
} catch (ExceptionInput $e) {
// ID is not valid
return HTTP::respEmpty(404);
}
return HTTP::respEmpty(204);
}
// mark a single article as read
protected function articleMarkStarred(array $url, array $data): ResponseInterface {
// initialize the matching context
$c = new Context;
$c->article((int) $url[2]);
// determine whether to mark read or unread
$set = ($url[3] === "star");
try {
Arsse::$db->articleMark(Arsse::$user->id, ['starred' => $set], $c);
} catch (ExceptionInput $e) {
// ID is not valid
return HTTP::respEmpty(404);
}
return HTTP::respEmpty(204);
}
// mark an array of articles as read
protected function articleMarkReadMulti(array $url, array $data): ResponseInterface {
// determine whether to mark read or unread
$set = ($url[1] === "read");
// initialize the matching context
$c = new Context;
$c->editions($data['items'] ?? []);
try {
Arsse::$db->articleMark(Arsse::$user->id, ['read' => $set], $c);
} catch (ExceptionInput $e) {
}
return HTTP::respEmpty(204);
}
// mark an array of articles as starred
protected function articleMarkStarredMulti(array $url, array $data): ResponseInterface {
// determine whether to mark starred or unstarred
$set = ($url[1] === "star");
// initialize the matching context
$c = new Context;
$c->articles(array_column($data['items'] ?? [], "guidHash"));
try {
Arsse::$db->articleMark(Arsse::$user->id, ['starred' => $set], $c);
} catch (ExceptionInput $e) {
}
return HTTP::respEmpty(204);
}
protected function userStatus(array $url, array $data): ResponseInterface {
return new Response([
'userId' => (string) Arsse::$user->id,
'displayName' => (string) Arsse::$user->id,
'lastLoginTimestamp' => time(),
'avatar' => null,
]);
}
protected function cleanupBefore(array $url, array $data): ResponseInterface {
if (!$this->isAdmin()) {
return HTTP::respEmpty(403);
}
Service::cleanupPre();
return HTTP::respEmpty(204);
}
protected function cleanupAfter(array $url, array $data): ResponseInterface {
if (!$this->isAdmin()) {
return HTTP::respEmpty(403);
}
Service::cleanupPost();
return HTTP::respEmpty(204);
}
// return the server version
protected function serverVersion(array $url, array $data): ResponseInterface {
return new Response([
'version' => self::VERSION,
'arsse_version' => Arsse::VERSION,
]);
}
protected function serverStatus(array $url, array $data): ResponseInterface {
return new Response([
'version' => self::VERSION,
'arsse_version' => Arsse::VERSION,
'warnings' => [
'improperlyConfiguredCron' => !Service::hasCheckedIn(),
'incorrectDbCharset' => !Arsse::$db->driverCharsetAcceptable(),
],
]);
}
}