Compare commits

...

15 Commits

Author SHA1 Message Date
d06249aecd beta4 changes (#26)
These changes are mostly in underlying libraries; however, this now uses the [inspired by F#](https://git.bitbadger.solutions/bit-badger/inspired-by-fsharp) library to handle the feed parsing pipeline and optional return values

Reviewed-on: #26
2024-08-06 23:20:17 +00:00
dfd9a873f8 Fix user delete process (#25)
- Bump version
2024-06-15 12:55:45 -04:00
c1901a68ec Fix refresh job (#24) 2024-06-12 07:49:56 -04:00
0c87392910 Documents and Documentation (beta 1) (#23)
- Change to SQLite document store
- Complete documentation on usage of Feed Reader Central
- Update INSTALLING.md for new installation procedures

Reviewed-on: #23
2024-06-12 02:07:35 +00:00
819979f2b2 Bump version 2024-06-07 20:21:20 -04:00
cfa56ec44f Drop .0 or .0.0 from version for display 2024-05-30 19:16:42 -04:00
1b65694e05 WIP on docs (#1) 2024-05-26 22:17:47 -04:00
da34f36530 Merge pull request 'Alpha 7' (#22) from alpha-7 into main
Reviewed-on: #22
2024-05-26 21:33:12 +00:00
58dd7a4ffb Add bookmark item search (#15)
- Implement form styling throughout
- Modify header links for narrower views
- Clean up CSS
2024-05-26 16:56:30 -04:00
9d59bfb1c6 Add search index (#15)
- Add utility rebuild script
- Add Search to header
- Add shell of search page
- Add search query support to ItemList
2024-05-26 14:18:20 -04:00
210377b4da Move list retrieve/render to class (#15)
- array_key_exists -> key_exists
2024-05-25 23:03:39 -04:00
c4e85e6734 Link name to bookmarked page (#14)
- Tweak CSS
2024-05-25 12:31:48 -04:00
2495136fc9 First cut of read bookmarked item page (#14)
- Added Bookmarked link to header if items exist
2024-05-23 23:02:07 -04:00
f4273935cb Add item bookmark buttons (#14)
Implemented as a toggle button

- Move init_cap func where web can see it
- Bump version to alpha7
2024-05-23 22:06:16 -04:00
4fa4dcb831 Alpha 6: Feed-level Pages (#21)
Reviewed-on: #21
2024-05-23 23:04:41 +00:00
47 changed files with 2601 additions and 1108 deletions

5
.gitignore vendored
View File

@ -1,3 +1,6 @@
.idea .idea
src/data/*.db vendor
src/data/*.db*
src/user-config.php src/user-config.php
*.tar.bz2
*.zip

View File

@ -1,30 +1,46 @@
# Installation # 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: ## Selecting a Server
- 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`. 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 ## 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 # 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 ## 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. 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 ### 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 ### 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_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_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. - `Feed::PURGE_BY_COUNT` purges items to preserve at most `PURGE_NUMBER` non-bookmarked items for each feed.

14
create_dist.sh Executable file
View File

@ -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

View File

@ -1,20 +1,57 @@
<?php <?php
/**
* Feed Reader Central Common Application Configuration
*
* This script sets up the environment required for the application and loads the user configuration
*
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\PDODocument\{AutoId, Configuration};
use FeedReaderCentral\Data;
/** The current Feed Reader Central version */ /** The current Feed Reader Central version */
const FRC_VERSION = '1.0.0-alpha5'; const FRC_VERSION = '1.0.0-beta4';
spl_autoload_register(function ($class) { /**
$file = implode(DIRECTORY_SEPARATOR, [__DIR__, 'lib', "$class.php"]); * Drop .0 or .0.0 from the end of the version to format it for display
if (file_exists($file)) { *
require $file; * @return string The version of the application for user display
return true; */
} function display_version(): string {
return false; [$major, $minor, $rev] = explode('.', FRC_VERSION);
}); $minor = $minor === '0' ? '' : ".$minor";
$rev = match (true) {
$rev == '0' => '',
str_starts_with($rev, '0-') => substr($rev, 1),
default => ".$rev"
};
return "v$major$minor$rev";
}
require __DIR__ . '/vendor/autoload.php';
require 'user-config.php'; require 'user-config.php';
Configuration::useDSN('sqlite:' . implode(DIRECTORY_SEPARATOR, [__DIR__, 'data', DATABASE_NAME]));
Configuration::$autoId = AutoId::Number;
Data::ensureDb(); Data::ensureDb();
/** @var string The date the world wide web was created */ /** @var string The date the world wide web was created */
const WWW_EPOCH = '1993-04-30T00:00:00+00:00'; const WWW_EPOCH = '1993-04-30T00:00:00+00:00';
/**
* Capitalize the first letter of the given string
*
* @param string $value The string to be capitalized
* @return string The given string with the first letter capitalized
*/
function init_cap(string $value): string {
return match (strlen($value)) {
0 => "",
1 => strtoupper($value),
default => strtoupper(substr($value, 0, 1)) . substr($value, 1),
};
}

View File

@ -1,4 +1,15 @@
<?php <?php
/**
* Command Line Utility Start Script
*
* This loads the environment needed for a command line utility
*
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
require 'app-config.php'; require 'app-config.php';
if (php_sapi_name() != 'cli') { if (php_sapi_name() != 'cli') {
@ -12,7 +23,8 @@ if (php_sapi_name() != 'cli') {
* @param string $format The format string * @param string $format The format string
* @param mixed ...$values The values for the placeholders in the string * @param mixed ...$values The values for the placeholders in the string
*/ */
function printfn(string $format, mixed ...$values): void { function printfn(string $format, mixed ...$values): void
{
printf($format . PHP_EOL, ...$values); printf($format . PHP_EOL, ...$values);
} }
@ -21,24 +33,11 @@ function printfn(string $format, mixed ...$values): void {
* *
* @param string $title The title to display on the command line * @param string $title The title to display on the command line
*/ */
function cli_title(string $title): void { function cli_title(string $title): void
$appTitle = 'Feed Reader Central ~ v' . FRC_VERSION; {
$appTitle = 'Feed Reader Central ~ ' . display_version();
$dashes = ' +' . str_repeat('-', strlen($title) + 2) . '+' . str_repeat('-', strlen($appTitle) + 2) . '+'; $dashes = ' +' . str_repeat('-', strlen($title) + 2) . '+' . str_repeat('-', strlen($appTitle) + 2) . '+';
printfn($dashes); printfn($dashes);
printfn(' | %s | %s |', $title, $appTitle); printfn(' | %s | %s |', $title, $appTitle);
printfn($dashes . PHP_EOL); printfn($dashes . PHP_EOL);
} }
/**
* Capitalize the first letter of the given string
*
* @param string $value The string to be capitalized
* @return string The given string with the first letter capitalized
*/
function init_cap(string $value): string {
return match (strlen($value)) {
0 => "",
1 => strtoupper($value),
default => strtoupper(substr($value, 0, 1)) . substr($value, 1),
};
}

18
src/composer.json Normal file
View File

@ -0,0 +1,18 @@
{
"name": "bit-badger/feed-reader-central",
"minimum-stability": "beta",
"require": {
"php": ">=8.2",
"bit-badger/pdo-document": "^1",
"ext-curl": "*",
"ext-dom": "*",
"ext-pdo": "*",
"ext-readline": "*",
"ext-sqlite3": "*"
},
"autoload": {
"psr-4": {
"FeedReaderCentral\\": "lib/"
}
}
}

176
src/composer.lock generated Normal file
View File

@ -0,0 +1,176 @@
{
"_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": "2966efd32e555ad8b63351673e75b5a5",
"packages": [
{
"name": "bit-badger/inspired-by-fsharp",
"version": "v1.0.0-beta2",
"source": {
"type": "git",
"url": "https://git.bitbadger.solutions/bit-badger/inspired-by-fsharp",
"reference": "fad428a4e40b606987499b17bb2d5b7d4b04502d"
},
"require": {
"php": "^8.2"
},
"require-dev": {
"phpoption/phpoption": "^1",
"phpunit/phpunit": "^11"
},
"type": "library",
"autoload": {
"psr-4": {
"BitBadger\\InspiredByFSharp\\": "./src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Daniel J. Summers",
"email": "daniel@bitbadger.solutions",
"homepage": "https://bitbadger.solutions",
"role": "Developer"
}
],
"description": "PHP utility classes whose functionality is inspired by their F# counterparts",
"keywords": [
"option",
"result"
],
"support": {
"email": "daniel@bitbadger.solutions",
"rss": "https://git.bitbadger.solutions/bit-badger/inspired-by-fsharp.rss",
"source": "https://git.bitbadger.solutions/bit-badger/inspired-by-fsharp"
},
"time": "2024-07-29T17:58:33+00:00"
},
{
"name": "bit-badger/pdo-document",
"version": "v1.0.0-beta9",
"source": {
"type": "git",
"url": "https://git.bitbadger.solutions/bit-badger/pdo-document",
"reference": "9e0e663811d9dbbdb94a2c04ce8b874e91a7c85b"
},
"require": {
"bit-badger/inspired-by-fsharp": "^1",
"ext-pdo": "*",
"netresearch/jsonmapper": "^4",
"php": ">=8.2"
},
"require-dev": {
"phpunit/phpunit": "^11",
"square/pjson": "^0.5.0"
},
"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 PostgreSQL as document stores",
"keywords": [
"database",
"document",
"pdo",
"postgresql",
"sqlite"
],
"support": {
"docs": "https://bitbadger.solutions/open-source/pdo-document/",
"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-07-29T20:57:51+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": "beta",
"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"
}

View File

@ -1,68 +1,139 @@
<?php <?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
namespace FeedReaderCentral;
use BitBadger\PDODocument\{Configuration, Custom, Definition, DocumentException, Field};
use BitBadger\PDODocument\Mapper\StringMapper;
use DateTimeImmutable;
use DateTimeInterface;
use Exception;
/** /**
* A centralized place for data access for the application * A centralized place for data access for the application
*/ */
class Data { class Data
{
/** Prevent instances of this class */
private function __construct() {}
/** /**
* Obtain a new connection to the database * Create the search index and synchronization triggers for the item table
* @return SQLite3 A new connection to the database *
* @throws DocumentException If any is encountered
*/ */
public static function getConnection(): SQLite3 { public static function createSearchIndex(): void
$db = new SQLite3(implode(DIRECTORY_SEPARATOR, [__DIR__, '..', 'data', DATABASE_NAME])); {
$db->exec('PRAGMA foreign_keys = ON;'); Custom::nonQuery("CREATE VIRTUAL TABLE item_search USING fts5(content, content='item', content_rowid='id')",
return $db; []);
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 * Make sure the expected tables exist
*
* @throws DocumentException If any is encountered
*/ */
public static function ensureDb(): void { public static function ensureDb(): void
$db = self::getConnection(); {
$tables = array(); $tables = Custom::array("SELECT name FROM sqlite_master WHERE type = 'table'", [], new StringMapper('name'));
$tableQuery = $db->query("SELECT name FROM sqlite_master WHERE type = 'table'"); if (!in_array(Table::User, $tables)) {
while ($table = $tableQuery->fetchArray(SQLITE3_NUM)) $tables[] = $table[0]; Definition::ensureTable(Table::User);
if (!in_array('frc_user', $tables)) { Definition::ensureFieldIndex(Table::User, 'email', ['email']);
$query = <<<'SQL'
CREATE TABLE frc_user (
id INTEGER NOT NULL PRIMARY KEY,
email TEXT NOT NULL,
password TEXT NOT NULL)
SQL;
$db->exec($query);
$db->exec('CREATE INDEX idx_user_email ON frc_user (email)');
} }
if (!in_array('feed', $tables)) { if (!in_array(Table::Feed, $tables)) {
$query = <<<'SQL' Definition::ensureTable(Table::Feed);
CREATE TABLE feed ( Definition::ensureFieldIndex(Table::Feed, 'user', ['user_id']);
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;
$db->exec($query);
} }
if (!in_array('item', $tables)) { if (!in_array(Table::Item, $tables)) {
$query = <<<'SQL' Definition::ensureTable(Table::Item);
CREATE TABLE item ( Definition::ensureFieldIndex(Table::Item, 'feed', ['feed_id', 'item_link']);
id INTEGER NOT NULL PRIMARY KEY, self::createSearchIndex();
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;
$db->exec($query);
} }
$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::UserId], ':user');
$userField->qualifier = $qualifier;
return $userField;
} }
/** /**
@ -71,41 +142,12 @@ class Data {
* @param ?string $value The date/time to be parsed and formatted * @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 * @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 { try {
return $value ? (new DateTimeImmutable($value))->format(DateTimeInterface::ATOM) : null; return $value ? (new DateTimeImmutable($value))->format(DateTimeInterface::ATOM) : null;
} catch (Exception) { } catch (Exception) {
return null; return null;
} }
} }
/**
* Retrieve a feed by its ID for the current user
*
* @param int $feedId The ID of the feed to retrieve
* @param ?SQLite3 $dbConn A database connection to use (optional; will use standalone if not provided)
* @return array|bool The data for the feed if found, false if not found
*/
public static function retrieveFeedById(int $feedId, ?SQLite3 $dbConn = null): array|bool {
$db = $dbConn ?? self::getConnection();
try {
$query = $db->prepare('SELECT * FROM feed WHERE id = :id AND user_id = :user');
$query->bindValue(':id', $feedId);
$query->bindValue(':user', $_SESSION[Key::USER_ID]);
$result = $query->execute();
return $result ? $result->fetchArray(SQLITE3_ASSOC) : false;
} finally {
if (is_null($dbConn)) $db->close();
}
}
/**
* 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()];
}
} }

View File

@ -1,380 +1,171 @@
<?php <?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
namespace FeedReaderCentral;
use BitBadger\InspiredByFSharp\{Option, Result};
use BitBadger\PDODocument\{
Configuration, Custom, Document, DocumentException, DocumentList, Exists, Field, Find, Parameters, Patch, Query
};
use DateTimeInterface;
/** /**
* Feed retrieval, parsing, and manipulation * An RSS or Atom feed
*/ */
class Feed { class Feed
{
/** @var string The URL for the feed */ // ***** CONSTANTS *****
public string $url = '';
/** @var string The title of the feed */
public string $title = '';
/** @var ?string When the feed was last updated */
public ?string $updatedOn = null;
/** @var FeedItem[] The items contained in the feed */
public array $items = [];
/** @var string The XML namespace for Atom feeds */
public const string ATOM_NS = 'http://www.w3.org/2005/Atom';
/** @var string The XML namespace for the `<content:encoded>` 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';
/** @var int Do not purge items */ /** @var int Do not purge items */
public const int PURGE_NONE = 0; public const PurgeNone = 0;
/** @var int Purge all read items (will not purge unread items) */ /** @var int Purge all read items (will not purge unread items) */
public const int PURGE_READ = 1; public const PurgeRead = 1;
/** @var int Purge items older than the specified number of days */ /** @var int Purge items older than the specified number of days */
public const int PURGE_BY_DAYS = 2; public const PurgeByDays = 2;
/** @var int Purge items in number greater than the specified number of items to keep */ /** @var int Purge items in number greater than the specified number of items to keep */
public const int PURGE_BY_COUNT = 3; public const PurgeByCount = 3;
/** /**
* When parsing XML into a DOMDocument, errors are presented as warnings; this creates an exception for them * @var int Do not purge items
* * @deprecated Use Feed::PurgeNone instead
* @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 { public const PURGE_NONE = 0;
if ($errno == E_WARNING && substr_count($errstr, 'DOMDocument::loadXML()') > 0) {
throw new DOMException($errstr, $errno);
}
return false;
}
/** /**
* Parse a feed into an XML tree * @var int Purge all read items (will not purge unread items)
* * @deprecated Use Feed::PurgeRead instead
* @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 { public const PURGE_READ = 1;
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 * @var int Purge items older than the specified number of days
* * @deprecated Use Feed::PurgeByDays 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 rssValue(DOMNode $element, string $tagName): string { public const PURGE_BY_DAYS = 2;
$tags = $element->getElementsByTagName($tagName);
return $tags->length == 0 ? "$tagName not found" : $tags->item(0)->textContent;
}
/** /**
* Extract items from an RSS feed * @var int Purge items in number greater than the specified number of items to keep
* * @deprecated Use Feed::PurgeByCount instead
* @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 { public const PURGE_BY_COUNT = 3;
$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 * Constructor
* *
* @param DOMNode $node The node with an attribute value to obtain * @param int $id The ID of the feed
* @param string $name The name of the attribute whose value should be obtained * @param int $user_id The ID of the user to whom this subscription belongs
* @return string The attribute value if it exists, an empty string if not * @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 attrValue(DOMNode $node, string $name): string { public function __construct(public int $id = 0, public int $user_id = 0, public string $url = '',
return ($node->hasAttributes() ? $node->attributes->getNamedItem($name)?->value : null) ?? ''; public ?string $title = null, public ?string $updated_on = null,
public ?string $checked_on = null) { }
} // ***** STATIC FUNCTIONS *****
/**
* 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 `<div>` 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 * Create a document from the parsed feed
* *
* @param DOMDocument $xml The XML received from the feed * @param ParsedFeed $parsed The parsed feed
* @param string $url The actual URL for the feed * @return Feed The document constructed from the parsed feed
* @return array|Feed[] ['ok' => feed]
*/ */
private static function fromAtom(DOMDocument $xml, string $url): array { public static function fromParsed(ParsedFeed $parsed): self
$root = $xml->getElementsByTagNameNS(self::ATOM_NS, 'feed')->item(0); {
if (($updatedOn = self::atomValue($root, 'updated')) == 'pubDate not found') $updatedOn = null; return new self(
user_id: $_SESSION[Key::UserId],
$feed = new Feed(); url: $parsed->url,
$feed->title = self::atomValue($root, 'title'); title: $parsed->title,
$feed->url = $url; updated_on: $parsed->updatedOn,
$feed->updatedOn = Data::formatDate($updatedOn); checked_on: Data::formatDate('now'));
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, '</head>') + 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 == '<!doctype' || str_starts_with($start, '<html')) {
$derivedURL = self::deriveFeedFromHTML($doc['content']);
if (array_key_exists('error', $derivedURL)) return ['error' => $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 = array_key_exists('port', $original) ? ":{$original['port']}" : '';
$feedURL = $original['scheme'] . '://' . $original['host'] . $port . $feedURL;
}
$doc = self::retrieveDocument($feedURL);
}
$parsed = self::parseFeed($doc['content']);
if (array_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();
} }
/** /**
* Update a feed's items * Update a feed's items
* *
* @param int $feedId The ID of the feed to which these items belong * @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) * @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 * @return Result<true, string> True if successful, an 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): Result
{
$results = $results =
array_map(function ($item) use ($db, $feedId) { array_map(function ($item) use ($feedId) {
$existsQuery = $db->prepare( try {
'SELECT id, published_on, updated_on FROM item WHERE feed_id = :feed AND item_guid = :guid'); $tryExisting = Find::firstByFields(Table::Item,
$existsQuery->bindValue(':feed', $feedId); [Field::EQ('feed_id', $feedId), Field::EQ('item_guid', $item->guid)], Item::class);
$existsQuery->bindValue(':guid', $item->guid); if ($tryExisting->isSome()) {
if ($exists = $existsQuery->execute()) { $existing = $tryExisting->get();
if ($existing = $exists->fetchArray(SQLITE3_ASSOC)) { if ($existing->published_on !== $item->publishedOn
if ( $existing['published_on'] != $item->publishedOn || ($existing->updated_on !== ($item->updatedOn ?? ''))) {
|| ($existing['updated_on'] ?? '') != ($item->updatedOn ?? '')) { Item::update($existing->id, $item);
if (!self::updateItem($existing['id'], $item, $db)) return Data::error($db);
} }
} else { } else {
if (!self::addItem($feedId, $item, $db)) return Data::error($db); Item::add($feedId, $item);
} }
} else { return Result::OK(true);
return Data::error($db); } catch (DocumentException $ex) {
return Result::Error("$ex");
} }
return ['ok' => true]; }, array_filter($parsed->items,
}, array_filter($feed->items, fn(ParsedItem $it) => date_create_immutable($it->updatedOn ?? $it->publishedOn) >= $lastChecked));
fn($it) => date_create_immutable($it->updatedOn ?? $it->publishedOn) >= $lastChecked)); $errors = array_map(fn(Result $it) => $it->getError(), array_filter($results, fn($it) => $it->isError()));
$errors = array_map(fn($it) => $it['error'], array_filter($results, fn($it) => array_key_exists('error', $it))); return sizeof($errors) > 0 ? Result::Error(implode("\n", $errors)) : Result::OK(true);
return sizeof($errors) > 0 ? ['error' => implode("\n", $errors)] : ['ok' => true];
} }
/** /**
* Purge items for a feed * Purge items for a feed
* *
* @param int $feedId The ID of the feed to be purged * @param int $feedId The ID of the feed to be purged
* @param SQLite3 $db The database connection on which items should be purged * @return Result<true, string> True if purging was successful, an error message if not
* @return array|string[]|true[] ['ok' => true] if purging was successful, ['error' => message] if not * @throws DocumentException If any is encountered
*/ */
private static function purgeItems(int $feedId, SQLite3 $db): array { private static function purgeItems(int $feedId): Result
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);
switch (PURGE_TYPE) {
case self::PurgeRead:
$readField = Field::EQ('is_read', 1, ':read');
$fields[] = $readField;
$sql .= ' AND ' . Query::whereByFields([$readField]);
break;
case self::PurgeByDays:
$fields[] = Field::EQ('', Data::formatDate('-' . PURGE_NUMBER . ' day'), ':oldest');
$sql .= " AND date(coalesce(data->>'updated_on', data->>'published_on')) < date(:oldest)";
break;
case self::PurgeByCount:
$fields[] = Field::EQ('', PURGE_NUMBER, ':keep');
$id = Configuration::$idField;
$table = Table::Item;
$sql .= ' ' . <<<SQL
AND data->>'$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;
break;
default:
return Result::Error('Unrecognized purge type ' . PURGE_TYPE);
}
try { try {
$sql = match (PURGE_TYPE) { Custom::nonQuery($sql, Parameters::addFields($fields, []));
self::PURGE_READ => 'AND is_read = 1', return Result::OK(true);
self::PURGE_BY_DAYS => 'AND date(coalesce(updated_on, published_on)) < date(:oldest)', } catch (DocumentException $ex) {
self::PURGE_BY_COUNT => 'AND id IN (SELECT id FROM item WHERE feed_id = :feed return Result::Error("$ex");
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()];
} }
} }
@ -383,135 +174,128 @@ class Feed {
* *
* @param int $feedId The ID of the feed to be refreshed * @param int $feedId The ID of the feed to be refreshed
* @param string $url The URL 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 Result<true, string> True if successful, an error message if not
* @return array|string[]|true[] ['ok' => true] if successful, ['error' => message] if not
*/ */
public static function refreshFeed(int $feedId, string $url, SQLite3 $db): array { public static function refreshFeed(int $feedId, string $url): Result
$feedRetrieval = self::retrieveFeed($url); {
if (array_key_exists('error', $feedRetrieval)) return $feedRetrieval; return ParsedFeed::retrieve($url)
$feed = $feedRetrieval['ok']; ->bind(function (ParsedFeed $feed) use ($feedId, $url) {
try {
$feedDoc = Find::byId(Table::Feed, $feedId, self::class);
if ($feedDoc->isNone()) return Result::Error('Could not derive date last checked for feed');
$lastChecked = date_create_immutable($feedDoc->get()->checked_on ?? WWW_EPOCH);
$lastCheckedQuery = $db->prepare('SELECT checked_on FROM feed where id = :id'); return self::updateItems($feedId, $feed, $lastChecked)
$lastCheckedQuery->bindValue(':id', $feedId); ->bind(function () use ($feed, $feedId, $url) {
if (!($lastCheckedResult = $lastCheckedQuery->execute())) return Data::error($db); $patch = [
if (!($lastChecked = date_create_immutable($lastCheckedResult->fetchArray(SQLITE3_NUM)[0] ?? WWW_EPOCH))) { 'title' => $feed->title,
return ['error' => 'Could not derive date last checked for feed']; 'updated_on' => $feed->updatedOn,
'checked_on' => Data::formatDate('now')
];
if ($url !== $feed->url) $patch['url'] = $feed->url;
Patch::byId(Table::Feed, $feedId, $patch);
return Result::OK(true);
})
->bind(fn() => PURGE_TYPE === self::PurgeNone ? Result::OK(true) : self::purgeItems($feedId));
} catch (DocumentException $ex) {
return Result::Error("$ex");
} }
});
$itemUpdate = self::updateItems($feedId, $feed, $lastChecked, $db);
if (array_key_exists('error', $itemUpdate)) return $itemUpdate;
$urlUpdate = $url == $feed->url ? '' : ', url = :url';
$feedUpdate = $db->prepare(<<<SQL
UPDATE feed
SET title = :title,
updated_on = :updated,
checked_on = :checked
$urlUpdate
WHERE id = :id
SQL);
$feedUpdate->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);
} }
/** /**
* Add an RSS feed * Add an RSS feed
* *
* @param string $url The URL of the RSS feed to add * @param string $url The URL of the RSS feed to add
* @return array ['ok' => feedId] if successful, ['error' => message] if not * @return Result<int, string> The feed ID if successful, an error message if not
*/ */
public static function add(string $url, SQLite3 $db): array { public static function add(string $url): Result
$feedExtract = self::retrieveFeed($url); {
if (array_key_exists('error', $feedExtract)) return $feedExtract; return ParsedFeed::retrieve($url)
->bind(function (ParsedFeed $feed) {
try {
$fields = [Field::EQ('user_id', $_SESSION[Key::UserId]), Field::EQ('url', $feed->url)];
if (Exists::byFields(Table::Feed, $fields)) {
return Result::Error("Already subscribed to feed $feed->url");
}
$feed = $feedExtract['ok']; Document::insert(Table::Feed, self::fromParsed($feed));
$existsQuery = $db->prepare('SELECT COUNT(*) FROM feed WHERE user_id = :user AND url = :url'); $doc = Find::firstByFields(Table::Feed, $fields, self::class);
$existsQuery->bindValue(':user', $_SESSION[Key::USER_ID]); if ($doc->isNone()) return Result::Error('Could not retrieve inserted feed');
$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"];
$query = $db->prepare(<<<'SQL' return self::updateItems($doc->get()->id, $feed, date_create_immutable(WWW_EPOCH))
INSERT INTO feed ( ->bind(fn() => Result::OK($doc->get()->id));
user_id, url, title, updated_on, checked_on } catch (DocumentException $ex) {
) VALUES ( return Result::Error("$ex");
: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);
$feedId = $db->lastInsertRowID();
$result = self::updateItems($feedId, $feed, date_create_immutable(WWW_EPOCH), $db);
if (array_key_exists('error', $result)) return $result;
return ['ok' => $feedId];
} }
/** /**
* Update an RSS feed * 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 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 Result<true, string> True if successful, an error message if not
* @return bool[]|string[] [ 'ok' => true ] if successful, [ 'error' => message ] if not
*/ */
public static function update(array $existing, string $url, SQLite3 $db): array { public static function update(Feed $existing, string $url): Result
$query = $db->prepare('UPDATE feed SET url = :url WHERE id = :id AND user_id = :user'); {
$query->bindValue(':url', $url); try {
$query->bindValue(':id', $existing['id']); Patch::byFields(Table::Feed,
$query->bindValue(':user', $_SESSION[Key::USER_ID]); [Field::EQ(Configuration::$idField, $existing->id), Field::EQ('user_id', $_SESSION[Key::UserId])],
if (!$query->execute()) return Data::error($db); ['url' => $url]);
return self::refreshFeed($existing->id, $url);
return self::refreshFeed($existing['id'], $url, $db); } catch (DocumentException $ex) {
return Result::Error("$ex");
}
} }
/** /**
* Retrieve all feeds, optionally for a specific user * 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) * @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<Feed> A list of feeds
* @throws DocumentException If any is encountered
*/ */
public static function retrieveAll(SQLite3 $db, int $user = 0): array { public static function retrieveAll(int $user = 0): DocumentList
$extraSQL = $user > 0 ? ' WHERE u.id = :user' : ''; {
$query = $db->prepare( return $user === 0
"SELECT f.id, f.url, u.email FROM feed f INNER JOIN frc_user u ON u.id = f.user_id$extraSQL"); ? Find::all(Table::Feed, self::class)
if ($user > 0) $query->bindValue(':user', $user); : Find::byFields(Table::Feed, [Field::EQ('user_id', $user)], self::class);
if (!($result = $query->execute())) return Data::error($db);
$feeds = [];
while ($feed = $result->fetchArray(SQLITE3_ASSOC)) $feeds[] = $feed;
return $feeds;
} }
/** /**
* Refresh all feeds * Refresh all feeds
* *
* @param SQLite3 $db The database connection to use for refreshing feeds * @return Result<true, string> True if successful an error message if not (may have multiple error lines)
* @return array|true[]|string[] ['ok' => true] if successful,
* ['error' => message] if not (may have multiple error lines)
*/ */
public static function refreshAll(SQLite3 $db): array { public static function refreshAll(): Result
$feeds = self::retrieveAll($db, $_SESSION[Key::USER_ID]); {
if (array_key_exists('error', $feeds)) return $feeds;
$errors = []; $errors = [];
array_walk($feeds, function ($feed) use ($db, &$errors) {
$result = self::refreshFeed($feed['id'], $feed['url'], $db);
if (array_key_exists('error', $result)) $errors[] = $result['error'];
});
return sizeof($errors) == 0 ? ['ok' => true] : ['error' => implode("\n", $errors)]; try {
self::retrieveAll($_SESSION[Key::UserId])->iter(function (Feed $feed) use (&$errors) {
self::refreshFeed($feed->id, $feed->url)
->mapError(function (string $err) use (&$errors) { $errors[] = $err; });
});
} catch (DocumentException $ex) {
return Result::Error("$ex");
}
return empty($errors) ? Result::OK(true) : Result::Error(implode("\n", $errors));
}
/**
* Retrieve a feed by its ID for the current user
*
* @param int $feedId The ID of the feed to retrieve
* @return Option<Feed> A `Some` value with the data for the feed if found, `None` otherwise
* @throws DocumentException If any is encountered
*/
public static function retrieveById(int $feedId): Option
{
return Find::byId(Table::Feed, $feedId, static::class)
->filter(fn($it) => $it->user_id === $_SESSION[Key::UserId]);
} }
} }

View File

@ -1,80 +0,0 @@
<?php
/**
* Information for a feed item
*/
class FeedItem {
/** @var string The title of the feed item */
public string $title = '';
/** @var string The unique ID for the feed item */
public string $guid = '';
/** @var string The link to the original content */
public string $link = '';
/** @var string When this item was published */
public string $publishedOn = '';
/** @var ?string When this item was last updated */
public ?string $updatedOn = null;
/** @var string The content for the item */
public string $content = '';
/**
* Construct a feed item from an Atom feed's `<entry>` 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 `<item>` 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;
}
}

93
src/lib/Item.php Normal file
View File

@ -0,0 +1,93 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
namespace FeedReaderCentral;
use BitBadger\PDODocument\{Document, DocumentException, Patch};
/**
* An item from a feed
*/
class Item
{
/**
* Constructor
*
* @param int $id The ID of this item in the Feed Reader Central database
* @param int $feed_id The ID of the feed to which this item belongs
* @param string $title The title of this item
* @param string $item_guid The Globally Unique ID (GUID) for this item (an attribute in the feed XML)
* @param string $item_link The link to the item on its original site
* @param string $published_on The date/time this item was published
* @param string|null $updated_on The date/time this item was last updated
* @param string $content The content for this item
* @param int $is_read 1 if the item has been read, 0 if not
* @param int $is_bookmarked 1 if the item is bookmarked, 0 if not
*/
public function __construct(public int $id = 0, public int $feed_id = 0, public string $title = '',
public string $item_guid = '', public string $item_link = '',
public string $published_on = '', public ?string $updated_on = null,
public string $content = '', public int $is_read = 0, public int $is_bookmarked = 0) { }
/**
* Has the item been read?
*
* @return bool True if the item has been read, false if not
*/
public function isRead(): bool
{
return $this->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 self(
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
]);
}
}

175
src/lib/ItemList.php Normal file
View File

@ -0,0 +1,175 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
namespace FeedReaderCentral;
use BitBadger\InspiredByFSharp\Option;
use BitBadger\PDODocument\{Configuration, Custom, DocumentException, DocumentList, Field, Parameters, Query};
use BitBadger\PDODocument\Mapper\DocumentMapper;
/**
* A list of items to be displayed
*
* This is a wrapper for retrieval and display of arbitrary lists of items based on a SQLite result.
*/
class ItemList
{
/** @var DocumentList<ItemWithFeed> The items matching the criteria, lazily iterable */
private DocumentList $dbList;
/** @var Option<string> The error message generated by executing a query */
private Option $error;
/** @var bool Whether to render a link to the feed to which the item belongs */
public bool $linkFeed = false;
/** @var bool Whether to display the feed to which the item belongs */
public bool $displayFeed = false;
/** @var bool Whether to show read / bookmarked indicators on posts */
public bool $showIndicators = false;
/**
* Constructor
*
* @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 Field[] $fields The fields to use to restrict the results
* @param string $searchWhere Additional WHERE clause to use for searching
*/
private function __construct(public string $itemType, public string $returnURL = '', array $fields = [],
string $searchWhere = '')
{
$this->error = Option::None();
$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 = Option::Some("$ex");
}
}
/**
* Create an item list with all the current user's bookmarked items
*
* @return ItemList An item list with all bookmarked items
*/
public static function allBookmarked(): self
{
$list = new self('Bookmarked', '/?bookmarked', [Data::bookmarkField(qualifier: Table::Item)]);
$list->linkFeed = true;
return $list;
}
/**
* Create an item list with all the current user's unread items
*
* @return ItemList An item list with all unread items
*/
public static function allUnread(): self
{
$list = new self('Unread', fields: [Data::unreadField(Table::Item)]);
$list->linkFeed = true;
return $list;
}
/**
* 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
* @return ItemList An item list with all items for the given feed
*/
public static function allForFeed(int $feedId): self
{
$list = new self('', "/feed/items?id=$feedId", [Data::feedField($feedId, Table::Feed)]);
$list->showIndicators = true;
return $list;
}
/**
* 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
* @return ItemList An item list with unread items for the given feed
*/
public static function unreadForFeed(int $feedId): self
{
return new self('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
* @return ItemList An item list with bookmarked items for the given feed
*/
public static function bookmarkedForFeed(int $feedId): self
{
return new self('Bookmarked', "/feed/items?id=$feedId&bookmarked",
[Data::feedField($feedId, Table::Feed), Data::bookmarkField(qualifier: Table::Item)]);
}
/**
* Create an item list with items matching given search terms
*
* @param string $search The item search terms / query
* @param bool $isBookmarked Whether to restrict the search to bookmarked items
* @return ItemList An item list match the given search terms
*/
public static function matchingSearch(string $search, bool $isBookmarked): self
{
$fields = [Field::EQ('content', $search, ':search')];
if ($isBookmarked) $fields[] = Data::bookmarkField(qualifier: Table::Item);
$list = new self('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;
}
/**
* Render this item list
*/
public function render(): void
{
if ($this->error->isSome()) {
echo "<p>Error retrieving list:<br>{$this->error->get()}";
return;
}
$return = $this->returnURL === '' ? '' : '&from=' . urlencode($this->returnURL);
echo '<article>';
if ($this->dbList->hasItems()) {
$this->dbList->iter(function (ItemWithFeed $item) use ($return) {
echo '<p>' . hx_get("/item?id=$item->id$return", strip_tags($item->title)) . '<br><small>';
if ($this->showIndicators) {
if (!$item->isRead()) echo '<strong>Unread</strong> &nbsp; ';
if ($item->isBookmarked()) echo '<strong>Bookmarked</strong> &nbsp; ';
}
echo '<em>' . date_time($item->updated_on ?? $item->published_on) . '</em>';
if ($this->linkFeed) {
echo ' &bull; ' .
hx_get("/feed/items?id={$item->feed->id}&" . strtolower($this->itemType),
htmlentities($item->feed->title));
} elseif ($this->displayFeed) {
echo ' &bull; ' . htmlentities($item->feed->title);
}
echo '</small>';
});
} else {
echo '<p><em>There are no ' . strtolower($this->itemType) . ' items</em>';
}
echo '</article>';
}
}

74
src/lib/ItemWithFeed.php Normal file
View File

@ -0,0 +1,74 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
namespace FeedReaderCentral;
use BitBadger\InspiredByFSharp\Option;
use BitBadger\PDODocument\{Configuration, Custom, DocumentException, Field, Parameters, Query};
use BitBadger\PDODocument\Mapper\{DocumentMapper, ExistsMapper};
/**
* A combined item and feed (used for lists)
*/
class ItemWithFeed extends Item
{
/** @var string The body of the `FROM` clause to join item and feed */
public const FROM_WITH_JOIN = Table::Item . ' INNER JOIN ' . Table::Feed
. ' ON ' . Table::Item . ".data->>'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::UserId], ':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 Option<ItemWithFeed> A `Some` value with the item if it is found, `None` otherwise
* @throws DocumentException If any is encountered
*/
public static function retrieveById(int $id): Option
{
$fields = self::idAndUserFields($id);
return Custom::single(self::SELECT_WITH_FEED . ' WHERE ' . Query::whereByFields($fields),
Parameters::addFields($fields, []), new DocumentMapper(self::class));
}
}

View File

@ -1,13 +1,27 @@
<?php <?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
class Key { declare(strict_types=1);
namespace FeedReaderCentral;
/**
* Session and other keys used for array indexes
*/
readonly class Key
{
/** @var string The $_SESSION key for the current user's e-mail address */ /** @var string The $_SESSION key for the current user's e-mail address */
public const string USER_EMAIL = 'FRC_USER_EMAIL'; public const UserEmail = 'FRC_USER_EMAIL';
/** @var string The $_SESSION key for the current user's ID */ /** @var string The $_SESSION key for the current user's ID */
public const string USER_ID = 'FRC_USER_ID'; public const UserId = 'FRC_USER_ID';
/** @var string The $_REQUEST key for the array of user messages to display */ /** @var string The $_REQUEST key for the array of user messages to display */
public const string USER_MSG = 'FRC_USER_MSG'; public const UserMsg = 'FRC_USER_MSG';
/** Prevent instances of this class */
private function __construct() {}
} }

276
src/lib/ParsedFeed.php Normal file
View File

@ -0,0 +1,276 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
namespace FeedReaderCentral;
use BitBadger\InspiredByFSharp\Result;
use DOMDocument;
use DOMElement;
use DOMException;
use DOMNode;
/**
* A feed, as parsed from the Atom or RSS XML
*/
readonly class ParsedFeed
{
/**
* Constructor
*
* @param string $url The URL for the feed
* @param string $title The title of the feed
* @param string|null $updatedOn When the feed was last updated
* @param ParsedItem[] $items The items contained in the feed
*/
public function __construct(public string $url = '', public string $title = '', public ?string $updatedOn = null,
public array $items = []) {}
/** @var string The XML namespace for Atom feeds */
public const ATOM_NS = 'http://www.w3.org/2005/Atom';
/** @var string The XML namespace for the `<content:encoded>` 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 Result<DOMDocument, string> The feed if successful, an error message if not
*/
public static function parseFeed(string $content): Result
{
set_error_handler(self::xmlParseError(...));
try {
$feed = new DOMDocument();
$feed->loadXML($content);
return Result::OK($feed);
} catch (DOMException $ex) {
return Result::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 Result<ParsedFeed, string> The feed if successful, an error message if not
*/
private static function fromRSS(DOMDocument $xml, string $url): Result
{
$channel = $xml->getElementsByTagName('channel')->item(0);
if (!($channel instanceof DOMElement)) {
$type = $channel?->nodeType ?? -1;
return Result::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;
}
}
return Result::OK(new self(
url: $url,
title: self::rssValue($channel, 'title'),
updatedOn: Data::formatDate($updatedOn),
items: array_map(ParsedItem::fromRSS(...), iterator_to_array($channel->getElementsByTagName('item')))));
}
/**
* 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 `<div>` 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 Result<ParsedFeed, string> The feed (does not have any error handling)
*/
private static function fromAtom(DOMDocument $xml, string $url): Result
{
$root = $xml->getElementsByTagNameNS(self::ATOM_NS, 'feed')->item(0);
if (($updatedOn = self::atomValue($root, 'updated')) == 'pubDate not found') $updatedOn = null;
return Result::OK(new self(
url: $url,
title: self::atomValue($root, 'title'),
updatedOn: Data::formatDate($updatedOn),
items: array_map(ParsedItem::fromAtom(...), iterator_to_array($root->getElementsByTagName('entry')))));
}
/**
* Retrieve a document (http/https)
*
* @param string $url The URL of the document to retrieve
* @return Result<array, string> ['content' => doc content, 'code' => HTTP response code, 'url' => effective URL] if
* successful, an error message if not
*/
private static function retrieveDocument(string $url): Result
{
$docReq = curl_init($url);
try {
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);
$error = curl_error($docReq);
if ($error !== '') return Result::Error($error);
return Result::OK([
'content' => curl_exec($docReq),
'code' => curl_getinfo($docReq, CURLINFO_RESPONSE_CODE),
'url' => curl_getinfo($docReq, CURLINFO_EFFECTIVE_URL)
]);
} finally {
curl_close($docReq);
}
}
/**
* Derive a feed URL from an HTML document
*
* @param string $content The HTML document content from which to derive a feed URL
* @return Result<string, string> The feed URL if successful, an error message if not
*/
private static function deriveFeedFromHTML(string $content): Result
{
$html = new DOMDocument();
$html->loadHTML(substr($content, 0, strpos($content, '</head>') + 7));
$headTags = $html->getElementsByTagName('head');
if ($headTags->length < 1) return Result::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 Result::OK(self::attrValue($link, 'href'));
}
}
}
return Result::Error('Cannot find feed at this URL');
}
/**
* Retrieve the feed
*
* @param string $url The URL of the feed to retrieve
* @return Result<ParsedFeed, string> The feed if successful, an error message if not
*/
public static function retrieve(string $url): Result
{
$doc = self::retrieveDocument($url)
->bind(fn(array $doc) => match ($doc['code']) {
200 => Result::OK($doc),
default => Result::Error(
"Prospective feed URL $url returned HTTP Code {$doc['code']}: {$doc['content']}"),
})
->bind(function (array $doc) use ($url) {
$start = strtolower(strlen($doc['content']) >= 9 ? substr($doc['content'], 0, 9) : $doc['content']);
return $start === '<!doctype' || str_starts_with($start, '<html')
? self::deriveFeedFromHTML($doc['content'])
->bind(function (string $feedURL) use ($url) {
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;
}
return self::retrieveDocument($feedURL);
})
->bind(fn($doc) => match ($doc['code']) {
200 => Result::OK($doc),
default => Result::Error(
"Derived feed URL {$doc['url']} returned HTTP Code {$doc['code']}: {$doc['content']}"),
})
: Result::OK($doc);
});
return $doc
->bind(fn($doc) => self::parseFeed($doc['content']))
->bind(function (DOMDocument $parsed) use ($doc) {
$extract = $parsed->getElementsByTagNameNS(self::ATOM_NS, 'feed')->length > 0
? self::fromAtom(...) : self::fromRSS(...);
return $extract($parsed, $doc->getOK()['url']);
});
}
}

84
src/lib/ParsedItem.php Normal file
View File

@ -0,0 +1,84 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
namespace FeedReaderCentral;
use DOMNode;
/**
* Information for a feed item
*/
readonly class ParsedItem
{
/**
* Constructor
*
* @param string $guid The unique ID for the feed item
* @param string $title The title of the feed item
* @param string $link The link to the original content
* @param string $publishedOn When this item was published
* @param string|null $updatedOn When this item was last updated
* @param string $content The content for the item
*/
private function __construct(public string $guid = '', public string $title = '', public string $link = '',
public string $publishedOn = '', public ?string $updatedOn = null,
public string $content = '') { }
/**
* Construct a feed item from an Atom feed's `<entry>` tag
*
* @param DOMNode $node The XML node from which a feed item should be constructed
* @return ParsedItem A feed item constructed from the given node
*/
public static function fromAtom(DOMNode $node): self
{
$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 self(
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 `<item>` tag
*
* @param DOMNode $node The XML node from which a feed item should be constructed
* @return ParsedItem A feed item constructed from the given node
*/
public static function fromRSS(DOMNode $node): self
{
$itemGuid = ParsedFeed::rssValue($node, 'guid');
$updNodes = $node->getElementsByTagNameNS(ParsedFeed::ATOM_NS, 'updated');
$encNodes = $node->getElementsByTagNameNS(ParsedFeed::CONTENT_NS, 'encoded');
return new self(
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'));
}
}

View File

@ -1,75 +1,77 @@
<?php <?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
namespace FeedReaderCentral;
use BitBadger\InspiredByFSharp\Option;
use BitBadger\PDODocument\{DocumentException, Field, Patch};
/** /**
* Security functions * Security functions
*/ */
class Security { readonly class Security
{
/** @var int Run as a single user requiring no password */ /** @var int Run as a single user requiring no password */
public const int SINGLE_USER = 0; public const SingleUserMode = 0;
/** @var int Run as a single user requiring a password */ /** @var int Run as a single user requiring a password */
public const int SINGLE_USER_WITH_PASSWORD = 1; public const SingleUserPasswordMode = 1;
/** @var int Require users to provide e-mail address and password */ /** @var int Require users to provide e-mail address and password */
public const int MULTI_USER = 2; public const MultiUserMode = 2;
/**
* @var int Run as a single user requiring no password
* @deprecated Use Security::SingleUserMode instead
*/
public const SINGLE_USER = 0;
/**
* @var int Run as a single user requiring a password
* @deprecated Use Security::SingleUserPasswordMode instead
*/
public const SINGLE_USER_WITH_PASSWORD = 1;
/**
* @var int Require users to provide e-mail address and password
* @deprecated Use Security::MultiUserMode instead
*/
public const MULTI_USER = 2;
/** @var string The e-mail address for the single user */ /** @var string The e-mail address for the single user */
public const string SINGLE_USER_EMAIL = 'solouser@example.com'; public const SingleUserEmail = 'solouser@example.com';
/** @var string The password for the single user with no password */ /** @var string The password for the single user with no password */
public const string SINGLE_USER_PASSWORD = 'no-password-required'; public const SingleUserPassword = 'no-password-required';
/** @var string The password algorithm to use for our passwords */ /** @var string The password algorithm to use for our passwords */
public const string PW_ALGORITHM = PASSWORD_DEFAULT; public const PasswordAlgorithm = PASSWORD_DEFAULT;
/** /** Prevent instances of this class */
* Find a user by their ID private function __construct() {}
*
* @param string $email The e-mail address of the user to retrieve
* @param SQLite3 $db The data connection to use to retrieve the user
* @return array|false The user information, or null if the user is not found
*/
public static function findUserByEmail(string $email, SQLite3 $db): array|false {
$query = $db->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();
}
/** /**
* Verify a user's password * 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 $password The password provided by the user
* @param string|null $returnTo The URL to which the user should be redirected * @param Option<string> $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 { private static function verifyPassword(User $user, string $password, Option $returnTo): void
if (password_verify($password, $user['password'])) { {
if (password_needs_rehash($user['password'], self::PW_ALGORITHM)) { if (password_verify($password, $user->password)) {
$rehash = $db->prepare('UPDATE frc_user SET password = :hash WHERE id = :id'); if (password_needs_rehash($user->password, self::PasswordAlgorithm)) {
$rehash->bindValue(':hash', password_hash($password, self::PW_ALGORITHM)); Patch::byId(Table::User, $user->id, ['password' => password_hash($password, self::PasswordAlgorithm)]);
$rehash->bindValue(':id', $user['id']);
$rehash->execute();
} }
$_SESSION[Key::USER_ID] = $user['id']; $_SESSION[Key::UserId] = $user->id;
$_SESSION[Key::USER_EMAIL] = $user['email']; $_SESSION[Key::UserEmail] = $user->email;
frc_redirect($returnTo ?? '/'); frc_redirect($returnTo->getOrDefault('/'));
} }
} }
@ -78,21 +80,21 @@ class Security {
* *
* @param string $email The e-mail address for the user (cannot be the single-user mode user) * @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 $password The password provided by the user
* @param string|null $returnTo The URL to which the user should be redirected * @param Option<string> $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, Option $returnTo): void
if (SECURITY_MODEL == self::SINGLE_USER_WITH_PASSWORD) { {
$dbEmail = self::SINGLE_USER_EMAIL; if (SECURITY_MODEL === self::SingleUserPasswordMode) {
$dbEmail = self::SingleUserEmail;
} else { } else {
if ($email == self::SINGLE_USER_EMAIL) { if ($email === self::SingleUserEmail) {
add_error('Invalid credentials; log on unsuccessful'); add_error('Invalid credentials; log on unsuccessful');
return; return;
} }
$dbEmail = $email; $dbEmail = $email;
} }
$user = self::findUserByEmail($dbEmail, $db); User::findByEmail($dbEmail)->iter(fn(User $it) => self::verifyPassword($it, $password, $returnTo));
if ($user) self::verifyPassword($user, $password, $returnTo, $db);
add_error('Invalid credentials; log on unsuccessful'); add_error('Invalid credentials; log on unsuccessful');
} }
@ -101,41 +103,41 @@ class Security {
* *
* @param string $email The e-mail address of the user whose password should be updated * @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 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 { public static function updatePassword(string $email, string $password): void
$query = $db->prepare('UPDATE frc_user SET password = :password WHERE email = :email'); {
$query->bindValue(':password', password_hash($password, self::PW_ALGORITHM)); Patch::byFields(Table::User, [Field::EQ('email', $email)],
$query->bindValue(':email', $email); ['password' => password_hash($password, self::PasswordAlgorithm)]);
$query->execute();
} }
/** /**
* Log on the single user * 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 { private static function logOnSingleUser(): void
$user = self::findUserByEmail(self::SINGLE_USER_EMAIL, $db); {
if (!$user) { $user = User::findByEmail(self::SingleUserEmail)->getOrCall(function () {
self::addUser(self::SINGLE_USER_EMAIL, self::SINGLE_USER_PASSWORD, $db); User::add(self::SingleUserEmail, self::SingleUserPassword);
$user = self::findUserByEmail(self::SINGLE_USER_EMAIL, $db); return User::findByEmail(self::SingleUserEmail)->get();
} });
self::verifyPassword($user, self::SINGLE_USER_PASSWORD, $_GET['returnTo'], $db); self::verifyPassword($user, self::SingleUserPassword, $_GET['returnTo']);
} }
/** /**
* Verify that user is logged on * 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 * @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 (array_key_exists(Key::USER_ID, $_SESSION)) return; {
if (key_exists(Key::UserId, $_SESSION)) return;
if (SECURITY_MODEL == self::SINGLE_USER) self::logOnSingleUser($db); if (SECURITY_MODEL === self::SingleUserMode) self::logOnSingleUser();
if (SECURITY_MODEL != self::SINGLE_USER_WITH_PASSWORD && SECURITY_MODEL != self::MULTI_USER) { if (SECURITY_MODEL !== self::SingleUserPasswordMode && SECURITY_MODEL != self::MultiUserMode) {
die('Unrecognized security model (' . SECURITY_MODEL . ')'); die('Unrecognized security model (' . SECURITY_MODEL . ')');
} }

27
src/lib/Table.php Normal file
View File

@ -0,0 +1,27 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
namespace FeedReaderCentral;
/**
* Constants to use when accessing tables
*/
readonly class Table
{
/** @var string The user table */
public const User = 'frc_user';
/** @var string The feed table */
public const Feed = 'feed';
/** @var string The item table */
public const Item = 'item';
/** Prevent instances of this class */
private function __construct() {}
}

65
src/lib/User.php Normal file
View File

@ -0,0 +1,65 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
namespace FeedReaderCentral;
use BitBadger\InspiredByFSharp\Option;
use BitBadger\PDODocument\{Custom, Document, DocumentException, Field, Find, Parameters, Query};
use BitBadger\PDODocument\Mapper\ExistsMapper;
/**
* A user of Feed Reader Central
*/
class User
{
/**
* Constructor
*
* @param int $id The ID of the user
* @param string $email The e-mail address for the user
* @param string $password The password for the user
*/
public function __construct(public int $id = 0, public string $email = '', public string $password = '') { }
/**
* Find a user by their e=mail address
*
* @param string $email The e-mail address of the user to retrieve
* @return Option<User> A `Some` value with the user information if found, `None` otherwise
* @throws DocumentException If any is encountered
*/
public static function findByEmail(string $email): Option
{
return Find::firstByFields(Table::User, [Field::EQ('email', $email)], User::class);
}
/**
* Add a user
*
* @param string $email The e-mail address for the user
* @param string $password The user's password
* @throws DocumentException If any is encountered
*/
public static function add(string $email, string $password): void
{
Document::insert(Table::User, new User(email: $email, password: $password));
}
/**
* Does this user have any bookmarked items?
*
* @return bool True if the user has any bookmarked items, false if not
* @throws DocumentException If any is encountered
*/
public static function hasBookmarks(): bool
{
$fields = [Data::userIdField(Table::Feed), Data::bookmarkField(true, Table::Item)];
return Custom::scalar(Query\Exists::query(ItemWithFeed::FROM_WITH_JOIN, Query::whereByFields($fields)),
Parameters::addFields($fields, []), new ExistsMapper());
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -22,27 +22,31 @@ header {
border-bottom-right-radius: .5rem; border-bottom-right-radius: .5rem;
color: white; color: white;
display: flex; display: flex;
flex-flow: row nowrap; flex-flow: row wrap;
justify-content: space-between; justify-content: space-between;
align-items: baseline; align-items: baseline;
div, nav {
margin-bottom: .25rem;
}
.title { .title {
font-size: 1.5rem; font-size: 1.5rem;
} }
.version { .version {
font-size: .85rem; font-size: .85rem;
padding-left: .5rem; padding-left: .5rem;
color: rgba(255, 255, 255, .75); color: rgba(255, 255, 255, .75);
} }
a:link, a:visited { a:link, a:visited {
color: white; color: white;
} }
nav {
display: flex;
flex-flow: row wrap;
gap: 0 .4rem;
}
} }
main { main {
padding: 0 .5rem; padding: 0 .5rem;
.refresh, .loading { .refresh, .loading {
font-style: italic; font-style: italic;
font-size: .9rem; font-size: .9rem;
@ -50,14 +54,12 @@ main {
.htmx-request .refresh { .htmx-request .refresh {
display: none; display: none;
} }
.loading { .loading {
display: none; display: none;
} }
.htmx-request .loading { .htmx-request .loading {
display: inline; display: inline;
} }
.user_messages { .user_messages {
display: flex; display: flex;
flex-flow: column; flex-flow: column;
@ -70,15 +72,9 @@ main {
background-color: rgba(255, 255, 255, .75); background-color: rgba(255, 255, 255, .75);
padding: .25rem; padding: .25rem;
} }
.user_messages + h1 { .user_messages + h1 {
margin-top: .25rem; margin-top: .25rem;
} }
.item_heading {
margin-bottom: 0;
}
.item_published { .item_published {
margin-bottom: 1rem; margin-bottom: 1rem;
line-height: 1.2; line-height: 1.2;
@ -87,33 +83,73 @@ main {
article { article {
max-width: 60rem; max-width: 60rem;
margin: auto; margin: auto;
.item_content { .item_content {
border: solid 1px navy; border: solid 1px navy;
border-radius: .5rem; border-radius: .5rem;
background-color: white; background-color: white;
padding: .5rem; padding: .5rem;
img { img {
max-width: 100%; max-width: 100%;
object-fit: contain; object-fit: contain;
height: unset;
width: unset;
} }
} }
} .meta {
article.docs { font-size: .9rem;
}
&.docs {
line-height: 1.4rem; line-height: 1.4rem;
}
} }
form {
input[type=url], display: flex;
input[type=text], flex-flow: row wrap;
input[type=email], justify-content: center;
input[type=password] { gap: 0 2rem;
width: 40%; label {
font-size: .9rem;
font-weight: bold;
input, select {
display: block;
}
}
.break {
flex-basis: 100%;
height: 1rem;
width: 0;
}
input[type=url],
input[type=text],
input[type=email],
input[type=password],
select {
min-width: 12rem;
max-width: 100%;
font-size: 1rem; font-size: 1rem;
padding: .25rem; padding: .25rem;
border-radius: .25rem; border-radius: .25rem;
background-color: white;
border: solid 2px navy;
}
select {
min-width: unset;
max-width: unset;
}
}
@media all and (min-width: 60rem) {
form {
input[type=url],
input[type=text],
input[type=email],
input[type=password] {
min-width: 25rem;
}
}
}
.action_buttons {
margin: 1rem 0;
display: flex;
flex-flow: row nowrap;
justify-content: space-evenly;
} }
button, button,
.action_buttons a:link, .action_buttons a:link,
@ -126,18 +162,11 @@ button,
border-radius: .25rem; border-radius: .25rem;
cursor: pointer; cursor: pointer;
border: none; border: none;
} &:hover {
button:hover,
.action_buttons a:hover {
text-decoration: none; text-decoration: none;
cursor: pointer; cursor: pointer;
background: linear-gradient(navy, #000032); background: linear-gradient(navy, #000032);
} }
.action_buttons {
margin: 1rem 0;
display: flex;
flex-flow: row nowrap;
justify-content: space-evenly;
} }
code { code {
font-size: .9rem; font-size: .9rem;
@ -145,3 +174,28 @@ code {
p.back-link { p.back-link {
margin-top: -1rem; margin-top: -1rem;
} }
.item_heading {
margin-bottom: 0;
.bookmark {
padding: 0;
border: solid 1px black;
border-radius: .5rem;
&.add {
background-color: lightgray;
&:hover {
background: linear-gradient(lightgreen, gray);
}
}
&.remove {
background: linear-gradient(lightgreen, green);
&:hover {
background: linear-gradient(gray, lightgreen);
}
}
img {
max-width: 1.5rem;
max-height: 1.5rem;
padding: .5rem;
}
}
}

44
src/public/bookmark.php Normal file
View File

@ -0,0 +1,44 @@
<?php
/**
* Bookmark Partial Handler
*
* This will display a button which will either add or remove a bookmark for a given item.
*
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\InspiredByFSharp\Option;
use BitBadger\PDODocument\{DocumentException, Patch};
use FeedReaderCentral\{ItemWithFeed, Table};
include '../start.php';
FeedReaderCentral\Security::verifyUser();
$id = key_exists('id', $_GET) ? (int)$_GET['id'] : -1;
$item = ItemWithFeed::retrieveById($id)->getOrCall(not_found(...));
if (key_exists('action', $_GET)) {
(match ($_GET['action']) {
'add' => Option::Some(1),
'remove' => Option::Some(0),
default => Option::None(),
})->iter(function (int $flag) use ($id, &$item) {
try {
Patch::byId(Table::Item, $id, ['is_bookmarked' => $flag]);
$item->is_bookmarked = $flag;
} catch (DocumentException $ex) {
add_error("$ex");
}
});
}
$action = $item->isBookmarked() ? 'remove' : 'add';
$icon = $item->isBookmarked() ? 'added' : 'add'; ?>
<button class="bookmark <?=$action?>" type=button role=button hx-patch="/bookmark?id=<?=$id?>&action=<?=$action?>"
hx-target=this hx-swap=outerHTML hx-push-url=false title="<?=init_cap($action)?> Bookmark">
<img src=/assets/bookmark-<?=$icon?>.png alt="<?=$action?> bookmark">
</button><?php

View File

@ -1,18 +1,46 @@
<?php <?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
include '../../start.php'; include '../../start.php';
$db = Data::getConnection(); FeedReaderCentral\Security::verifyUser(redirectIfAnonymous: false);
Security::verifyUser($db, redirectIfAnonymous: false);
page_head('Refresh Feeds | Documentation'); ?> page_head('Feeds | Documentation'); ?>
<h1>Refresh Feeds</h1> <h1>Feeds</h1>
<p class=back-link><a href=./>&lang;&lang; Documentation Home</a> <p class=back-link><?=hx_get('./', '&lang;&lang; Documentation Home')?>
<article class=docs> <article class=docs>
<h2>Manual Feed Refresh</h2> <h2>Adding a Feed</h2>
<p>Next to the &ldquo;Your Unread Items&rdquo; heading on the main page, there is a link labeled &ldquo;Refresh All <p>On the top menu bar, click the <strong>Feeds</strong> link, then click the <strong>Add Feed</strong> button. In
Feeds&rdquo;. Clicking this link will reload the main page once the feeds have been refreshed. Depending on the the field that is displayed, enter the
number and size of feeds, this may take a bit of time; each feed is refreshed individually. <abbr title="Uniform Resource Locator (aka &ldquo;link&rdquo;)">URL</abbr> for the feed. Then click the
<h2>Automatic Refreshing</h2> <strong>Save</strong> button; if all goes well, the application will subscribe to the feed and pull in all its
current items.
<p>If you do not have the feed&rsquo;s direct link, you can enter the URL for the site that hosts the feed. In most
cases, the application should be able to find it and subscribe to it.
<h2>Editing a Feed&rsquo;s URL</h2>
<p>If the feed to which you are subscribed has moved, you can edit the URL of the feed. In this case, the
application will confirm that the new feed exists and will synchronize with its items. Depending on how the feed
was moved, this may result in items reappearing as new; however, bookmarked items will not be removed, and older
items will not be removed until they would otherwise have been pruned.
<h2>Deleting a Feed</h2>
<p>On the <strong>Feeds</strong> page, below each feed&rsquo;s title, there is a <strong>Delete</strong> link at the
end of the line. Once that is clicked, you will be prompted to confirm that you really mean to delete this feed;
if you confirm the deletion, the feed and all its items (including bookmarked items) will be deleted.
<h2>Refreshing Feeds</h2>
<p>Feeds are pulled when their subscriptions are added; however, a one-time pull of feeds does not keep us up to
date on future posts. From within the application, feeds can be updated manually; there is also a way to set up
a job to regularly refresh feeds.
<h3>Manual Refresh</h3>
<p>Next to the &ldquo;Your Unread Items&rdquo; heading on the main page, there is a link labeled
<strong>Refresh All Feeds</strong>. Clicking this link will reload the main page once the feeds have been
refreshed. Depending on the number and size of feeds, this may take a bit of time; each feed is refreshed
individually.
<h3>Automatic Refresh Job <em>(Linux / Mac)</em></h3>
<p>The <code>refresh</code> utility script will perform this refresh from the CLI. As it runs, it will list the <p>The <code>refresh</code> utility script will perform this refresh from the CLI. As it runs, it will list the
feeds as it processes them, and if it encounters any errors, that is noted as well. This process can be feeds as it processes them, and if it encounters any errors, that is noted as well. This process can be
automated via <code>cron</code> on Linux or Mac systems. This is most easily implemented by writing a small automated via <code>cron</code> on Linux or Mac systems. This is most easily implemented by writing a small
@ -49,4 +77,3 @@ php-cli util/refresh.php all</pre>
add <code>nice -n 1</code> (with a trailing space) before the path to the script. add <code>nice -n 1</code> (with a trailing space) before the path to the script.
</article><?php </article><?php
page_foot(); page_foot();
$db->close();

View File

@ -1,16 +1,39 @@
<?php <?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
include '../../start.php'; include '../../start.php';
$db = Data::getConnection(); FeedReaderCentral\Security::verifyUser(redirectIfAnonymous: false);
Security::verifyUser($db, redirectIfAnonymous: false);
page_head('Documentation'); ?> page_head('Documentation'); ?>
<h1>Documentation Home</h1> <h1>Documentation Home</h1>
<article> <article>
<p><a href=./the-cli>About the CLI</a> provides orientation on Feed Reader Central&rsquo;s command line interface <h2>About the Application</h2>
<p><a href=./security-modes>Configuring Security Modes</a> describes the three security modes and how to manage each <p>This application is designed to be a lightweight, near-zero dependency application that individuals can run
of them themselves. The idea that let to its creation was a desire to have a set of
<p><a href=./refresh-feeds>Refresh Feeds</a> has instructions on how feeds can be refreshed on a schedule <abbr title="Really Simple Syndication">RSS</abbr> feeds which could be read from multiple devices on the same
home network; for example, if an item was read from a laptop, it would not show up as new if the feed were read
from a phone. The author had planned to write something similar when Google Reader shut down, and the
combination of those two ideas led to the application you are looking at now.
<h2>Installation</h2>
<p><a href=https://git.bitbadger.solutions/bit-badger/feed-reader-central/src/branch/main/INSTALLING.md
target=_blank rel=noopener>Steps to install this application</a> are found in the Feed Reader Central source
code repository. It also describes some required configuration (mostly surrounding security modes; see the first
feature below for all the details on that) and some optional ways to change the application's behavior.
<h2>Usage</h2>
<p><?=hx_get('./security-modes', 'Security Modes')?> &ndash; Feed Reader Central can be as secure as you require it
to be. Learn about the modes available, their suitability for private networks or the open Internet, and how to
manage users in a multi-user instance.
<p><?=hx_get('./feeds', 'Feeds')?> &ndash; Without feeds, this application is just a bunch of near-empty pages;
this page describes how to add and maintain feeds.
<p><?=hx_get('./items', 'Items')?> &ndash; Read the items from feeds, bookmark them, search for text within them,
and more.
<p><?=hx_get('./the-cli', 'Command Line Interface')?> (CLI) &ndash; Feed Reader Central provides several functions
that can be performed via its command line interface.
</article><?php </article><?php
page_foot(); page_foot();
$db->close();

74
src/public/docs/items.php Normal file
View File

@ -0,0 +1,74 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
include '../../start.php';
FeedReaderCentral\Security::verifyUser(redirectIfAnonymous: false);
page_head('Items | Documentation'); ?>
<h1>Items</h1>
<p class=back-link><?=hx_get('./', '&lang;&lang; Documentation Home')?>
<article class=docs>
<h2>Reading an Item</h2>
<p>Before we look at all the different ways you can produce lists of items, we will take a quick look at how the
item page works. Within a list of items, this page can be accessed via the title of the item.
<ul>
<li>The title of the items is a link to the item at its original source; it will open in a new tab.
<li>The bookmark icon shows its current bookmark status; gray is not bookmarked, green is bookmarked. Clicking
the icon will toggle the status for the item. If the current user has any bookmarked items, a
<strong>Bookmarked</strong> item will appear in the top menu bar.
<em>(The application usually does not replace the menu bar; clicking the
<strong>Feed Reader Central</strong> link will reload it.)</em>
<li>Below the title, the source, published date, and updated date (if applicable) are displayed.
<li>The text of the item is displayed. Images are constrained to be no wider than the screen on which they are
being viewed, but other embedded objects (YouTube videos, etc.) with specific sizes may end up overflowing.
<em>(Web devs, if you have any ideas on how to constrain those as well, I'm all ears&hellip;)</em>
<li>Below the text, there are three buttons. <strong>Done</strong> leaves the item marked as having been read,
<strong>Keep as New</strong> will mark the item as unread, and <strong>Delete</strong> will delete the item
from the database. Any of them will return you to wherever you were when you clicked the title of the item.
</ul>
<h2>Viewing Items for All Feeds</h2>
<p>There are three different ways to view items across all your subscriptions.
<h3>Unread Items</h3>
<p>The main page of the application (after logging on, if required) displays all currently unread items across all
subscribed feeds. The name of the feed is a link to view just that feed&rsquo;s unread items.
<h3>Bookmarked Items</h3>
<p>If you have any bookmarked items, the <strong>Bookmarked</strong> link in the top menu bar will bring you to a
list of all those items. The name of the feed is a link to view just that feed&rsquo;s bookmarked items.
<h3>Searching for Items</h3>
<p>The <strong>Search</strong> link in the top menu bar presents a box where search text can be entered; you can
also choose whether to search all items, or only those that have been bookmarked. The search applies to the
content of each item. Within the search results, there is an indicator displayed if the item is unread or
bookmarked, and the name of the feed is not linked.
<p>Under the hood, this uses
<a href=https://www.sqlite.org/fts5.html#full_text_query_syntax target=_blank rel=noopener>SQLite&rsquo;s FTS5</a>,
so it is case-insensitive by default and provides some interesting ways to query your items.
<ul>
<li><code>election</code> would return items with the word &ldquo;election&rdquo; in them.
<li><code>senat*</code> would return items with words like &ldquo;Senator&rdquo;, &ldquo;senatorial&rdquo;, or
&ldquo;Senate&rdquo; in them.
<li><code>"election 2024"</code> <em>(note the quotes)</em> would return items that have the phrase
&ldquo;Election 2024&rdquo; in them; <code>election 2024</code> would return items that have either
&ldquo;election&rdquo; <em>or</em> &ldquo;2024&rdquo; in them.
<li><code>election AND 2024</code> is the same as above; <code>AND</code> requires that both conditions be
satisfied. (<code>OR</code> is also supported; both these must be uppercase.)
<li><code>election NOT president*</code> would return items containing &ldquo;election&rdquo;, but only if they
did <em>not</em> have words like &ldquo;president&rdquo; or &ldquo;presidential&rdquo;.
</ul>
<h2>Viewing Items by Feed</h2>
<p>Other than the unread and bookmarked links mentioned above, lists of items by feed are linked on the
<strong>Feeds</strong> page. Below each feed&rsquo;s title, there are links for <strong>All</strong>,
<strong>Unread</strong>, and <strong>Bookmarked</strong>, along with a count of items that fit the status.
(If there are no items, the word is not linked.)
<h2>A Note on Sorting</h2>
<p>All lists of items are sorted by date, with the most recent items on top. Both the RSS and Atom specifications
provide both a published date and an updated date. When selecting a date for sorting, the application uses the
updated date if it is present; otherwise, it uses the published date. Some sites update their items more than
others; if the items seem to move around in the list after a refresh, this is likely the cause.
</article><?php
page_foot();

View File

@ -1,12 +1,18 @@
<?php <?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
include '../../start.php'; include '../../start.php';
$db = Data::getConnection(); FeedReaderCentral\Security::verifyUser(redirectIfAnonymous: false);
Security::verifyUser($db, redirectIfAnonymous: false);
page_head('Security Modes | Documentation'); ?> page_head('Security Modes | Documentation'); ?>
<h1>Configuring Security Modes</h1> <h1>Configuring Security Modes</h1>
<p class=back-link><a href=./>&lang;&lang; Documentation Home</a> <p class=back-link><?=hx_get('./', '&lang;&lang; Documentation Home')?>
<article class=docs> <article class=docs>
<h2>Security Modes</h2> <h2>Security Modes</h2>
<p><strong>Single-User</strong> mode assumes that every connection to the application is the same person. It is <p><strong>Single-User</strong> mode assumes that every connection to the application is the same person. It is
@ -38,7 +44,7 @@ page_head('Security Modes | Documentation'); ?>
<p>In Single-User mode, the application uses a known e-mail address and password to mimic multi-user mode where that <p>In Single-User mode, the application uses a known e-mail address and password to mimic multi-user mode where that
user is always logged on. If you have been using the application this way, and decide that you want to run in user is always logged on. If you have been using the application this way, and decide that you want to run in
multi-user mode instead, you will need to update <code>SECURITY_MODEL</code> in <code>user-config.php</code> to multi-user mode instead, you will need to update <code>SECURITY_MODEL</code> in <code>user-config.php</code> to
<code>Security::MULTI_USER</code>. <code>Security::MultiUserMode</code>.
<p>The e-mail address used for Single-User mode is not allowed to log on in Multi-User mode. If you want to preserve <p>The e-mail address used for Single-User mode is not allowed to log on in Multi-User mode. If you want to preserve
the feeds defined by the single user, use the CLI to replace its e-mail address and password. the feeds defined by the single user, use the CLI to replace its e-mail address and password.
<p><code>php-cli utils/user.php migrate-single-user dave@example.com Dav3sPas$wort</code> <p><code>php-cli utils/user.php migrate-single-user dave@example.com Dav3sPas$wort</code>
@ -49,14 +55,13 @@ page_head('Security Modes | Documentation'); ?>
displays feeds from the Single-User mode user. The information for the other users remains in the database, displays feeds from the Single-User mode user. The information for the other users remains in the database,
though, so this change is not destructive. though, so this change is not destructive.
<h2 id=change-single-to-pw>Changing from Single-User to Single-User with Password Mode</h2> <h2 id=change-single-to-pw>Changing from Single-User to Single-User with Password Mode</h2>
<p>Set <code>SECURITY_MODEL</code> in <code>user-config.php</code> to <p>Set <code>SECURITY_MODEL</code> in <code>user-config.php</code> to <code>Security::SingleUserPasswordMode</code>,
<code>Security::SINGLE_USER_WITH_PASSWORD</code>, then use the <code>user</code> CLI utility to set a password. then use the <code>user</code> CLI utility to set a password.
<p><code>php-cli util/user.php set-single-password aNiceC0mplexPassw0rd</code> <p><code>php-cli util/user.php set-single-password aNiceC0mplexPassw0rd</code>
<h2 id=change-pw-to-single>Changing from Single-User with Password to Single-User Mode</h2> <h2 id=change-pw-to-single>Changing from Single-User with Password to Single-User Mode</h2>
<p>If you decide you do not want to enter a password, but want to maintain single-user mode, set <p>If you decide you do not want to enter a password, but want to maintain single-user mode, set
<code>SECURITY_MODEL</code> in <code>user-config.php</code> to <code>Security::SINGLE_USER</code>, then run the <code>SECURITY_MODEL</code> in <code>user-config.php</code> to <code>Security::SingleUserMode</code>, then run
<code>user</code> CLI utility to reset the single user back to its expected default. the <code>user</code> CLI utility to reset the single user back to its expected default.
<p><code>php-cli util/user.php reset-single-password</code> <p><code>php-cli util/user.php reset-single-password</code>
</article><?php </article><?php
page_foot(); page_foot();
$db->close();

View File

@ -1,12 +1,18 @@
<?php <?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
include '../../start.php'; include '../../start.php';
$db = Data::getConnection(); FeedReaderCentral\Security::verifyUser(redirectIfAnonymous: false);
Security::verifyUser($db, redirectIfAnonymous: false);
page_head('About the CLI | Documentation'); ?> page_head('About the CLI | Documentation'); ?>
<h1>About the CLI</h1> <h1>About the CLI</h1>
<p class=back-link><a href=./>&lang;&lang; Documentation Home</a> <p class=back-link><?=hx_get('./', '&lang;&lang; Documentation Home')?>
<article class=docs> <article class=docs>
<p>Feed Reader Central&rsquo;s low-friction design includes having many administrative tasks run in a terminal or <p>Feed Reader Central&rsquo;s low-friction design includes having many administrative tasks run in a terminal or
shell. &ldquo;CLI&rdquo; is short for &ldquo;Command Line Interface&rdquo;, and refers to commands that are run shell. &ldquo;CLI&rdquo; is short for &ldquo;Command Line Interface&rdquo;, and refers to commands that are run
@ -21,4 +27,3 @@ page_head('About the CLI | Documentation'); ?>
<p><code>php-cli util/some-process.php command option1 option2</code> <p><code>php-cli util/some-process.php command option1 option2</code>
</article><?php </article><?php
page_foot(); page_foot();
$db->close();

View File

@ -1,61 +0,0 @@
<?php
/**
* Add/Edit Feed Page
*
* Allows users to add or edit RSS feeds
*/
include '../start.php';
$db = Data::getConnection();
Security::verifyUser($db);
$feedId = $_GET['id'] ?? '';
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
$isNew = $_POST['id'] == 'new';
if ($isNew) {
$result = Feed::add($_POST['url'], $db);
} else {
$toEdit = Data::retrieveFeedById($_POST['id'], $db);
$result = $toEdit ? Feed::update($toEdit, $_POST['url'], $db) : ['error' => "Feed {$_POST['id']} not found"];
}
if (array_key_exists('ok', $result)) {
add_info('Feed saved successfully');
$feedId = $isNew ? $result['ok'] : $_POST['id'];
} else {
add_error($result['error']);
$feedId = 'error';
}
}
if ($feedId == 'new') {
$title = 'Add RSS Feed';
$feed = [ 'id' => $_GET['id'], 'url' => ''];
} else {
$title = 'Edit RSS Feed';
if ($feedId == 'error') {
$feed = ['id' => $_POST['id'] ?? '', 'url' => $_POST['url'] ?? ''];
} else {
$feed = Data::retrieveFeedById((int) $feedId, $db);
if (!$feed) {
http_response_code(404);
die();
}
}
}
page_head($title); ?>
<h1><?=$title?></h1>
<article>
<form method=POST action=/feed hx-post=/feed>
<input type=hidden name=id value=<?=$feed['id']?>>
<label>
Feed URL
<input type=url name=url required autofocus value="<?=$feed['url']?>">
</label><br>
<button type=submit>Save</button>
</form>
</article><?php
page_foot();
$db->close();

82
src/public/feed/index.php Normal file
View File

@ -0,0 +1,82 @@
<?php
/**
* Add/Edit/Delete Feed Page
*
* Allows users to add, edit, and delete feeds
*
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\InspiredByFSharp\Result;
use BitBadger\PDODocument\{Delete, DocumentException, Field};
use FeedReaderCentral\{Feed, Security, Table};
include '../../start.php';
Security::verifyUser();
$feedId = key_exists('id', $_GET) ? (int)$_GET['id'] : -1;
switch ($_SERVER['REQUEST_METHOD']) {
case 'DELETE':
try {
$feed = Feed::retrieveById($feedId)->getOrCall(not_found(...));
Delete::byFields(Table::Item, [Field::EQ('feed_id', $feed->id)]);
Delete::byId(Table::Feed, $feed->id);
add_info('Feed &ldquo;' . htmlentities($feed->title) . '&rdquo; deleted successfully');
frc_redirect('/feeds');
} catch (DocumentException $ex) {
add_error("$ex");
}
break;
case 'POST':
try {
if ((int)$_POST['id'] === -1) {
$result = Feed::add($_POST['url']);
} else {
$feedId = (int)$_POST['id'];
$toEdit = Feed::retrieveById($feedId);
$result = $toEdit->isSome()
? Feed::update($toEdit->get(), $_POST['url'])
: Result::Error("Feed $feedId not found");
}
$result->iter(function () {
add_info('Feed saved successfully');
frc_redirect('/feeds');
});
add_error($result->getError());
$feedId = 'error';
} catch (DocumentException $ex) {
add_error("$ex");
}
break;
}
if ($feedId == -1) {
$title = 'Add RSS Feed';
$feed = new Feed(id: -1);
} else {
$title = 'Edit RSS Feed';
$feed = $feedId == 'error'
? new Feed(id: (int)$_POST['id'], url: $_POST['url'] ?? '')
: Feed::retrieveById((int)$feedId)->getOrCall(not_found(...));
}
page_head($title); ?>
<h1><?=$title?></h1>
<article>
<form method=POST action=/feed/ hx-post=/feed/>
<input type=hidden name=id value=<?=$feed->id?>>
<label>
Feed URL
<input type=url name=url required autofocus value="<?=$feed->url?>">
</label>
<span class=break></span>
<button type=submit>Save</button>
</form>
</article><?php
page_foot();

36
src/public/feed/items.php Normal file
View File

@ -0,0 +1,36 @@
<?php
/**
* Feed Item List Page
*
* Lists items in a given feed (all, unread, or bookmarked)
*
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use FeedReaderCentral\{Feed, ItemList};
include '../../start.php';
FeedReaderCentral\Security::verifyUser();
$id = key_exists('id', $_GET) ? (int)$_GET['id'] : -1;
$feed = Feed::retrieveById($id)->getOrCall(not_found(...));
$list = match (true) {
key_exists('unread', $_GET) => ItemList::unreadForFeed($feed->id),
key_exists('bookmarked', $_GET) => ItemList::bookmarkedForFeed($feed->id),
default => ItemList::allForFeed($feed->id)
};
page_head(($list->itemType === '' ? '' : "$list->itemType Items | ") . strip_tags($feed->title));
if ($list->itemType === '') {
echo '<h1>' . htmlentities($feed->title) . '</h1>';
} else {
echo '<h1 class=item_heading>' . htmlentities($feed->title) . '</h1>';
echo "<div class=item_published>$list->itemType Items</div>";
}
$list->render();
page_foot();

48
src/public/feeds.php Normal file
View File

@ -0,0 +1,48 @@
<?php
/**
* Feed Maintenance Page
*
* List feeds and provide links for maintenance actions
*
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\PDODocument\{Custom, Field, Query};
use BitBadger\PDODocument\Mapper\{ArrayMapper, DocumentMapper};
use FeedReaderCentral\{Feed, Key, Table};
include '../start.php';
FeedReaderCentral\Security::verifyUser();
$field = Field::EQ('user_id', $_SESSION[Key::UserId], ':user');
$feeds = Custom::list(Query\Find::byFields(Table::Feed, [$field]) . " ORDER BY lower(data->>'title')",
$field->appendParameter([]), new DocumentMapper(Feed::class));
page_head('Your Feeds');
echo '<h1>Your Feeds</h1><article><p class=action_buttons>' . hx_get('/feed/?id=-1', 'Add Feed') . '</p>';
$feeds->iter(function (Feed $feed) {
$item = Table::Item;
$counts = Custom::single(<<<SQL
SELECT (SELECT COUNT(*) FROM $item WHERE data->>'feed_id' = :feed) AS total,
(SELECT COUNT(*) FROM $item WHERE data->>'feed_id' = :feed AND data->>'is_read' = 0) AS unread,
(SELECT COUNT(*) FROM $item WHERE data->>'feed_id' = :feed AND data->>'is_bookmarked' = 1) AS marked
SQL, [':feed' => $feed->id], new ArrayMapper())->getOrDefault(['total' => 0, 'unread' => 0, 'marked' => 0]);
echo '<p><strong>' . htmlentities($feed->title) . '</strong><br>'
. '<span class=meta><em>Last Updated ' . date_time($feed->updated_on) . ' &bull; '
. 'As of ' . date_time($feed->checked_on) . '</em><br>' . hx_get("/feed/?id=$feed->id", 'Edit') . ' &bull; '
. 'Read ' . ($counts['unread'] > 0 ? hx_get("/feed/items?id=$feed->id&unread", 'Unread') : 'Unread')
. " ({$counts['unread']}) | "
. ($counts['total'] > 0 ? hx_get("/feed/items?id=$feed->id", 'All') : 'All') . " ({$counts['total']}) | "
. ($counts['marked'] > 0 ? hx_get("/feed/items?id=$feed->id&bookmarked", 'Bookmarked') : 'Bookmarked')
. " ({$counts['marked']}) &bull; "
. "<a href=/feed/?id=$feed->id hx-delete=/feed/?id=$feed->id "
. ' hx-confirm="Are you sure you want to delete &ldquo;' . htmlspecialchars($feed->title)
. '&rdquo;? This will remove the feed and all its items, including unread and bookmarked.">Delete</a>'
. '</span>';
});
echo '</article>';
page_foot();

View File

@ -2,53 +2,41 @@
/** /**
* Home Page * Home Page
* *
* Displays a list of unread feed items for the current user * Displays a list of unread or bookmarked items for the current user
*
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/ */
declare(strict_types=1);
use FeedReaderCentral\{Feed, ItemList};
include '../start.php'; include '../start.php';
$db = Data::getConnection(); FeedReaderCentral\Security::verifyUser();
Security::verifyUser($db);
if (array_key_exists('refresh', $_GET)) { if (key_exists('refresh', $_GET)) {
$refreshResult = Feed::refreshAll($db); $refreshResult = Feed::refreshAll();
if (array_key_exists('ok', $refreshResult)) { if ($refreshResult->isOK()) {
add_info('All feeds refreshed successfully'); add_info('All feeds refreshed successfully');
} else { } else {
add_error(nl2br($refreshResult['error'])); add_error(nl2br($refreshResult->getError()));
} }
} }
$query = $db->prepare(<<<'SQL' $list = match (true) {
SELECT item.id, item.title AS item_title, coalesce(item.updated_on, item.published_on) AS as_of, key_exists('bookmarked', $_GET) => ItemList::allBookmarked(),
feed.title AS feed_title default => ItemList::allUnread(),
FROM item };
INNER JOIN feed ON feed.id = item.feed_id $title = "Your $list->itemType Items";
WHERE feed.user_id = :userId
AND item.is_read = 0
ORDER BY coalesce(item.updated_on, item.published_on) DESC
SQL);
$query->bindValue(':userId', $_SESSION[Key::USER_ID]);
$result = $query->execute();
$item = $result ? $result->fetchArray(SQLITE3_ASSOC) : false;
page_head('Your Unread Items'); ?>
<h1>
Your Unread Items &nbsp;
<a class=refresh href=/?refresh hx-get=/?refresh hx-indicator="closest h1">(Refresh All Feeds)</a>
<span class=loading>Refreshing&hellip;</span>
</h1>
<article><?php
if ($item) {
while ($item) { ?>
<p><a href=/item?id=<?=$item['id']?> hx-get=/item?id=<?=$item['id']?>><?=strip_tags($item['item_title'])?></a>
<br><?=htmlentities($item['feed_title'])?><br><small><em><?=date_time($item['as_of'])?></em></small><?php
$item = $result->fetchArray(SQLITE3_ASSOC);
}
} else { ?>
<p>There are no unread items</p><?php
} ?>
</article><?php
page_head($title);
echo "<h1>$title";
if ($list->itemType == 'Unread') {
echo ' &nbsp; ' . hx_get('/?refresh', '(Refresh All Feeds)', 'class=refresh hx-indicator="closest h1"')
. '<span class=loading>Refreshing&hellip;</span>';
}
echo '</h1>';
$list->render();
page_foot(); page_foot();
$db->close();

View File

@ -1,92 +1,82 @@
<?php <?php
/** /**
* Item View Page * Item View Page
* *
* Retrieves and displays an item from a feed belonging to the current user * Retrieves and displays an item from a feed belonging to the current user
*
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/ */
declare(strict_types=1);
use BitBadger\PDODocument\{Delete, DocumentException, Patch};
use FeedReaderCentral\{ItemWithFeed, Table};
include '../start.php'; include '../start.php';
$db = Data::getConnection(); FeedReaderCentral\Security::verifyUser();
Security::verifyUser($db);
if ($_SERVER['REQUEST_METHOD'] == 'POST') { $id = match (true) {
// "Keep as New" button sends a POST request to reset the is_read flag before going back to the list of unread items key_exists('id', $_POST) => (int)$_POST['id'],
$isValidQuery = $db->prepare(<<<'SQL' key_exists('id', $_GET) => (int)$_GET['id'],
SELECT COUNT(*) default => -1,
FROM item INNER JOIN feed ON feed.id = item.feed_id };
WHERE item.id = :id AND feed.user_id = :user $from = match ($_SERVER['REQUEST_METHOD']) {
SQL); 'POST' => $_POST['from'],
$isValidQuery->bindValue(':id', $_POST['id']); default => $_GET['from'] ?? '/',
$isValidQuery->bindValue(':user', $_SESSION[Key::USER_ID]); };
$isValidResult = $isValidQuery->execute();
if ($isValidResult && $isValidResult->fetchArray(SQLITE3_NUM)[0] == 1) { switch ($_SERVER['REQUEST_METHOD']) {
$keepUnread = $db->prepare('UPDATE item SET is_read = 0 WHERE id = :id'); case 'POST':
$keepUnread->bindValue(':id', $_POST['id']); try {
$keepUnread->execute(); // "Keep as New" button sends a POST request to reset the is_read flag before going back to the item list
if (ItemWithFeed::existsById($id)) {
Patch::byId(Table::Item, $id, ['is_read' => 0]);
} }
$db->close(); frc_redirect($from);
frc_redirect('/'); } catch (DocumentException $ex) {
} add_error("$ex");
if ($_SERVER['REQUEST_METHOD'] == 'DELETE') {
$deleteQuery = $db->prepare(<<<'SQL'
DELETE FROM item
WHERE id IN (
SELECT item.id
FROM item INNER JOIN feed ON feed.id = item.feed_id
WHERE item.id = :id
AND feed.user_id = :user)
SQL);
$deleteQuery->bindValue(':id', $_GET['id']);
$deleteQuery->bindValue(':user', $_SESSION[Key::USER_ID]);
if ($deleteQuery->execute()) {
add_info('Item deleted');
} else {
add_error(Data::error($db)['error']);
} }
$db->close(); break;
frc_redirect('/');
case 'DELETE':
try {
if (ItemWithFeed::existsById($id)) Delete::byId(Table::Item, $id);
} catch (DocumentException $ex) {
add_error("$ex");
}
frc_redirect($from);
} }
$query = $db->prepare(<<<'SQL' $item = ItemWithFeed::retrieveById($id)->getOrCall(not_found(...));
SELECT item.title AS item_title, item.item_link, item.published_on, item.updated_on, item.content, try {
feed.title AS feed_title Patch::byId(Table::Item, $id, ['is_read' => 1]);
FROM item INNER JOIN feed ON feed.id = item.feed_id } catch (DocumentException $ex) {
WHERE item.id = :id add_error("$ex");
AND feed.user_id = :user
SQL);
$query->bindValue(':id', $_GET['id']);
$query->bindValue(':user', $_SESSION[Key::USER_ID]);
$result = $query->execute();
$item = $result ? $result->fetchArray(SQLITE3_ASSOC) : false;
if ($item) {
$markRead = $db->prepare('UPDATE item SET is_read = 1 WHERE id = :id');
$markRead->bindValue(':id', $_GET['id']);
$markRead->execute();
} }
$published = date_time($item['published_on']); $published = date_time($item->published_on);
$updated = isset($item['updated_on']) ? date_time($item['updated_on']) : null; $updated = isset($item->updated_on) ? date_time($item->updated_on) : null;
page_head(htmlentities("{$item['item_title']} | {$item['feed_title']}")); ?> page_head(htmlentities("$item->title | {$item->feed->title}")); ?>
<h1 class=item_heading> <h1 class=item_heading>
<a href="<?=$item['item_link']?>" target=_blank rel=noopener><?=strip_tags($item['item_title'])?></a><br> <span class=bookmark hx-get="/bookmark?id=<?=$_GET['id']?>" hx-trigger=load hx-target=this hx-swap=outerHTML
hx-push-url=false></span>
<a href="<?=$item->item_link?>" target=_blank rel=noopener><?=strip_tags($item->title)?></a><br>
</h1> </h1>
<div class=item_published> <div class=item_published>
From <strong><?=htmlentities($item['feed_title'])?></strong><br> From <strong><?=htmlentities($item->feed->title)?></strong><br>
Published <?=date_time($item['published_on'])?><?=$updated && $updated != $published ? " (Updated $updated)" : ''?> Published <?=date_time($item->published_on)?><?=$updated && $updated != $published ? " (Updated $updated)" : ''?>
</div> </div>
<article> <article>
<div class=item_content><?=str_replace('<a ', '<a target=_blank rel=noopener ', $item['content'])?></div> <div class=item_content><?=str_replace('<a ', '<a target=_blank rel=noopener ', $item->content)?></div>
<form class=action_buttons action=/item method=POST hx-post=/item> <form class=action_buttons action=/item method=POST hx-post=/item>
<input type=hidden name=id value=<?=$_GET['id']?>> <input type=hidden name=id value=<?=$_GET['id']?>>
<a href=/ hx-get="/">Done</a> <input type=hidden name=from value="<?=$from?>">
<?=hx_get($from, 'Done')?>
<button type=submit>Keep as New</button> <button type=submit>Keep as New</button>
<button type=button hx-delete=/item>Delete</button> <button type=button hx-delete=/item>Delete</button>
</form> </form>
</article><?php </article><?php
page_foot(); page_foot();
$db->close();

47
src/public/search.php Normal file
View File

@ -0,0 +1,47 @@
<?php
/**
* Item Search Page
*
* Search for items across all feeds
*
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
include '../start.php';
FeedReaderCentral\Security::verifyUser();
$search = $_GET['search'] ?? '';
$items = $_GET['items'] ?? 'all';
if ($search !== '') {
$list = FeedReaderCentral\ItemList::matchingSearch($search, $items === 'bookmarked');
}
page_head('Item Search'); ?>
<h1>Item Search</h1>
<article>
<form method=GET action=/search>
<label>
Search Criteria
<input type=text name=search required autofocus value="<?=htmlspecialchars($search)?>">
</label>
<label>
Items to Search
<select name=items>
<option value=all <?=$items === 'all' ? ' selected' : ''?>>All</option>
<option value=bookmarked <?=$items === 'bookmarked' ? ' selected' : ''?>>Bookmarked</option>
</select>
</label>
<span class=break></span>
<button type=submit>Search</button>
</form><?php
if (isset($list)) { ?>
<hr><?php
$list->render();
} ?>
</article><?php
page_foot();

View File

@ -1,10 +1,15 @@
<?php <?php
/** /**
* User Log Off Page * User Log Off Page
*
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/ */
declare(strict_types=1);
include '../../start.php'; include '../../start.php';
if (array_key_exists(Key::USER_ID, $_SESSION)) session_destroy(); if (key_exists(FeedReaderCentral\Key::UserId, $_SESSION)) session_destroy();
frc_redirect('/'); frc_redirect('/');

View File

@ -1,39 +1,53 @@
<?php <?php
/**
* User Log On Page
*
* Accepts the user's e-mail address (multi-user) and password (multi-user or single-user-with-password) and attempts
* to log them on to Feed Reader Central
*
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
include '../../start.php'; include '../../start.php';
$db = Data::getConnection(); use BitBadger\InspiredByFSharp\Option;
Security::verifyUser($db, redirectIfAnonymous: false); use FeedReaderCentral\{Key, Security};
Security::verifyUser(redirectIfAnonymous: false);
// Users already logged on have no need of this page // Users already logged on have no need of this page
if (array_key_exists(Key::USER_ID, $_SESSION)) frc_redirect('/'); if (key_exists(Key::UserId, $_SESSION)) frc_redirect('/');
if ($_SERVER['REQUEST_METHOD'] == 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
Security::logOnUser($_POST['email'] ?? '', $_POST['password'], $_POST['returnTo'] ?? null, $db); Security::logOnUser($_POST['email'] ?? '', $_POST['password'], Option::of($_POST['returnTo'] ?? null));
// If we're still here, something didn't work; preserve the returnTo parameter // If we're still here, something didn't work; preserve the returnTo parameter
$_GET['returnTo'] = $_POST['returnTo']; $_GET['returnTo'] = $_POST['returnTo'];
} }
$isSingle = SECURITY_MODEL == Security::SINGLE_USER_WITH_PASSWORD; $isSingle = SECURITY_MODEL === Security::SingleUserPasswordMode;
page_head('Log On'); ?> page_head('Log On'); ?>
<h1>Log On</h1> <h1>Log On</h1>
<article> <article>
<form method=POST action=/user/log-on><?php <form method=POST action=/user/log-on><?php
if (($_GET['returnTo'] ?? '') != '') { ?> if (($_GET['returnTo'] ?? '') !== '') { ?>
<input type=hidden name=returnTo value="<?=$_GET['returnTo']?>"><?php <input type=hidden name=returnTo value="<?=$_GET['returnTo']?>"><?php
} }
if (!$isSingle) { ?> if (!$isSingle) { ?>
<label> <label>
E-mail Address E-mail Address
<input type=email name=email required autofocus> <input type=email name=email required autofocus>
</label><br><?php </label><?php
} ?> } ?>
<label> <label>
Password Password
<input type=password name=password required<?=$isSingle ? ' autofocus' : ''?>> <input type=password name=password required<?=$isSingle ? ' autofocus' : ''?>>
</label><br> </label>
<span class=break></span>
<button type=submit>Log On</button> <button type=submit>Log On</button>
</form> </form>
</article><?php </article><?php
page_foot(); page_foot();
$db->close();

View File

@ -0,0 +1,9 @@
# Rename this to Caddyfile and place it in the directory where the release is unarchived
http://localhost:8205 {
root * ./public
# May change based on your PHP FPM configuration
php_fastcgi //unix/run/php/php8.3-fpm.sock {
try_files {path} {path}.php
}
file_server
}

View File

@ -1,9 +1,10 @@
# Rename this to Caddyfile and place it in the directory where the release is unarchived
{ {
frankenphp frankenphp
order php_server before file_server order php_server before file_server
} }
http://localhost:8205 { http://localhost:8205 {
root ./public root * ./public
try_files {path} {path}.php try_files {path} {path}.php
php_server php_server
} }

5
src/servers/htaccess Normal file
View File

@ -0,0 +1,5 @@
# Name this .htaccess and place it in the directory where the release is unarchived
RewriteEngine on
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
Rewrite "^(.*)" "$1.php" [QSA,L]

27
src/servers/nginx.conf Normal file
View File

@ -0,0 +1,27 @@
## nginx Configuration for Feed Reader Central using FastCGI Process Manager (FPM)
server {
server_name server.name;
listen 80;
listen [::]:80;
# /path/to/files is the root directory for the site
access_log /path/to/files/log/access.log;
error_log /path/to/files/log/error.log;
# /path/to/files/app is where the release distribution should be unarchived
root /path/to/files/app/public;
index index.php;
location ~[^/]\.php(/|$) {
fastcgi_split_path_info ^(.+?\.php)(/.*)$;
if (!-f $document_root$fastcgi_script_name) {
return 404;
}
# This may need to change based on the configuration of PHP FPM
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_index index.php;
include /etc/nginx/fastcgi_params;
}
location / {
try_files $uri $uri/ $uri.php$is_args$args;
}
}

View File

@ -1,5 +1,17 @@
<?php <?php
use JetBrains\PhpStorm\NoReturn; /**
* Web Request Start Script
*
* This loads the environment needed for a web request
*
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\PDODocument\Configuration;
use FeedReaderCentral\{Key, Security, User};
require 'app-config.php'; require 'app-config.php';
@ -15,9 +27,10 @@ session_start([
* @param string $level The level (type) of the message * @param string $level The level (type) of the message
* @param string $message The message itself * @param string $message The message itself
*/ */
function add_message(string $level, string $message): void { function add_message(string $level, string $message): void
if (!array_key_exists(Key::USER_MSG, $_SESSION)) $_SESSION[Key::USER_MSG] = array(); {
$_SESSION[Key::USER_MSG][] = ['level' => $level, 'message' => $message]; if (!key_exists(Key::UserMsg, $_SESSION)) $_SESSION[Key::UserMsg] = [];
$_SESSION[Key::UserMsg][] = ['level' => $level, 'message' => $message];
} }
/** /**
@ -25,7 +38,8 @@ function add_message(string $level, string $message): void {
* *
* @param string $message The message to be displayed * @param string $message The message to be displayed
*/ */
function add_error(string $message): void { function add_error(string $message): void
{
add_message('ERROR', $message); add_message('ERROR', $message);
} }
@ -34,79 +48,87 @@ function add_error(string $message): void {
* *
* @param string $message The message to be displayed * @param string $message The message to be displayed
*/ */
function add_info(string $message): void { function add_info(string $message): void
{
add_message('INFO', $message); add_message('INFO', $message);
} }
/** @var bool $is_htmx True if this request was initiated by htmx, false if not */ /** True if this request was initiated by htmx, false if not */
$is_htmx = array_key_exists('HTTP_HX_REQUEST', $_SERVER) $is_htmx = key_exists('HTTP_HX_REQUEST', $_SERVER) && !key_exists('HTTP_HX_HISTORY_RESTORE_REQUEST', $_SERVER);
&& !array_key_exists('HTTP_HX_HISTORY_RESTORE_REQUEST', $_SERVER);
/**
* Create a navigation link in the top right nav bar
*
* @param string $link The link to be placed
* @param bool $isFirst True if this is the first link being placed, false if not
*/
function nav_link(string $link, bool $isFirst = false): void
{
$sep = $isFirst ? '' : ' | ';
echo "<span>$sep$link</span>";
}
/**
* Render the title bar for the page
*/
function title_bar(): void
{
$version = display_version();
echo "<header hx-target=#main hx-push-url=true>"
. "<div><a href=/ class=title>Feed Reader Central</a><span class=version>$version</span></div>"
. "<nav>";
if (key_exists(Key::UserId, $_SESSION)) {
nav_link(hx_get('/feeds', 'Feeds'), true);
if (User::hasBookmarks()) nav_link(hx_get('/?bookmarked', 'Bookmarked'));
nav_link(hx_get('/search', 'Search'));
nav_link(hx_get('/docs/', 'Docs'));
nav_link('<a href=/user/log-off>Log Off</a>');
if ($_SESSION[Key::UserEmail] !== Security::SingleUserEmail) {
nav_link($_SESSION[Key::UserEmail]);
}
} else {
nav_link(hx_get('/user/log-on', 'Log On'), true);
nav_link(hx_get('/docs/', 'Docs'));
}
echo '</nav></header>'
. '<main id=main hx-target=this hx-push-url=true hx-swap="innerHTML show:window:top">';
}
/** /**
* Render the page title * Render the page title
* @param string $title The title of the page being displayed * @param string $title The title of the page being displayed
*/ */
function page_head(string $title): void { function page_head(string $title): void
{
global $is_htmx; global $is_htmx;
$version = match (true) { echo '<!DOCTYPE html><html lang=en>'
str_ends_with(FRC_VERSION, '.0.0') => substr(FRC_VERSION, 0, strlen(FRC_VERSION) - 4), . "<head><title>$title | Feed Reader Central</title>";
str_ends_with(FRC_VERSION, '.0') => substr(FRC_VERSION, 0, strlen(FRC_VERSION) - 2), if (!$is_htmx) {
default => FRC_VERSION echo '<meta name=viewport content="width=device-width, initial-scale=1">'
}; . "<meta name=htmx-config content='{\"historyCacheSize\":0}'>"
//if ($is_htmx) header('HX-Push-Url: true'); . '<link href=/assets/style.css rel=stylesheet>';
?>
<!DOCTYPE html>
<html lang=en>
<head>
<title><?=$title?> | Feed Reader Central</title><?php
if (!$is_htmx) { ?>
<meta name=viewport content="width=device-width, initial-scale=1">
<link href=/assets/style.css rel=stylesheet><?php
} ?>
</head>
<body><?php
if (!$is_htmx) { ?>
<header hx-target=#main hx-push-url=true>
<div><a class=title href=/ hx-get="/">Feed Reader Central</a><span class=version>v<?=$version?></span></div>
<div><?php
if (array_key_exists(Key::USER_ID, $_SESSION)) { ?>
<a href=/feed?id=new hx-get=/feed?id=new>Add Feed</a> |
<a href=/docs/ hx-get=/docs/>Docs</a> |
<a href=/user/log-off hx-get=/user/log-off>Log Off</a><?php
if ($_SESSION[Key::USER_EMAIL] != Security::SINGLE_USER_EMAIL) { ?>
| <?=$_SESSION[Key::USER_EMAIL]?><?php
} }
} else { ?> echo '</head><body>';
<a href=/user/log-on hx-get=/user/log-on>Log On</a> | <a href=/docs/ hx-get=/docs/>Docs</a><?php if (!$is_htmx) title_bar();
} ?> if (sizeof($messages = $_SESSION[Key::UserMsg] ?? []) > 0) {
</div> echo '<div class=user_messages>';
</header> array_walk($messages, function ($msg) {
<main id=main hx-target=this hx-push-url=true hx-swap="innerHTML show:window:top"><?php echo '<div class=user_message>'
} . ($msg['level'] === 'INFO' ? '' : "<strong>{$msg['level']}</strong><br>")
if (sizeof($messages = $_SESSION[Key::USER_MSG] ?? []) > 0) { ?> . $msg['message'] . '</div>';
<div class=user_messages><?php });
array_walk($messages, function ($msg) { ?> echo '</div>';
<div class=user_message> $_SESSION[Key::UserMsg] = [];
<?=$msg['level'] == 'INFO' ? '' : "<strong>{$msg['level']}</strong><br>"?>
<?=$msg['message']?>
</div><?php
}); ?>
</div><?php
$_SESSION[Key::USER_MSG] = [];
} }
} }
/** /**
* Render the end of the page * Render the end of the page
*/ */
function page_foot(): void { function page_foot(): void
global $is_htmx; ?> {
</main><?php global $is_htmx;
if (!$is_htmx) { ?> echo '</main>' . ($is_htmx ? '' : '<script src=/assets/htmx.min.js></script>') . '</body></html>';
<script src=/assets/htmx.min.js></script><?php
} ?>
</body>
</html><?php
session_commit(); session_commit();
} }
@ -115,14 +137,15 @@ function page_foot(): void {
* *
* @param string $value A local URL to which the user should be redirected * @param string $value A local URL to which the user should be redirected
*/ */
#[NoReturn] function frc_redirect(string $value): never
function frc_redirect(string $value): void { {
if (str_starts_with($value, 'http')) { if (str_starts_with($value, 'http')) {
http_response_code(400); http_response_code(400);
die(); die();
} }
session_commit(); session_commit();
header("Location: $value", true, 303); header("Location: $value", true, 303);
Configuration::resetPDO();
die(); die();
} }
@ -132,10 +155,34 @@ function frc_redirect(string $value): void {
* @param string $value The date/time string * @param string $value The date/time string
* @return string The standard format of a date/time, or '(invalid date)' if the date could not be parsed * @return string The standard format of a date/time, or '(invalid date)' if the date could not be parsed
*/ */
function date_time(string $value): string { function date_time(string $value): string
{
try { try {
return (new DateTimeImmutable($value))->format(DATE_TIME_FORMAT); return (new DateTimeImmutable($value))->format(DATE_TIME_FORMAT);
} catch (Exception) { } catch (Exception) {
return '(invalid date)'; return '(invalid date)';
} }
} }
/**
* Create an anchor tag with both `href` and `hx-get` attributes
*
* @param string $url The URL to which navigation should occur
* @param string $text The text for the link
* @param string $extraAttrs Extra attributes for the anchor tag (must be attribute-encoded)
* @return string The anchor tag with both `href` and `hx-get` attributes
*/
function hx_get(string $url, string $text, string $extraAttrs = ''): string
{
$attrs = $extraAttrs !== '' ? " $extraAttrs" : '';
return "<a href=\"$url\" hx-get=\"$url\"$attrs>$text</a>";
}
/**
* Return a 404 Not Found
*/
function not_found(): never
{
http_response_code(404);
die('Not Found');
}

View File

@ -1,4 +1,5 @@
<?php <?php declare(strict_types=1);
/** /**
* USER CONFIGURATION ITEMS * USER CONFIGURATION ITEMS
* *
@ -7,14 +8,15 @@
* On initial installation, rename this file to user-config.php and configure it as desired * On initial installation, rename this file to user-config.php and configure it as desired
*/ */
use FeedReaderCentral\{Feed, Security};
/** /**
* Which security model should the application use? Options are: * Which security model should the application use? Options are:
* - Security::SINGLE_USER (no e-mail required, does not require a password) * - Security::SingleUserMode (no e-mail required, does not require a password)
* - Security::SINGLE_USER_WITH_PASSWORD (no e-mail required, does require a password) * - Security::SingleUserPasswordMode (no e-mail required, does require a password)
* - Security::MULTI_USER (e-mail and password required for all users) * - Security::MultiUserMode (e-mail and password required for all users)
*/ */
const SECURITY_MODEL = 'CONFIGURE_ME'; const SECURITY_MODEL = 'ConfigureMe';
/** The name of the database file where users and feeds should be kept */ /** The name of the database file where users and feeds should be kept */
const DATABASE_NAME = 'frc.db'; const DATABASE_NAME = 'frc.db';
@ -28,12 +30,12 @@ const DATE_TIME_FORMAT = 'F j, Y \a\t g:ia';
/** /**
* How should item purging be done? (Purging never applies to bookmarked items.) Options are: * How should item purging be done? (Purging never applies to bookmarked items.) Options are:
* - Feed::PURGE_NONE - Do not purge items * - Feed::PurgeNone - Do not purge items
* - Feed::PURGE_READ - Purge all read items whenever purging is run (will not purge unread items) * - Feed::PurgeRead - Purge all read items whenever purging is run (will not purge unread items)
* - Feed::PURGE_BY_DAYS - Purge read and unread items older than a number of days (PURGE_NUMBER below) * - Feed::PurgeByDays - Purge read and unread items older than a number of days (PURGE_NUMBER below)
* - Feed::PURGE_BY_COUNT - Purge read and unread items beyond the number to keep (PURGE_NUMBER below) * - Feed::PurgeByCount - Purge read and unread items beyond the number to keep (PURGE_NUMBER below)
*/ */
const PURGE_TYPE = Feed::PURGE_BY_DAYS; const PURGE_TYPE = Feed::PurgeByDays;
/** /**
* For purge-by-days, how many days of items should be kept; for purge-by-count, how many items should be kept * For purge-by-days, how many days of items should be kept; for purge-by-count, how many items should be kept

121
src/util/db-update.php Normal file
View File

@ -0,0 +1,121 @@
<?php
/**
* Alpha 7 -> Beta 1 Database Update Utility
*
* Between these two versions, the data format changed; this utility migrates existing data to its new format.
*
* _It will be removed in v1.1._
*
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\PDODocument\{Custom, Document, DocumentException};
use BitBadger\PDODocument\Mapper\{ArrayMapper, ExistsMapper};
use FeedReaderCentral\{Data, Feed, Item, Table, User};
require __DIR__ . '/../cli-start.php';
cli_title('DATABASE UPDATE');
if ($argc < 2) display_help();
switch ($argv[1]) {
case 'check':
check_status();
break;
case 'run':
run_update();
break;
default:
printfn('Unrecognized option "%s"', $argv[1]);
display_help();
}
/**
* Display the options for this utility and exit
*/
function display_help(): never
{
printfn('Options:');
printfn(' - check');
printfn(' Check to see if the configured database has been updated');
printfn(' - run');
printfn(' Run the beta1 database storage update');
exit(0);
}
function json_column_exists(): bool
{
try {
$table = Custom::single("SELECT sql FROM sqlite_master WHERE tbl_name='frc_user'", [], new ArrayMapper())
->getOrDefault(['sql' => '']);
return $table && substr_compare(strtolower($table['sql']), 'data text not null', 0) >= 0;
} catch (DocumentException $ex) {
printfn("ERR $ex");
return false;
}
}
function check_status(): void
{
if (json_column_exists()) {
printfn('The database has already been updated');
} else {
printfn('The database has yet to be updated');
}
}
function run_update(): void
{
try {
$searchExists = Custom::scalar("SELECT EXISTS (SELECT 1 FROM sqlite_master WHERE name = 'item_ai')", [],
new ExistsMapper());
if ($searchExists) {
printfn('Removing search index...');
Custom::nonQuery('DROP TRIGGER item_ai', []);
Custom::nonQuery('DROP TRIGGER item_au', []);
Custom::nonQuery('DROP TRIGGER item_ad', []);
Custom::nonQuery('DROP TABLE item_search', []);
}
printfn('Moving old tables...');
Custom::nonQuery('ALTER TABLE item RENAME TO old_item', []);
Custom::nonQuery('ALTER TABLE feed RENAME TO old_feed', []);
Custom::nonQuery('ALTER TABLE frc_user RENAME TO old_user', []);
printfn('Creating new tables...');
Data::ensureDb();
printfn('Migrating users...');
$users = Custom::list('SELECT * FROM old_user', [], new ArrayMapper());
if (!$users->hasItems()) throw new DocumentException('Could not retrieve users');
foreach ($users->items() as $user) {
Document::insert(Table::User, new User($user['id'], $user['email'], $user['password']));
}
printfn('Migrating feeds...');
$feeds = Custom::list('SELECT * FROM old_feed', [], new ArrayMapper());
if (!$feeds->hasItems()) throw new DocumentException('Could not retrieve feeds');
foreach ($feeds->items() as $feed) {
Document::insert(Table::Feed,
new Feed($feed['id'], $feed['user_id'], $feed['url'], $feed['title'], $feed['updated_on'],
$feed['checked_on']));
}
printfn('Migrating items...');
$items = Custom::list('SELECT * FROM old_item', [], new ArrayMapper());
if (!$items->hasItems()) throw new DocumentException('Could not retrieve items');
foreach ($items->items() as $item) {
Document::insert(Table::Item,
new Item($item['id'], $item['feed_id'], $item['title'], $item['item_guid'], $item['item_link'],
$item['published_on'], $item['updated_on'], $item['content'], $item['is_read'],
$item['is_bookmarked']));
}
printfn('Dropping old tables...');
Custom::nonQuery('DROP TABLE old_item', []);
Custom::nonQuery('DROP TABLE old_feed', []);
Custom::nonQuery('DROP TABLE old_user', []);
printfn(PHP_EOL. 'Migration complete!');
} catch (DocumentException $ex) {
printfn("ERR $ex");
}
}

View File

@ -1,5 +1,18 @@
<?php <?php
use JetBrains\PhpStorm\NoReturn; /**
* Feed Refresh Utility
*
* This will refresh all known feeds in the database; it is suitable for executing via cron or as a scheduled task
*
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\InspiredByFSharp\Result;
use BitBadger\PDODocument\{DocumentException, Find};
use FeedReaderCentral\{Feed, Table, User};
require __DIR__ . '/../cli-start.php'; require __DIR__ . '/../cli-start.php';
@ -20,34 +33,35 @@ switch ($argv[1]) {
/** /**
* Display the options for this utility and exit * Display the options for this utility and exit
*/ */
#[NoReturn] function display_help(): never
function display_help(): void { {
printfn('Options:'); printfn('Options:');
printfn(' - all'); printfn(' - all');
printfn(' Refreshes all feeds'); printfn(' Refreshes all feeds');
exit(0); exit(0);
} }
function refresh_all(): void { function refresh_all(): void
$db = Data::getConnection(); {
try { try {
$feeds = Feed::retrieveAll($db); $users = [];
if (array_key_exists('error', $feeds)) { Feed::retrieveAll()->iter(function (Feed $feed) use (&$users) {
printfn('SQLite error: %s', $feeds['error']); $result = Feed::refreshFeed($feed->id, $feed->url);
return; $userKey = "$feed->user_id";
if (!key_exists($userKey, $users)) {
$users[$userKey] = Find::byId(Table::User, $feed->user_id, User::class)
->getOrDefault(new User(email: 'user-not-found'));
} }
array_walk($feeds, function ($feed) use ($db) { if ($result->isError()) {
$result = Feed::refreshFeed($feed['id'], $feed['url'], $db); printfn('ERR (%s) %s', $users[$userKey]->email, $feed->url);
if (array_key_exists('error', $result)) { printfn(' %s', $result->getError());
printfn('ERR (%s) %s', $feed['email'], $feed['url']);
printfn(' %s', $result['error']);
} else { } else {
printfn('OK (%s) %s', $feed['email'], $feed['url']); printfn('OK (%s) %s', $users[$userKey]->email, $feed->url);
} }
}); });
printfn(PHP_EOL . 'All feeds refreshed'); printfn(PHP_EOL . 'All feeds refreshed');
} finally { } catch (DocumentException $ex) {
$db->close(); printfn("ERR $ex");
return;
} }
} }

62
src/util/search.php Normal file
View File

@ -0,0 +1,62 @@
<?php
/**
* Search Maintenance Utility
*
* This allows on-demand refreshing of the search index (should be unnecessary in normal use)
*
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\PDODocument\{Custom, DocumentException};
use BitBadger\PDODocument\Mapper\ExistsMapper;
use FeedReaderCentral\Data;
require __DIR__ . '/../cli-start.php';
cli_title('SEARCH MAINTENANCE');
if ($argc < 2) display_help();
switch ($argv[1]) {
case 'rebuild':
rebuild_index();
break;
default:
printfn('Unrecognized option "%s"', $argv[1]);
display_help();
}
/**
* Display the options for this utility and exit
*/
function display_help(): never
{
printfn('Options:');
printfn(' - rebuild');
printfn(' Rebuilds search index');
exit(0);
}
/**
* Rebuild the search index, creating it if it does not already exist
*/
function rebuild_index(): void
{
try {
$hasIndex = Custom::scalar("SELECT EXISTS (SELECT 1 FROM sqlite_master WHERE name = 'item_ai')", [],
new ExistsMapper());
if (!$hasIndex) {
printfn('Creating search index....');
Data::createSearchIndex();
}
printfn('Rebuilding search index...');
Custom::nonQuery("INSERT INTO item_search (item_search) VALUES ('rebuild')", []);
printfn(PHP_EOL . 'Search index rebuilt');
} catch (DocumentException $ex) {
printfn("$ex");
}
}

View File

@ -1,5 +1,19 @@
<?php <?php
use JetBrains\PhpStorm\NoReturn; /**
* User Maintenance Utility
*
* This provides several user maintenance functions for Feed Reader Central; none of these are available through the web
* interface
*
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\InspiredByFSharp\Option;
use BitBadger\PDODocument\{Count, Custom, Delete, DocumentException, Field, Parameters, Patch, Query};
use FeedReaderCentral\{Security, Table, User};
require __DIR__ . '/../cli-start.php'; require __DIR__ . '/../cli-start.php';
@ -34,10 +48,10 @@ switch ($argv[1]) {
printfn('Missing parameters: set-single-password requires a new password'); printfn('Missing parameters: set-single-password requires a new password');
exit(-1); exit(-1);
} }
set_password(Security::SINGLE_USER_EMAIL, $argv[2]); set_password(Security::SingleUserEmail, $argv[2]);
break; break;
case 'reset-single-password': case 'reset-single-password':
set_password(Security::SINGLE_USER_EMAIL, Security::SINGLE_USER_PASSWORD); set_password(Security::SingleUserEmail, Security::SingleUserPassword);
break; break;
case 'migrate-single-user': case 'migrate-single-user':
if ($argc < 4) { if ($argc < 4) {
@ -47,7 +61,7 @@ switch ($argv[1]) {
migrate_single_user(); migrate_single_user();
break; break;
case 'remove-single-user': case 'remove-single-user':
delete_user(Security::SINGLE_USER_EMAIL); delete_user(Security::SingleUserEmail);
break; break;
default: default:
printfn('Unrecognized option "%s"', $argv[1]); printfn('Unrecognized option "%s"', $argv[1]);
@ -58,8 +72,8 @@ switch ($argv[1]) {
/** /**
* Display the options for this utility and exit * Display the options for this utility and exit
*/ */
#[NoReturn] function display_help(): never
function display_help(): void { {
printfn('Options:'); printfn('Options:');
printfn(' - add-user [e-mail] [password]'); printfn(' - add-user [e-mail] [password]');
printfn(' Adds a new user to this instance'); printfn(' Adds a new user to this instance');
@ -83,24 +97,23 @@ function display_help(): void {
/** /**
* Add a new user * Add a new user
*/ */
function add_user(): void { function add_user(): void
{
global $argv; global $argv;
$db = Data::getConnection();
try { try {
// Ensure there is not already a user with this e-mail address // Ensure there is not already a user with this e-mail address
$user = Security::findUserByEmail($argv[2], $db); $user = User::findByEmail($argv[2]);
if ($user) { if ($user->isSome()) {
printfn('A user with e-mail address "%s" already exists', $argv[2]); printfn('A user with e-mail address "%s" already exists', $argv[2]);
return; return;
} }
Security::addUser($argv[2], $argv[3], $db); User::add($argv[2], $argv[3]);
printfn('User "%s" with password "%s" added successfully', $argv[2], $argv[3]); printfn('User "%s" with password "%s" added successfully', $argv[2], $argv[3]);
} finally { } catch (DocumentException $ex) {
$db->close(); printfn("$ex");
} }
} }
@ -110,32 +123,33 @@ function add_user(): void {
* @param string $email The e-mail address of the user * @param string $email The e-mail address of the user
* @return string The string to use when displaying results * @return string The string to use when displaying results
*/ */
function display_user(string $email): string { function display_user(string $email): string
return $email == Security::SINGLE_USER_EMAIL ? 'single-user mode user' : "user \"$email\""; {
return $email === Security::SingleUserEmail ? 'single-user mode user' : "user \"$email\"";
} }
/** /**
* Set a user's password * Set a user's password
*/ */
function set_password(string $email, string $password): void { function set_password(string $email, string $password): void
$db = Data::getConnection(); {
try { try {
$displayUser = display_user($email); $displayUser = display_user($email);
// Ensure this user exists // Ensure this user exists
$user = Security::findUserByEmail($email, $db); $user = User::findByEmail($email);
if (!$user) { if ($user->isNone()) {
printfn('No %s exists', $displayUser); printfn('No %s exists', $displayUser);
return; return;
} }
Security::updatePassword($email, $password, $db); Security::updatePassword($email, $password);
$msg = $email == Security::SINGLE_USER_EMAIL && $password == Security::SINGLE_USER_PASSWORD $msg = $email === Security::SingleUserEmail && $password === Security::SingleUserPassword
? 'reset' : sprintf('set to "%s"', $password); ? 'reset' : sprintf('set to "%s"', $password);
printfn('%s password %s successfully', init_cap($displayUser), $msg); printfn('%s password %s successfully', init_cap($displayUser), $msg);
} finally { } catch (DocumentException $ex) {
$db->close(); printfn("$ex");
} }
} }
@ -144,76 +158,68 @@ function set_password(string $email, string $password): void {
* *
* @param string $email The e-mail address of the user to be deleted * @param string $email The e-mail address of the user to be deleted
*/ */
function delete_user(string $email): void { function delete_user(string $email): void
{
$db = Data::getConnection();
try { try {
$displayUser = display_user($email); $displayUser = display_user($email);
// Get the ID for the provided e-mail address // Get the user for the provided e-mail address
$user = Security::findUserByEmail($email, $db); $tryUser = User::findByEmail($email);
if (!$user) { if ($tryUser->isNone()) {
printfn('No %s exists', $displayUser); printfn('No %s exists', $displayUser);
return; return;
} }
$feedCountQuery = $db->prepare('SELECT COUNT(*) FROM feed WHERE user_id = :user'); $user = $tryUser->get();
$feedCountQuery->bindValue(':user', $user['id']); try {
$feedCountResult = $feedCountQuery->execute(); $feedCount = Count::byFields(Table::Feed, [Field::EQ('user_id', $user->id)]);
if (!$feedCountResult) { } catch (DocumentException $ex) {
printfn('SQLite error: %s', $db->lastErrorMsg()); printfn("$ex");
return; return;
} }
$feedCount = $feedCountResult->fetchArray(SQLITE3_NUM);
$proceed = readline("Delete the $displayUser and their $feedCount[0] feed(s)? (y/N)" . PHP_EOL); $proceed = readline("Delete the $displayUser and their $feedCount feed(s)? (y/N)" . PHP_EOL);
if (!$proceed || !str_starts_with(strtolower($proceed), 'y')) { if (!$proceed || !str_starts_with(strtolower($proceed), 'y')) {
printfn('Deletion canceled'); printfn('Deletion canceled');
return; return;
} }
$itemDelete = $db->prepare('DELETE FROM item WHERE feed_id IN (SELECT id FROM feed WHERE user_id = :user)'); try {
$itemDelete->bindValue(':user', $user['id']); $fields = [Field::EQ('user_id', $user->id, ':user')];
$itemDelete->execute(); Custom::nonQuery(
'DELETE FROM ' . Table::Item . " WHERE data->>'feed_id' IN (SELECT data->>'id' FROM " . Table::Feed
$feedDelete = $db->prepare('DELETE FROM feed WHERE user_id = :user'); . ' WHERE ' . Query::whereByFields($fields) . ')', Parameters::addFields($fields, []));
$feedDelete->bindValue(':user', $user['id']); Delete::byFields(Table::Feed, $fields);
$feedDelete->execute(); Delete::byId(Table::User, $user->id);
$userDelete = $db->prepare('DELETE FROM frc_user WHERE id = :user');
$userDelete->bindValue(':user', $user['id']);
$userDelete->execute();
printfn('%s deleted successfully', init_cap($displayUser)); printfn('%s deleted successfully', init_cap($displayUser));
} finally { } catch (DocumentException $ex) {
$db->close(); printfn("$ex");
}
} catch (DocumentException $ex) {
printfn("$ex");
} }
} }
/** /**
* Change the single-user mode user to a different e-mail address and password * Change the single-user mode user to a different e-mail address and password
*/ */
function migrate_single_user(): void { function migrate_single_user(): void
{
global $argv; global $argv;
$db = Data::getConnection();
try { try {
$single = Security::findUserByEmail(Security::SINGLE_USER_EMAIL, $db); $single = User::findByEmail(Security::SingleUserEmail);
if (!$single) { if ($single->isNone()) {
printfn('There is no single-user mode user to be migrated'); printfn('There is no single-user mode user to be migrated');
return; return;
} }
$migrateQuery = $db->prepare('UPDATE frc_user SET email = :email, password = :password WHERE id = :id'); Patch::byId(Table::User, $single->get()->id,
$migrateQuery->bindValue(':email', $argv[2]); ['email' => $argv[2], 'password' => password_hash($argv[3], Security::PasswordAlgorithm)]);
$migrateQuery->bindValue(':password', password_hash($argv[3], Security::PW_ALGORITHM));
$migrateQuery->bindValue(':id', $single['id']);
$migrateQuery->execute();
printfn('The single user has been moved to "%s", with password "%s"', $argv[2], $argv[3]); printfn('The single user has been moved to "%s", with password "%s"', $argv[2], $argv[3]);
} finally { } catch (DocumentException $ex) {
$db->close(); printfn("$ex");
} }
} }