Compare commits

..

2 Commits

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

Reviewed-on: #26
2024-08-06 23:20:17 +00:00
dfd9a873f8 Fix user delete process (#25)
- Bump version
2024-06-15 12:55:45 -04:00
35 changed files with 868 additions and 536 deletions

View File

@ -1,10 +1,20 @@
<?php declare(strict_types=1); <?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 BitBadger\PDODocument\{AutoId, Configuration};
use FeedReaderCentral\Data; use FeedReaderCentral\Data;
/** The current Feed Reader Central version */ /** The current Feed Reader Central version */
const FRC_VERSION = '1.0.0-beta2'; const FRC_VERSION = '1.0.0-beta4';
/** /**
* Drop .0 or .0.0 from the end of the version to format it for display * Drop .0 or .0.0 from the end of the version to format it for display
@ -13,7 +23,7 @@ const FRC_VERSION = '1.0.0-beta2';
*/ */
function display_version(): string { function display_version(): string {
[$major, $minor, $rev] = explode('.', FRC_VERSION); [$major, $minor, $rev] = explode('.', FRC_VERSION);
$minor = $minor == '0' ? '' : ".$minor"; $minor = $minor === '0' ? '' : ".$minor";
$rev = match (true) { $rev = match (true) {
$rev == '0' => '', $rev == '0' => '',
str_starts_with($rev, '0-') => substr($rev, 1), str_starts_with($rev, '0-') => substr($rev, 1),
@ -25,7 +35,7 @@ function display_version(): string {
require __DIR__ . '/vendor/autoload.php'; require __DIR__ . '/vendor/autoload.php';
require 'user-config.php'; require 'user-config.php';
Configuration::$pdoDSN = 'sqlite:' . implode(DIRECTORY_SEPARATOR, [__DIR__, 'data', DATABASE_NAME]); Configuration::useDSN('sqlite:' . implode(DIRECTORY_SEPARATOR, [__DIR__, 'data', DATABASE_NAME]));
Configuration::$autoId = AutoId::Number; Configuration::$autoId = AutoId::Number;
Data::ensureDb(); Data::ensureDb();

View File

@ -1,4 +1,14 @@
<?php declare(strict_types=1); <?php
/**
* Command Line Utility Start Script
*
* This loads the environment needed for a command line utility
*
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
require 'app-config.php'; require 'app-config.php';

View File

@ -1,6 +1,6 @@
{ {
"name": "bit-badger/feed-reader-central", "name": "bit-badger/feed-reader-central",
"minimum-stability": "dev", "minimum-stability": "beta",
"require": { "require": {
"php": ">=8.2", "php": ">=8.2",
"bit-badger/pdo-document": "^1", "bit-badger/pdo-document": "^1",

63
src/composer.lock generated
View File

@ -4,23 +4,70 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "4f0add59e384feb6787acf3685c9e031", "content-hash": "2966efd32e555ad8b63351673e75b5a5",
"packages": [ "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", "name": "bit-badger/pdo-document",
"version": "v1.0.0-alpha2", "version": "v1.0.0-beta9",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://git.bitbadger.solutions/bit-badger/pdo-document", "url": "https://git.bitbadger.solutions/bit-badger/pdo-document",
"reference": "330e27218756df8b93081a17dead8aaec789b071" "reference": "9e0e663811d9dbbdb94a2c04ce8b874e91a7c85b"
}, },
"require": { "require": {
"bit-badger/inspired-by-fsharp": "^1",
"ext-pdo": "*", "ext-pdo": "*",
"netresearch/jsonmapper": "^4", "netresearch/jsonmapper": "^4",
"php": ">=8.2" "php": ">=8.2"
}, },
"require-dev": { "require-dev": {
"phpunit/phpunit": "^11" "phpunit/phpunit": "^11",
"square/pjson": "^0.5.0"
}, },
"type": "library", "type": "library",
"autoload": { "autoload": {
@ -42,19 +89,21 @@
"role": "Developer" "role": "Developer"
} }
], ],
"description": "Treat SQLite (and soon PostgreSQL) as a document store", "description": "Treat SQLite and PostgreSQL as document stores",
"keywords": [ "keywords": [
"database", "database",
"document", "document",
"pdo", "pdo",
"postgresql",
"sqlite" "sqlite"
], ],
"support": { "support": {
"docs": "https://bitbadger.solutions/open-source/pdo-document/",
"email": "daniel@bitbadger.solutions", "email": "daniel@bitbadger.solutions",
"rss": "https://git.bitbadger.solutions/bit-badger/pdo-document.rss", "rss": "https://git.bitbadger.solutions/bit-badger/pdo-document.rss",
"source": "https://git.bitbadger.solutions/bit-badger/pdo-document" "source": "https://git.bitbadger.solutions/bit-badger/pdo-document"
}, },
"time": "2024-06-11T11:07:56+00:00" "time": "2024-07-29T20:57:51+00:00"
}, },
{ {
"name": "netresearch/jsonmapper", "name": "netresearch/jsonmapper",
@ -110,7 +159,7 @@
], ],
"packages-dev": [], "packages-dev": [],
"aliases": [], "aliases": [],
"minimum-stability": "dev", "minimum-stability": "beta",
"stability-flags": [], "stability-flags": [],
"prefer-stable": false, "prefer-stable": false,
"prefer-lowest": false, "prefer-lowest": false,

View File

@ -1,4 +1,10 @@
<?php declare(strict_types=1); <?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
namespace FeedReaderCentral; namespace FeedReaderCentral;
@ -13,6 +19,9 @@ use Exception;
*/ */
class Data class Data
{ {
/** Prevent instances of this class */
private function __construct() {}
/** /**
* Create the search index and synchronization triggers for the item table * Create the search index and synchronization triggers for the item table
* *
@ -56,17 +65,17 @@ class Data
public static function ensureDb(): void public static function ensureDb(): void
{ {
$tables = Custom::array("SELECT name FROM sqlite_master WHERE type = 'table'", [], new StringMapper('name')); $tables = Custom::array("SELECT name FROM sqlite_master WHERE type = 'table'", [], new StringMapper('name'));
if (!in_array(Table::USER, $tables)) { if (!in_array(Table::User, $tables)) {
Definition::ensureTable(Table::USER); Definition::ensureTable(Table::User);
Definition::ensureFieldIndex(Table::USER, 'email', ['email']); Definition::ensureFieldIndex(Table::User, 'email', ['email']);
} }
if (!in_array(Table::FEED, $tables)) { if (!in_array(Table::Feed, $tables)) {
Definition::ensureTable(Table::FEED); Definition::ensureTable(Table::Feed);
Definition::ensureFieldIndex(Table::FEED, 'user', ['user_id']); Definition::ensureFieldIndex(Table::Feed, 'user', ['user_id']);
} }
if (!in_array(Table::ITEM, $tables)) { if (!in_array(Table::Item, $tables)) {
Definition::ensureTable(Table::ITEM); Definition::ensureTable(Table::Item);
Definition::ensureFieldIndex(Table::ITEM, 'feed', ['feed_id', 'item_link']); Definition::ensureFieldIndex(Table::Item, 'feed', ['feed_id', 'item_link']);
self::createSearchIndex(); self::createSearchIndex();
} }
$journalMode = Custom::scalar("PRAGMA journal_mode", [], new StringMapper('journal_mode')); $journalMode = Custom::scalar("PRAGMA journal_mode", [], new StringMapper('journal_mode'));
@ -122,7 +131,7 @@ class Data
*/ */
public static function userIdField(string $qualifier = ''): Field public static function userIdField(string $qualifier = ''): Field
{ {
$userField = Field::EQ('user_id', $_SESSION[Key::USER_ID], ':user'); $userField = Field::EQ('user_id', $_SESSION[Key::UserId], ':user');
$userField->qualifier = $qualifier; $userField->qualifier = $qualifier;
return $userField; return $userField;
} }

View File

@ -1,7 +1,14 @@
<?php declare(strict_types=1); <?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
namespace FeedReaderCentral; namespace FeedReaderCentral;
use BitBadger\InspiredByFSharp\{Option, Result};
use BitBadger\PDODocument\{ use BitBadger\PDODocument\{
Configuration, Custom, Document, DocumentException, DocumentList, Exists, Field, Find, Parameters, Patch, Query Configuration, Custom, Document, DocumentException, DocumentList, Exists, Field, Find, Parameters, Patch, Query
}; };
@ -15,15 +22,39 @@ class Feed
// ***** CONSTANTS ***** // ***** CONSTANTS *****
/** @var int Do not purge items */ /** @var int Do not purge items */
public const PURGE_NONE = 0; public const PurgeNone = 0;
/** @var int Purge all read items (will not purge unread items) */ /** @var int Purge all read items (will not purge unread items) */
public const PURGE_READ = 1; public const PurgeRead = 1;
/** @var int Purge items older than the specified number of days */ /** @var int Purge items older than the specified number of days */
public const PURGE_BY_DAYS = 2; public const PurgeByDays = 2;
/** @var int Purge items in number greater than the specified number of items to keep */ /** @var int Purge items in number greater than the specified number of items to keep */
public const PurgeByCount = 3;
/**
* @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; public const PURGE_BY_COUNT = 3;
/** /**
@ -46,12 +77,12 @@ class Feed
* Create a document from the parsed feed * Create a document from the parsed feed
* *
* @param ParsedFeed $parsed The parsed feed * @param ParsedFeed $parsed The parsed feed
* @return static The document constructed from the parsed feed * @return Feed The document constructed from the parsed feed
*/ */
public static function fromParsed(ParsedFeed $parsed): static public static function fromParsed(ParsedFeed $parsed): self
{ {
return new static( return new self(
user_id: $_SESSION[Key::USER_ID], user_id: $_SESSION[Key::UserId],
url: $parsed->url, url: $parsed->url,
title: $parsed->title, title: $parsed->title,
updated_on: $parsed->updatedOn, updated_on: $parsed->updatedOn,
@ -64,72 +95,77 @@ class Feed
* @param int $feedId The ID of the feed to which these items belong * @param int $feedId The ID of the feed to which these items belong
* @param ParsedFeed $parsed The extracted Atom or RSS feed items * @param ParsedFeed $parsed The extracted Atom or RSS feed items
* @param DateTimeInterface $lastChecked When this feed was last checked (only new items will be added) * @param DateTimeInterface $lastChecked When this feed was last checked (only new items will be added)
* @return array ['ok' => true] if successful, ['error' => message] if not * @return Result<true, string> True if successful, an error message if not
*/ */
public static function updateItems(int $feedId, ParsedFeed $parsed, DateTimeInterface $lastChecked): array public static function updateItems(int $feedId, ParsedFeed $parsed, DateTimeInterface $lastChecked): Result
{ {
$results = $results =
array_map(function ($item) use ($feedId) { array_map(function ($item) use ($feedId) {
try { try {
$existing = Find::firstByFields(Table::ITEM, $tryExisting = Find::firstByFields(Table::Item,
[Field::EQ('feed_id', $feedId), Field::EQ('item_guid', $item->guid)], Item::class); [Field::EQ('feed_id', $feedId), Field::EQ('item_guid', $item->guid)], Item::class);
if ($existing) { if ($tryExisting->isSome()) {
if ($existing->published_on != $item->publishedOn $existing = $tryExisting->get();
|| ($existing->updated_on != ($item->updatedOn ?? ''))) { if ($existing->published_on !== $item->publishedOn
|| ($existing->updated_on !== ($item->updatedOn ?? ''))) {
Item::update($existing->id, $item); Item::update($existing->id, $item);
} }
} else { } else {
Item::add($feedId, $item); Item::add($feedId, $item);
} }
return ['ok' => true]; return Result::OK(true);
} catch (DocumentException $ex) { } catch (DocumentException $ex) {
return ['error' => "$ex"]; return Result::Error("$ex");
} }
}, array_filter($parsed->items, }, array_filter($parsed->items,
fn($it) => date_create_immutable($it->updatedOn ?? $it->publishedOn) >= $lastChecked)); fn(ParsedItem $it) => date_create_immutable($it->updatedOn ?? $it->publishedOn) >= $lastChecked));
$errors = array_map(fn($it) => $it['error'], array_filter($results, fn($it) => array_key_exists('error', $it))); $errors = array_map(fn(Result $it) => $it->getError(), array_filter($results, fn($it) => $it->isError()));
return sizeof($errors) > 0 ? ['error' => implode("\n", $errors)] : ['ok' => true]; return sizeof($errors) > 0 ? Result::Error(implode("\n", $errors)) : Result::OK(true);
} }
/** /**
* Purge items for a feed * Purge items for a feed
* *
* @param int $feedId The ID of the feed to be purged * @param int $feedId The ID of the feed to be purged
* @return array|string[]|true[] ['ok' => true] if purging was successful, ['error' => message] if not * @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): array private static function purgeItems(int $feedId): Result
{ {
if (!array_search(PURGE_TYPE, [self::PURGE_READ, self::PURGE_BY_DAYS, self::PURGE_BY_COUNT])) {
return ['error' => 'Unrecognized purge type ' . PURGE_TYPE];
}
$fields = [Field::EQ('feed_id', $feedId, ':feed'), Data::bookmarkField(false)]; $fields = [Field::EQ('feed_id', $feedId, ':feed'), Data::bookmarkField(false)];
$sql = Query\Delete::byFields(Table::ITEM, $fields); $sql = Query\Delete::byFields(Table::Item, $fields);
if (PURGE_TYPE == self::PURGE_READ) {
$readField = Field::EQ('is_read', 1, ':read'); switch (PURGE_TYPE) {
$fields[] = $readField; case self::PurgeRead:
$sql .= ' AND ' . Query::whereByFields([$readField]); $readField = Field::EQ('is_read', 1, ':read');
} elseif (PURGE_TYPE == self::PURGE_BY_DAYS) { $fields[] = $readField;
$fields[] = Field::EQ('', Data::formatDate('-' . PURGE_NUMBER . ' day'), ':oldest'); $sql .= ' AND ' . Query::whereByFields([$readField]);
$sql .= " AND date(coalesce(data->>'updated_on', data->>'published_on')) < date(:oldest)"; break;
} elseif (PURGE_TYPE == self::PURGE_BY_COUNT) { case self::PurgeByDays:
$fields[] = Field::EQ('', PURGE_NUMBER, ':keep'); $fields[] = Field::EQ('', Data::formatDate('-' . PURGE_NUMBER . ' day'), ':oldest');
$id = Configuration::$idField; $sql .= " AND date(coalesce(data->>'updated_on', data->>'published_on')) < date(:oldest)";
$table = Table::ITEM; break;
$sql .= ' ' . <<<SQL case self::PurgeByCount:
AND data->>'$id' IN ( $fields[] = Field::EQ('', PURGE_NUMBER, ':keep');
SELECT data->>'$id' FROM $table $id = Configuration::$idField;
WHERE data->>'feed_id' = :feed $table = Table::Item;
ORDER BY date(coalesce(data->>'updated_on', data->>'published_on')) DESC $sql .= ' ' . <<<SQL
LIMIT -1 OFFSET :keep AND data->>'$id' IN (
) SELECT data->>'$id' FROM $table
SQL; WHERE data->>'feed_id' = :feed
ORDER BY date(coalesce(data->>'updated_on', data->>'published_on')) DESC
LIMIT -1 OFFSET :keep
)
SQL;
break;
default:
return Result::Error('Unrecognized purge type ' . PURGE_TYPE);
} }
try { try {
Custom::nonQuery($sql, Parameters::addFields($fields, [])); Custom::nonQuery($sql, Parameters::addFields($fields, []));
return ['ok' => true]; return Result::OK(true);
} catch (DocumentException $ex) { } catch (DocumentException $ex) {
return ['error' => "$ex"]; return Result::Error("$ex");
} }
} }
@ -138,67 +174,62 @@ class Feed
* *
* @param int $feedId The ID of the feed to be refreshed * @param int $feedId The ID of the feed to be refreshed
* @param string $url The URL of the feed to be refreshed * @param string $url The URL of the feed to be refreshed
* @return array|string[]|true[] ['ok' => true] if successful, ['error' => message] if not * @return Result<true, string> True if successful, an error message if not
*/ */
public static function refreshFeed(int $feedId, string $url): array public static function refreshFeed(int $feedId, string $url): Result
{ {
$feedRetrieval = ParsedFeed::retrieve($url); return ParsedFeed::retrieve($url)
if (key_exists('error', $feedRetrieval)) return $feedRetrieval; ->bind(function (ParsedFeed $feed) use ($feedId, $url) {
$feed = $feedRetrieval['ok']; 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);
try { return self::updateItems($feedId, $feed, $lastChecked)
$feedDoc = Find::byId(Table::FEED, $feedId, self::class); ->bind(function () use ($feed, $feedId, $url) {
if (!$feedDoc) return ['error' => 'Could not derive date last checked for feed']; $patch = [
$lastChecked = date_create_immutable($feedDoc->checked_on ?? WWW_EPOCH); 'title' => $feed->title,
'updated_on' => $feed->updatedOn,
$itemUpdate = self::updateItems($feedId, $feed, $lastChecked); 'checked_on' => Data::formatDate('now')
if (key_exists('error', $itemUpdate)) return $itemUpdate; ];
if ($url !== $feed->url) $patch['url'] = $feed->url;
$patch = [ Patch::byId(Table::Feed, $feedId, $patch);
'title' => $feed->title, return Result::OK(true);
'updated_on' => $feed->updatedOn, })
'checked_on' => Data::formatDate('now') ->bind(fn() => PURGE_TYPE === self::PurgeNone ? Result::OK(true) : self::purgeItems($feedId));
]; } catch (DocumentException $ex) {
if ($url == $feed->url) $patch['url'] = $feed->url; return Result::Error("$ex");
Patch::byId(Table::FEED, $feedId, $patch); }
} catch (DocumentException $ex) { });
return ['error' => "$ex"];
}
return PURGE_TYPE == self::PURGE_NONE ? ['ok' => true] : self::purgeItems($feedId);
} }
/** /**
* Add an RSS feed * Add an RSS feed
* *
* @param string $url The URL of the RSS feed to add * @param string $url The URL of the RSS feed to add
* @return array ['ok' => feedId] if successful, ['error' => message] if not * @return Result<int, string> The feed ID if successful, an error message if not
*/ */
public static function add(string $url): array public static function add(string $url): Result
{ {
$feedExtract = ParsedFeed::retrieve($url); return ParsedFeed::retrieve($url)
if (key_exists('error', $feedExtract)) return $feedExtract; ->bind(function (ParsedFeed $feed) {
try {
$fields = [Field::EQ('user_id', $_SESSION[Key::UserId]), Field::EQ('url', $feed->url)];
if (Exists::byFields(Table::Feed, $fields)) {
return Result::Error("Already subscribed to feed $feed->url");
}
$feed = $feedExtract['ok']; Document::insert(Table::Feed, self::fromParsed($feed));
try { $doc = Find::firstByFields(Table::Feed, $fields, self::class);
$fields = [Field::EQ('user_id', $_SESSION[Key::USER_ID]), Field::EQ('url', $feed->url)]; if ($doc->isNone()) return Result::Error('Could not retrieve inserted feed');
if (Exists::byFields(Table::FEED, $fields)) {
return ['error' => "Already subscribed to feed $feed->url"];
}
Document::insert(Table::FEED, self::fromParsed($feed)); return self::updateItems($doc->get()->id, $feed, date_create_immutable(WWW_EPOCH))
->bind(fn() => Result::OK($doc->get()->id));
$doc = Find::firstByFields(Table::FEED, $fields, static::class); } catch (DocumentException $ex) {
if (!$doc) return ['error' => 'Could not retrieve inserted feed']; return Result::Error("$ex");
}
$result = self::updateItems($doc->id, $feed, date_create_immutable(WWW_EPOCH)); });
if (key_exists('error', $result)) return $result;
return ['ok' => $doc->id];
} catch (DocumentException $ex) {
return ['error' => "$ex"];
}
} }
/** /**
@ -206,18 +237,17 @@ class Feed
* *
* @param Feed $existing The existing feed * @param Feed $existing The existing feed
* @param string $url The URL with which the existing feed should be modified * @param string $url The URL with which the existing feed should be modified
* @return bool[]|string[] [ 'ok' => true ] if successful, [ 'error' => message ] if not * @return Result<true, string> True if successful, an error message if not
*/ */
public static function update(Feed $existing, string $url): array public static function update(Feed $existing, string $url): Result
{ {
try { try {
Patch::byFields(Table::FEED, Patch::byFields(Table::Feed,
[Field::EQ(Configuration::$idField, $existing->id), Field::EQ('user_id', $_SESSION[Key::USER_ID])], [Field::EQ(Configuration::$idField, $existing->id), Field::EQ('user_id', $_SESSION[Key::UserId])],
['url' => $url]); ['url' => $url]);
return self::refreshFeed($existing->id, $url); return self::refreshFeed($existing->id, $url);
} catch (DocumentException $ex) { } catch (DocumentException $ex) {
return ['error' => "$ex"]; return Result::Error("$ex");
} }
} }
@ -230,44 +260,42 @@ class Feed
*/ */
public static function retrieveAll(int $user = 0): DocumentList public static function retrieveAll(int $user = 0): DocumentList
{ {
return $user == 0 return $user === 0
? Find::all(Table::FEED, self::class) ? Find::all(Table::Feed, self::class)
: Find::byFields(Table::FEED, [Field::EQ('user_id', $user)], self::class); : Find::byFields(Table::Feed, [Field::EQ('user_id', $user)], self::class);
} }
/** /**
* Refresh all feeds * Refresh all feeds
* *
* @return array|true[]|string[] ['ok' => true] if successful, * @return Result<true, string> True if successful an error message if not (may have multiple error lines)
* ['error' => message] if not (may have multiple error lines)
*/ */
public static function refreshAll(): array public static function refreshAll(): Result
{ {
try { $errors = [];
$feeds = self::retrieveAll($_SESSION[Key::USER_ID]);
$errors = []; try {
foreach ($feeds->items() as $feed) { self::retrieveAll($_SESSION[Key::UserId])->iter(function (Feed $feed) use (&$errors) {
$result = self::refreshFeed($feed->id, $feed->url); self::refreshFeed($feed->id, $feed->url)
if (key_exists('error', $result)) $errors[] = $result['error']; ->mapError(function (string $err) use (&$errors) { $errors[] = $err; });
} });
} catch (DocumentException $ex) { } catch (DocumentException $ex) {
return ['error' => "$ex"]; return Result::Error("$ex");
} }
return sizeof($errors) == 0 ? ['ok' => true] : ['error' => implode("\n", $errors)]; return empty($errors) ? Result::OK(true) : Result::Error(implode("\n", $errors));
} }
/** /**
* Retrieve a feed by its ID for the current user * Retrieve a feed by its ID for the current user
* *
* @param int $feedId The ID of the feed to retrieve * @param int $feedId The ID of the feed to retrieve
* @return static|false The data for the feed if found, false if not found * @return Option<Feed> A `Some` value with the data for the feed if found, `None` otherwise
* @throws DocumentException If any is encountered * @throws DocumentException If any is encountered
*/ */
public static function retrieveById(int $feedId): static|false public static function retrieveById(int $feedId): Option
{ {
$doc = Find::byId(Table::FEED, $feedId, static::class); return Find::byId(Table::Feed, $feedId, static::class)
return $doc && $doc->user_id == $_SESSION[Key::USER_ID] ? $doc : false; ->filter(fn($it) => $it->user_id === $_SESSION[Key::UserId]);
} }
} }

View File

@ -1,4 +1,10 @@
<?php declare(strict_types=1); <?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
namespace FeedReaderCentral; namespace FeedReaderCentral;
@ -57,7 +63,7 @@ class Item
*/ */
public static function add(int $feedId, ParsedItem $parsed): void public static function add(int $feedId, ParsedItem $parsed): void
{ {
Document::insert(Table::ITEM, new static( Document::insert(Table::Item, new self(
feed_id: $feedId, feed_id: $feedId,
title: $parsed->title, title: $parsed->title,
item_guid: $parsed->guid, item_guid: $parsed->guid,
@ -76,7 +82,7 @@ class Item
*/ */
public static function update(int $id, ParsedItem $parsed): void public static function update(int $id, ParsedItem $parsed): void
{ {
Patch::byId(Table::ITEM, $id, [ Patch::byId(Table::Item, $id, [
'title' => $parsed->title, 'title' => $parsed->title,
'published_on' => $parsed->publishedOn, 'published_on' => $parsed->publishedOn,
'updated_on' => $parsed->updatedOn, 'updated_on' => $parsed->updatedOn,

View File

@ -1,7 +1,14 @@
<?php declare(strict_types=1); <?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
namespace FeedReaderCentral; namespace FeedReaderCentral;
use BitBadger\InspiredByFSharp\Option;
use BitBadger\PDODocument\{Configuration, Custom, DocumentException, DocumentList, Field, Parameters, Query}; use BitBadger\PDODocument\{Configuration, Custom, DocumentException, DocumentList, Field, Parameters, Query};
use BitBadger\PDODocument\Mapper\DocumentMapper; use BitBadger\PDODocument\Mapper\DocumentMapper;
@ -15,18 +22,8 @@ class ItemList
/** @var DocumentList<ItemWithFeed> The items matching the criteria, lazily iterable */ /** @var DocumentList<ItemWithFeed> The items matching the criteria, lazily iterable */
private DocumentList $dbList; private DocumentList $dbList;
/** @var string The error message generated by executing a query */ /** @var Option<string> The error message generated by executing a query */
public string $error = ''; private Option $error;
/**
* Is there an error condition associated with this list?
*
* @return bool True if there is an error condition associated with this list, false if not
*/
public function isError(): bool
{
return $this->error != '';
}
/** @var bool Whether to render a link to the feed to which the item belongs */ /** @var bool Whether to render a link to the feed to which the item belongs */
public bool $linkFeed = false; public bool $linkFeed = false;
@ -42,32 +39,33 @@ class ItemList
* *
* @param string $itemType The type of item being displayed (unread, bookmark, etc.) * @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 string $returnURL The URL to which the item page should return once the item has been viewed
* @param array|Field[] $fields The fields to use to restrict the results * @param Field[] $fields The fields to use to restrict the results
* @param string $searchWhere Additional WHERE clause to use for searching * @param string $searchWhere Additional WHERE clause to use for searching
*/ */
private function __construct(public string $itemType, public string $returnURL = '', array $fields = [], private function __construct(public string $itemType, public string $returnURL = '', array $fields = [],
string $searchWhere = '') string $searchWhere = '')
{ {
$allFields = [Data::userIdField(Table::FEED), ...$fields]; $this->error = Option::None();
$allFields = [Data::userIdField(Table::Feed), ...$fields];
try { try {
$this->dbList = Custom::list( $this->dbList = Custom::list(
ItemWithFeed::SELECT_WITH_FEED . ' WHERE ' ItemWithFeed::SELECT_WITH_FEED . ' WHERE '
. Query::whereByFields(array_filter($allFields, fn($it) => $it->paramName <> ':search')) . Query::whereByFields(array_filter($allFields, fn($it) => $it->paramName !== ':search'))
. "$searchWhere ORDER BY coalesce(item.data->>'updated_on', item.data->>'published_on') DESC", . "$searchWhere ORDER BY coalesce(item.data->>'updated_on', item.data->>'published_on') DESC",
Parameters::addFields($allFields, []), new DocumentMapper(ItemWithFeed::class)); Parameters::addFields($allFields, []), new DocumentMapper(ItemWithFeed::class));
} catch (DocumentException $ex) { } catch (DocumentException $ex) {
$this->error = "$ex"; $this->error = Option::Some("$ex");
} }
} }
/** /**
* Create an item list with all the current user's bookmarked items * Create an item list with all the current user's bookmarked items
* *
* @return static An item list with all bookmarked items * @return ItemList An item list with all bookmarked items
*/ */
public static function allBookmarked(): static public static function allBookmarked(): self
{ {
$list = new static('Bookmarked', '/?bookmarked', [Data::bookmarkField(qualifier: Table::ITEM)]); $list = new self('Bookmarked', '/?bookmarked', [Data::bookmarkField(qualifier: Table::Item)]);
$list->linkFeed = true; $list->linkFeed = true;
return $list; return $list;
} }
@ -75,11 +73,11 @@ class ItemList
/** /**
* Create an item list with all the current user's unread items * Create an item list with all the current user's unread items
* *
* @return static An item list with all unread items * @return ItemList An item list with all unread items
*/ */
public static function allUnread(): static public static function allUnread(): self
{ {
$list = new static('Unread', fields: [Data::unreadField(Table::ITEM)]); $list = new self('Unread', fields: [Data::unreadField(Table::Item)]);
$list->linkFeed = true; $list->linkFeed = true;
return $list; return $list;
} }
@ -88,11 +86,11 @@ class ItemList
* Create an item list with all items for the given feed * Create an item list with all items for the given feed
* *
* @param int $feedId The ID of the feed for which items should be retrieved * @param int $feedId The ID of the feed for which items should be retrieved
* @return static An item list with all items for the given feed * @return ItemList An item list with all items for the given feed
*/ */
public static function allForFeed(int $feedId): static public static function allForFeed(int $feedId): self
{ {
$list = new static('', "/feed/items?id=$feedId", [Data::feedField($feedId, Table::FEED)]); $list = new self('', "/feed/items?id=$feedId", [Data::feedField($feedId, Table::Feed)]);
$list->showIndicators = true; $list->showIndicators = true;
return $list; return $list;
} }
@ -101,24 +99,24 @@ class ItemList
* Create an item list with unread items for the given feed * Create an item list with unread items for the given feed
* *
* @param int $feedId The ID of the feed for which items should be retrieved * @param int $feedId The ID of the feed for which items should be retrieved
* @return static An item list with unread items for the given feed * @return ItemList An item list with unread items for the given feed
*/ */
public static function unreadForFeed(int $feedId): static public static function unreadForFeed(int $feedId): self
{ {
return new static('Unread', "/feed/items?id=$feedId&unread", return new self('Unread', "/feed/items?id=$feedId&unread",
[Data::feedField($feedId, Table::FEED), Data::unreadField(Table::ITEM)]); [Data::feedField($feedId, Table::Feed), Data::unreadField(Table::Item)]);
} }
/** /**
* Create an item list with bookmarked items for the given feed * Create an item list with bookmarked items for the given feed
* *
* @param int $feedId The ID of the feed for which items should be retrieved * @param int $feedId The ID of the feed for which items should be retrieved
* @return static An item list with bookmarked items for the given feed * @return ItemList An item list with bookmarked items for the given feed
*/ */
public static function bookmarkedForFeed(int $feedId): static public static function bookmarkedForFeed(int $feedId): self
{ {
return new static('Bookmarked', "/feed/items?id=$feedId&bookmarked", return new self('Bookmarked', "/feed/items?id=$feedId&bookmarked",
[Data::feedField($feedId, Table::FEED), Data::bookmarkField(qualifier: Table::ITEM)]); [Data::feedField($feedId, Table::Feed), Data::bookmarkField(qualifier: Table::Item)]);
} }
/** /**
@ -126,15 +124,15 @@ class ItemList
* *
* @param string $search The item search terms / query * @param string $search The item search terms / query
* @param bool $isBookmarked Whether to restrict the search to bookmarked items * @param bool $isBookmarked Whether to restrict the search to bookmarked items
* @return static An item list match the given search terms * @return ItemList An item list match the given search terms
*/ */
public static function matchingSearch(string $search, bool $isBookmarked): static public static function matchingSearch(string $search, bool $isBookmarked): self
{ {
$fields = [Field::EQ('content', $search, ':search')]; $fields = [Field::EQ('content', $search, ':search')];
if ($isBookmarked) $fields[] = Data::bookmarkField(qualifier: Table::ITEM); if ($isBookmarked) $fields[] = Data::bookmarkField(qualifier: Table::Item);
$list = new static('Matching' . ($isBookmarked ? ' Bookmarked' : ''), $list = new self('Matching' . ($isBookmarked ? ' Bookmarked' : ''),
"/search?search=$search&items=" . ($isBookmarked ? 'bookmarked' : 'all'), $fields, "/search?search=$search&items=" . ($isBookmarked ? 'bookmarked' : 'all'), $fields,
' AND ' . Table::ITEM . ".data->>'" . Configuration::$idField . "' IN " ' AND ' . Table::Item . ".data->>'" . Configuration::$idField . "' IN "
. '(SELECT ROWID FROM item_search WHERE content MATCH :search)'); . '(SELECT ROWID FROM item_search WHERE content MATCH :search)');
$list->showIndicators = true; $list->showIndicators = true;
$list->displayFeed = true; $list->displayFeed = true;
@ -146,29 +144,29 @@ class ItemList
*/ */
public function render(): void public function render(): void
{ {
if ($this->isError()) { if ($this->error->isSome()) {
echo "<p>Error retrieving list:<br>$this->error"; echo "<p>Error retrieving list:<br>{$this->error->get()}";
return; return;
} }
$return = $this->returnURL == '' ? '' : '&from=' . urlencode($this->returnURL); $return = $this->returnURL === '' ? '' : '&from=' . urlencode($this->returnURL);
echo '<article>'; echo '<article>';
if ($this->dbList->hasItems()) { if ($this->dbList->hasItems()) {
foreach ($this->dbList->items() as $it) { $this->dbList->iter(function (ItemWithFeed $item) use ($return) {
echo '<p>' . hx_get("/item?id=$it->id$return", strip_tags($it->title)) . '<br><small>'; echo '<p>' . hx_get("/item?id=$item->id$return", strip_tags($item->title)) . '<br><small>';
if ($this->showIndicators) { if ($this->showIndicators) {
if (!$it->isRead()) echo '<strong>Unread</strong> &nbsp; '; if (!$item->isRead()) echo '<strong>Unread</strong> &nbsp; ';
if ($it->isBookmarked()) echo '<strong>Bookmarked</strong> &nbsp; '; if ($item->isBookmarked()) echo '<strong>Bookmarked</strong> &nbsp; ';
} }
echo '<em>' . date_time($it->updated_on ?? $it->published_on) . '</em>'; echo '<em>' . date_time($item->updated_on ?? $item->published_on) . '</em>';
if ($this->linkFeed) { if ($this->linkFeed) {
echo ' &bull; ' . echo ' &bull; ' .
hx_get("/feed/items?id={$it->feed->id}&" . strtolower($this->itemType), hx_get("/feed/items?id={$item->feed->id}&" . strtolower($this->itemType),
htmlentities($it->feed->title)); htmlentities($item->feed->title));
} elseif ($this->displayFeed) { } elseif ($this->displayFeed) {
echo ' &bull; ' . htmlentities($it->feed->title); echo ' &bull; ' . htmlentities($item->feed->title);
} }
echo '</small>'; echo '</small>';
} });
} else { } else {
echo '<p><em>There are no ' . strtolower($this->itemType) . ' items</em>'; echo '<p><em>There are no ' . strtolower($this->itemType) . ' items</em>';
} }

View File

@ -1,7 +1,14 @@
<?php declare(strict_types=1); <?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
namespace FeedReaderCentral; namespace FeedReaderCentral;
use BitBadger\InspiredByFSharp\Option;
use BitBadger\PDODocument\{Configuration, Custom, DocumentException, Field, Parameters, Query}; use BitBadger\PDODocument\{Configuration, Custom, DocumentException, Field, Parameters, Query};
use BitBadger\PDODocument\Mapper\{DocumentMapper, ExistsMapper}; use BitBadger\PDODocument\Mapper\{DocumentMapper, ExistsMapper};
@ -11,12 +18,12 @@ use BitBadger\PDODocument\Mapper\{DocumentMapper, ExistsMapper};
class ItemWithFeed extends Item class ItemWithFeed extends Item
{ {
/** @var string The body of the `FROM` clause to join item and feed */ /** @var string The body of the `FROM` clause to join item and feed */
public const FROM_WITH_JOIN = Table::ITEM . ' INNER JOIN ' . Table::FEED public const FROM_WITH_JOIN = Table::Item . ' INNER JOIN ' . Table::Feed
. ' ON ' . Table::ITEM . ".data->>'feed_id' = " . Table::FEED . ".data->>'id'"; . ' 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 */ /** @var string The `SELECT` clause to add the feed as a property to the item's document */
public const SELECT_WITH_FEED = public const SELECT_WITH_FEED =
'SELECT json_set(' . Table::ITEM . ".data, '$.feed', json(" . Table::FEED . '.data)) AS data FROM ' 'SELECT json_set(' . Table::Item . ".data, '$.feed', json(" . Table::Feed . '.data)) AS data FROM '
. self::FROM_WITH_JOIN; . self::FROM_WITH_JOIN;
/** @var Feed The feed to which this item belongs */ /** @var Feed The feed to which this item belongs */
@ -31,9 +38,9 @@ class ItemWithFeed extends Item
private static function idAndUserFields(int $id): array private static function idAndUserFields(int $id): array
{ {
$idField = Field::EQ(Configuration::$idField, $id, ':id'); $idField = Field::EQ(Configuration::$idField, $id, ':id');
$idField->qualifier = Table::ITEM; $idField->qualifier = Table::Item;
$userField = Field::EQ('user_id', $_SESSION[Key::USER_ID], ':user'); $userField = Field::EQ('user_id', $_SESSION[Key::UserId], ':user');
$userField->qualifier = Table::FEED; $userField->qualifier = Table::Feed;
return [$idField, $userField]; return [$idField, $userField];
} }
@ -55,10 +62,10 @@ class ItemWithFeed extends Item
* Retrieve an item via its ID * Retrieve an item via its ID
* *
* @param int $id The ID of the item to be retrieved * @param int $id The ID of the item to be retrieved
* @return ItemWithFeed|false The item if it is found, false if not * @return Option<ItemWithFeed> A `Some` value with the item if it is found, `None` otherwise
* @throws DocumentException If any is encountered * @throws DocumentException If any is encountered
*/ */
public static function retrieveById(int $id): ItemWithFeed|false public static function retrieveById(int $id): Option
{ {
$fields = self::idAndUserFields($id); $fields = self::idAndUserFields($id);
return Custom::single(self::SELECT_WITH_FEED . ' WHERE ' . Query::whereByFields($fields), return Custom::single(self::SELECT_WITH_FEED . ' WHERE ' . Query::whereByFields($fields),

View File

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

View File

@ -1,25 +1,34 @@
<?php declare(strict_types=1); <?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
namespace FeedReaderCentral; namespace FeedReaderCentral;
use BitBadger\InspiredByFSharp\Result;
use DOMDocument; use DOMDocument;
use DOMElement; use DOMElement;
use DOMException; use DOMException;
use DOMNode; use DOMNode;
class ParsedFeed /**
* A feed, as parsed from the Atom or RSS XML
*/
readonly class ParsedFeed
{ {
/** @var string The URL for the feed */ /**
public string $url = ''; * Constructor
*
/** @var string The title of the feed */ * @param string $url The URL for the feed
public string $title = ''; * @param string $title The title of the feed
* @param string|null $updatedOn When the feed was last updated
/** @var ?string When the feed was last updated */ * @param ParsedItem[] $items The items contained in the feed
public ?string $updatedOn = null; */
public function __construct(public string $url = '', public string $title = '', public ?string $updatedOn = null,
/** @var ParsedItem[] The items contained in the feed */ public array $items = []) {}
public array $items = [];
/** @var string The XML namespace for Atom feeds */ /** @var string The XML namespace for Atom feeds */
public const ATOM_NS = 'http://www.w3.org/2005/Atom'; public const ATOM_NS = 'http://www.w3.org/2005/Atom';
@ -42,8 +51,9 @@ class ParsedFeed
* @return bool False, to delegate to the next error handler in the chain * @return bool False, to delegate to the next error handler in the chain
* @throws DOMException If the error is a warning * @throws DOMException If the error is a warning
*/ */
private static function xmlParseError(int $errno, string $errstr): bool { private static function xmlParseError(int $errno, string $errstr): bool
if ($errno == E_WARNING && substr_count($errstr, 'DOMDocument::loadXML()') > 0) { {
if ($errno === E_WARNING && substr_count($errstr, 'DOMDocument::loadXML()') > 0) {
throw new DOMException($errstr, $errno); throw new DOMException($errstr, $errno);
} }
return false; return false;
@ -53,16 +63,17 @@ class ParsedFeed
* Parse a feed into an XML tree * Parse a feed into an XML tree
* *
* @param string $content The feed's RSS content * @param string $content The feed's RSS content
* @return array|DOMDocument[]|string[] ['ok' => feed] if successful, ['error' => message] if not * @return Result<DOMDocument, string> The feed if successful, an error message if not
*/ */
public static function parseFeed(string $content): array { public static function parseFeed(string $content): Result
{
set_error_handler(self::xmlParseError(...)); set_error_handler(self::xmlParseError(...));
try { try {
$feed = new DOMDocument(); $feed = new DOMDocument();
$feed->loadXML($content); $feed->loadXML($content);
return ['ok' => $feed]; return Result::OK($feed);
} catch (DOMException $ex) { } catch (DOMException $ex) {
return ['error' => $ex->getMessage()]; return Result::Error($ex->getMessage());
} finally { } finally {
restore_error_handler(); restore_error_handler();
} }
@ -75,9 +86,10 @@ class ParsedFeed
* @param string $tagName The name of the tag whose value should be obtained * @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) * @return string The value of the element (or "[element] not found" if that element does not exist)
*/ */
public static function rssValue(DOMNode $element, string $tagName): string { public static function rssValue(DOMNode $element, string $tagName): string
{
$tags = $element->getElementsByTagName($tagName); $tags = $element->getElementsByTagName($tagName);
return $tags->length == 0 ? "$tagName not found" : $tags->item(0)->textContent; return $tags->length === 0 ? "$tagName not found" : $tags->item(0)->textContent;
} }
/** /**
@ -85,13 +97,14 @@ class ParsedFeed
* *
* @param DOMDocument $xml The XML received from the feed * @param DOMDocument $xml The XML received from the feed
* @param string $url The actual URL for the feed * @param string $url The actual URL for the feed
* @return array|Feed[]|string[] ['ok' => feed] if successful, ['error' => message] if not * @return Result<ParsedFeed, string> The feed if successful, an error message if not
*/ */
private static function fromRSS(DOMDocument $xml, string $url): array { private static function fromRSS(DOMDocument $xml, string $url): Result
{
$channel = $xml->getElementsByTagName('channel')->item(0); $channel = $xml->getElementsByTagName('channel')->item(0);
if (!($channel instanceof DOMElement)) { if (!($channel instanceof DOMElement)) {
$type = $channel?->nodeType ?? -1; $type = $channel?->nodeType ?? -1;
return ['error' => "Channel element not found ($type)"]; 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 // The Atom namespace provides a lastBuildDate, which contains the last time an item in the feed was updated; if
@ -102,13 +115,11 @@ class ParsedFeed
} }
} }
$feed = new static(); return Result::OK(new self(
$feed->title = self::rssValue($channel, 'title'); url: $url,
$feed->url = $url; title: self::rssValue($channel, 'title'),
$feed->updatedOn = Data::formatDate($updatedOn); updatedOn: Data::formatDate($updatedOn),
foreach ($channel->getElementsByTagName('item') as $item) $feed->items[] = ParsedItem::fromRSS($item); items: array_map(ParsedItem::fromRSS(...), iterator_to_array($channel->getElementsByTagName('item')))));
return ['ok' => $feed];
} }
/** /**
@ -118,10 +129,11 @@ class ParsedFeed
* @param string $name The name of the attribute whose value should be obtained * @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 * @return string The attribute value if it exists, an empty string if not
*/ */
private static function attrValue(DOMNode $node, string $name): string { private static function attrValue(DOMNode $node, string $name): string
{
return ($node->hasAttributes() ? $node->attributes->getNamedItem($name)?->value : null) ?? ''; return ($node->hasAttributes() ? $node->attributes->getNamedItem($name)?->value : null) ?? '';
} }
/** /**
* Get the value of a child element by its tag name for an Atom feed * Get the value of a child element by its tag name for an Atom feed
* *
@ -132,14 +144,15 @@ class ParsedFeed
* @param string $tagName The name of the tag whose value should be obtained * @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) * @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 { public static function atomValue(DOMNode $element, string $tagName): string
{
$tags = $element->getElementsByTagName($tagName); $tags = $element->getElementsByTagName($tagName);
if ($tags->length == 0) return "$tagName not found"; if ($tags->length === 0) return "$tagName not found";
$tag = $tags->item(0); $tag = $tags->item(0);
if (!($tag instanceof DOMElement)) return $tag->textContent; if (!($tag instanceof DOMElement)) return $tag->textContent;
if (self::attrValue($tag, 'type') == 'xhtml') { if (self::attrValue($tag, 'type') == 'xhtml') {
$div = $tag->getElementsByTagNameNS(self::XHTML_NS, 'div'); $div = $tag->getElementsByTagNameNS(self::XHTML_NS, 'div');
if ($div->length == 0) return "-- invalid XHTML content --"; if ($div->length === 0) return "-- invalid XHTML content --";
return $div->item(0)->textContent; return $div->item(0)->textContent;
} }
return $tag->textContent; return $tag->textContent;
@ -150,103 +163,114 @@ class ParsedFeed
* *
* @param DOMDocument $xml The XML received from the feed * @param DOMDocument $xml The XML received from the feed
* @param string $url The actual URL for the feed * @param string $url The actual URL for the feed
* @return array|Feed[] ['ok' => feed] * @return Result<ParsedFeed, string> The feed (does not have any error handling)
*/ */
private static function fromAtom(DOMDocument $xml, string $url): array { private static function fromAtom(DOMDocument $xml, string $url): Result
{
$root = $xml->getElementsByTagNameNS(self::ATOM_NS, 'feed')->item(0); $root = $xml->getElementsByTagNameNS(self::ATOM_NS, 'feed')->item(0);
if (($updatedOn = self::atomValue($root, 'updated')) == 'pubDate not found') $updatedOn = null; if (($updatedOn = self::atomValue($root, 'updated')) == 'pubDate not found') $updatedOn = null;
$feed = new static(); return Result::OK(new self(
$feed->title = self::atomValue($root, 'title'); url: $url,
$feed->url = $url; title: self::atomValue($root, 'title'),
$feed->updatedOn = Data::formatDate($updatedOn); updatedOn: Data::formatDate($updatedOn),
foreach ($root->getElementsByTagName('entry') as $entry) $feed->items[] = ParsedItem::fromAtom($entry); items: array_map(ParsedItem::fromAtom(...), iterator_to_array($root->getElementsByTagName('entry')))));
return ['ok' => $feed];
} }
/** /**
* Retrieve a document (http/https) * Retrieve a document (http/https)
* *
* @param string $url The URL of the document to retrieve * @param string $url The URL of the document to retrieve
* @return array ['content' => document content, 'error' => error message, 'code' => HTTP response code, * @return Result<array, string> ['content' => doc content, 'code' => HTTP response code, 'url' => effective URL] if
* 'url' => effective URL] * successful, an error message if not
*/ */
private static function retrieveDocument(string $url): array { private static function retrieveDocument(string $url): Result
{
$docReq = curl_init($url); $docReq = curl_init($url);
curl_setopt($docReq, CURLOPT_FOLLOWLOCATION, true); try {
curl_setopt($docReq, CURLOPT_RETURNTRANSFER, true); curl_setopt($docReq, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($docReq, CURLOPT_CONNECTTIMEOUT, 5); curl_setopt($docReq, CURLOPT_RETURNTRANSFER, true);
curl_setopt($docReq, CURLOPT_TIMEOUT, 15); curl_setopt($docReq, CURLOPT_CONNECTTIMEOUT, 5);
curl_setopt($docReq, CURLOPT_USERAGENT, self::USER_AGENT); curl_setopt($docReq, CURLOPT_TIMEOUT, 15);
curl_setopt($docReq, CURLOPT_USERAGENT, self::USER_AGENT);
$result = [ $error = curl_error($docReq);
'content' => curl_exec($docReq), if ($error !== '') return Result::Error($error);
'error' => curl_error($docReq),
'code' => curl_getinfo($docReq, CURLINFO_RESPONSE_CODE),
'url' => curl_getinfo($docReq, CURLINFO_EFFECTIVE_URL)
];
curl_close($docReq); return Result::OK([
return $result; '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 * Derive a feed URL from an HTML document
* *
* @param string $content The HTML document content from which to derive a feed URL * @param string $content The HTML document content from which to derive a feed URL
* @return array|string[] ['ok' => feed URL] if successful, ['error' => message] if not * @return Result<string, string> The feed URL if successful, an error message if not
*/ */
private static function deriveFeedFromHTML(string $content): array { private static function deriveFeedFromHTML(string $content): Result
{
$html = new DOMDocument(); $html = new DOMDocument();
$html->loadHTML(substr($content, 0, strpos($content, '</head>') + 7)); $html->loadHTML(substr($content, 0, strpos($content, '</head>') + 7));
$headTags = $html->getElementsByTagName('head'); $headTags = $html->getElementsByTagName('head');
if ($headTags->length < 1) return ['error' => 'Cannot find feed at this URL']; if ($headTags->length < 1) return Result::Error('Cannot find feed at this URL');
$head = $headTags->item(0); $head = $headTags->item(0);
foreach ($head->getElementsByTagName('link') as $link) { foreach ($head->getElementsByTagName('link') as $link) {
if (self::attrValue($link, 'rel') == 'alternate') { if (self::attrValue($link, 'rel') === 'alternate') {
$type = self::attrValue($link, 'type'); $type = self::attrValue($link, 'type');
if ($type == 'application/rss+xml' || $type == 'application/atom+xml') { if ($type === 'application/rss+xml' || $type === 'application/atom+xml') {
return ['ok' => self::attrValue($link, 'href')]; return Result::OK(self::attrValue($link, 'href'));
} }
} }
} }
return ['error' => 'Cannot find feed at this URL']; return Result::Error('Cannot find feed at this URL');
} }
/** /**
* Retrieve the feed * Retrieve the feed
* *
* @param string $url The URL of the feed to retrieve * @param string $url The URL of the feed to retrieve
* @return array|ParsedFeed[]|string[] ['ok' => feed] if successful, ['error' => message] if not * @return Result<ParsedFeed, string> The feed if successful, an error message if not
*/ */
public static function retrieve(string $url): array { public static function retrieve(string $url): Result
$doc = self::retrieveDocument($url); {
$doc = self::retrieveDocument($url)
if ($doc['error'] != '') return ['error' => $doc['error']]; ->bind(fn(array $doc) => match ($doc['code']) {
if ($doc['code'] != 200) { 200 => Result::OK($doc),
return ['error' => "Prospective feed URL $url returned HTTP Code {$doc['code']}: {$doc['content']}"]; default => Result::Error(
} "Prospective feed URL $url returned HTTP Code {$doc['code']}: {$doc['content']}"),
})
$start = strtolower(strlen($doc['content']) >= 9 ? substr($doc['content'], 0, 9) : $doc['content']); ->bind(function (array $doc) use ($url) {
if ($start == '<!doctype' || str_starts_with($start, '<html')) { $start = strtolower(strlen($doc['content']) >= 9 ? substr($doc['content'], 0, 9) : $doc['content']);
$derivedURL = self::deriveFeedFromHTML($doc['content']); return $start === '<!doctype' || str_starts_with($start, '<html')
if (key_exists('error', $derivedURL)) return ['error' => $derivedURL['error']]; ? self::deriveFeedFromHTML($doc['content'])
$feedURL = $derivedURL['ok']; ->bind(function (string $feedURL) use ($url) {
if (!str_starts_with($feedURL, 'http')) { if (!str_starts_with($feedURL, 'http')) {
// Relative URL; feed should be retrieved in the context of the original URL // Relative URL; feed should be retrieved in the context of the original URL
$original = parse_url($url); $original = parse_url($url);
$port = key_exists('port', $original) ? ":{$original['port']}" : ''; $port = key_exists('port', $original) ? ":{$original['port']}" : '';
$feedURL = $original['scheme'] . '://' . $original['host'] . $port . $feedURL; $feedURL = $original['scheme'] . '://' . $original['host'] . $port . $feedURL;
} }
$doc = self::retrieveDocument($feedURL); return self::retrieveDocument($feedURL);
} })
->bind(fn($doc) => match ($doc['code']) {
$parsed = self::parseFeed($doc['content']); 200 => Result::OK($doc),
if (key_exists('error', $parsed)) return ['error' => $parsed['error']]; default => Result::Error(
"Derived feed URL {$doc['url']} returned HTTP Code {$doc['code']}: {$doc['content']}"),
$extract = $parsed['ok']->getElementsByTagNameNS(self::ATOM_NS, 'feed')->length > 0 })
? self::fromAtom(...) : self::fromRSS(...); : Result::OK($doc);
return $extract($parsed['ok'], $doc['url']); });
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,4 +1,10 @@
<?php declare(strict_types=1); <?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
namespace FeedReaderCentral; namespace FeedReaderCentral;
@ -7,7 +13,7 @@ use DOMNode;
/** /**
* Information for a feed item * Information for a feed item
*/ */
class ParsedItem readonly class ParsedItem
{ {
/** /**
* Constructor * Constructor
@ -27,24 +33,24 @@ class ParsedItem
* Construct a feed item from an Atom feed's `<entry>` tag * 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 * @param DOMNode $node The XML node from which a feed item should be constructed
* @return static A feed item constructed from the given node * @return ParsedItem A feed item constructed from the given node
*/ */
public static function fromAtom(DOMNode $node): static public static function fromAtom(DOMNode $node): self
{ {
$guid = ParsedFeed::atomValue($node, 'id'); $guid = ParsedFeed::atomValue($node, 'id');
$link = ''; $link = '';
foreach ($node->getElementsByTagName('link') as $linkElt) { foreach ($node->getElementsByTagName('link') as $linkElt) {
if ($linkElt->hasAttributes()) { if ($linkElt->hasAttributes()) {
$relAttr = $linkElt->attributes->getNamedItem('rel'); $relAttr = $linkElt->attributes->getNamedItem('rel');
if ($relAttr && $relAttr->value == 'alternate') { if ($relAttr && $relAttr->value === 'alternate') {
$link = $linkElt->attributes->getNamedItem('href')->value; $link = $linkElt->attributes->getNamedItem('href')->value;
break; break;
} }
} }
} }
if ($link == '' && str_starts_with($guid, 'http')) $link = $guid; if ($link === '' && str_starts_with($guid, 'http')) $link = $guid;
return new static( return new self(
guid: $guid, guid: $guid,
title: ParsedFeed::atomValue($node, 'title'), title: ParsedFeed::atomValue($node, 'title'),
link: $link, link: $link,
@ -57,15 +63,15 @@ class ParsedItem
* Construct a feed item from an RSS feed's `<item>` tag * 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 * @param DOMNode $node The XML node from which a feed item should be constructed
* @return static A feed item constructed from the given node * @return ParsedItem A feed item constructed from the given node
*/ */
public static function fromRSS(DOMNode $node): static public static function fromRSS(DOMNode $node): self
{ {
$itemGuid = ParsedFeed::rssValue($node, 'guid'); $itemGuid = ParsedFeed::rssValue($node, 'guid');
$updNodes = $node->getElementsByTagNameNS(ParsedFeed::ATOM_NS, 'updated'); $updNodes = $node->getElementsByTagNameNS(ParsedFeed::ATOM_NS, 'updated');
$encNodes = $node->getElementsByTagNameNS(ParsedFeed::CONTENT_NS, 'encoded'); $encNodes = $node->getElementsByTagNameNS(ParsedFeed::CONTENT_NS, 'encoded');
return new static( return new self(
guid: $itemGuid == 'guid not found' ? ParsedFeed::rssValue($node, 'link') : $itemGuid, guid: $itemGuid == 'guid not found' ? ParsedFeed::rssValue($node, 'link') : $itemGuid,
title: ParsedFeed::rssValue($node, 'title'), title: ParsedFeed::rssValue($node, 'title'),
link: ParsedFeed::rssValue($node, 'link'), link: ParsedFeed::rssValue($node, 'link'),

View File

@ -1,49 +1,77 @@
<?php declare(strict_types=1); <?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
namespace FeedReaderCentral; namespace FeedReaderCentral;
use BitBadger\InspiredByFSharp\Option;
use BitBadger\PDODocument\{DocumentException, Field, Patch}; use BitBadger\PDODocument\{DocumentException, Field, Patch};
/** /**
* Security functions * Security functions
*/ */
class Security readonly class Security
{ {
/** @var int Run as a single user requiring no password */ /** @var int Run as a single user requiring no password */
public const SINGLE_USER = 0; public const SingleUserMode = 0;
/** @var int Run as a single user requiring a password */ /** @var int Run as a single user requiring a password */
public const SINGLE_USER_WITH_PASSWORD = 1; public const SingleUserPasswordMode = 1;
/** @var int Require users to provide e-mail address and password */ /** @var int Require users to provide e-mail address and password */
public const 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 MULTI_USER = 2;
/** @var string The e-mail address for the single user */ /** @var string The e-mail address for the single user */
public const SINGLE_USER_EMAIL = 'solouser@example.com'; public const SingleUserEmail = 'solouser@example.com';
/** @var string The password for the single user with no password */ /** @var string The password for the single user with no password */
public const SINGLE_USER_PASSWORD = 'no-password-required'; public const SingleUserPassword = 'no-password-required';
/** @var string The password algorithm to use for our passwords */ /** @var string The password algorithm to use for our passwords */
public const PW_ALGORITHM = PASSWORD_DEFAULT; public const PasswordAlgorithm = PASSWORD_DEFAULT;
/** Prevent instances of this class */
private function __construct() {}
/** /**
* Verify a user's password * Verify a user's password
* *
* @param User $user The user information retrieved from the database * @param User $user The user information retrieved from the database
* @param string $password The password provided by the user * @param string $password The password provided by the user
* @param string|null $returnTo The URL to which the user should be redirected * @param Option<string> $returnTo The URL to which the user should be redirected
* @throws DocumentException if any is encountered * @throws DocumentException if any is encountered
*/ */
private static function verifyPassword(User $user, string $password, ?string $returnTo): void private static function verifyPassword(User $user, string $password, Option $returnTo): void
{ {
if (password_verify($password, $user->password)) { if (password_verify($password, $user->password)) {
if (password_needs_rehash($user->password, self::PW_ALGORITHM)) { if (password_needs_rehash($user->password, self::PasswordAlgorithm)) {
Patch::byId(Table::USER, $user->id, ['password' => password_hash($password, self::PW_ALGORITHM)]); Patch::byId(Table::User, $user->id, ['password' => password_hash($password, self::PasswordAlgorithm)]);
} }
$_SESSION[Key::USER_ID] = $user->id; $_SESSION[Key::UserId] = $user->id;
$_SESSION[Key::USER_EMAIL] = $user->email; $_SESSION[Key::UserEmail] = $user->email;
frc_redirect($returnTo ?? '/'); frc_redirect($returnTo->getOrDefault('/'));
} }
} }
@ -52,21 +80,21 @@ class Security
* *
* @param string $email The e-mail address for the user (cannot be the single-user mode user) * @param string $email The e-mail address for the user (cannot be the single-user mode user)
* @param string $password The password provided by the user * @param string $password The password provided by the user
* @param string|null $returnTo The URL to which the user should be redirected * @param Option<string> $returnTo The URL to which the user should be redirected
* @throws DocumentException If any is encountered * @throws DocumentException If any is encountered
*/ */
public static function logOnUser(string $email, string $password, ?string $returnTo): void { public static function logOnUser(string $email, string $password, Option $returnTo): void
if (SECURITY_MODEL == self::SINGLE_USER_WITH_PASSWORD) { {
$dbEmail = self::SINGLE_USER_EMAIL; if (SECURITY_MODEL === self::SingleUserPasswordMode) {
$dbEmail = self::SingleUserEmail;
} else { } else {
if ($email == self::SINGLE_USER_EMAIL) { if ($email === self::SingleUserEmail) {
add_error('Invalid credentials; log on unsuccessful'); add_error('Invalid credentials; log on unsuccessful');
return; return;
} }
$dbEmail = $email; $dbEmail = $email;
} }
$user = User::findByEmail($dbEmail); User::findByEmail($dbEmail)->iter(fn(User $it) => self::verifyPassword($it, $password, $returnTo));
if ($user) self::verifyPassword($user, $password, $returnTo);
add_error('Invalid credentials; log on unsuccessful'); add_error('Invalid credentials; log on unsuccessful');
} }
@ -77,9 +105,10 @@ class Security
* @param string $password The new password for this user * @param string $password The new password for this user
* @throws DocumentException If any is encountered * @throws DocumentException If any is encountered
*/ */
public static function updatePassword(string $email, string $password): void { public static function updatePassword(string $email, string $password): void
Patch::byFields(Table::USER, [Field::EQ('email', $email)], {
['password' => password_hash($password, self::PW_ALGORITHM)]); Patch::byFields(Table::User, [Field::EQ('email', $email)],
['password' => password_hash($password, self::PasswordAlgorithm)]);
} }
/** /**
@ -87,13 +116,13 @@ class Security
* *
* @throws DocumentException If any is encountered * @throws DocumentException If any is encountered
*/ */
private static function logOnSingleUser(): void { private static function logOnSingleUser(): void
$user = User::findByEmail(self::SINGLE_USER_EMAIL); {
if (!$user) { $user = User::findByEmail(self::SingleUserEmail)->getOrCall(function () {
User::add(self::SINGLE_USER_EMAIL, self::SINGLE_USER_PASSWORD); User::add(self::SingleUserEmail, self::SingleUserPassword);
$user = User::findByEmail(self::SINGLE_USER_EMAIL); return User::findByEmail(self::SingleUserEmail)->get();
} });
self::verifyPassword($user, self::SINGLE_USER_PASSWORD, $_GET['returnTo']); self::verifyPassword($user, self::SingleUserPassword, $_GET['returnTo']);
} }
/** /**
@ -102,12 +131,13 @@ class Security
* @param bool $redirectIfAnonymous Whether to redirect the request if there is no user logged on * @param bool $redirectIfAnonymous Whether to redirect the request if there is no user logged on
* @throws DocumentException If any is encountered * @throws DocumentException If any is encountered
*/ */
public static function verifyUser(bool $redirectIfAnonymous = true): void { public static function verifyUser(bool $redirectIfAnonymous = true): void
if (key_exists(Key::USER_ID, $_SESSION)) return; {
if (key_exists(Key::UserId, $_SESSION)) return;
if (SECURITY_MODEL == self::SINGLE_USER) self::logOnSingleUser(); if (SECURITY_MODEL === self::SingleUserMode) self::logOnSingleUser();
if (SECURITY_MODEL != self::SINGLE_USER_WITH_PASSWORD && SECURITY_MODEL != self::MULTI_USER) { if (SECURITY_MODEL !== self::SingleUserPasswordMode && SECURITY_MODEL != self::MultiUserMode) {
die('Unrecognized security model (' . SECURITY_MODEL . ')'); die('Unrecognized security model (' . SECURITY_MODEL . ')');
} }

View File

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

View File

@ -1,7 +1,14 @@
<?php declare(strict_types=1); <?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
namespace FeedReaderCentral; namespace FeedReaderCentral;
use BitBadger\InspiredByFSharp\Option;
use BitBadger\PDODocument\{Custom, Document, DocumentException, Field, Find, Parameters, Query}; use BitBadger\PDODocument\{Custom, Document, DocumentException, Field, Find, Parameters, Query};
use BitBadger\PDODocument\Mapper\ExistsMapper; use BitBadger\PDODocument\Mapper\ExistsMapper;
@ -23,12 +30,12 @@ class User
* Find a user by their e=mail address * Find a user by their e=mail address
* *
* @param string $email The e-mail address of the user to retrieve * @param string $email The e-mail address of the user to retrieve
* @return User|false The user information, or null if the user is not found * @return Option<User> A `Some` value with the user information if found, `None` otherwise
* @throws DocumentException If any is encountered * @throws DocumentException If any is encountered
*/ */
public static function findByEmail(string $email): User|false public static function findByEmail(string $email): Option
{ {
return Find::firstByFields(Table::USER, [Field::EQ('email', $email)], User::class); return Find::firstByFields(Table::User, [Field::EQ('email', $email)], User::class);
} }
/** /**
@ -40,7 +47,7 @@ class User
*/ */
public static function add(string $email, string $password): void public static function add(string $email, string $password): void
{ {
Document::insert(Table::USER, new User(email: $email, password: $password)); Document::insert(Table::User, new User(email: $email, password: $password));
} }
/** /**
@ -51,7 +58,7 @@ class User
*/ */
public static function hasBookmarks(): bool public static function hasBookmarks(): bool
{ {
$fields = [Data::userIdField(Table::FEED), Data::bookmarkField(true, Table::ITEM)]; $fields = [Data::userIdField(Table::Feed), Data::bookmarkField(true, Table::Item)];
return Custom::scalar(Query\Exists::query(ItemWithFeed::FROM_WITH_JOIN, Query::whereByFields($fields)), return Custom::scalar(Query\Exists::query(ItemWithFeed::FROM_WITH_JOIN, Query::whereByFields($fields)),
Parameters::addFields($fields, []), new ExistsMapper()); Parameters::addFields($fields, []), new ExistsMapper());
} }

View File

@ -1,11 +1,16 @@
<?php declare(strict_types=1); <?php
/** /**
* Bookmark Partial Handler * Bookmark Partial Handler
* *
* This will display a button which will either add or remove a bookmark for a given item. * 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 BitBadger\PDODocument\{DocumentException, Patch};
use FeedReaderCentral\{ItemWithFeed, Table}; use FeedReaderCentral\{ItemWithFeed, Table};
@ -13,24 +18,22 @@ include '../start.php';
FeedReaderCentral\Security::verifyUser(); FeedReaderCentral\Security::verifyUser();
$id = key_exists('id', $_GET) ? (int)$_GET['id'] : -1; $id = key_exists('id', $_GET) ? (int)$_GET['id'] : -1;
$item = ItemWithFeed::retrieveById($id)->getOrCall(not_found(...));
if (!$item = ItemWithFeed::retrieveById($id)) not_found();
if (key_exists('action', $_GET)) { if (key_exists('action', $_GET)) {
$flag = match ($_GET['action']) { (match ($_GET['action']) {
'add' => 1, 'add' => Option::Some(1),
'remove' => 0, 'remove' => Option::Some(0),
default => null default => Option::None(),
}; })->iter(function (int $flag) use ($id, &$item) {
if (isset($flag)) {
try { try {
Patch::byId(Table::ITEM, $id, ['is_bookmarked' => $flag]); Patch::byId(Table::Item, $id, ['is_bookmarked' => $flag]);
$item->is_bookmarked = $flag; $item->is_bookmarked = $flag;
} catch (DocumentException $ex) { } catch (DocumentException $ex) {
add_error("$ex"); add_error("$ex");
} }
} });
} }
$action = $item->isBookmarked() ? 'remove' : 'add'; $action = $item->isBookmarked() ? 'remove' : 'add';

View File

@ -1,4 +1,10 @@
<?php declare(strict_types=1); <?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
include '../../start.php'; include '../../start.php';

View File

@ -1,4 +1,10 @@
<?php declare(strict_types=1); <?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
include '../../start.php'; include '../../start.php';

View File

@ -1,4 +1,10 @@
<?php declare(strict_types=1); <?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
include '../../start.php'; include '../../start.php';

View File

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

View File

@ -1,4 +1,10 @@
<?php declare(strict_types=1); <?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
include '../../start.php'; include '../../start.php';

View File

@ -1,11 +1,16 @@
<?php declare(strict_types=1); <?php
/** /**
* Add/Edit/Delete Feed Page * Add/Edit/Delete Feed Page
* *
* Allows users to add, edit, and delete feeds * 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 BitBadger\PDODocument\{Delete, DocumentException, Field};
use FeedReaderCentral\{Feed, Security, Table}; use FeedReaderCentral\{Feed, Security, Table};
@ -15,39 +20,40 @@ Security::verifyUser();
$feedId = key_exists('id', $_GET) ? (int)$_GET['id'] : -1; $feedId = key_exists('id', $_GET) ? (int)$_GET['id'] : -1;
if ($_SERVER['REQUEST_METHOD'] == 'DELETE') { switch ($_SERVER['REQUEST_METHOD']) {
try { case 'DELETE':
if (!($feed = Feed::retrieveById($feedId))) not_found(); try {
Delete::byFields(Table::ITEM, [Field::EQ('feed_id', $feed->id)]); $feed = Feed::retrieveById($feedId)->getOrCall(not_found(...));
Delete::byId(Table::FEED, $feed->id); Delete::byFields(Table::Item, [Field::EQ('feed_id', $feed->id)]);
add_info('Feed &ldquo;' . htmlentities($feed->title) . '&rdquo; deleted successfully'); Delete::byId(Table::Feed, $feed->id);
frc_redirect('/feeds'); add_info('Feed &ldquo;' . htmlentities($feed->title) . '&rdquo; deleted successfully');
} catch (DocumentException $ex) {
add_error("$ex");
}
}
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
try {
$isNew = $_POST['id'] == '-1';
if ($isNew) {
$result = Feed::add($_POST['url']);
} else {
$feedId = (int)$_POST['id'];
$toEdit = Feed::retrieveById($feedId);
$result = $toEdit
? Feed::update($toEdit, $_POST['url'])
: ['error' => "Feed $feedId not found"];
}
if (key_exists('ok', $result)) {
add_info('Feed saved successfully');
frc_redirect('/feeds'); frc_redirect('/feeds');
} catch (DocumentException $ex) {
add_error("$ex");
} }
add_error($result['error']); break;
$feedId = 'error';
} catch (DocumentException $ex) { case 'POST':
add_error("$ex"); 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) { if ($feedId == -1) {
@ -55,9 +61,9 @@ if ($feedId == -1) {
$feed = new Feed(id: -1); $feed = new Feed(id: -1);
} else { } else {
$title = 'Edit RSS Feed'; $title = 'Edit RSS Feed';
if ($feedId == 'error') { $feed = $feedId == 'error'
$feed = new Feed(id: (int)$_POST['id'], url: $_POST['url'] ?? ''); ? new Feed(id: (int)$_POST['id'], url: $_POST['url'] ?? '')
} elseif (!($feed = Feed::retrieveById((int)$feedId))) not_found(); : Feed::retrieveById((int)$feedId)->getOrCall(not_found(...));
} }
page_head($title); ?> page_head($title); ?>

View File

@ -1,19 +1,23 @@
<?php declare(strict_types=1); <?php
/** /**
* Feed Item List Page * Feed Item List Page
* *
* Lists items in a given feed (all, unread, or bookmarked) * 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}; use FeedReaderCentral\{Feed, ItemList};
include '../../start.php'; include '../../start.php';
FeedReaderCentral\Security::verifyUser(); FeedReaderCentral\Security::verifyUser();
$id = key_exists('id', $_GET) ? (int)$_GET['id'] : -1; $id = key_exists('id', $_GET) ? (int)$_GET['id'] : -1;
if (!($feed = Feed::retrieveById($id))) not_found(); $feed = Feed::retrieveById($id)->getOrCall(not_found(...));
$list = match (true) { $list = match (true) {
key_exists('unread', $_GET) => ItemList::unreadForFeed($feed->id), key_exists('unread', $_GET) => ItemList::unreadForFeed($feed->id),
@ -21,8 +25,8 @@ $list = match (true) {
default => ItemList::allForFeed($feed->id) default => ItemList::allForFeed($feed->id)
}; };
page_head(($list->itemType != '' ? "$list->itemType Items | " : '') . strip_tags($feed->title)); page_head(($list->itemType === '' ? '' : "$list->itemType Items | ") . strip_tags($feed->title));
if ($list->itemType == '') { if ($list->itemType === '') {
echo '<h1>' . htmlentities($feed->title) . '</h1>'; echo '<h1>' . htmlentities($feed->title) . '</h1>';
} else { } else {
echo '<h1 class=item_heading>' . htmlentities($feed->title) . '</h1>'; echo '<h1 class=item_heading>' . htmlentities($feed->title) . '</h1>';

View File

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

View File

@ -1,11 +1,15 @@
<?php declare(strict_types=1); <?php
/** /**
* Home Page * Home Page
* *
* Displays a list of unread or bookmarked items for the current user * Displays a list of unread or bookmarked items for the current user
*
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/ */
declare(strict_types=1);
use FeedReaderCentral\{Feed, ItemList}; use FeedReaderCentral\{Feed, ItemList};
include '../start.php'; include '../start.php';
@ -14,16 +18,16 @@ FeedReaderCentral\Security::verifyUser();
if (key_exists('refresh', $_GET)) { if (key_exists('refresh', $_GET)) {
$refreshResult = Feed::refreshAll(); $refreshResult = Feed::refreshAll();
if (key_exists('ok', $refreshResult)) { if ($refreshResult->isOK()) {
add_info('All feeds refreshed successfully'); add_info('All feeds refreshed successfully');
} else { } else {
add_error(nl2br($refreshResult['error'])); add_error(nl2br($refreshResult->getError()));
} }
} }
$list = match (true) { $list = match (true) {
key_exists('bookmarked', $_GET) => ItemList::allBookmarked(), key_exists('bookmarked', $_GET) => ItemList::allBookmarked(),
default => ItemList::allUnread() default => ItemList::allUnread(),
}; };
$title = "Your $list->itemType Items"; $title = "Your $list->itemType Items";

View File

@ -1,11 +1,15 @@
<?php declare(strict_types=1); <?php
/** /**
* Item View Page * Item View Page
* *
* Retrieves and displays an item from a feed belonging to the current user * Retrieves and displays an item from a feed belonging to the current user
*
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/ */
declare(strict_types=1);
use BitBadger\PDODocument\{Delete, DocumentException, Patch}; use BitBadger\PDODocument\{Delete, DocumentException, Patch};
use FeedReaderCentral\{ItemWithFeed, Table}; use FeedReaderCentral\{ItemWithFeed, Table};
@ -16,35 +20,38 @@ FeedReaderCentral\Security::verifyUser();
$id = match (true) { $id = match (true) {
key_exists('id', $_POST) => (int)$_POST['id'], key_exists('id', $_POST) => (int)$_POST['id'],
key_exists('id', $_GET) => (int)$_GET['id'], key_exists('id', $_GET) => (int)$_GET['id'],
default => -1 default => -1,
};
$from = match ($_SERVER['REQUEST_METHOD']) {
'POST' => $_POST['from'],
default => $_GET['from'] ?? '/',
}; };
if ($_SERVER['REQUEST_METHOD'] == 'POST') { switch ($_SERVER['REQUEST_METHOD']) {
try { case 'POST':
// "Keep as New" button sends a POST request to reset the is_read flag before going back to the item list try {
if (ItemWithFeed::existsById($id)) { // "Keep as New" button sends a POST request to reset the is_read flag before going back to the item list
Patch::byId(Table::ITEM, $id, ['is_read' => 0]); if (ItemWithFeed::existsById($id)) {
Patch::byId(Table::Item, $id, ['is_read' => 0]);
}
frc_redirect($from);
} catch (DocumentException $ex) {
add_error("$ex");
} }
frc_redirect($_POST['from']); break;
} catch (DocumentException $ex) {
add_error("$ex"); case 'DELETE':
} try {
if (ItemWithFeed::existsById($id)) Delete::byId(Table::Item, $id);
} catch (DocumentException $ex) {
add_error("$ex");
}
frc_redirect($from);
} }
$from = $_GET['from'] ?? '/'; $item = ItemWithFeed::retrieveById($id)->getOrCall(not_found(...));
if ($_SERVER['REQUEST_METHOD'] == 'DELETE') {
try {
if (ItemWithFeed::existsById($id)) Delete::byId(Table::ITEM, $id);
} catch (DocumentException $ex) {
add_error("$ex");
}
frc_redirect($from);
}
if (!$item = ItemWithFeed::retrieveById($id)) not_found();
try { try {
Patch::byId(Table::ITEM, $id, ['is_read' => 1]); Patch::byId(Table::Item, $id, ['is_read' => 1]);
} catch (DocumentException $ex) { } catch (DocumentException $ex) {
add_error("$ex"); add_error("$ex");
} }

View File

@ -1,11 +1,15 @@
<?php declare(strict_types=1); <?php
/** /**
* Item Search Page * Item Search Page
* *
* Search for items across all feeds * Search for items across all feeds
*
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/ */
declare(strict_types=1);
include '../start.php'; include '../start.php';
FeedReaderCentral\Security::verifyUser(); FeedReaderCentral\Security::verifyUser();
@ -13,8 +17,8 @@ FeedReaderCentral\Security::verifyUser();
$search = $_GET['search'] ?? ''; $search = $_GET['search'] ?? '';
$items = $_GET['items'] ?? 'all'; $items = $_GET['items'] ?? 'all';
if ($search != '') { if ($search !== '') {
$list = FeedReaderCentral\ItemList::matchingSearch($search, $items == 'bookmarked'); $list = FeedReaderCentral\ItemList::matchingSearch($search, $items === 'bookmarked');
} }
page_head('Item Search'); ?> page_head('Item Search'); ?>
@ -28,8 +32,8 @@ page_head('Item Search'); ?>
<label> <label>
Items to Search Items to Search
<select name=items> <select name=items>
<option value=all <?=$items == 'all' ? ' selected' : ''?>>All</option> <option value=all <?=$items === 'all' ? ' selected' : ''?>>All</option>
<option value=bookmarked <?=$items == 'bookmarked' ? ' selected' : ''?>>Bookmarked</option> <option value=bookmarked <?=$items === 'bookmarked' ? ' selected' : ''?>>Bookmarked</option>
</select> </select>
</label> </label>
<span class=break></span> <span class=break></span>

View File

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

View File

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

View File

@ -1,4 +1,14 @@
<?php declare(strict_types=1); <?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 BitBadger\PDODocument\Configuration;
use FeedReaderCentral\{Key, Security, User}; use FeedReaderCentral\{Key, Security, User};
@ -19,8 +29,8 @@ session_start([
*/ */
function add_message(string $level, string $message): void function add_message(string $level, string $message): void
{ {
if (!key_exists(Key::USER_MSG, $_SESSION)) $_SESSION[Key::USER_MSG] = array(); if (!key_exists(Key::UserMsg, $_SESSION)) $_SESSION[Key::UserMsg] = [];
$_SESSION[Key::USER_MSG][] = ['level' => $level, 'message' => $message]; $_SESSION[Key::UserMsg][] = ['level' => $level, 'message' => $message];
} }
/** /**
@ -43,7 +53,7 @@ function add_info(string $message): void
add_message('INFO', $message); add_message('INFO', $message);
} }
/** @var bool $is_htmx True if this request was initiated by htmx, false if not */ /** True if this request was initiated by htmx, false if not */
$is_htmx = key_exists('HTTP_HX_REQUEST', $_SERVER) && !key_exists('HTTP_HX_HISTORY_RESTORE_REQUEST', $_SERVER); $is_htmx = key_exists('HTTP_HX_REQUEST', $_SERVER) && !key_exists('HTTP_HX_HISTORY_RESTORE_REQUEST', $_SERVER);
/** /**
@ -67,14 +77,14 @@ function title_bar(): void
echo "<header hx-target=#main hx-push-url=true>" echo "<header hx-target=#main hx-push-url=true>"
. "<div><a href=/ class=title>Feed Reader Central</a><span class=version>$version</span></div>" . "<div><a href=/ class=title>Feed Reader Central</a><span class=version>$version</span></div>"
. "<nav>"; . "<nav>";
if (key_exists(Key::USER_ID, $_SESSION)) { if (key_exists(Key::UserId, $_SESSION)) {
nav_link(hx_get('/feeds', 'Feeds'), true); nav_link(hx_get('/feeds', 'Feeds'), true);
if (User::hasBookmarks()) nav_link(hx_get('/?bookmarked', 'Bookmarked')); if (User::hasBookmarks()) nav_link(hx_get('/?bookmarked', 'Bookmarked'));
nav_link(hx_get('/search', 'Search')); nav_link(hx_get('/search', 'Search'));
nav_link(hx_get('/docs/', 'Docs')); nav_link(hx_get('/docs/', 'Docs'));
nav_link('<a href=/user/log-off>Log Off</a>'); nav_link('<a href=/user/log-off>Log Off</a>');
if ($_SESSION[Key::USER_EMAIL] != Security::SINGLE_USER_EMAIL) { if ($_SESSION[Key::UserEmail] !== Security::SingleUserEmail) {
nav_link($_SESSION[Key::USER_EMAIL]); nav_link($_SESSION[Key::UserEmail]);
} }
} else { } else {
nav_link(hx_get('/user/log-on', 'Log On'), true); nav_link(hx_get('/user/log-on', 'Log On'), true);
@ -100,15 +110,15 @@ function page_head(string $title): void
} }
echo '</head><body>'; echo '</head><body>';
if (!$is_htmx) title_bar(); if (!$is_htmx) title_bar();
if (sizeof($messages = $_SESSION[Key::USER_MSG] ?? []) > 0) { if (sizeof($messages = $_SESSION[Key::UserMsg] ?? []) > 0) {
echo '<div class=user_messages>'; echo '<div class=user_messages>';
array_walk($messages, function ($msg) { array_walk($messages, function ($msg) {
echo '<div class=user_message>' echo '<div class=user_message>'
. ($msg['level'] == 'INFO' ? '' : "<strong>{$msg['level']}</strong><br>") . ($msg['level'] === 'INFO' ? '' : "<strong>{$msg['level']}</strong><br>")
. $msg['message'] . '</div>'; . $msg['message'] . '</div>';
}); });
echo '</div>'; echo '</div>';
$_SESSION[Key::USER_MSG] = []; $_SESSION[Key::UserMsg] = [];
} }
} }
@ -164,7 +174,7 @@ function date_time(string $value): string
*/ */
function hx_get(string $url, string $text, string $extraAttrs = ''): string function hx_get(string $url, string $text, string $extraAttrs = ''): string
{ {
$attrs = $extraAttrs != '' ? " $extraAttrs" : ''; $attrs = $extraAttrs !== '' ? " $extraAttrs" : '';
return "<a href=\"$url\" hx-get=\"$url\"$attrs>$text</a>"; return "<a href=\"$url\" hx-get=\"$url\"$attrs>$text</a>";
} }

View File

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

View File

@ -1,4 +1,16 @@
<?php declare(strict_types=1); <?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\{Custom, Document, DocumentException};
use BitBadger\PDODocument\Mapper\{ArrayMapper, ExistsMapper}; use BitBadger\PDODocument\Mapper\{ArrayMapper, ExistsMapper};
@ -8,8 +20,6 @@ require __DIR__ . '/../cli-start.php';
cli_title('DATABASE UPDATE'); cli_title('DATABASE UPDATE');
//const PDO_DOC_DEBUG_SQL = true;
if ($argc < 2) display_help(); if ($argc < 2) display_help();
switch ($argv[1]) { switch ($argv[1]) {
@ -41,7 +51,8 @@ function display_help(): never
function json_column_exists(): bool function json_column_exists(): bool
{ {
try { try {
$table = Custom::single("SELECT sql FROM sqlite_master WHERE tbl_name='frc_user'", [], new ArrayMapper()); $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; return $table && substr_compare(strtolower($table['sql']), 'data text not null', 0) >= 0;
} catch (DocumentException $ex) { } catch (DocumentException $ex) {
printfn("ERR $ex"); printfn("ERR $ex");
@ -80,13 +91,13 @@ function run_update(): void
$users = Custom::list('SELECT * FROM old_user', [], new ArrayMapper()); $users = Custom::list('SELECT * FROM old_user', [], new ArrayMapper());
if (!$users->hasItems()) throw new DocumentException('Could not retrieve users'); if (!$users->hasItems()) throw new DocumentException('Could not retrieve users');
foreach ($users->items() as $user) { foreach ($users->items() as $user) {
Document::insert(Table::USER, new User($user['id'], $user['email'], $user['password'])); Document::insert(Table::User, new User($user['id'], $user['email'], $user['password']));
} }
printfn('Migrating feeds...'); printfn('Migrating feeds...');
$feeds = Custom::list('SELECT * FROM old_feed', [], new ArrayMapper()); $feeds = Custom::list('SELECT * FROM old_feed', [], new ArrayMapper());
if (!$feeds->hasItems()) throw new DocumentException('Could not retrieve feeds'); if (!$feeds->hasItems()) throw new DocumentException('Could not retrieve feeds');
foreach ($feeds->items() as $feed) { foreach ($feeds->items() as $feed) {
Document::insert(Table::FEED, Document::insert(Table::Feed,
new Feed($feed['id'], $feed['user_id'], $feed['url'], $feed['title'], $feed['updated_on'], new Feed($feed['id'], $feed['user_id'], $feed['url'], $feed['title'], $feed['updated_on'],
$feed['checked_on'])); $feed['checked_on']));
} }
@ -94,7 +105,7 @@ function run_update(): void
$items = Custom::list('SELECT * FROM old_item', [], new ArrayMapper()); $items = Custom::list('SELECT * FROM old_item', [], new ArrayMapper());
if (!$items->hasItems()) throw new DocumentException('Could not retrieve items'); if (!$items->hasItems()) throw new DocumentException('Could not retrieve items');
foreach ($items->items() as $item) { foreach ($items->items() as $item) {
Document::insert(Table::ITEM, Document::insert(Table::Item,
new Item($item['id'], $item['feed_id'], $item['title'], $item['item_guid'], $item['item_link'], 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['published_on'], $item['updated_on'], $item['content'], $item['is_read'],
$item['is_bookmarked'])); $item['is_bookmarked']));

View File

@ -1,5 +1,16 @@
<?php declare(strict_types=1); <?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 BitBadger\PDODocument\{DocumentException, Find};
use FeedReaderCentral\{Feed, Table, User}; use FeedReaderCentral\{Feed, Table, User};
@ -34,17 +45,20 @@ function refresh_all(): void
{ {
try { try {
$users = []; $users = [];
foreach (Feed::retrieveAll()->items() as /** @var Feed $feed */ $feed) { Feed::retrieveAll()->iter(function (Feed $feed) use (&$users) {
$result = Feed::refreshFeed($feed->id, $feed->url); $result = Feed::refreshFeed($feed->id, $feed->url);
$userKey = "$feed->user_id"; $userKey = "$feed->user_id";
if (!key_exists($userKey, $users)) $users[$userKey] = Find::byId(Table::USER, $feed->user_id, User::class); if (!key_exists($userKey, $users)) {
if (array_key_exists('error', $result)) { $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('ERR (%s) %s', $users[$userKey]->email, $feed->url);
printfn(' %s', $result['error']); printfn(' %s', $result->getError());
} else { } else {
printfn('OK (%s) %s', $users[$userKey]->email, $feed->url); printfn('OK (%s) %s', $users[$userKey]->email, $feed->url);
} }
} });
printfn(PHP_EOL . 'All feeds refreshed'); printfn(PHP_EOL . 'All feeds refreshed');
} catch (DocumentException $ex) { } catch (DocumentException $ex) {
printfn("ERR $ex"); printfn("ERR $ex");

View File

@ -1,4 +1,14 @@
<?php declare(strict_types=1); <?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\{Custom, DocumentException};
use BitBadger\PDODocument\Mapper\ExistsMapper; use BitBadger\PDODocument\Mapper\ExistsMapper;

View File

@ -1,5 +1,17 @@
<?php declare(strict_types=1); <?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 BitBadger\PDODocument\{Count, Custom, Delete, DocumentException, Field, Parameters, Patch, Query};
use FeedReaderCentral\{Security, Table, User}; use FeedReaderCentral\{Security, Table, User};
@ -36,10 +48,10 @@ switch ($argv[1]) {
printfn('Missing parameters: set-single-password requires a new password'); printfn('Missing parameters: set-single-password requires a new password');
exit(-1); exit(-1);
} }
set_password(Security::SINGLE_USER_EMAIL, $argv[2]); set_password(Security::SingleUserEmail, $argv[2]);
break; break;
case 'reset-single-password': case 'reset-single-password':
set_password(Security::SINGLE_USER_EMAIL, Security::SINGLE_USER_PASSWORD); set_password(Security::SingleUserEmail, Security::SingleUserPassword);
break; break;
case 'migrate-single-user': case 'migrate-single-user':
if ($argc < 4) { if ($argc < 4) {
@ -49,7 +61,7 @@ switch ($argv[1]) {
migrate_single_user(); migrate_single_user();
break; break;
case 'remove-single-user': case 'remove-single-user':
delete_user(Security::SINGLE_USER_EMAIL); delete_user(Security::SingleUserEmail);
break; break;
default: default:
printfn('Unrecognized option "%s"', $argv[1]); printfn('Unrecognized option "%s"', $argv[1]);
@ -92,7 +104,7 @@ function add_user(): void
try { try {
// Ensure there is not already a user with this e-mail address // Ensure there is not already a user with this e-mail address
$user = User::findByEmail($argv[2]); $user = User::findByEmail($argv[2]);
if ($user) { if ($user->isSome()) {
printfn('A user with e-mail address "%s" already exists', $argv[2]); printfn('A user with e-mail address "%s" already exists', $argv[2]);
return; return;
} }
@ -113,7 +125,7 @@ function add_user(): void
*/ */
function display_user(string $email): string function display_user(string $email): string
{ {
return $email == Security::SINGLE_USER_EMAIL ? 'single-user mode user' : "user \"$email\""; return $email === Security::SingleUserEmail ? 'single-user mode user' : "user \"$email\"";
} }
/** /**
@ -126,14 +138,14 @@ function set_password(string $email, string $password): void
// Ensure this user exists // Ensure this user exists
$user = User::findByEmail($email); $user = User::findByEmail($email);
if (!$user) { if ($user->isNone()) {
printfn('No %s exists', $displayUser); printfn('No %s exists', $displayUser);
return; return;
} }
Security::updatePassword($email, $password); Security::updatePassword($email, $password);
$msg = $email == Security::SINGLE_USER_EMAIL && $password == Security::SINGLE_USER_PASSWORD $msg = $email === Security::SingleUserEmail && $password === Security::SingleUserPassword
? 'reset' : sprintf('set to "%s"', $password); ? 'reset' : sprintf('set to "%s"', $password);
printfn('%s password %s successfully', init_cap($displayUser), $msg); printfn('%s password %s successfully', init_cap($displayUser), $msg);
} catch (DocumentException $ex) { } catch (DocumentException $ex) {
@ -152,14 +164,15 @@ function delete_user(string $email): void
$displayUser = display_user($email); $displayUser = display_user($email);
// Get the user for the provided e-mail address // Get the user for the provided e-mail address
$user = User::findByEmail($email); $tryUser = User::findByEmail($email);
if (!$user) { if ($tryUser->isNone()) {
printfn('No %s exists', $displayUser); printfn('No %s exists', $displayUser);
return; return;
} }
$user = $tryUser->get();
try { try {
$feedCount = Count::byFields(Table::FEED, [Field::EQ('user_id', $user->id)]); $feedCount = Count::byFields(Table::Feed, [Field::EQ('user_id', $user->id)]);
} catch (DocumentException $ex) { } catch (DocumentException $ex) {
printfn("$ex"); printfn("$ex");
return; return;
@ -172,12 +185,12 @@ function delete_user(string $email): void
} }
try { try {
$fields = [Field::EQ('user_id', $user->id, '@user')]; $fields = [Field::EQ('user_id', $user->id, ':user')];
Custom::nonQuery( Custom::nonQuery(
'DELETE FROM ' . Table::ITEM . " WHERE data->>'feed_id' IN (SELECT data->>'id' FROM " . Table::FEED 'DELETE FROM ' . Table::Item . " WHERE data->>'feed_id' IN (SELECT data->>'id' FROM " . Table::Feed
. ' WHERE ' . Query::whereByFields($fields) . ')', Parameters::addFields($fields, [])); . ' WHERE ' . Query::whereByFields($fields) . ')', Parameters::addFields($fields, []));
Delete::byFields(Table::FEED, $fields); Delete::byFields(Table::Feed, $fields);
Delete::byId(Table::USER, $user->id); Delete::byId(Table::User, $user->id);
printfn('%s deleted successfully', init_cap($displayUser)); printfn('%s deleted successfully', init_cap($displayUser));
} catch (DocumentException $ex) { } catch (DocumentException $ex) {
@ -196,13 +209,14 @@ function migrate_single_user(): void
global $argv; global $argv;
try { try {
if (!$single = User::findByEmail(Security::SINGLE_USER_EMAIL)) { $single = User::findByEmail(Security::SingleUserEmail);
if ($single->isNone()) {
printfn('There is no single-user mode user to be migrated'); printfn('There is no single-user mode user to be migrated');
return; return;
} }
Patch::byId(Table::USER, $single->id, Patch::byId(Table::User, $single->get()->id,
['email' => $argv[2], 'password' => password_hash($argv[3], Security::PW_ALGORITHM)]); ['email' => $argv[2], 'password' => password_hash($argv[3], Security::PasswordAlgorithm)]);
printfn('The single user has been moved to "%s", with password "%s"', $argv[2], $argv[3]); printfn('The single user has been moved to "%s", with password "%s"', $argv[2], $argv[3]);
} catch (DocumentException $ex) { } catch (DocumentException $ex) {