1
1
Fork 0
mirror of https://code.mensbeam.com/MensBeam/Arsse.git synced 2024-12-23 17:12:41 +00:00
Arsse/lib/REST/Miniflux/V1.php

1215 lines
52 KiB
PHP
Raw Normal View History

2020-11-01 01:26:11 +00:00
<?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\Miniflux;
use JKingWeb\Arsse\Arsse;
2020-12-02 23:00:27 +00:00
use JKingWeb\Arsse\Feed;
use JKingWeb\Arsse\ExceptionType;
2020-12-02 23:00:27 +00:00
use JKingWeb\Arsse\Feed\Exception as FeedException;
2020-11-01 01:26:11 +00:00
use JKingWeb\Arsse\AbstractException;
2020-12-14 17:41:09 +00:00
use JKingWeb\Arsse\Context\Context;
use JKingWeb\Arsse\Context\UnionContext;
use JKingWeb\Arsse\Context\RootContext;
use JKingWeb\Arsse\Db\ExceptionInput;
2021-02-06 01:29:41 +00:00
use JKingWeb\Arsse\ImportExport\OPML;
use JKingWeb\Arsse\ImportExport\Exception as ImportException;
2020-12-08 20:34:31 +00:00
use JKingWeb\Arsse\Misc\Date;
2021-01-20 04:17:03 +00:00
use JKingWeb\Arsse\Misc\URL;
use JKingWeb\Arsse\Misc\HTTP;
2020-12-02 23:00:27 +00:00
use JKingWeb\Arsse\Misc\ValueInfo as V;
2020-11-01 01:26:11 +00:00
use JKingWeb\Arsse\REST\Exception;
2021-01-20 04:17:03 +00:00
use JKingWeb\Arsse\Rule\Rule;
2020-12-28 13:12:30 +00:00
use JKingWeb\Arsse\User\ExceptionConflict;
use JKingWeb\Arsse\User\Exception as UserException;
2020-11-01 01:26:11 +00:00
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
2022-08-06 17:40:02 +00:00
use GuzzleHttp\Psr7\Uri;
2020-11-01 01:26:11 +00:00
class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
public const VERSION = "2.0.28";
2020-12-02 23:00:27 +00:00
2020-12-01 17:08:45 +00:00
protected const ACCEPTED_TYPES_OPML = ["application/xml", "text/xml", "text/x-opml"];
protected const ACCEPTED_TYPES_JSON = ["application/json"];
2021-02-04 22:07:22 +00:00
protected const DEFAULT_ENTRY_LIMIT = 100;
protected const DEFAULT_ORDER_COL = "modified_date";
2021-02-03 21:27:55 +00:00
protected const DATE_FORMAT_SEC = "Y-m-d\TH:i:sP";
protected const DATE_FORMAT_MICRO = "Y-m-d\TH:i:s.uP";
2021-01-30 18:38:02 +00:00
protected const VALID_QUERY = [
'status' => V::T_STRING + V::M_ARRAY,
'offset' => V::T_INT,
'limit' => V::T_INT,
'order' => V::T_STRING,
'direction' => V::T_STRING,
'before' => V::T_DATE, // Unix timestamp
'after' => V::T_DATE, // Unix timestamp
'before_entry_id' => V::T_INT,
'after_entry_id' => V::T_INT,
'starred' => V::T_MIXED, // the presence of the starred key is the only thing considered by Miniflux
2021-01-30 18:38:02 +00:00
'search' => V::T_STRING,
'category_id' => V::T_INT,
];
2020-12-02 23:00:27 +00:00
protected const VALID_JSON = [
2021-01-20 04:17:03 +00:00
// user properties which map directly to Arsse user metadata are listed separately;
2021-02-09 00:14:11 +00:00
// not all these properties are used by our implementation, but they are treated
2021-01-20 04:17:03 +00:00
// with the same strictness as in Miniflux to ease cross-compatibility
'url' => "string",
'username' => "string",
'password' => "string",
'user_agent' => "string",
'title' => "string",
'feed_url' => "string",
'category_id' => "integer",
'crawler' => "boolean",
'user_agent' => "string",
'scraper_rules' => "string",
'rewrite_rules' => "string",
'keeplist_rules' => "string",
'blocklist_rules' => "string",
'disabled' => "boolean",
'ignore_http_cache' => "boolean",
'fetch_via_proxy' => "boolean",
2021-02-05 13:48:14 +00:00
'entry_ids' => "array", // this is a special case: it is an array of integers
'status' => "string",
2020-12-02 23:00:27 +00:00
];
2020-12-28 13:12:30 +00:00
protected const USER_META_MAP = [
// Miniflux ID // Arsse ID Default value
'is_admin' => ["admin", false],
'theme' => ["theme", "light_serif"],
'language' => ["lang", "en_US"],
'timezone' => ["tz", "UTC"],
'entry_sorting_direction' => ["sort_asc", false],
'entries_per_page' => ["page_size", 100],
'keyboard_shortcuts' => ["shortcuts", true],
'show_reading_time' => ["reading_time", true],
'entry_swipe' => ["swipe", true],
'stylesheet' => ["stylesheet", ""],
2020-12-28 13:12:30 +00:00
];
2021-01-25 01:28:00 +00:00
/** A map between Miniflux's input properties and our input properties when modifiying feeds
2021-02-09 00:14:11 +00:00
*
2021-01-25 01:28:00 +00:00
* Miniflux also allows changing the following properties:
*
* - feed_url
* - username
* - password
* - user_agent
* - scraper_rules
* - rewrite_rules
* - disabled
* - ignore_http_cache
* - fetch_via_proxy
*
* These either do not apply because we have no cache or proxy,
* or cannot be changed because feeds are deduplicated and changing
* how they are fetched is not practical with our implementation.
* The properties are still checked for type and syntactic validity
2021-02-09 00:14:11 +00:00
* where practical, on the assumption Miniflux would also reject
2021-01-25 01:28:00 +00:00
* invalid values.
*/
protected const FEED_META_MAP = [
'title' => "title",
'category_id' => "folder",
'crawler' => "scrape",
'keeplist_rules' => "keep_rule",
'blocklist_rules' => "block_rule",
];
protected const ARTICLE_COLUMNS = [
2021-02-09 00:14:11 +00:00
"id", "url", "title", "subscription",
2021-02-03 18:06:36 +00:00
"author", "fingerprint",
2021-02-09 00:14:11 +00:00
"published_date", "modified_date",
2021-02-03 18:06:36 +00:00
"starred", "unread", "hidden",
2021-02-09 00:14:11 +00:00
"content", "media_url", "media_type",
];
protected const CALLS = [ // handler method Admin Path Body Query Required fields
2020-12-14 17:41:09 +00:00
'/categories' => [
'GET' => ["getCategories", false, false, false, false, []],
'POST' => ["createCategory", false, false, true, false, ["title"]],
2020-12-14 17:41:09 +00:00
],
'/categories/1' => [
'PUT' => ["updateCategory", false, true, true, false, ["title"]], // title is effectively required since no other field can be changed
'DELETE' => ["deleteCategory", false, true, false, false, []],
2020-12-14 17:41:09 +00:00
],
'/categories/1/entries' => [
'GET' => ["getCategoryEntries", false, true, false, true, []],
],
'/categories/1/entries/1' => [
'GET' => ["getCategoryEntry", false, true, false, false, []],
],
'/categories/1/feeds' => [
'GET' => ["getCategoryFeeds", false, true, false, false, []],
],
2020-12-14 17:41:09 +00:00
'/categories/1/mark-all-as-read' => [
2021-02-07 04:55:40 +00:00
'PUT' => ["markCategory", false, true, false, false, []],
2020-12-14 17:41:09 +00:00
],
'/discover' => [
'POST' => ["discoverSubscriptions", false, false, true, false, ["url"]],
2020-12-14 17:41:09 +00:00
],
'/entries' => [
'GET' => ["getEntries", false, false, false, true, []],
2021-02-05 13:48:14 +00:00
'PUT' => ["updateEntries", false, false, true, false, ["entry_ids", "status"]],
2020-12-14 17:41:09 +00:00
],
'/entries/1' => [
'GET' => ["getEntry", false, true, false, false, []],
2020-12-14 17:41:09 +00:00
],
'/entries/1/bookmark' => [
'PUT' => ["toggleEntryBookmark", false, true, false, false, []],
2020-12-14 17:41:09 +00:00
],
'/export' => [
'GET' => ["opmlExport", false, false, false, false, []],
2020-12-14 17:41:09 +00:00
],
'/feeds' => [
'GET' => ["getFeeds", false, false, false, false, []],
2021-01-20 04:17:03 +00:00
'POST' => ["createFeed", false, false, true, false, ["feed_url", "category_id"]],
2020-12-14 17:41:09 +00:00
],
'/feeds/1' => [
'GET' => ["getFeed", false, true, false, false, []],
'PUT' => ["updateFeed", false, true, true, false, []],
'DELETE' => ["deleteFeed", false, true, false, false, []],
2020-12-14 17:41:09 +00:00
],
'/feeds/1/entries' => [
'GET' => ["getFeedEntries", false, true, false, true, []],
2020-12-14 17:41:09 +00:00
],
'/feeds/1/entries/1' => [
'GET' => ["getFeedEntry", false, true, false, false, []],
2020-12-14 17:41:09 +00:00
],
'/feeds/1/icon' => [
'GET' => ["getFeedIcon", false, true, false, false, []],
2020-12-14 17:41:09 +00:00
],
'/feeds/1/mark-all-as-read' => [
'PUT' => ["markFeed", false, true, false, false, []],
2020-12-14 17:41:09 +00:00
],
'/feeds/1/refresh' => [
'PUT' => ["refreshFeed", false, true, false, false, []],
2020-12-14 17:41:09 +00:00
],
'/feeds/refresh' => [
'PUT' => ["refreshAllFeeds", false, false, false, false, []],
2020-12-14 17:41:09 +00:00
],
'/import' => [
'POST' => ["opmlImport", false, false, true, false, []],
2020-12-14 17:41:09 +00:00
],
'/me' => [
'GET' => ["getCurrentUser", false, false, false, false, []],
2020-12-14 17:41:09 +00:00
],
'/users' => [
'GET' => ["getUsers", true, false, false, false, []],
'POST' => ["createUser", true, false, true, false, ["username", "password"]],
2020-12-14 17:41:09 +00:00
],
'/users/1' => [
'GET' => ["getUserByNum", true, true, false, false, []],
'PUT' => ["updateUserByNum", false, true, true, false, []], // requires admin for users other than self
'DELETE' => ["deleteUserByNum", true, true, false, false, []],
2020-12-14 17:41:09 +00:00
],
'/users/1/mark-all-as-read' => [
'PUT' => ["markUserByNum", false, true, false, false, []],
2020-12-14 17:41:09 +00:00
],
'/users/*' => [
'GET' => ["getUserById", true, true, false, false, []],
2020-12-14 17:41:09 +00:00
],
2020-11-01 01:26:11 +00:00
];
public function __construct() {
}
public static function respError($data, int $status = 400, array $headers = []): ResponseInterface {
assert(isset(Arsse::$lang) && Arsse::$lang instanceof \JKingWeb\Arsse\Lang, new \Exception("Language database must be initialized before use"));
$data = (array) $data;
$msg = array_shift($data);
$data = ["error_message" => Arsse::$lang->msg("API.Miniflux.Error.".$msg, $data)];
return HTTP::respJson($data, $status, $headers);
}
2020-11-01 01:26:11 +00:00
2020-11-23 14:31:50 +00:00
protected function authenticate(ServerRequestInterface $req): bool {
// first check any tokens; this is what Miniflux does
if ($req->hasHeader("X-Auth-Token")) {
$t = $req->getHeader("X-Auth-Token")[0]; // consider only the first token
if (strlen($t)) { // and only if it is not blank
2020-11-23 14:31:50 +00:00
try {
$d = Arsse::$db->tokenLookup("miniflux.login", $t);
} catch (ExceptionInput $e) {
return false;
}
Arsse::$user->id = $d['user'];
2020-11-23 14:31:50 +00:00
return true;
}
}
2020-12-22 21:13:12 +00:00
// next check HTTP auth
2020-11-01 01:26:11 +00:00
if ($req->getAttribute("authenticated", false)) {
Arsse::$user->id = $req->getAttribute("authenticatedUser");
return true;
2020-11-23 14:31:50 +00:00
}
return false;
}
public function dispatch(ServerRequestInterface $req): ResponseInterface {
2020-11-01 01:26:11 +00:00
// get the request path only; this is assumed to already be normalized
$target = parse_url($req->getRequestTarget(), \PHP_URL_PATH) ?? "";
2020-11-02 00:09:17 +00:00
$method = $req->getMethod();
2020-11-01 01:26:11 +00:00
// handle HTTP OPTIONS requests
2020-11-02 00:09:17 +00:00
if ($method === "OPTIONS") {
2020-11-01 01:26:11 +00:00
return $this->handleHTTPOptions($target);
}
// try to authenticate
if (!$this->authenticate($req)) {
return self::respError("401", 401);
}
2020-11-02 00:09:17 +00:00
$func = $this->chooseCall($target, $method);
if ($func instanceof ResponseInterface) {
return $func;
2020-12-14 17:41:09 +00:00
} else {
[$func, $reqAdmin, $reqPath, $reqBody, $reqQuery, $reqFields] = $func;
}
2020-12-14 17:41:09 +00:00
if ($reqAdmin && !$this->isAdmin()) {
return self::respError("403", 403);
2020-12-08 20:34:31 +00:00
}
2020-12-14 17:41:09 +00:00
$args = [];
if ($reqPath) {
$args[] = explode("/", ltrim($target, "/"));
}
if ($reqBody) {
if ($func === "opmlImport") {
2021-02-07 18:04:44 +00:00
$data = (string) $req->getBody();
2020-12-14 17:41:09 +00:00
} else {
$data = (string) $req->getBody();
if (strlen($data)) {
$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 self::respError(["InvalidBodyJSON", json_last_error_msg()], 400);
2020-12-14 17:41:09 +00:00
}
} else {
$data = [];
}
$data = $this->normalizeBody((array) $data, $reqFields);
2020-12-14 17:41:09 +00:00
if ($data instanceof ResponseInterface) {
return $data;
}
2020-12-02 23:00:27 +00:00
}
2020-12-14 17:41:09 +00:00
$args[] = $data;
}
if ($reqQuery) {
$query = $this->normalizeQuery(parse_url($req->getRequestTarget(), \PHP_URL_QUERY) ?? "");
if ($query instanceof ResponseInterface) {
return $query;
}
$args[] = $query;
2020-11-02 00:09:17 +00:00
}
try {
2020-12-14 17:41:09 +00:00
return $this->$func(...$args);
2021-02-09 00:14:11 +00:00
// @codeCoverageIgnoreStart
2020-11-02 00:09:17 +00:00
} catch (Exception $e) {
// if there was a REST exception return 400
return HTTP::respEmpty(400);
2020-11-02 00:09:17 +00:00
} catch (AbstractException $e) {
// if there was any other Arsse exception return 500
return HTTP::respEmpty(500);
2020-11-02 00:09:17 +00:00
}
// @codeCoverageIgnoreEnd
2020-11-01 01:26:11 +00:00
}
2020-12-14 17:41:09 +00:00
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(self::CALLS[$url])) {
// if the path is supported, make sure the method is allowed
if (isset(self::CALLS[$url][$method])) {
// if it is allowed, return the object method to run, assuming the method exists
assert(method_exists($this, self::CALLS[$url][$method][0]), new \Exception("Method is not implemented"));
return self::CALLS[$url][$method];
} else {
// otherwise return 405
return HTTP::respEmpty(405, ['Allow' => implode(", ", array_keys(self::CALLS[$url]))]);
2020-12-14 17:41:09 +00:00
}
} else {
// if the path is not supported, return 404
return HTTP::respEmpty(404);
2020-12-14 17:41:09 +00:00
}
}
2020-11-01 01:26:11 +00:00
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++) {
2020-12-02 23:00:27 +00:00
if (V::id($path[$a])) {
2020-11-01 01:26:11 +00:00
$path[$a] = "1";
}
}
2020-11-02 00:09:17 +00:00
// handle special case "Get User By User Name", which can have any non-numeric string, non-empty as the last component
if (sizeof($path) === 3 && $path[0] === "" && $path[1] === "users" && !preg_match("/^(?:\d+)?$/D", $path[2])) {
2020-11-02 00:09:17 +00:00
$path[2] = "*";
}
2020-11-01 01:26:11 +00:00
return implode("/", $path);
}
protected function normalizeBody(array $body, array $req) {
2020-12-14 17:41:09 +00:00
// Miniflux does not attempt to coerce values into different types
foreach (self::VALID_JSON as $k => $t) {
if (!isset($body[$k])) {
2023-01-29 15:59:39 +00:00
// if a valid key is missing set it to null so that any key may be accessed safely
2020-12-14 17:41:09 +00:00
$body[$k] = null;
} elseif (gettype($body[$k]) !== $t) {
return self::respError(["InvalidInputType", 'field' => $k, 'expected' => $t, 'actual' => gettype($body[$k])], 422);
2021-01-20 23:28:51 +00:00
} elseif (
(in_array($k, ["keeplist_rules", "blocklist_rules"]) && !Rule::validate($body[$k]))
2021-02-09 00:14:11 +00:00
|| (in_array($k, ["url", "feed_url"]) && !URL::absolute($body[$k]))
|| ($k === "category_id" && $body[$k] < 1)
2021-02-05 13:48:14 +00:00
|| ($k === "status" && !in_array($body[$k], ["read", "unread", "removed"]))
2021-01-20 23:28:51 +00:00
) {
return self::respError(["InvalidInputValue", 'field' => $k], 422);
2021-02-05 13:48:14 +00:00
} elseif ($k === "entry_ids") {
foreach ($body[$k] as $v) {
if (gettype($v) !== "integer") {
return self::respError(["InvalidInputType", 'field' => $k, 'expected' => "integer", 'actual' => gettype($v)], 422);
2021-02-05 13:48:14 +00:00
} elseif ($v < 1) {
return self::respError(["InvalidInputValue", 'field' => $k], 422);
2021-02-05 13:48:14 +00:00
}
}
2020-12-28 13:12:30 +00:00
}
}
//normalize user-specific input
2021-01-08 20:47:19 +00:00
foreach (self::USER_META_MAP as $k => [,$d]) {
2020-12-28 13:12:30 +00:00
$t = gettype($d);
if (!isset($body[$k])) {
$body[$k] = null;
2020-12-30 22:01:17 +00:00
} elseif ($k === "entry_sorting_direction") {
if (!in_array($body[$k], ["asc", "desc"])) {
return self::respError(["InvalidInputValue", 'field' => $k], 422);
2020-12-30 22:01:17 +00:00
}
2020-12-28 13:12:30 +00:00
} elseif (gettype($body[$k]) !== $t) {
return self::respError(["InvalidInputType", 'field' => $k, 'expected' => $t, 'actual' => gettype($body[$k])], 422);
2020-12-14 17:41:09 +00:00
}
}
// check for any missing required values
foreach ($req as $k) {
2021-02-05 13:48:14 +00:00
if (!isset($body[$k]) || (is_array($body[$k]) && !$body[$k])) {
return self::respError(["MissingInputValue", 'field' => $k], 422);
}
}
2020-12-14 17:41:09 +00:00
return $body;
}
protected function normalizeQuery(string $query) {
2021-01-30 18:38:02 +00:00
// fill an array with all valid keys
$out = [];
$seen = [];
2021-01-30 18:38:02 +00:00
foreach (self::VALID_QUERY as $k => $t) {
$out[$k] = ($t >= V::M_ARRAY) ? [] : null;
$seen[$k] = false;
2021-01-30 18:38:02 +00:00
}
// split the query string and normalize the values to their correct types
foreach (explode("&", $query) as $parts) {
$parts = explode("=", $parts, 2);
$k = rawurldecode($parts[0]);
$v = (isset($parts[1])) ? rawurldecode($parts[1]) : "";
if (!isset(self::VALID_QUERY[$k])) {
// ignore unknown keys
2021-01-30 18:38:02 +00:00
continue;
}
$t = self::VALID_QUERY[$k] & ~V::M_ARRAY;
$a = self::VALID_QUERY[$k] >= V::M_ARRAY;
try {
if ($seen[$k] && !$a) {
// if the key has already been seen and it's not an array field, bail
// NOTE: Miniflux itself simply ignores duplicates entirely
return self::respError(["DuplicateInputValue", 'field' => $k], 400);
}
$seen[$k] = true;
if ($k === "starred") {
// the starred key is a special case in that Miniflux only considers the presence of the key
$out[$k] = true;
continue;
} elseif ($v === "") {
// if the value is empty we can discard the value, but subsequent values for the same non-array key are still considered duplicates
continue;
} elseif ($a) {
$out[$k][] = V::normalize($v, $t + V::M_STRICT, "unix");
} else {
$out[$k] = V::normalize($v, $t + V::M_STRICT, "unix");
}
} catch (ExceptionType $e) {
return self::respError(["InvalidInputValue", 'field' => $k], 400);
}
// perform additional validation
if (
(in_array($k, ["category_id", "before_entry_id", "after_entry_id"]) && $v < 1)
|| (in_array($k, ["limit", "offset"]) && $v < 0)
|| ($k === "direction" && !in_array($v, ["asc", "desc"]))
|| ($k === "order" && !in_array($v, ["id", "status", "published_at", "category_title", "category_id"]))
|| ($k === "status" && !in_array($v, ["read", "unread", "removed"]))
) {
return self::respError(["InvalidInputValue", 'field' => $k], 400);
2021-01-30 18:38:02 +00:00
}
}
return $out;
}
2020-11-01 01:26:11 +00:00
protected function handleHTTPOptions(string $url): ResponseInterface {
// normalize the URL path: change any IDs to 1 for easier comparison
$url = $this->normalizePathIDs($url);
2020-12-14 17:41:09 +00:00
if (isset(self::CALLS[$url])) {
2020-11-01 01:26:11 +00:00
// if the path is supported, respond with the allowed methods and other metadata
2020-12-14 17:41:09 +00:00
$allowed = array_keys(self::CALLS[$url]);
2020-11-01 01:26:11 +00:00
// if GET is allowed, so is HEAD
if (in_array("GET", $allowed)) {
array_unshift($allowed, "HEAD");
}
return HTTP::respEmpty(204, [
2020-12-01 17:08:45 +00:00
'Allow' => implode(", ", $allowed),
2020-11-02 00:09:17 +00:00
'Accept' => implode(", ", $url === "/import" ? self::ACCEPTED_TYPES_OPML : self::ACCEPTED_TYPES_JSON),
2020-11-01 01:26:11 +00:00
]);
} else {
// if the path is not supported, return 404
return HTTP::respEmpty(404);
2020-11-01 01:26:11 +00:00
}
}
2020-12-08 20:34:31 +00:00
protected function listUsers(array $users, bool $reportMissing): array {
$out = [];
2020-12-10 04:39:29 +00:00
$now = Date::transform($this->now(), "iso8601m");
2020-12-08 20:34:31 +00:00
foreach ($users as $u) {
try {
$info = Arsse::$user->propertiesGet($u, true);
} catch (UserException $e) {
if ($reportMissing) {
throw $e;
} else {
continue;
}
}
2020-12-28 13:12:30 +00:00
$entry = [
2020-12-08 20:34:31 +00:00
'id' => $info['num'],
'username' => $u,
'last_login_at' => $now,
'google_id' => "",
'openid_connect_id' => "",
2020-12-08 20:34:31 +00:00
];
foreach (self::USER_META_MAP as $ext => [$int, $default]) {
$entry[$ext] = $info[$int] ?? $default;
2020-12-28 13:12:30 +00:00
}
$entry['entry_sorting_direction'] = ($entry['entry_sorting_direction']) ? "asc" : "desc";
$out[] = $entry;
2020-12-08 20:34:31 +00:00
}
return $out;
}
2020-12-31 18:57:36 +00:00
protected function editUser(string $user, array $data): array {
// map Miniflux properties to internal metadata properties
$in = [];
2021-02-09 00:14:11 +00:00
foreach (self::USER_META_MAP as $i => [$o]) {
2020-12-31 18:57:36 +00:00
if (isset($data[$i])) {
if ($i === "entry_sorting_direction") {
$in[$o] = $data[$i] === "asc";
} else {
$in[$o] = $data[$i];
}
}
}
// make any requested changes
$tr = Arsse::$user->begin();
if ($in) {
Arsse::$user->propertiesSet($user, $in);
}
// read out the newly-modified user and commit the changes
$out = $this->listUsers([$user], true)[0];
$tr->commit();
// add the input password if a password change was requested
if (isset($data['password'])) {
$out['password'] = $data['password'];
}
return $out;
}
2020-12-14 17:41:09 +00:00
protected function discoverSubscriptions(array $data): ResponseInterface {
2020-12-02 23:00:27 +00:00
try {
$list = Feed::discoverAll((string) $data['url'], (string) $data['username'], (string) $data['password']);
} catch (FeedException $e) {
$msg = [
2020-12-11 18:31:35 +00:00
10502 => "Fetch404",
10506 => "Fetch403",
10507 => "Fetch401",
2021-01-23 17:00:11 +00:00
10521 => "Fetch404",
2020-12-11 18:31:35 +00:00
][$e->getCode()] ?? "FetchOther";
return self::respError($msg, 502);
2020-12-02 23:00:27 +00:00
}
$out = [];
2020-12-22 21:13:12 +00:00
foreach ($list as $url) {
2020-12-02 23:00:27 +00:00
// TODO: This needs to be refined once PicoFeed is replaced
$out[] = ['title' => "Feed", 'type' => "rss", 'url' => $url];
}
return HTTP::respJson($out);
2020-12-02 23:00:27 +00:00
}
2020-12-14 17:41:09 +00:00
protected function getUsers(): ResponseInterface {
2020-12-28 13:12:30 +00:00
$tr = Arsse::$user->begin();
return HTTP::respJson($this->listUsers(Arsse::$user->list(), false));
2020-12-08 20:34:31 +00:00
}
2020-12-14 17:41:09 +00:00
protected function getUserById(array $path): ResponseInterface {
2020-12-08 20:34:31 +00:00
try {
return HTTP::respJson($this->listUsers([$path[1]], true)[0] ?? new \stdClass);
2020-12-08 20:34:31 +00:00
} catch (UserException $e) {
return self::respError("404", 404);
2020-12-08 20:34:31 +00:00
}
}
2020-12-14 17:41:09 +00:00
protected function getUserByNum(array $path): ResponseInterface {
try {
$user = Arsse::$user->lookup((int) $path[1]);
return HTTP::respJson($this->listUsers([$user], true)[0] ?? new \stdClass);
} catch (UserException $e) {
return self::respError("404", 404);
}
2020-12-08 20:34:31 +00:00
}
2020-12-22 21:13:12 +00:00
2020-12-14 17:41:09 +00:00
protected function getCurrentUser(): ResponseInterface {
return HTTP::respJson($this->listUsers([Arsse::$user->id], false)[0] ?? new \stdClass);
2020-12-08 20:34:31 +00:00
}
2020-12-31 18:57:36 +00:00
protected function createUser(array $data): ResponseInterface {
try {
$tr = Arsse::$user->begin();
$data['password'] = Arsse::$user->add($data['username'], $data['password']);
$out = $this->editUser($data['username'], $data);
$tr->commit();
} catch (UserException $e) {
switch ($e->getCode()) {
case 10403:
return self::respError(["DuplicateUser", 'user' => $data['username']], 409);
2020-12-31 18:57:36 +00:00
case 10441:
return self::respError(["InvalidInputValue", 'field' => "timezone"], 422);
2020-12-31 18:57:36 +00:00
case 10443:
return self::respError(["InvalidInputValue", 'field' => "entries_per_page"], 422);
2020-12-31 18:57:36 +00:00
case 10444:
return self::respError(["InvalidInputValue", 'field' => "username"], 422);
2020-12-31 18:57:36 +00:00
}
throw $e; // @codeCoverageIgnore
}
return HTTP::respJson($out, 201);
2020-12-31 18:57:36 +00:00
}
2020-12-30 22:01:17 +00:00
protected function updateUserByNum(array $path, array $data): ResponseInterface {
// this function is restricted to admins unless the affected user and calling user are the same
$user = Arsse::$user->propertiesGet(Arsse::$user->id, false);
if (((int) $path[1]) === $user['num']) {
if ($data['is_admin'] && !$user['admin']) {
// non-admins should not be able to set themselves as admin
return self::respError("InvalidElevation", 403);
2020-12-30 22:01:17 +00:00
}
$user = Arsse::$user->id;
} elseif (!$user['admin']) {
return self::respError("403", 403);
2020-12-30 22:01:17 +00:00
} else {
try {
$user = Arsse::$user->lookup((int) $path[1]);
} catch (ExceptionConflict $e) {
return self::respError("404", 404);
2020-12-28 13:12:30 +00:00
}
}
// make any requested changes
try {
$tr = Arsse::$user->begin();
if (isset($data['username'])) {
Arsse::$user->rename($user, $data['username']);
$user = $data['username'];
}
if (isset($data['password'])) {
Arsse::$user->passwordSet($user, $data['password']);
}
2020-12-31 18:57:36 +00:00
$out = $this->editUser($user, $data);
2020-12-28 13:12:30 +00:00
$tr->commit();
} catch (UserException $e) {
switch ($e->getCode()) {
case 10403:
return self::respError(["DuplicateUser", 'user' => $data['username']], 409);
2020-12-30 22:01:17 +00:00
case 10441:
return self::respError(["InvalidInputValue", 'field' => "timezone"], 422);
2020-12-28 13:12:30 +00:00
case 10443:
return self::respError(["InvalidInputValue", 'field' => "entries_per_page"], 422);
2020-12-28 13:12:30 +00:00
case 10444:
return self::respError(["InvalidInputValue", 'field' => "username"], 422);
2020-12-28 13:12:30 +00:00
}
throw $e; // @codeCoverageIgnore
}
return HTTP::respJson($out, 201);
2020-12-28 13:12:30 +00:00
}
2020-12-31 22:03:08 +00:00
protected function deleteUserByNum(array $path): ResponseInterface {
try {
Arsse::$user->remove(Arsse::$user->lookup((int) $path[1]));
} catch (ExceptionConflict $e) {
return self::respError("404", 404);
2020-12-31 22:03:08 +00:00
}
return HTTP::respEmpty(204);
2020-12-31 22:03:08 +00:00
}
/** Returns a useful subset of user metadata
2021-02-09 00:14:11 +00:00
*
* The following keys are included:
2021-02-09 00:14:11 +00:00
*
* - "num": The user's numeric ID,
* - "root": The effective name of the root folder
* - "tz": The time zone preference of the user, or UTC if not set
*/
protected function userMeta(string $user): array {
$meta = Arsse::$user->propertiesGet($user, false);
return [
'num' => $meta['num'],
'root' => $meta['root_folder_name'] ?? Arsse::$lang->msg("API.Miniflux.DefaultCategoryName"),
'tz' => new \DateTimeZone($meta['tz'] ?? "UTC"),
];
}
protected function getCategories(): ResponseInterface {
$out = [];
2020-12-11 18:31:35 +00:00
// add the root folder as a category
$meta = $this->userMeta(Arsse::$user->id);
$out[] = ['id' => 1, 'title' => $meta['root'], 'user_id' => $meta['num']];
2020-12-11 18:31:35 +00:00
// add other top folders as categories
foreach (Arsse::$db->folderList(Arsse::$user->id, null, false) as $f) {
// always add 1 to the ID since the root folder will always be 1 instead of 0.
$out[] = ['id' => $f['id'] + 1, 'title' => $f['name'], 'user_id' => $meta['num']];
2020-12-11 18:31:35 +00:00
}
return HTTP::respJson($out);
2020-12-11 18:31:35 +00:00
}
2020-12-14 17:41:09 +00:00
protected function createCategory(array $data): ResponseInterface {
2020-12-12 04:47:13 +00:00
try {
$id = Arsse::$db->folderAdd(Arsse::$user->id, ['name' => (string) $data['title']]);
} catch (ExceptionInput $e) {
if ($e->getCode() === 10236) {
return self::respError(["DuplicateCategory", 'title' => $data['title']], 409);
2020-12-12 04:47:13 +00:00
} else {
return self::respError(["InvalidCategory", 'title' => $data['title']], 422);
2020-12-12 04:47:13 +00:00
}
}
$meta = Arsse::$user->propertiesGet(Arsse::$user->id, false);
return HTTP::respJson(['id' => $id + 1, 'title' => $data['title'], 'user_id' => $meta['num']], 201);
2020-12-12 04:47:13 +00:00
}
2020-12-14 17:41:09 +00:00
protected function updateCategory(array $path, array $data): ResponseInterface {
2020-12-13 17:56:57 +00:00
// category IDs in Miniflux are always greater than 1; we have folder 0, so we decrement category IDs by 1 to get the folder ID
2020-12-12 04:47:13 +00:00
$folder = $path[1] - 1;
$title = $data['title'] ?? "";
try {
if ($folder === 0) {
2020-12-13 17:56:57 +00:00
// folder 0 doesn't actually exist in the database, so its name is kept as user metadata
2020-12-12 04:47:13 +00:00
if (!strlen(trim($title))) {
throw new ExceptionInput("whitespace", ['field' => "title", 'action' => __FUNCTION__]);
2020-12-12 04:47:13 +00:00
}
$title = Arsse::$user->propertiesSet(Arsse::$user->id, ['root_folder_name' => $title])['root_folder_name'];
} else {
Arsse::$db->folderPropertiesSet(Arsse::$user->id, $folder, ['name' => $title]);
}
} catch (ExceptionInput $e) {
if ($e->getCode() === 10236) {
return self::respError(["DuplicateCategory", 'title' => $title], 409);
2020-12-14 03:10:34 +00:00
} elseif (in_array($e->getCode(), [10237, 10239])) {
return self::respError("404", 404);
2020-12-12 04:47:13 +00:00
} else {
return self::respError(["InvalidCategory", 'title' => $title], 422);
2020-12-12 04:47:13 +00:00
}
}
$meta = Arsse::$user->propertiesGet(Arsse::$user->id, false);
return HTTP::respJson(['id' => (int) $path[1], 'title' => $title, 'user_id' => $meta['num']], 201);
2020-12-12 04:47:13 +00:00
}
2020-12-14 17:41:09 +00:00
protected function deleteCategory(array $path): ResponseInterface {
2020-12-14 03:10:34 +00:00
try {
$folder = $path[1] - 1;
if ($folder !== 0) {
Arsse::$db->folderRemove(Arsse::$user->id, $folder);
} else {
// if we're deleting from the root folder, delete each child subscription individually
2020-12-22 21:13:12 +00:00
// otherwise we'd be deleting the entire tree
2020-12-14 03:10:34 +00:00
$tr = Arsse::$db->begin();
foreach (Arsse::$db->subscriptionList(Arsse::$user->id, null, false) as $sub) {
2021-03-02 16:27:48 +00:00
Arsse::$db->subscriptionRemove(Arsse::$user->id, (int) $sub['id']);
2020-12-14 03:10:34 +00:00
}
$tr->commit();
}
} catch (ExceptionInput $e) {
return self::respError("404", 404);
2020-12-14 03:10:34 +00:00
}
return HTTP::respEmpty(204);
2020-12-14 03:10:34 +00:00
}
2021-02-03 21:27:55 +00:00
protected function transformFeed(array $sub, int $uid, string $rootName, \DateTimeZone $tz): array {
2021-01-17 18:02:31 +00:00
$url = new Uri($sub['url']);
return [
'id' => (int) $sub['id'],
'user_id' => $uid,
2021-01-17 18:02:31 +00:00
'feed_url' => (string) $url->withUserInfo(""),
'site_url' => (string) $sub['source'],
'title' => (string) $sub['title'],
2021-02-03 21:27:55 +00:00
'checked_at' => Date::normalize($sub['updated'], "sql")->setTimezone($tz)->format(self::DATE_FORMAT_MICRO),
'next_check_at' => $sub['next_fetch'] ? Date::normalize($sub['next_fetch'], "sql")->setTimezone($tz)->format(self::DATE_FORMAT_MICRO) : "0001-01-01T00:00:00Z",
2021-01-17 18:02:31 +00:00
'etag_header' => (string) $sub['etag'],
'last_modified_header' => (string) Date::transform($sub['edited'], "http", "sql"),
'parsing_error_message' => (string) $sub['err_msg'],
'parsing_error_count' => (int) $sub['err_count'],
'scraper_rules' => "",
'rewrite_rules' => "",
'crawler' => (bool) $sub['scrape'],
'blocklist_rules' => (string) $sub['block_rule'],
'keeplist_rules' => (string) $sub['keep_rule'],
'user_agent' => "",
'username' => rawurldecode(explode(":", $url->getUserInfo(), 2)[0] ?? ""),
'password' => rawurldecode(explode(":", $url->getUserInfo(), 2)[1] ?? ""),
'disabled' => false,
'ignore_http_cache' => false,
'fetch_via_proxy' => false,
'category' => [
'id' => (int) $sub['top_folder'] + 1,
'title' => $sub['top_folder_name'] ?? $rootName,
'user_id' => $uid,
],
2021-01-17 18:02:31 +00:00
'icon' => $sub['icon_id'] ? ['feed_id' => (int) $sub['id'], 'icon_id' => (int) $sub['icon_id']] : null,
];
}
protected function getFeeds(): ResponseInterface {
$out = [];
$tr = Arsse::$db->begin();
$meta = $this->userMeta(Arsse::$user->id);
2021-01-17 03:52:07 +00:00
foreach (Arsse::$db->subscriptionList(Arsse::$user->id) as $r) {
2021-02-03 21:27:55 +00:00
$out[] = $this->transformFeed($r, $meta['num'], $meta['root'], $meta['tz']);
2021-01-17 03:52:07 +00:00
}
return HTTP::respJson($out);
2021-01-17 03:52:07 +00:00
}
protected function getCategoryFeeds(array $path): ResponseInterface {
// transform the category number into a folder number by subtracting one
$folder = ((int) $path[1]) - 1;
// unless the folder is root, list recursive
$recursive = $folder > 0;
$out = [];
$tr = Arsse::$db->begin();
// get the list of subscriptions, or bail
try {
$meta = $this->userMeta(Arsse::$user->id);
foreach (Arsse::$db->subscriptionList(Arsse::$user->id, $folder, $recursive) as $r) {
2021-02-03 21:27:55 +00:00
$out[] = $this->transformFeed($r, $meta['num'], $meta['root'], $meta['tz']);
}
} catch (ExceptionInput $e) {
// the folder does not exist
return self::respError("404", 404);
}
return HTTP::respJson($out);
}
2021-01-24 18:54:54 +00:00
protected function getFeed(array $path): ResponseInterface {
$tr = Arsse::$db->begin();
$meta = $this->userMeta(Arsse::$user->id);
2021-01-24 18:54:54 +00:00
try {
$sub = Arsse::$db->subscriptionPropertiesGet(Arsse::$user->id, (int) $path[1]);
return HTTP::respJson($this->transformFeed($sub, $meta['num'], $meta['root'], $meta['tz']));
2021-01-24 18:54:54 +00:00
} catch (ExceptionInput $e) {
return self::respError("404", 404);
2021-01-24 18:54:54 +00:00
}
}
2021-01-20 04:17:03 +00:00
protected function createFeed(array $data): ResponseInterface {
$properties = [
'folder' => $data['category_id'] - 1,
'scrape' => (bool) $data['crawler'],
'keep_rule' => $data['keeplist_rules'],
'block_rule' => $data['blocklist_rules'],
];
2021-01-20 04:17:03 +00:00
try {
$id = Arsse::$db->subscriptionAdd(Arsse::$user->id, $data['feed_url'], (string) $data['username'], (string) $data['password'], false, $properties);
2021-01-20 04:17:03 +00:00
} catch (FeedException $e) {
$msg = [
10502 => "Fetch404",
10506 => "Fetch403",
10507 => "Fetch401",
2021-01-23 17:00:11 +00:00
10521 => "Fetch404",
10522 => "FetchFormat",
2021-01-20 04:17:03 +00:00
][$e->getCode()] ?? "FetchOther";
return self::respError($msg, 502);
2021-01-20 04:17:03 +00:00
} catch (ExceptionInput $e) {
switch ($e->getCode()) {
case 10235:
return self::respError("MissingCategory", 422);
2021-01-20 04:17:03 +00:00
case 10236:
return self::respError("DuplicateFeed", 409);
2021-01-20 04:17:03 +00:00
}
}
return HTTP::respJson(['feed_id' => $id], 201);
2021-01-20 04:17:03 +00:00
}
2021-01-25 01:28:00 +00:00
protected function updateFeed(array $path, array $data): ResponseInterface {
$in = [];
foreach (self::FEED_META_MAP as $from => $to) {
if (isset($data[$from])) {
$in[$to] = $data[$from];
}
}
if (isset($in['folder'])) {
$in['folder'] -= 1;
}
try {
Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, (int) $path[1], $in);
} catch (ExceptionInput $e) {
switch ($e->getCode()) {
case 10231:
case 10232:
return self::respError("InvalidTitle", 422);
2021-01-25 01:28:00 +00:00
case 10235:
return self::respError("MissingCategory", 422);
2021-01-25 01:28:00 +00:00
case 10239:
return self::respError("404", 404);
2021-01-25 01:28:00 +00:00
}
}
return $this->getFeed($path)->withStatus(201);
2021-01-25 02:12:32 +00:00
}
protected function deleteFeed(array $path): ResponseInterface {
try {
Arsse::$db->subscriptionRemove(Arsse::$user->id, (int) $path[1]);
return HTTP::respEmpty(204);
2021-01-25 02:12:32 +00:00
} catch (ExceptionInput $e) {
return self::respError("404", 404);
2021-01-25 02:12:32 +00:00
}
2021-01-25 01:28:00 +00:00
}
2021-01-25 02:53:45 +00:00
protected function getFeedIcon(array $path): ResponseInterface {
try {
$icon = Arsse::$db->subscriptionIcon(Arsse::$user->id, (int) $path[1]);
} catch (ExceptionInput $e) {
return self::respError("404", 404);
2021-01-25 02:53:45 +00:00
}
if (!$icon || !$icon['type'] || !$icon['data']) {
return self::respError("404", 404);
2021-01-25 02:53:45 +00:00
}
return HTTP::respJson([
2021-02-09 14:26:12 +00:00
'id' => (int) $icon['id'],
2021-02-09 00:14:11 +00:00
'data' => $icon['type'].";base64,".base64_encode($icon['data']),
'mime_type' => $icon['type'],
2021-01-25 02:53:45 +00:00
]);
}
protected function computeContext(array $query, Context $c): RootContext {
2021-03-06 16:26:14 +00:00
if ($query['before'] && $query['before']->getTimestamp() === 0) {
$query['before'] = null; // NOTE: This workaround is needed for compatibility with "Microflux for Miniflux", an Android Client
}
$c->limit($query['limit'] ?? self::DEFAULT_ENTRY_LIMIT) // NOTE: This does not honour user preferences
->offset($query['offset'])
->starred($query['starred'])
->modifiedRange($query['after'], $query['before']) // FIXME: This may not be the correct date field
->articleRange($query['after_entry_id'] ? $query['after_entry_id'] + 1 : null, $query['before_entry_id'] ? $query['before_entry_id'] - 1 : null) // FIXME: This might be edition
->searchTerms(strlen($query['search'] ?? "") ? preg_split("/\s+/", $query['search']) : null); // NOTE: Miniflux matches only whole words; we match simple substrings
if ($query['category_id']) {
if ($query['category_id'] === 1) {
$c->folderShallow(0);
} else {
$c->folder($query['category_id'] - 1);
}
}
2021-02-03 18:06:36 +00:00
$status = array_unique($query['status']);
sort($status);
if ($status === ["read", "removed"]) {
$c1 = $c;
$c2 = clone $c;
$c = new UnionContext($c1->unread(false), $c2->hidden(true));
} elseif ($status === ["read", "unread"]) {
$c->hidden(false);
} elseif ($status === ["read"]) {
$c->hidden(false)->unread(false);
} elseif ($status === ["removed", "unread"]) {
$c1 = $c;
$c2 = clone $c;
$c = new UnionContext($c1->unread(true), $c2->hidden(true));
} elseif ($status === ["removed"]) {
$c->hidden(true);
} elseif ($status === ["unread"]) {
$c->hidden(false)->unread(true);
}
return $c;
}
protected function computeOrder(array $query): array {
$desc = $query['direction'] === "desc" ? " desc" : "";
if ($query['order'] === "id") {
return ["id".$desc];
} elseif ($query['order'] === "status") {
if (!$desc) {
return ["hidden", "unread desc"];
} else {
return ["hidden desc", "unread"];
}
} elseif ($query['order'] === "published_at") {
return ["modified_date".$desc];
} elseif ($query['order'] === "category_title") {
return ["top_folder_name".$desc];
2021-02-04 22:07:22 +00:00
} elseif ($query['order'] === "category_id") {
return ["top_folder".$desc];
} else {
2021-02-04 22:07:22 +00:00
return [self::DEFAULT_ORDER_COL.$desc];
}
}
protected function transformEntry(array $entry, int $uid, \DateTimeZone $tz): array {
if ($entry['hidden']) {
$status = "removed";
} elseif ($entry['unread']) {
$status = "unread";
} else {
$status = "read";
}
if ($entry['media_url']) {
$enclosures = [
[
2021-02-09 14:26:12 +00:00
'id' => (int) $entry['id'], // NOTE: We don't have IDs for enclosures, but we also only have one enclosure per entry, so we can just re-use the same ID
'user_id' => $uid,
2021-02-09 14:26:12 +00:00
'entry_id' => (int) $entry['id'],
'url' => $entry['media_url'],
'mime_type' => $entry['media_type'] ?: "application/octet-stream",
'size' => 0,
2021-02-09 00:14:11 +00:00
],
];
} else {
$enclosures = null;
}
return [
'id' => (int) $entry['id'],
'user_id' => $uid,
'feed_id' => (int) $entry['subscription'],
'status' => $status,
'hash' => $entry['fingerprint'],
'title' => $entry['title'],
'url' => $entry['url'],
'comments_url' => "",
2021-02-03 21:27:55 +00:00
'published_at' => Date::normalize($entry['published_date'], "sql")->setTimezone($tz)->format(self::DATE_FORMAT_SEC),
'created_at' => Date::normalize($entry['modified_date'], "sql")->setTimezone($tz)->format(self::DATE_FORMAT_MICRO),
'content' => $entry['content'],
'author' => (string) $entry['author'],
'share_code' => "",
'starred' => (bool) $entry['starred'],
'reading_time' => 0,
'enclosures' => $enclosures,
'feed' => null,
];
}
protected function listEntries(array $query, Context $c): array {
$c = $this->computeContext($query, $c);
$order = $this->computeOrder($query);
$tr = Arsse::$db->begin();
$meta = $this->userMeta(Arsse::$user->id);
// compile the list of entries
$out = [];
foreach (Arsse::$db->articleList(Arsse::$user->id, $c, self::ARTICLE_COLUMNS, $order) as $entry) {
$out[] = $this->transformEntry($entry, $meta['num'], $meta['tz']);
}
// next compile a map of feeds to add to the entries
2021-02-03 18:06:36 +00:00
if ($out) {
$feeds = [];
foreach (Arsse::$db->subscriptionList(Arsse::$user->id) as $r) {
2021-02-03 21:27:55 +00:00
$feeds[(int) $r['id']] = $this->transformFeed($r, $meta['num'], $meta['root'], $meta['tz']);
2021-02-03 18:06:36 +00:00
}
// add the feed objects to each entry
// NOTE: If ever we implement multiple enclosure, this would be the right place to add them
for ($a = 0; $a < sizeof($out); $a++) {
$out[$a]['feed'] = $feeds[$out[$a]['feed_id']];
}
}
2021-02-04 22:07:22 +00:00
// finally compute the total number of entries match the query, where necessary
$count = sizeof($out);
if ($c->offset || ($c->limit && $count >= $c->limit)) {
2021-02-02 21:14:04 +00:00
$count = Arsse::$db->articleCount(Arsse::$user->id, (clone $c)->limit(0)->offset(0));
}
return ['total' => $count, 'entries' => $out];
}
2021-02-05 01:19:35 +00:00
protected function findEntry(int $id, Context $c = null): array {
$c = ($c ?? new Context)->article($id);
$tr = Arsse::$db->begin();
$meta = $this->userMeta(Arsse::$user->id);
// find the entry we want
$entry = Arsse::$db->articleList(Arsse::$user->id, $c, self::ARTICLE_COLUMNS)->getRow();
if (!$entry) {
throw new ExceptionInput("idMissing", ['id' => $id, 'field' => 'entry']);
2021-02-05 01:19:35 +00:00
}
$out = $this->transformEntry($entry, $meta['num'], $meta['tz']);
// next transform the parent feed of the entry
$out['feed'] = $this->transformFeed(Arsse::$db->subscriptionPropertiesGet(Arsse::$user->id, $out['feed_id']), $meta['num'], $meta['root'], $meta['tz']);
return $out;
}
2021-02-09 00:14:11 +00:00
protected function getEntries(array $query): ResponseInterface {
try {
return HTTP::respJson($this->listEntries($query, new Context));
} catch (ExceptionInput $e) {
return self::respError("MissingCategory", 400);
}
}
2021-02-09 00:14:11 +00:00
protected function getFeedEntries(array $path, array $query): ResponseInterface {
$c = (new Context)->subscription((int) $path[1]);
try {
return HTTP::respJson($this->listEntries($query, $c));
} catch (ExceptionInput $e) {
// FIXME: this should differentiate between a missing feed and a missing category, but doesn't
return self::respError("404", 404);
}
}
2021-02-09 00:14:11 +00:00
protected function getCategoryEntries(array $path, array $query): ResponseInterface {
$query['category_id'] = (int) $path[1];
try {
return HTTP::respJson($this->listEntries($query, new Context));
} catch (ExceptionInput $e) {
return self::respError("404", 404);
}
}
2021-02-09 00:14:11 +00:00
2021-02-05 01:19:35 +00:00
protected function getEntry(array $path): ResponseInterface {
try {
return HTTP::respJson($this->findEntry((int) $path[1]));
2021-02-05 01:19:35 +00:00
} catch (ExceptionInput $e) {
return self::respError("404", 404);
2021-02-05 01:19:35 +00:00
}
}
2021-02-09 00:14:11 +00:00
2021-02-05 01:19:35 +00:00
protected function getFeedEntry(array $path): ResponseInterface {
$c = (new Context)->subscription((int) $path[1]);
try {
return HTTP::respJson($this->findEntry((int) $path[3], $c));
2021-02-05 01:19:35 +00:00
} catch (ExceptionInput $e) {
return self::respError("404", 404);
2021-02-05 01:19:35 +00:00
}
}
2021-02-09 00:14:11 +00:00
2021-02-05 01:19:35 +00:00
protected function getCategoryEntry(array $path): ResponseInterface {
$c = new Context;
if ($path[1] === "1") {
$c->folderShallow(0);
} else {
$c->folder((int) $path[1] - 1);
}
try {
return HTTP::respJson($this->findEntry((int) $path[3], $c));
2021-02-05 01:19:35 +00:00
} catch (ExceptionInput $e) {
return self::respError("404", 404);
2021-02-05 01:19:35 +00:00
}
}
2021-02-05 13:48:14 +00:00
protected function updateEntries(array $data): ResponseInterface {
if ($data['status'] === "read") {
$in = ['read' => true, 'hidden' => false];
} elseif ($data['status'] === "unread") {
$in = ['read' => false, 'hidden' => false];
} elseif ($data['status'] === "removed") {
$in = ['read' => true, 'hidden' => true];
}
assert(isset($in), new \Exception("Unknown status specified"));
Arsse::$db->articleMark(Arsse::$user->id, $in, (new Context)->articles($data['entry_ids']));
return HTTP::respEmpty(204);
2021-02-05 13:48:14 +00:00
}
protected function massRead(Context $c): void {
Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], $c->hidden(false));
}
protected function markUserByNum(array $path): ResponseInterface {
// this function is restricted to the logged-in user
$user = Arsse::$user->propertiesGet(Arsse::$user->id, false);
if (((int) $path[1]) !== $user['num']) {
return self::respError("403", 403);
2021-02-05 13:48:14 +00:00
}
$this->massRead(new Context);
return HTTP::respEmpty(204);
2021-02-05 13:48:14 +00:00
}
protected function markFeed(array $path): ResponseInterface {
try {
$this->massRead((new Context)->subscription((int) $path[1]));
} catch (ExceptionInput $e) {
return self::respError("404", 404);
2021-02-05 13:48:14 +00:00
}
return HTTP::respEmpty(204);
2021-02-05 13:48:14 +00:00
}
protected function markCategory(array $path): ResponseInterface {
$folder = $path[1] - 1;
$c = new Context;
if ($folder === 0) {
// if we're marking the root folder don't also mark its child folders, since Miniflux organizes it as a peer of other folders
$c->folderShallow($folder);
} else {
$c->folder($folder);
}
try {
$this->massRead($c);
} catch (ExceptionInput $e) {
return self::respError("404", 404);
2021-02-05 13:48:14 +00:00
}
return HTTP::respEmpty(204);
2021-02-05 13:48:14 +00:00
}
protected function toggleEntryBookmark(array $path): ResponseInterface {
// NOTE: A toggle is bad design, but we have no choice but to implement what Miniflux does
$id = (int) $path[1];
$c = (new Context)->article($id);
try {
$tr = Arsse::$db->begin();
if (Arsse::$db->articleCount(Arsse::$user->id, (clone $c)->starred(false))) {
Arsse::$db->articleMark(Arsse::$user->id, ['starred' => true], $c);
} else {
Arsse::$db->articleMark(Arsse::$user->id, ['starred' => false], $c);
}
$tr->commit();
} catch (ExceptionInput $e) {
return self::respError("404", 404);
2021-02-05 13:48:14 +00:00
}
return HTTP::respEmpty(204);
2021-02-05 13:48:14 +00:00
}
2021-02-05 14:04:00 +00:00
protected function refreshFeed(array $path): ResponseInterface {
// NOTE: This is a no-op; we simply check that the feed exists
try {
Arsse::$db->subscriptionPropertiesGet(Arsse::$user->id, (int) $path[1]);
} catch (ExceptionInput $e) {
return self::respError("404", 404);
2021-02-05 14:04:00 +00:00
}
return HTTP::respEmpty(204);
2021-02-05 14:04:00 +00:00
}
protected function refreshAllFeeds(): ResponseInterface {
// NOTE: This is a no-op
// It could be implemented, but the need is considered low since we use a dynamic schedule always
return HTTP::respEmpty(204);
2021-02-05 14:04:00 +00:00
}
2021-02-06 01:29:41 +00:00
protected function opmlImport(string $data): ResponseInterface {
try {
Arsse::$obj->get(OPML::class)->import(Arsse::$user->id, $data);
2021-02-06 01:29:41 +00:00
} catch (ImportException $e) {
switch ($e->getCode()) {
case 10611:
return self::respError("InvalidBodyXML", 400);
2021-02-06 01:29:41 +00:00
case 10612:
return self::respError("InvalidBodyOPML", 422);
2021-02-06 01:29:41 +00:00
case 10613:
return self::respError("InvalidImportCategory", 422);
2021-02-06 01:29:41 +00:00
case 10614:
return self::respError("DuplicateImportCategory", 422);
2021-02-06 01:29:41 +00:00
case 10615:
return self::respError("InvalidImportLabel", 422);
2021-02-06 01:29:41 +00:00
}
} catch (FeedException $e) {
return self::respError(["FailedImportFeed", 'url' => $e->getParams()['url'], 'code' => $e->getCode()], 502);
2021-02-06 01:29:41 +00:00
}
return HTTP::respJson(['message' => Arsse::$lang->msg("API.Miniflux.ImportSuccess")]);
2021-02-06 01:29:41 +00:00
}
protected function opmlExport(): ResponseInterface {
2022-08-06 17:40:02 +00:00
return HTTP::respText(Arsse::$obj->get(OPML::class)->export(Arsse::$user->id), 200, ['Content-Type' => "application/xml"]);
2021-02-06 01:29:41 +00:00
}
2020-11-01 01:26:11 +00:00
}