beta4 changes #26

Merged
danieljsummers merged 7 commits from beta5 into main 2024-08-06 23:20:17 +00:00
34 changed files with 658 additions and 383 deletions
Showing only changes of commit 6dc264d34c - Show all commits

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-beta3'; 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-beta3';
*/ */
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';

42
src/composer.lock generated
View File

@ -8,11 +8,11 @@
"packages": [ "packages": [
{ {
"name": "bit-badger/pdo-document", "name": "bit-badger/pdo-document",
"version": "v1.0.0-beta2", "version": "v1.0.0-beta7",
"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": "50854275a8b39074966cf00370f30b3e68edc6e7" "reference": "57d8f9ddc17169883f7dd77e51dea1443040858b"
}, },
"require": { "require": {
"ext-pdo": "*", "ext-pdo": "*",
@ -21,7 +21,8 @@
"phpoption/phpoption": "^1.9" "phpoption/phpoption": "^1.9"
}, },
"require-dev": { "require-dev": {
"phpunit/phpunit": "^11" "phpunit/phpunit": "^11",
"square/pjson": "^0.5.0"
}, },
"type": "library", "type": "library",
"autoload": { "autoload": {
@ -52,32 +53,33 @@
"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-25T14:42:26+00:00" "time": "2024-07-25T00:57:23+00:00"
}, },
{ {
"name": "graham-campbell/result-type", "name": "graham-campbell/result-type",
"version": "v1.1.2", "version": "v1.1.3",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/GrahamCampbell/Result-Type.git", "url": "https://github.com/GrahamCampbell/Result-Type.git",
"reference": "fbd48bce38f73f8a4ec8583362e732e4095e5862" "reference": "3ba905c11371512af9d9bdd27d99b782216b6945"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/fbd48bce38f73f8a4ec8583362e732e4095e5862", "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/3ba905c11371512af9d9bdd27d99b782216b6945",
"reference": "fbd48bce38f73f8a4ec8583362e732e4095e5862", "reference": "3ba905c11371512af9d9bdd27d99b782216b6945",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": "^7.2.5 || ^8.0", "php": "^7.2.5 || ^8.0",
"phpoption/phpoption": "^1.9.2" "phpoption/phpoption": "^1.9.3"
}, },
"require-dev": { "require-dev": {
"phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2" "phpunit/phpunit": "^8.5.39 || ^9.6.20 || ^10.5.28"
}, },
"type": "library", "type": "library",
"autoload": { "autoload": {
@ -106,7 +108,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/GrahamCampbell/Result-Type/issues", "issues": "https://github.com/GrahamCampbell/Result-Type/issues",
"source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.2" "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.3"
}, },
"funding": [ "funding": [
{ {
@ -118,7 +120,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2023-11-12T22:16:48+00:00" "time": "2024-07-20T21:45:45+00:00"
}, },
{ {
"name": "netresearch/jsonmapper", "name": "netresearch/jsonmapper",
@ -173,16 +175,16 @@
}, },
{ {
"name": "phpoption/phpoption", "name": "phpoption/phpoption",
"version": "1.9.2", "version": "1.9.3",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/schmittjoh/php-option.git", "url": "https://github.com/schmittjoh/php-option.git",
"reference": "80735db690fe4fc5c76dfa7f9b770634285fa820" "reference": "e3fac8b24f56113f7cb96af14958c0dd16330f54"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/schmittjoh/php-option/zipball/80735db690fe4fc5c76dfa7f9b770634285fa820", "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/e3fac8b24f56113f7cb96af14958c0dd16330f54",
"reference": "80735db690fe4fc5c76dfa7f9b770634285fa820", "reference": "e3fac8b24f56113f7cb96af14958c0dd16330f54",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -190,13 +192,13 @@
}, },
"require-dev": { "require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2", "bamarni/composer-bin-plugin": "^1.8.2",
"phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2" "phpunit/phpunit": "^8.5.39 || ^9.6.20 || ^10.5.28"
}, },
"type": "library", "type": "library",
"extra": { "extra": {
"bamarni-bin": { "bamarni-bin": {
"bin-links": true, "bin-links": true,
"forward-command": true "forward-command": false
}, },
"branch-alias": { "branch-alias": {
"dev-master": "1.9-dev" "dev-master": "1.9-dev"
@ -232,7 +234,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/schmittjoh/php-option/issues", "issues": "https://github.com/schmittjoh/php-option/issues",
"source": "https://github.com/schmittjoh/php-option/tree/1.9.2" "source": "https://github.com/schmittjoh/php-option/tree/1.9.3"
}, },
"funding": [ "funding": [
{ {
@ -244,7 +246,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2023-11-12T21:59:55+00:00" "time": "2024-07-20T21:41:07+00:00"
} }
], ],
"packages-dev": [], "packages-dev": [],

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,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;
@ -6,9 +12,7 @@ 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
}; };
use DateTimeInterface; use DateTimeInterface;
use GrahamCampbell\ResultType\Error; use GrahamCampbell\ResultType\{Error, Result, Success};
use GrahamCampbell\ResultType\Result;
use GrahamCampbell\ResultType\Success;
use PhpOption\Option; use PhpOption\Option;
/** /**
@ -19,15 +23,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;
/** /**
@ -50,12 +78,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,
@ -75,12 +103,12 @@ class Feed
$results = $results =
array_map(function ($item) use ($feedId) { array_map(function ($item) use ($feedId) {
try { try {
$tryExisting = 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 ($tryExisting->isDefined()) { if ($tryExisting->isDefined()) {
$existing = $tryExisting->get(); $existing = $tryExisting->get();
if ($existing->published_on != $item->publishedOn if ($existing->published_on !== $item->publishedOn
|| ($existing->updated_on != ($item->updatedOn ?? ''))) { || ($existing->updated_on !== ($item->updatedOn ?? ''))) {
Item::update($existing->id, $item); Item::update($existing->id, $item);
} }
} else { } else {
@ -106,31 +134,34 @@ class Feed
*/ */
private static function purgeItems(int $feedId): Result 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::create('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 Error::create('Unrecognized purge type ' . PURGE_TYPE);
} }
try { try {
Custom::nonQuery($sql, Parameters::addFields($fields, [])); Custom::nonQuery($sql, Parameters::addFields($fields, []));
@ -155,7 +186,7 @@ class Feed
$feed = $tryRetrieve->success()->get(); $feed = $tryRetrieve->success()->get();
try { try {
$feedDoc = Find::byId(Table::FEED, $feedId, self::class); $feedDoc = Find::byId(Table::Feed, $feedId, self::class);
if ($feedDoc->isEmpty()) return Error::create('Could not derive date last checked for feed'); if ($feedDoc->isEmpty()) return Error::create('Could not derive date last checked for feed');
$lastChecked = date_create_immutable($feedDoc->get()->checked_on ?? WWW_EPOCH); $lastChecked = date_create_immutable($feedDoc->get()->checked_on ?? WWW_EPOCH);
@ -167,13 +198,13 @@ class Feed
'updated_on' => $feed->updatedOn, 'updated_on' => $feed->updatedOn,
'checked_on' => Data::formatDate('now') 'checked_on' => Data::formatDate('now')
]; ];
if ($url == $feed->url) $patch['url'] = $feed->url; if ($url !== $feed->url) $patch['url'] = $feed->url;
Patch::byId(Table::FEED, $feedId, $patch); Patch::byId(Table::Feed, $feedId, $patch);
} catch (DocumentException $ex) { } catch (DocumentException $ex) {
return Error::create("$ex"); return Error::create("$ex");
} }
return PURGE_TYPE == self::PURGE_NONE ? Success::create(true) : self::purgeItems($feedId); return PURGE_TYPE === self::PurgeNone ? Success::create(true) : self::purgeItems($feedId);
} }
/** /**
@ -189,14 +220,14 @@ class Feed
$feed = $tryRetrieve->success()->get(); $feed = $tryRetrieve->success()->get();
try { try {
$fields = [Field::EQ('user_id', $_SESSION[Key::USER_ID]), Field::EQ('url', $feed->url)]; $fields = [Field::EQ('user_id', $_SESSION[Key::UserId]), Field::EQ('url', $feed->url)];
if (Exists::byFields(Table::FEED, $fields)) { if (Exists::byFields(Table::Feed, $fields)) {
return Error::create("Already subscribed to feed $feed->url"); return Error::create("Already subscribed to feed $feed->url");
} }
Document::insert(Table::FEED, self::fromParsed($feed)); Document::insert(Table::Feed, self::fromParsed($feed));
$tryDoc = Find::firstByFields(Table::FEED, $fields, static::class); $tryDoc = Find::firstByFields(Table::Feed, $fields, self::class);
if ($tryDoc->isEmpty()) return Error::create('Could not retrieve inserted feed'); if ($tryDoc->isEmpty()) return Error::create('Could not retrieve inserted feed');
$doc = $tryDoc->get(); $doc = $tryDoc->get();
@ -217,10 +248,9 @@ class Feed
public static function update(Feed $existing, string $url): Result 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::create("$ex"); return Error::create("$ex");
@ -236,9 +266,9 @@ 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);
} }
/** /**
@ -249,7 +279,7 @@ class Feed
public static function refreshAll(): Result public static function refreshAll(): Result
{ {
try { try {
$feeds = self::retrieveAll($_SESSION[Key::USER_ID]); $feeds = self::retrieveAll($_SESSION[Key::UserId]);
$errors = []; $errors = [];
foreach ($feeds->items() as $feed) { foreach ($feeds->items() as $feed) {
@ -260,7 +290,7 @@ class Feed
return Error::create("$ex"); return Error::create("$ex");
} }
return sizeof($errors) == 0 ? Success::create(true) : Error::create(implode("\n", $errors)); return empty($errors) ? Success::create(true) : Error::create(implode("\n", $errors));
} }
/** /**
@ -272,7 +302,7 @@ class Feed
*/ */
public static function retrieveById(int $feedId): Option public static function retrieveById(int $feedId): Option
{ {
return Find::byId(Table::FEED, $feedId, static::class) return Find::byId(Table::Feed, $feedId, static::class)
->filter(fn ($it) => $it->user_id == $_SESSION[Key::USER_ID]); ->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,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;
@ -25,7 +31,7 @@ class ItemList
*/ */
public function isError(): bool public function isError(): bool
{ {
return $this->error != ''; 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 */
@ -42,17 +48,17 @@ 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]; $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) {
@ -63,11 +69,11 @@ class ItemList
/** /**
* 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 +81,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 +94,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 +107,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 +132,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;
@ -150,25 +156,25 @@ class ItemList
echo "<p>Error retrieving list:<br>$this->error"; echo "<p>Error retrieving list:<br>$this->error";
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,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;
@ -12,12 +18,12 @@ use PhpOption\Option;
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 */
@ -32,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];
} }

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,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;
@ -10,19 +16,21 @@ use GrahamCampbell\ResultType\Error;
use GrahamCampbell\ResultType\Result; use GrahamCampbell\ResultType\Result;
use GrahamCampbell\ResultType\Success; use GrahamCampbell\ResultType\Success;
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';
@ -45,8 +53,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;
@ -58,7 +67,8 @@ class ParsedFeed
* @param string $content The feed's RSS content * @param string $content The feed's RSS content
* @return Result<DOMDocument, string> The feed if successful, an error message if not * @return Result<DOMDocument, string> The feed if successful, an error message if not
*/ */
public static function parseFeed(string $content): Result { 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();
@ -78,9 +88,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;
} }
/** /**
@ -88,9 +99,10 @@ 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 Result<Feed, string> The feed if successful, an 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): Result { 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;
@ -105,13 +117,11 @@ class ParsedFeed
} }
} }
$feed = new static(); return Success::create(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 Success::create($feed);
} }
/** /**
@ -121,10 +131,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
* *
@ -135,14 +146,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;
@ -153,19 +165,18 @@ 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 Result<Feed, string> The feed (does not have any error handling) * @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 Success::create(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 Success::create($feed);
} }
/** /**
@ -175,7 +186,8 @@ class ParsedFeed
* @return Result<array, string> ['content' => doc content, 'code' => HTTP response code, 'url' => effective URL] if * @return Result<array, string> ['content' => doc content, 'code' => HTTP response code, 'url' => effective URL] if
* successful, an error message if not * successful, an error message if not
*/ */
private static function retrieveDocument(string $url): Result { private static function retrieveDocument(string $url): Result
{
$docReq = curl_init($url); $docReq = curl_init($url);
try { try {
curl_setopt($docReq, CURLOPT_FOLLOWLOCATION, true); curl_setopt($docReq, CURLOPT_FOLLOWLOCATION, true);
@ -185,7 +197,7 @@ class ParsedFeed
curl_setopt($docReq, CURLOPT_USERAGENT, self::USER_AGENT); curl_setopt($docReq, CURLOPT_USERAGENT, self::USER_AGENT);
$error = curl_error($docReq); $error = curl_error($docReq);
if ($error <> '') return Error::create($error); if ($error !== '') return Error::create($error);
return Success::create([ return Success::create([
'content' => curl_exec($docReq), 'content' => curl_exec($docReq),
@ -203,16 +215,17 @@ class ParsedFeed
* @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 Result<string, string> The feed URL if successful, an error message if not * @return Result<string, string> The feed URL if successful, an error message if not
*/ */
private static function deriveFeedFromHTML(string $content): Result { 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::create('Cannot find feed at this URL'); if ($headTags->length < 1) return Error::create('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 Success::create(self::attrValue($link, 'href')); return Success::create(self::attrValue($link, 'href'));
} }
} }
@ -226,17 +239,18 @@ class ParsedFeed
* @param string $url The URL of the feed to retrieve * @param string $url The URL of the feed to retrieve
* @return Result<ParsedFeed, string> The feed if successful, an error message if not * @return Result<ParsedFeed, string> The feed if successful, an error message if not
*/ */
public static function retrieve(string $url): Result { public static function retrieve(string $url): Result
{
$tryDoc = self::retrieveDocument($url); $tryDoc = self::retrieveDocument($url);
if ($tryDoc->error()->isDefined()) return $tryDoc->error()->get(); if ($tryDoc->error()->isDefined()) return $tryDoc->error()->get();
$doc = $tryDoc->success()->get(); $doc = $tryDoc->success()->get();
if ($doc['code'] != 200) { if ($doc['code'] !== 200) {
return Error::create("Prospective feed URL $url returned HTTP Code {$doc['code']}: {$doc['content']}"); return Error::create("Prospective feed URL $url returned HTTP Code {$doc['code']}: {$doc['content']}");
} }
$start = strtolower(strlen($doc['content']) >= 9 ? substr($doc['content'], 0, 9) : $doc['content']); $start = strtolower(strlen($doc['content']) >= 9 ? substr($doc['content'], 0, 9) : $doc['content']);
if ($start == '<!doctype' || str_starts_with($start, '<html')) { if ($start === '<!doctype' || str_starts_with($start, '<html')) {
$derivedURL = self::deriveFeedFromHTML($doc['content']); $derivedURL = self::deriveFeedFromHTML($doc['content']);
if ($derivedURL->error()->isDefined()) return $derivedURL->error()->get(); if ($derivedURL->error()->isDefined()) return $derivedURL->error()->get();
$feedURL = $derivedURL->success()->get(); $feedURL = $derivedURL->success()->get();
@ -249,7 +263,7 @@ class ParsedFeed
$tryDoc = self::retrieveDocument($feedURL); $tryDoc = self::retrieveDocument($feedURL);
if ($tryDoc->error()->isDefined()) return $tryDoc->error()->get(); if ($tryDoc->error()->isDefined()) return $tryDoc->error()->get();
$doc = $tryDoc->success()->get(); $doc = $tryDoc->success()->get();
if ($doc['code'] != 200) { if ($doc['code'] !== 200) {
return Error::create("Derived feed URL $url returned HTTP Code {$doc['code']}: {$doc['content']}"); return Error::create("Derived feed URL $url returned HTTP Code {$doc['code']}: {$doc['content']}");
} }
} }

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,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,25 +13,46 @@ 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
@ -38,11 +65,11 @@ class Security
private static function verifyPassword(User $user, string $password, ?string $returnTo): void private static function verifyPassword(User $user, string $password, ?string $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 ?? '/');
} }
} }
@ -55,11 +82,12 @@ class Security
* @param string|null $returnTo The URL to which the user should be redirected * @param string|null $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, ?string $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;
} }
@ -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,14 @@ 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); {
$user = User::findByEmail(self::SingleUserEmail);
if ($user->isEmpty()) { if ($user->isEmpty()) {
User::add(self::SINGLE_USER_EMAIL, self::SINGLE_USER_PASSWORD); User::add(self::SingleUserEmail, self::SingleUserPassword);
$user = User::findByEmail(self::SINGLE_USER_EMAIL); $user = User::findByEmail(self::SingleUserEmail);
} }
self::verifyPassword($user->get(), self::SINGLE_USER_PASSWORD, $_GET['returnTo']); self::verifyPassword($user->get(), self::SingleUserPassword, $_GET['returnTo']);
} }
/** /**
@ -102,12 +132,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,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;
@ -29,7 +35,7 @@ class User
*/ */
public static function findByEmail(string $email): Option 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);
} }
/** /**
@ -41,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));
} }
/** /**
@ -52,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,15 @@
<?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\PDODocument\{DocumentException, Patch}; use BitBadger\PDODocument\{DocumentException, Patch};
use FeedReaderCentral\{ItemWithFeed, Table}; use FeedReaderCentral\{ItemWithFeed, Table};
@ -24,7 +28,7 @@ if (key_exists('action', $_GET)) {
}; };
if (isset($flag)) { 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");

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,15 @@
<?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\PDODocument\{Delete, DocumentException, Field}; use BitBadger\PDODocument\{Delete, DocumentException, Field};
use FeedReaderCentral\{Feed, Security, Table}; use FeedReaderCentral\{Feed, Security, Table};
use GrahamCampbell\ResultType\Error; use GrahamCampbell\ResultType\Error;
@ -16,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':
$feed = Feed::retrieveById($feedId)->getOrCall(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->isDefined()
? Feed::update($toEdit->get(), $_POST['url'])
: Error::create("Feed $feedId not found");
}
if ($result->success()->isDefined()) {
add_info('Feed saved successfully');
frc_redirect('/feeds'); frc_redirect('/feeds');
} catch (DocumentException $ex) {
add_error("$ex");
} }
add_error($result->error()->get()); 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->isDefined()
? Feed::update($toEdit->get(), $_POST['url'])
: Error::create("Feed $feedId not found");
}
if ($result->success()->isDefined()) {
add_info('Feed saved successfully');
frc_redirect('/feeds');
}
add_error($result->error()->get());
$feedId = 'error';
} catch (DocumentException $ex) {
add_error("$ex");
}
break;
} }
if ($feedId == -1) { if ($feedId == -1) {

View File

@ -1,18 +1,22 @@
<?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;
$feed = Feed::retrieveById($id)->getOrCall(not_found(...)); $feed = Feed::retrieveById($id)->getOrCall(not_found(...));
$list = match (true) { $list = match (true) {
@ -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,14 +18,14 @@ 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,
@ -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';

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");
}
}
$from = $_GET['from'] ?? '/'; case 'DELETE':
try {
if ($_SERVER['REQUEST_METHOD'] == 'DELETE') { if (ItemWithFeed::existsById($id)) Delete::byId(Table::Item, $id);
try { } catch (DocumentException $ex) {
if (ItemWithFeed::existsById($id)) Delete::byId(Table::ITEM, $id); add_error("$ex");
} catch (DocumentException $ex) { }
add_error("$ex"); frc_redirect($from);
}
frc_redirect($from);
} }
!$item = ItemWithFeed::retrieveById($id)->getOrCall(not_found(...)); !$item = ItemWithFeed::retrieveById($id)->getOrCall(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,12 +1,16 @@
<?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 FeedReaderCentral\{Key, Security}; use FeedReaderCentral\{Key, Security};
@ -14,21 +18,21 @@ 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'], $_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]) {
@ -81,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']));
} }
@ -95,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,4 +1,14 @@
<?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\PDODocument\{DocumentException, Find}; use BitBadger\PDODocument\{DocumentException, Find};
use FeedReaderCentral\{Feed, Table, User}; use FeedReaderCentral\{Feed, Table, User};
@ -34,11 +44,11 @@ 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)) { if (!key_exists($userKey, $users)) {
$users[$userKey] = Find::byId(Table::USER, $feed->user_id, User::class) $users[$userKey] = Find::byId(Table::User, $feed->user_id, User::class)
->getOrElse(new User(email: 'user-not-found')); ->getOrElse(new User(email: 'user-not-found'));
} }
if ($result->error()->isDefined()) { if ($result->error()->isDefined()) {
@ -47,7 +57,7 @@ function refresh_all(): void
} 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,4 +1,15 @@
<?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\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 +47,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 +60,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]);
@ -113,7 +124,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\"";
} }
/** /**
@ -133,7 +144,7 @@ function set_password(string $email, string $password): void
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) {
@ -160,7 +171,7 @@ function delete_user(string $email): void
$user = $tryUser->get(); $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;
@ -175,10 +186,10 @@ 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) {
@ -197,14 +208,14 @@ function migrate_single_user(): void
global $argv; global $argv;
try { try {
$single = User::findByEmail(Security::SINGLE_USER_EMAIL); $single = User::findByEmail(Security::SingleUserEmail);
if ($single->isEmpty()) { if ($single->isEmpty()) {
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->get()->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) {