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 9497134..bcfcb3a 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 @@ -27,14 +27,14 @@ 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 -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/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 @@ + "", + 1 => strtoupper($value), + default => strtoupper(substr($value, 0, 1)) . substr($value, 1), + }; +} 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 7224812..27dde74 100644 --- a/src/lib/Feed.php +++ b/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 `` 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]; } @@ -229,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']; @@ -341,7 +357,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"]; @@ -382,12 +398,12 @@ 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) SQL); - $query->bindValue(':user', $_REQUEST[Key::USER_ID]); + $query->bindValue(':user', $_SESSION[Key::USER_ID]); $query->bindValue(':url', $feed->url); $query->bindValue(':title', $feed->title); $query->bindValue(':updated', $feed->updatedOn); @@ -415,7 +431,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); @@ -429,7 +445,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..d8e865b 100644 --- a/src/lib/Security.php +++ b/src/lib/Security.php @@ -14,38 +14,134 @@ 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 */ + public const string SINGLE_USER_PASSWORD = 'no-password-required'; + + /** @var string The password algorithm to use for our passwords */ + public const string PW_ALGORITHM = PASSWORD_DEFAULT; + /** - * Verify that user is logged on - * @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']; + 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(); + 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, self::PW_ALGORITHM)); + $query->execute(); + } + + /** + * Verify a user's password + * + * @param array $user The user information retrieved from the database + * @param string $password The password provided by the user + * @param string|null $returnTo The URL to which the user should be redirected + * @param SQLite3 $db The database connection to use to verify the user's credentials + */ + private static function verifyPassword(array $user, string $password, ?string $returnTo, SQLite3 $db): void { + if (password_verify($password, $user['password'])) { + if (password_needs_rehash($user['password'], self::PW_ALGORITHM)) { + $rehash = $db->prepare('UPDATE frc_user SET password = :hash WHERE id = :id'); + $rehash->bindValue(':hash', password_hash($password, self::PW_ALGORITHM)); + $rehash->bindValue(':id', $user['id']); + $rehash->execute(); + } + $_SESSION[Key::USER_ID] = $user['id']; + $_SESSION[Key::USER_EMAIL] = $user['email']; + frc_redirect($returnTo ?? '/'); + } + } + + /** + * Log on a user with e-mail address and password + * + * @param string $email The e-mail address for the user (cannot be the single-user mode user) + * @param string $password The password provided by the user + * @param string|null $returnTo The URL to which the user should be redirected + * @param SQLite3 $db The database connection to use to verify the user's credentials + */ + public static function logOnUser(string $email, string $password, ?string $returnTo, SQLite3 $db): void { + if (SECURITY_MODEL == self::SINGLE_USER_WITH_PASSWORD) { + $dbEmail = self::SINGLE_USER_EMAIL; + } else { + if ($email == self::SINGLE_USER_EMAIL) { + add_error('Invalid credentials; log on unsuccessful'); + return; + } + $dbEmail = $email; + } + $user = self::findUserByEmail($dbEmail, $db); + if ($user) self::verifyPassword($user, $password, $returnTo, $db); + add_error('Invalid credentials; log on unsuccessful'); + } + + /** + * Update the password for the given user + * + * @param string $email The e-mail address of the user whose password should be updated + * @param string $password The new password for this user + * @param SQLite3 $db The database connection to use in updating the password + */ + public static function updatePassword(string $email, string $password, SQLite3 $db): void { + $query = $db->prepare('UPDATE frc_user SET password = :password WHERE email = :email'); + $query->bindValue(':password', password_hash($password, self::PW_ALGORITHM)); + $query->bindValue(':email', $email); + $query->execute(); + } + + /** + * Log on the single user + * + * @param SQLite3 $db The data connection to use to retrieve the user + */ + private static function logOnSingleUser(SQLite3 $db): void { + $user = self::findUserByEmail(self::SINGLE_USER_EMAIL, $db); + if (!$user) { + self::addUser(self::SINGLE_USER_EMAIL, self::SINGLE_USER_PASSWORD, $db); + $user = self::findUserByEmail(self::SINGLE_USER_EMAIL, $db); + } + self::verifyPassword($user, self::SINGLE_USER_PASSWORD, $_GET['returnTo'], $db); + } + + /** + * Verify that user is logged on + * + * @param SQLite3 $db The data connection to use if required + * @param bool $redirectIfAnonymous Whether to redirect the request if there is no user logged on + */ + public static function verifyUser(SQLite3 $db, bool $redirectIfAnonymous = true): void { + if (array_key_exists(Key::USER_ID, $_SESSION)) return; + + if (SECURITY_MODEL == self::SINGLE_USER) self::logOnSingleUser($db); + + if (SECURITY_MODEL != self::SINGLE_USER_WITH_PASSWORD && SECURITY_MODEL != self::MULTI_USER) { + die('Unrecognized security model (' . SECURITY_MODEL . ')'); + } + + if ($redirectIfAnonymous) { + header("Location: /user/log-on?returnTo={$_SERVER['REQUEST_URI']}", true, 307); + die(); + } } } diff --git a/src/public/assets/style.css b/src/public/assets/style.css index 86bd961..9ce2647 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; } @@ -55,9 +61,23 @@ article { border-radius: .5rem; background-color: white; padding: .5rem; + + img { + max-width: 100%; + object-fit: contain; + height: unset; + width: unset; + } } } -input[type=url], input[type=text] { +article.docs { + line-height: 1.4rem; +} + +input[type=url], +input[type=text], +input[type=email], +input[type=password] { width: 50%; font-size: 1rem; padding: .25rem; @@ -87,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/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 91bbc16..7b8de61 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); @@ -20,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'); ?> @@ -35,7 +37,7 @@ page_head('Welcome'); ?>
-

>
+

>

fetchArray(SQLITE3_ASSOC); } diff --git a/src/public/item.php b/src/public/item.php index 967ec9e..b7a2cfb 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; @@ -54,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/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..406cf50 100644 --- a/src/start.php +++ b/src/start.php @@ -1,18 +1,13 @@ 'FRCSESSION', + 'use_strict_mode' => true, + 'cookie_httponly' => true, + 'cookie_samesite' => 'Strict']); /** * Add a message to be displayed at the top of the page @@ -48,7 +43,12 @@ function add_info(string $message): void { * @param string $title The title of the page being displayed */ function page_head(string $title): void { - ?> + $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 + } ?> + @@ -57,22 +57,22 @@ 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 | Docs | Log Off'; + if ($_SESSION[Key::USER_EMAIL] != Security::SINGLE_USER_EMAIL) echo " | {$_SESSION[Key::USER_EMAIL]}"; + } else { + echo 'Log On | Docs'; } ?>
-
- {$msg['level']}
"?> - -
+
+ {$msg['level']}
"?> + +
'; + session_commit(); } /** @@ -94,8 +95,8 @@ function frc_redirect(string $value): void { http_response_code(400); die(); } - header("Location: $value"); - http_response_code(303); + session_commit(); + header("Location: $value", true, 303); die(); } diff --git a/src/user-config.php b/src/user-config.dist.php similarity index 81% rename from src/user-config.php rename to src/user-config.dist.php index 55af358..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 */ @@ -11,10 +13,8 @@ * - Security::SINGLE_USER (no e-mail required, does not require a password) * - Security::SINGLE_USER_WITH_PASSWORD (no e-mail required, does require a password) * - Security::MULTI_USER (e-mail and password required for all users) - * - * (NOTE THAT ONLY SINGLE_USER IS CURRENTLY IMPLEMENTED) */ -const SECURITY_MODEL = Security::SINGLE_USER; +const SECURITY_MODEL = 'CONFIGURE_ME'; /** The name of the database file where users and feeds should be kept */ const DATABASE_NAME = 'frc.db'; @@ -25,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) diff --git a/src/util/user.php b/src/util/user.php new file mode 100644 index 0000000..0155ca7 --- /dev/null +++ b/src/util/user.php @@ -0,0 +1,219 @@ +close(); + } +} + +/** + * Get the way we will refer to the user against whom action is being taken + * + * @param string $email The e-mail address of the user + * @return string The string to use when displaying results + */ +function display_user(string $email): string { + return $email == Security::SINGLE_USER_EMAIL ? 'single-user mode user' : "user \"$email\""; +} + +/** + * Set a user's password + */ +function set_password(string $email, string $password): void { + $db = Data::getConnection(); + try { + $displayUser = display_user($email); + + // Ensure this user exists + $user = Security::findUserByEmail($email, $db); + if (!$user) { + printfn('No %s exists', $displayUser); + return; + } + + Security::updatePassword($email, $password, $db); + + $msg = $email == Security::SINGLE_USER_EMAIL && $password == Security::SINGLE_USER_PASSWORD + ? 'reset' : sprintf('set to "%s"', $password); + printfn('%s password %s successfully', init_cap($displayUser), $msg); + } finally { + $db->close(); + } +} + +/** + * Delete a user + * + * @param string $email The e-mail address of the user to be deleted + */ +function delete_user(string $email): void { + + $db = Data::getConnection(); + + try { + $displayUser = display_user($email); + + // Get the ID for the provided e-mail address + $user = Security::findUserByEmail($email, $db); + if (!$user) { + printfn('No %s exists', $displayUser); + return; + } + + $feedCountQuery = $db->prepare('SELECT COUNT(*) FROM feed WHERE user_id = :user'); + $feedCountQuery->bindValue(':user', $user['id']); + $feedCountResult = $feedCountQuery->execute(); + if (!$feedCountResult) { + printfn('SQLite error: %s', $db->lastErrorMsg()); + return; + } + $feedCount = $feedCountResult->fetchArray(SQLITE3_NUM); + + $proceed = readline("Delete the $displayUser and their $feedCount[0] feed(s)? (y/N)" . PHP_EOL); + if (!$proceed || !str_starts_with(strtolower($proceed), 'y')) { + printfn('Deletion canceled'); + return; + } + + $itemDelete = $db->prepare('DELETE FROM item WHERE feed_id IN (SELECT id FROM feed WHERE user_id = :user)'); + $itemDelete->bindValue(':user', $user['id']); + $itemDelete->execute(); + + $feedDelete = $db->prepare('DELETE FROM feed WHERE user_id = :user'); + $feedDelete->bindValue(':user', $user['id']); + $feedDelete->execute(); + + $userDelete = $db->prepare('DELETE FROM frc_user WHERE id = :user'); + $userDelete->bindValue(':user', $user['id']); + $userDelete->execute(); + + printfn('%s deleted successfully', init_cap($displayUser)); + } finally { + $db->close(); + } +} + +/** + * Change the single-user mode user to a different e-mail address and password + */ +function migrate_single_user(): void { + global $argv; + + $db = Data::getConnection(); + + try { + $single = Security::findUserByEmail(Security::SINGLE_USER_EMAIL, $db); + if (!$single) { + printfn('There is no single-user mode user to be migrated'); + return; + } + + $migrateQuery = $db->prepare('UPDATE frc_user SET email = :email, password = :password WHERE id = :id'); + $migrateQuery->bindValue(':email', $argv[2]); + $migrateQuery->bindValue(':password', password_hash($argv[3], Security::PW_ALGORITHM)); + $migrateQuery->bindValue(':id', $single['id']); + $migrateQuery->execute(); + + printfn('The single user has been moved to "%s", with password "%s"', $argv[2], $argv[3]); + } finally { + $db->close(); + } +}