17 Commits

Author SHA1 Message Date
93377ffa0e Merge pull request 'Alpha 5' (#20) from add-via-html into main
Reviewed-on: #20
2024-04-30 23:46:36 +00:00
b14399deb8 Add no purge, manual delete options (#12)
- Add htmx, hx attributes
- Only add/update items since last check
- Move messages to session to persist across redirects
- Polish styles a bit (still WIP)
2024-04-30 18:51:09 -04:00
d8ba178c55 Add pruning support (#12) 2024-04-29 23:01:49 -04:00
473dded4f9 Add docs for scheduled refresh (#11)
- Add user agent for feed requests
2024-04-28 21:16:42 -04:00
10638101d3 Add refresh utility script (#11) 2024-04-28 14:09:53 -04:00
1826ecd588 Derive feed from HTML (#10)
- Prevent duplicate feed subscriptions
- Move FeedItem to its own file
2024-04-27 22:05:39 -04:00
27f7a4c7d9 Merge pull request 'Implement remaining security models' (#19) from security-models into main
Reviewed-on: #19
2024-04-27 23:14:48 +00:00
2ac4a87865 Rename user config (#18)
- Update installation instructions
2024-04-27 19:09:13 -04:00
36373aae01 Add docs for security models (#9)
- Change default security to CONFIGURE_ME
- Fix log on return URL handling
- Update INSTALLING security model descriptions
2024-04-27 18:54:57 -04:00
9611893da3 Add single-user password utils (#9)
- Constrain images to reading viewport
2024-04-27 16:34:59 -04:00
0df40f3cfd Add version to page header 2024-04-27 14:11:31 -04:00
bf6b2a0ffa Add single-user handling (#9)
- Disallow log on for single-user mode user
- Improve CLI header display
2024-04-27 13:54:09 -04:00
c1790b58fd Add user maintenance CLI (#9)
- Add CLI infrastructure
- Add user to index page query
- Strip tags from title
- Move item parsing to FeedItem
2024-04-27 13:01:57 -04:00
7b21b86550 Merge branch 'main' into security-models 2024-04-26 20:49:34 -04:00
ce83b2a389 Add Atom support (#17) 2024-04-25 21:19:29 -04:00
9d476b644b Remove is_encoded reference (#16) 2024-04-17 09:03:57 -04:00
cab26db255 First cut of log on page (#9)
- Add session support
- Refactor security handling to use db connection
- Fix db path issue
2024-04-15 23:25:58 -04:00
24 changed files with 1296 additions and 291 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
.idea .idea
src/data/*.db src/data/*.db
src/user-config.php

View File

@@ -17,7 +17,7 @@ _(More environments will be detailed as part of a later release; an nginx revers
## PHP Requirements ## PHP Requirements
This is written to target PHP 8.3, and requires the `curl`, `DOM`, and `SQLite3` modules. _(FrankenPHP contains these modules as part of its build.)_ This is written to target PHP 8.3, and requires the `curl`, `DOM`, and `SQLite3` modules and the `php-cli` feature. _(FrankenPHP contains all these as part of its build.)_
# Setup and Configuration # Setup and Configuration
@@ -27,14 +27,14 @@ The default `Caddyfile` will run the site at `http://localhost:8205`. To have th
## Feed Reader Central Behavior ## Feed Reader Central Behavior
Within the `/src` directory, there is a file named `user-config.php`. This file is the place for customizations and configuration of the instance's behavior. Within the `/src` directory, there is a file named `user-config.dist.php`. Rename this file to `user-config.php`; this is where customizations and configuration of the instance's behavior are placed.
### Security Model ### Security Model
There ~~are~~ will be three supported security models, designed around different ways the software may be deployed. There are three supported security models, designed around different ways the software may be deployed. `SECURITY_MODEL` in `user-config.php` **must be set** to one of these values.
- `Securty::SINGLE_USER` assumes that all connections to the instance are the same person. There is no password required, and no username or e-mail address will be displayed for that user. This is a good setup for a single user on a home intranet. **DO NOT PUT AN INSTANCE WITH THIS CONFIGURATION ON THE PUBLIC INTERNET!** If you do, you deserve what you get. - `Securty::SINGLE_USER` assumes that all connections to the instance are the same person. There is no password required, and no username or e-mail address will be displayed for that user. This is a good setup for a single user on a home intranet. **DO NOT PUT AN INSTANCE WITH THIS CONFIGURATION ON THE PUBLIC INTERNET!** If you do, you deserve what you get.
- `Security::SINGLE_USER_WITH_PASSWORD` _(not yet implemented)_ will be the same as the above, but will require a password. This setup is ideal for intranets where the user does not want any other users ending up marking their feeds as read just by browsing them. - `Security::SINGLE_USER_WITH_PASSWORD` is the same as the above but requires a password. This setup is ideal for intranets where the user does not want any other users ending up marking their feeds as read just by browsing them.
- `Security::MULTI_USER` _(not yet implemented)_ will require a known e-mail address and password be provided to establish the identity of each user. This will be the most appropriate setup for an Internet-facing instance, even if there is only one user. - `Security::MULTI_USER` requires a known e-mail address and password be provided to establish the identity of each user. This is the most appropriate setup for an Internet-facing instance, even if there is only one user.
### Database Name ### Database Name
@@ -42,4 +42,13 @@ Data is stored under the `/src/data` directory, and the default database name is
### Date/Time Format ### Date/Time Format
The default format for dates and times look like "May 28, 2023 at 3:15pm". Changing the string there will alter the display on the main page and when reading an item. Any [supported PHP date or time token](https://www.php.net/manual/en/datetime.format.php) is supported. The default format for dates and times look like "May 28, 2023 at 3:15pm". Changing the string there will alter the display on the main page and when reading an item. Any [supported PHP date or time token](https://www.php.net/manual/en/datetime.format.php) is supported.
### Item Purging
Feed Reader Central tries to keep the database tidy by purging items that have been read and are no longer required. There are four variants:
- `Feed::PURGE_NONE` does no purging (items have a "Delete" button, so they may be deleted manually)
- `Feed::PURGE_READ` purges non-bookmarked read items for a feed whenever it is refreshed. This is the most aggressive purging strategy, but it is also the only one that will not purge unread items.
- `Feed::PURGE_BY_DAYS` purges non-bookmarked items that are older than `PURGE_NUMBER` days old. This is the default value, and `PURGE_NUMBER`'s default value is 30; items will be kept for 30 days, read or unread.
- `Feed::PURGE_BY_COUNT` purges items to preserve at most `PURGE_NUMBER` non-bookmarked items for each feed.

20
src/app-config.php Normal file
View File

@@ -0,0 +1,20 @@
<?php
/** The current Feed Reader Central version */
const FRC_VERSION = '1.0.0-alpha5';
spl_autoload_register(function ($class) {
$file = implode(DIRECTORY_SEPARATOR, [__DIR__, 'lib', "$class.php"]);
if (file_exists($file)) {
require $file;
return true;
}
return false;
});
require 'user-config.php';
Data::ensureDb();
/** @var string The date the world wide web was created */
const WWW_EPOCH = '1993-04-30T00:00:00+00:00';

44
src/cli-start.php Normal file
View File

@@ -0,0 +1,44 @@
<?php
require 'app-config.php';
if (php_sapi_name() != 'cli') {
http_response_code(404);
die('Not Found');
}
/**
* Print a line with a newline character at the end (printf + newline = printfn; surprised this isn't built in!)
*
* @param string $format The format string
* @param mixed ...$values The values for the placeholders in the string
*/
function printfn(string $format, mixed ...$values): void {
printf($format . PHP_EOL, ...$values);
}
/**
* Display a title for a CLI run
*
* @param string $title The title to display on the command line
*/
function cli_title(string $title): void {
$appTitle = 'Feed Reader Central ~ v' . FRC_VERSION;
$dashes = ' +' . str_repeat('-', strlen($title) + 2) . '+' . str_repeat('-', strlen($appTitle) + 2) . '+';
printfn($dashes);
printfn(' | %s | %s |', $title, $appTitle);
printfn($dashes . PHP_EOL);
}
/**
* Capitalize the first letter of the given string
*
* @param string $value The string to be capitalized
* @return string The given string with the first letter capitalized
*/
function init_cap(string $value): string {
return match (strlen($value)) {
0 => "",
1 => strtoupper($value),
default => strtoupper(substr($value, 0, 1)) . substr($value, 1),
};
}

View File

@@ -9,7 +9,7 @@ class Data {
* @return SQLite3 A new connection to the database * @return SQLite3 A new connection to the database
*/ */
public static function getConnection(): SQLite3 { public static function getConnection(): SQLite3 {
$db = new SQLite3('../data/' . DATABASE_NAME); $db = new SQLite3(implode(DIRECTORY_SEPARATOR, [__DIR__, '..', 'data', DATABASE_NAME]));
$db->exec('PRAGMA foreign_keys = ON;'); $db->exec('PRAGMA foreign_keys = ON;');
return $db; return $db;
} }
@@ -65,47 +65,6 @@ class Data {
$db->close(); $db->close();
} }
/**
* Find a user by their ID
*
* @param string $email The e-mail address of the user to retrieve
* @return array|null The user information, or null if the user is not found
*/
public static function findUserByEmail(string $email): ?array {
$db = self::getConnection();
try {
$query = $db->prepare('SELECT * FROM frc_user WHERE email = :email');
$query->bindValue(':email', $email);
$result = $query->execute();
if ($result) {
$user = $result->fetchArray(SQLITE3_ASSOC);
if ($user) return $user;
return null;
}
return null;
} finally {
$db->close();
}
}
/**
* Add a user
*
* @param string $email The e-mail address for the user
* @param string $password The user's password
*/
public static function addUser(string $email, string $password): void {
$db = self::getConnection();
try {
$query = $db->prepare('INSERT INTO frc_user (email, password) VALUES (:email, :password)');
$query->bindValue(':email', $email);
$query->bindValue(':password', password_hash($password, PASSWORD_DEFAULT));
$query->execute();
} finally {
$db->close();
}
}
/** /**
* Parse/format a date/time from a string * Parse/format a date/time from a string
* *
@@ -132,11 +91,21 @@ class Data {
try { try {
$query = $db->prepare('SELECT * FROM feed WHERE id = :id AND user_id = :user'); $query = $db->prepare('SELECT * FROM feed WHERE id = :id AND user_id = :user');
$query->bindValue(':id', $feedId); $query->bindValue(':id', $feedId);
$query->bindValue(':user', $_REQUEST[Key::USER_ID]); $query->bindValue(':user', $_SESSION[Key::USER_ID]);
$result = $query->execute(); $result = $query->execute();
return $result ? $result->fetchArray(SQLITE3_ASSOC) : false; return $result ? $result->fetchArray(SQLITE3_ASSOC) : false;
} finally { } finally {
if (is_null($dbConn)) $db->close(); if (is_null($dbConn)) $db->close();
} }
} }
/**
* Return the last SQLite error message as a result array
*
* @param SQLite3 $db The database connection on which the error has occurred
* @return string[] ['error' => message] for last SQLite error message
*/
public static function error(SQLite3 $db): array {
return ['error' => 'SQLite error: ' . $db->lastErrorMsg()];
}
} }

View File

@@ -1,15 +1,47 @@
<?php <?php
/** /**
* Feed retrieval, parsing, and manipulation * Feed retrieval, parsing, and manipulation
*/ */
class Feed { class Feed {
/** @var string The URL for the feed */
public string $url = '';
/** @var string The title of the feed */
public string $title = '';
/** @var ?string When the feed was last updated */
public ?string $updatedOn = null;
/** @var FeedItem[] The items contained in the feed */
public array $items = [];
/** @var string The XML namespace for Atom feeds */ /** @var string The XML namespace for Atom feeds */
public const string ATOM_NS = 'http://www.w3.org/2005/Atom'; public const string ATOM_NS = 'http://www.w3.org/2005/Atom';
/** @var string The XML namespace for the `<content:encoded>` tag that allows HTML content in a feed */ /** @var string The XML namespace for the `<content:encoded>` tag that allows HTML content in a feed */
public const string CONTENT_NS = 'http://purl.org/rss/1.0/modules/content/'; public const string CONTENT_NS = 'http://purl.org/rss/1.0/modules/content/';
/** @var string The XML namespace for XHTML */
public const string XHTML_NS = 'http://www.w3.org/1999/xhtml';
/** @var string The user agent for Feed Reader Central's refresh requests */
private const string USER_AGENT =
'FeedReaderCentral/' . FRC_VERSION . ' +https://bitbadger.solutions/open-source/feed-reader-central';
/** @var int Do not purge items */
public const int PURGE_NONE = 0;
/** @var int Purge all read items (will not purge unread items) */
public const int PURGE_READ = 1;
/** @var int Purge items older than the specified number of days */
public const int PURGE_BY_DAYS = 2;
/** @var int Purge items in number greater than the specified number of items to keep */
public const int PURGE_BY_COUNT = 3;
/** /**
* When parsing XML into a DOMDocument, errors are presented as warnings; this creates an exception for them * When parsing XML into a DOMDocument, errors are presented as warnings; this creates an exception for them
* *
@@ -19,7 +51,7 @@ class Feed {
* @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;
@@ -45,102 +77,196 @@ class Feed {
} }
/** /**
* Get the value of a child element by its tag name * Get the value of a child element by its tag name for an RSS feed
* *
* @param DOMElement $element The parent element * @param DOMNode $element The parent element
* @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)
*/ */
private static function eltValue(DOMElement $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;
} }
/** /**
* Retrieve the feed * Extract items from an RSS feed
* *
* @param string $url * @param DOMDocument $xml The XML received from the feed
* @return array|DOMDocument[]|string[]|DOMElement[] * @param string $url The actual URL for the feed
* ['ok' => feedXml, 'url' => actualUrl, 'channel' => channel, 'updated' => updatedDate] if successful, * @return array|Feed[]|string[] ['ok' => feed] if successful, ['error' => message] if not
* ['error' => message] if not
*/ */
public static function retrieveFeed(string $url): array { private static function fromRSS(DOMDocument $xml, string $url): array {
$feedReq = curl_init($url); $channel = $xml->getElementsByTagName('channel')->item(0);
curl_setopt($feedReq, CURLOPT_FOLLOWLOCATION, true); if (!($channel instanceof DOMElement)) {
curl_setopt($feedReq, CURLOPT_RETURNTRANSFER, true); $type = $channel?->nodeType ?? -1;
curl_setopt($feedReq, CURLOPT_CONNECTTIMEOUT, 5); return ['error' => "Channel element not found ($type)"];
curl_setopt($feedReq, CURLOPT_TIMEOUT, 15);
$feedContent = curl_exec($feedReq);
$result = array();
$error = curl_error($feedReq);
$code = curl_getinfo($feedReq, CURLINFO_RESPONSE_CODE);
if ($error) {
$result['error'] = $error;
} else if ($code == 200) {
$parsed = self::parseFeed($feedContent);
if (array_key_exists('error', $parsed)) {
$result['error'] = $parsed['error'];
} else {
$result['ok'] = $parsed['ok'];
$result['url'] = curl_getinfo($feedReq, CURLINFO_EFFECTIVE_URL);
$channel = $result['ok']->getElementsByTagName('channel')->item(0);
if ($channel instanceof DOMElement) {
$result['channel'] = $channel;
} else {
return ['error' => "Channel element not found ($channel->nodeType)"];
}
// In Atom feeds, lastBuildDate contains the last time an item in the feed was updated; if that is not
// present, use the pubDate element instead
$updated = self::eltValue($channel, 'lastBuildDate');
if ($updated == 'lastBuildDate not found') {
$updated = self::eltValue($channel, 'pubDate');
if ($updated == 'pubDate not found') $updated = null;
}
$result['updated'] = Data::formatDate($updated);
return $result;
}
} else {
$result['error'] = "Prospective feed URL $url returned HTTP Code $code: $feedContent";
} }
curl_close($feedReq); // The Atom namespace provides a lastBuildDate, which contains the last time an item in the feed was updated; if
// that is not present, use the pubDate element instead
if (($updatedOn = self::rssValue($channel, 'lastBuildDate')) == 'lastBuildDate not found') {
if (($updatedOn = self::rssValue($channel, 'pubDate')) == 'pubDate not found') {
$updatedOn = null;
}
}
$feed = new Feed();
$feed->title = self::rssValue($channel, 'title');
$feed->url = $url;
$feed->updatedOn = Data::formatDate($updatedOn);
foreach ($channel->getElementsByTagName('item') as $item) $feed->items[] = FeedItem::fromRSS($item);
return ['ok' => $feed];
}
/**
* Get an attribute value from a DOM node
*
* @param DOMNode $node The node with an attribute value to obtain
* @param string $name The name of the attribute whose value should be obtained
* @return string The attribute value if it exists, an empty string if not
*/
private static function attrValue(DOMNode $node, string $name): string {
return ($node->hasAttributes() ? $node->attributes->getNamedItem($name)?->value : null) ?? '';
}
/**
* Get the value of a child element by its tag name for an Atom feed
*
* (Atom feeds can have type attributes on nearly any value. For our purposes, types "text" and "html" will work as
* regular string values; for "xhtml", though, we will need to get the `<div>` and extract its contents instead.)
*
* @param DOMNode $element The parent element
* @param string $tagName The name of the tag whose value should be obtained
* @return string The value of the element (or "[element] not found" if that element does not exist)
*/
public static function atomValue(DOMNode $element, string $tagName): string {
$tags = $element->getElementsByTagName($tagName);
if ($tags->length == 0) return "$tagName not found";
$tag = $tags->item(0);
if (!($tag instanceof DOMElement)) return $tag->textContent;
if (self::attrValue($tag, 'type') == 'xhtml') {
$div = $tag->getElementsByTagNameNS(Feed::XHTML_NS, 'div');
if ($div->length == 0) return "-- invalid XHTML content --";
return $div->item(0)->textContent;
}
return $tag->textContent;
}
/**
* Extract items from an Atom feed
*
* @param DOMDocument $xml The XML received from the feed
* @param string $url The actual URL for the feed
* @return array|Feed[] ['ok' => feed]
*/
private static function fromAtom(DOMDocument $xml, string $url): array {
$root = $xml->getElementsByTagNameNS(self::ATOM_NS, 'feed')->item(0);
if (($updatedOn = self::atomValue($root, 'updated')) == 'pubDate not found') $updatedOn = null;
$feed = new Feed();
$feed->title = self::atomValue($root, 'title');
$feed->url = $url;
$feed->updatedOn = Data::formatDate($updatedOn);
foreach ($root->getElementsByTagName('entry') as $entry) $feed->items[] = FeedItem::fromAtom($entry);
return ['ok' => $feed];
}
/**
* Retrieve a document (http/https)
*
* @param string $url The URL of the document to retrieve
* @return array ['content' => document content, 'error' => error message, 'code' => HTTP response code,
* 'url' => effective URL]
*/
private static function retrieveDocument(string $url): array {
$docReq = curl_init($url);
curl_setopt($docReq, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($docReq, CURLOPT_RETURNTRANSFER, true);
curl_setopt($docReq, CURLOPT_CONNECTTIMEOUT, 5);
curl_setopt($docReq, CURLOPT_TIMEOUT, 15);
curl_setopt($docReq, CURLOPT_USERAGENT, self::USER_AGENT);
$result = [
'content' => curl_exec($docReq),
'error' => curl_error($docReq),
'code' => curl_getinfo($docReq, CURLINFO_RESPONSE_CODE),
'url' => curl_getinfo($docReq, CURLINFO_EFFECTIVE_URL)
];
curl_close($docReq);
return $result; return $result;
} }
/** /**
* Extract the fields we need to keep from the feed * Derive a feed URL from an HTML document
* *
* @param DOMElement $item The item from the feed * @param string $content The HTML document content from which to derive a feed URL
* @return array The fields for the item as an associative array * @return array|string[] ['ok' => feed URL] if successful, ['error' => message] if not
*/ */
private static function itemFields(DOMElement $item): array { private static function deriveFeedFromHTML(string $content): array {
$itemGuid = self::eltValue($item, 'guid'); $html = new DOMDocument();
$updNodes = $item->getElementsByTagNameNS(self::ATOM_NS, 'updated'); $html->loadHTML(substr($content, 0, strpos($content, '</head>') + 7));
$encNodes = $item->getElementsByTagNameNS(self::CONTENT_NS, 'encoded'); $headTags = $html->getElementsByTagName('head');
return [ if ($headTags->length < 1) return ['error' => 'Cannot find feed at this URL'];
'guid' => $itemGuid == 'guid not found' ? self::eltValue($item, 'link') : $itemGuid, $head = $headTags->item(0);
'title' => self::eltValue($item, 'title'), foreach ($head->getElementsByTagName('link') as $link) {
'link' => self::eltValue($item, 'link'), if (self::attrValue($link, 'rel') == 'alternate') {
'published' => Data::formatDate(self::eltValue($item, 'pubDate')), $type = self::attrValue($link, 'type');
'updated' => Data::formatDate($updNodes->length > 0 ? $updNodes->item(0)->textContent : null), if ($type == 'application/rss+xml' || $type == 'application/atom+xml') {
'content' => $encNodes->length > 0 ? $encNodes->item(0)->textContent return ['ok' => self::attrValue($link, 'href')];
: self::eltValue($item, 'description') }
]; }
}
return ['error' => 'Cannot find feed at this URL'];
}
/**
* Retrieve the feed
*
* @param string $url The URL of the feed to retrieve
* @return array|Feed[]|string[] ['ok' => feed] if successful, ['error' => message] if not
*/
public static function retrieveFeed(string $url): array {
$doc = self::retrieveDocument($url);
if ($doc['error'] != '') return ['error' => $doc['error']];
if ($doc['code'] != 200) {
return ['error' => "Prospective feed URL $url returned HTTP Code {$doc['code']}: {$doc['content']}"];
}
$start = strtolower(strlen($doc['content']) >= 9 ? substr($doc['content'], 0, 9) : $doc['content']);
if ($start == '<!doctype' || str_starts_with($start, '<html')) {
$derivedURL = self::deriveFeedFromHTML($doc['content']);
if (array_key_exists('error', $derivedURL)) return ['error' => $derivedURL['error']];
$feedURL = $derivedURL['ok'];
if (!str_starts_with($feedURL, 'http')) {
// Relative URL; feed should be retrieved in the context of the original URL
$original = parse_url($url);
$port = array_key_exists('port', $original) ? ":{$original['port']}" : '';
$feedURL = $original['scheme'] . '://' . $original['host'] . $port . $feedURL;
}
$doc = self::retrieveDocument($feedURL);
}
$parsed = self::parseFeed($doc['content']);
if (array_key_exists('error', $parsed)) return ['error' => $parsed['error']];
$extract = $parsed['ok']->getElementsByTagNameNS(self::ATOM_NS, 'feed')->length > 0
? self::fromAtom(...) : self::fromRSS(...);
return $extract($parsed['ok'], $doc['url']);
} }
/** /**
* Update a feed item * Update a feed item
* *
* @param int $itemId The ID of the item to be updated * @param int $itemId The ID of the item to be updated
* @param array $item The fields from the updated item * @param FeedItem $item The item to be updated
* @param SQLite3 $db A database connection to use for the update * @param SQLite3 $db A database connection to use for the update
* @return bool|SQLite3Result The result if the update is successful, false if it failed
*/ */
private static function updateItem(int $itemId, array $item, SQLite3 $db): void { private static function updateItem(int $itemId, FeedItem $item, SQLite3 $db): bool|SQLite3Result {
$query = $db->prepare(<<<'SQL' $query = $db->prepare(<<<'SQL'
UPDATE item UPDATE item
SET title = :title, SET title = :title,
@@ -150,22 +276,23 @@ class Feed {
is_read = 0 is_read = 0
WHERE id = :id WHERE id = :id
SQL); SQL);
$query->bindValue(':title', $item['title']); $query->bindValue(':title', $item->title);
$query->bindValue(':published', $item['published']); $query->bindValue(':published', $item->publishedOn);
$query->bindValue(':updated', $item['updated']); $query->bindValue(':updated', $item->updatedOn);
$query->bindValue(':content', $item['content']); $query->bindValue(':content', $item->content);
$query->bindValue(':id', $itemId); $query->bindValue(':id', $itemId);
$query->execute(); return $query->execute();
} }
/** /**
* Add a feed item * Add a feed item
* *
* @param int $feedId The ID of the feed to which the item should be added * @param int $feedId The ID of the feed to which the item should be added
* @param array $item The fields for the item * @param FeedItem $item The item to be added
* @param SQLite3 $db A database connection to use for the addition * @param SQLite3 $db A database connection to use for the addition
* @return bool|SQLite3Result The result if the update is successful, false if it failed
*/ */
private static function addItem(int $feedId, array $item, SQLite3 $db): void { private static function addItem(int $feedId, FeedItem $item, SQLite3 $db): bool|SQLite3Result {
$query = $db->prepare(<<<'SQL' $query = $db->prepare(<<<'SQL'
INSERT INTO item ( INSERT INTO item (
feed_id, item_guid, item_link, title, published_on, updated_on, content feed_id, item_guid, item_link, title, published_on, updated_on, content
@@ -174,73 +301,107 @@ class Feed {
) )
SQL); SQL);
$query->bindValue(':feed', $feedId); $query->bindValue(':feed', $feedId);
$query->bindValue(':guid', $item['guid']); $query->bindValue(':guid', $item->guid);
$query->bindValue(':link', $item['link']); $query->bindValue(':link', $item->link);
$query->bindValue(':title', $item['title']); $query->bindValue(':title', $item->title);
$query->bindValue(':published', $item['published']); $query->bindValue(':published', $item->publishedOn);
$query->bindValue(':updated', $item['updated']); $query->bindValue(':updated', $item->updatedOn);
$query->bindValue(':content', $item['content']); $query->bindValue(':content', $item->content);
$query->execute(); return $query->execute();
} }
/** /**
* Update a feed's items * Update a feed's items
* *
* @param int $feedId The ID of the feed to which these items belong * @param int $feedId The ID of the feed to which these items belong
* @param DOMElement $channel The RSS feed items * @param Feed $feed The extracted Atom or RSS feed items
* @param DateTimeInterface $lastChecked When this feed was last checked (only new items will be added)
* @return array ['ok' => true] if successful, ['error' => message] if not * @return array ['ok' => true] if successful, ['error' => message] if not
*/ */
public static function updateItems(int $feedId, DOMElement $channel, SQLite3 $db): array { public static function updateItems(int $feedId, Feed $feed, DateTimeInterface $lastChecked, SQLite3 $db): array {
try { $results =
foreach ($channel->getElementsByTagName('item') as $rawItem) { array_map(function ($item) use ($db, $feedId) {
$item = self::itemFields($rawItem);
$existsQuery = $db->prepare( $existsQuery = $db->prepare(
'SELECT id, published_on, updated_on FROM item WHERE feed_id = :feed AND item_guid = :guid'); 'SELECT id, published_on, updated_on FROM item WHERE feed_id = :feed AND item_guid = :guid');
$existsQuery->bindValue(':feed', $feedId); $existsQuery->bindValue(':feed', $feedId);
$existsQuery->bindValue(':guid', $item['guid']); $existsQuery->bindValue(':guid', $item->guid);
$exists = $existsQuery->execute(); if ($exists = $existsQuery->execute()) {
if ($exists) { if ($existing = $exists->fetchArray(SQLITE3_ASSOC)) {
$existing = $exists->fetchArray(SQLITE3_ASSOC); if ( $existing['published_on'] != $item->publishedOn
if ($existing) { || ($existing['updated_on'] ?? '') != ($item->updatedOn ?? '')) {
if ( $existing['published_on'] != $item['published'] if (!self::updateItem($existing['id'], $item, $db)) return Data::error($db);
|| $existing['updated_on'] ?? '' != $item['updated'] ?? '') {
self::updateItem($existing['id'], $item, $db);
} }
} else { } else {
self::addItem($feedId, $item, $db); if (!self::addItem($feedId, $item, $db)) return Data::error($db);
} }
} else { } else {
throw new Exception($db->lastErrorMsg()); return Data::error($db);
} }
return ['ok' => true];
}, array_filter($feed->items,
fn($it) => date_create_immutable($it->updatedOn ?? $it->publishedOn) >= $lastChecked));
$errors = array_map(fn($it) => $it['error'], array_filter($results, fn($it) => array_key_exists('error', $it)));
return sizeof($errors) > 0 ? ['error' => implode("\n", $errors)] : ['ok' => true];
}
/**
* Purge items for a feed
*
* @param int $feedId The ID of the feed to be purged
* @param SQLite3 $db The database connection on which items should be purged
* @return array|string[]|true[] ['ok' => true] if purging was successful, ['error' => message] if not
*/
private static function purgeItems(int $feedId, SQLite3 $db): array {
if (!array_search(PURGE_TYPE, [self::PURGE_READ, self::PURGE_BY_DAYS, self::PURGE_BY_COUNT])) {
return ['error' => 'Unrecognized purge type ' . PURGE_TYPE];
}
try {
$sql = match (PURGE_TYPE) {
self::PURGE_READ => 'AND is_read = 1',
self::PURGE_BY_DAYS => 'AND date(coalesce(updated_on, published_on)) < date(:oldest)',
self::PURGE_BY_COUNT => 'AND id IN (SELECT id FROM item WHERE feed_id = :feed
ORDER BY date(coalesce(updated_on, published_on)) DESC
LIMIT -1 OFFSET :keep)'
};
$purge = $db->prepare("DELETE FROM item WHERE feed_id = :feed AND is_bookmarked = 0 $sql");
$purge->bindValue(':feed', $feedId);
if (PURGE_TYPE == self::PURGE_BY_DAYS) {
$purge->bindValue(':oldest', Data::formatDate('-' . PURGE_NUMBER . ' day'));
} elseif (PURGE_TYPE == self::PURGE_BY_COUNT) {
$purge->bindValue(':keep', PURGE_NUMBER);
} }
return $purge->execute() ? ['ok' => true] : Data::error($db);
} catch (Exception $ex) { } catch (Exception $ex) {
return ['error' => $ex->getMessage()]; return ['error' => $ex->getMessage()];
} }
return ['ok', true];
} }
/** /**
* Refresh a feed * Refresh a feed
* *
* @param int $feedId The ID of the feed to be refreshed
* @param string $url The URL of the feed to be refreshed * @param string $url The URL of the feed to be refreshed
* @param SQLite3 $db A database connection to use to refresh the feed * @param SQLite3 $db A database connection to use to refresh the feed
* @return array|string[]|true[] ['ok' => true] if successful, ['error' => message] if not * @return array|string[]|true[] ['ok' => true] if successful, ['error' => message] if not
*/ */
private static function refreshFeed(string $url, SQLite3 $db): array { public static function refreshFeed(int $feedId, string $url, SQLite3 $db): array {
$feedQuery = $db->prepare('SELECT id FROM feed WHERE url = :url AND user_id = :user'); $feedRetrieval = self::retrieveFeed($url);
$feedQuery->bindValue(':url', $url); if (array_key_exists('error', $feedRetrieval)) return $feedRetrieval;
$feedQuery->bindValue(':user', $_REQUEST[Key::USER_ID]); $feed = $feedRetrieval['ok'];
$feedResult = $feedQuery->execute();
$feedId = $feedResult ? $feedResult->fetchArray(SQLITE3_NUM)[0] : -1;
if ($feedId < 0) return ['error' => "No feed for URL $url found"];
$feed = self::retrieveFeed($url); $lastCheckedQuery = $db->prepare('SELECT checked_on FROM feed where id = :id');
if (array_key_exists('error', $feed)) return $feed; $lastCheckedQuery->bindValue(':id', $feedId);
if (!($lastCheckedResult = $lastCheckedQuery->execute())) return Data::error($db);
if (!($lastChecked = date_create_immutable($lastCheckedResult->fetchArray(SQLITE3_NUM)[0] ?? WWW_EPOCH))) {
return ['error' => 'Could not derive date last checked for feed'];
}
$itemUpdate = self::updateItems($feedId, $feed['channel'], $db); $itemUpdate = self::updateItems($feedId, $feed, $lastChecked, $db);
if (array_key_exists('error', $itemUpdate)) return $itemUpdate; if (array_key_exists('error', $itemUpdate)) return $itemUpdate;
$urlUpdate = $url == $feed['url'] ? '' : ', url = :url'; $urlUpdate = $url == $feed->url ? '' : ', url = :url';
$feedUpdate = $db->prepare(<<<SQL $feedUpdate = $db->prepare(<<<SQL
UPDATE feed UPDATE feed
SET title = :title, SET title = :title,
@@ -249,14 +410,14 @@ class Feed {
$urlUpdate $urlUpdate
WHERE id = :id WHERE id = :id
SQL); SQL);
$feedUpdate->bindValue(':title', self::eltValue($feed['channel'], 'title')); $feedUpdate->bindValue(':title', $feed->title);
$feedUpdate->bindValue(':updated', $feed['updated']); $feedUpdate->bindValue(':updated', $feed->updatedOn);
$feedUpdate->bindValue(':checked', Data::formatDate('now')); $feedUpdate->bindValue(':checked', Data::formatDate('now'));
$feedUpdate->bindValue(':id', $feedId); $feedUpdate->bindValue(':id', $feedId);
if ($urlUpdate != '') $feedUpdate->bindValue(':url', $feed['url']); if ($urlUpdate != '') $feedUpdate->bindValue(':url', $feed->url);
$feedUpdate->execute(); if (!$feedUpdate->execute()) return Data::error($db);
return ['ok' => true]; return PURGE_TYPE == self::PURGE_NONE ? ['ok' => true] : self::purgeItems($feedId, $db);
} }
/** /**
@@ -266,24 +427,33 @@ class Feed {
* @return array ['ok' => feedId] if successful, ['error' => message] if not * @return array ['ok' => feedId] if successful, ['error' => message] if not
*/ */
public static function add(string $url, SQLite3 $db): array { public static function add(string $url, SQLite3 $db): array {
$feed = self::retrieveFeed($url); $feedExtract = self::retrieveFeed($url);
if (array_key_exists('error', $feed)) return $feed; if (array_key_exists('error', $feedExtract)) return $feedExtract;
$feed = $feedExtract['ok'];
$existsQuery = $db->prepare('SELECT COUNT(*) FROM feed WHERE user_id = :user AND url = :url');
$existsQuery->bindValue(':user', $_SESSION[Key::USER_ID]);
$existsQuery->bindValue(':url', $feed->url);
if (!($exists = $existsQuery->execute())) return Data::error($db);
if ($exists->fetchArray(SQLITE3_NUM)[0] > 0) return ['error' => "Already subscribed to feed $feed->url"];
$query = $db->prepare(<<<'SQL' $query = $db->prepare(<<<'SQL'
INSERT INTO feed (user_id, url, title, updated_on, checked_on) INSERT INTO feed (
VALUES (:user, :url, :title, :updated, :checked) user_id, url, title, updated_on, checked_on
) VALUES (
:user, :url, :title, :updated, :checked
)
SQL); SQL);
$query->bindValue(':user', $_REQUEST[Key::USER_ID]); $query->bindValue(':user', $_SESSION[Key::USER_ID]);
$query->bindValue(':url', $feed['url']); $query->bindValue(':url', $feed->url);
$query->bindValue(':title', self::eltValue($feed['channel'], 'title')); $query->bindValue(':title', $feed->title);
$query->bindValue(':updated', $feed['updated']); $query->bindValue(':updated', $feed->updatedOn);
$query->bindValue(':checked', Data::formatDate('now')); $query->bindValue(':checked', Data::formatDate('now'));
$result = $query->execute(); if (!$query->execute()) return Data::error($db);
$feedId = $result ? $db->lastInsertRowID() : -1; $feedId = $db->lastInsertRowID();
if ($feedId < 0) return ['error' => $db->lastErrorMsg()]; $result = self::updateItems($feedId, $feed, date_create_immutable(WWW_EPOCH), $db);
$result = self::updateItems($feedId, $feed['channel'], $db);
if (array_key_exists('error', $result)) return $result; if (array_key_exists('error', $result)) return $result;
return ['ok' => $feedId]; return ['ok' => $feedId];
@@ -294,36 +464,54 @@ class Feed {
* *
* @param array $existing The existing RSS feed * @param array $existing The existing RSS feed
* @param string $url The URL with which the existing feed should be modified * @param string $url The URL with which the existing feed should be modified
* @param SQLite3 $db The database connection on which to execute the update
* @return bool[]|string[] [ 'ok' => true ] if successful, [ 'error' => message ] if not * @return bool[]|string[] [ 'ok' => true ] if successful, [ 'error' => message ] if not
*/ */
public static function update(array $existing, string $url, SQLite3 $db): array { public static function update(array $existing, string $url, SQLite3 $db): array {
$query = $db->prepare('UPDATE feed SET url = :url WHERE id = :id AND user_id = :user'); $query = $db->prepare('UPDATE feed SET url = :url WHERE id = :id AND user_id = :user');
$query->bindValue(':url', $url); $query->bindValue(':url', $url);
$query->bindValue(':id', $existing['id']); $query->bindValue(':id', $existing['id']);
$query->bindValue(':user', $_REQUEST[Key::USER_ID]); $query->bindValue(':user', $_SESSION[Key::USER_ID]);
$query->execute(); if (!$query->execute()) return Data::error($db);
return self::refreshFeed($url, $db); return self::refreshFeed($existing['id'], $url, $db);
} }
/** /**
* @param SQLite3 $db * Retrieve all feeds, optionally for a specific user
* @return array|true[] ['ok => true] if successful, ['error' => message] if not (may have multiple error lines) *
* @param SQLite3 $db The database connection to use to retrieve the feeds
* @param int $user The ID of the user whose feeds should be retrieved (optional, defaults to all feeds)
* @return array An array of arrays with ['id', 'url', 'email'] keys
*/
public static function retrieveAll(SQLite3 $db, int $user = 0): array {
$extraSQL = $user > 0 ? ' WHERE u.id = :user' : '';
$query = $db->prepare(
"SELECT f.id, f.url, u.email FROM feed f INNER JOIN frc_user u ON u.id = f.user_id$extraSQL");
if ($user > 0) $query->bindValue(':user', $user);
if (!($result = $query->execute())) return Data::error($db);
$feeds = [];
while ($feed = $result->fetchArray(SQLITE3_ASSOC)) $feeds[] = $feed;
return $feeds;
}
/**
* Refresh all feeds
*
* @param SQLite3 $db The database connection to use for refreshing feeds
* @return array|true[]|string[] ['ok' => true] if successful,
* ['error' => message] if not (may have multiple error lines)
*/ */
public static function refreshAll(SQLite3 $db): array { public static function refreshAll(SQLite3 $db): array {
$query = $db->prepare('SELECT url FROM feed WHERE user_id = :user'); $feeds = self::retrieveAll($db, $_SESSION[Key::USER_ID]);
$query->bindValue(':user', $_REQUEST[Key::USER_ID]); if (array_key_exists('error', $feeds)) return $feeds;
$result = $query->execute();
$url = $result ? $result->fetchArray(SQLITE3_NUM) : false; $errors = [];
if ($url) { array_walk($feeds, function ($feed) use ($db, &$errors) {
$errors = array(); $result = self::refreshFeed($feed['id'], $feed['url'], $db);
while ($url) { if (array_key_exists('error', $result)) $errors[] = $result['error'];
$updateResult = self::refreshFeed($url[0], $db); });
if (array_key_exists('error', $updateResult)) $errors[] = $updateResult['error'];
$url = $result->fetchArray(SQLITE3_NUM); return sizeof($errors) == 0 ? ['ok' => true] : ['error' => implode("\n", $errors)];
}
return sizeof($errors) == 0 ? ['ok' => true] : ['error' => implode("\n", $errors)];
}
return ['error' => $db->lastErrorMsg()];
} }
} }

80
src/lib/FeedItem.php Normal file
View File

@@ -0,0 +1,80 @@
<?php
/**
* Information for a feed item
*/
class FeedItem {
/** @var string The title of the feed item */
public string $title = '';
/** @var string The unique ID for the feed item */
public string $guid = '';
/** @var string The link to the original content */
public string $link = '';
/** @var string When this item was published */
public string $publishedOn = '';
/** @var ?string When this item was last updated */
public ?string $updatedOn = null;
/** @var string The content for the item */
public string $content = '';
/**
* Construct a feed item from an Atom feed's `<entry>` tag
*
* @param DOMNode $node The XML node from which a feed item should be constructed
* @return FeedItem A feed item constructed from the given node
*/
public static function fromAtom(DOMNode $node): FeedItem {
$guid = Feed::atomValue($node, 'id');
$link = '';
foreach ($node->getElementsByTagName('link') as $linkElt) {
if ($linkElt->hasAttributes()) {
$relAttr = $linkElt->attributes->getNamedItem('rel');
if ($relAttr && $relAttr->value == 'alternate') {
$link = $linkElt->attributes->getNamedItem('href')->value;
break;
}
}
}
if ($link == '' && str_starts_with($guid, 'http')) $link = $guid;
$item = new FeedItem();
$item->guid = $guid;
$item->title = Feed::atomValue($node, 'title');
$item->link = $link;
$item->publishedOn = Data::formatDate(Feed::atomValue($node, 'published'));
$item->updatedOn = Data::formatDate(Feed::atomValue($node, 'updated'));
$item->content = Feed::atomValue($node, 'content');
return $item;
}
/**
* Construct a feed item from an RSS feed's `<item>` tag
*
* @param DOMNode $node The XML node from which a feed item should be constructed
* @return FeedItem A feed item constructed from the given node
*/
public static function fromRSS(DOMNode $node): FeedItem {
$itemGuid = Feed::rssValue($node, 'guid');
$updNodes = $node->getElementsByTagNameNS(Feed::ATOM_NS, 'updated');
$encNodes = $node->getElementsByTagNameNS(Feed::CONTENT_NS, 'encoded');
$item = new FeedItem();
$item->guid = $itemGuid == 'guid not found' ? Feed::rssValue($node, 'link') : $itemGuid;
$item->title = Feed::rssValue($node, 'title');
$item->link = Feed::rssValue($node, 'link');
$item->publishedOn = Data::formatDate(Feed::rssValue($node, 'pubDate'));
$item->updatedOn = Data::formatDate($updNodes->length > 0 ? $updNodes->item(0)->textContent : null);
$item->content = $encNodes->length > 0
? $encNodes->item(0)->textContent
: Feed::rssValue($node, 'description');
return $item;
}
}

View File

@@ -2,10 +2,10 @@
class Key { class Key {
/** @var string The $_REQUEST key for teh current user's e-mail address */ /** @var string The $_SESSION key for the current user's e-mail address */
public const string USER_EMAIL = 'FRC_USER_EMAIL'; public const string USER_EMAIL = 'FRC_USER_EMAIL';
/** @var string The $_REQUEST key for the current user's ID */ /** @var string The $_SESSION key for the current user's ID */
public const string USER_ID = 'FRC_USER_ID'; public const string USER_ID = '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 */

View File

@@ -14,38 +14,134 @@ class Security {
/** @var int Require users to provide e-mail address and password */ /** @var int Require users to provide e-mail address and password */
public const int MULTI_USER = 2; public const int MULTI_USER = 2;
/** @var string The e-mail address for the single user */
public const string SINGLE_USER_EMAIL = 'solouser@example.com';
/** @var string The password for the single user with no password */
public const string SINGLE_USER_PASSWORD = 'no-password-required';
/** @var string The password algorithm to use for our passwords */
public const string PW_ALGORITHM = PASSWORD_DEFAULT;
/** /**
* Verify that user is logged on * Find a user by their ID
* @param bool $redirectIfAnonymous Whether to redirect the request if there is no user logged on *
* @param string $email The e-mail address of the user to retrieve
* @param SQLite3 $db The data connection to use to retrieve the user
* @return array|false The user information, or null if the user is not found
*/ */
public static function verifyUser(bool $redirectIfAnonymous = true): void { public static function findUserByEmail(string $email, SQLite3 $db): array|false {
switch (SECURITY_MODEL) { $query = $db->prepare('SELECT * FROM frc_user WHERE email = :email');
case self::SINGLE_USER: $query->bindValue(':email', $email);
$user = self::retrieveSingleUser(); $result = $query->execute();
break; return $result ? $result->fetchArray(SQLITE3_ASSOC) : false;
case self::SINGLE_USER_WITH_PASSWORD:
die('Single User w/ Password has not yet been implemented');
case self::MULTI_USER:
die('Multi-User Mode has not yet been implemented');
default:
die('Unrecognized security model (' . SECURITY_MODEL . ')');
}
if (!$user && $redirectIfAnonymous) {
header('/logon?returnTo=' . $_SERVER['REQUEST_URI'], true, HTTP_REDIRECT_TEMP);
die();
}
$_REQUEST[Key::USER_ID] = $user['id'];
$_REQUEST[Key::USER_EMAIL] = $user['email'];
} }
/** /**
* Retrieve the single user * Add a user
* @return array The user information for the single user *
* @param string $email The e-mail address for the user
* @param string $password The user's password
* @param SQLite3 $db The data connection to use to add the user
*/ */
private static function retrieveSingleUser(): array { public static function addUser(string $email, string $password, SQLite3 $db): void {
$user = Data::findUserByEmail('solouser@example.com'); $query = $db->prepare('INSERT INTO frc_user (email, password) VALUES (:email, :password)');
if ($user) return $user; $query->bindValue(':email', $email);
Data::addUser('solouser@example.com', 'no-password-required'); $query->bindValue(':password', password_hash($password, self::PW_ALGORITHM));
return Data::findUserByEmail('solouser@example.com'); $query->execute();
}
/**
* Verify a user's password
*
* @param array $user The user information retrieved from the database
* @param string $password The password provided by the user
* @param string|null $returnTo The URL to which the user should be redirected
* @param SQLite3 $db The database connection to use to verify the user's credentials
*/
private static function verifyPassword(array $user, string $password, ?string $returnTo, SQLite3 $db): void {
if (password_verify($password, $user['password'])) {
if (password_needs_rehash($user['password'], self::PW_ALGORITHM)) {
$rehash = $db->prepare('UPDATE frc_user SET password = :hash WHERE id = :id');
$rehash->bindValue(':hash', password_hash($password, self::PW_ALGORITHM));
$rehash->bindValue(':id', $user['id']);
$rehash->execute();
}
$_SESSION[Key::USER_ID] = $user['id'];
$_SESSION[Key::USER_EMAIL] = $user['email'];
frc_redirect($returnTo ?? '/');
}
}
/**
* Log on a user with e-mail address and password
*
* @param string $email The e-mail address for the user (cannot be the single-user mode user)
* @param string $password The password provided by the user
* @param string|null $returnTo The URL to which the user should be redirected
* @param SQLite3 $db The database connection to use to verify the user's credentials
*/
public static function logOnUser(string $email, string $password, ?string $returnTo, SQLite3 $db): void {
if (SECURITY_MODEL == self::SINGLE_USER_WITH_PASSWORD) {
$dbEmail = self::SINGLE_USER_EMAIL;
} else {
if ($email == self::SINGLE_USER_EMAIL) {
add_error('Invalid credentials; log on unsuccessful');
return;
}
$dbEmail = $email;
}
$user = self::findUserByEmail($dbEmail, $db);
if ($user) self::verifyPassword($user, $password, $returnTo, $db);
add_error('Invalid credentials; log on unsuccessful');
}
/**
* Update the password for the given user
*
* @param string $email The e-mail address of the user whose password should be updated
* @param string $password The new password for this user
* @param SQLite3 $db The database connection to use in updating the password
*/
public static function updatePassword(string $email, string $password, SQLite3 $db): void {
$query = $db->prepare('UPDATE frc_user SET password = :password WHERE email = :email');
$query->bindValue(':password', password_hash($password, self::PW_ALGORITHM));
$query->bindValue(':email', $email);
$query->execute();
}
/**
* Log on the single user
*
* @param SQLite3 $db The data connection to use to retrieve the user
*/
private static function logOnSingleUser(SQLite3 $db): void {
$user = self::findUserByEmail(self::SINGLE_USER_EMAIL, $db);
if (!$user) {
self::addUser(self::SINGLE_USER_EMAIL, self::SINGLE_USER_PASSWORD, $db);
$user = self::findUserByEmail(self::SINGLE_USER_EMAIL, $db);
}
self::verifyPassword($user, self::SINGLE_USER_PASSWORD, $_GET['returnTo'], $db);
}
/**
* Verify that user is logged on
*
* @param SQLite3 $db The data connection to use if required
* @param bool $redirectIfAnonymous Whether to redirect the request if there is no user logged on
*/
public static function verifyUser(SQLite3 $db, bool $redirectIfAnonymous = true): void {
if (array_key_exists(Key::USER_ID, $_SESSION)) return;
if (SECURITY_MODEL == self::SINGLE_USER) self::logOnSingleUser($db);
if (SECURITY_MODEL != self::SINGLE_USER_WITH_PASSWORD && SECURITY_MODEL != self::MULTI_USER) {
die('Unrecognized security model (' . SECURITY_MODEL . ')');
}
if ($redirectIfAnonymous) {
header("Location: /user/log-on?returnTo={$_SERVER['REQUEST_URI']}", true, 307);
die();
}
} }
} }

1
src/public/assets/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -30,6 +30,12 @@ header {
font-size: 1.5rem; font-size: 1.5rem;
} }
.version {
font-size: .85rem;
padding-left: .5rem;
color: rgba(255, 255, 255, .75);
}
a:link, a:visited { a:link, a:visited {
color: white; color: white;
} }
@@ -37,6 +43,38 @@ header {
main { main {
padding: 0 .5rem; padding: 0 .5rem;
.refresh, .loading {
font-style: italic;
font-size: .9rem;
}
.htmx-request .refresh {
display: none;
}
.loading {
display: none;
}
.htmx-request .loading {
display: inline;
}
.user_messages {
display: flex;
flex-flow: column;
justify-content: center;
}
.user_message {
margin: .25rem auto;
border: solid 1px navy;
border-radius: .5rem;
background-color: rgba(255, 255, 255, .75);
padding: .25rem;
}
.user_messages + h1 {
margin-top: .25rem;
}
.item_heading { .item_heading {
margin-bottom: 0; margin-bottom: 0;
} }
@@ -55,10 +93,24 @@ article {
border-radius: .5rem; border-radius: .5rem;
background-color: white; background-color: white;
padding: .5rem; padding: .5rem;
img {
max-width: 100%;
object-fit: contain;
height: unset;
width: unset;
}
} }
} }
input[type=url], input[type=text] { article.docs {
width: 50%; line-height: 1.4rem;
}
input[type=url],
input[type=text],
input[type=email],
input[type=password] {
width: 40%;
font-size: 1rem; font-size: 1rem;
padding: .25rem; padding: .25rem;
border-radius: .25rem; border-radius: .25rem;
@@ -87,3 +139,9 @@ button:hover,
flex-flow: row nowrap; flex-flow: row nowrap;
justify-content: space-evenly; justify-content: space-evenly;
} }
code {
font-size: .9rem;
}
p.back-link {
margin-top: -1rem;
}

16
src/public/docs/index.php Normal file
View File

@@ -0,0 +1,16 @@
<?php
include '../../start.php';
$db = Data::getConnection();
Security::verifyUser($db, redirectIfAnonymous: false);
page_head('Documentation'); ?>
<h1>Documentation Home</h1>
<article>
<p><a href=./the-cli>About the CLI</a> provides orientation on Feed Reader Central&rsquo;s command line interface
<p><a href=./security-modes>Configuring Security Modes</a> describes the three security modes and how to manage each
of them
<p><a href=./refresh-feeds>Refresh Feeds</a> has instructions on how feeds can be refreshed on a schedule
</article><?php
page_foot();
$db->close();

View File

@@ -0,0 +1,52 @@
<?php
include '../../start.php';
$db = Data::getConnection();
Security::verifyUser($db, redirectIfAnonymous: false);
page_head('Refresh Feeds | Documentation'); ?>
<h1>Refresh Feeds</h1>
<p class=back-link><a href=./>&lang;&lang; Documentation Home</a>
<article class=docs>
<h2>Manual Feed Refresh</h2>
<p>Next to the &ldquo;Your Unread Items&rdquo; heading on the main page, there is a link labeled &ldquo;Refresh All
Feeds&rdquo;. Clicking this link will reload the main page once the feeds have been refreshed. Depending on the
number and size of feeds, this may take a bit of time; each feed is refreshed individually.
<h2>Automatic Refreshing</h2>
<p>The <code>refresh</code> utility script will perform this refresh from the CLI. As it runs, it will list the
feeds as it processes them, and if it encounters any errors, that is noted as well. This process can be
automated via <code>cron</code> on Linux or Mac systems. This is most easily implemented by writing a small
shell script to provide some environment settings, then telling <code>cron</code> to run that script.
<pre class=item_content>
#!/bin/bash
exec 1> >(logger -t feed-reader-central) 2>&1
cd /path/to/frc
php-cli util/refresh.php all</pre>
<p>Save this (<code>frc-refresh.sh</code> might be a good name) and be sure it is executable
(<code>chmod +x ./frc-refresh.sh</code>). Before we put it in crontab, though, let&rsquo;s understand what each
line does:
<ul>
<li>Line 1 tells the operating system to use the <code>bash</code> shell.
<li>Line 2 directs all output to the system log (<code>/var/log/syslog</code>), labeling each entry with
<code>feed-reader-central</code>. This lets you review the output for its runs in a log that is already
maintained and rotated by the operating system.
<li>Line 3 changes the current directory to the one where Feed Reader Central is installed; modify it for where
you have installed it. Since we are setting up for a <a href=./the-cli>CLI execution</a>, this should place
us one directory up from <code>/public</code>.
<li>Line 4 executes the refresh script.
</ul>
<p>Finally, we are ready to add this to our crontab. Enter <code>crontab -e</code> to edit the file, then add a row
at the bottom that looks like this:
<pre class=item_content>
0 */6 * * * /path/to/job/frc-refresh.sh</pre>
<p>The items before the path specify when it should run. This example will run at the top of the hour every six
hours. Crontab schedules can be tricky to create; a full treatment is outside the scope of this documentation.
However, <a href=https://crontab.guru/#0_*/6_*_*_* target=_blank rel=noopener title="Crontab.guru">this site</a>
lets you put values in each position and it translates that to words; this lets you see if what you put is what
you meant.
<p>This should not require many resources; the majority of its time will be spent waiting for the websites to return
their feeds so it can process them. However, if you want it to yield to everything else happening on the server,
add <code>nice -n 1</code> (with a trailing space) before the path to the script.
</article><?php
page_foot();
$db->close();

View File

@@ -0,0 +1,62 @@
<?php
include '../../start.php';
$db = Data::getConnection();
Security::verifyUser($db, redirectIfAnonymous: false);
page_head('Security Modes | Documentation'); ?>
<h1>Configuring Security Modes</h1>
<p class=back-link><a href=./>&lang;&lang; Documentation Home</a>
<article class=docs>
<h2>Security Modes</h2>
<p><strong>Single-User</strong> mode assumes that every connection to the application is the same person. It is
designed for one person to use on a trusted internal network; under no circumstances should an instance
configured this way be reachable from the Internet. However, it is a low-friction way to keep up with feeds from
multiple devices on a home network.
<p><strong>Single-User with Password</strong> mode operates the same way as Single-User mode does, but the
application will require a password. Depending on the strength of the password, this model may be appropriate
for Internet access, but its intent is more for keeping other internal network users from accessing the site
and reading the items before its intended user is able to do so. The password should be set using the CLI.
<p><strong>Multi-User</strong> mode requires both an e-mail address and password before allowing the user to
proceed. It is the most appropriate configuration for an Internet-facing instance, and it can also be used to
provide access to multiple users on an internal network. Managing users is performed via the CLI.
<h2 id=manage-users>Managing Users in Multi-User Mode</h2>
<p>Users can be added or deleted, and passwords set, using the <code>user</code> CLI utility.<br><br>
<em>(For all the &ldquo;password&rdquo; parameters, if a character in the password conflicts with a shell escape
character, enclose the password in double-quotes for *sh or single-quotes for PowerShell.)</em>
<h3>Add a User</h3>
<p><code>php-cli utils/user.php add-user alice@example.com AlicesSecur3P4ssword</code>
<p>The utility should respond with the e-mail address and password that were added. If a user with that e-mail
address already exists, the utility will not add it again.
<h3>Set a User&rsquo;s Password</h3>
<p><code>php-cli utils/user.php set-password bob@example.com AN3wPassCauseB0bForg0t1t</code>
<h3>Delete a User</h3>
<p><code>php-cli utils/user.php delete-user carol@example.com</code>
<p>The utility will require confirmation that the user and their feeds should be deleted. Any input that starts with
the letter &ldquo;y&rdquo; will confirm, and any other input will cancel the process.
<h2 id=change-to-multi>Changing from Single-User to Multi-User Mode</h2>
<p>In Single-User mode, the application uses a known e-mail address and password to mimic multi-user mode where that
user is always logged on. If you have been using the application this way, and decide that you want to run in
multi-user mode instead, you will need to update <code>SECURITY_MODEL</code> in <code>user-config.php</code> to
<code>Security::MULTI_USER</code>.
<p>The e-mail address used for Single-User mode is not allowed to log on in Multi-User mode. If you want to preserve
the feeds defined by the single user, use the CLI to replace its e-mail address and password.
<p><code>php-cli utils/user.php migrate-single-user dave@example.com Dav3sPas$wort</code>
<p>If, however, you do not wish to maintain the single user&rsquo;s information at all, delete it.
<p><code>php-cli utils/user.php remove-single-user</code>
<h2 id=change-multi-to-single>Changing from Multi-User to any Single-User Mode</h2>
<p>This scenario is possible, but not really advisable. When the application is in any Single-User mode, it only
displays feeds from the Single-User mode user. The information for the other users remains in the database,
though, so this change is not destructive.
<h2 id=change-single-to-pw>Changing from Single-User to Single-User with Password Mode</h2>
<p>Set <code>SECURITY_MODEL</code> in <code>user-config.php</code> to
<code>Security::SINGLE_USER_WITH_PASSWORD</code>, then use the <code>user</code> CLI utility to set a password.
<p><code>php-cli util/user.php set-single-password aNiceC0mplexPassw0rd</code>
<h2 id=change-pw-to-single>Changing from Single-User with Password to Single-User Mode</h2>
<p>If you decide you do not want to enter a password, but want to maintain single-user mode, set
<code>SECURITY_MODEL</code> in <code>user-config.php</code> to <code>Security::SINGLE_USER</code>, then run the
<code>user</code> CLI utility to reset the single user back to its expected default.
<p><code>php-cli util/user.php reset-single-password</code>
</article><?php
page_foot();
$db->close();

View File

@@ -0,0 +1,24 @@
<?php
include '../../start.php';
$db = Data::getConnection();
Security::verifyUser($db, redirectIfAnonymous: false);
page_head('About the CLI | Documentation'); ?>
<h1>About the CLI</h1>
<p class=back-link><a href=./>&lang;&lang; Documentation Home</a>
<article class=docs>
<p>Feed Reader Central&rsquo;s low-friction design includes having many administrative tasks run in a terminal or
shell. &ldquo;CLI&rdquo; is short for &ldquo;Command Line Interface&rdquo;, and refers to commands that are run
via PHP&rsquo;s <code>php-cli</code> command. Ensure that the version of PHP installed also has that feature
enabled. (If you are using the recommended
<a href=https://frankenphp.dev target=_blank rel=noopener>FrankenPHP</a> environment, it has PHP CLI support;
use <code>frankenphp php-cli</code> instead of <code>php-cli</code> when following instructions here.)
<h2>Running CLI Commands</h2>
<p>CLI commands should be run from the same directory with <code>start.php</code>; this will be one directory level
up from <code>/public</code>, the web root directory. CLI utilities are in the <code>/util</code> directory, so
an invocation will follow the pattern:
<p><code>php-cli util/some-process.php command option1 option2</code>
</article><?php
page_foot();
$db->close();

View File

@@ -7,10 +7,10 @@
include '../start.php'; include '../start.php';
Security::verifyUser(); $db = Data::getConnection();
Security::verifyUser($db);
$feedId = array_key_exists('id', $_GET) ? $_GET['id'] : ''; $feedId = $_GET['id'] ?? '';
$db = Data::getConnection();
if ($_SERVER['REQUEST_METHOD'] == 'POST') { if ($_SERVER['REQUEST_METHOD'] == 'POST') {
$isNew = $_POST['id'] == 'new'; $isNew = $_POST['id'] == 'new';
@@ -31,7 +31,7 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
if ($feedId == 'new') { if ($feedId == 'new') {
$title = 'Add RSS Feed'; $title = 'Add RSS Feed';
$feed = [ 'id' => $_GET['id'], 'url' => '' ]; $feed = [ 'id' => $_GET['id'], 'url' => ''];
} else { } else {
$title = 'Edit RSS Feed'; $title = 'Edit RSS Feed';
if ($feedId == 'error') { if ($feedId == 'error') {

View File

@@ -7,9 +7,8 @@
include '../start.php'; include '../start.php';
Security::verifyUser();
$db = Data::getConnection(); $db = Data::getConnection();
Security::verifyUser($db);
if (array_key_exists('refresh', $_GET)) { if (array_key_exists('refresh', $_GET)) {
$refreshResult = Feed::refreshAll($db); $refreshResult = Feed::refreshAll($db);
@@ -20,23 +19,30 @@ if (array_key_exists('refresh', $_GET)) {
} }
} }
$result = $db->query(<<<'SQL' $query = $db->prepare(<<<'SQL'
SELECT item.id, item.title AS item_title, coalesce(item.updated_on, item.published_on) AS as_of, SELECT item.id, item.title AS item_title, coalesce(item.updated_on, item.published_on) AS as_of,
feed.title AS feed_title feed.title AS feed_title
FROM item FROM item
INNER JOIN feed ON feed.id = item.feed_id INNER JOIN feed ON feed.id = item.feed_id
WHERE item.is_read = 0 WHERE feed.user_id = :userId
AND item.is_read = 0
ORDER BY coalesce(item.updated_on, item.published_on) DESC ORDER BY coalesce(item.updated_on, item.published_on) DESC
SQL); SQL);
$query->bindValue(':userId', $_SESSION[Key::USER_ID]);
$result = $query->execute();
$item = $result ? $result->fetchArray(SQLITE3_ASSOC) : false; $item = $result ? $result->fetchArray(SQLITE3_ASSOC) : false;
page_head('Welcome'); ?> page_head('Your Unread Items'); ?>
<h1>Your Unread Items &nbsp; <a href=/?refresh><small><small><em>(Refresh All Feeds)</em></small></small></a></h1> <h1>
Your Unread Items &nbsp;
<a class=refresh href=/?refresh hx-get=/?refresh hx-indicator="closest h1">(Refresh All Feeds)</a>
<span class=loading>Refreshing&hellip;</span>
</h1>
<article><?php <article><?php
if ($item) { if ($item) {
while ($item) { ?> while ($item) { ?>
<p><a href=/item?id=<?=$item['id']?>><?=$item['item_title']?></a><br> <p><a href=/item?id=<?=$item['id']?> hx-get=/item?id=<?=$item['id']?>><?=strip_tags($item['item_title'])?></a>
<?=$item['feed_title']?><br><small><em><?=date_time($item['as_of'])?></em></small><?php <br><?=htmlentities($item['feed_title'])?><br><small><em><?=date_time($item['as_of'])?></em></small><?php
$item = $result->fetchArray(SQLITE3_ASSOC); $item = $result->fetchArray(SQLITE3_ASSOC);
} }
} else { ?> } else { ?>

View File

@@ -8,9 +8,8 @@
include '../start.php'; include '../start.php';
Security::verifyUser();
$db = Data::getConnection(); $db = Data::getConnection();
Security::verifyUser($db);
if ($_SERVER['REQUEST_METHOD'] == 'POST') { if ($_SERVER['REQUEST_METHOD'] == 'POST') {
// "Keep as New" button sends a POST request to reset the is_read flag before going back to the list of unread items // "Keep as New" button sends a POST request to reset the is_read flag before going back to the list of unread items
@@ -20,7 +19,7 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
WHERE item.id = :id AND feed.user_id = :user WHERE item.id = :id AND feed.user_id = :user
SQL); SQL);
$isValidQuery->bindValue(':id', $_POST['id']); $isValidQuery->bindValue(':id', $_POST['id']);
$isValidQuery->bindValue(':user', $_REQUEST[Key::USER_ID]); $isValidQuery->bindValue(':user', $_SESSION[Key::USER_ID]);
$isValidResult = $isValidQuery->execute(); $isValidResult = $isValidQuery->execute();
if ($isValidResult && $isValidResult->fetchArray(SQLITE3_NUM)[0] == 1) { if ($isValidResult && $isValidResult->fetchArray(SQLITE3_NUM)[0] == 1) {
$keepUnread = $db->prepare('UPDATE item SET is_read = 0 WHERE id = :id'); $keepUnread = $db->prepare('UPDATE item SET is_read = 0 WHERE id = :id');
@@ -31,15 +30,35 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
frc_redirect('/'); frc_redirect('/');
} }
if ($_SERVER['REQUEST_METHOD'] == 'DELETE') {
$deleteQuery = $db->prepare(<<<'SQL'
DELETE FROM item
WHERE id IN (
SELECT item.id
FROM item INNER JOIN feed ON feed.id = item.feed_id
WHERE item.id = :id
AND feed.user_id = :user)
SQL);
$deleteQuery->bindValue(':id', $_GET['id']);
$deleteQuery->bindValue(':user', $_SESSION[Key::USER_ID]);
if ($deleteQuery->execute()) {
add_info('Item deleted');
} else {
add_error(Data::error($db)['error']);
}
$db->close();
frc_redirect('/');
}
$query = $db->prepare(<<<'SQL' $query = $db->prepare(<<<'SQL'
SELECT item.title AS item_title, item.item_link, item.published_on, item.updated_on, item.content, item.is_encoded, SELECT item.title AS item_title, item.item_link, item.published_on, item.updated_on, item.content,
feed.title AS feed_title feed.title AS feed_title
FROM item INNER JOIN feed ON feed.id = item.feed_id FROM item INNER JOIN feed ON feed.id = item.feed_id
WHERE item.id = :id WHERE item.id = :id
AND feed.user_id = :user AND feed.user_id = :user
SQL); SQL);
$query->bindValue(':id', $_GET['id']); $query->bindValue(':id', $_GET['id']);
$query->bindValue(':user', $_REQUEST[Key::USER_ID]); $query->bindValue(':user', $_SESSION[Key::USER_ID]);
$result = $query->execute(); $result = $query->execute();
$item = $result ? $result->fetchArray(SQLITE3_ASSOC) : false; $item = $result ? $result->fetchArray(SQLITE3_ASSOC) : false;
@@ -54,7 +73,7 @@ $updated = isset($item['updated_on']) ? date_time($item['updated_on']) : null;
page_head(htmlentities("{$item['item_title']} | {$item['feed_title']}")); ?> page_head(htmlentities("{$item['item_title']} | {$item['feed_title']}")); ?>
<h1 class=item_heading> <h1 class=item_heading>
<a href="<?=$item['item_link']?>" target=_blank rel=noopener><?=$item['item_title']?></a><br> <a href="<?=$item['item_link']?>" target=_blank rel=noopener><?=strip_tags($item['item_title'])?></a><br>
</h1> </h1>
<div class=item_published> <div class=item_published>
From <strong><?=htmlentities($item['feed_title'])?></strong><br> From <strong><?=htmlentities($item['feed_title'])?></strong><br>
@@ -62,10 +81,11 @@ page_head(htmlentities("{$item['item_title']} | {$item['feed_title']}")); ?>
</div> </div>
<article> <article>
<div class=item_content><?=str_replace('<a ', '<a target=_blank rel=noopener ', $item['content'])?></div> <div class=item_content><?=str_replace('<a ', '<a target=_blank rel=noopener ', $item['content'])?></div>
<form class=action_buttons action=/item method=POST> <form class=action_buttons action=/item method=POST hx-post=/item>
<input type=hidden name=id value=<?=$_GET['id']?>> <input type=hidden name=id value=<?=$_GET['id']?>>
<a href="/">Done</a> <a href=/ hx-get="/">Done</a>
<button type=submit>Keep as New</button> <button type=submit>Keep as New</button>
<button type=button hx-delete=/item>Delete</button>
</form> </form>
</article><?php </article><?php
page_foot(); page_foot();

View File

@@ -0,0 +1,10 @@
<?php
/**
* User Log Off Page
*/
include '../../start.php';
if (array_key_exists(Key::USER_ID, $_SESSION)) session_destroy();
frc_redirect('/');

View File

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

View File

@@ -1,18 +1,13 @@
<?php <?php
use JetBrains\PhpStorm\NoReturn; use JetBrains\PhpStorm\NoReturn;
spl_autoload_register(function ($class) { require 'app-config.php';
$file = implode(DIRECTORY_SEPARATOR, [__DIR__, 'lib', "$class.php"]);
if (file_exists($file)) {
require $file;
return true;
}
return false;
});
require 'user-config.php'; session_start([
'name' => 'FRCSESSION',
Data::ensureDb(); 'use_strict_mode' => true,
'cookie_httponly' => true,
'cookie_samesite' => 'Strict']);
/** /**
* Add a message to be displayed at the top of the page * Add a message to be displayed at the top of the page
@@ -21,8 +16,8 @@ Data::ensureDb();
* @param string $message The message itself * @param string $message The message itself
*/ */
function add_message(string $level, string $message): void { function add_message(string $level, string $message): void {
if (!array_key_exists(Key::USER_MSG, $_REQUEST)) $_REQUEST[Key::USER_MSG] = array(); if (!array_key_exists(Key::USER_MSG, $_SESSION)) $_SESSION[Key::USER_MSG] = array();
$_REQUEST[Key::USER_MSG][] = ['level' => $level, 'message' => $message]; $_SESSION[Key::USER_MSG][] = ['level' => $level, 'message' => $message];
} }
/** /**
@@ -43,44 +38,76 @@ 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 */
$is_htmx = array_key_exists('HTTP_HX_REQUEST', $_SERVER)
&& !array_key_exists('HTTP_HX_HISTORY_RESTORE_REQUEST', $_SERVER);
/** /**
* Render the page title * Render the page title
* @param string $title The title of the page being displayed * @param string $title The title of the page being displayed
*/ */
function page_head(string $title): void { function page_head(string $title): void {
?><!DOCTYPE html> global $is_htmx;
$version = match (true) {
str_ends_with(FRC_VERSION, '.0.0') => substr(FRC_VERSION, 0, strlen(FRC_VERSION) - 4),
str_ends_with(FRC_VERSION, '.0') => substr(FRC_VERSION, 0, strlen(FRC_VERSION) - 2),
default => FRC_VERSION
};
//if ($is_htmx) header('HX-Push-Url: true');
?>
<!DOCTYPE html>
<html lang=en> <html lang=en>
<head> <head>
<meta name=viewport content="width=device-width, initial-scale=1"> <title><?=$title?> | Feed Reader Central</title><?php
<title><?=$title?> | Feed Reader Central</title> if (!$is_htmx) { ?>
<link href=/assets/style.css rel=stylesheet> <meta name=viewport content="width=device-width, initial-scale=1">
<link href=/assets/style.css rel=stylesheet><?php
} ?>
</head> </head>
<body> <body><?php
<header> if (!$is_htmx) { ?>
<a class=title href="/">Feed Reader Central</a> <header hx-target=#main hx-push-url=true>
<div><?php <div><a class=title href=/ hx-get="/">Feed Reader Central</a><span class=version>v<?=$version?></span></div>
if (array_key_exists(Key::USER_ID, $_REQUEST)) { <div><?php
echo '<a href=/feed?id=new>Add Feed</a>'; if (array_key_exists(Key::USER_ID, $_SESSION)) { ?>
if ($_REQUEST[Key::USER_EMAIL] != 'solouser@example.com') echo " | {$_REQUEST[Key::USER_EMAIL]}"; <a href=/feed?id=new hx-get=/feed?id=new>Add Feed</a> |
} ?> <a href=/docs/ hx-get=/docs/>Docs</a> |
</div> <a href=/user/log-off hx-get=/user/log-off>Log Off</a><?php
</header> if ($_SESSION[Key::USER_EMAIL] != Security::SINGLE_USER_EMAIL) { ?>
<main hx-target=this><?php | <?=$_SESSION[Key::USER_EMAIL]?><?php
if (array_key_exists(Key::USER_MSG, $_REQUEST)) { }
foreach ($_REQUEST[Key::USER_MSG] as $msg) { ?> } else { ?>
<div> <a href=/user/log-on hx-get=/user/log-on>Log On</a> | <a href=/docs/ hx-get=/docs/>Docs</a><?php
<?=$msg['level'] == 'INFO' ? '' : "<strong>{$msg['level']}</strong><br>"?> } ?>
<?=$msg['message']?> </div>
</div><?php </header>
} <main id=main hx-target=this hx-push-url=true hx-swap="innerHTML show:window:top"><?php
} }
if (sizeof($messages = $_SESSION[Key::USER_MSG] ?? []) > 0) { ?>
<div class=user_messages><?php
array_walk($messages, function ($msg) { ?>
<div class=user_message>
<?=$msg['level'] == 'INFO' ? '' : "<strong>{$msg['level']}</strong><br>"?>
<?=$msg['message']?>
</div><?php
}); ?>
</div><?php
$_SESSION[Key::USER_MSG] = [];
}
} }
/** /**
* Render the end of the page * Render the end of the page
*/ */
function page_foot(): void { function page_foot(): void {
?></main></body></html><?php global $is_htmx; ?>
</main><?php
if (!$is_htmx) { ?>
<script src=/assets/htmx.min.js></script><?php
} ?>
</body>
</html><?php
session_commit();
} }
/** /**
@@ -94,8 +121,8 @@ function frc_redirect(string $value): void {
http_response_code(400); http_response_code(400);
die(); die();
} }
header("Location: $value"); session_commit();
http_response_code(303); header("Location: $value", true, 303);
die(); die();
} }

View File

@@ -3,6 +3,8 @@
* USER CONFIGURATION ITEMS * USER CONFIGURATION ITEMS
* *
* Editing the values here customizes the behavior of Feed Reader Central * Editing the values here customizes the behavior of Feed Reader Central
*
* On initial installation, rename this file to user-config.php and configure it as desired
*/ */
@@ -11,10 +13,8 @@
* - Security::SINGLE_USER (no e-mail required, does not require a password) * - Security::SINGLE_USER (no e-mail required, does not require a password)
* - Security::SINGLE_USER_WITH_PASSWORD (no e-mail required, does require a password) * - Security::SINGLE_USER_WITH_PASSWORD (no e-mail required, does require a password)
* - Security::MULTI_USER (e-mail and password required for all users) * - Security::MULTI_USER (e-mail and password required for all users)
*
* (NOTE THAT ONLY SINGLE_USER IS CURRENTLY IMPLEMENTED)
*/ */
const SECURITY_MODEL = Security::SINGLE_USER; const SECURITY_MODEL = 'CONFIGURE_ME';
/** 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';
@@ -26,5 +26,16 @@ const DATABASE_NAME = 'frc.db';
*/ */
const DATE_TIME_FORMAT = 'F j, Y \a\t g:ia'; const DATE_TIME_FORMAT = 'F j, Y \a\t g:ia';
// END USER CONFIGURATION ITEMS /**
// (editing below this line is not advised) * How should item purging be done? (Purging never applies to bookmarked items.) Options are:
* - Feed::PURGE_NONE - Do not purge items
* - Feed::PURGE_READ - 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::PURGE_BY_COUNT - Purge read and unread items beyond the number to keep (PURGE_NUMBER below)
*/
const PURGE_TYPE = Feed::PURGE_BY_DAYS;
/**
* For purge-by-days, how many days of items should be kept; for purge-by-count, how many items should be kept
*/
const PURGE_NUMBER = 30;

53
src/util/refresh.php Normal file
View File

@@ -0,0 +1,53 @@
<?php
use JetBrains\PhpStorm\NoReturn;
require __DIR__ . '/../cli-start.php';
cli_title('FEED REFRESH');
if ($argc < 2) display_help();
switch ($argv[1]) {
case 'all':
refresh_all();
break;
default:
printfn('Unrecognized option "%s"', $argv[1]);
display_help();
}
/**
* Display the options for this utility and exit
*/
#[NoReturn]
function display_help(): void {
printfn('Options:');
printfn(' - all');
printfn(' Refreshes all feeds');
exit(0);
}
function refresh_all(): void {
$db = Data::getConnection();
try {
$feeds = Feed::retrieveAll($db);
if (array_key_exists('error', $feeds)) {
printfn('SQLite error: %s', $feeds['error']);
return;
}
array_walk($feeds, function ($feed) use ($db) {
$result = Feed::refreshFeed($feed['id'], $feed['url'], $db);
if (array_key_exists('error', $result)) {
printfn('ERR (%s) %s', $feed['email'], $feed['url']);
printfn(' %s', $result['error']);
} else {
printfn('OK (%s) %s', $feed['email'], $feed['url']);
}
});
printfn(PHP_EOL . 'All feeds refreshed');
} finally {
$db->close();
}
}

219
src/util/user.php Normal file
View File

@@ -0,0 +1,219 @@
<?php
use JetBrains\PhpStorm\NoReturn;
require __DIR__ . '/../cli-start.php';
cli_title('USER MAINTENANCE');
if ($argc < 2) display_help();
switch ($argv[1]) {
case 'add-user':
if ($argc < 4) {
printfn('Missing parameters: add-user requires e-mail and password');
exit(-1);
}
add_user();
break;
case 'set-password':
if ($argc < 4) {
printfn('Missing parameters: set-password requires e-mail and password');
exit(-1);
}
set_password($argv[2], $argv[3]);
break;
case 'delete-user':
if ($argc < 3) {
printfn('Missing parameters: delete-user requires e-mail address');
exit(-1);
}
delete_user($argv[2]);
break;
case 'set-single-password':
if ($argc < 3) {
printfn('Missing parameters: set-single-password requires a new password');
exit(-1);
}
set_password(Security::SINGLE_USER_EMAIL, $argv[2]);
break;
case 'reset-single-password':
set_password(Security::SINGLE_USER_EMAIL, Security::SINGLE_USER_PASSWORD);
break;
case 'migrate-single-user':
if ($argc < 4) {
printfn('Missing parameters: migrate-single-user requires e-mail and password');
exit(-1);
}
migrate_single_user();
break;
case 'remove-single-user':
delete_user(Security::SINGLE_USER_EMAIL);
break;
default:
printfn('Unrecognized option "%s"', $argv[1]);
display_help();
}
/**
* Display the options for this utility and exit
*/
#[NoReturn]
function display_help(): void {
printfn('Options:');
printfn(' - add-user [e-mail] [password]');
printfn(' Adds a new user to this instance');
printfn(' - set-password [e-mail] [password]');
printfn(' Sets the password for the given user');
printfn(' - delete-user [e-mail]');
printfn(' Deletes a user and all their data' . PHP_EOL);
printfn('To assist with changing from single-user to single-user-with-password mode:');
printfn(' - set-single-password [password]');
printfn(' Sets the password for the single-user mode user');
printfn(' - reset-single-password');
printfn(' Resets the single-user mode user\'s password to its default' . PHP_EOL);
printfn('To assist with changing from single-user to multi-user mode:');
printfn(' - migrate-single-user [e-mail] [password]');
printfn(' Changes the e-mail address and password for the single-user mode user');
printfn(' - remove-single-user');
printfn(' Removes the single-user mode user and its data');
exit(0);
}
/**
* Add a new user
*/
function add_user(): void {
global $argv;
$db = Data::getConnection();
try {
// Ensure there is not already a user with this e-mail address
$user = Security::findUserByEmail($argv[2], $db);
if ($user) {
printfn('A user with e-mail address "%s" already exists', $argv[2]);
return;
}
Security::addUser($argv[2], $argv[3], $db);
printfn('User "%s" with password "%s" added successfully', $argv[2], $argv[3]);
} finally {
$db->close();
}
}
/**
* Get the way we will refer to the user against whom action is being taken
*
* @param string $email The e-mail address of the user
* @return string The string to use when displaying results
*/
function display_user(string $email): string {
return $email == Security::SINGLE_USER_EMAIL ? 'single-user mode user' : "user \"$email\"";
}
/**
* Set a user's password
*/
function set_password(string $email, string $password): void {
$db = Data::getConnection();
try {
$displayUser = display_user($email);
// Ensure this user exists
$user = Security::findUserByEmail($email, $db);
if (!$user) {
printfn('No %s exists', $displayUser);
return;
}
Security::updatePassword($email, $password, $db);
$msg = $email == Security::SINGLE_USER_EMAIL && $password == Security::SINGLE_USER_PASSWORD
? 'reset' : sprintf('set to "%s"', $password);
printfn('%s password %s successfully', init_cap($displayUser), $msg);
} finally {
$db->close();
}
}
/**
* Delete a user
*
* @param string $email The e-mail address of the user to be deleted
*/
function delete_user(string $email): void {
$db = Data::getConnection();
try {
$displayUser = display_user($email);
// Get the ID for the provided e-mail address
$user = Security::findUserByEmail($email, $db);
if (!$user) {
printfn('No %s exists', $displayUser);
return;
}
$feedCountQuery = $db->prepare('SELECT COUNT(*) FROM feed WHERE user_id = :user');
$feedCountQuery->bindValue(':user', $user['id']);
$feedCountResult = $feedCountQuery->execute();
if (!$feedCountResult) {
printfn('SQLite error: %s', $db->lastErrorMsg());
return;
}
$feedCount = $feedCountResult->fetchArray(SQLITE3_NUM);
$proceed = readline("Delete the $displayUser and their $feedCount[0] feed(s)? (y/N)" . PHP_EOL);
if (!$proceed || !str_starts_with(strtolower($proceed), 'y')) {
printfn('Deletion canceled');
return;
}
$itemDelete = $db->prepare('DELETE FROM item WHERE feed_id IN (SELECT id FROM feed WHERE user_id = :user)');
$itemDelete->bindValue(':user', $user['id']);
$itemDelete->execute();
$feedDelete = $db->prepare('DELETE FROM feed WHERE user_id = :user');
$feedDelete->bindValue(':user', $user['id']);
$feedDelete->execute();
$userDelete = $db->prepare('DELETE FROM frc_user WHERE id = :user');
$userDelete->bindValue(':user', $user['id']);
$userDelete->execute();
printfn('%s deleted successfully', init_cap($displayUser));
} finally {
$db->close();
}
}
/**
* Change the single-user mode user to a different e-mail address and password
*/
function migrate_single_user(): void {
global $argv;
$db = Data::getConnection();
try {
$single = Security::findUserByEmail(Security::SINGLE_USER_EMAIL, $db);
if (!$single) {
printfn('There is no single-user mode user to be migrated');
return;
}
$migrateQuery = $db->prepare('UPDATE frc_user SET email = :email, password = :password WHERE id = :id');
$migrateQuery->bindValue(':email', $argv[2]);
$migrateQuery->bindValue(':password', password_hash($argv[3], Security::PW_ALGORITHM));
$migrateQuery->bindValue(':id', $single['id']);
$migrateQuery->execute();
printfn('The single user has been moved to "%s", with password "%s"', $argv[2], $argv[3]);
} finally {
$db->close();
}
}