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 'search' => V::T_STRING, 'category_id' => V::T_INT, ]; protected const VALID_JSON = [ // user properties which map directly to Arsse user metadata are listed separately; // not all these properties are used by our implementation, but they are treated // 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", 'entry_ids' => "array", // this is a special case: it is an array of integers 'status' => "string", ]; 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", ""], ]; /** A map between Miniflux's input properties and our input properties when modifiying feeds * * 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 * where practical, on the assumption Miniflux would also reject * 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 = [ "id", "url", "title", "subscription", "author", "fingerprint", "published_date", "modified_date", "starred", "unread", "hidden", "content", "media_url", "media_type", ]; protected const CALLS = [ // handler method Admin Path Body Query Required fields '/categories' => [ 'GET' => ["getCategories", false, false, false, false, []], 'POST' => ["createCategory", false, false, true, false, ["title"]], ], '/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, []], ], '/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, []], ], '/categories/1/mark-all-as-read' => [ 'PUT' => ["markCategory", false, true, false, false, []], ], '/discover' => [ 'POST' => ["discoverSubscriptions", false, false, true, false, ["url"]], ], '/entries' => [ 'GET' => ["getEntries", false, false, false, true, []], 'PUT' => ["updateEntries", false, false, true, false, ["entry_ids", "status"]], ], '/entries/1' => [ 'GET' => ["getEntry", false, true, false, false, []], ], '/entries/1/bookmark' => [ 'PUT' => ["toggleEntryBookmark", false, true, false, false, []], ], '/export' => [ 'GET' => ["opmlExport", false, false, false, false, []], ], '/feeds' => [ 'GET' => ["getFeeds", false, false, false, false, []], 'POST' => ["createFeed", false, false, true, false, ["feed_url", "category_id"]], ], '/feeds/1' => [ 'GET' => ["getFeed", false, true, false, false, []], 'PUT' => ["updateFeed", false, true, true, false, []], 'DELETE' => ["deleteFeed", false, true, false, false, []], ], '/feeds/1/entries' => [ 'GET' => ["getFeedEntries", false, true, false, true, []], ], '/feeds/1/entries/1' => [ 'GET' => ["getFeedEntry", false, true, false, false, []], ], '/feeds/1/icon' => [ 'GET' => ["getFeedIcon", false, true, false, false, []], ], '/feeds/1/mark-all-as-read' => [ 'PUT' => ["markFeed", false, true, false, false, []], ], '/feeds/1/refresh' => [ 'PUT' => ["refreshFeed", false, true, false, false, []], ], '/feeds/refresh' => [ 'PUT' => ["refreshAllFeeds", false, false, false, false, []], ], '/import' => [ 'POST' => ["opmlImport", false, false, true, false, []], ], '/me' => [ 'GET' => ["getCurrentUser", false, false, false, false, []], ], '/users' => [ 'GET' => ["getUsers", true, false, false, false, []], 'POST' => ["createUser", true, false, true, false, ["username", "password"]], ], '/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, []], ], '/users/1/mark-all-as-read' => [ 'PUT' => ["markUserByNum", false, true, false, false, []], ], '/users/*' => [ 'GET' => ["getUserById", true, true, false, false, []], ], ]; public function __construct() { } 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 try { $d = Arsse::$db->tokenLookup("miniflux.login", $t); } catch (ExceptionInput $e) { return false; } Arsse::$user->id = $d['user']; return true; } } // next check HTTP auth if ($req->getAttribute("authenticated", false)) { Arsse::$user->id = $req->getAttribute("authenticatedUser"); return true; } return false; } public function dispatch(ServerRequestInterface $req): ResponseInterface { // try to authenticate if (!$this->authenticate($req)) { return new ErrorResponse("401", 401); } // get the request path only; this is assumed to already be normalized $target = parse_url($req->getRequestTarget(), \PHP_URL_PATH) ?? ""; $method = $req->getMethod(); // handle HTTP OPTIONS requests if ($method === "OPTIONS") { return $this->handleHTTPOptions($target); } $func = $this->chooseCall($target, $method); if ($func instanceof ResponseInterface) { return $func; } else { [$func, $reqAdmin, $reqPath, $reqBody, $reqQuery, $reqFields] = $func; } if ($reqAdmin && !$this->isAdmin()) { return new ErrorResponse("403", 403); } $args = []; if ($reqPath) { $args[] = explode("/", ltrim($target, "/")); } if ($reqBody) { if ($func === "opmlImport") { $data = (string) $req->getBody(); } 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 new ErrorResponse(["InvalidBodyJSON", json_last_error_msg()], 400); } } else { $data = []; } $data = $this->normalizeBody((array) $data, $reqFields); if ($data instanceof ResponseInterface) { return $data; } } $args[] = $data; } if ($reqQuery) { $query = $this->normalizeQuery(parse_url($req->getRequestTarget(), \PHP_URL_QUERY) ?? ""); if ($query instanceof ResponseInterface) { return $query; } $args[] = $query; } try { return $this->$func(...$args); // @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 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 new EmptyResponse(405, ['Allow' => implode(", ", array_keys(self::CALLS[$url]))]); } } else { // if the path is not supported, return 404 return new EmptyResponse(404); } } 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 (V::id($path[$a])) { $path[$a] = "1"; } } // 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+)?$/", $path[2])) { $path[2] = "*"; } return implode("/", $path); } protected function normalizeBody(array $body, array $req) { // Miniflux does not attempt to coerce values into different types foreach (self::VALID_JSON as $k => $t) { if (!isset($body[$k])) { $body[$k] = null; } elseif (gettype($body[$k]) !== $t) { return new ErrorResponse(["InvalidInputType", 'field' => $k, 'expected' => $t, 'actual' => gettype($body[$k])], 422); } elseif ( (in_array($k, ["keeplist_rules", "blocklist_rules"]) && !Rule::validate($body[$k])) || (in_array($k, ["url", "feed_url"]) && !URL::absolute($body[$k])) || ($k === "category_id" && $body[$k] < 1) || ($k === "status" && !in_array($body[$k], ["read", "unread", "removed"])) ) { return new ErrorResponse(["InvalidInputValue", 'field' => $k], 422); } elseif ($k === "entry_ids") { foreach ($body[$k] as $v) { if (gettype($v) !== "integer") { return new ErrorResponse(["InvalidInputType", 'field' => $k, 'expected' => "integer", 'actual' => gettype($v)], 422); } elseif ($v < 1) { return new ErrorResponse(["InvalidInputValue", 'field' => $k], 422); } } } } //normalize user-specific input foreach (self::USER_META_MAP as $k => [,$d]) { $t = gettype($d); if (!isset($body[$k])) { $body[$k] = null; } elseif ($k === "entry_sorting_direction") { if (!in_array($body[$k], ["asc", "desc"])) { return new ErrorResponse(["InvalidInputValue", 'field' => $k], 422); } } elseif (gettype($body[$k]) !== $t) { return new ErrorResponse(["InvalidInputType", 'field' => $k, 'expected' => $t, 'actual' => gettype($body[$k])], 422); } } // check for any missing required values foreach ($req as $k) { if (!isset($body[$k]) || (is_array($body[$k]) && !$body[$k])) { return new ErrorResponse(["MissingInputValue", 'field' => $k], 422); } } return $body; } protected function normalizeQuery(string $query) { // fill an array with all valid keys $out = []; $seen = []; foreach (self::VALID_QUERY as $k => $t) { $out[$k] = ($t >= V::M_ARRAY) ? [] : null; $seen[$k] = false; } // 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 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 new ErrorResponse(["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 new ErrorResponse(["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 new ErrorResponse(["InvalidInputValue", 'field' => $k], 400); } } return $out; } protected function handleHTTPOptions(string $url): ResponseInterface { // normalize the URL path: change any IDs to 1 for easier comparison $url = $this->normalizePathIDs($url); if (isset(self::CALLS[$url])) { // if the path is supported, respond with the allowed methods and other metadata $allowed = array_keys(self::CALLS[$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' => implode(", ", $url === "/import" ? self::ACCEPTED_TYPES_OPML : self::ACCEPTED_TYPES_JSON), ]); } else { // if the path is not supported, return 404 return new EmptyResponse(404); } } protected function listUsers(array $users, bool $reportMissing): array { $out = []; $now = Date::transform($this->now(), "iso8601m"); foreach ($users as $u) { try { $info = Arsse::$user->propertiesGet($u, true); } catch (UserException $e) { if ($reportMissing) { throw $e; } else { continue; } } $entry = [ 'id' => $info['num'], 'username' => $u, 'last_login_at' => $now, 'google_id' => "", 'openid_connect_id' => "", ]; foreach (self::USER_META_MAP as $ext => [$int, $default]) { $entry[$ext] = $info[$int] ?? $default; } $entry['entry_sorting_direction'] = ($entry['entry_sorting_direction']) ? "asc" : "desc"; $out[] = $entry; } return $out; } protected function editUser(string $user, array $data): array { // map Miniflux properties to internal metadata properties $in = []; foreach (self::USER_META_MAP as $i => [$o]) { 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; } protected function discoverSubscriptions(array $data): ResponseInterface { try { $list = Feed::discoverAll((string) $data['url'], (string) $data['username'], (string) $data['password']); } catch (FeedException $e) { $msg = [ 10502 => "Fetch404", 10506 => "Fetch403", 10507 => "Fetch401", 10521 => "Fetch404", ][$e->getCode()] ?? "FetchOther"; return new ErrorResponse($msg, 502); } $out = []; foreach ($list as $url) { // TODO: This needs to be refined once PicoFeed is replaced $out[] = ['title' => "Feed", 'type' => "rss", 'url' => $url]; } return new Response($out); } protected function getUsers(): ResponseInterface { $tr = Arsse::$user->begin(); return new Response($this->listUsers(Arsse::$user->list(), false)); } protected function getUserById(array $path): ResponseInterface { try { return new Response($this->listUsers([$path[1]], true)[0] ?? new \stdClass); } catch (UserException $e) { return new ErrorResponse("404", 404); } } protected function getUserByNum(array $path): ResponseInterface { try { $user = Arsse::$user->lookup((int) $path[1]); return new Response($this->listUsers([$user], true)[0] ?? new \stdClass); } catch (UserException $e) { return new ErrorResponse("404", 404); } } protected function getCurrentUser(): ResponseInterface { return new Response($this->listUsers([Arsse::$user->id], false)[0] ?? new \stdClass); } 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 new ErrorResponse(["DuplicateUser", 'user' => $data['username']], 409); case 10441: return new ErrorResponse(["InvalidInputValue", 'field' => "timezone"], 422); case 10443: return new ErrorResponse(["InvalidInputValue", 'field' => "entries_per_page"], 422); case 10444: return new ErrorResponse(["InvalidInputValue", 'field' => "username"], 422); } throw $e; // @codeCoverageIgnore } return new Response($out, 201); } 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 new ErrorResponse("InvalidElevation", 403); } $user = Arsse::$user->id; } elseif (!$user['admin']) { return new ErrorResponse("403", 403); } else { try { $user = Arsse::$user->lookup((int) $path[1]); } catch (ExceptionConflict $e) { return new ErrorResponse("404", 404); } } // 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']); } $out = $this->editUser($user, $data); $tr->commit(); } catch (UserException $e) { switch ($e->getCode()) { case 10403: return new ErrorResponse(["DuplicateUser", 'user' => $data['username']], 409); case 10441: return new ErrorResponse(["InvalidInputValue", 'field' => "timezone"], 422); case 10443: return new ErrorResponse(["InvalidInputValue", 'field' => "entries_per_page"], 422); case 10444: return new ErrorResponse(["InvalidInputValue", 'field' => "username"], 422); } throw $e; // @codeCoverageIgnore } return new Response($out); } protected function deleteUserByNum(array $path): ResponseInterface { try { Arsse::$user->remove(Arsse::$user->lookup((int) $path[1])); } catch (ExceptionConflict $e) { return new ErrorResponse("404", 404); } return new EmptyResponse(204); } /** Returns a useful subset of user metadata * * The following keys are included: * * - "num": The user's numeric ID, * - "root": The effective name of the root folder */ protected function userMeta(string $user): array { $meta = Arsse::$user->propertiesGet(Arsse::$user->id, 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 = []; // add the root folder as a category $meta = $this->userMeta(Arsse::$user->id); $out[] = ['id' => 1, 'title' => $meta['root'], 'user_id' => $meta['num']]; // 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']]; } return new Response($out); } protected function createCategory(array $data): ResponseInterface { try { $id = Arsse::$db->folderAdd(Arsse::$user->id, ['name' => (string) $data['title']]); } catch (ExceptionInput $e) { if ($e->getCode() === 10236) { return new ErrorResponse(["DuplicateCategory", 'title' => $data['title']], 409); } else { return new ErrorResponse(["InvalidCategory", 'title' => $data['title']], 422); } } $meta = Arsse::$user->propertiesGet(Arsse::$user->id, false); return new Response(['id' => $id + 1, 'title' => $data['title'], 'user_id' => $meta['num']], 201); } protected function updateCategory(array $path, array $data): ResponseInterface { // 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 $folder = $path[1] - 1; $title = $data['title'] ?? ""; try { if ($folder === 0) { // folder 0 doesn't actually exist in the database, so its name is kept as user metadata if (!strlen(trim($title))) { throw new ExceptionInput("whitespace"); } $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 new ErrorResponse(["DuplicateCategory", 'title' => $title], 409); } elseif (in_array($e->getCode(), [10237, 10239])) { return new ErrorResponse("404", 404); } else { return new ErrorResponse(["InvalidCategory", 'title' => $title], 422); } } $meta = Arsse::$user->propertiesGet(Arsse::$user->id, false); return new Response(['id' => (int) $path[1], 'title' => $title, 'user_id' => $meta['num']]); } protected function deleteCategory(array $path): ResponseInterface { 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 // otherwise we'd be deleting the entire tree $tr = Arsse::$db->begin(); foreach (Arsse::$db->subscriptionList(Arsse::$user->id, null, false) as $sub) { Arsse::$db->subscriptionRemove(Arsse::$user->id, $sub['id']); } $tr->commit(); } } catch (ExceptionInput $e) { return new ErrorResponse("404", 404); } return new EmptyResponse(204); } protected function transformFeed(array $sub, int $uid, string $rootName, \DateTimeZone $tz): array { $url = new Uri($sub['url']); return [ 'id' => (int) $sub['id'], 'user_id' => $uid, 'feed_url' => (string) $url->withUserInfo(""), 'site_url' => (string) $sub['source'], 'title' => (string) $sub['title'], '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", '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, ], '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); foreach (Arsse::$db->subscriptionList(Arsse::$user->id) as $r) { $out[] = $this->transformFeed($r, $meta['num'], $meta['root'], $meta['tz']); } return new Response($out); } 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) { $out[] = $this->transformFeed($r, $meta['num'], $meta['root'], $meta['tz']); } } catch (ExceptionInput $e) { // the folder does not exist return new ErrorResponse("404", 404); } return new Response($out); } protected function getFeed(array $path): ResponseInterface { $tr = Arsse::$db->begin(); $meta = $this->userMeta(Arsse::$user->id); try { $sub = Arsse::$db->subscriptionPropertiesGet(Arsse::$user->id, (int) $path[1]); return new Response($this->transformFeed($sub, $meta['num'], $meta['root'], $meta['tz'])); } catch (ExceptionInput $e) { return new ErrorResponse("404", 404); } } protected function createFeed(array $data): ResponseInterface { try { Arsse::$db->feedAdd($data['feed_url'], (string) $data['username'], (string) $data['password'], false, (bool) $data['crawler']); $tr = Arsse::$db->begin(); $id = Arsse::$db->subscriptionAdd(Arsse::$user->id, $data['feed_url'], (string) $data['username'], (string) $data['password'], false, (bool) $data['crawler']); Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, $id, ['folder' => $data['category_id'] - 1, 'scrape' => (bool) $data['crawler']]); $tr->commit(); if (strlen($data['keeplist_rules'] ?? "") || strlen($data['blocklist_rules'] ?? "")) { // we do rules separately so as not to tie up the database Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, $id, ['keep_rule' => $data['keeplist_rules'], 'block_rule' => $data['blocklist_rules']]); } } catch (FeedException $e) { $msg = [ 10502 => "Fetch404", 10506 => "Fetch403", 10507 => "Fetch401", 10521 => "Fetch404", 10522 => "FetchFormat", ][$e->getCode()] ?? "FetchOther"; return new ErrorResponse($msg, 502); } catch (ExceptionInput $e) { switch ($e->getCode()) { case 10235: return new ErrorResponse("MissingCategory", 422); case 10236: return new ErrorResponse("DuplicateFeed", 409); } } return new Response(['feed_id' => $id], 201); } 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 new ErrorResponse("InvalidTitle", 422); case 10235: return new ErrorResponse("MissingCategory", 422); case 10239: return new ErrorResponse("404", 404); } } return $this->getFeed($path); } protected function deleteFeed(array $path): ResponseInterface { try { Arsse::$db->subscriptionRemove(Arsse::$user->id, (int) $path[1]); return new EmptyResponse(204); } catch (ExceptionInput $e) { return new ErrorResponse("404", 404); } } protected function getFeedIcon(array $path): ResponseInterface { try { $icon = Arsse::$db->subscriptionIcon(Arsse::$user->id, (int) $path[1]); } catch (ExceptionInput $e) { return new ErrorResponse("404", 404); } if (!$icon || !$icon['type'] || !$icon['data']) { return new ErrorResponse("404", 404); } return new Response([ 'id' => (int) $icon['id'], 'data' => $icon['type'].";base64,".base64_encode($icon['data']), 'mime_type' => $icon['type'], ]); } protected function computeContext(array $query, Context $c = null): Context { $c = ($c ?? new Context) ->limit($query['limit'] ?? self::DEFAULT_ENTRY_LIMIT) // NOTE: This does not honour user preferences ->offset($query['offset']) ->starred($query['starred']) ->modifiedSince($query['after']) // FIXME: This may not be the correct date field ->notModifiedSince($query['before']) ->oldestArticle($query['after_entry_id'] ? $query['after_entry_id'] + 1 : null) // FIXME: This might be edition ->latestArticle($query['before_entry_id'] ? $query['before_entry_id'] - 1 : null) ->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); } } // FIXME: specifying e.g. ?status=read&status=removed should yield all hidden articles and all read articles, but the best we can do is all read articles which are or are not hidden $status = array_unique($query['status']); sort($status); if ($status === ["read", "removed"]) { $c->unread(false); } elseif ($status === ["read", "unread"]) { $c->hidden(false); } elseif ($status === ["read"]) { $c->hidden(false)->unread(false); } elseif ($status === ["removed", "unread"]) { $c->unread(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]; } elseif ($query['order'] === "category_id") { return ["top_folder".$desc]; } else { 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 = [ [ '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, 'entry_id' => (int) $entry['id'], 'url' => $entry['media_url'], 'mime_type' => $entry['media_type'] ?: "application/octet-stream", 'size' => 0, ], ]; } 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' => "", '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 if ($out) { $feeds = []; foreach (Arsse::$db->subscriptionList(Arsse::$user->id) as $r) { $feeds[(int) $r['id']] = $this->transformFeed($r, $meta['num'], $meta['root'], $meta['tz']); } // 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']]; } } // finally compute the total number of entries match the query, where necessary $count = sizeof($out); if ($c->offset || ($c->limit && $count >= $c->limit)) { $count = Arsse::$db->articleCount(Arsse::$user->id, (clone $c)->limit(0)->offset(0)); } return ['total' => $count, 'entries' => $out]; } 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"); } $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; } protected function getEntries(array $query): ResponseInterface { try { return new Response($this->listEntries($query, new Context)); } catch (ExceptionInput $e) { return new ErrorResponse("MissingCategory", 400); } } protected function getFeedEntries(array $path, array $query): ResponseInterface { $c = (new Context)->subscription((int) $path[1]); try { return new Response($this->listEntries($query, $c)); } catch (ExceptionInput $e) { // FIXME: this should differentiate between a missing feed and a missing category, but doesn't return new ErrorResponse("404", 404); } } protected function getCategoryEntries(array $path, array $query): ResponseInterface { $query['category_id'] = (int) $path[1]; try { return new Response($this->listEntries($query, new Context)); } catch (ExceptionInput $e) { return new ErrorResponse("404", 404); } } protected function getEntry(array $path): ResponseInterface { try { return new Response($this->findEntry((int) $path[1])); } catch (ExceptionInput $e) { return new ErrorResponse("404", 404); } } protected function getFeedEntry(array $path): ResponseInterface { $c = (new Context)->subscription((int) $path[1]); try { return new Response($this->findEntry((int) $path[3], $c)); } catch (ExceptionInput $e) { return new ErrorResponse("404", 404); } } 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 new Response($this->findEntry((int) $path[3], $c)); } catch (ExceptionInput $e) { return new ErrorResponse("404", 404); } } 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 new EmptyResponse(204); } 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 new ErrorResponse("403", 403); } $this->massRead(new Context); return new EmptyResponse(204); } protected function markFeed(array $path): ResponseInterface { try { $this->massRead((new Context)->subscription((int) $path[1])); } catch (ExceptionInput $e) { return new ErrorResponse("404", 404); } return new EmptyResponse(204); } 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 new ErrorResponse("404", 404); } return new EmptyResponse(204); } 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 new ErrorResponse("404", 404); } return new EmptyResponse(204); } 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 new ErrorResponse("404", 404); } return new EmptyResponse(204); } 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 new EmptyResponse(204); } protected function opmlImport(string $data): ResponseInterface { try { Arsse::$obj->get(OPML::class)->import(Arsse::$user->id, $data); } catch (ImportException $e) { switch ($e->getCode()) { case 10611: return new ErrorResponse("InvalidBodyXML", 400); case 10612: return new ErrorResponse("InvalidBodyOPML", 422); case 10613: return new ErrorResponse("InvalidImportCategory", 422); case 10614: return new ErrorResponse("DuplicateImportCategory", 422); case 10615: return new ErrorResponse("InvalidImportLabel", 422); } } catch (FeedException $e) { return new ErrorResponse(["FailedImportFeed", 'url' => $e->getParams()['url'], 'code' => $e->getCode()], 502); } return new Response(['message' => Arsse::$lang->msg("API.Miniflux.ImportSuccess")]); } protected function opmlExport(): ResponseInterface { return new GenericResponse(Arsse::$obj->get(OPML::class)->export(Arsse::$user->id), 200, ['Content-Type' => "application/xml"]); } }