Compare commits

..

No commits in common. "main" and "v1.0.0-alpha4" have entirely different histories.

47 changed files with 911 additions and 2765 deletions

5
.gitignore vendored
View File

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

View File

@ -1,46 +1,30 @@
# Installation
## Obtaining the Files
## All Environments (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.
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.
## Selecting a Server
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`
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/).
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`.
### Apache 2
Once those steps are complete, from the `/src` directory, run `frankenphp run`.
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`.~~
_(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.)_
## PHP Requirements
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.)_
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.)_
# Setup and Configuration
## Site Address
The default `Caddyfile` will run the site at `http://localhost:8205`. To have the process respond to other devices on your network, you can add the server name to that to line 5 (ex. `http://localhost:8205, http://server:8205`); you can also change the port on which it listens. (Note that if `http` is not specified, Caddy will attempt to obtain and install a server certificate. This may be what you want, but that also could be a source of startup errors.)
## Feed Reader Central Behavior
Within the `/src` directory, there is a file named `user-config.dist.php`. Rename this file to `user-config.php`; this is where customizations and configuration of the instance's behavior are placed.
@ -54,16 +38,8 @@ There are three supported security models, designed around different ways the so
### Database Name
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`.
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`.
### Date/Time Format
The default format for dates and times look like "May 28, 2023 at 3:15pm". Changing the string there will alter the display on the main page and when reading an item. Any [supported PHP date or time token](https://www.php.net/manual/en/datetime.format.php) is supported.
### Item Purging
Feed Reader Central tries to keep the database tidy by purging items that have been read and are no longer required. There are four variants:
- `Feed::PURGE_NONE` does no purging (items have a "Delete" button, so they may be deleted manually)
- `Feed::PURGE_READ` purges non-bookmarked read items for a feed whenever it is refreshed. This is the most aggressive purging strategy, but it is also the only one that will not purge unread items.
- `Feed::PURGE_BY_DAYS` purges non-bookmarked items that are older than `PURGE_NUMBER` days old. This is the default value, and `PURGE_NUMBER`'s default value is 30; items will be kept for 30 days, read or unread.
- `Feed::PURGE_BY_COUNT` purges items to preserve at most `PURGE_NUMBER` non-bookmarked items for each feed.

View File

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

View File

@ -1,57 +1,17 @@
<?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 */
const FRC_VERSION = '1.0.0-beta4';
const FRC_VERSION = '1.0.0-alpha4';
/**
* Drop .0 or .0.0 from the end of the version to format it for display
*
* @return string The version of the application for user display
*/
function display_version(): string {
[$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";
}
spl_autoload_register(function ($class) {
$file = implode(DIRECTORY_SEPARATOR, [__DIR__, 'lib', "$class.php"]);
if (file_exists($file)) {
require $file;
return true;
}
return false;
});
require __DIR__ . '/vendor/autoload.php';
require 'user-config.php';
Configuration::useDSN('sqlite:' . implode(DIRECTORY_SEPARATOR, [__DIR__, 'data', DATABASE_NAME]));
Configuration::$autoId = AutoId::Number;
Data::ensureDb();
/** @var string The date the world wide web was created */
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,15 +1,4 @@
<?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';
if (php_sapi_name() != 'cli') {
@ -23,8 +12,7 @@ if (php_sapi_name() != 'cli') {
* @param string $format The format 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);
}
@ -33,11 +21,24 @@ function printfn(string $format, mixed ...$values): void
*
* @param string $title The title to display on the command line
*/
function cli_title(string $title): void
{
$appTitle = 'Feed Reader Central ~ ' . display_version();
function cli_title(string $title): void {
$appTitle = 'Feed Reader Central ~ v' . FRC_VERSION;
$dashes = ' +' . str_repeat('-', strlen($title) + 2) . '+' . str_repeat('-', strlen($appTitle) + 2) . '+';
printfn($dashes);
printfn(' | %s | %s |', $title, $appTitle);
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),
};
}

View File

@ -1,18 +0,0 @@
{
"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
View File

@ -1,176 +0,0 @@
{
"_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,139 +1,68 @@
<?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
*/
class Data
{
/** Prevent instances of this class */
private function __construct() {}
class Data {
/**
* Create the search index and synchronization triggers for the item table
*
* @throws DocumentException If any is encountered
* Obtain a new connection to the database
* @return SQLite3 A new connection to the database
*/
public static function createSearchIndex(): void
{
Custom::nonQuery("CREATE VIRTUAL TABLE item_search USING fts5(content, content='item', content_rowid='id')",
[]);
Custom::nonQuery(<<<'SQL'
CREATE TRIGGER item_ai AFTER INSERT ON item BEGIN
INSERT INTO item_search (rowid, content) VALUES (new.data->>'id', new.data->>'content');
END;
SQL, []);
Custom::nonQuery(<<<'SQL'
CREATE TRIGGER item_au AFTER UPDATE ON item BEGIN
INSERT INTO item_search (
item_search, rowid, content
) VALUES (
'delete', old.data->>'id', old.data->>'content'
);
INSERT INTO item_search (rowid, content) VALUES (new.data->>'id', new.data->>'content');
END;
SQL, []);
Custom::nonQuery(<<<'SQL'
CREATE TRIGGER item_ad AFTER DELETE ON item BEGIN
INSERT INTO item_search (
item_search, rowid, content
) VALUES (
'delete', old.data->>'id', old.data->>'content'
);
END;
SQL, []);
public static function getConnection(): SQLite3 {
$db = new SQLite3(implode(DIRECTORY_SEPARATOR, [__DIR__, '..', 'data', DATABASE_NAME]));
$db->exec('PRAGMA foreign_keys = ON;');
return $db;
}
/**
* Make sure the expected tables exist
*
* @throws DocumentException If any is encountered
*/
public static function ensureDb(): void
{
$tables = Custom::array("SELECT name FROM sqlite_master WHERE type = 'table'", [], new StringMapper('name'));
if (!in_array(Table::User, $tables)) {
Definition::ensureTable(Table::User);
Definition::ensureFieldIndex(Table::User, 'email', ['email']);
public static function ensureDb(): void {
$db = self::getConnection();
$tables = array();
$tableQuery = $db->query("SELECT name FROM sqlite_master WHERE type = 'table'");
while ($table = $tableQuery->fetchArray(SQLITE3_NUM)) $tables[] = $table[0];
if (!in_array('frc_user', $tables)) {
$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(Table::Feed, $tables)) {
Definition::ensureTable(Table::Feed);
Definition::ensureFieldIndex(Table::Feed, 'user', ['user_id']);
if (!in_array('feed', $tables)) {
$query = <<<'SQL'
CREATE TABLE feed (
id INTEGER NOT NULL PRIMARY KEY,
user_id INTEGER NOT NULL,
url TEXT NOT NULL,
title TEXT,
updated_on TEXT,
checked_on TEXT,
FOREIGN KEY (user_id) REFERENCES frc_user (id))
SQL;
$db->exec($query);
}
if (!in_array(Table::Item, $tables)) {
Definition::ensureTable(Table::Item);
Definition::ensureFieldIndex(Table::Item, 'feed', ['feed_id', 'item_link']);
self::createSearchIndex();
if (!in_array('item', $tables)) {
$query = <<<'SQL'
CREATE TABLE item (
id INTEGER NOT NULL PRIMARY KEY,
feed_id INTEGER NOT NULL,
title TEXT NOT NULL,
item_guid TEXT NOT NULL,
item_link TEXT NOT NULL,
published_on TEXT NOT NULL,
updated_on TEXT,
content TEXT NOT NULL,
is_read BOOLEAN NOT NULL DEFAULT 0,
is_bookmarked BOOLEAN NOT NULL DEFAULT 0,
FOREIGN KEY (feed_id) REFERENCES feed (id))
SQL;
$db->exec($query);
}
$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;
$db->close();
}
/**
@ -142,12 +71,31 @@ class Data
* @param ?string $value The date/time to be parsed and formatted
* @return string|null The date/time in `DateTimeInterface::ATOM` format, or `null` if the input cannot be parsed
*/
public static function formatDate(?string $value): ?string
{
public static function formatDate(?string $value): ?string {
try {
return $value ? (new DateTimeImmutable($value))->format(DateTimeInterface::ATOM) : null;
} catch (Exception) {
return null;
}
}
/**
* 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();
}
}
}

View File

@ -1,301 +1,462 @@
<?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;
/**
* An RSS or Atom feed
* Information for a feed item
*/
class Feed
{
// ***** CONSTANTS *****
class FeedItem {
/** @var int Do not purge items */
public const PurgeNone = 0;
/** @var string The title of the feed item */
public string $title = '';
/** @var int Purge all read items (will not purge unread items) */
public const PurgeRead = 1;
/** @var string The unique ID for the feed item */
public string $guid = '';
/** @var int Purge items older than the specified number of days */
public const PurgeByDays = 2;
/** @var string The link to the original content */
public string $link = '';
/** @var int Purge items in number greater than the specified number of items to keep */
public const PurgeByCount = 3;
/** @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 = '';
/**
* @var int Do not purge items
* @deprecated Use Feed::PurgeNone instead
*/
public const PURGE_NONE = 0;
/**
* @var int Purge all read items (will not purge unread items)
* @deprecated Use Feed::PurgeRead instead
*/
public const PURGE_READ = 1;
/**
* @var int Purge items older than the specified number of days
* @deprecated Use Feed::PurgeByDays instead
*/
public const PURGE_BY_DAYS = 2;
/**
* @var int Purge items in number greater than the specified number of items to keep
* @deprecated Use Feed::PurgeByCount instead
*/
public const PURGE_BY_COUNT = 3;
/**
* Constructor
* Construct a feed item from an Atom feed's `<entry>` tag
*
* @param int $id The ID of the feed
* @param int $user_id The ID of the user to whom this subscription belongs
* @param string $url The URL of the feed
* @param string|null $title The title of this feed
* @param string|null $updated_on The date/time items in this feed were last updated
* @param string|null $checked_on The date/time this feed was last checked
* @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 function __construct(public int $id = 0, public int $user_id = 0, public string $url = '',
public ?string $title = null, public ?string $updated_on = null,
public ?string $checked_on = null) { }
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;
// ***** STATIC FUNCTIONS *****
$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;
}
/**
* Create a document from the parsed feed
* Construct a feed item from an RSS feed's `<item>` tag
*
* @param ParsedFeed $parsed The parsed feed
* @return Feed The document constructed from the parsed feed
* @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 fromParsed(ParsedFeed $parsed): self
{
return new self(
user_id: $_SESSION[Key::UserId],
url: $parsed->url,
title: $parsed->title,
updated_on: $parsed->updatedOn,
checked_on: Data::formatDate('now'));
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;
}
}
/**
* Feed retrieval, parsing, and manipulation
*/
class Feed {
/** @var string The URL for the feed */
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';
/**
* When parsing XML into a DOMDocument, errors are presented as warnings; this creates an exception for them
*
* @param int $errno The error level encountered
* @param string $errstr The text of the error encountered
* @return bool False, to delegate to the next error handler in the chain
* @throws DOMException If the error is a warning
*/
private static function xmlParseError(int $errno, string $errstr): bool {
if ($errno == E_WARNING && substr_count($errstr, 'DOMDocument::loadXml()') > 0) {
throw new DOMException($errstr, $errno);
}
return false;
}
/**
* Parse a feed into an XML tree
*
* @param string $content The feed's RSS content
* @return array|DOMDocument[]|string[] ['ok' => feed] if successful, ['error' => message] if not
*/
public static function parseFeed(string $content): array {
set_error_handler(self::xmlParseError(...));
try {
$feed = new DOMDocument();
$feed->loadXML($content);
return ['ok' => $feed];
} catch (DOMException $ex) {
return ['error' => $ex->getMessage()];
} finally {
restore_error_handler();
}
}
/**
* Get the value of a child element by its tag name for an RSS feed
*
* @param DOMNode $element The parent element
* @param string $tagName The name of the tag whose value should be obtained
* @return string The value of the element (or "[element] not found" if that element does not exist)
*/
public static function rssValue(DOMNode $element, string $tagName): string {
$tags = $element->getElementsByTagName($tagName);
return $tags->length == 0 ? "$tagName not found" : $tags->item(0)->textContent;
}
/**
* Extract items from an RSS feed
*
* @param DOMDocument $xml The XML received from the feed
* @param string $url The actual URL for the feed
* @return array|Feed[]|string[] ['ok' => feed] if successful, ['error' => message] if not
*/
private static function fromRSS(DOMDocument $xml, string $url): array {
$channel = $xml->getElementsByTagName('channel')->item(0);
if (!($channel instanceof DOMElement)) {
return ['error' => "Channel element not found ($channel->nodeType)"];
}
// 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
$updatedOn = self::rssValue($channel, 'lastBuildDate');
if ($updatedOn == 'lastBuildDate not found') {
$updatedOn = self::rssValue($channel, 'pubDate');
if ($updatedOn == '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 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 ($tag->hasAttributes() && $tag->attributes->getNamedItem('type') == 'xhtml') {
$div = $tag->getElementsByTagNameNS(Feed::XHTML_NS, 'div');
if ($div->length == 0) return "-- invalid XHTML content --";
return $div->item(0)->textContent;
}
return $tag->textContent;
}
/**
* Extract items from an Atom feed
*
* @param DOMDocument $xml The XML received from the feed
* @param string $url The actual URL for the feed
* @return array|Feed[] ['ok' => feed]
*/
private static function fromAtom(DOMDocument $xml, string $url): array {
$root = $xml->getElementsByTagNameNS(self::ATOM_NS, 'feed')->item(0);
$updatedOn = self::atomValue($root, 'updated');
if ($updatedOn == 'pubDate not found') $updatedOn = null;
$feed = new Feed();
$feed->title = self::atomValue($root, 'title');
$feed->url = $url;
$feed->updatedOn = Data::formatDate($updatedOn);
foreach ($root->getElementsByTagName('entry') as $entry) $feed->items[] = FeedItem::fromAtom($entry);
return ['ok' => $feed];
}
/**
* Retrieve 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 {
$feedReq = curl_init($url);
curl_setopt($feedReq, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($feedReq, CURLOPT_RETURNTRANSFER, true);
curl_setopt($feedReq, CURLOPT_CONNECTTIMEOUT, 5);
curl_setopt($feedReq, CURLOPT_TIMEOUT, 15);
$feedContent = curl_exec($feedReq);
$result = array();
$error = curl_error($feedReq);
$code = curl_getinfo($feedReq, CURLINFO_RESPONSE_CODE);
if ($error) {
$result['error'] = $error;
} elseif ($code == 200) {
$parsed = self::parseFeed($feedContent);
if (array_key_exists('error', $parsed)) {
$result['error'] = $parsed['error'];
} else {
$extract = $parsed['ok']->getElementsByTagNameNS(self::ATOM_NS, 'feed')->length > 0
? self::fromAtom(...) : self::fromRSS(...);
$result = $extract($parsed['ok'], curl_getinfo($feedReq, CURLINFO_EFFECTIVE_URL));
}
} else {
$result['error'] = "Prospective feed URL $url returned HTTP Code $code: $feedContent";
}
curl_close($feedReq);
return $result;
}
/**
* 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
*/
private static function updateItem(int $itemId, FeedItem $item, SQLite3 $db): void {
$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);
$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
*/
private static function addItem(int $feedId, FeedItem $item, SQLite3 $db): void {
$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);
$query->execute();
}
/**
* Update a feed's items
*
* @param int $feedId The ID of the feed to which these items belong
* @param ParsedFeed $parsed The extracted Atom or RSS feed items
* @param DateTimeInterface $lastChecked When this feed was last checked (only new items will be added)
* @return Result<true, string> True if successful, an error message if not
* @param Feed $feed The extracted Atom or RSS feed items
* @return array ['ok' => true] if successful, ['error' => message] if not
*/
public static function updateItems(int $feedId, ParsedFeed $parsed, DateTimeInterface $lastChecked): Result
{
$results =
array_map(function ($item) use ($feedId) {
public static function updateItems(int $feedId, Feed $feed, SQLite3 $db): array {
try {
$tryExisting = Find::firstByFields(Table::Item,
[Field::EQ('feed_id', $feedId), Field::EQ('item_guid', $item->guid)], Item::class);
if ($tryExisting->isSome()) {
$existing = $tryExisting->get();
if ($existing->published_on !== $item->publishedOn
|| ($existing->updated_on !== ($item->updatedOn ?? ''))) {
Item::update($existing->id, $item);
foreach ($feed->items as $item) {
$existsQuery = $db->prepare(
'SELECT id, published_on, updated_on FROM item WHERE feed_id = :feed AND item_guid = :guid');
$existsQuery->bindValue(':feed', $feedId);
$existsQuery->bindValue(':guid', $item->guid);
$exists = $existsQuery->execute();
if ($exists) {
$existing = $exists->fetchArray(SQLITE3_ASSOC);
if ($existing) {
if ( $existing['published_on'] != $item->publishedOn
|| ($existing['updated_on'] ?? '') != ($item->updatedOn ?? '')) {
self::updateItem($existing['id'], $item, $db);
}
} else {
Item::add($feedId, $item);
self::addItem($feedId, $item, $db);
}
return Result::OK(true);
} catch (DocumentException $ex) {
return Result::Error("$ex");
} else {
throw new Exception($db->lastErrorMsg());
}
}, array_filter($parsed->items,
fn(ParsedItem $it) => date_create_immutable($it->updatedOn ?? $it->publishedOn) >= $lastChecked));
$errors = array_map(fn(Result $it) => $it->getError(), array_filter($results, fn($it) => $it->isError()));
return sizeof($errors) > 0 ? Result::Error(implode("\n", $errors)) : Result::OK(true);
}
/**
* Purge items for a feed
*
* @param int $feedId The ID of the feed to be purged
* @return Result<true, string> True if purging was successful, an error message if not
* @throws DocumentException If any is encountered
*/
private static function purgeItems(int $feedId): Result
{
$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 {
Custom::nonQuery($sql, Parameters::addFields($fields, []));
return Result::OK(true);
} catch (DocumentException $ex) {
return Result::Error("$ex");
} catch (Exception $ex) {
return ['error' => $ex->getMessage()];
}
return ['ok', true];
}
/**
* Refresh a feed
*
* @param int $feedId The ID of the feed to be refreshed
* @param string $url The URL of the feed to be refreshed
* @return Result<true, string> True if successful, an error message if not
* @param SQLite3 $db A database connection to use to refresh the feed
* @return array|string[]|true[] ['ok' => true] if successful, ['error' => message] if not
*/
public static function refreshFeed(int $feedId, string $url): Result
{
return ParsedFeed::retrieve($url)
->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);
private static function refreshFeed(string $url, SQLite3 $db): array {
$feedQuery = $db->prepare('SELECT id FROM feed WHERE url = :url AND user_id = :user');
$feedQuery->bindValue(':url', $url);
$feedQuery->bindValue(':user', $_SESSION[Key::USER_ID]);
$feedResult = $feedQuery->execute();
$feedId = $feedResult ? $feedResult->fetchArray(SQLITE3_NUM)[0] : -1;
if ($feedId < 0) return ['error' => "No feed for URL $url found"];
return self::updateItems($feedId, $feed, $lastChecked)
->bind(function () use ($feed, $feedId, $url) {
$patch = [
'title' => $feed->title,
'updated_on' => $feed->updatedOn,
'checked_on' => Data::formatDate('now')
];
if ($url !== $feed->url) $patch['url'] = $feed->url;
Patch::byId(Table::Feed, $feedId, $patch);
return Result::OK(true);
})
->bind(fn() => PURGE_TYPE === self::PurgeNone ? Result::OK(true) : self::purgeItems($feedId));
} catch (DocumentException $ex) {
return Result::Error("$ex");
}
});
$feedExtract = self::retrieveFeed($url);
if (array_key_exists('error', $feedExtract)) return $feedExtract;
$feed = $feedExtract['ok'];
$itemUpdate = self::updateItems($feedId, $feed, $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);
$feedUpdate->execute();
return ['ok' => true];
}
/**
* Add an RSS feed
*
* @param string $url The URL of the RSS feed to add
* @return Result<int, string> The feed ID if successful, an error message if not
* @return array ['ok' => feedId] if successful, ['error' => message] if not
*/
public static function add(string $url): Result
{
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");
}
public static function add(string $url, SQLite3 $db): array {
$feedExtract = self::retrieveFeed($url);
if (array_key_exists('error', $feedExtract)) return $feedExtract;
Document::insert(Table::Feed, self::fromParsed($feed));
$feed = $feedExtract['ok'];
$query = $db->prepare(<<<'SQL'
INSERT INTO feed (user_id, url, title, updated_on, checked_on)
VALUES (:user, :url, :title, :updated, :checked)
SQL);
$query->bindValue(':user', $_SESSION[Key::USER_ID]);
$query->bindValue(':url', $feed->url);
$query->bindValue(':title', $feed->title);
$query->bindValue(':updated', $feed->updatedOn);
$query->bindValue(':checked', Data::formatDate('now'));
$result = $query->execute();
$doc = Find::firstByFields(Table::Feed, $fields, self::class);
if ($doc->isNone()) return Result::Error('Could not retrieve inserted feed');
$feedId = $result ? $db->lastInsertRowID() : -1;
if ($feedId < 0) return ['error' => $db->lastErrorMsg()];
return self::updateItems($doc->get()->id, $feed, date_create_immutable(WWW_EPOCH))
->bind(fn() => Result::OK($doc->get()->id));
} catch (DocumentException $ex) {
return Result::Error("$ex");
}
});
$result = self::updateItems($feedId, $feed, $db);
if (array_key_exists('error', $result)) return $result;
return ['ok' => $feedId];
}
/**
* Update an RSS feed
*
* @param Feed $existing The existing feed
* @param array $existing The existing RSS feed
* @param string $url The URL with which the existing feed should be modified
* @return Result<true, string> True if successful, an error message if not
* @param SQLite3 $db The database connection on which to execute the update
* @return bool[]|string[] [ 'ok' => true ] if successful, [ 'error' => message ] if not
*/
public static function update(Feed $existing, string $url): Result
{
try {
Patch::byFields(Table::Feed,
[Field::EQ(Configuration::$idField, $existing->id), Field::EQ('user_id', $_SESSION[Key::UserId])],
['url' => $url]);
return self::refreshFeed($existing->id, $url);
} catch (DocumentException $ex) {
return Result::Error("$ex");
}
}
public static function update(array $existing, string $url, SQLite3 $db): array {
$query = $db->prepare('UPDATE feed SET url = :url WHERE id = :id AND user_id = :user');
$query->bindValue(':url', $url);
$query->bindValue(':id', $existing['id']);
$query->bindValue(':user', $_SESSION[Key::USER_ID]);
$query->execute();
/**
* Retrieve all feeds, optionally for a specific user
*
* @param int $user The ID of the user whose feeds should be retrieved (optional, defaults to all feeds)
* @return DocumentList<Feed> A list of feeds
* @throws DocumentException If any is encountered
*/
public static function retrieveAll(int $user = 0): DocumentList
{
return $user === 0
? Find::all(Table::Feed, self::class)
: Find::byFields(Table::Feed, [Field::EQ('user_id', $user)], self::class);
return self::refreshFeed($url, $db);
}
/**
* Refresh all feeds
*
* @return Result<true, string> True if successful an error message if not (may have multiple error lines)
* @param SQLite3 $db The database connection to use for refreshing feeds
* @return array|true[] ['ok => true] if successful, ['error' => message] if not (may have multiple error lines)
*/
public static function refreshAll(): Result
{
$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");
public static function refreshAll(SQLite3 $db): array {
$query = $db->prepare('SELECT url FROM feed WHERE user_id = :user');
$query->bindValue(':user', $_SESSION[Key::USER_ID]);
$result = $query->execute();
$url = $result ? $result->fetchArray(SQLITE3_NUM) : false;
if ($url) {
$errors = array();
while ($url) {
$updateResult = self::refreshFeed($url[0], $db);
if (array_key_exists('error', $updateResult)) $errors[] = $updateResult['error'];
$url = $result->fetchArray(SQLITE3_NUM);
}
return empty($errors) ? Result::OK(true) : Result::Error(implode("\n", $errors));
return sizeof($errors) == 0 ? ['ok' => true] : ['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]);
return ['error' => $db->lastErrorMsg()];
}
}

View File

@ -1,93 +0,0 @@
<?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
]);
}
}

View File

@ -1,175 +0,0 @@
<?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>';
}
}

View File

@ -1,74 +0,0 @@
<?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,27 +1,13 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
class Key {
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 */
public const UserEmail = 'FRC_USER_EMAIL';
public const string USER_EMAIL = 'FRC_USER_EMAIL';
/** @var string The $_SESSION key for the current user's ID */
public const UserId = 'FRC_USER_ID';
public const string USER_ID = 'FRC_USER_ID';
/** @var string The $_REQUEST key for the array of user messages to display */
public const UserMsg = 'FRC_USER_MSG';
/** Prevent instances of this class */
private function __construct() {}
public const string USER_MSG = 'FRC_USER_MSG';
}

View File

@ -1,276 +0,0 @@
<?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']);
});
}
}

View File

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

View File

@ -1,27 +0,0 @@
<?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() {}
}

View File

@ -1,65 +0,0 @@
<?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.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

File diff suppressed because one or more lines are too long

View File

@ -22,59 +22,31 @@ header {
border-bottom-right-radius: .5rem;
color: white;
display: flex;
flex-flow: row wrap;
flex-flow: row nowrap;
justify-content: space-between;
align-items: baseline;
div, nav {
margin-bottom: .25rem;
}
.title {
font-size: 1.5rem;
}
.version {
font-size: .85rem;
padding-left: .5rem;
color: rgba(255, 255, 255, .75);
}
a:link, a:visited {
color: white;
}
nav {
display: flex;
flex-flow: row wrap;
gap: 0 .4rem;
}
}
main {
padding: 0 .5rem;
.refresh, .loading {
font-style: italic;
font-size: .9rem;
}
.htmx-request .refresh {
display: none;
}
.loading {
display: none;
}
.htmx-request .loading {
display: inline;
}
.user_messages {
display: flex;
flex-flow: column;
justify-content: center;
}
.user_message {
margin: .25rem auto;
border: solid 1px navy;
border-radius: .5rem;
background-color: rgba(255, 255, 255, .75);
padding: .25rem;
}
.user_messages + h1 {
margin-top: .25rem;
.item_heading {
margin-bottom: 0;
}
.item_published {
margin-bottom: 1rem;
line-height: 1.2;
@ -83,73 +55,33 @@ main {
article {
max-width: 60rem;
margin: auto;
.item_content {
border: solid 1px navy;
border-radius: .5rem;
background-color: white;
padding: .5rem;
img {
max-width: 100%;
object-fit: contain;
height: unset;
width: unset;
}
}
.meta {
font-size: .9rem;
}
&.docs {
line-height: 1.4rem;
}
}
form {
display: flex;
flex-flow: row wrap;
justify-content: center;
gap: 0 2rem;
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%;
article.docs {
line-height: 1.4rem;
}
input[type=url],
input[type=text],
input[type=email],
input[type=password] {
width: 50%;
font-size: 1rem;
padding: .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,
.action_buttons a:link,
@ -162,11 +94,18 @@ button,
border-radius: .25rem;
cursor: pointer;
border: none;
&:hover {
}
button:hover,
.action_buttons a:hover {
text-decoration: none;
cursor: pointer;
background: linear-gradient(navy, #000032);
}
}
.action_buttons {
margin: 1rem 0;
display: flex;
flex-flow: row nowrap;
justify-content: space-evenly;
}
code {
font-size: .9rem;
@ -174,28 +113,3 @@ code {
p.back-link {
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;
}
}
}

View File

@ -1,44 +0,0 @@
<?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,79 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
include '../../start.php';
FeedReaderCentral\Security::verifyUser(redirectIfAnonymous: false);
page_head('Feeds | Documentation'); ?>
<h1>Feeds</h1>
<p class=back-link><?=hx_get('./', '&lang;&lang; Documentation Home')?>
<article class=docs>
<h2>Adding a Feed</h2>
<p>On the top menu bar, click the <strong>Feeds</strong> link, then click the <strong>Add Feed</strong> button. In
the field that is displayed, enter the
<abbr title="Uniform Resource Locator (aka &ldquo;link&rdquo;)">URL</abbr> for the feed. Then click the
<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
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
shell script to provide some environment settings, then telling <code>cron</code> to run that script.
<pre class=item_content>
#!/bin/bash
exec 1> >(logger -t feed-reader-central) 2>&1
cd /path/to/frc
php-cli util/refresh.php all</pre>
<p>Save this (<code>frc-refresh.sh</code> might be a good name) and be sure it is executable
(<code>chmod +x ./frc-refresh.sh</code>). Before we put it in crontab, though, let&rsquo;s understand what each
line does:
<ul>
<li>Line 1 tells the operating system to use the <code>bash</code> shell.
<li>Line 2 directs all output to the system log (<code>/var/log/syslog</code>), labeling each entry with
<code>feed-reader-central</code>. This lets you review the output for its runs in a log that is already
maintained and rotated by the operating system.
<li>Line 3 changes the current directory to the one where Feed Reader Central is installed; modify it for where
you have installed it. Since we are setting up for a <a href=./the-cli>CLI execution</a>, this should place
us one directory up from <code>/public</code>.
<li>Line 4 executes the refresh script.
</ul>
<p>Finally, we are ready to add this to our crontab. Enter <code>crontab -e</code> to edit the file, then add a row
at the bottom that looks like this:
<pre class=item_content>
0 */6 * * * /path/to/job/frc-refresh.sh</pre>
<p>The items before the path specify when it should run. This example will run at the top of the hour every six
hours. Crontab schedules can be tricky to create; a full treatment is outside the scope of this documentation.
However, <a href=https://crontab.guru/#0_*/6_*_*_* target=_blank rel=noopener title="Crontab.guru">this site</a>
lets you put values in each position and it translates that to words; this lets you see if what you put is what
you meant.
<p>This should not require many resources; the majority of its time will be spent waiting for the websites to return
their feeds so it can process them. However, if you want it to yield to everything else happening on the server,
add <code>nice -n 1</code> (with a trailing space) before the path to the script.
</article><?php
page_foot();

View File

@ -1,39 +1,15 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
include '../../start.php';
FeedReaderCentral\Security::verifyUser(redirectIfAnonymous: false);
$db = Data::getConnection();
Security::verifyUser($db, redirectIfAnonymous: false);
page_head('Documentation'); ?>
<h1>Documentation Home</h1>
<article>
<h2>About the Application</h2>
<p>This application is designed to be a lightweight, near-zero dependency application that individuals can run
themselves. The idea that let to its creation was a desire to have a set of
<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.
<p><a href=./the-cli>About the CLI</a> provides orientation on Feed Reader Central&rsquo;s command line interface
<p><a href=./security-modes>Configuring Security Modes</a> describes the three security modes and how to manage each
of them
</article><?php
page_foot();
$db->close();

View File

@ -1,74 +0,0 @@
<?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,18 +1,12 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
include '../../start.php';
FeedReaderCentral\Security::verifyUser(redirectIfAnonymous: false);
$db = Data::getConnection();
Security::verifyUser($db, redirectIfAnonymous: false);
page_head('Security Modes | Documentation'); ?>
<h1>Configuring Security Modes</h1>
<p class=back-link><?=hx_get('./', '&lang;&lang; Documentation Home')?>
<p class=back-link><a href=./>&lang;&lang; Documentation Home</a>
<article class=docs>
<h2>Security Modes</h2>
<p><strong>Single-User</strong> mode assumes that every connection to the application is the same person. It is
@ -44,7 +38,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
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
<code>Security::MultiUserMode</code>.
<code>Security::MULTI_USER</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
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>
@ -55,13 +49,14 @@ page_head('Security Modes | Documentation'); ?>
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.
<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 <code>Security::SingleUserPasswordMode</code>,
then use the <code>user</code> CLI utility to set a password.
<p>Set <code>SECURITY_MODEL</code> in <code>user-config.php</code> to
<code>Security::SINGLE_USER_WITH_PASSWORD</code>, then use the <code>user</code> CLI utility to set a password.
<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>
<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::SingleUserMode</code>, then run
the <code>user</code> CLI utility to reset the single user back to its expected default.
<code>SECURITY_MODEL</code> in <code>user-config.php</code> to <code>Security::SINGLE_USER</code>, then run 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>
</article><?php
page_foot();
$db->close();

View File

@ -1,18 +1,12 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
include '../../start.php';
FeedReaderCentral\Security::verifyUser(redirectIfAnonymous: false);
$db = Data::getConnection();
Security::verifyUser($db, redirectIfAnonymous: false);
page_head('About the CLI | Documentation'); ?>
<h1>About the CLI</h1>
<p class=back-link><?=hx_get('./', '&lang;&lang; Documentation Home')?>
<p class=back-link><a href=./>&lang;&lang; Documentation Home</a>
<article class=docs>
<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
@ -27,3 +21,4 @@ page_head('About the CLI | Documentation'); ?>
<p><code>php-cli util/some-process.php command option1 option2</code>
</article><?php
page_foot();
$db->close();

61
src/public/feed.php Normal file
View File

@ -0,0 +1,61 @@
<?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();

View File

@ -1,82 +0,0 @@
<?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();

View File

@ -1,36 +0,0 @@
<?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();

View File

@ -1,48 +0,0 @@
<?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,41 +2,49 @@
/**
* Home Page
*
* Displays a list of unread or bookmarked items for the current user
*
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
* Displays a list of unread feed items for the current user
*/
declare(strict_types=1);
use FeedReaderCentral\{Feed, ItemList};
include '../start.php';
FeedReaderCentral\Security::verifyUser();
$db = Data::getConnection();
Security::verifyUser($db);
if (key_exists('refresh', $_GET)) {
$refreshResult = Feed::refreshAll();
if ($refreshResult->isOK()) {
if (array_key_exists('refresh', $_GET)) {
$refreshResult = Feed::refreshAll($db);
if (array_key_exists('ok', $refreshResult)) {
add_info('All feeds refreshed successfully');
} else {
add_error(nl2br($refreshResult->getError()));
add_error(nl2br($refreshResult['error']));
}
}
$list = match (true) {
key_exists('bookmarked', $_GET) => ItemList::allBookmarked(),
default => ItemList::allUnread(),
};
$title = "Your $list->itemType Items";
$query = $db->prepare(<<<'SQL'
SELECT item.id, item.title AS item_title, coalesce(item.updated_on, item.published_on) AS as_of,
feed.title AS feed_title
FROM item
INNER JOIN feed ON feed.id = item.feed_id
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('Welcome'); ?>
<h1>Your Unread Items &nbsp; <a href=/?refresh><small><small><em>(Refresh All Feeds)</em></small></small></a></h1>
<article><?php
if ($item) {
while ($item) { ?>
<p><a href=/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();
$db->close();

View File

@ -1,82 +1,71 @@
<?php
/**
* Item View Page
*
* 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';
FeedReaderCentral\Security::verifyUser();
$db = Data::getConnection();
Security::verifyUser($db);
$id = match (true) {
key_exists('id', $_POST) => (int)$_POST['id'],
key_exists('id', $_GET) => (int)$_GET['id'],
default => -1,
};
$from = match ($_SERVER['REQUEST_METHOD']) {
'POST' => $_POST['from'],
default => $_GET['from'] ?? '/',
};
switch ($_SERVER['REQUEST_METHOD']) {
case 'POST':
try {
// "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]);
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
// "Keep as New" button sends a POST request to reset the is_read flag before going back to the list of unread items
$isValidQuery = $db->prepare(<<<'SQL'
SELECT COUNT(*)
FROM item INNER JOIN feed ON feed.id = item.feed_id
WHERE item.id = :id AND feed.user_id = :user
SQL);
$isValidQuery->bindValue(':id', $_POST['id']);
$isValidQuery->bindValue(':user', $_SESSION[Key::USER_ID]);
$isValidResult = $isValidQuery->execute();
if ($isValidResult && $isValidResult->fetchArray(SQLITE3_NUM)[0] == 1) {
$keepUnread = $db->prepare('UPDATE item SET is_read = 0 WHERE id = :id');
$keepUnread->bindValue(':id', $_POST['id']);
$keepUnread->execute();
}
frc_redirect($from);
} catch (DocumentException $ex) {
add_error("$ex");
}
break;
case 'DELETE':
try {
if (ItemWithFeed::existsById($id)) Delete::byId(Table::Item, $id);
} catch (DocumentException $ex) {
add_error("$ex");
}
frc_redirect($from);
$db->close();
frc_redirect('/');
}
$item = ItemWithFeed::retrieveById($id)->getOrCall(not_found(...));
try {
Patch::byId(Table::Item, $id, ['is_read' => 1]);
} catch (DocumentException $ex) {
add_error("$ex");
$query = $db->prepare(<<<'SQL'
SELECT item.title AS item_title, item.item_link, item.published_on, item.updated_on, item.content,
feed.title AS feed_title
FROM item INNER JOIN feed ON feed.id = item.feed_id
WHERE item.id = :id
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);
$updated = isset($item->updated_on) ? date_time($item->updated_on) : null;
$published = date_time($item['published_on']);
$updated = isset($item['updated_on']) ? date_time($item['updated_on']) : null;
page_head(htmlentities("$item->title | {$item->feed->title}")); ?>
page_head(htmlentities("{$item['item_title']} | {$item['feed_title']}")); ?>
<h1 class=item_heading>
<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>
<a href="<?=$item['item_link']?>" target=_blank rel=noopener><?=strip_tags($item['item_title'])?></a><br>
</h1>
<div class=item_published>
From <strong><?=htmlentities($item->feed->title)?></strong><br>
Published <?=date_time($item->published_on)?><?=$updated && $updated != $published ? " (Updated $updated)" : ''?>
From <strong><?=htmlentities($item['feed_title'])?></strong><br>
Published <?=date_time($item['published_on'])?><?=$updated && $updated != $published ? " (Updated $updated)" : ''?>
</div>
<article>
<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>
<div class=item_content><?=str_replace('<a ', '<a target=_blank rel=noopener ', $item['content'])?></div>
<form class=action_buttons action=/item method=POST>
<input type=hidden name=id value=<?=$_GET['id']?>>
<input type=hidden name=from value="<?=$from?>">
<?=hx_get($from, 'Done')?>
<a href="/">Done</a>
<button type=submit>Keep as New</button>
<button type=button hx-delete=/item>Delete</button>
</form>
</article><?php
page_foot();
$db->close();

View File

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

View File

@ -1,53 +1,39 @@
<?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';
use BitBadger\InspiredByFSharp\Option;
use FeedReaderCentral\{Key, Security};
Security::verifyUser(redirectIfAnonymous: false);
$db = Data::getConnection();
Security::verifyUser($db, redirectIfAnonymous: false);
// Users already logged on have no need of this page
if (key_exists(Key::UserId, $_SESSION)) frc_redirect('/');
if (array_key_exists(Key::USER_ID, $_SESSION)) frc_redirect('/');
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
Security::logOnUser($_POST['email'] ?? '', $_POST['password'], Option::of($_POST['returnTo'] ?? null));
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
Security::logOnUser($_POST['email'] ?? '', $_POST['password'], $_POST['returnTo'] ?? null, $db);
// If we're still here, something didn't work; preserve the returnTo parameter
$_GET['returnTo'] = $_POST['returnTo'];
}
$isSingle = SECURITY_MODEL === Security::SingleUserPasswordMode;
$isSingle = SECURITY_MODEL == Security::SINGLE_USER_WITH_PASSWORD;
page_head('Log On'); ?>
<h1>Log On</h1>
<article>
<form method=POST action=/user/log-on><?php
if (($_GET['returnTo'] ?? '') !== '') { ?>
<form method=POST action=/user/log-on hx-post=/user/log-on><?php
if (($_GET['returnTo'] ?? '') != '') { ?>
<input type=hidden name=returnTo value="<?=$_GET['returnTo']?>"><?php
}
if (!$isSingle) { ?>
<label>
E-mail Address
<input type=email name=email required autofocus>
</label><?php
</label><br><?php
} ?>
<label>
Password
<input type=password name=password required<?=$isSingle ? ' autofocus' : ''?>>
</label>
<span class=break></span>
</label><br>
<button type=submit>Log On</button>
</form>
</article><?php
page_foot();
$db->close();

View File

@ -1,9 +0,0 @@
# 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,5 +0,0 @@
# 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]

View File

@ -1,27 +0,0 @@
## 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,17 +1,5 @@
<?php
/**
* 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};
use JetBrains\PhpStorm\NoReturn;
require 'app-config.php';
@ -27,10 +15,9 @@ session_start([
* @param string $level The level (type) of the message
* @param string $message The message itself
*/
function add_message(string $level, string $message): void
{
if (!key_exists(Key::UserMsg, $_SESSION)) $_SESSION[Key::UserMsg] = [];
$_SESSION[Key::UserMsg][] = ['level' => $level, 'message' => $message];
function add_message(string $level, string $message): void {
if (!array_key_exists(Key::USER_MSG, $_REQUEST)) $_REQUEST[Key::USER_MSG] = array();
$_REQUEST[Key::USER_MSG][] = ['level' => $level, 'message' => $message];
}
/**
@ -38,8 +25,7 @@ function add_message(string $level, string $message): void
*
* @param string $message The message to be displayed
*/
function add_error(string $message): void
{
function add_error(string $message): void {
add_message('ERROR', $message);
}
@ -48,87 +34,53 @@ function add_error(string $message): void
*
* @param string $message The message to be displayed
*/
function add_info(string $message): void
{
function add_info(string $message): void {
add_message('INFO', $message);
}
/** True if this request was initiated by htmx, false if not */
$is_htmx = key_exists('HTTP_HX_REQUEST', $_SERVER) && !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
* @param string $title The title of the page being displayed
*/
function page_head(string $title): void
{
global $is_htmx;
echo '<!DOCTYPE html><html lang=en>'
. "<head><title>$title | Feed Reader Central</title>";
if (!$is_htmx) {
echo '<meta name=viewport content="width=device-width, initial-scale=1">'
. "<meta name=htmx-config content='{\"historyCacheSize\":0}'>"
. '<link href=/assets/style.css rel=stylesheet>';
}
echo '</head><body>';
if (!$is_htmx) title_bar();
if (sizeof($messages = $_SESSION[Key::UserMsg] ?? []) > 0) {
echo '<div class=user_messages>';
array_walk($messages, function ($msg) {
echo '<div class=user_message>'
. ($msg['level'] === 'INFO' ? '' : "<strong>{$msg['level']}</strong><br>")
. $msg['message'] . '</div>';
});
echo '</div>';
$_SESSION[Key::UserMsg] = [];
function page_head(string $title): void {
$version = match (true) {
str_ends_with(FRC_VERSION, '.0.0') => substr(FRC_VERSION, 0, strlen(FRC_VERSION) - 4),
str_ends_with(FRC_VERSION, '.0') => substr(FRC_VERSION, 0, strlen(FRC_VERSION) - 2),
default => FRC_VERSION
} ?>
<!DOCTYPE html>
<html lang=en>
<head>
<meta name=viewport content="width=device-width, initial-scale=1">
<title><?=$title?> | Feed Reader Central</title>
<link href=/assets/style.css rel=stylesheet>
</head>
<body>
<header>
<div><a class=title href="/">Feed Reader Central</a><span class=version>v<?=$version?></span></div>
<div><?php
if (array_key_exists(Key::USER_ID, $_SESSION)) {
echo '<a href=/feed?id=new>Add Feed</a> | <a href=/docs/>Docs</a> | <a href=/user/log-off>Log Off</a>';
if ($_SESSION[Key::USER_EMAIL] != Security::SINGLE_USER_EMAIL) echo " | {$_SESSION[Key::USER_EMAIL]}";
} else {
echo '<a href=/user/log-on>Log On</a> | <a href=/docs/>Docs</a>';
} ?>
</div>
</header>
<main hx-target=this><?php
foreach ($_REQUEST[Key::USER_MSG] ?? [] as $msg) { ?>
<div>
<?=$msg['level'] == 'INFO' ? '' : "<strong>{$msg['level']}</strong><br>"?>
<?=$msg['message']?>
</div><?php
}
}
/**
* Render the end of the page
*/
function page_foot(): void
{
global $is_htmx;
echo '</main>' . ($is_htmx ? '' : '<script src=/assets/htmx.min.js></script>') . '</body></html>';
function page_foot(): void {
echo '</main></body></html>';
session_commit();
}
@ -137,15 +89,14 @@ function page_foot(): void
*
* @param string $value A local URL to which the user should be redirected
*/
function frc_redirect(string $value): never
{
#[NoReturn]
function frc_redirect(string $value): void {
if (str_starts_with($value, 'http')) {
http_response_code(400);
die();
}
session_commit();
header("Location: $value", true, 303);
Configuration::resetPDO();
die();
}
@ -155,34 +106,10 @@ function frc_redirect(string $value): never
* @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
*/
function date_time(string $value): string
{
function date_time(string $value): string {
try {
return (new DateTimeImmutable($value))->format(DATE_TIME_FORMAT);
} catch (Exception) {
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,5 +1,4 @@
<?php declare(strict_types=1);
<?php
/**
* USER CONFIGURATION ITEMS
*
@ -8,15 +7,14 @@
* 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:
* - Security::SingleUserMode (no e-mail required, does not require a password)
* - Security::SingleUserPasswordMode (no e-mail required, does require a password)
* - Security::MultiUserMode (e-mail and password required for all users)
* - Security::SINGLE_USER (no e-mail required, does not require a password)
* - Security::SINGLE_USER_WITH_PASSWORD (no e-mail required, does require a password)
* - Security::MULTI_USER (e-mail and password required for all users)
*/
const SECURITY_MODEL = 'ConfigureMe';
const SECURITY_MODEL = 'CONFIGURE_ME';
/** The name of the database file where users and feeds should be kept */
const DATABASE_NAME = 'frc.db';
@ -27,17 +25,3 @@ const DATABASE_NAME = 'frc.db';
* The default, 'F j, Y \a\t g:ia', equates to "August 17, 2023 at 4:45pm"
*/
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:
* - Feed::PurgeNone - Do not purge items
* - Feed::PurgeRead - Purge all read items whenever purging is run (will not purge unread items)
* - Feed::PurgeByDays - Purge read and unread items older than a number of days (PURGE_NUMBER below)
* - Feed::PurgeByCount - Purge read and unread items beyond the number to keep (PURGE_NUMBER below)
*/
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
*/
const PURGE_NUMBER = 30;

View File

@ -1,121 +0,0 @@
<?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,67 +0,0 @@
<?php
/**
* 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';
cli_title('FEED REFRESH');
if ($argc < 2) display_help();
switch ($argv[1]) {
case 'all':
refresh_all();
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(' - all');
printfn(' Refreshes all feeds');
exit(0);
}
function refresh_all(): void
{
try {
$users = [];
Feed::retrieveAll()->iter(function (Feed $feed) use (&$users) {
$result = Feed::refreshFeed($feed->id, $feed->url);
$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'));
}
if ($result->isError()) {
printfn('ERR (%s) %s', $users[$userKey]->email, $feed->url);
printfn(' %s', $result->getError());
} else {
printfn('OK (%s) %s', $users[$userKey]->email, $feed->url);
}
});
printfn(PHP_EOL . 'All feeds refreshed');
} catch (DocumentException $ex) {
printfn("ERR $ex");
return;
}
}

View File

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