mirror of
https://code.mensbeam.com/MensBeam/Arsse.git
synced 2024-12-23 06:24:53 +00:00
f90b78a976
- CORS OPTIONS requests may not ask for credentials - Fever apparently didn't care about supplied Content-Type
709 lines
28 KiB
PHP
709 lines
28 KiB
PHP
<?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;
|
|
use Laminas\Diactoros\Response\EmptyResponse;
|
|
|
|
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 new EmptyResponse(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 new EmptyResponse(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 new EmptyResponse(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 new EmptyResponse(400);
|
|
} catch (AbstractException $e) {
|
|
// if there was any other Arsse exception return 500
|
|
return new EmptyResponse(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 new EmptyResponse(405, ['Allow' => implode(", ", array_keys($this->paths[$url]))]);
|
|
}
|
|
} else {
|
|
// if the path is not supported, return 404
|
|
return new EmptyResponse(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 new EmptyResponse(204, [
|
|
'Allow' => implode(",", $allowed),
|
|
'Accept' => self::ACCEPTED_TYPE,
|
|
]);
|
|
} else {
|
|
// if the path is not supported, return 404
|
|
return new EmptyResponse(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 new EmptyResponse(409);
|
|
// folder name not acceptable
|
|
case 10231:
|
|
case 10232: return new EmptyResponse(422);
|
|
// other errors related to input
|
|
default: return new EmptyResponse(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 new EmptyResponse(404);
|
|
}
|
|
return new EmptyResponse(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 new EmptyResponse(404);
|
|
// folder already exists
|
|
case 10236: return new EmptyResponse(409);
|
|
// folder name not acceptable
|
|
case 10231:
|
|
case 10232: return new EmptyResponse(422);
|
|
// other errors related to input
|
|
default: return new EmptyResponse(400); // @codeCoverageIgnore
|
|
}
|
|
}
|
|
return new EmptyResponse(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 new EmptyResponse(422);
|
|
}
|
|
// build the context
|
|
$c = (new Context)->hidden(false);
|
|
$c->latestEdition((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 new EmptyResponse(404);
|
|
}
|
|
return new EmptyResponse(204);
|
|
}
|
|
|
|
// return list of feeds which should be refreshed
|
|
protected function feedListStale(array $url, array $data): ResponseInterface {
|
|
if (!$this->isAdmin()) {
|
|
return new EmptyResponse(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 new EmptyResponse(403);
|
|
}
|
|
try {
|
|
Arsse::$db->feedUpdate($data['feedId']);
|
|
} catch (ExceptionInput $e) {
|
|
switch ($e->getCode()) {
|
|
case 10239: // feed does not exist
|
|
return new EmptyResponse(404);
|
|
case 10237: // feed ID invalid
|
|
return new EmptyResponse(422);
|
|
default: // other errors related to input
|
|
return new EmptyResponse(400); // @codeCoverageIgnore
|
|
}
|
|
}
|
|
return new EmptyResponse(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 new EmptyResponse(409);
|
|
} catch (FeedException $e) {
|
|
// feed could not be retrieved
|
|
return new EmptyResponse(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 new EmptyResponse(404);
|
|
}
|
|
return new EmptyResponse(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 new EmptyResponse(404);
|
|
// name is invalid
|
|
case 10231:
|
|
case 10232: return new EmptyResponse(422);
|
|
// other errors related to input
|
|
default: return new EmptyResponse(400); // @codeCoverageIgnore
|
|
}
|
|
}
|
|
return new EmptyResponse(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 new EmptyResponse(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 new EmptyResponse(404);
|
|
case 10235: // folder does not exist
|
|
case 10237: // folder ID is invalid
|
|
return new EmptyResponse(422);
|
|
default: // other errors related to input
|
|
return new EmptyResponse(400); // @codeCoverageIgnore
|
|
}
|
|
}
|
|
return new EmptyResponse(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 new EmptyResponse(422);
|
|
}
|
|
// build the context
|
|
$c = (new Context)->hidden(false);
|
|
$c->latestEdition((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 new EmptyResponse(404);
|
|
}
|
|
return new EmptyResponse(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->latestEdition($data['offset'] - 1);
|
|
} else {
|
|
$c->oldestEdition($data['offset'] + 1);
|
|
}
|
|
}
|
|
// 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->markedSince($data['lastModified']);
|
|
}
|
|
// 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 new EmptyResponse(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 new EmptyResponse(422);
|
|
}
|
|
// build the context
|
|
$c = (new Context)->hidden(false);
|
|
$c->latestEdition((int) $data['newestItemId']);
|
|
// perform the operation
|
|
Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], $c);
|
|
return new EmptyResponse(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 new EmptyResponse(404);
|
|
}
|
|
return new EmptyResponse(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 new EmptyResponse(404);
|
|
}
|
|
return new EmptyResponse(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 new EmptyResponse(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 new EmptyResponse(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 new EmptyResponse(403);
|
|
}
|
|
Service::cleanupPre();
|
|
return new EmptyResponse(204);
|
|
}
|
|
|
|
protected function cleanupAfter(array $url, array $data): ResponseInterface {
|
|
if (!$this->isAdmin()) {
|
|
return new EmptyResponse(403);
|
|
}
|
|
Service::cleanupPost();
|
|
return new EmptyResponse(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(),
|
|
],
|
|
]);
|
|
}
|
|
}
|