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:
parent
7b21b86550
commit
c1790b58fd
17
src/app-config.php
Normal file
17
src/app-config.php
Normal 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
26
src/cli-start.php
Normal 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);
|
||||
}
|
136
src/lib/Feed.php
136
src/lib/Feed.php
@ -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];
|
||||
}
|
||||
|
@ -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
|
||||
*
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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
177
src/util/user.php
Normal 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();
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user