diff --git a/.gitignore b/.gitignore
index f9b4ff3..7d5a505 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,6 @@
.idea
-src/data/*.db
+vendor
+src/data/*.db*
src/user-config.php
+*.tar.bz2
+*.zip
diff --git a/INSTALLING.md b/INSTALLING.md
index 047f8ea..79a3664 100644
--- a/INSTALLING.md
+++ b/INSTALLING.md
@@ -1,30 +1,46 @@
# Installation
-## All Environments (FrankenPHP)
+## Obtaining the Files
-The easiest way to get up and running quickly is by using [FrankenPHP](https://frankenphp.dev), a version of [Caddy](https://caddyserver.com) that runs PHP in its process. There is a `Caddyfile` in the `/src` directory which will configure the site to run with FrankenPHP.
+Releases are named `frc-[version]` and are provided in both `.zip` and `.tar.bz2` formats on [the release page](https://git.bitbadger.solutions/bit-badger/feed-reader-central/releases) (starting with beta1). Select a folder on the server that will host this instance and unarchive the release there.
-For Linux / Mac users:
-- Follow [their instructions](https://frankenphp.dev/docs/#standalone-binary) for downloading a binary for your system
-- Rename that binary to `frankenphp` and make it executable (`chmod +x ./frankenphp`)
-- Move that binary to `/usr/local/bin`
+## Selecting a Server
-For Windows users, the steps are the same; however, the binary should be named `frankenphp.exe` and be placed somewhere within your system's `PATH`.
+Configuration samples are provided in the `/servers` directory for [Apache 2](https://httpd.apache.org/docs/), [nginx](https://nginx.org/en/), and [Caddy](https://caddyserver.com/docs/).
-Once those steps are complete, from the `/src` directory, run `frankenphp run`.
+### Apache 2
-_(More environments will be detailed as part of a later release; an nginx reverse proxy via FastCGI is another common way to run PHP applications.)_
+Configure a virtual host (if appropriate), then copy `/servers/htaccess` to `.htaccess` in the directory where the release archive was expanded. This file enables the extensionless rewrites required for the URLs to work.
+
+### nginx
+
+The configuration file `/servers/nginx.conf` is a full virtual host configuration. Update it with the paths for your installation, then add it to your configuration (on Debian/Ubuntu, copy it to `/etc/nginx/sites-available`, create a symlink in `/etc/nginx/sites-enabed`, and reload the nginx config).
+
+### Caddy (with FastCGI)
+
+`/servers/fastcgi.Caddyfile` contains configuration to host the site using Caddy. Ensure the PHP FPM address is correct, then rename it to `Caddyfile` and place it in the directory where the release archive was expanded. Running `caddy run` will serve the site with the given parameters.
+
+### Caddy (with FrankenPHP)
+
+_NOTE: This is currently not recommended. There is a known sequence of links that cause FrankenPHP to no longer respond. Hopefully this will be resolved before the beta moniker is dropped._
+
+~~[FrankenPHP](https://frankenphp.dev) is a version of Caddy that runs PHP in its process. `/servers/frankenphp.Caddyfile` will configure the site to run with FrankenPHP.~~
+
+~~Obtain a version for Linux / Mac users:~~
+- ~~Follow [their instructions](https://frankenphp.dev/docs/#standalone-binary) for downloading a binary for your system~~
+- ~~Rename that binary to `frankenphp` and make it executable (`chmod +x ./frankenphp`)~~
+- ~~Move that binary to `/usr/local/bin`~~
+
+~~For Windows users, the steps are the same; however, the binary should be named `frankenphp.exe` and be placed somewhere within your system's `PATH`.~~
+
+~~Once those steps are complete, from the `/src` directory, run `frankenphp run`.~~
## PHP Requirements
-This is written to target PHP 8.3, and requires the `curl`, `DOM`, and `SQLite3` modules and the `php-cli` feature. _(FrankenPHP contains all these as part of its build.)_
+This is written to target PHP 8.2, and requires the `curl`, `DOM`, and `SQLite3` modules and the `php-cli` feature. _(FrankenPHP contains all these as part of its build.)_
# Setup and Configuration
-## Site Address
-
-The default `Caddyfile` will run the site at `http://localhost:8205`. To have the process respond to other devices on your network, you can add the server name to that to line 5 (ex. `http://localhost:8205, http://server:8205`); you can also change the port on which it listens. (Note that if `http` is not specified, Caddy will attempt to obtain and install a server certificate. This may be what you want, but that also could be a source of startup errors.)
-
## Feed Reader Central Behavior
Within the `/src` directory, there is a file named `user-config.dist.php`. Rename this file to `user-config.php`; this is where customizations and configuration of the instance's behavior are placed.
@@ -38,7 +54,7 @@ There are three supported security models, designed around different ways the so
### Database Name
-Data is stored under the `/src/data` directory, and the default database name is `frc.db`. If users want to change that path or file name, the path provided should be relative to `/src/data`, not just `/src`.
+Data is stored in the `/data` subdirectory of the release extraction location, and the default database name is `frc.db`. If users want to change that path or file name, the path provided should be relative to `/data`.
### Date/Time Format
@@ -51,4 +67,3 @@ Feed Reader Central tries to keep the database tidy by purging items that have b
- `Feed::PURGE_READ` purges non-bookmarked read items for a feed whenever it is refreshed. This is the most aggressive purging strategy, but it is also the only one that will not purge unread items.
- `Feed::PURGE_BY_DAYS` purges non-bookmarked items that are older than `PURGE_NUMBER` days old. This is the default value, and `PURGE_NUMBER`'s default value is 30; items will be kept for 30 days, read or unread.
- `Feed::PURGE_BY_COUNT` purges items to preserve at most `PURGE_NUMBER` non-bookmarked items for each feed.
-
\ No newline at end of file
diff --git a/create_dist.sh b/create_dist.sh
new file mode 100755
index 0000000..097ee13
--- /dev/null
+++ b/create_dist.sh
@@ -0,0 +1,14 @@
+#!/bin/bash
+# Create release distribution archives
+mkdir tmp
+cd tmp || exit
+cp -r ../src/* .
+rm ./data/* || true
+rm -rf ./vendor/bit-badger/pdo-document/.git* || true
+rm -rf ./vendor/bit-badger/pdo-document/tests || true
+rm -rf ./vendor/bit-badger/pdo-document/composer.lock || true
+rm ./user-config.php || true
+zip -q -r ../frc-"$1".zip *
+tar cfj ../frc-"$1".tar.bz2 *
+cd .. || exit
+rm -rf tmp
\ No newline at end of file
diff --git a/src/app-config.php b/src/app-config.php
index b8a10cb..775daf0 100644
--- a/src/app-config.php
+++ b/src/app-config.php
@@ -1,4 +1,7 @@
-=8.2",
+ "bit-badger/pdo-document": "^1",
+ "ext-curl": "*",
+ "ext-dom": "*",
+ "ext-pdo": "*",
+ "ext-readline": "*",
+ "ext-sqlite3": "*"
+ },
+ "autoload": {
+ "psr-4": {
+ "FeedReaderCentral\\": "lib/"
+ }
+ }
+}
diff --git a/src/composer.lock b/src/composer.lock
new file mode 100644
index 0000000..a51a4d6
--- /dev/null
+++ b/src/composer.lock
@@ -0,0 +1,127 @@
+{
+ "_readme": [
+ "This file locks the dependencies of your project to a known state",
+ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
+ "This file is @generated automatically"
+ ],
+ "content-hash": "4f0add59e384feb6787acf3685c9e031",
+ "packages": [
+ {
+ "name": "bit-badger/pdo-document",
+ "version": "v1.0.0-alpha2",
+ "source": {
+ "type": "git",
+ "url": "https://git.bitbadger.solutions/bit-badger/pdo-document",
+ "reference": "330e27218756df8b93081a17dead8aaec789b071"
+ },
+ "require": {
+ "ext-pdo": "*",
+ "netresearch/jsonmapper": "^4",
+ "php": ">=8.2"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^11"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "BitBadger\\PDODocument\\": "./src",
+ "BitBadger\\PDODocument\\Query\\": "./src/Query",
+ "BitBadger\\PDODocument\\Mapper\\": "./src/Mapper"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Daniel J. Summers",
+ "email": "daniel@bitbadger.solutions",
+ "homepage": "https://bitbadger.solutions",
+ "role": "Developer"
+ }
+ ],
+ "description": "Treat SQLite (and soon PostgreSQL) as a document store",
+ "keywords": [
+ "database",
+ "document",
+ "pdo",
+ "sqlite"
+ ],
+ "support": {
+ "email": "daniel@bitbadger.solutions",
+ "rss": "https://git.bitbadger.solutions/bit-badger/pdo-document.rss",
+ "source": "https://git.bitbadger.solutions/bit-badger/pdo-document"
+ },
+ "time": "2024-06-11T11:07:56+00:00"
+ },
+ {
+ "name": "netresearch/jsonmapper",
+ "version": "v4.4.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/cweiske/jsonmapper.git",
+ "reference": "132c75c7dd83e45353ebb9c6c9f591952995bbf0"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/cweiske/jsonmapper/zipball/132c75c7dd83e45353ebb9c6c9f591952995bbf0",
+ "reference": "132c75c7dd83e45353ebb9c6c9f591952995bbf0",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "ext-pcre": "*",
+ "ext-reflection": "*",
+ "ext-spl": "*",
+ "php": ">=7.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "~7.5 || ~8.0 || ~9.0 || ~10.0",
+ "squizlabs/php_codesniffer": "~3.5"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-0": {
+ "JsonMapper": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "OSL-3.0"
+ ],
+ "authors": [
+ {
+ "name": "Christian Weiske",
+ "email": "cweiske@cweiske.de",
+ "homepage": "http://github.com/cweiske/jsonmapper/",
+ "role": "Developer"
+ }
+ ],
+ "description": "Map nested JSON structures onto PHP classes",
+ "support": {
+ "email": "cweiske@cweiske.de",
+ "issues": "https://github.com/cweiske/jsonmapper/issues",
+ "source": "https://github.com/cweiske/jsonmapper/tree/v4.4.1"
+ },
+ "time": "2024-01-31T06:18:54+00:00"
+ }
+ ],
+ "packages-dev": [],
+ "aliases": [],
+ "minimum-stability": "dev",
+ "stability-flags": [],
+ "prefer-stable": false,
+ "prefer-lowest": false,
+ "platform": {
+ "php": ">=8.2",
+ "ext-curl": "*",
+ "ext-dom": "*",
+ "ext-pdo": "*",
+ "ext-readline": "*",
+ "ext-sqlite3": "*"
+ },
+ "platform-dev": [],
+ "plugin-api-version": "2.6.0"
+}
diff --git a/src/lib/Data.php b/src/lib/Data.php
index 4bc8ac8..4174b5d 100644
--- a/src/lib/Data.php
+++ b/src/lib/Data.php
@@ -1,91 +1,130 @@
-exec('PRAGMA foreign_keys = ON;');
- return $db;
- }
-
+class Data
+{
/**
* Create the search index and synchronization triggers for the item table
*
- * @param SQLite3 $db The database connection on which these will be created
+ * @throws DocumentException If any is encountered
*/
- public static function createSearchIndex(SQLite3 $db): void {
- $db->exec("CREATE VIRTUAL TABLE item_search USING fts5(content, content='item', content_rowid='id')");
- $db->exec(<<<'SQL'
- CREATE TRIGGER item_ai AFTER INSERT ON item BEGIN
- INSERT INTO item_search (rowid, content) VALUES (new.id, new.content);
- END;
- SQL);
- $db->exec(<<<'SQL'
- CREATE TRIGGER item_au AFTER UPDATE ON item BEGIN
- INSERT INTO item_search (item_search, rowid, content) VALUES ('delete', old.id, old.content);
- INSERT INTO item_search (rowid, content) VALUES (new.id, new.content);
- END;
- SQL);
- $db->exec(<<<'SQL'
- CREATE TRIGGER item_ad AFTER DELETE ON item BEGIN
- INSERT INTO item_search (item_search, rowid, content) VALUES ('delete', old.id, old.content);
- END;
- SQL);
+ public static function createSearchIndex(): void
+ {
+ Custom::nonQuery("CREATE VIRTUAL TABLE item_search USING fts5(content, content='item', content_rowid='id')",
+ []);
+ Custom::nonQuery(<<<'SQL'
+ CREATE TRIGGER item_ai AFTER INSERT ON item BEGIN
+ INSERT INTO item_search (rowid, content) VALUES (new.data->>'id', new.data->>'content');
+ END;
+ SQL, []);
+ Custom::nonQuery(<<<'SQL'
+ CREATE TRIGGER item_au AFTER UPDATE ON item BEGIN
+ INSERT INTO item_search (
+ item_search, rowid, content
+ ) VALUES (
+ 'delete', old.data->>'id', old.data->>'content'
+ );
+ INSERT INTO item_search (rowid, content) VALUES (new.data->>'id', new.data->>'content');
+ END;
+ SQL, []);
+ Custom::nonQuery(<<<'SQL'
+ CREATE TRIGGER item_ad AFTER DELETE ON item BEGIN
+ INSERT INTO item_search (
+ item_search, rowid, content
+ ) VALUES (
+ 'delete', old.data->>'id', old.data->>'content'
+ );
+ END;
+ SQL, []);
}
/**
* Make sure the expected tables exist
+ *
+ * @throws DocumentException If any is encountered
*/
- public static function ensureDb(): void {
- $db = self::getConnection();
- $tables = array();
- $tableQuery = $db->query("SELECT name FROM sqlite_master WHERE type = 'table'");
- while ($table = $tableQuery->fetchArray(SQLITE3_NUM)) $tables[] = $table[0];
- if (!in_array('frc_user', $tables)) {
- $db->exec(<<<'SQL'
- CREATE TABLE frc_user (
- id INTEGER NOT NULL PRIMARY KEY,
- email TEXT NOT NULL,
- password TEXT NOT NULL)
- SQL);
- $db->exec('CREATE INDEX idx_user_email ON frc_user (email)');
+ public static function ensureDb(): void
+ {
+ $tables = Custom::array("SELECT name FROM sqlite_master WHERE type = 'table'", [], new StringMapper('name'));
+ if (!in_array(Table::USER, $tables)) {
+ Definition::ensureTable(Table::USER);
+ Definition::ensureFieldIndex(Table::USER, 'email', ['email']);
}
- if (!in_array('feed', $tables)) {
- $db->exec(<<<'SQL'
- CREATE TABLE feed (
- id INTEGER NOT NULL PRIMARY KEY,
- user_id INTEGER NOT NULL,
- url TEXT NOT NULL,
- title TEXT,
- updated_on TEXT,
- checked_on TEXT,
- FOREIGN KEY (user_id) REFERENCES frc_user (id))
- SQL);
+ if (!in_array(Table::FEED, $tables)) {
+ Definition::ensureTable(Table::FEED);
+ Definition::ensureFieldIndex(Table::FEED, 'user', ['user_id']);
}
- if (!in_array('item', $tables)) {
- $db->exec(<<<'SQL'
- CREATE TABLE item (
- id INTEGER NOT NULL PRIMARY KEY,
- feed_id INTEGER NOT NULL,
- title TEXT NOT NULL,
- item_guid TEXT NOT NULL,
- item_link TEXT NOT NULL,
- published_on TEXT NOT NULL,
- updated_on TEXT,
- content TEXT NOT NULL,
- is_read BOOLEAN NOT NULL DEFAULT 0,
- is_bookmarked BOOLEAN NOT NULL DEFAULT 0,
- FOREIGN KEY (feed_id) REFERENCES feed (id))
- SQL);
- self::createSearchIndex($db);
+ if (!in_array(Table::ITEM, $tables)) {
+ Definition::ensureTable(Table::ITEM);
+ Definition::ensureFieldIndex(Table::ITEM, 'feed', ['feed_id', 'item_link']);
+ self::createSearchIndex();
}
- $db->close();
+ $journalMode = Custom::scalar("PRAGMA journal_mode", [], new StringMapper('journal_mode'));
+ if ($journalMode <> 'wal') Custom::nonQuery("PRAGMA journal_mode = 'wal'", []);
+ }
+
+ /**
+ * Create a JSON field comparison to find bookmarked items
+ *
+ * @param bool $value The flag to set (optional; defaults to true)
+ * @param string $qualifier The table qualifier to include (optional; defaults to no qualifier)
+ * @return Field A field that will find bookmarked items
+ */
+ public static function bookmarkField(bool $value = true, string $qualifier = ''): Field
+ {
+ $bookField = Field::EQ('is_bookmarked', ($value ? 1 : 0), ':book');
+ $bookField->qualifier = $qualifier;
+ return $bookField;
+ }
+
+ /**
+ * Create a JSON field comparison to find items for a given feed
+ *
+ * @param int $feedId The ID of the feed for which items should be retrieved
+ * @param string $qualifier The table qualifier to include (optional; defaults to no qualifier)
+ * @return Field A field to find items for the give feed
+ */
+ public static function feedField(int $feedId, string $qualifier = ''): Field
+ {
+ $feedField = Field::EQ(Configuration::$idField, $feedId, ':feed');
+ $feedField->qualifier = $qualifier;
+ return $feedField;
+ }
+
+ /**
+ * Create a JSON field comparison to find unread items
+ *
+ * @param string $qualifier The table qualifier to include (optional; defaults to no qualifier)
+ * @return Field A field to find unread items
+ */
+ public static function unreadField(string $qualifier = ''): Field
+ {
+ $readField = Field::EQ('is_read', 0, ':read');
+ $readField->qualifier = $qualifier;
+ return $readField;
+ }
+
+ /**
+ * Create a JSON field comparison to find items belonging to feeds to which the given user is subscribed
+ *
+ * @param string $qualifier The table qualifier to include (optional; defaults to no qualifier)
+ * @return Field A field to find feeds belonging to the given user
+ */
+ public static function userIdField(string $qualifier = ''): Field
+ {
+ $userField = Field::EQ('user_id', $_SESSION[Key::USER_ID], ':user');
+ $userField->qualifier = $qualifier;
+ return $userField;
}
/**
@@ -94,21 +133,12 @@ class Data {
* @param ?string $value The date/time to be parsed and formatted
* @return string|null The date/time in `DateTimeInterface::ATOM` format, or `null` if the input cannot be parsed
*/
- public static function formatDate(?string $value): ?string {
+ public static function formatDate(?string $value): ?string
+ {
try {
return $value ? (new DateTimeImmutable($value))->format(DateTimeInterface::ATOM) : null;
} catch (Exception) {
return null;
}
}
-
- /**
- * Return the last SQLite error message as a result array
- *
- * @param SQLite3 $db The database connection on which the error has occurred
- * @return string[] ['error' => message] for last SQLite error message
- */
- public static function error(SQLite3 $db): array {
- return ['error' => 'SQLite error: ' . $db->lastErrorMsg()];
- }
}
diff --git a/src/lib/Feed.php b/src/lib/Feed.php
index 8e37465..f24ed87 100644
--- a/src/lib/Feed.php
+++ b/src/lib/Feed.php
@@ -1,344 +1,91 @@
-` tag that allows HTML content in a feed */
- public const string CONTENT_NS = 'http://purl.org/rss/1.0/modules/content/';
-
- /** @var string The XML namespace for XHTML */
- public const string XHTML_NS = 'http://www.w3.org/1999/xhtml';
-
- /** @var string The user agent for Feed Reader Central's refresh requests */
- private const string USER_AGENT =
- 'FeedReaderCentral/' . FRC_VERSION . ' +https://bitbadger.solutions/open-source/feed-reader-central';
+class Feed
+{
+ // ***** CONSTANTS *****
/** @var int Do not purge items */
- public const int PURGE_NONE = 0;
+ public const PURGE_NONE = 0;
/** @var int Purge all read items (will not purge unread items) */
- public const int PURGE_READ = 1;
+ public const PURGE_READ = 1;
/** @var int Purge items older than the specified number of days */
- public const int PURGE_BY_DAYS = 2;
+ public const PURGE_BY_DAYS = 2;
/** @var int Purge items in number greater than the specified number of items to keep */
- public const int PURGE_BY_COUNT = 3;
+ public const PURGE_BY_COUNT = 3;
/**
- * When parsing XML into a DOMDocument, errors are presented as warnings; this creates an exception for them
+ * Constructor
*
- * @param int $errno The error level encountered
- * @param string $errstr The text of the error encountered
- * @return bool False, to delegate to the next error handler in the chain
- * @throws DOMException If the error is a warning
+ * @param int $id The ID of the feed
+ * @param int $user_id The ID of the user to whom this subscription belongs
+ * @param string $url The URL of the feed
+ * @param string|null $title The title of this feed
+ * @param string|null $updated_on The date/time items in this feed were last updated
+ * @param string|null $checked_on The date/time this feed was last checked
*/
- private static function xmlParseError(int $errno, string $errstr): bool {
- if ($errno == E_WARNING && substr_count($errstr, 'DOMDocument::loadXML()') > 0) {
- throw new DOMException($errstr, $errno);
- }
- return false;
- }
+ public function __construct(public int $id = 0, public int $user_id = 0, public string $url = '',
+ public ?string $title = null, public ?string $updated_on = null,
+ public ?string $checked_on = null) { }
+
+ // ***** STATIC FUNCTIONS *****
/**
- * Parse a feed into an XML tree
+ * Create a document from the parsed feed
*
- * @param string $content The feed's RSS content
- * @return array|DOMDocument[]|string[] ['ok' => feed] if successful, ['error' => message] if not
+ * @param ParsedFeed $parsed The parsed feed
+ * @return static The document constructed from the parsed feed
*/
- public static function parseFeed(string $content): array {
- set_error_handler(self::xmlParseError(...));
- try {
- $feed = new DOMDocument();
- $feed->loadXML($content);
- return ['ok' => $feed];
- } catch (DOMException $ex) {
- return ['error' => $ex->getMessage()];
- } finally {
- restore_error_handler();
- }
- }
-
- /**
- * Get the value of a child element by its tag name for an RSS feed
- *
- * @param DOMNode $element The parent element
- * @param string $tagName The name of the tag whose value should be obtained
- * @return string The value of the element (or "[element] not found" if that element does not exist)
- */
- public static function rssValue(DOMNode $element, string $tagName): string {
- $tags = $element->getElementsByTagName($tagName);
- return $tags->length == 0 ? "$tagName not found" : $tags->item(0)->textContent;
- }
-
- /**
- * Extract items from an RSS feed
- *
- * @param DOMDocument $xml The XML received from the feed
- * @param string $url The actual URL for the feed
- * @return array|Feed[]|string[] ['ok' => feed] if successful, ['error' => message] if not
- */
- private static function fromRSS(DOMDocument $xml, string $url): array {
- $channel = $xml->getElementsByTagName('channel')->item(0);
- if (!($channel instanceof DOMElement)) {
- $type = $channel?->nodeType ?? -1;
- return ['error' => "Channel element not found ($type)"];
- }
-
- // The Atom namespace provides a lastBuildDate, which contains the last time an item in the feed was updated; if
- // that is not present, use the pubDate element instead
- if (($updatedOn = self::rssValue($channel, 'lastBuildDate')) == 'lastBuildDate not found') {
- if (($updatedOn = self::rssValue($channel, 'pubDate')) == 'pubDate not found') {
- $updatedOn = null;
- }
- }
-
- $feed = new Feed();
- $feed->title = self::rssValue($channel, 'title');
- $feed->url = $url;
- $feed->updatedOn = Data::formatDate($updatedOn);
- foreach ($channel->getElementsByTagName('item') as $item) $feed->items[] = FeedItem::fromRSS($item);
-
- return ['ok' => $feed];
- }
-
- /**
- * Get an attribute value from a DOM node
- *
- * @param DOMNode $node The node with an attribute value to obtain
- * @param string $name The name of the attribute whose value should be obtained
- * @return string The attribute value if it exists, an empty string if not
- */
- private static function attrValue(DOMNode $node, string $name): string {
- return ($node->hasAttributes() ? $node->attributes->getNamedItem($name)?->value : null) ?? '';
-
- }
- /**
- * Get the value of a child element by its tag name for an Atom feed
- *
- * (Atom feeds can have type attributes on nearly any value. For our purposes, types "text" and "html" will work as
- * regular string values; for "xhtml", though, we will need to get the `
` and extract its contents instead.)
- *
- * @param DOMNode $element The parent element
- * @param string $tagName The name of the tag whose value should be obtained
- * @return string The value of the element (or "[element] not found" if that element does not exist)
- */
- public static function atomValue(DOMNode $element, string $tagName): string {
- $tags = $element->getElementsByTagName($tagName);
- if ($tags->length == 0) return "$tagName not found";
- $tag = $tags->item(0);
- if (!($tag instanceof DOMElement)) return $tag->textContent;
- if (self::attrValue($tag, 'type') == 'xhtml') {
- $div = $tag->getElementsByTagNameNS(Feed::XHTML_NS, 'div');
- if ($div->length == 0) return "-- invalid XHTML content --";
- return $div->item(0)->textContent;
- }
- return $tag->textContent;
- }
-
- /**
- * Extract items from an Atom feed
- *
- * @param DOMDocument $xml The XML received from the feed
- * @param string $url The actual URL for the feed
- * @return array|Feed[] ['ok' => feed]
- */
- private static function fromAtom(DOMDocument $xml, string $url): array {
- $root = $xml->getElementsByTagNameNS(self::ATOM_NS, 'feed')->item(0);
- if (($updatedOn = self::atomValue($root, 'updated')) == 'pubDate not found') $updatedOn = null;
-
- $feed = new Feed();
- $feed->title = self::atomValue($root, 'title');
- $feed->url = $url;
- $feed->updatedOn = Data::formatDate($updatedOn);
- foreach ($root->getElementsByTagName('entry') as $entry) $feed->items[] = FeedItem::fromAtom($entry);
-
- return ['ok' => $feed];
- }
-
- /**
- * Retrieve a document (http/https)
- *
- * @param string $url The URL of the document to retrieve
- * @return array ['content' => document content, 'error' => error message, 'code' => HTTP response code,
- * 'url' => effective URL]
- */
- private static function retrieveDocument(string $url): array {
- $docReq = curl_init($url);
- curl_setopt($docReq, CURLOPT_FOLLOWLOCATION, true);
- curl_setopt($docReq, CURLOPT_RETURNTRANSFER, true);
- curl_setopt($docReq, CURLOPT_CONNECTTIMEOUT, 5);
- curl_setopt($docReq, CURLOPT_TIMEOUT, 15);
- curl_setopt($docReq, CURLOPT_USERAGENT, self::USER_AGENT);
-
- $result = [
- 'content' => curl_exec($docReq),
- 'error' => curl_error($docReq),
- 'code' => curl_getinfo($docReq, CURLINFO_RESPONSE_CODE),
- 'url' => curl_getinfo($docReq, CURLINFO_EFFECTIVE_URL)
- ];
-
- curl_close($docReq);
- return $result;
- }
-
- /**
- * Derive a feed URL from an HTML document
- *
- * @param string $content The HTML document content from which to derive a feed URL
- * @return array|string[] ['ok' => feed URL] if successful, ['error' => message] if not
- */
- private static function deriveFeedFromHTML(string $content): array {
- $html = new DOMDocument();
- $html->loadHTML(substr($content, 0, strpos($content, '') + 7));
- $headTags = $html->getElementsByTagName('head');
- if ($headTags->length < 1) return ['error' => 'Cannot find feed at this URL'];
- $head = $headTags->item(0);
- foreach ($head->getElementsByTagName('link') as $link) {
- if (self::attrValue($link, 'rel') == 'alternate') {
- $type = self::attrValue($link, 'type');
- if ($type == 'application/rss+xml' || $type == 'application/atom+xml') {
- return ['ok' => self::attrValue($link, 'href')];
- }
- }
- }
- return ['error' => 'Cannot find feed at this URL'];
- }
-
- /**
- * Retrieve the feed
- *
- * @param string $url The URL of the feed to retrieve
- * @return array|Feed[]|string[] ['ok' => feed] if successful, ['error' => message] if not
- */
- public static function retrieveFeed(string $url): array {
- $doc = self::retrieveDocument($url);
-
- if ($doc['error'] != '') return ['error' => $doc['error']];
- if ($doc['code'] != 200) {
- return ['error' => "Prospective feed URL $url returned HTTP Code {$doc['code']}: {$doc['content']}"];
- }
-
- $start = strtolower(strlen($doc['content']) >= 9 ? substr($doc['content'], 0, 9) : $doc['content']);
- if ($start == ' $derivedURL['error']];
- $feedURL = $derivedURL['ok'];
- if (!str_starts_with($feedURL, 'http')) {
- // Relative URL; feed should be retrieved in the context of the original URL
- $original = parse_url($url);
- $port = key_exists('port', $original) ? ":{$original['port']}" : '';
- $feedURL = $original['scheme'] . '://' . $original['host'] . $port . $feedURL;
- }
- $doc = self::retrieveDocument($feedURL);
- }
-
- $parsed = self::parseFeed($doc['content']);
- if (key_exists('error', $parsed)) return ['error' => $parsed['error']];
-
- $extract = $parsed['ok']->getElementsByTagNameNS(self::ATOM_NS, 'feed')->length > 0
- ? self::fromAtom(...) : self::fromRSS(...);
- return $extract($parsed['ok'], $doc['url']);
- }
-
- /**
- * Update a feed item
- *
- * @param int $itemId The ID of the item to be updated
- * @param FeedItem $item The item to be updated
- * @param SQLite3 $db A database connection to use for the update
- * @return bool|SQLite3Result The result if the update is successful, false if it failed
- */
- private static function updateItem(int $itemId, FeedItem $item, SQLite3 $db): bool|SQLite3Result {
- $query = $db->prepare(<<<'SQL'
- UPDATE item
- SET title = :title,
- published_on = :published,
- updated_on = :updated,
- content = :content,
- is_read = 0
- WHERE id = :id
- SQL);
- $query->bindValue(':title', $item->title);
- $query->bindValue(':published', $item->publishedOn);
- $query->bindValue(':updated', $item->updatedOn);
- $query->bindValue(':content', $item->content);
- $query->bindValue(':id', $itemId);
- return $query->execute();
- }
-
- /**
- * Add a feed item
- *
- * @param int $feedId The ID of the feed to which the item should be added
- * @param FeedItem $item The item to be added
- * @param SQLite3 $db A database connection to use for the addition
- * @return bool|SQLite3Result The result if the update is successful, false if it failed
- */
- private static function addItem(int $feedId, FeedItem $item, SQLite3 $db): bool|SQLite3Result {
- $query = $db->prepare(<<<'SQL'
- INSERT INTO item (
- feed_id, item_guid, item_link, title, published_on, updated_on, content
- ) VALUES (
- :feed, :guid, :link, :title, :published, :updated, :content
- )
- SQL);
- $query->bindValue(':feed', $feedId);
- $query->bindValue(':guid', $item->guid);
- $query->bindValue(':link', $item->link);
- $query->bindValue(':title', $item->title);
- $query->bindValue(':published', $item->publishedOn);
- $query->bindValue(':updated', $item->updatedOn);
- $query->bindValue(':content', $item->content);
- return $query->execute();
+ public static function fromParsed(ParsedFeed $parsed): static
+ {
+ return new static(
+ user_id: $_SESSION[Key::USER_ID],
+ url: $parsed->url,
+ title: $parsed->title,
+ updated_on: $parsed->updatedOn,
+ checked_on: Data::formatDate('now'));
}
/**
* Update a feed's items
*
* @param int $feedId The ID of the feed to which these items belong
- * @param Feed $feed The extracted Atom or RSS feed items
+ * @param ParsedFeed $parsed The extracted Atom or RSS feed items
* @param DateTimeInterface $lastChecked When this feed was last checked (only new items will be added)
* @return array ['ok' => true] if successful, ['error' => message] if not
*/
- public static function updateItems(int $feedId, Feed $feed, DateTimeInterface $lastChecked, SQLite3 $db): array {
+ public static function updateItems(int $feedId, ParsedFeed $parsed, DateTimeInterface $lastChecked): array
+ {
$results =
- array_map(function ($item) use ($db, $feedId) {
- $existsQuery = $db->prepare(
- 'SELECT id, published_on, updated_on FROM item WHERE feed_id = :feed AND item_guid = :guid');
- $existsQuery->bindValue(':feed', $feedId);
- $existsQuery->bindValue(':guid', $item->guid);
- if ($exists = $existsQuery->execute()) {
- if ($existing = $exists->fetchArray(SQLITE3_ASSOC)) {
- if ( $existing['published_on'] != $item->publishedOn
- || ($existing['updated_on'] ?? '') != ($item->updatedOn ?? '')) {
- if (!self::updateItem($existing['id'], $item, $db)) return Data::error($db);
+ array_map(function ($item) use ($feedId) {
+ try {
+ $existing = Find::firstByFields(Table::ITEM,
+ [Field::EQ('feed_id', $feedId), Field::EQ('item_guid', $item->guid)], Item::class);
+ if ($existing) {
+ if ($existing->published_on != $item->publishedOn
+ || ($existing->updated_on != ($item->updatedOn ?? ''))) {
+ Item::update($existing->id, $item);
}
} else {
- if (!self::addItem($feedId, $item, $db)) return Data::error($db);
+ Item::add($feedId, $item);
}
- } else {
- return Data::error($db);
+ return ['ok' => true];
+ } catch (DocumentException $ex) {
+ return ['error' => "$ex"];
}
- return ['ok' => true];
- }, array_filter($feed->items,
+ }, array_filter($parsed->items,
fn($it) => date_create_immutable($it->updatedOn ?? $it->publishedOn) >= $lastChecked));
$errors = array_map(fn($it) => $it['error'], array_filter($results, fn($it) => array_key_exists('error', $it)));
return sizeof($errors) > 0 ? ['error' => implode("\n", $errors)] : ['ok' => true];
@@ -348,33 +95,41 @@ class Feed {
* Purge items for a feed
*
* @param int $feedId The ID of the feed to be purged
- * @param SQLite3 $db The database connection on which items should be purged
* @return array|string[]|true[] ['ok' => true] if purging was successful, ['error' => message] if not
*/
- private static function purgeItems(int $feedId, SQLite3 $db): array {
+ private static function purgeItems(int $feedId): array
+ {
if (!array_search(PURGE_TYPE, [self::PURGE_READ, self::PURGE_BY_DAYS, self::PURGE_BY_COUNT])) {
return ['error' => 'Unrecognized purge type ' . PURGE_TYPE];
}
+ $fields = [Field::EQ('feed_id', $feedId, ':feed'), Data::bookmarkField(false)];
+ $sql = Query\Delete::byFields(Table::ITEM, $fields);
+ if (PURGE_TYPE == self::PURGE_READ) {
+ $readField = Field::EQ('is_read', 1, ':read');
+ $fields[] = $readField;
+ $sql .= ' AND ' . Query::whereByFields([$readField]);
+ } elseif (PURGE_TYPE == self::PURGE_BY_DAYS) {
+ $fields[] = Field::EQ('', Data::formatDate('-' . PURGE_NUMBER . ' day'), ':oldest');
+ $sql .= " AND date(coalesce(data->>'updated_on', data->>'published_on')) < date(:oldest)";
+ } elseif (PURGE_TYPE == self::PURGE_BY_COUNT) {
+ $fields[] = Field::EQ('', PURGE_NUMBER, ':keep');
+ $id = Configuration::$idField;
+ $table = Table::ITEM;
+ $sql .= ' ' . <<>'$id' IN (
+ SELECT data->>'$id' FROM $table
+ WHERE data->>'feed_id' = :feed
+ ORDER BY date(coalesce(data->>'updated_on', data->>'published_on')) DESC
+ LIMIT -1 OFFSET :keep
+ )
+ SQL;
+ }
try {
- $sql = match (PURGE_TYPE) {
- self::PURGE_READ => 'AND is_read = 1',
- self::PURGE_BY_DAYS => 'AND date(coalesce(updated_on, published_on)) < date(:oldest)',
- self::PURGE_BY_COUNT => 'AND id IN (SELECT id FROM item WHERE feed_id = :feed
- ORDER BY date(coalesce(updated_on, published_on)) DESC
- LIMIT -1 OFFSET :keep)'
- };
-
- $purge = $db->prepare("DELETE FROM item WHERE feed_id = :feed AND is_bookmarked = 0 $sql");
- $purge->bindValue(':feed', $feedId);
- if (PURGE_TYPE == self::PURGE_BY_DAYS) {
- $purge->bindValue(':oldest', Data::formatDate('-' . PURGE_NUMBER . ' day'));
- } elseif (PURGE_TYPE == self::PURGE_BY_COUNT) {
- $purge->bindValue(':keep', PURGE_NUMBER);
- }
- return $purge->execute() ? ['ok' => true] : Data::error($db);
- } catch (Exception $ex) {
- return ['error' => $ex->getMessage()];
+ Custom::nonQuery($sql, Parameters::addFields($fields, []));
+ return ['ok' => true];
+ } catch (DocumentException $ex) {
+ return ['error' => "$ex"];
}
}
@@ -383,41 +138,34 @@ class Feed {
*
* @param int $feedId The ID of the feed to be refreshed
* @param string $url The URL of the feed to be refreshed
- * @param SQLite3 $db A database connection to use to refresh the feed
* @return array|string[]|true[] ['ok' => true] if successful, ['error' => message] if not
*/
- public static function refreshFeed(int $feedId, string $url, SQLite3 $db): array {
- $feedRetrieval = self::retrieveFeed($url);
+ public static function refreshFeed(int $feedId, string $url): array
+ {
+ $feedRetrieval = ParsedFeed::retrieve($url);
if (key_exists('error', $feedRetrieval)) return $feedRetrieval;
$feed = $feedRetrieval['ok'];
- $lastCheckedQuery = $db->prepare('SELECT checked_on FROM feed where id = :id');
- $lastCheckedQuery->bindValue(':id', $feedId);
- if (!($lastCheckedResult = $lastCheckedQuery->execute())) return Data::error($db);
- if (!($lastChecked = date_create_immutable($lastCheckedResult->fetchArray(SQLITE3_NUM)[0] ?? WWW_EPOCH))) {
- return ['error' => 'Could not derive date last checked for feed'];
+ try {
+ $feedDoc = Find::byId(Table::FEED, $feedId, self::class);
+ if (!$feedDoc) return ['error' => 'Could not derive date last checked for feed'];
+ $lastChecked = date_create_immutable($feedDoc->checked_on ?? WWW_EPOCH);
+
+ $itemUpdate = self::updateItems($feedId, $feed, $lastChecked);
+ if (key_exists('error', $itemUpdate)) return $itemUpdate;
+
+ $patch = [
+ 'title' => $feed->title,
+ 'updated_on' => $feed->updatedOn,
+ 'checked_on' => Data::formatDate('now')
+ ];
+ if ($url == $feed->url) $patch['url'] = $feed->url;
+ Patch::byId(Table::FEED, $feedId, $patch);
+ } catch (DocumentException $ex) {
+ return ['error' => "$ex"];
}
- $itemUpdate = self::updateItems($feedId, $feed, $lastChecked, $db);
- if (key_exists('error', $itemUpdate)) return $itemUpdate;
-
- $urlUpdate = $url == $feed->url ? '' : ', url = :url';
- $feedUpdate = $db->prepare(<<bindValue(':title', $feed->title);
- $feedUpdate->bindValue(':updated', $feed->updatedOn);
- $feedUpdate->bindValue(':checked', Data::formatDate('now'));
- $feedUpdate->bindValue(':id', $feedId);
- if ($urlUpdate != '') $feedUpdate->bindValue(':url', $feed->url);
- if (!$feedUpdate->execute()) return Data::error($db);
-
- return PURGE_TYPE == self::PURGE_NONE ? ['ok' => true] : self::purgeItems($feedId, $db);
+ return PURGE_TYPE == self::PURGE_NONE ? ['ok' => true] : self::purgeItems($feedId);
}
/**
@@ -426,91 +174,86 @@ class Feed {
* @param string $url The URL of the RSS feed to add
* @return array ['ok' => feedId] if successful, ['error' => message] if not
*/
- public static function add(string $url, SQLite3 $db): array {
- $feedExtract = self::retrieveFeed($url);
+ public static function add(string $url): array
+ {
+ $feedExtract = ParsedFeed::retrieve($url);
if (key_exists('error', $feedExtract)) return $feedExtract;
$feed = $feedExtract['ok'];
- $existsQuery = $db->prepare('SELECT COUNT(*) FROM feed WHERE user_id = :user AND url = :url');
- $existsQuery->bindValue(':user', $_SESSION[Key::USER_ID]);
- $existsQuery->bindValue(':url', $feed->url);
- if (!($exists = $existsQuery->execute())) return Data::error($db);
- if ($exists->fetchArray(SQLITE3_NUM)[0] > 0) return ['error' => "Already subscribed to feed $feed->url"];
+ try {
+ $fields = [Field::EQ('user_id', $_SESSION[Key::USER_ID]), Field::EQ('url', $feed->url)];
+ if (Exists::byFields(Table::FEED, $fields)) {
+ return ['error' => "Already subscribed to feed $feed->url"];
+ }
- $query = $db->prepare(<<<'SQL'
- INSERT INTO feed (
- user_id, url, title, updated_on, checked_on
- ) VALUES (
- :user, :url, :title, :updated, :checked
- )
- SQL);
- $query->bindValue(':user', $_SESSION[Key::USER_ID]);
- $query->bindValue(':url', $feed->url);
- $query->bindValue(':title', $feed->title);
- $query->bindValue(':updated', $feed->updatedOn);
- $query->bindValue(':checked', Data::formatDate('now'));
- if (!$query->execute()) return Data::error($db);
+ Document::insert(Table::FEED, self::fromParsed($feed));
- $feedId = $db->lastInsertRowID();
- $result = self::updateItems($feedId, $feed, date_create_immutable(WWW_EPOCH), $db);
- if (key_exists('error', $result)) return $result;
+ $doc = Find::firstByFields(Table::FEED, $fields, static::class);
+ if (!$doc) return ['error' => 'Could not retrieve inserted feed'];
- return ['ok' => $feedId];
+ $result = self::updateItems($doc->id, $feed, date_create_immutable(WWW_EPOCH));
+ if (key_exists('error', $result)) return $result;
+
+ return ['ok' => $doc->id];
+ } catch (DocumentException $ex) {
+ return ['error' => "$ex"];
+ }
}
/**
* Update an RSS feed
*
- * @param array $existing The existing RSS feed
+ * @param Feed $existing The existing feed
* @param string $url The URL with which the existing feed should be modified
- * @param SQLite3 $db The database connection on which to execute the update
* @return bool[]|string[] [ 'ok' => true ] if successful, [ 'error' => message ] if not
*/
- public static function update(array $existing, string $url, SQLite3 $db): array {
- $query = $db->prepare('UPDATE feed SET url = :url WHERE id = :id AND user_id = :user');
- $query->bindValue(':url', $url);
- $query->bindValue(':id', $existing['id']);
- $query->bindValue(':user', $_SESSION[Key::USER_ID]);
- if (!$query->execute()) return Data::error($db);
+ public static function update(Feed $existing, string $url): array
+ {
+ try {
+ Patch::byFields(Table::FEED,
+ [Field::EQ(Configuration::$idField, $existing->id), Field::EQ('user_id', $_SESSION[Key::USER_ID])],
+ ['url' => $url]);
- return self::refreshFeed($existing['id'], $url, $db);
+ return self::refreshFeed($existing->id, $url);
+ } catch (DocumentException $ex) {
+ return ['error' => "$ex"];
+ }
}
/**
* Retrieve all feeds, optionally for a specific user
*
- * @param SQLite3 $db The database connection to use to retrieve the feeds
* @param int $user The ID of the user whose feeds should be retrieved (optional, defaults to all feeds)
- * @return array An array of arrays with ['id', 'url', 'email'] keys
+ * @return DocumentList A list of feeds
+ * @throws DocumentException If any is encountered
*/
- public static function retrieveAll(SQLite3 $db, int $user = 0): array {
- $extraSQL = $user > 0 ? ' WHERE u.id = :user' : '';
- $query = $db->prepare(
- "SELECT f.id, f.url, u.email FROM feed f INNER JOIN frc_user u ON u.id = f.user_id$extraSQL");
- if ($user > 0) $query->bindValue(':user', $user);
- if (!($result = $query->execute())) return Data::error($db);
- $feeds = [];
- while ($feed = $result->fetchArray(SQLITE3_ASSOC)) $feeds[] = $feed;
- return $feeds;
+ public static function retrieveAll(int $user = 0): DocumentList
+ {
+ return $user == 0
+ ? Find::all(Table::FEED, self::class)
+ : Find::byFields(Table::FEED, [Field::EQ('user_id', $user)], self::class);
}
/**
* Refresh all feeds
*
- * @param SQLite3 $db The database connection to use for refreshing feeds
* @return array|true[]|string[] ['ok' => true] if successful,
* ['error' => message] if not (may have multiple error lines)
*/
- public static function refreshAll(SQLite3 $db): array {
- $feeds = self::retrieveAll($db, $_SESSION[Key::USER_ID]);
- if (key_exists('error', $feeds)) return $feeds;
+ public static function refreshAll(): array
+ {
+ try {
+ $feeds = self::retrieveAll($_SESSION[Key::USER_ID]);
- $errors = [];
- array_walk($feeds, function ($feed) use ($db, &$errors) {
- $result = self::refreshFeed($feed['id'], $feed['url'], $db);
- if (key_exists('error', $result)) $errors[] = $result['error'];
- });
+ $errors = [];
+ foreach ($feeds->items() as $feed) {
+ $result = self::refreshFeed($feed->id, $feed->url);
+ if (key_exists('error', $result)) $errors[] = $result['error'];
+ }
+ } catch (DocumentException $ex) {
+ return ['error' => "$ex"];
+ }
return sizeof($errors) == 0 ? ['ok' => true] : ['error' => implode("\n", $errors)];
}
@@ -519,13 +262,12 @@ class Feed {
* Retrieve a feed by its ID for the current user
*
* @param int $feedId The ID of the feed to retrieve
- * @param SQLite3 $db A database connection to use to retrieve the feed
- * @return array|bool The data for the feed if found, false if not found
+ * @return static|false The data for the feed if found, false if not found
+ * @throws DocumentException If any is encountered
*/
- public static function retrieveById(int $feedId, SQLite3 $db): array|bool {
- $query = $db->prepare('SELECT * FROM feed WHERE id = :id AND user_id = :user');
- $query->bindValue(':id', $feedId);
- $query->bindValue(':user', $_SESSION[Key::USER_ID]);
- return ($result = $query->execute()) ? $result->fetchArray(SQLITE3_ASSOC) : false;
+ public static function retrieveById(int $feedId): static|false
+ {
+ $doc = Find::byId(Table::FEED, $feedId, static::class);
+ return $doc && $doc->user_id == $_SESSION[Key::USER_ID] ? $doc : false;
}
}
diff --git a/src/lib/FeedItem.php b/src/lib/FeedItem.php
deleted file mode 100644
index ab6aa04..0000000
--- a/src/lib/FeedItem.php
+++ /dev/null
@@ -1,80 +0,0 @@
-` tag
- *
- * @param DOMNode $node The XML node from which a feed item should be constructed
- * @return FeedItem A feed item constructed from the given node
- */
- public static function fromAtom(DOMNode $node): FeedItem {
- $guid = Feed::atomValue($node, 'id');
- $link = '';
- foreach ($node->getElementsByTagName('link') as $linkElt) {
- if ($linkElt->hasAttributes()) {
- $relAttr = $linkElt->attributes->getNamedItem('rel');
- if ($relAttr && $relAttr->value == 'alternate') {
- $link = $linkElt->attributes->getNamedItem('href')->value;
- break;
- }
- }
- }
- if ($link == '' && str_starts_with($guid, 'http')) $link = $guid;
-
- $item = new FeedItem();
- $item->guid = $guid;
- $item->title = Feed::atomValue($node, 'title');
- $item->link = $link;
- $item->publishedOn = Data::formatDate(Feed::atomValue($node, 'published'));
- $item->updatedOn = Data::formatDate(Feed::atomValue($node, 'updated'));
- $item->content = Feed::atomValue($node, 'content');
-
- return $item;
- }
-
- /**
- * Construct a feed item from an RSS feed's `` tag
- *
- * @param DOMNode $node The XML node from which a feed item should be constructed
- * @return FeedItem A feed item constructed from the given node
- */
- public static function fromRSS(DOMNode $node): FeedItem {
- $itemGuid = Feed::rssValue($node, 'guid');
- $updNodes = $node->getElementsByTagNameNS(Feed::ATOM_NS, 'updated');
- $encNodes = $node->getElementsByTagNameNS(Feed::CONTENT_NS, 'encoded');
-
- $item = new FeedItem();
- $item->guid = $itemGuid == 'guid not found' ? Feed::rssValue($node, 'link') : $itemGuid;
- $item->title = Feed::rssValue($node, 'title');
- $item->link = Feed::rssValue($node, 'link');
- $item->publishedOn = Data::formatDate(Feed::rssValue($node, 'pubDate'));
- $item->updatedOn = Data::formatDate($updNodes->length > 0 ? $updNodes->item(0)->textContent : null);
- $item->content = $encNodes->length > 0
- ? $encNodes->item(0)->textContent
- : Feed::rssValue($node, 'description');
-
- return $item;
- }
-}
diff --git a/src/lib/Item.php b/src/lib/Item.php
new file mode 100644
index 0000000..757d1cb
--- /dev/null
+++ b/src/lib/Item.php
@@ -0,0 +1,87 @@
+is_read <> 0;
+ }
+
+ /**
+ * Is the item bookmarked?
+ *
+ * @return bool True if the item is bookmarked, false if not
+ */
+ public function isBookmarked(): bool
+ {
+ return $this->is_bookmarked <> 0;
+ }
+
+ /**
+ * Add an item
+ *
+ * @param int $feedId The ID of the feed to which the item belongs
+ * @param ParsedItem $parsed The parsed item from the feed XML
+ * @throws DocumentException If any is encountered
+ */
+ public static function add(int $feedId, ParsedItem $parsed): void
+ {
+ Document::insert(Table::ITEM, new static(
+ feed_id: $feedId,
+ title: $parsed->title,
+ item_guid: $parsed->guid,
+ item_link: $parsed->link,
+ published_on: $parsed->publishedOn,
+ updated_on: $parsed->updatedOn,
+ content: $parsed->content));
+ }
+
+ /**
+ * Update an item
+ *
+ * @param int $id The ID of the item to be updated
+ * @param ParsedItem $parsed The parsed item from the feed XML
+ * @throws DocumentException If any is encountered
+ */
+ public static function update(int $id, ParsedItem $parsed): void
+ {
+ Patch::byId(Table::ITEM, $id, [
+ 'title' => $parsed->title,
+ 'published_on' => $parsed->publishedOn,
+ 'updated_on' => $parsed->updatedOn,
+ 'content' => $parsed->content,
+ 'is_read' => 0
+ ]);
+ }
+}
diff --git a/src/lib/ItemList.php b/src/lib/ItemList.php
index 2f9866b..0a4292a 100644
--- a/src/lib/ItemList.php
+++ b/src/lib/ItemList.php
@@ -1,14 +1,19 @@
- The items matching the criteria, lazily iterable */
+ private DocumentList $dbList;
/** @var string The error message generated by executing a query */
public string $error = '';
@@ -18,7 +23,8 @@ class ItemList {
*
* @return bool True if there is an error condition associated with this list, false if not
*/
- public function isError(): bool {
+ public function isError(): bool
+ {
return $this->error != '';
}
@@ -34,51 +40,34 @@ class ItemList {
/**
* Constructor
*
- * @param SQLite3 $db The database connection (used to retrieve error information if the query fails)
- * @param SQLite3Stmt $query The query to retrieve the items for this list
* @param string $itemType The type of item being displayed (unread, bookmark, etc.)
* @param string $returnURL The URL to which the item page should return once the item has been viewed
+ * @param array|Field[] $fields The fields to use to restrict the results
+ * @param string $searchWhere Additional WHERE clause to use for searching
*/
- private function __construct(SQLite3 $db, SQLite3Stmt $query, public string $itemType, public string $returnURL = '') {
- $result = $query->execute();
- if (!$result) {
- $this->error = Data::error($db)['error'];
- } else {
- $this->items = $result;
+ private function __construct(public string $itemType, public string $returnURL = '', array $fields = [],
+ string $searchWhere = '')
+ {
+ $allFields = [Data::userIdField(Table::FEED), ...$fields];
+ try {
+ $this->dbList = Custom::list(
+ ItemWithFeed::SELECT_WITH_FEED . ' WHERE '
+ . Query::whereByFields(array_filter($allFields, fn($it) => $it->paramName <> ':search'))
+ . "$searchWhere ORDER BY coalesce(item.data->>'updated_on', item.data->>'published_on') DESC",
+ Parameters::addFields($allFields, []), new DocumentMapper(ItemWithFeed::class));
+ } catch (DocumentException $ex) {
+ $this->error = "$ex";
}
}
- /**
- * Create an item list query
- *
- * @param SQLite3 $db The database connection to use to obtain items
- * @param array $criteria One or more SQL WHERE conditions (will be combined with AND)
- * @param array $parameters Parameters to be added to the query (key index 0, value index 1; optional)
- * @return SQLite3Stmt The query, ready to be executed
- */
- private static function makeQuery(SQLite3 $db, array $criteria, array $parameters = []): SQLite3Stmt {
- $where = empty($criteria) ? '' : 'AND ' . implode(' AND ', $criteria);
- $sql = <<prepare($sql);
- $query->bindValue(':userId', $_SESSION[Key::USER_ID]);
- foreach ($parameters as $param) $query->bindValue($param[0], $param[1]);
- return $query;
- }
-
/**
* Create an item list with all the current user's bookmarked items
*
- * @param SQLite3 $db The database connection to use to obtain items
* @return static An item list with all bookmarked items
*/
- public static function allBookmarked(SQLite3 $db): static {
- $list = new static($db, self::makeQuery($db, ['item.is_bookmarked = 1']), 'Bookmarked', '/?bookmarked');
+ public static function allBookmarked(): static
+ {
+ $list = new static('Bookmarked', '/?bookmarked', [Data::bookmarkField(qualifier: Table::ITEM)]);
$list->linkFeed = true;
return $list;
}
@@ -86,11 +75,11 @@ class ItemList {
/**
* Create an item list with all the current user's unread items
*
- * @param SQLite3 $db The database connection to use to obtain items
* @return static An item list with all unread items
*/
- public static function allUnread(SQLite3 $db): static {
- $list = new static($db, self::makeQuery($db, ['item.is_read = 0']), 'Unread');
+ public static function allUnread(): static
+ {
+ $list = new static('Unread', fields: [Data::unreadField(Table::ITEM)]);
$list->linkFeed = true;
return $list;
}
@@ -99,12 +88,11 @@ class ItemList {
* Create an item list with all items for the given feed
*
* @param int $feedId The ID of the feed for which items should be retrieved
- * @param SQLite3 $db The database connection to use to obtain items
* @return static An item list with all items for the given feed
*/
- public static function allForFeed(int $feedId, SQLite3 $db): static {
- $list = new static($db, self::makeQuery($db, ['feed.id = :feed'], [[':feed', $feedId]]), '',
- "/feed/items?id=$feedId");
+ public static function allForFeed(int $feedId): static
+ {
+ $list = new static('', "/feed/items?id=$feedId", [Data::feedField($feedId, Table::FEED)]);
$list->showIndicators = true;
return $list;
}
@@ -113,25 +101,24 @@ class ItemList {
* Create an item list with unread items for the given feed
*
* @param int $feedId The ID of the feed for which items should be retrieved
- * @param SQLite3 $db The database connection to use to obtain items
* @return static An item list with unread items for the given feed
*/
- public static function unreadForFeed(int $feedId, SQLite3 $db): static {
- return new static($db, self::makeQuery($db, ['feed.id = :feed', 'item.is_read = 0'], [[':feed', $feedId]]),
- 'Unread', "/feed/items?id=$feedId&unread");
+ public static function unreadForFeed(int $feedId): static
+ {
+ return new static('Unread', "/feed/items?id=$feedId&unread",
+ [Data::feedField($feedId, Table::FEED), Data::unreadField(Table::ITEM)]);
}
/**
* Create an item list with bookmarked items for the given feed
*
* @param int $feedId The ID of the feed for which items should be retrieved
- * @param SQLite3 $db The database connection to use to obtain items
* @return static An item list with bookmarked items for the given feed
*/
- public static function bookmarkedForFeed(int $feedId, SQLite3 $db): static {
- return new static($db,
- self::makeQuery($db, ['feed.id = :feed', 'item.is_bookmarked = 1'], [[':feed', $feedId]]), 'Bookmarked',
- "/feed/items?id=$feedId&bookmarked");
+ public static function bookmarkedForFeed(int $feedId): static
+ {
+ return new static('Bookmarked', "/feed/items?id=$feedId&bookmarked",
+ [Data::feedField($feedId, Table::FEED), Data::bookmarkField(qualifier: Table::ITEM)]);
}
/**
@@ -139,15 +126,16 @@ class ItemList {
*
* @param string $search The item search terms / query
* @param bool $isBookmarked Whether to restrict the search to bookmarked items
- * @param SQLite3 $db The database connection to use to obtain items
* @return static An item list match the given search terms
*/
- public static function matchingSearch(string $search, bool $isBookmarked, SQLite3 $db): static {
- $where = $isBookmarked ? ['item.is_bookmarked = 1'] : [];
- $where[] = 'item.id IN (SELECT ROWID FROM item_search WHERE content MATCH :search)';
- $list = new static($db, self::makeQuery($db, $where, [[':search', $search]]),
- 'Matching' . ($isBookmarked ? ' Bookmarked' : ''),
- "/search?search=$search&items=" . ($isBookmarked ? 'bookmarked' : 'all'));
+ public static function matchingSearch(string $search, bool $isBookmarked): static
+ {
+ $fields = [Field::EQ('content', $search, ':search')];
+ if ($isBookmarked) $fields[] = Data::bookmarkField(qualifier: Table::ITEM);
+ $list = new static('Matching' . ($isBookmarked ? ' Bookmarked' : ''),
+ "/search?search=$search&items=" . ($isBookmarked ? 'bookmarked' : 'all'), $fields,
+ ' AND ' . Table::ITEM . ".data->>'" . Configuration::$idField . "' IN "
+ . '(SELECT ROWID FROM item_search WHERE content MATCH :search)');
$list->showIndicators = true;
$list->displayFeed = true;
return $list;
@@ -156,35 +144,33 @@ class ItemList {
/**
* Render this item list
*/
- public function render(): void {
- if ($this->isError()) { ?>
-
There are no =strtolower($this->itemType)?> itemsThere are no ' . strtolower($this->itemType) . ' items';
}
echo '
';
}
diff --git a/src/lib/ItemWithFeed.php b/src/lib/ItemWithFeed.php
new file mode 100644
index 0000000..602626b
--- /dev/null
+++ b/src/lib/ItemWithFeed.php
@@ -0,0 +1,67 @@
+>'feed_id' = " . Table::FEED . ".data->>'id'";
+
+ /** @var string The `SELECT` clause to add the feed as a property to the item's document */
+ public const SELECT_WITH_FEED =
+ 'SELECT json_set(' . Table::ITEM . ".data, '$.feed', json(" . Table::FEED . '.data)) AS data FROM '
+ . self::FROM_WITH_JOIN;
+
+ /** @var Feed The feed to which this item belongs */
+ public Feed $feed;
+
+ /**
+ * Create JSON comparison fields to retrieve items while also checking the owning user
+ *
+ * @param int $id The ID of the item being retrieved
+ * @return array|Field[] The fields for item ID and user ID
+ */
+ private static function idAndUserFields(int $id): array
+ {
+ $idField = Field::EQ(Configuration::$idField, $id, ':id');
+ $idField->qualifier = Table::ITEM;
+ $userField = Field::EQ('user_id', $_SESSION[Key::USER_ID], ':user');
+ $userField->qualifier = Table::FEED;
+ return [$idField, $userField];
+ }
+
+ /**
+ * Check an item's existence via its ID
+ *
+ * @param int $id The ID of the item whose existence should be checked
+ * @return bool True if the item exists for the current user, false if not
+ * @throws DocumentException If any is encountered
+ */
+ public static function existsById(int $id): bool
+ {
+ $fields = self::idAndUserFields($id);
+ return Custom::scalar(Query\Exists::query(self::FROM_WITH_JOIN, Query::whereByFields($fields)),
+ Parameters::addFields($fields, []), new ExistsMapper());
+ }
+
+ /**
+ * Retrieve an item via its ID
+ *
+ * @param int $id The ID of the item to be retrieved
+ * @return ItemWithFeed|false The item if it is found, false if not
+ * @throws DocumentException If any is encountered
+ */
+ public static function retrieveById(int $id): ItemWithFeed|false
+ {
+ $fields = self::idAndUserFields($id);
+ return Custom::single(self::SELECT_WITH_FEED . ' WHERE ' . Query::whereByFields($fields),
+ Parameters::addFields($fields, []), new DocumentMapper(self::class));
+ }
+}
diff --git a/src/lib/Key.php b/src/lib/Key.php
index e1bf82a..d68ba38 100644
--- a/src/lib/Key.php
+++ b/src/lib/Key.php
@@ -1,13 +1,18 @@
-` tag that allows HTML content in a feed */
+ public const CONTENT_NS = 'http://purl.org/rss/1.0/modules/content/';
+
+ /** @var string The XML namespace for XHTML */
+ public const XHTML_NS = 'http://www.w3.org/1999/xhtml';
+
+ /** @var string The user agent for Feed Reader Central's refresh requests */
+ private const USER_AGENT =
+ 'FeedReaderCentral/' . FRC_VERSION . ' +https://bitbadger.solutions/open-source/feed-reader-central';
+
+ /**
+ * When parsing XML into a DOMDocument, errors are presented as warnings; this creates an exception for them
+ *
+ * @param int $errno The error level encountered
+ * @param string $errstr The text of the error encountered
+ * @return bool False, to delegate to the next error handler in the chain
+ * @throws DOMException If the error is a warning
+ */
+ private static function xmlParseError(int $errno, string $errstr): bool {
+ if ($errno == E_WARNING && substr_count($errstr, 'DOMDocument::loadXML()') > 0) {
+ throw new DOMException($errstr, $errno);
+ }
+ return false;
+ }
+
+ /**
+ * Parse a feed into an XML tree
+ *
+ * @param string $content The feed's RSS content
+ * @return array|DOMDocument[]|string[] ['ok' => feed] if successful, ['error' => message] if not
+ */
+ public static function parseFeed(string $content): array {
+ set_error_handler(self::xmlParseError(...));
+ try {
+ $feed = new DOMDocument();
+ $feed->loadXML($content);
+ return ['ok' => $feed];
+ } catch (DOMException $ex) {
+ return ['error' => $ex->getMessage()];
+ } finally {
+ restore_error_handler();
+ }
+ }
+
+ /**
+ * Get the value of a child element by its tag name for an RSS feed
+ *
+ * @param DOMNode $element The parent element
+ * @param string $tagName The name of the tag whose value should be obtained
+ * @return string The value of the element (or "[element] not found" if that element does not exist)
+ */
+ public static function rssValue(DOMNode $element, string $tagName): string {
+ $tags = $element->getElementsByTagName($tagName);
+ return $tags->length == 0 ? "$tagName not found" : $tags->item(0)->textContent;
+ }
+
+ /**
+ * Extract items from an RSS feed
+ *
+ * @param DOMDocument $xml The XML received from the feed
+ * @param string $url The actual URL for the feed
+ * @return array|Feed[]|string[] ['ok' => feed] if successful, ['error' => message] if not
+ */
+ private static function fromRSS(DOMDocument $xml, string $url): array {
+ $channel = $xml->getElementsByTagName('channel')->item(0);
+ if (!($channel instanceof DOMElement)) {
+ $type = $channel?->nodeType ?? -1;
+ return ['error' => "Channel element not found ($type)"];
+ }
+
+ // The Atom namespace provides a lastBuildDate, which contains the last time an item in the feed was updated; if
+ // that is not present, use the pubDate element instead
+ if (($updatedOn = self::rssValue($channel, 'lastBuildDate')) == 'lastBuildDate not found') {
+ if (($updatedOn = self::rssValue($channel, 'pubDate')) == 'pubDate not found') {
+ $updatedOn = null;
+ }
+ }
+
+ $feed = new static();
+ $feed->title = self::rssValue($channel, 'title');
+ $feed->url = $url;
+ $feed->updatedOn = Data::formatDate($updatedOn);
+ foreach ($channel->getElementsByTagName('item') as $item) $feed->items[] = ParsedItem::fromRSS($item);
+
+ return ['ok' => $feed];
+ }
+
+ /**
+ * Get an attribute value from a DOM node
+ *
+ * @param DOMNode $node The node with an attribute value to obtain
+ * @param string $name The name of the attribute whose value should be obtained
+ * @return string The attribute value if it exists, an empty string if not
+ */
+ private static function attrValue(DOMNode $node, string $name): string {
+ return ($node->hasAttributes() ? $node->attributes->getNamedItem($name)?->value : null) ?? '';
+
+ }
+ /**
+ * Get the value of a child element by its tag name for an Atom feed
+ *
+ * (Atom feeds can have type attributes on nearly any value. For our purposes, types "text" and "html" will work as
+ * regular string values; for "xhtml", though, we will need to get the `
` and extract its contents instead.)
+ *
+ * @param DOMNode $element The parent element
+ * @param string $tagName The name of the tag whose value should be obtained
+ * @return string The value of the element (or "[element] not found" if that element does not exist)
+ */
+ public static function atomValue(DOMNode $element, string $tagName): string {
+ $tags = $element->getElementsByTagName($tagName);
+ if ($tags->length == 0) return "$tagName not found";
+ $tag = $tags->item(0);
+ if (!($tag instanceof DOMElement)) return $tag->textContent;
+ if (self::attrValue($tag, 'type') == 'xhtml') {
+ $div = $tag->getElementsByTagNameNS(self::XHTML_NS, 'div');
+ if ($div->length == 0) return "-- invalid XHTML content --";
+ return $div->item(0)->textContent;
+ }
+ return $tag->textContent;
+ }
+
+ /**
+ * Extract items from an Atom feed
+ *
+ * @param DOMDocument $xml The XML received from the feed
+ * @param string $url The actual URL for the feed
+ * @return array|Feed[] ['ok' => feed]
+ */
+ private static function fromAtom(DOMDocument $xml, string $url): array {
+ $root = $xml->getElementsByTagNameNS(self::ATOM_NS, 'feed')->item(0);
+ if (($updatedOn = self::atomValue($root, 'updated')) == 'pubDate not found') $updatedOn = null;
+
+ $feed = new static();
+ $feed->title = self::atomValue($root, 'title');
+ $feed->url = $url;
+ $feed->updatedOn = Data::formatDate($updatedOn);
+ foreach ($root->getElementsByTagName('entry') as $entry) $feed->items[] = ParsedItem::fromAtom($entry);
+
+ return ['ok' => $feed];
+ }
+
+ /**
+ * Retrieve a document (http/https)
+ *
+ * @param string $url The URL of the document to retrieve
+ * @return array ['content' => document content, 'error' => error message, 'code' => HTTP response code,
+ * 'url' => effective URL]
+ */
+ private static function retrieveDocument(string $url): array {
+ $docReq = curl_init($url);
+ curl_setopt($docReq, CURLOPT_FOLLOWLOCATION, true);
+ curl_setopt($docReq, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($docReq, CURLOPT_CONNECTTIMEOUT, 5);
+ curl_setopt($docReq, CURLOPT_TIMEOUT, 15);
+ curl_setopt($docReq, CURLOPT_USERAGENT, self::USER_AGENT);
+
+ $result = [
+ 'content' => curl_exec($docReq),
+ 'error' => curl_error($docReq),
+ 'code' => curl_getinfo($docReq, CURLINFO_RESPONSE_CODE),
+ 'url' => curl_getinfo($docReq, CURLINFO_EFFECTIVE_URL)
+ ];
+
+ curl_close($docReq);
+ return $result;
+ }
+
+ /**
+ * Derive a feed URL from an HTML document
+ *
+ * @param string $content The HTML document content from which to derive a feed URL
+ * @return array|string[] ['ok' => feed URL] if successful, ['error' => message] if not
+ */
+ private static function deriveFeedFromHTML(string $content): array {
+ $html = new DOMDocument();
+ $html->loadHTML(substr($content, 0, strpos($content, '') + 7));
+ $headTags = $html->getElementsByTagName('head');
+ if ($headTags->length < 1) return ['error' => 'Cannot find feed at this URL'];
+ $head = $headTags->item(0);
+ foreach ($head->getElementsByTagName('link') as $link) {
+ if (self::attrValue($link, 'rel') == 'alternate') {
+ $type = self::attrValue($link, 'type');
+ if ($type == 'application/rss+xml' || $type == 'application/atom+xml') {
+ return ['ok' => self::attrValue($link, 'href')];
+ }
+ }
+ }
+ return ['error' => 'Cannot find feed at this URL'];
+ }
+
+ /**
+ * Retrieve the feed
+ *
+ * @param string $url The URL of the feed to retrieve
+ * @return array|ParsedFeed[]|string[] ['ok' => feed] if successful, ['error' => message] if not
+ */
+ public static function retrieve(string $url): array {
+ $doc = self::retrieveDocument($url);
+
+ if ($doc['error'] != '') return ['error' => $doc['error']];
+ if ($doc['code'] != 200) {
+ return ['error' => "Prospective feed URL $url returned HTTP Code {$doc['code']}: {$doc['content']}"];
+ }
+
+ $start = strtolower(strlen($doc['content']) >= 9 ? substr($doc['content'], 0, 9) : $doc['content']);
+ if ($start == ' $derivedURL['error']];
+ $feedURL = $derivedURL['ok'];
+ if (!str_starts_with($feedURL, 'http')) {
+ // Relative URL; feed should be retrieved in the context of the original URL
+ $original = parse_url($url);
+ $port = key_exists('port', $original) ? ":{$original['port']}" : '';
+ $feedURL = $original['scheme'] . '://' . $original['host'] . $port . $feedURL;
+ }
+ $doc = self::retrieveDocument($feedURL);
+ }
+
+ $parsed = self::parseFeed($doc['content']);
+ if (key_exists('error', $parsed)) return ['error' => $parsed['error']];
+
+ $extract = $parsed['ok']->getElementsByTagNameNS(self::ATOM_NS, 'feed')->length > 0
+ ? self::fromAtom(...) : self::fromRSS(...);
+ return $extract($parsed['ok'], $doc['url']);
+ }
+}
diff --git a/src/lib/ParsedItem.php b/src/lib/ParsedItem.php
new file mode 100644
index 0000000..7287506
--- /dev/null
+++ b/src/lib/ParsedItem.php
@@ -0,0 +1,78 @@
+` tag
+ *
+ * @param DOMNode $node The XML node from which a feed item should be constructed
+ * @return static A feed item constructed from the given node
+ */
+ public static function fromAtom(DOMNode $node): static
+ {
+ $guid = ParsedFeed::atomValue($node, 'id');
+ $link = '';
+ foreach ($node->getElementsByTagName('link') as $linkElt) {
+ if ($linkElt->hasAttributes()) {
+ $relAttr = $linkElt->attributes->getNamedItem('rel');
+ if ($relAttr && $relAttr->value == 'alternate') {
+ $link = $linkElt->attributes->getNamedItem('href')->value;
+ break;
+ }
+ }
+ }
+ if ($link == '' && str_starts_with($guid, 'http')) $link = $guid;
+
+ return new static(
+ guid: $guid,
+ title: ParsedFeed::atomValue($node, 'title'),
+ link: $link,
+ publishedOn: Data::formatDate(ParsedFeed::atomValue($node, 'published')),
+ updatedOn: Data::formatDate(ParsedFeed::atomValue($node, 'updated')),
+ content: ParsedFeed::atomValue($node, 'content'));
+ }
+
+ /**
+ * Construct a feed item from an RSS feed's `` tag
+ *
+ * @param DOMNode $node The XML node from which a feed item should be constructed
+ * @return static A feed item constructed from the given node
+ */
+ public static function fromRSS(DOMNode $node): static
+ {
+ $itemGuid = ParsedFeed::rssValue($node, 'guid');
+ $updNodes = $node->getElementsByTagNameNS(ParsedFeed::ATOM_NS, 'updated');
+ $encNodes = $node->getElementsByTagNameNS(ParsedFeed::CONTENT_NS, 'encoded');
+
+ return new static(
+ guid: $itemGuid == 'guid not found' ? ParsedFeed::rssValue($node, 'link') : $itemGuid,
+ title: ParsedFeed::rssValue($node, 'title'),
+ link: ParsedFeed::rssValue($node, 'link'),
+ publishedOn: Data::formatDate(ParsedFeed::rssValue($node, 'pubDate')),
+ updatedOn: Data::formatDate($updNodes->length > 0 ? $updNodes->item(0)->textContent : null),
+ content: $encNodes->length > 0
+ ? $encNodes->item(0)->textContent
+ : ParsedFeed::rssValue($node, 'description'));
+ }
+}
diff --git a/src/lib/Security.php b/src/lib/Security.php
index 68e2bbb..8e74926 100644
--- a/src/lib/Security.php
+++ b/src/lib/Security.php
@@ -1,74 +1,48 @@
-prepare('SELECT * FROM frc_user WHERE email = :email');
- $query->bindValue(':email', $email);
- $result = $query->execute();
- return $result ? $result->fetchArray(SQLITE3_ASSOC) : false;
- }
-
- /**
- * Add a user
- *
- * @param string $email The e-mail address for the user
- * @param string $password The user's password
- * @param SQLite3 $db The data connection to use to add the user
- */
- public static function addUser(string $email, string $password, SQLite3 $db): void {
- $query = $db->prepare('INSERT INTO frc_user (email, password) VALUES (:email, :password)');
- $query->bindValue(':email', $email);
- $query->bindValue(':password', password_hash($password, self::PW_ALGORITHM));
- $query->execute();
- }
+ public const PW_ALGORITHM = PASSWORD_DEFAULT;
/**
* Verify a user's password
*
- * @param array $user The user information retrieved from the database
+ * @param User $user The user information retrieved from the database
* @param string $password The password provided by the user
* @param string|null $returnTo The URL to which the user should be redirected
- * @param SQLite3 $db The database connection to use to verify the user's credentials
+ * @throws DocumentException if any is encountered
*/
- private static function verifyPassword(array $user, string $password, ?string $returnTo, SQLite3 $db): void {
- if (password_verify($password, $user['password'])) {
- if (password_needs_rehash($user['password'], self::PW_ALGORITHM)) {
- $rehash = $db->prepare('UPDATE frc_user SET password = :hash WHERE id = :id');
- $rehash->bindValue(':hash', password_hash($password, self::PW_ALGORITHM));
- $rehash->bindValue(':id', $user['id']);
- $rehash->execute();
+ private static function verifyPassword(User $user, string $password, ?string $returnTo): void
+ {
+ if (password_verify($password, $user->password)) {
+ if (password_needs_rehash($user->password, self::PW_ALGORITHM)) {
+ Patch::byId(Table::USER, $user->id, ['password' => password_hash($password, self::PW_ALGORITHM)]);
}
- $_SESSION[Key::USER_ID] = $user['id'];
- $_SESSION[Key::USER_EMAIL] = $user['email'];
+ $_SESSION[Key::USER_ID] = $user->id;
+ $_SESSION[Key::USER_EMAIL] = $user->email;
frc_redirect($returnTo ?? '/');
}
}
@@ -79,9 +53,9 @@ class Security {
* @param string $email The e-mail address for the user (cannot be the single-user mode user)
* @param string $password The password provided by the user
* @param string|null $returnTo The URL to which the user should be redirected
- * @param SQLite3 $db The database connection to use to verify the user's credentials
+ * @throws DocumentException If any is encountered
*/
- public static function logOnUser(string $email, string $password, ?string $returnTo, SQLite3 $db): void {
+ public static function logOnUser(string $email, string $password, ?string $returnTo): void {
if (SECURITY_MODEL == self::SINGLE_USER_WITH_PASSWORD) {
$dbEmail = self::SINGLE_USER_EMAIL;
} else {
@@ -91,8 +65,8 @@ class Security {
}
$dbEmail = $email;
}
- $user = self::findUserByEmail($dbEmail, $db);
- if ($user) self::verifyPassword($user, $password, $returnTo, $db);
+ $user = User::findByEmail($dbEmail);
+ if ($user) self::verifyPassword($user, $password, $returnTo);
add_error('Invalid credentials; log on unsuccessful');
}
@@ -101,39 +75,37 @@ class Security {
*
* @param string $email The e-mail address of the user whose password should be updated
* @param string $password The new password for this user
- * @param SQLite3 $db The database connection to use in updating the password
+ * @throws DocumentException If any is encountered
*/
- public static function updatePassword(string $email, string $password, SQLite3 $db): void {
- $query = $db->prepare('UPDATE frc_user SET password = :password WHERE email = :email');
- $query->bindValue(':password', password_hash($password, self::PW_ALGORITHM));
- $query->bindValue(':email', $email);
- $query->execute();
+ public static function updatePassword(string $email, string $password): void {
+ Patch::byFields(Table::USER, [Field::EQ('email', $email)],
+ ['password' => password_hash($password, self::PW_ALGORITHM)]);
}
/**
* Log on the single user
*
- * @param SQLite3 $db The data connection to use to retrieve the user
+ * @throws DocumentException If any is encountered
*/
- private static function logOnSingleUser(SQLite3 $db): void {
- $user = self::findUserByEmail(self::SINGLE_USER_EMAIL, $db);
+ private static function logOnSingleUser(): void {
+ $user = User::findByEmail(self::SINGLE_USER_EMAIL);
if (!$user) {
- self::addUser(self::SINGLE_USER_EMAIL, self::SINGLE_USER_PASSWORD, $db);
- $user = self::findUserByEmail(self::SINGLE_USER_EMAIL, $db);
+ User::add(self::SINGLE_USER_EMAIL, self::SINGLE_USER_PASSWORD);
+ $user = User::findByEmail(self::SINGLE_USER_EMAIL);
}
- self::verifyPassword($user, self::SINGLE_USER_PASSWORD, $_GET['returnTo'], $db);
+ self::verifyPassword($user, self::SINGLE_USER_PASSWORD, $_GET['returnTo']);
}
/**
* Verify that user is logged on
*
- * @param SQLite3 $db The data connection to use if required
* @param bool $redirectIfAnonymous Whether to redirect the request if there is no user logged on
+ * @throws DocumentException If any is encountered
*/
- public static function verifyUser(SQLite3 $db, bool $redirectIfAnonymous = true): void {
+ public static function verifyUser(bool $redirectIfAnonymous = true): void {
if (key_exists(Key::USER_ID, $_SESSION)) return;
- if (SECURITY_MODEL == self::SINGLE_USER) self::logOnSingleUser($db);
+ if (SECURITY_MODEL == self::SINGLE_USER) self::logOnSingleUser();
if (SECURITY_MODEL != self::SINGLE_USER_WITH_PASSWORD && SECURITY_MODEL != self::MULTI_USER) {
die('Unrecognized security model (' . SECURITY_MODEL . ')');
diff --git a/src/lib/Table.php b/src/lib/Table.php
new file mode 100644
index 0000000..b4f62b0
--- /dev/null
+++ b/src/lib/Table.php
@@ -0,0 +1,18 @@
+prepare(
- 'SELECT item.id FROM item INNER JOIN feed ON feed.id = item.feed_id WHERE item.id = :id AND feed.user_id = :user');
-$existsQuery->bindValue(':id', $id);
-$existsQuery->bindValue(':user', $_SESSION[Key::USER_ID]);
-$existsResult = $existsQuery->execute();
-$exists = $existsResult ? $existsResult->fetchArray(SQLITE3_ASSOC) : false;
-
-if (!$exists) not_found();
+if (!$item = ItemWithFeed::retrieveById($id)) not_found();
if (key_exists('action', $_GET)) {
- if ($_GET['action'] == 'add') {
- $flag = 1;
- } elseif ($_GET['action'] == 'remove') {
- $flag = 0;
- }
+ $flag = match ($_GET['action']) {
+ 'add' => 1,
+ 'remove' => 0,
+ default => null
+ };
if (isset($flag)) {
- $update = $db->prepare('UPDATE item SET is_bookmarked = :flag WHERE id = :id');
- $update->bindValue(':id', $id);
- $update->bindValue(':flag', $flag);
- if (!$update->execute()) die(Data::error($db)['error']);
+ try {
+ Patch::byId(Table::ITEM, $id, ['is_bookmarked' => $flag]);
+ $item->is_bookmarked = $flag;
+ } catch (DocumentException $ex) {
+ add_error("$ex");
+ }
}
}
-$bookQuery = $db->prepare('SELECT id, is_bookmarked FROM item WHERE id = :id');
-$bookQuery->bindValue(':id', $id);
-$bookResult = $bookQuery->execute();
-$bookmark = $bookResult ? $bookResult->fetchArray(SQLITE3_ASSOC) : ['id' => $id, 'is_bookmarked' => 0];
-
-$action = $bookmark['is_bookmarked'] ? 'remove' : 'add';
-$icon = $bookmark['is_bookmarked'] ? 'added' : 'add'; ?>
+$action = $item->isBookmarked() ? 'remove' : 'add';
+$icon = $item->isBookmarked() ? 'added' : 'add'; ?>