From cab26db25552097c2147493f1b6adb5dfa2b34df Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Mon, 15 Apr 2024 23:25:58 -0400 Subject: [PATCH 1/7] First cut of log on page (#9) - Add session support - Refactor security handling to use db connection - Fix db path issue --- src/lib/Data.php | 45 +------------ src/lib/Feed.php | 8 +-- src/lib/Key.php | 4 +- src/lib/Security.php | 124 ++++++++++++++++++++++++++++-------- src/public/assets/style.css | 5 +- src/public/feed.php | 8 +-- src/public/index.php | 3 +- src/public/item.php | 7 +- src/public/user/log-off.php | 10 +++ src/public/user/log-on.php | 39 ++++++++++++ src/start.php | 31 +++++---- src/user-config.php | 2 - 12 files changed, 185 insertions(+), 101 deletions(-) create mode 100644 src/public/user/log-off.php create mode 100644 src/public/user/log-on.php diff --git a/src/lib/Data.php b/src/lib/Data.php index 3f380b0..6713767 100644 --- a/src/lib/Data.php +++ b/src/lib/Data.php @@ -9,7 +9,7 @@ class Data { * @return SQLite3 A new connection to the database */ 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;'); return $db; } @@ -65,47 +65,6 @@ class Data { $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 * @@ -132,7 +91,7 @@ class Data { try { $query = $db->prepare('SELECT * FROM feed WHERE id = :id AND user_id = :user'); $query->bindValue(':id', $feedId); - $query->bindValue(':user', $_REQUEST[Key::USER_ID]); + $query->bindValue(':user', $_SESSION[Key::USER_ID]); $result = $query->execute(); return $result ? $result->fetchArray(SQLITE3_ASSOC) : false; } finally { diff --git a/src/lib/Feed.php b/src/lib/Feed.php index 3f2cbbc..79a9859 100644 --- a/src/lib/Feed.php +++ b/src/lib/Feed.php @@ -229,7 +229,7 @@ class Feed { private static function refreshFeed(string $url, SQLite3 $db): array { $feedQuery = $db->prepare('SELECT id FROM feed WHERE url = :url AND user_id = :user'); $feedQuery->bindValue(':url', $url); - $feedQuery->bindValue(':user', $_REQUEST[Key::USER_ID]); + $feedQuery->bindValue(':user', $_SESSION[Key::USER_ID]); $feedResult = $feedQuery->execute(); $feedId = $feedResult ? $feedResult->fetchArray(SQLITE3_NUM)[0] : -1; if ($feedId < 0) return ['error' => "No feed for URL $url found"]; @@ -273,7 +273,7 @@ class Feed { INSERT INTO feed (user_id, url, title, updated_on, checked_on) VALUES (:user, :url, :title, :updated, :checked) SQL); - $query->bindValue(':user', $_REQUEST[Key::USER_ID]); + $query->bindValue(':user', $_SESSION[Key::USER_ID]); $query->bindValue(':url', $feed['url']); $query->bindValue(':title', self::eltValue($feed['channel'], 'title')); $query->bindValue(':updated', $feed['updated']); @@ -300,7 +300,7 @@ class Feed { $query = $db->prepare('UPDATE feed SET url = :url WHERE id = :id AND user_id = :user'); $query->bindValue(':url', $url); $query->bindValue(':id', $existing['id']); - $query->bindValue(':user', $_REQUEST[Key::USER_ID]); + $query->bindValue(':user', $_SESSION[Key::USER_ID]); $query->execute(); return self::refreshFeed($url, $db); @@ -312,7 +312,7 @@ class Feed { */ public static function refreshAll(SQLite3 $db): array { $query = $db->prepare('SELECT url FROM feed WHERE user_id = :user'); - $query->bindValue(':user', $_REQUEST[Key::USER_ID]); + $query->bindValue(':user', $_SESSION[Key::USER_ID]); $result = $query->execute(); $url = $result ? $result->fetchArray(SQLITE3_NUM) : false; if ($url) { diff --git a/src/lib/Key.php b/src/lib/Key.php index 39e7bfd..e1bf82a 100644 --- a/src/lib/Key.php +++ b/src/lib/Key.php @@ -2,10 +2,10 @@ 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'; - /** @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'; /** @var string The $_REQUEST key for the array of user messages to display */ diff --git a/src/lib/Security.php b/src/lib/Security.php index 7b1c7cf..c79ebed 100644 --- a/src/lib/Security.php +++ b/src/lib/Security.php @@ -14,38 +14,108 @@ class Security { /** @var int Require users to provide e-mail address and password */ 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 */ + private const string SINGLE_USER_PASSWORD = 'no-password-required'; + /** - * Verify that user is logged on - * @param bool $redirectIfAnonymous Whether to redirect the request if there is no user logged on + * Find a user by their ID + * + * @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 { - switch (SECURITY_MODEL) { - case self::SINGLE_USER: - $user = self::retrieveSingleUser(); - break; - 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']; + private static function findUserByEmail(string $email, SQLite3 $db): array|false { + $query = $db->prepare('SELECT * FROM frc_user WHERE email = :email'); + $query->bindValue(':email', $email); + $result = $query->execute(); + return $result ? $result->fetchArray(SQLITE3_ASSOC) : false; } /** - * Retrieve the single user - * @return array The user information for the single user + * Add a 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 { - $user = Data::findUserByEmail('solouser@example.com'); - if ($user) return $user; - Data::addUser('solouser@example.com', 'no-password-required'); - return Data::findUserByEmail('solouser@example.com'); + public static function addUser(string $email, string $password, SQLite3 $db): void { + $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(); + } + + /** + * 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'], PASSWORD_DEFAULT)) { + $rehash = $db->prepare('UPDATE frc_user SET password = :hash WHERE id = :id'); + $rehash->bindValue(':hash', password_hash($password, PASSWORD_DEFAULT)); + $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 + * @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 { + $user = self::findUserByEmail($email, $db); + if ($user) self::verifyPassword($user, $password, $returnTo, $db); + add_error('Invalid credentials; log on unsuccessful'); + } + + /** + * 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(); + } } } diff --git a/src/public/assets/style.css b/src/public/assets/style.css index 86bd961..9518ea3 100644 --- a/src/public/assets/style.css +++ b/src/public/assets/style.css @@ -57,7 +57,10 @@ article { padding: .5rem; } } -input[type=url], input[type=text] { +input[type=url], +input[type=text], +input[type=email], +input[type=password] { width: 50%; font-size: 1rem; padding: .25rem; diff --git a/src/public/feed.php b/src/public/feed.php index 85446c2..80f985a 100644 --- a/src/public/feed.php +++ b/src/public/feed.php @@ -7,10 +7,10 @@ include '../start.php'; -Security::verifyUser(); +$db = Data::getConnection(); +Security::verifyUser($db); -$feedId = array_key_exists('id', $_GET) ? $_GET['id'] : ''; -$db = Data::getConnection(); +$feedId = $_GET['id'] ?? ''; if ($_SERVER['REQUEST_METHOD'] == 'POST') { $isNew = $_POST['id'] == 'new'; @@ -31,7 +31,7 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') { if ($feedId == 'new') { $title = 'Add RSS Feed'; - $feed = [ 'id' => $_GET['id'], 'url' => '' ]; + $feed = [ 'id' => $_GET['id'], 'url' => '']; } else { $title = 'Edit RSS Feed'; if ($feedId == 'error') { diff --git a/src/public/index.php b/src/public/index.php index 6bcb14f..9b75399 100644 --- a/src/public/index.php +++ b/src/public/index.php @@ -7,9 +7,8 @@ include '../start.php'; -Security::verifyUser(); - $db = Data::getConnection(); +Security::verifyUser($db); if (array_key_exists('refresh', $_GET)) { $refreshResult = Feed::refreshAll($db); diff --git a/src/public/item.php b/src/public/item.php index 1e98bd5..ee4a714 100644 --- a/src/public/item.php +++ b/src/public/item.php @@ -8,9 +8,8 @@ include '../start.php'; -Security::verifyUser(); - $db = Data::getConnection(); +Security::verifyUser($db); 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 @@ -20,7 +19,7 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') { WHERE item.id = :id AND feed.user_id = :user SQL); $isValidQuery->bindValue(':id', $_POST['id']); - $isValidQuery->bindValue(':user', $_REQUEST[Key::USER_ID]); + $isValidQuery->bindValue(':user', $_SESSION[Key::USER_ID]); $isValidResult = $isValidQuery->execute(); if ($isValidResult && $isValidResult->fetchArray(SQLITE3_NUM)[0] == 1) { $keepUnread = $db->prepare('UPDATE item SET is_read = 0 WHERE id = :id'); @@ -39,7 +38,7 @@ $query = $db->prepare(<<<'SQL' AND feed.user_id = :user SQL); $query->bindValue(':id', $_GET['id']); -$query->bindValue(':user', $_REQUEST[Key::USER_ID]); +$query->bindValue(':user', $_SESSION[Key::USER_ID]); $result = $query->execute(); $item = $result ? $result->fetchArray(SQLITE3_ASSOC) : false; diff --git a/src/public/user/log-off.php b/src/public/user/log-off.php new file mode 100644 index 0000000..5ba1f32 --- /dev/null +++ b/src/public/user/log-off.php @@ -0,0 +1,10 @@ + +

Log On

+
+
+ +
+
+ +
+
close(); diff --git a/src/start.php b/src/start.php index 48701ab..249c21f 100644 --- a/src/start.php +++ b/src/start.php @@ -14,6 +14,12 @@ require 'user-config.php'; Data::ensureDb(); +session_start([ + 'name' => 'FRCSESSION', + 'use_strict_mode' => true, + 'cookie_httponly' => true, + 'cookie_samesite' => 'Strict']); + /** * Add a message to be displayed at the top of the page * @@ -59,20 +65,20 @@ function page_head(string $title): void {
Feed Reader Central
Add Feed'; - if ($_REQUEST[Key::USER_EMAIL] != 'solouser@example.com') echo " | {$_REQUEST[Key::USER_EMAIL]}"; + if (array_key_exists(Key::USER_ID, $_SESSION)) { + echo 'Add Feed | Log Off'; + if ($_SESSION[Key::USER_EMAIL] != Security::SINGLE_USER_EMAIL) echo " | {$_SESSION[Key::USER_EMAIL]}"; + } else { + echo 'Log On'; } ?>
-
- {$msg['level']}
"?> - -
+
+ {$msg['level']}
"?> + +
Date: Sat, 27 Apr 2024 13:01:57 -0400 Subject: [PATCH 2/7] Add user maintenance CLI (#9) - Add CLI infrastructure - Add user to index page query - Strip tags from title - Move item parsing to FeedItem --- src/app-config.php | 17 +++++ src/cli-start.php | 26 +++++++ src/lib/Feed.php | 136 ++++++++++++++++++--------------- src/lib/Security.php | 14 ++++ src/public/index.php | 9 ++- src/public/item.php | 2 +- src/start.php | 13 +--- src/util/user.php | 177 +++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 318 insertions(+), 76 deletions(-) create mode 100644 src/app-config.php create mode 100644 src/cli-start.php create mode 100644 src/util/user.php diff --git a/src/app-config.php b/src/app-config.php new file mode 100644 index 0000000..d5e34c9 --- /dev/null +++ b/src/app-config.php @@ -0,0 +1,17 @@ +` 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 `` 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 `
` 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]; } diff --git a/src/lib/Security.php b/src/lib/Security.php index c79ebed..f7e1af3 100644 --- a/src/lib/Security.php +++ b/src/lib/Security.php @@ -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 * diff --git a/src/public/index.php b/src/public/index.php index 3775c26..7b8de61 100644 --- a/src/public/index.php +++ b/src/public/index.php @@ -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'); ?>
-

>
+

>

fetchArray(SQLITE3_ASSOC); } diff --git a/src/public/item.php b/src/public/item.php index 43fe030..b7a2cfb 100644 --- a/src/public/item.php +++ b/src/public/item.php @@ -53,7 +53,7 @@ $updated = isset($item['updated_on']) ? date_time($item['updated_on']) : null; page_head(htmlentities("{$item['item_title']} | {$item['feed_title']}")); ?>

-
+

From
diff --git a/src/start.php b/src/start.php index 249c21f..ba8a5a3 100644 --- a/src/start.php +++ b/src/start.php @@ -1,18 +1,7 @@ 'FRCSESSION', diff --git a/src/util/user.php b/src/util/user.php new file mode 100644 index 0000000..81b8a34 --- /dev/null +++ b/src/util/user.php @@ -0,0 +1,177 @@ +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(); + } +} -- 2.45.1 From bf6b2a0ffa610ffdd3c5bac29a5eff95f15f4427 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sat, 27 Apr 2024 13:54:09 -0400 Subject: [PATCH 3/7] Add single-user handling (#9) - Disallow log on for single-user mode user - Improve CLI header display --- src/cli-start.php | 6 ++- src/lib/Security.php | 19 +++++++--- src/util/user.php | 89 ++++++++++++++++++++++++++------------------ 3 files changed, 70 insertions(+), 44 deletions(-) diff --git a/src/cli-start.php b/src/cli-start.php index 78d6184..b6dda7e 100644 --- a/src/cli-start.php +++ b/src/cli-start.php @@ -22,5 +22,9 @@ function printfn(string $format, mixed ...$values): void { * @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); + $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); } diff --git a/src/lib/Security.php b/src/lib/Security.php index f7e1af3..7235a03 100644 --- a/src/lib/Security.php +++ b/src/lib/Security.php @@ -20,6 +20,9 @@ class Security { /** @var string The password for the single user with no password */ private 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; + /** * Find a user by their ID * @@ -27,7 +30,7 @@ class Security { * @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 */ - private static function findUserByEmail(string $email, SQLite3 $db): array|false { + public static function findUserByEmail(string $email, SQLite3 $db): array|false { $query = $db->prepare('SELECT * FROM frc_user WHERE email = :email'); $query->bindValue(':email', $email); $result = $query->execute(); @@ -44,7 +47,7 @@ class Security { public static function addUser(string $email, string $password, SQLite3 $db): void { $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->bindValue(':password', password_hash($password, self::PW_ALGORITHM)); $query->execute(); } @@ -58,9 +61,9 @@ class Security { */ 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'], PASSWORD_DEFAULT)) { + 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, PASSWORD_DEFAULT)); + $rehash->bindValue(':hash', password_hash($password, self::PW_ALGORITHM)); $rehash->bindValue(':id', $user['id']); $rehash->execute(); } @@ -73,12 +76,16 @@ class Security { /** * Log on a user with e-mail address and password * - * @param string $email The e-mail address for the user + * @param string $email The e-mail address for the user (cannot be the single-user mode user) * @param string $password The password provided by the user * @param string|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 ($email == self::SINGLE_USER_EMAIL) { + add_error('Invalid credentials; log on unsuccessful'); + return; + } $user = self::findUserByEmail($email, $db); if ($user) self::verifyPassword($user, $password, $returnTo, $db); add_error('Invalid credentials; log on unsuccessful'); @@ -93,7 +100,7 @@ class Security { */ 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(':password', password_hash($password, self::PW_ALGORITHM)); $query->bindValue(':email', $email); $query->execute(); } diff --git a/src/util/user.php b/src/util/user.php index 81b8a34..3244963 100644 --- a/src/util/user.php +++ b/src/util/user.php @@ -47,13 +47,17 @@ switch ($argv[1]) { printfn('Missing parameters: delete-user requires e-mail address'); exit(-1); } - delete_user(); + delete_user($argv[2]); break; case 'migrate-single-user': - printfn('TODO: single-user migration'); + if ($argc < 4) { + printfn('Missing parameters: migrate-single-user requires e-mail and password'); + exit(-1); + } + migrate_single_user(); break; case 'remove-single-user': - printfn('TODO: single-user removal'); + delete_user(Security::SINGLE_USER_EMAIL); break; default: printfn('Unrecognized option "%s"', $argv[1]); @@ -70,14 +74,8 @@ function add_user(): void { 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) { + $user = Security::findUserByEmail($argv[2], $db); + if ($user) { printfn('A user with e-mail address "%s" already exists', $argv[2]); return; } @@ -100,14 +98,8 @@ function set_password(): void { 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) { + $user = Security::findUserByEmail($argv[2], $db); + if (!$user) { printfn('No user exists with e-mail address "%s"', $argv[2]); return; } @@ -122,29 +114,25 @@ function set_password(): void { /** * Delete a user + * + * @param string $email The e-mail address of the user to be deleted */ -function delete_user(): void { - global $argv; +function delete_user(string $email): void { $db = Data::getConnection(); try { + $displayUser = $email == Security::SINGLE_USER_EMAIL ? 'single-user mode user' : "user \"$email\""; + // 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]); + $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', $id[0]); + $feedCountQuery->bindValue(':user', $user['id']); $feedCountResult = $feedCountQuery->execute(); if (!$feedCountResult) { printfn('SQLite error: %s', $db->lastErrorMsg()); @@ -152,25 +140,52 @@ function delete_user(): void { } $feedCount = $feedCountResult->fetchArray(SQLITE3_NUM); - $proceed = readline("Delete user \"$argv[2]\" and their $feedCount[0] feed(s)? (y/N)" . PHP_EOL); + $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', $id[0]); + $itemDelete->bindValue(':user', $user['id']); $itemDelete->execute(); $feedDelete = $db->prepare('DELETE FROM feed WHERE user_id = :user'); - $feedDelete->bindValue(':user', $id[0]); + $feedDelete->bindValue(':user', $user['id']); $feedDelete->execute(); $userDelete = $db->prepare('DELETE FROM frc_user WHERE id = :user'); - $userDelete->bindValue(':user', $id[0]); + $userDelete->bindValue(':user', $user['id']); $userDelete->execute(); - printfn('User "%s" deleted successfully', $argv[2]); + printfn(strtoupper(substr($displayUser, 0, 1)) . substr($displayUser, 1) . ' deleted successfully'); + } 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(); } -- 2.45.1 From 0df40f3cfd92f9cf00db602604fe9b77830841a2 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sat, 27 Apr 2024 14:11:31 -0400 Subject: [PATCH 4/7] Add version to page header --- src/public/assets/style.css | 6 ++++++ src/start.php | 13 +++++++++--- src/util/user.php | 41 +++++++++++++++++++------------------ 3 files changed, 37 insertions(+), 23 deletions(-) diff --git a/src/public/assets/style.css b/src/public/assets/style.css index 9518ea3..ef5f9e9 100644 --- a/src/public/assets/style.css +++ b/src/public/assets/style.css @@ -30,6 +30,12 @@ header { font-size: 1.5rem; } + .version { + font-size: .85rem; + padding-left: .5rem; + color: rgba(255, 255, 255, .75); + } + a:link, a:visited { color: white; } diff --git a/src/start.php b/src/start.php index ba8a5a3..f2caea0 100644 --- a/src/start.php +++ b/src/start.php @@ -43,7 +43,14 @@ function add_info(string $message): void { * @param string $title The title of the page being displayed */ function page_head(string $title): void { - ?> + if (str_ends_with(FRC_VERSION, '.0.0')) { + $version = substr(FRC_VERSION, 0, strlen(FRC_VERSION) - 4); + } elseif (str_ends_with(FRC_VERSION, '.0')) { + $version = substr(FRC_VERSION, 0, strlen(FRC_VERSION) - 2); + } else { + $version = FRC_VERSION; + } ?> + @@ -52,7 +59,7 @@ function page_head(string $title): void {
- Feed Reader Central +
Add Feed | Log Off'; @@ -75,7 +82,7 @@ function page_head(string $title): void { * Render the end of the page */ function page_foot(): void { - ?>'; session_commit(); } diff --git a/src/util/user.php b/src/util/user.php index 3244963..03d27c0 100644 --- a/src/util/user.php +++ b/src/util/user.php @@ -5,26 +5,6 @@ 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]) { @@ -65,11 +45,32 @@ switch ($argv[1]) { } +/** + * 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); +} + /** * Add a new user */ function add_user(): void { global $argv; + $db = Data::getConnection(); try { -- 2.45.1 From 9611893da3301eea05596d5b1f179b1daa4d34b7 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sat, 27 Apr 2024 16:34:59 -0400 Subject: [PATCH 5/7] Add single-user password utils (#9) - Constrain images to reading viewport --- src/cli-start.php | 14 +++++++++++ src/lib/Feed.php | 4 +-- src/lib/Security.php | 15 +++++++---- src/public/assets/style.css | 7 ++++++ src/start.php | 10 +++----- src/util/user.php | 50 ++++++++++++++++++++++++++++--------- 6 files changed, 75 insertions(+), 25 deletions(-) diff --git a/src/cli-start.php b/src/cli-start.php index b6dda7e..5a61045 100644 --- a/src/cli-start.php +++ b/src/cli-start.php @@ -28,3 +28,17 @@ function cli_title(string $title): void { 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), + }; +} diff --git a/src/lib/Feed.php b/src/lib/Feed.php index 331e61e..27dde74 100644 --- a/src/lib/Feed.php +++ b/src/lib/Feed.php @@ -245,7 +245,7 @@ class Feed { $code = curl_getinfo($feedReq, CURLINFO_RESPONSE_CODE); if ($error) { $result['error'] = $error; - } else if ($code == 200) { + } elseif ($code == 200) { $parsed = self::parseFeed($feedContent); if (array_key_exists('error', $parsed)) { $result['error'] = $parsed['error']; @@ -398,7 +398,7 @@ class Feed { $feedExtract = self::retrieveFeed($url); if (array_key_exists('error', $feedExtract)) return $feedExtract; - $feed = $feedExtract['ok']; + $feed = $feedExtract['ok']; $query = $db->prepare(<<<'SQL' INSERT INTO feed (user_id, url, title, updated_on, checked_on) VALUES (:user, :url, :title, :updated, :checked) diff --git a/src/lib/Security.php b/src/lib/Security.php index 7235a03..d8e865b 100644 --- a/src/lib/Security.php +++ b/src/lib/Security.php @@ -18,7 +18,7 @@ class Security { public const string SINGLE_USER_EMAIL = 'solouser@example.com'; /** @var string The password for the single user with no password */ - private const string SINGLE_USER_PASSWORD = 'no-password-required'; + 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; @@ -82,11 +82,16 @@ class Security { * @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 ($email == self::SINGLE_USER_EMAIL) { - add_error('Invalid credentials; log on unsuccessful'); - return; + 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($email, $db); + $user = self::findUserByEmail($dbEmail, $db); if ($user) self::verifyPassword($user, $password, $returnTo, $db); add_error('Invalid credentials; log on unsuccessful'); } diff --git a/src/public/assets/style.css b/src/public/assets/style.css index ef5f9e9..f3d7654 100644 --- a/src/public/assets/style.css +++ b/src/public/assets/style.css @@ -61,6 +61,13 @@ article { border-radius: .5rem; background-color: white; padding: .5rem; + + img { + max-width: 100%; + object-fit: contain; + height: unset; + width: unset; + } } } input[type=url], diff --git a/src/start.php b/src/start.php index f2caea0..9261221 100644 --- a/src/start.php +++ b/src/start.php @@ -43,12 +43,10 @@ function add_info(string $message): void { * @param string $title The title of the page being displayed */ function page_head(string $title): void { - if (str_ends_with(FRC_VERSION, '.0.0')) { - $version = substr(FRC_VERSION, 0, strlen(FRC_VERSION) - 4); - } elseif (str_ends_with(FRC_VERSION, '.0')) { - $version = substr(FRC_VERSION, 0, strlen(FRC_VERSION) - 2); - } else { - $version = FRC_VERSION; + $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 } ?> diff --git a/src/util/user.php b/src/util/user.php index 03d27c0..0155ca7 100644 --- a/src/util/user.php +++ b/src/util/user.php @@ -20,7 +20,7 @@ switch ($argv[1]) { printfn('Missing parameters: set-password requires e-mail and password'); exit(-1); } - set_password(); + set_password($argv[2], $argv[3]); break; case 'delete-user': if ($argc < 3) { @@ -29,6 +29,16 @@ switch ($argv[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'); @@ -57,7 +67,12 @@ function display_help(): void { 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('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'); @@ -89,25 +104,36 @@ function add_user(): void { } } +/** + * 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(): void { - global $argv; - +function set_password(string $email, string $password): void { $db = Data::getConnection(); - try { + $displayUser = display_user($email); + // Ensure this user exists - $user = Security::findUserByEmail($argv[2], $db); + $user = Security::findUserByEmail($email, $db); if (!$user) { - printfn('No user exists with e-mail address "%s"', $argv[2]); + printfn('No %s exists', $displayUser); return; } - Security::updatePassword($argv[2], $argv[3], $db); + Security::updatePassword($email, $password, $db); - printfn('User "%s" password set to "%s" successfully', $argv[2], $argv[3]); + $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(); } @@ -123,7 +149,7 @@ function delete_user(string $email): void { $db = Data::getConnection(); try { - $displayUser = $email == Security::SINGLE_USER_EMAIL ? 'single-user mode user' : "user \"$email\""; + $displayUser = display_user($email); // Get the ID for the provided e-mail address $user = Security::findUserByEmail($email, $db); @@ -159,7 +185,7 @@ function delete_user(string $email): void { $userDelete->bindValue(':user', $user['id']); $userDelete->execute(); - printfn(strtoupper(substr($displayUser, 0, 1)) . substr($displayUser, 1) . ' deleted successfully'); + printfn('%s deleted successfully', init_cap($displayUser)); } finally { $db->close(); } -- 2.45.1 From 36373aae0159a60ec40b8c8302d45dcdd076c285 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sat, 27 Apr 2024 18:54:57 -0400 Subject: [PATCH 6/7] Add docs for security models (#9) - Change default security to CONFIGURE_ME - Fix log on return URL handling - Update INSTALLING security model descriptions --- INSTALLING.md | 8 ++-- src/public/assets/style.css | 10 +++++ src/public/docs/index.php | 15 ++++++++ src/public/docs/security-modes.php | 62 ++++++++++++++++++++++++++++++ src/public/docs/the-cli.php | 24 ++++++++++++ src/public/user/log-on.php | 2 +- src/start.php | 4 +- src/user-config.php | 2 +- 8 files changed, 119 insertions(+), 8 deletions(-) create mode 100644 src/public/docs/index.php create mode 100644 src/public/docs/security-modes.php create mode 100644 src/public/docs/the-cli.php diff --git a/INSTALLING.md b/INSTALLING.md index 9497134..70854f8 100644 --- a/INSTALLING.md +++ b/INSTALLING.md @@ -17,7 +17,7 @@ _(More environments will be detailed as part of a later release; an nginx revers ## 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 @@ -31,10 +31,10 @@ Within the `/src` directory, there is a file named `user-config.php`. This file ### 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. -- `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::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::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` 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 diff --git a/src/public/assets/style.css b/src/public/assets/style.css index f3d7654..9ce2647 100644 --- a/src/public/assets/style.css +++ b/src/public/assets/style.css @@ -70,6 +70,10 @@ article { } } } +article.docs { + line-height: 1.4rem; +} + input[type=url], input[type=text], input[type=email], @@ -103,3 +107,9 @@ button:hover, flex-flow: row nowrap; justify-content: space-evenly; } +code { + font-size: .9rem; +} +p.back-link { + margin-top: -1rem; +} diff --git a/src/public/docs/index.php b/src/public/docs/index.php new file mode 100644 index 0000000..13ad8c9 --- /dev/null +++ b/src/public/docs/index.php @@ -0,0 +1,15 @@ + +

Documentation Home

+close(); diff --git a/src/public/docs/security-modes.php b/src/public/docs/security-modes.php new file mode 100644 index 0000000..32ddf49 --- /dev/null +++ b/src/public/docs/security-modes.php @@ -0,0 +1,62 @@ + +

Configuring Security Modes

+
+

Security Modes

+

Single-User 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. +

Single-User with Password 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. +

Multi-User 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. +

Managing Users in Multi-User Mode

+

Users can be added or deleted, and passwords set, using the user CLI utility.

+ (For all the “password” 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.) +

Add a User

+

php-cli utils/user.php add-user alice@example.com AlicesSecur3P4ssword +

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. +

Set a User’s Password

+

php-cli utils/user.php set-password bob@example.com AN3wPassCauseB0bForg0t1t +

Delete a User

+

php-cli utils/user.php delete-user carol@example.com +

The utility will require confirmation that the user and their feeds should be deleted. Any input that starts with + the letter “y” will confirm, and any other input will cancel the process. +

Changing from Single-User to Multi-User Mode

+

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 SECURITY_MODEL in user-config.php to + Security::MULTI_USER. +

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. +

php-cli utils/user.php migrate-single-user dave@example.com Dav3sPas$wort +

If, however, you do not wish to maintain the single user’s information at all, delete it. +

php-cli utils/user.php remove-single-user +

Changing from Multi-User to any Single-User Mode

+

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. +

Changing from Single-User to Single-User with Password Mode

+

Set SECURITY_MODEL in user-config.php to + Security::SINGLE_USER_WITH_PASSWORD, then use the user CLI utility to set a password. +

php-cli util/user.php set-single-password aNiceC0mplexPassw0rd +

Changing from Single-User with Password to Single-User Mode

+

If you decide you do not want to enter a password, but want to maintain single-user mode, set + SECURITY_MODEL in user-config.php to Security::SINGLE_USER, then run the + user CLI utility to reset the single user back to its expected default. +

php-cli util/user.php reset-single-password +

close(); diff --git a/src/public/docs/the-cli.php b/src/public/docs/the-cli.php new file mode 100644 index 0000000..80aec65 --- /dev/null +++ b/src/public/docs/the-cli.php @@ -0,0 +1,24 @@ + +

About the CLI

+
+

Feed Reader Central’s low-friction design includes having many administrative tasks run in a terminal or + shell. “CLI” is short for “Command Line Interface”, and refers to commands that are run + via PHP’s php-cli command. Ensure that the version of PHP installed also has that feature + enabled. (If you are using the recommended + FrankenPHP environment, it has PHP CLI support; + use frankenphp php-cli instead of php-cli when following instructions here.) +

Running CLI Commands

+

CLI commands should be run from the same directory with start.php; this will be one directory level + up from /public, the web root directory. CLI utilities are in the /util directory, so + an invocation will follow the pattern: +

php-cli util/some-process.php command option1 option2 +

close(); diff --git a/src/public/user/log-on.php b/src/public/user/log-on.php index 64f4aab..63dc93e 100644 --- a/src/public/user/log-on.php +++ b/src/public/user/log-on.php @@ -8,7 +8,7 @@ Security::verifyUser($db, redirectIfAnonymous: false); if (array_key_exists(Key::USER_ID, $_SESSION)) frc_redirect('/'); if ($_SERVER['REQUEST_METHOD'] == 'POST') { - Security::logOnUser($_POST['email'] ?? '', $_POST['password'], $_POST['returnTo'], $db); + 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']; } diff --git a/src/start.php b/src/start.php index 9261221..406cf50 100644 --- a/src/start.php +++ b/src/start.php @@ -60,10 +60,10 @@ function page_head(string $title): void {
Add Feed | Log Off'; + echo 'Add Feed | Docs | Log Off'; if ($_SESSION[Key::USER_EMAIL] != Security::SINGLE_USER_EMAIL) echo " | {$_SESSION[Key::USER_EMAIL]}"; } else { - echo 'Log On'; + echo 'Log On | Docs'; } ?>
diff --git a/src/user-config.php b/src/user-config.php index a89cefe..e0ca5f8 100644 --- a/src/user-config.php +++ b/src/user-config.php @@ -12,7 +12,7 @@ * - Security::SINGLE_USER_WITH_PASSWORD (no e-mail required, does require a password) * - Security::MULTI_USER (e-mail and password required for all users) */ -const SECURITY_MODEL = Security::SINGLE_USER; +const SECURITY_MODEL = 'CONFIGURE_ME'; /** The name of the database file where users and feeds should be kept */ const DATABASE_NAME = 'frc.db'; -- 2.45.1 From 2ac4a878656920a38b5c48548f511bf3710c8967 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sat, 27 Apr 2024 19:09:13 -0400 Subject: [PATCH 7/7] Rename user config (#18) - Update installation instructions --- .gitignore | 1 + INSTALLING.md | 2 +- src/{user-config.php => user-config.dist.php} | 5 ++--- 3 files changed, 4 insertions(+), 4 deletions(-) rename src/{user-config.php => user-config.dist.php} (89%) diff --git a/.gitignore b/.gitignore index 73afc4d..f9b4ff3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .idea src/data/*.db +src/user-config.php diff --git a/INSTALLING.md b/INSTALLING.md index 70854f8..bcfcb3a 100644 --- a/INSTALLING.md +++ b/INSTALLING.md @@ -27,7 +27,7 @@ The default `Caddyfile` will run the site at `http://localhost:8205`. To have th ## 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 diff --git a/src/user-config.php b/src/user-config.dist.php similarity index 89% rename from src/user-config.php rename to src/user-config.dist.php index e0ca5f8..7b3f824 100644 --- a/src/user-config.php +++ b/src/user-config.dist.php @@ -3,6 +3,8 @@ * USER CONFIGURATION ITEMS * * 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 */ @@ -23,6 +25,3 @@ const DATABASE_NAME = 'frc.db'; * The default, 'F j, Y \a\t g:ia', equates to "August 17, 2023 at 4:45pm" */ const DATE_TIME_FORMAT = 'F j, Y \a\t g:ia'; - -// END USER CONFIGURATION ITEMS -// (editing below this line is not advised) -- 2.45.1