Add user maintenance CLI (#9)

- Add CLI infrastructure
- Add user to index page query
- Strip tags from title
- Move item parsing to FeedItem
This commit is contained in:
Daniel J. Summers 2024-04-27 13:01:57 -04:00
parent 7b21b86550
commit c1790b58fd
8 changed files with 318 additions and 76 deletions

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

@ -0,0 +1,17 @@
<?php
/** The current Feed Reader Central version */
const FRC_VERSION = '1.0.0-alpha4';
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();

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

@ -0,0 +1,26 @@
<?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 {
printfn("$title | Feed Reader Central v%s" . PHP_EOL, FRC_VERSION);
}

View File

@ -22,6 +22,61 @@ class FeedItem {
/** @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;
}
}
/**
@ -87,11 +142,11 @@ class Feed {
/**
* 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
* @return string The value of the element (or "[element] not found" if that element does not exist)
*/
private static function rssValue(DOMElement $element, string $tagName): string {
public static function rssValue(DOMNode $element, string $tagName): string {
$tags = $element->getElementsByTagName($tagName);
return $tags->length == 0 ? "$tagName not found" : $tags->item(0)->textContent;
}
@ -109,34 +164,19 @@ class Feed {
return ['error' => "Channel element not found ($channel->nodeType)"];
}
$feed = new Feed();
$feed->title = self::rssValue($channel, 'title');
$feed->url = $url;
// 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
$feed->updatedOn = self::rssValue($channel, 'lastBuildDate');
if ($feed->updatedOn == 'lastBuildDate not found') {
$feed->updatedOn = self::rssValue($channel, 'pubDate');
if ($feed->updatedOn == 'pubDate not found') $feed->updatedOn = null;
$updatedOn = self::rssValue($channel, 'lastBuildDate');
if ($updatedOn == 'lastBuildDate not found') {
$updatedOn = self::rssValue($channel, 'pubDate');
if ($updatedOn == 'pubDate not found') $updatedOn = null;
}
$feed->updatedOn = Data::formatDate($feed->updatedOn);
foreach ($channel->getElementsByTagName('item') as $xmlItem) {
$itemGuid = self::rssValue($xmlItem, 'guid');
$updNodes = $xmlItem->getElementsByTagNameNS(Feed::ATOM_NS, 'updated');
$encNodes = $xmlItem->getElementsByTagNameNS(Feed::CONTENT_NS, 'encoded');
$item = new FeedItem();
$item->guid = $itemGuid == 'guid not found' ? self::rssValue($xmlItem, 'link') : $itemGuid;
$item->title = self::rssValue($xmlItem, 'title');
$item->link = self::rssValue($xmlItem, 'link');
$item->publishedOn = Data::formatDate(self::rssValue($xmlItem, 'pubDate'));
$item->updatedOn = Data::formatDate($updNodes->length > 0 ? $updNodes->item(0)->textContent : null);
$item->content = $encNodes->length > 0
? $encNodes->item(0)->textContent
: self::rssValue($xmlItem, 'description');
$feed->items[] = $item;
}
$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];
}
@ -147,11 +187,11 @@ class 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 DOMElement $element The parent element
* @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)
*/
private static function atomValue(DOMElement $element, string $tagName): string {
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);
@ -172,39 +212,15 @@ class Feed {
* @return array|Feed[] ['ok' => feed]
*/
private static function fromAtom(DOMDocument $xml, string $url): array {
/** @var DOMElement $root */
$root = $xml->getElementsByTagNameNS(self::ATOM_NS, 'feed')->item(0);
$feed = new Feed();
$feed->title = self::atomValue($root, 'title');
$feed->url = $url;
$root = $xml->getElementsByTagNameNS(self::ATOM_NS, 'feed')->item(0);
$updatedOn = self::atomValue($root, 'updated');
if ($updatedOn == 'pubDate not found') $updatedOn = null;
$feed->updatedOn = self::atomValue($root, 'updated');
if ($feed->updatedOn == 'pubDate not found') $feed->updatedOn = null;
$feed->updatedOn = Data::formatDate($feed->updatedOn);
foreach ($root->getElementsByTagName('entry') as $xmlItem) {
$guid = self::atomValue($xmlItem, 'id');
$link = '';
foreach ($xmlItem->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 = self::atomValue($xmlItem, 'title');
$item->link = $link;
$item->publishedOn = Data::formatDate(self::atomValue($xmlItem, 'published'));
$item->updatedOn = Data::formatDate(self::atomValue($xmlItem, 'updated'));
$item->content = self::atomValue($xmlItem, 'content');
$feed->items[] = $item;
}
$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];
}

View File

@ -84,6 +84,20 @@ class Security {
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, PASSWORD_DEFAULT));
$query->bindValue(':email', $email);
$query->execute();
}
/**
* Log on the single user
*

View File

@ -19,14 +19,17 @@ 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,
feed.title AS feed_title
FROM item
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
SQL);
$query->bindValue(':userId', $_SESSION[Key::USER_ID]);
$result = $query->execute();
$item = $result ? $result->fetchArray(SQLITE3_ASSOC) : false;
page_head('Welcome'); ?>
@ -34,7 +37,7 @@ page_head('Welcome'); ?>
<article><?php
if ($item) {
while ($item) { ?>
<p><a href=/item?id=<?=$item['id']?>><?=$item['item_title']?></a><br>
<p><a href=/item?id=<?=$item['id']?>><?=strip_tags($item['item_title'])?></a><br>
<?=htmlentities($item['feed_title'])?><br><small><em><?=date_time($item['as_of'])?></em></small><?php
$item = $result->fetchArray(SQLITE3_ASSOC);
}

View File

@ -53,7 +53,7 @@ $updated = isset($item['updated_on']) ? date_time($item['updated_on']) : null;
page_head(htmlentities("{$item['item_title']} | {$item['feed_title']}")); ?>
<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>
<div class=item_published>
From <strong><?=htmlentities($item['feed_title'])?></strong><br>

View File

@ -1,18 +1,7 @@
<?php
use JetBrains\PhpStorm\NoReturn;
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();
require 'app-config.php';
session_start([
'name' => 'FRCSESSION',

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

@ -0,0 +1,177 @@
<?php
use JetBrains\PhpStorm\NoReturn;
require __DIR__ . '/../cli-start.php';
cli_title('USER MAINTENANCE');
/**
* 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 migrating 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);
}
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();
break;
case 'delete-user':
if ($argc < 3) {
printfn('Missing parameters: delete-user requires e-mail address');
exit(-1);
}
delete_user();
break;
case 'migrate-single-user':
printfn('TODO: single-user migration');
break;
case 'remove-single-user':
printfn('TODO: single-user removal');
break;
default:
printfn('Unrecognized option "%s"', $argv[1]);
display_help();
}
/**
* 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
$existsQuery = $db->prepare('SELECT COUNT(*) FROM frc_user WHERE email = :email');
$existsQuery->bindValue(':email', $argv[2]);
$existsResult = $existsQuery->execute();
if (!$existsResult) {
printfn('SQLite error: %s', $db->lastErrorMsg());
return;
}
if ($existsResult->fetchArray(SQLITE3_NUM)[0] != 0) {
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();
}
}
/**
* Set a user's password
*/
function set_password(): void {
global $argv;
$db = Data::getConnection();
try {
// Ensure this user exists
$existsQuery = $db->prepare('SELECT COUNT(*) FROM frc_user WHERE email = :email');
$existsQuery->bindValue(':email', $argv[2]);
$existsResult = $existsQuery->execute();
if (!$existsResult) {
printfn('SQLite error: %s', $db->lastErrorMsg());
return;
}
if ($existsResult->fetchArray(SQLITE3_NUM)[0] == 0) {
printfn('No user exists with e-mail address "%s"', $argv[2]);
return;
}
Security::updatePassword($argv[2], $argv[3], $db);
printfn('User "%s" password set to "%s" successfully', $argv[2], $argv[3]);
} finally {
$db->close();
}
}
/**
* Delete a user
*/
function delete_user(): void {
global $argv;
$db = Data::getConnection();
try {
// Get the ID for the provided e-mail address
$idQuery = $db->prepare('SELECT id FROM frc_user WHERE email = :email');
$idQuery->bindValue(':email', $argv[2]);
$idResult = $idQuery->execute();
if (!$idResult) {
printfn('SQLite error: %s', $db->lastErrorMsg());
return;
}
$id = $idResult->fetchArray(SQLITE3_NUM);
if (!$id) {
printfn('No user exists with e-mail address "%s"', $argv[2]);
return;
}
$feedCountQuery = $db->prepare('SELECT COUNT(*) FROM feed WHERE user_id = :user');
$feedCountQuery->bindValue(':user', $id[0]);
$feedCountResult = $feedCountQuery->execute();
if (!$feedCountResult) {
printfn('SQLite error: %s', $db->lastErrorMsg());
return;
}
$feedCount = $feedCountResult->fetchArray(SQLITE3_NUM);
$proceed = readline("Delete user \"$argv[2]\" 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', $id[0]);
$itemDelete->execute();
$feedDelete = $db->prepare('DELETE FROM feed WHERE user_id = :user');
$feedDelete->bindValue(':user', $id[0]);
$feedDelete->execute();
$userDelete = $db->prepare('DELETE FROM frc_user WHERE id = :user');
$userDelete->bindValue(':user', $id[0]);
$userDelete->execute();
printfn('User "%s" deleted successfully', $argv[2]);
} finally {
$db->close();
}
}