alpha1 #8
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @ -0,0 +1,2 @@ | |||||||
|  | .idea | ||||||
|  | src/data/*.db | ||||||
							
								
								
									
										45
									
								
								INSTALLING.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								INSTALLING.md
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,45 @@ | |||||||
|  | # Installation | ||||||
|  | 
 | ||||||
|  | ## All Environments (FrankenPHP) | ||||||
|  | 
 | ||||||
|  | The easiest way to get up and running quickly is by using [FrankenPHP](https://frankenphp.dev), a version of [Caddy](https://caddyserver.com) that runs PHP in its process. There is a `Caddyfile` in the `/src` directory which will configure the site to run with FrankenPHP. | ||||||
|  | 
 | ||||||
|  | For Linux / Mac users: | ||||||
|  | - Follow [their instructions](https://frankenphp.dev/docs/#standalone-binary) for downloading a binary for your system | ||||||
|  | - Rename that binary to `frankenphp` and make it executable (`chmod +x ./frankenphp`) | ||||||
|  | - Move that binary to `/usr/local/bin` | ||||||
|  | 
 | ||||||
|  | For Windows users, the steps are the same; however, the binary should be named `frankenphp.exe` and be placed somewhere within your system's `PATH`. | ||||||
|  | 
 | ||||||
|  | Once those steps are complete, from the `/src` directory, run `frankenphp run`. | ||||||
|  | 
 | ||||||
|  | _(More environments will be detailed as part of a later release; an nginx reverse proxy via FastCGI is another common way to run PHP applications.)_ | ||||||
|  | 
 | ||||||
|  | ## 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.)_ | ||||||
|  | 
 | ||||||
|  | # Setup and Configuration | ||||||
|  | 
 | ||||||
|  | ## Site Address | ||||||
|  | 
 | ||||||
|  | The default `Caddyfile` will run the site at `http://localhost:8205`. To have the process respond to other devices on your network, you can add the server name to that to line 5 (ex. `http://localhost:8205, http://server:8205`); you can also change the port on which it listens. (Note that if `http` is not specified, Caddy will attempt to obtain and install a server certificate. This may be what you want, but that also could be a source of startup errors.) | ||||||
|  | 
 | ||||||
|  | ## 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. | ||||||
|  | 
 | ||||||
|  | ### Security Model | ||||||
|  | 
 | ||||||
|  | There ~~are~~ will be three supported security models, designed around different ways the software may be deployed. | ||||||
|  | - `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. | ||||||
|  | 
 | ||||||
|  | ### Database Name | ||||||
|  | 
 | ||||||
|  | Data is stored under the `/src/data` directory, and the default database name is `frc.db`. If users want to change that path or file name, the path provided should be relative to `/src/data`, not just `/src`. | ||||||
|  | 
 | ||||||
|  | ### Date/Time Format | ||||||
|  | 
 | ||||||
|  | The default format for dates and times look like "May 28, 2023 at 3:15pm". Changing the string there will alter the display on the main page and when reading an item. Any [supported PHP date or time token](https://www.php.net/manual/en/datetime.format.php) is supported. | ||||||
| @ -1,3 +1,7 @@ | |||||||
| # feed-reader-central | # Feed Reader Central | ||||||
| 
 | 
 | ||||||
| A centralized, lightweight feed reader with simple self-hosting | Feed Reader Central is a lightweight feed reader with simple self-hosting. The self-hosted instance serves as a place where feeds can be read and referenced from different devices. | ||||||
|  | 
 | ||||||
|  | It is written in vanilla PHP, and uses a SQLite database to keep track of items. | ||||||
|  | 
 | ||||||
|  | See [INSTALLING.md](./INSTALLING.md) for setup and configuration instructions. | ||||||
|  | |||||||
							
								
								
									
										9
									
								
								src/Caddyfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								src/Caddyfile
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,9 @@ | |||||||
|  | { | ||||||
|  | 	frankenphp | ||||||
|  | 	order php_server before file_server | ||||||
|  | } | ||||||
|  | http://localhost:8205 { | ||||||
|  | 	root ./public | ||||||
|  | 	try_files {path} {path}.php | ||||||
|  | 	php_server | ||||||
|  | } | ||||||
							
								
								
									
										0
									
								
								src/data/.gitkeep
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								src/data/.gitkeep
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										142
									
								
								src/lib/Data.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										142
									
								
								src/lib/Data.php
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,142 @@ | |||||||
|  | <?php | ||||||
|  | /** | ||||||
|  |  * A centralized place for data access for the application | ||||||
|  |  */ | ||||||
|  | class Data { | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Obtain a new connection to the database | ||||||
|  |      * @return SQLite3 A new connection to the database | ||||||
|  |      */ | ||||||
|  |     public static function getConnection(): SQLite3 { | ||||||
|  |         $db = new SQLite3('../data/' . DATABASE_NAME); | ||||||
|  |         $db->exec('PRAGMA foreign_keys = ON;'); | ||||||
|  |         return $db; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Make sure the expected tables exist | ||||||
|  |      */ | ||||||
|  |     public static function ensureDb(): void { | ||||||
|  |         $db = self::getConnection(); | ||||||
|  |         $tables = array(); | ||||||
|  |         $tableQuery = $db->query("SELECT name FROM sqlite_master WHERE type = 'table'"); | ||||||
|  |         while ($table = $tableQuery->fetchArray(SQLITE3_NUM)) $tables[] = $table[0]; | ||||||
|  |         if (!in_array('frc_user', $tables)) { | ||||||
|  |             $query = <<<'SQL' | ||||||
|  |                 CREATE TABLE frc_user ( | ||||||
|  |                     id       INTEGER NOT NULL PRIMARY KEY, | ||||||
|  |                     email    TEXT    NOT NULL, | ||||||
|  |                     password TEXT    NOT NULL) | ||||||
|  |                 SQL; | ||||||
|  |             $db->exec($query); | ||||||
|  |             $db->exec('CREATE INDEX idx_user_email ON frc_user (email)'); | ||||||
|  |         } | ||||||
|  |         if (!in_array('feed', $tables)) { | ||||||
|  |             $query = <<<'SQL' | ||||||
|  |                 CREATE TABLE feed ( | ||||||
|  |                     id         INTEGER NOT NULL PRIMARY KEY, | ||||||
|  |                     user_id    INTEGER NOT NULL, | ||||||
|  |                     url        TEXT    NOT NULL, | ||||||
|  |                     title      TEXT, | ||||||
|  |                     updated_on TEXT, | ||||||
|  |                     checked_on TEXT, | ||||||
|  |                     FOREIGN KEY (user_id) REFERENCES frc_user (id)) | ||||||
|  |                 SQL; | ||||||
|  |             $db->exec($query); | ||||||
|  |         } | ||||||
|  |         if (!in_array('item', $tables)) { | ||||||
|  |             $query = <<<'SQL' | ||||||
|  |                 CREATE TABLE item ( | ||||||
|  |                     id            INTEGER NOT NULL PRIMARY KEY, | ||||||
|  |                     feed_id       INTEGER NOT NULL, | ||||||
|  |                     title         TEXT    NOT NULL, | ||||||
|  |                     item_guid     TEXT    NOT NULL, | ||||||
|  |                     item_link     TEXT    NOT NULL, | ||||||
|  |                     published_on  TEXT    NOT NULL, | ||||||
|  |                     updated_on    TEXT, | ||||||
|  |                     content       TEXT    NOT NULL, | ||||||
|  |                     is_read       BOOLEAN NOT NULL DEFAULT 0, | ||||||
|  |                     is_bookmarked BOOLEAN NOT NULL DEFAULT 0, | ||||||
|  |                     FOREIGN KEY (feed_id) REFERENCES feed (id)) | ||||||
|  |                 SQL; | ||||||
|  |             $db->exec($query); | ||||||
|  |         } | ||||||
|  |         $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 | ||||||
|  |      * | ||||||
|  |      * @param ?string $value The date/time to be parsed and formatted | ||||||
|  |      * @return string|null The date/time in `DateTimeInterface::ATOM` format, or `null` if the input cannot be parsed | ||||||
|  |      */ | ||||||
|  |     public static function formatDate(?string $value): ?string { | ||||||
|  |         try { | ||||||
|  |             return $value ? (new DateTimeImmutable($value))->format(DateTimeInterface::ATOM) : null; | ||||||
|  |         } catch (Exception) { | ||||||
|  |             return null; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Retrieve a feed by its ID for the current user | ||||||
|  |      * | ||||||
|  |      * @param int $feedId The ID of the feed to retrieve | ||||||
|  |      * @param ?SQLite3 $dbConn A database connection to use (optional; will use standalone if not provided) | ||||||
|  |      * @return array|bool The data for the feed if found, false if not found | ||||||
|  |      */ | ||||||
|  |     public static function retrieveFeedById(int $feedId, ?SQLite3 $dbConn = null): array|bool { | ||||||
|  |         $db = $dbConn ?? self::getConnection(); | ||||||
|  |         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]); | ||||||
|  |             $result = $query->execute(); | ||||||
|  |             return $result ? $result->fetchArray(SQLITE3_ASSOC) : false; | ||||||
|  |         } finally { | ||||||
|  |             if (is_null($dbConn)) $db->close(); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										329
									
								
								src/lib/Feed.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										329
									
								
								src/lib/Feed.php
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,329 @@ | |||||||
|  | <?php | ||||||
|  | /** | ||||||
|  |  * Feed retrieval, parsing, and manipulation | ||||||
|  |  */ | ||||||
|  | class Feed { | ||||||
|  | 
 | ||||||
|  |     /** @var string The XML namespace for Atom feeds */ | ||||||
|  |     public const string ATOM_NS = 'http://www.w3.org/2005/Atom'; | ||||||
|  | 
 | ||||||
|  |     /** @var string The XML namespace for the `<content:encoded>` tag that allows HTML content in a feed */ | ||||||
|  |     public const string CONTENT_NS = 'http://purl.org/rss/1.0/modules/content/'; | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * When parsing XML into a DOMDocument, errors are presented as warnings; this creates an exception for them | ||||||
|  |      * | ||||||
|  |      * @param int $errno The error level encountered | ||||||
|  |      * @param string $errstr The text of the error encountered | ||||||
|  |      * @return bool False, to delegate to the next error handler in the chain | ||||||
|  |      * @throws DOMException If the error is a warning | ||||||
|  |      */ | ||||||
|  |     private static function xmlParseError(int $errno, string $errstr): bool { | ||||||
|  |         if ($errno == E_WARNING && substr_count($errstr, 'DOMDocument::loadXml()') > 0) { | ||||||
|  |             throw new DOMException($errstr, $errno); | ||||||
|  |         } | ||||||
|  |         return false; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Parse a feed into an XML tree | ||||||
|  |      * | ||||||
|  |      * @param string $content The feed's RSS content | ||||||
|  |      * @return array|DOMDocument[]|string[] ['ok' => feed] if successful, ['error' => message] if not | ||||||
|  |      */ | ||||||
|  |     public static function parseFeed(string $content): array { | ||||||
|  |         set_error_handler(self::xmlParseError(...)); | ||||||
|  |         try { | ||||||
|  |             $feed = new DOMDocument(); | ||||||
|  |             $feed->loadXML($content); | ||||||
|  |             return ['ok' => $feed]; | ||||||
|  |         } catch (DOMException $ex) { | ||||||
|  |             return ['error' => $ex->getMessage()]; | ||||||
|  |         } finally { | ||||||
|  |             restore_error_handler(); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Get the value of a child element by its tag name | ||||||
|  |      * | ||||||
|  |      * @param DOMElement $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 eltValue(DOMElement $element, string $tagName): string { | ||||||
|  |         $tags = $element->getElementsByTagName($tagName); | ||||||
|  |         return $tags->length == 0 ? "$tagName not found" : $tags->item(0)->textContent; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Retrieve the feed | ||||||
|  |      * | ||||||
|  |      * @param string $url | ||||||
|  |      * @return array|DOMDocument[]|string[]|DOMElement[] | ||||||
|  |      *      ['ok' => feedXml, 'url' => actualUrl, 'channel' => channel, 'updated' => updatedDate] if successful, | ||||||
|  |      *      ['error' => message] if not | ||||||
|  |      */ | ||||||
|  |     public static function retrieveFeed(string $url): array { | ||||||
|  |         $feedReq = curl_init($url); | ||||||
|  |         curl_setopt($feedReq, CURLOPT_FOLLOWLOCATION, true); | ||||||
|  |         curl_setopt($feedReq, CURLOPT_RETURNTRANSFER, true); | ||||||
|  |         curl_setopt($feedReq, CURLOPT_CONNECTTIMEOUT, 5); | ||||||
|  |         curl_setopt($feedReq, CURLOPT_TIMEOUT,        15); | ||||||
|  | 
 | ||||||
|  |         $feedContent = curl_exec($feedReq); | ||||||
|  | 
 | ||||||
|  |         $result = array(); | ||||||
|  |         $error  = curl_error($feedReq); | ||||||
|  |         $code   = curl_getinfo($feedReq, CURLINFO_RESPONSE_CODE); | ||||||
|  |         if ($error) { | ||||||
|  |             $result['error'] = $error; | ||||||
|  |         } else if ($code == 200) { | ||||||
|  |             $parsed = self::parseFeed($feedContent); | ||||||
|  |             if (array_key_exists('error', $parsed)) { | ||||||
|  |                 $result['error'] = $parsed['error']; | ||||||
|  |             } else { | ||||||
|  |                 $result['ok']  = $parsed['ok']; | ||||||
|  |                 $result['url'] = curl_getinfo($feedReq, CURLINFO_EFFECTIVE_URL); | ||||||
|  | 
 | ||||||
|  |                 $channel = $result['ok']->getElementsByTagName('channel')->item(0); | ||||||
|  |                 if ($channel instanceof DOMElement) { | ||||||
|  |                     $result['channel'] = $channel; | ||||||
|  |                 } else { | ||||||
|  |                     return ['error' => "Channel element not found ($channel->nodeType)"]; | ||||||
|  |                 } | ||||||
|  | 
 | ||||||
|  |                 // In Atom feeds, lastBuildDate contains the last time an item in the feed was updated; if that is not
 | ||||||
|  |                 // present, use the pubDate element instead
 | ||||||
|  |                 $updated = self::eltValue($channel, 'lastBuildDate'); | ||||||
|  |                 if ($updated == 'lastBuildDate not found') { | ||||||
|  |                     $updated = self::eltValue($channel, 'pubDate'); | ||||||
|  |                     if ($updated == 'pubDate not found') $updated = null; | ||||||
|  |                 } | ||||||
|  |                 $result['updated'] = Data::formatDate($updated); | ||||||
|  |                 return $result; | ||||||
|  | 
 | ||||||
|  |             } | ||||||
|  |         } else { | ||||||
|  |             $result['error'] = "Prospective feed URL $url returned HTTP Code $code: $feedContent"; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         curl_close($feedReq); | ||||||
|  |         return $result; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Extract the fields we need to keep from the feed | ||||||
|  |      * | ||||||
|  |      * @param DOMElement $item The item from the feed | ||||||
|  |      * @return array The fields for the item as an associative array | ||||||
|  |      */ | ||||||
|  |     private static function itemFields(DOMElement $item): array { | ||||||
|  |         $itemGuid = self::eltValue($item, 'guid'); | ||||||
|  |         $updNodes = $item->getElementsByTagNameNS(self::ATOM_NS,    'updated'); | ||||||
|  |         $encNodes = $item->getElementsByTagNameNS(self::CONTENT_NS, 'encoded'); | ||||||
|  |         return [ | ||||||
|  |             'guid'      => $itemGuid == 'guid not found' ? self::eltValue($item, 'link') : $itemGuid, | ||||||
|  |             'title'     => self::eltValue($item, 'title'), | ||||||
|  |             'link'      => self::eltValue($item, 'link'), | ||||||
|  |             'published' => Data::formatDate(self::eltValue($item, 'pubDate')), | ||||||
|  |             'updated'   => Data::formatDate($updNodes->length > 0 ? $updNodes->item(0)->textContent : null), | ||||||
|  |             'content'   => $encNodes->length > 0 ? $encNodes->item(0)->textContent | ||||||
|  |                                                  : self::eltValue($item, 'description') | ||||||
|  |         ]; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Update a feed item | ||||||
|  |      * | ||||||
|  |      * @param int $itemId The ID of the item to be updated | ||||||
|  |      * @param array $item The fields from the updated item | ||||||
|  |      * @param SQLite3 $db A database connection to use for the update | ||||||
|  |      */ | ||||||
|  |     private static function updateItem(int $itemId, array $item, SQLite3 $db): void { | ||||||
|  |         $query = $db->prepare(<<<'SQL' | ||||||
|  |             UPDATE item | ||||||
|  |                SET title        = :title, | ||||||
|  |                    published_on = :published, | ||||||
|  |                    updated_on   = :updated, | ||||||
|  |                    content      = :content, | ||||||
|  |                    is_read      = 0 | ||||||
|  |              WHERE id = :id | ||||||
|  |             SQL); | ||||||
|  |         $query->bindValue(':title',     $item['title']); | ||||||
|  |         $query->bindValue(':published', $item['published']); | ||||||
|  |         $query->bindValue(':updated',   $item['updated']); | ||||||
|  |         $query->bindValue(':content',   $item['content']); | ||||||
|  |         $query->bindValue(':id',        $itemId); | ||||||
|  |         $query->execute(); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Add a feed item | ||||||
|  |      * | ||||||
|  |      * @param int $feedId The ID of the feed to which the item should be added | ||||||
|  |      * @param array $item The fields for the item | ||||||
|  |      * @param SQLite3 $db A database connection to use for the addition | ||||||
|  |      */ | ||||||
|  |     private static function addItem(int $feedId, array $item, SQLite3 $db): void { | ||||||
|  |         $query = $db->prepare(<<<'SQL' | ||||||
|  |             INSERT INTO item ( | ||||||
|  |                 feed_id, item_guid, item_link, title, published_on, updated_on, content | ||||||
|  |             ) VALUES ( | ||||||
|  |                 :feed, :guid, :link, :title, :published, :updated, :content | ||||||
|  |             ) | ||||||
|  |             SQL); | ||||||
|  |         $query->bindValue(':feed',      $feedId); | ||||||
|  |         $query->bindValue(':guid',      $item['guid']); | ||||||
|  |         $query->bindValue(':link',      $item['link']); | ||||||
|  |         $query->bindValue(':title',     $item['title']); | ||||||
|  |         $query->bindValue(':published', $item['published']); | ||||||
|  |         $query->bindValue(':updated',   $item['updated']); | ||||||
|  |         $query->bindValue(':content',   $item['content']); | ||||||
|  |         $query->execute(); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Update a feed's items | ||||||
|  |      * | ||||||
|  |      * @param int $feedId The ID of the feed to which these items belong | ||||||
|  |      * @param DOMElement $channel The RSS feed items | ||||||
|  |      * @return array ['ok' => true] if successful, ['error' => message] if not | ||||||
|  |      */ | ||||||
|  |     public static function updateItems(int $feedId, DOMElement $channel, SQLite3 $db): array { | ||||||
|  |         try { | ||||||
|  |             foreach ($channel->getElementsByTagName('item') as $rawItem) { | ||||||
|  |                 $item = self::itemFields($rawItem); | ||||||
|  |                 $existsQuery = $db->prepare( | ||||||
|  |                     'SELECT id, published_on, updated_on FROM item WHERE feed_id = :feed AND item_guid = :guid'); | ||||||
|  |                 $existsQuery->bindValue(':feed', $feedId); | ||||||
|  |                 $existsQuery->bindValue(':guid', $item['guid']); | ||||||
|  |                 $exists = $existsQuery->execute(); | ||||||
|  |                 if ($exists) { | ||||||
|  |                     $existing = $exists->fetchArray(SQLITE3_ASSOC); | ||||||
|  |                     if ($existing) { | ||||||
|  |                         if (   $existing['published_on']     != $item['published'] | ||||||
|  |                             || $existing['updated_on'] ?? '' != $item['updated'] ?? '') { | ||||||
|  |                             self::updateItem($existing['id'], $item, $db); | ||||||
|  |                         } | ||||||
|  |                     } else { | ||||||
|  |                         self::addItem($feedId, $item, $db); | ||||||
|  |                     } | ||||||
|  |                 } else { | ||||||
|  |                     throw new Exception($db->lastErrorMsg()); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } catch (Exception $ex) { | ||||||
|  |             return ['error' => $ex->getMessage()]; | ||||||
|  |         } | ||||||
|  |         return ['ok', true]; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Refresh a feed | ||||||
|  |      * | ||||||
|  |      * @param string $url The URL of the feed to be refreshed | ||||||
|  |      * @param SQLite3 $db A database connection to use to refresh the feed | ||||||
|  |      * @return array|string[]|true[] ['ok' => true] if successful, ['error' => message] if not | ||||||
|  |      */ | ||||||
|  |     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]); | ||||||
|  |         $feedResult = $feedQuery->execute(); | ||||||
|  |         $feedId = $feedResult ? $feedResult->fetchArray(SQLITE3_NUM)[0] : -1; | ||||||
|  |         if ($feedId < 0) return ['error' => "No feed for URL $url found"]; | ||||||
|  | 
 | ||||||
|  |         $feed = self::retrieveFeed($url); | ||||||
|  |         if (array_key_exists('error', $feed)) return $feed; | ||||||
|  | 
 | ||||||
|  |         $itemUpdate = self::updateItems($feedId, $feed['channel'], $db); | ||||||
|  |         if (array_key_exists('error', $itemUpdate)) return $itemUpdate; | ||||||
|  | 
 | ||||||
|  |         $urlUpdate = $url == $feed['url'] ? '' : ', url = :url'; | ||||||
|  |         $feedUpdate = $db->prepare(<<<SQL | ||||||
|  |             UPDATE feed | ||||||
|  |                SET title      = :title, | ||||||
|  |                    updated_on = :updated, | ||||||
|  |                    checked_on = :checked | ||||||
|  |                    $urlUpdate | ||||||
|  |              WHERE id = :id | ||||||
|  |             SQL); | ||||||
|  |         $feedUpdate->bindValue(':title',   self::eltValue($feed['channel'], 'title')); | ||||||
|  |         $feedUpdate->bindValue(':updated', $feed['updated']); | ||||||
|  |         $feedUpdate->bindValue(':checked', Data::formatDate('now')); | ||||||
|  |         $feedUpdate->bindValue(':id',      $feedId); | ||||||
|  |         if ($urlUpdate != '') $feedUpdate->bindValue(':url', $feed['url']); | ||||||
|  |         $feedUpdate->execute(); | ||||||
|  | 
 | ||||||
|  |         return ['ok' => true]; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Add an RSS feed | ||||||
|  |      * | ||||||
|  |      * @param string $url The URL of the RSS feed to add | ||||||
|  |      * @return array ['ok' => feedId] if successful, ['error' => message] if not | ||||||
|  |      */ | ||||||
|  |     public static function add(string $url, SQLite3 $db): array { | ||||||
|  |         $feed = self::retrieveFeed($url); | ||||||
|  |         if (array_key_exists('error', $feed)) return $feed; | ||||||
|  | 
 | ||||||
|  |         $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(':url',     $feed['url']); | ||||||
|  |         $query->bindValue(':title',   self::eltValue($feed['channel'], 'title')); | ||||||
|  |         $query->bindValue(':updated', $feed['updated']); | ||||||
|  |         $query->bindValue(':checked', Data::formatDate('now')); | ||||||
|  |         $result = $query->execute(); | ||||||
|  | 
 | ||||||
|  |         $feedId = $result ? $db->lastInsertRowID() : -1; | ||||||
|  |         if ($feedId < 0) return ['error' => $db->lastErrorMsg()]; | ||||||
|  | 
 | ||||||
|  |         $result = self::updateItems($feedId, $feed['channel'], $db); | ||||||
|  |         if (array_key_exists('error', $result)) return $result; | ||||||
|  | 
 | ||||||
|  |         return ['ok' => $feedId]; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Update an RSS feed | ||||||
|  |      * | ||||||
|  |      * @param array $existing The existing RSS feed | ||||||
|  |      * @param string $url The URL with which the existing feed should be modified | ||||||
|  |      * @return bool[]|string[] [ 'ok' => true ] if successful, [ 'error' => message ] if not | ||||||
|  |      */ | ||||||
|  |     public static function update(array $existing, string $url, SQLite3 $db): array { | ||||||
|  |         $query = $db->prepare('UPDATE feed SET url = :url WHERE id = :id AND user_id = :user'); | ||||||
|  |         $query->bindValue(':url',  $url); | ||||||
|  |         $query->bindValue(':id',   $existing['id']); | ||||||
|  |         $query->bindValue(':user', $_REQUEST[Key::USER_ID]); | ||||||
|  |         $query->execute(); | ||||||
|  | 
 | ||||||
|  |         return self::refreshFeed($url, $db); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * @param SQLite3 $db | ||||||
|  |      * @return array|true[] ['ok => true] if successful, ['error' => message] if not (may have multiple error lines) | ||||||
|  |      */ | ||||||
|  |     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]); | ||||||
|  |         $result = $query->execute(); | ||||||
|  |         $url    = $result ? $result->fetchArray(SQLITE3_NUM) : false; | ||||||
|  |         if ($url) { | ||||||
|  |             $errors = array(); | ||||||
|  |             while ($url) { | ||||||
|  |                 $updateResult = self::refreshFeed($url[0], $db); | ||||||
|  |                 if (array_key_exists('error', $updateResult)) $errors[] = $updateResult['error']; | ||||||
|  |                 $url = $result->fetchArray(SQLITE3_NUM); | ||||||
|  |             } | ||||||
|  |             return sizeof($errors) == 0 ? ['ok' => true] : ['error' => implode("\n", $errors)]; | ||||||
|  |         } | ||||||
|  |         return ['error' => $db->lastErrorMsg()]; | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										13
									
								
								src/lib/Key.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								src/lib/Key.php
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,13 @@ | |||||||
|  | <?php | ||||||
|  | 
 | ||||||
|  | class Key { | ||||||
|  | 
 | ||||||
|  |     /** @var string The $_REQUEST key for teh 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 */ | ||||||
|  |     public const string USER_ID = 'FRC_USER_ID'; | ||||||
|  | 
 | ||||||
|  |     /** @var string The $_REQUEST key for the array of user messages to display */ | ||||||
|  |     public const string USER_MSG = 'FRC_USER_MSG'; | ||||||
|  | } | ||||||
							
								
								
									
										51
									
								
								src/lib/Security.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								src/lib/Security.php
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,51 @@ | |||||||
|  | <?php | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Security functions | ||||||
|  |  */ | ||||||
|  | class Security { | ||||||
|  | 
 | ||||||
|  |     /** @var int Run as a single user requiring no password */ | ||||||
|  |     public const int SINGLE_USER = 0; | ||||||
|  | 
 | ||||||
|  |     /** @var int Run as a single user requiring a password */ | ||||||
|  |     public const int SINGLE_USER_WITH_PASSWORD = 1; | ||||||
|  | 
 | ||||||
|  |     /** @var int Require users to provide e-mail address and password */ | ||||||
|  |     public const int MULTI_USER = 2; | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Verify that user is logged on | ||||||
|  |      * @param bool $redirectIfAnonymous Whether to redirect the request if there is no user logged on | ||||||
|  |      */ | ||||||
|  |     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']; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Retrieve the single user | ||||||
|  |      * @return array The user information for the single 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'); | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										89
									
								
								src/public/assets/style.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								src/public/assets/style.css
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,89 @@ | |||||||
|  | html { | ||||||
|  |     min-height: 100vh; | ||||||
|  |     font-family: sans-serif; | ||||||
|  | } | ||||||
|  | body { | ||||||
|  |     margin: 0; | ||||||
|  |     font-size: 1rem; | ||||||
|  |     background: linear-gradient(135deg, #eeeeff, #ddddff, #eeeeff, #ccccff) fixed; | ||||||
|  | } | ||||||
|  | a:link, a:visited { | ||||||
|  |     text-decoration: none; | ||||||
|  |     font-weight: bold; | ||||||
|  |     color: navy; | ||||||
|  | } | ||||||
|  | a:hover { | ||||||
|  |     text-decoration: underline; | ||||||
|  | } | ||||||
|  | header { | ||||||
|  |     padding: 0 1rem .5rem 1rem; | ||||||
|  |     background: linear-gradient(#000032, #000048, #000064); | ||||||
|  |     border-bottom-left-radius: .5rem; | ||||||
|  |     border-bottom-right-radius: .5rem; | ||||||
|  |     color: white; | ||||||
|  |     display: flex; | ||||||
|  |     flex-flow: row nowrap; | ||||||
|  |     justify-content: space-between; | ||||||
|  |     align-items: baseline; | ||||||
|  | 
 | ||||||
|  |     .title { | ||||||
|  |         font-size: 1.5rem; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     a:link, a:visited { | ||||||
|  |         color: white; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | main { | ||||||
|  |     padding: 0 .5rem; | ||||||
|  | 
 | ||||||
|  |     .item_heading { | ||||||
|  |         margin-bottom: 0; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     .item_published { | ||||||
|  |         margin-bottom: 1rem; | ||||||
|  |         line-height: 1.2; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | article { | ||||||
|  |     max-width: 60rem; | ||||||
|  |     margin: auto; | ||||||
|  | 
 | ||||||
|  |     .item_content { | ||||||
|  |         border: solid 1px navy; | ||||||
|  |         border-radius: .5rem; | ||||||
|  |         background-color: white; | ||||||
|  |         padding: .5rem; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | input[type=url], input[type=text] { | ||||||
|  |     width: 50%; | ||||||
|  |     font-size: 1rem; | ||||||
|  |     padding: .25rem; | ||||||
|  |     border-radius: .25rem; | ||||||
|  | } | ||||||
|  | button, | ||||||
|  | .action_buttons a:link, | ||||||
|  | .action_buttons a:visited { | ||||||
|  |     font-size: 1rem; | ||||||
|  |     font-weight: normal; | ||||||
|  |     background-color: navy; | ||||||
|  |     color: white; | ||||||
|  |     padding: .5rem 1rem; | ||||||
|  |     border-radius: .25rem; | ||||||
|  |     cursor: pointer; | ||||||
|  |     border: none; | ||||||
|  | } | ||||||
|  | button:hover, | ||||||
|  | .action_buttons a:hover { | ||||||
|  |     text-decoration: none; | ||||||
|  |     cursor: pointer; | ||||||
|  |     background: linear-gradient(navy, #000032); | ||||||
|  | } | ||||||
|  | .action_buttons { | ||||||
|  |     margin: 1rem 0; | ||||||
|  |     display: flex; | ||||||
|  |     flex-flow: row nowrap; | ||||||
|  |     justify-content: space-evenly; | ||||||
|  | } | ||||||
							
								
								
									
										61
									
								
								src/public/feed.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								src/public/feed.php
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,61 @@ | |||||||
|  | <?php | ||||||
|  | /** | ||||||
|  |  * Add/Edit Feed Page | ||||||
|  |  * | ||||||
|  |  * Allows users to add or edit RSS feeds | ||||||
|  |  */ | ||||||
|  | 
 | ||||||
|  | include '../start.php'; | ||||||
|  | 
 | ||||||
|  | Security::verifyUser(); | ||||||
|  | 
 | ||||||
|  | $feedId = array_key_exists('id', $_GET) ? $_GET['id'] : ''; | ||||||
|  | $db     = Data::getConnection(); | ||||||
|  | 
 | ||||||
|  | if ($_SERVER['REQUEST_METHOD'] == 'POST') { | ||||||
|  |     $isNew = $_POST['id'] == 'new'; | ||||||
|  |     if ($isNew) { | ||||||
|  |         $result = Feed::add($_POST['url'], $db); | ||||||
|  |     } else { | ||||||
|  |         $toEdit = Data::retrieveFeedById($_POST['id'], $db); | ||||||
|  |         $result = $toEdit ? Feed::update($toEdit, $_POST['url'], $db) : ['error' => "Feed {$_POST['id']} not found"]; | ||||||
|  |     } | ||||||
|  |     if (array_key_exists('ok', $result)) { | ||||||
|  |         add_info('Feed saved successfully'); | ||||||
|  |         $feedId = $isNew ? $result['ok'] : $_POST['id']; | ||||||
|  |     } else { | ||||||
|  |         add_error($result['error']); | ||||||
|  |         $feedId = 'error'; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | if ($feedId == 'new') { | ||||||
|  |     $title = 'Add RSS Feed'; | ||||||
|  |     $feed = [ 'id' => $_GET['id'], 'url' => '' ]; | ||||||
|  | } else { | ||||||
|  |     $title = 'Edit RSS Feed'; | ||||||
|  |     if ($feedId == 'error') { | ||||||
|  |         $feed = ['id' => $_POST['id'] ?? '', 'url' => $_POST['url'] ?? '']; | ||||||
|  |     } else { | ||||||
|  |         $feed = Data::retrieveFeedById((int) $feedId, $db); | ||||||
|  |         if (!$feed) { | ||||||
|  |             http_response_code(404); | ||||||
|  |             die(); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | page_head($title); ?>
 | ||||||
|  | <h1><?=$title?></h1>
 | ||||||
|  | <article> | ||||||
|  |     <form method=POST action=/feed hx-post=/feed> | ||||||
|  |         <input type=hidden name=id value=<?=$feed['id']?>>
 | ||||||
|  |         <label> | ||||||
|  |             Feed URL | ||||||
|  |             <input type=url name=url required autofocus value="<?=$feed['url']?>"> | ||||||
|  |         </label><br> | ||||||
|  |         <button type=submit>Save</button> | ||||||
|  |     </form> | ||||||
|  | </article><?php | ||||||
|  | page_foot(); | ||||||
|  | $db->close(); | ||||||
							
								
								
									
										48
									
								
								src/public/index.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								src/public/index.php
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,48 @@ | |||||||
|  | <?php | ||||||
|  | /** | ||||||
|  |  * Home Page | ||||||
|  |  * | ||||||
|  |  * Displays a list of unread feed items for the current user | ||||||
|  |  */ | ||||||
|  | 
 | ||||||
|  | include '../start.php'; | ||||||
|  | 
 | ||||||
|  | Security::verifyUser(); | ||||||
|  | 
 | ||||||
|  | $db = Data::getConnection(); | ||||||
|  | 
 | ||||||
|  | if (array_key_exists('refresh', $_GET)) { | ||||||
|  |     $refreshResult = Feed::refreshAll($db); | ||||||
|  |     if (array_key_exists('ok', $refreshResult)) { | ||||||
|  |         add_info('All feeds refreshed successfully'); | ||||||
|  |     } else { | ||||||
|  |         add_error(nl2br($refreshResult['error'])); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | $result = $db->query(<<<'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 | ||||||
|  |      ORDER BY coalesce(item.updated_on, item.published_on) DESC | ||||||
|  |     SQL); | ||||||
|  | $item   = $result ? $result->fetchArray(SQLITE3_ASSOC) : false; | ||||||
|  | 
 | ||||||
|  | page_head('Welcome'); ?>
 | ||||||
|  | <h1>Your Unread Items   <a href=/?refresh><small><small><em>(Refresh All Feeds)</em></small></small></a></h1> | ||||||
|  | <article><?php | ||||||
|  | if ($item) { | ||||||
|  |     while ($item) { ?>
 | ||||||
|  |         <p><a href=/item?id=<?=$item['id']?>><?=$item['item_title']?></a><br>
 | ||||||
|  |             <?=$item['feed_title']?><br><small><em><?=date_time($item['as_of'])?></em></small><?php
 | ||||||
|  |         $item = $result->fetchArray(SQLITE3_ASSOC); | ||||||
|  |     } | ||||||
|  | } else { ?>
 | ||||||
|  |     <p>There are no unread items</p><?php | ||||||
|  | } ?>
 | ||||||
|  | </article><?php | ||||||
|  | 
 | ||||||
|  | page_foot(); | ||||||
|  | $db->close(); | ||||||
							
								
								
									
										72
									
								
								src/public/item.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								src/public/item.php
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,72 @@ | |||||||
|  | <?php | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Item View Page | ||||||
|  |  * | ||||||
|  |  * Retrieves and displays an item from a feed belonging to the current user | ||||||
|  |  */ | ||||||
|  | 
 | ||||||
|  | include '../start.php'; | ||||||
|  | 
 | ||||||
|  | Security::verifyUser(); | ||||||
|  | 
 | ||||||
|  | $db = Data::getConnection(); | ||||||
|  | 
 | ||||||
|  | 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
 | ||||||
|  |     $isValidQuery = $db->prepare(<<<'SQL' | ||||||
|  |         SELECT COUNT(*) | ||||||
|  |           FROM item INNER JOIN feed ON feed.id = item.feed_id | ||||||
|  |          WHERE item.id = :id AND feed.user_id = :user | ||||||
|  |         SQL); | ||||||
|  |     $isValidQuery->bindValue(':id',   $_POST['id']); | ||||||
|  |     $isValidQuery->bindValue(':user', $_REQUEST[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'); | ||||||
|  |         $keepUnread->bindValue(':id', $_POST['id']); | ||||||
|  |         $keepUnread->execute(); | ||||||
|  |     } | ||||||
|  |     $db->close(); | ||||||
|  |     frc_redirect('/'); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | $query = $db->prepare(<<<'SQL' | ||||||
|  |     SELECT item.title AS item_title, item.item_link, item.published_on, item.updated_on, item.content, item.is_encoded, | ||||||
|  |            feed.title AS feed_title | ||||||
|  |       FROM item INNER JOIN feed ON feed.id = item.feed_id | ||||||
|  |      WHERE item.id      = :id | ||||||
|  |        AND feed.user_id = :user | ||||||
|  |     SQL); | ||||||
|  | $query->bindValue(':id',   $_GET['id']); | ||||||
|  | $query->bindValue(':user', $_REQUEST[Key::USER_ID]); | ||||||
|  | $result = $query->execute(); | ||||||
|  | $item = $result ? $result->fetchArray(SQLITE3_ASSOC) : false; | ||||||
|  | 
 | ||||||
|  | if ($item) { | ||||||
|  |     $markRead = $db->prepare('UPDATE item SET is_read = 1 WHERE id = :id'); | ||||||
|  |     $markRead->bindValue(':id', $_GET['id']); | ||||||
|  |     $markRead->execute(); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | $published = date_time($item['published_on']); | ||||||
|  | $updated   = isset($item['updated_on']) ? date_time($item['updated_on']) : null; | ||||||
|  | 
 | ||||||
|  | page_head(htmlentities("{$item['item_title']} | {$item['feed_title']}")); ?>
 | ||||||
|  | <h1 class=item_heading> | ||||||
|  |     <a href="<?=$item['item_link']?>" target=_blank rel=noopener><?=$item['item_title']?></a><br>
 | ||||||
|  | </h1> | ||||||
|  | <div class=item_published> | ||||||
|  |     From <strong><?=htmlentities($item['feed_title'])?></strong><br>
 | ||||||
|  |     Published <?=date_time($item['published_on'])?><?=$updated && $updated != $published ? " (Updated $updated)" : ''?>
 | ||||||
|  | </div> | ||||||
|  | <article> | ||||||
|  |     <div class=item_content><?=str_replace('<a ', '<a target=_blank rel=noopener ', $item['content'])?></div>
 | ||||||
|  |     <form class=action_buttons action=/item method=POST> | ||||||
|  |         <input type=hidden name=id value=<?=$_GET['id']?>>
 | ||||||
|  |         <a href="/">Done</a> | ||||||
|  |         <button type=submit>Keep as New</button> | ||||||
|  |     </form> | ||||||
|  | </article><?php | ||||||
|  | page_foot(); | ||||||
|  | $db->close(); | ||||||
							
								
								
									
										114
									
								
								src/start.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										114
									
								
								src/start.php
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,114 @@ | |||||||
|  | <?php | ||||||
|  | use JetBrains\PhpStorm\NoReturn; | ||||||
|  | 
 | ||||||
|  | spl_autoload_register(function ($class) { | ||||||
|  |     $file = implode(DIRECTORY_SEPARATOR, [__DIR__, 'lib', "$class.php"]); | ||||||
|  |     if (file_exists($file)) { | ||||||
|  |         require $file; | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
|  |     return false; | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | require 'user-config.php'; | ||||||
|  | 
 | ||||||
|  | Data::ensureDb(); | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Add a message to be displayed at the top of the page | ||||||
|  |  * | ||||||
|  |  * @param string $level The level (type) of the message | ||||||
|  |  * @param string $message The message itself | ||||||
|  |  */ | ||||||
|  | function add_message(string $level, string $message): void { | ||||||
|  |     if (!array_key_exists(Key::USER_MSG, $_REQUEST)) $_REQUEST[Key::USER_MSG] = array(); | ||||||
|  |     $_REQUEST[Key::USER_MSG][] = ['level' => $level, 'message' => $message]; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Add an error message to be displayed at the top of the page | ||||||
|  |  * | ||||||
|  |  * @param string $message The message to be displayed | ||||||
|  |  */ | ||||||
|  | function add_error(string $message): void { | ||||||
|  |     add_message('ERROR', $message); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Add an error message to be displayed at the top of the page | ||||||
|  |  * | ||||||
|  |  * @param string $message The message to be displayed | ||||||
|  |  */ | ||||||
|  | function add_info(string $message): void { | ||||||
|  |     add_message('INFO', $message); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Render the page title | ||||||
|  |  * @param string $title The title of the page being displayed | ||||||
|  |  */ | ||||||
|  | function page_head(string $title): void { | ||||||
|  |     ?><!DOCTYPE html>
 | ||||||
|  | <html lang=en> | ||||||
|  | <head> | ||||||
|  |     <meta name=viewport content="width=device-width, initial-scale=1"> | ||||||
|  |     <title><?=$title?> | Feed Reader Central</title>
 | ||||||
|  |     <link href=/assets/style.css rel=stylesheet> | ||||||
|  | </head> | ||||||
|  | <body> | ||||||
|  | <header> | ||||||
|  |     <a class=title href="/">Feed Reader Central</a> | ||||||
|  |     <div><?php | ||||||
|  |         if (array_key_exists(Key::USER_ID, $_REQUEST)) { | ||||||
|  |             echo '<a href=/feed?id=new>Add Feed</a>'; | ||||||
|  |             if ($_REQUEST[Key::USER_EMAIL] != 'solouser@example.com') echo " | {$_REQUEST[Key::USER_EMAIL]}"; | ||||||
|  |         } ?>
 | ||||||
|  |     </div> | ||||||
|  | </header> | ||||||
|  | <main hx-target=this><?php | ||||||
|  |     if (array_key_exists(Key::USER_MSG, $_REQUEST)) { | ||||||
|  |         foreach ($_REQUEST[Key::USER_MSG] as $msg) { ?>
 | ||||||
|  |             <div> | ||||||
|  |                 <?=$msg['level'] == 'INFO' ? '' : "<strong>{$msg['level']}</strong><br>"?>
 | ||||||
|  |                 <?=$msg['message']?>
 | ||||||
|  |             </div><?php | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Render the end of the page | ||||||
|  |  */ | ||||||
|  | function page_foot(): void { | ||||||
|  |     ?></main></body></html><?php
 | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Redirect the user to the given URL | ||||||
|  |  * | ||||||
|  |  * @param string $value A local URL to which the user should be redirected | ||||||
|  |  */ | ||||||
|  | #[NoReturn]
 | ||||||
|  | function frc_redirect(string $value): void { | ||||||
|  |     if (str_starts_with($value, 'http')) { | ||||||
|  |         http_response_code(400); | ||||||
|  |         die(); | ||||||
|  |     } | ||||||
|  |     header("Location: $value"); | ||||||
|  |     http_response_code(303); | ||||||
|  |     die(); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Convert a date/time string to a date/time in the configured format | ||||||
|  |  * | ||||||
|  |  * @param string $value The date/time string | ||||||
|  |  * @return string The standard format of a date/time, or '(invalid date)' if the date could not be parsed | ||||||
|  |  */ | ||||||
|  | function date_time(string $value): string { | ||||||
|  |     try { | ||||||
|  |         return (new DateTimeImmutable($value))->format(DATE_TIME_FORMAT); | ||||||
|  |     } catch (Exception) { | ||||||
|  |         return '(invalid date)'; | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										30
									
								
								src/user-config.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								src/user-config.php
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,30 @@ | |||||||
|  | <?php | ||||||
|  | /** | ||||||
|  |  * USER CONFIGURATION ITEMS | ||||||
|  |  * | ||||||
|  |  * Editing the values here customizes the behavior of Feed Reader Central | ||||||
|  |  */ | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Which security model should the application use? Options are: | ||||||
|  |  * - 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; | ||||||
|  | 
 | ||||||
|  | /** The name of the database file where users and feeds should be kept */ | ||||||
|  | const DATABASE_NAME = 'frc.db'; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * The format for date/time outputs; see https://www.php.net/manual/en/datetime.format.php for acceptable values | ||||||
|  |  * | ||||||
|  |  * 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)
 | ||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user