From df20936af2d1d7a693cf0a56c9f53fa74320007c Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Thu, 30 May 2024 21:58:54 -0400 Subject: [PATCH 01/15] WIP on document conversion --- .gitignore | 1 + src/app-config.php | 20 +-- src/cli-start.php | 6 +- src/composer.json | 23 ++++ src/composer.lock | 49 ++++++++ src/lib/Data.php | 7 ++ src/lib/Domain/Feed.php | 71 +++++++++++ src/lib/Domain/Item.php | 78 ++++++++++++ src/lib/Domain/Table.php | 17 +++ src/lib/Domain/User.php | 17 +++ src/lib/Feed.php | 191 +++++++++++++++++------------ src/lib/FeedItem.php | 33 ++++- src/lib/ItemList.php | 32 +++-- src/lib/Key.php | 4 + src/lib/Security.php | 61 +++++---- src/public/bookmark.php | 16 ++- src/public/docs/feeds.php | 4 + src/public/docs/index.php | 4 + src/public/docs/items.php | 4 + src/public/docs/security-modes.php | 4 + src/public/docs/the-cli.php | 4 + src/public/feed/index.php | 27 ++-- src/public/feed/items.php | 11 +- src/public/feeds.php | 5 + src/public/index.php | 5 + src/public/item.php | 13 +- src/public/search.php | 4 + src/public/user/log-off.php | 2 +- src/public/user/log-on.php | 3 + src/start.php | 51 +++++--- src/user-config.dist.php | 1 + src/util/refresh.php | 11 +- src/util/search.php | 20 +-- src/util/user.php | 79 +++++++----- 34 files changed, 674 insertions(+), 204 deletions(-) create mode 100644 src/composer.json create mode 100644 src/composer.lock create mode 100644 src/lib/Domain/Feed.php create mode 100644 src/lib/Domain/Item.php create mode 100644 src/lib/Domain/Table.php create mode 100644 src/lib/Domain/User.php diff --git a/.gitignore b/.gitignore index f9b4ff3..aaf5ea9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .idea +vendor src/data/*.db src/user-config.php diff --git a/src/app-config.php b/src/app-config.php index bf43f2c..c06208c 100644 --- a/src/app-config.php +++ b/src/app-config.php @@ -1,6 +1,9 @@ updated_on) ? null : new DateTimeImmutable($this->updated_on); + } + + /** + * The date/time this feed was last checked + * + * @return DateTimeImmutable|null The last checked date, or null if it is not set + * @throws Exception If the date/time is an invalid format + */ + public function checkedOn(): ?DateTimeImmutable + { + return is_null($this->checked_on) ? null : new DateTimeImmutable($this->checked_on); + } + + /** + * Create a document from the parsed feed + * + * @param \FeedReaderCentral\Feed $feed The parsed feed + * @return static The document constructed from the parsed feed + */ + public static function fromParsed(\FeedReaderCentral\Feed $feed): static + { + $it = new static(); + $it->user_id = $_SESSION[Key::USER_ID]; + $it->url = $feed->url; + $it->title = $feed->title; + $it->updated_on = $feed->updatedOn; + $it->checked_on = Data::formatDate('now'); + + return $it; + } +} diff --git a/src/lib/Domain/Item.php b/src/lib/Domain/Item.php new file mode 100644 index 0000000..e0c17d5 --- /dev/null +++ b/src/lib/Domain/Item.php @@ -0,0 +1,78 @@ +is_read <> 0; + } + + /** + * Is the item bookmarked? + * + * @return bool True if the item is bookmarked, false if not + */ + public function isBookmarked(): bool + { + return $this->is_bookmarked <> 0; + } + + /** + * Create an item document from a parsed feed item + * + * @param int $feedId The ID of the feed to which this item belongs + * @param FeedItem $item The parsed feed item + * @return static The item document + */ + public static function fromFeedItem(int $feedId, FeedItem $item): static + { + $it = new static(); + $it->feed_id = $feedId; + $it->item_guid = $item->guid; + $it->item_link = $item->link; + $it->title = $item->title; + $it->published_on = $item->publishedOn; + $it->updated_on = $item->updatedOn; + $it->content = $item->content; + + return $it; + } +} diff --git a/src/lib/Domain/Table.php b/src/lib/Domain/Table.php new file mode 100644 index 0000000..b9c5a95 --- /dev/null +++ b/src/lib/Domain/Table.php @@ -0,0 +1,17 @@ +title = self::rssValue($channel, 'title'); $feed->url = $url; $feed->updatedOn = Data::formatDate($updatedOn); @@ -267,21 +287,23 @@ class Feed { * @return bool|SQLite3Result The result if the update is successful, false if it failed */ private static function updateItem(int $itemId, FeedItem $item, SQLite3 $db): bool|SQLite3Result { - $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->publishedOn); - $query->bindValue(':updated', $item->updatedOn); - $query->bindValue(':content', $item->content); - $query->bindValue(':id', $itemId); - return $query->execute(); + Patch::byId(Table::ITEM, $itemId, $item->patchFields(), $db); +// $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->publishedOn); +// $query->bindValue(':updated', $item->updatedOn); +// $query->bindValue(':content', $item->content); +// $query->bindValue(':id', $itemId); +// return $query->execute(); + return true; } /** @@ -293,21 +315,23 @@ class Feed { * @return bool|SQLite3Result The result if the update is successful, false if it failed */ private static function addItem(int $feedId, FeedItem $item, SQLite3 $db): bool|SQLite3Result { - $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->publishedOn); - $query->bindValue(':updated', $item->updatedOn); - $query->bindValue(':content', $item->content); - return $query->execute(); + Document::insert(Table::ITEM, Item::fromFeedItem($feedId, $item), $db); +// $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->publishedOn); +// $query->bindValue(':updated', $item->updatedOn); +// $query->bindValue(':content', $item->content); +// return $query->execute(); + return true; } /** @@ -321,6 +345,7 @@ class Feed { public static function updateItems(int $feedId, Feed $feed, DateTimeInterface $lastChecked, SQLite3 $db): array { $results = array_map(function ($item) use ($db, $feedId) { + // TODO: convert this query $existsQuery = $db->prepare( 'SELECT id, published_on, updated_on FROM item WHERE feed_id = :feed AND item_guid = :guid'); $existsQuery->bindValue(':feed', $feedId); @@ -357,6 +382,7 @@ class Feed { } try { + // TODO: convert this query $sql = match (PURGE_TYPE) { self::PURGE_READ => 'AND is_read = 1', self::PURGE_BY_DAYS => 'AND date(coalesce(updated_on, published_on)) < date(:oldest)', @@ -401,21 +427,24 @@ class Feed { $itemUpdate = self::updateItems($feedId, $feed, $lastChecked, $db); if (key_exists('error', $itemUpdate)) return $itemUpdate; - $urlUpdate = $url == $feed->url ? '' : ', url = :url'; - $feedUpdate = $db->prepare(<<bindValue(':title', $feed->title); - $feedUpdate->bindValue(':updated', $feed->updatedOn); - $feedUpdate->bindValue(':checked', Data::formatDate('now')); - $feedUpdate->bindValue(':id', $feedId); - if ($urlUpdate != '') $feedUpdate->bindValue(':url', $feed->url); - if (!$feedUpdate->execute()) return Data::error($db); + $patch = ['title' => $feed->title, 'updated_on' => $feed->updatedOn, 'checked_on' => Data::formatDate('now')]; + if ($url == $feed->url) $patch['url'] = $feed->url; + Patch::byId(Table::FEED, $feedId, $patch, $db); +// $urlUpdate = $url == $feed->url ? '' : ', url = :url'; +// $feedUpdate = $db->prepare(<<bindValue(':title', $feed->title); +// $feedUpdate->bindValue(':updated', $feed->updatedOn); +// $feedUpdate->bindValue(':checked', Data::formatDate('now')); +// $feedUpdate->bindValue(':id', $feedId); +// if ($urlUpdate != '') $feedUpdate->bindValue(':url', $feed->url); +// if (!$feedUpdate->execute()) return Data::error($db); return PURGE_TYPE == self::PURGE_NONE ? ['ok' => true] : self::purgeItems($feedId, $db); } @@ -432,49 +461,55 @@ class Feed { $feed = $feedExtract['ok']; - $existsQuery = $db->prepare('SELECT COUNT(*) FROM feed WHERE user_id = :user AND url = :url'); - $existsQuery->bindValue(':user', $_SESSION[Key::USER_ID]); - $existsQuery->bindValue(':url', $feed->url); - if (!($exists = $existsQuery->execute())) return Data::error($db); - if ($exists->fetchArray(SQLITE3_NUM)[0] > 0) return ['error' => "Already subscribed to feed $feed->url"]; + $whereUserAndUrl = ' WHERE ' . Query::whereByField(Field::EQ('user_id', ''), '@user') + . ' AND ' . Query::whereByField(Field::EQ('url', ''), '@url'); + $userAndUrlParams = ['@user' => $_SESSION[Key::USER_ID], '@url' => $feed->url]; + if (Custom::scalar('SELECT EXISTS (SELECT 1 FROM ' . Table::FEED . $whereUserAndUrl . ')', $userAndUrlParams, + Results::toExists(...), $db)) { + return ['error' => "Already subscribed to feed $feed->url"]; + } - $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', $_SESSION[Key::USER_ID]); - $query->bindValue(':url', $feed->url); - $query->bindValue(':title', $feed->title); - $query->bindValue(':updated', $feed->updatedOn); - $query->bindValue(':checked', Data::formatDate('now')); - if (!$query->execute()) return Data::error($db); + Document::insert(Table::FEED, FeedDocument::fromParsed($feed), $db); +// $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', $_SESSION[Key::USER_ID]); +// $query->bindValue(':url', $feed->url); +// $query->bindValue(':title', $feed->title); +// $query->bindValue(':updated', $feed->updatedOn); +// $query->bindValue(':checked', Data::formatDate('now')); +// if (!$query->execute()) return Data::error($db); + $doc = Custom::single(Query::selectFromTable(Table::FEED) . $whereUserAndUrl, $userAndUrlParams, + Results::fromData(...), Domain\Feed::class, $db); + if (!$doc) return ['error' => 'Could not retrieve inserted feed']; - $feedId = $db->lastInsertRowID(); - $result = self::updateItems($feedId, $feed, date_create_immutable(WWW_EPOCH), $db); + $result = self::updateItems($doc->id, $feed, date_create_immutable(WWW_EPOCH), $db); if (key_exists('error', $result)) return $result; - return ['ok' => $feedId]; + return ['ok' => $doc->id]; } /** * Update an RSS feed * - * @param array $existing The existing RSS feed + * @param FeedDocument $existing The existing RSS feed * @param string $url The URL with which the existing feed should be modified * @param SQLite3 $db The database connection on which to execute the update * @return bool[]|string[] [ 'ok' => true ] if successful, [ 'error' => message ] if not */ - public static function update(array $existing, string $url, SQLite3 $db): array { + public static function update(FeedDocument $existing, string $url, SQLite3 $db): array { + // TODO: convert this query (need to make Query\Patch::update visible) $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(':id', $existing->id); $query->bindValue(':user', $_SESSION[Key::USER_ID]); if (!$query->execute()) return Data::error($db); - return self::refreshFeed($existing['id'], $url, $db); + return self::refreshFeed($existing->id, $url, $db); } /** @@ -520,12 +555,14 @@ class Feed { * * @param int $feedId The ID of the feed to retrieve * @param SQLite3 $db A database connection to use to retrieve the feed - * @return array|bool The data for the feed if found, false if not found + * @return FeedDocument|false The data for the feed if found, false if not found */ - public static function retrieveById(int $feedId, SQLite3 $db): array|bool { - $query = $db->prepare('SELECT * FROM feed WHERE id = :id AND user_id = :user'); - $query->bindValue(':id', $feedId); - $query->bindValue(':user', $_SESSION[Key::USER_ID]); - return ($result = $query->execute()) ? $result->fetchArray(SQLITE3_ASSOC) : false; + public static function retrieveById(int $feedId, SQLite3 $db): FeedDocument|false { + $doc = Find::byId(Table::FEED, $feedId, FeedDocument::class, $db); + return $doc && $doc->user_id == $_SESSION[Key::USER_ID] ? $doc : false; +// $query = $db->prepare('SELECT * FROM feed WHERE id = :id AND user_id = :user'); +// $query->bindValue(':id', $feedId); +// $query->bindValue(':user', $_SESSION[Key::USER_ID]); +// return ($result = $query->execute()) ? $result->fetchArray(SQLITE3_ASSOC) : false; } } diff --git a/src/lib/FeedItem.php b/src/lib/FeedItem.php index ab6aa04..1b2cd95 100644 --- a/src/lib/FeedItem.php +++ b/src/lib/FeedItem.php @@ -1,4 +1,7 @@ $this->title, + 'published_on' => $this->publishedOn, + 'updated_on' => $this->updatedOn, + 'content' => $this->content, + 'is_read' => 0 + ]; + } + /** * 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 + * @return static A feed item constructed from the given node */ - public static function fromAtom(DOMNode $node): FeedItem { + public static function fromAtom(DOMNode $node): static + { $guid = Feed::atomValue($node, 'id'); $link = ''; foreach ($node->getElementsByTagName('link') as $linkElt) { @@ -43,7 +63,7 @@ class FeedItem { } if ($link == '' && str_starts_with($guid, 'http')) $link = $guid; - $item = new FeedItem(); + $item = new static(); $item->guid = $guid; $item->title = Feed::atomValue($node, 'title'); $item->link = $link; @@ -58,14 +78,15 @@ class FeedItem { * 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 + * @return static A feed item constructed from the given node */ - public static function fromRSS(DOMNode $node): FeedItem { + public static function fromRSS(DOMNode $node): static + { $itemGuid = Feed::rssValue($node, 'guid'); $updNodes = $node->getElementsByTagNameNS(Feed::ATOM_NS, 'updated'); $encNodes = $node->getElementsByTagNameNS(Feed::CONTENT_NS, 'encoded'); - $item = new FeedItem(); + $item = new static(); $item->guid = $itemGuid == 'guid not found' ? Feed::rssValue($node, 'link') : $itemGuid; $item->title = Feed::rssValue($node, 'title'); $item->link = Feed::rssValue($node, 'link'); diff --git a/src/lib/ItemList.php b/src/lib/ItemList.php index 2f9866b..7697149 100644 --- a/src/lib/ItemList.php +++ b/src/lib/ItemList.php @@ -1,4 +1,9 @@ execute(); if (!$result) { $this->error = Data::error($db)['error']; @@ -56,7 +62,8 @@ class ItemList { * @param array $parameters Parameters to be added to the query (key index 0, value index 1; optional) * @return SQLite3Stmt The query, ready to be executed */ - private static function makeQuery(SQLite3 $db, array $criteria, array $parameters = []): SQLite3Stmt { + private static function makeQuery(SQLite3 $db, array $criteria, array $parameters = []): SQLite3Stmt + { $where = empty($criteria) ? '' : 'AND ' . implode(' AND ', $criteria); $sql = <<linkFeed = true; return $list; @@ -89,7 +97,8 @@ class ItemList { * @param SQLite3 $db The database connection to use to obtain items * @return static An item list with all unread items */ - public static function allUnread(SQLite3 $db): static { + public static function allUnread(SQLite3 $db): static + { $list = new static($db, self::makeQuery($db, ['item.is_read = 0']), 'Unread'); $list->linkFeed = true; return $list; @@ -102,7 +111,8 @@ class ItemList { * @param SQLite3 $db The database connection to use to obtain items * @return static An item list with all items for the given feed */ - public static function allForFeed(int $feedId, SQLite3 $db): static { + public static function allForFeed(int $feedId, SQLite3 $db): static + { $list = new static($db, self::makeQuery($db, ['feed.id = :feed'], [[':feed', $feedId]]), '', "/feed/items?id=$feedId"); $list->showIndicators = true; @@ -116,7 +126,8 @@ class ItemList { * @param SQLite3 $db The database connection to use to obtain items * @return static An item list with unread items for the given feed */ - public static function unreadForFeed(int $feedId, SQLite3 $db): static { + public static function unreadForFeed(int $feedId, SQLite3 $db): static + { return new static($db, self::makeQuery($db, ['feed.id = :feed', 'item.is_read = 0'], [[':feed', $feedId]]), 'Unread', "/feed/items?id=$feedId&unread"); } @@ -128,7 +139,8 @@ class ItemList { * @param SQLite3 $db The database connection to use to obtain items * @return static An item list with bookmarked items for the given feed */ - public static function bookmarkedForFeed(int $feedId, SQLite3 $db): static { + public static function bookmarkedForFeed(int $feedId, SQLite3 $db): static + { return new static($db, self::makeQuery($db, ['feed.id = :feed', 'item.is_bookmarked = 1'], [[':feed', $feedId]]), 'Bookmarked', "/feed/items?id=$feedId&bookmarked"); @@ -142,7 +154,8 @@ class ItemList { * @param SQLite3 $db The database connection to use to obtain items * @return static An item list match the given search terms */ - public static function matchingSearch(string $search, bool $isBookmarked, SQLite3 $db): static { + public static function matchingSearch(string $search, bool $isBookmarked, SQLite3 $db): static + { $where = $isBookmarked ? ['item.is_bookmarked = 1'] : []; $where[] = 'item.id IN (SELECT ROWID FROM item_search WHERE content MATCH :search)'; $list = new static($db, self::makeQuery($db, $where, [[':search', $search]]), @@ -156,7 +169,8 @@ class ItemList { /** * Render this item list */ - public function render(): void { + public function render(): void + { if ($this->isError()) { ?>

Error retrieving list:
error?>prepare('SELECT * FROM frc_user WHERE email = :email'); - $query->bindValue(':email', $email); - $result = $query->execute(); - return $result ? $result->fetchArray(SQLITE3_ASSOC) : false; + public static function findUserByEmail(string $email, SQLite3 $db): User|false { + return Find::firstByField(Table::USER, Field::EQ('email', $email), User::class, $db); +// $query = $db->prepare('SELECT * FROM frc_user WHERE email = :email'); +// $query->bindValue(':email', $email); +// $result = $query->execute(); +// return $result ? $result->fetchArray(SQLITE3_ASSOC) : false; } /** @@ -45,27 +55,32 @@ class Security { * @param SQLite3 $db The data connection to use to add the user */ 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(); + $user = new User(); + $user->email = $email; + $user->password = $password; + Document::insert(Table::USER, $user, $db); +// $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 User $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(); + private static function verifyPassword(User $user, string $password, ?string $returnTo, SQLite3 $db): void { + if (password_verify($password, $user->password)) { + if (password_needs_rehash($user->password, self::PW_ALGORITHM)) { + Patch::byId(Table::USER, $user->id, ['password' => password_hash($password, self::PW_ALGORITHM)], $db); +// $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']; @@ -104,10 +119,12 @@ class Security { * @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(); + Patch::byField(Table::USER, Field::EQ('email', $email), + ['password' => password_hash($password, self::PW_ALGORITHM)], $db); +// $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(); } /** diff --git a/src/public/bookmark.php b/src/public/bookmark.php index 4c16617..13e11ce 100644 --- a/src/public/bookmark.php +++ b/src/public/bookmark.php @@ -6,6 +6,12 @@ * This will display a button which will either add or remove a bookmark for a given item. */ +use BitBadger\Documents\SQLite\Patch; +use FeedReaderCentral\Data; +use FeedReaderCentral\Domain\Table; +use FeedReaderCentral\Key; +use FeedReaderCentral\Security; + include '../start.php'; $db = Data::getConnection(); @@ -13,6 +19,7 @@ Security::verifyUser($db); $id = $_GET['id']; +// TODO: adapt query once "by fields" is available $existsQuery = $db->prepare( 'SELECT item.id FROM item INNER JOIN feed ON feed.id = item.feed_id WHERE item.id = :id AND feed.user_id = :user'); $existsQuery->bindValue(':id', $id); @@ -29,10 +36,11 @@ if (key_exists('action', $_GET)) { $flag = 0; } if (isset($flag)) { - $update = $db->prepare('UPDATE item SET is_bookmarked = :flag WHERE id = :id'); - $update->bindValue(':id', $id); - $update->bindValue(':flag', $flag); - if (!$update->execute()) die(Data::error($db)['error']); + Patch::byId(Table::ITEM, $id, ['is_bookmarked' => $flag], $db); +// $update = $db->prepare('UPDATE item SET is_bookmarked = :flag WHERE id = :id'); +// $update->bindValue(':id', $id); +// $update->bindValue(':flag', $flag); +// if (!$update->execute()) die(Data::error($db)['error']); } } diff --git a/src/public/docs/feeds.php b/src/public/docs/feeds.php index 0f9f2e0..527039d 100644 --- a/src/public/docs/feeds.php +++ b/src/public/docs/feeds.php @@ -1,4 +1,8 @@ prepare('DELETE FROM item WHERE feed_id = :feed'); - $itemDelete->bindValue(':feed', $feed['id']); - if (!$itemDelete->execute()) add_error(Data::error($db)['error']); - $feedDelete = $db->prepare('DELETE FROM feed WHERE id = :feed'); - $feedDelete->bindValue(':feed', $feed['id']); - if ($feedDelete->execute()) { + Delete::byField(Table::ITEM, Field::EQ('feed_id', $feed->id), $db); +// $itemDelete = $db->prepare('DELETE FROM item WHERE feed_id = :feed'); +// $itemDelete->bindValue(':feed', $feed['id']); +// if (!$itemDelete->execute()) add_error(Data::error($db)['error']); + Delete::byId(Table::FEED, $feed->id, $db); +// $feedDelete = $db->prepare('DELETE FROM feed WHERE id = :feed'); +// $feedDelete->bindValue(':feed', $feed['id']); +// if ($feedDelete->execute()) { add_info('Feed “' . htmlentities($feed['title']) . '” deleted successfully'); - } else { - add_error(Data::error($db)['error']); - } +// } else { +// add_error(Data::error($db)['error']); +// } frc_redirect('/feeds'); } diff --git a/src/public/feed/items.php b/src/public/feed/items.php index a92269f..f83d20a 100644 --- a/src/public/feed/items.php +++ b/src/public/feed/items.php @@ -5,6 +5,11 @@ * Lists items in a given feed (all, unread, or bookmarked) */ +use FeedReaderCentral\Data; +use FeedReaderCentral\Feed; +use FeedReaderCentral\ItemList; +use FeedReaderCentral\Security; + include '../../start.php'; $db = Data::getConnection(); @@ -13,9 +18,9 @@ Security::verifyUser($db); if (!($feed = Feed::retrieveById($_GET['id'], $db))) not_found(); $list = match (true) { - key_exists('unread', $_GET) => ItemList::unreadForFeed($feed['id'], $db), - key_exists('bookmarked', $_GET) => ItemList::bookmarkedForFeed($feed['id'], $db), - default => ItemList::allForFeed($feed['id'], $db) + key_exists('unread', $_GET) => ItemList::unreadForFeed($feed->id, $db), + key_exists('bookmarked', $_GET) => ItemList::bookmarkedForFeed($feed->id, $db), + default => ItemList::allForFeed($feed->id, $db) }; page_head(($list->itemType != '' ? "$list->itemType Items | " : '') . strip_tags($feed['title'])); diff --git a/src/public/feeds.php b/src/public/feeds.php index b765b70..c6af217 100644 --- a/src/public/feeds.php +++ b/src/public/feeds.php @@ -5,11 +5,16 @@ * List feeds and provide links for maintenance actions */ +use FeedReaderCentral\Data; +use FeedReaderCentral\Key; +use FeedReaderCentral\Security; + include '../start.php'; $db = Data::getConnection(); Security::verifyUser($db); +// TODO: adapt query when document list is done $feedQuery = $db->prepare('SELECT * FROM feed WHERE user_id = :user ORDER BY lower(title)'); $feedQuery->bindValue(':user', $_SESSION[Key::USER_ID]); if (!($feedResult = $feedQuery->execute())) { diff --git a/src/public/index.php b/src/public/index.php index 73307e7..d92e2e2 100644 --- a/src/public/index.php +++ b/src/public/index.php @@ -5,6 +5,11 @@ * Displays a list of unread or bookmarked items for the current user */ +use FeedReaderCentral\Data; +use FeedReaderCentral\Feed; +use FeedReaderCentral\ItemList; +use FeedReaderCentral\Security; + include '../start.php'; $db = Data::getConnection(); diff --git a/src/public/item.php b/src/public/item.php index 3633f87..8be3978 100644 --- a/src/public/item.php +++ b/src/public/item.php @@ -6,6 +6,12 @@ * Retrieves and displays an item from a feed belonging to the current user */ +use BitBadger\Documents\SQLite\Patch; +use FeedReaderCentral\Data; +use FeedReaderCentral\Domain\Table; +use FeedReaderCentral\Key; +use FeedReaderCentral\Security; + include '../start.php'; $db = Data::getConnection(); @@ -22,9 +28,10 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') { $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'); - $keepUnread->bindValue(':id', $_POST['id']); - $keepUnread->execute(); + Patch::byId(Table::ITEM, $_POST['id'], ['is_read' => 0], $db); +// $keepUnread = $db->prepare('UPDATE item SET is_read = 0 WHERE id = :id'); +// $keepUnread->bindValue(':id', $_POST['id']); +// $keepUnread->execute(); } $db->close(); frc_redirect($_POST['from']); diff --git a/src/public/search.php b/src/public/search.php index af5e6ab..790a1a5 100644 --- a/src/public/search.php +++ b/src/public/search.php @@ -6,6 +6,10 @@ * Search for items across all feeds */ +use FeedReaderCentral\Data; +use FeedReaderCentral\ItemList; +use FeedReaderCentral\Security; + include '../start.php'; $db = Data::getConnection(); diff --git a/src/public/user/log-off.php b/src/public/user/log-off.php index 73fd811..9c6baea 100644 --- a/src/public/user/log-off.php +++ b/src/public/user/log-off.php @@ -5,6 +5,6 @@ include '../../start.php'; -if (key_exists(Key::USER_ID, $_SESSION)) session_destroy(); +if (key_exists(FeedReaderCentral\Key::USER_ID, $_SESSION)) session_destroy(); frc_redirect('/'); diff --git a/src/public/user/log-on.php b/src/public/user/log-on.php index 3133f0f..9d8fc4f 100644 --- a/src/public/user/log-on.php +++ b/src/public/user/log-on.php @@ -1,6 +1,9 @@ $level, 'message' => $message]; } @@ -25,7 +29,8 @@ function add_message(string $level, string $message): void { * * @param string $message The message to be displayed */ -function add_error(string $message): void { +function add_error(string $message): void +{ add_message('ERROR', $message); } @@ -34,14 +39,22 @@ function add_error(string $message): void { * * @param string $message The message to be displayed */ -function add_info(string $message): void { +function add_info(string $message): void +{ add_message('INFO', $message); } /** @var bool $is_htmx True if this request was initiated by htmx, false if not */ $is_htmx = key_exists('HTTP_HX_REQUEST', $_SERVER) && !key_exists('HTTP_HX_HISTORY_RESTORE_REQUEST', $_SERVER); -function nav_link(string $link, bool $isFirst = false) { +/** + * Create a navigation link in the top right nav bar + * + * @param string $link The link to be placed + * @param bool $isFirst True if this is the first link being placed, false if not + */ +function nav_link(string $link, bool $isFirst = false): void +{ $sep = $isFirst ? '' : ' | '; echo "$sep$link"; } @@ -49,8 +62,9 @@ function nav_link(string $link, bool $isFirst = false) { /** * Render the title bar for the page */ -function title_bar(): void { - $version = display_version();; ?> +function title_bar(): void +{ + $version = display_version(); ?>

' + . '
'; } /** @@ -94,28 +89,24 @@ function title_bar(): void */ function page_head(string $title): void { - global $is_htmx; ?> - - - - <?=$title?> | Feed Reader Central - - - - -' + . "$title | Feed Reader Central"; + if (!$is_htmx) { + echo '' + . "" + . ''; + } + echo ''; if (!$is_htmx) title_bar(); - if (sizeof($messages = $_SESSION[Key::USER_MSG] ?? []) > 0) { ?> -
-
- {$msg['level']}
"?> - -
-
0) { + echo '
'; + array_walk($messages, function ($msg) { + echo '
' + . ($msg['level'] == 'INFO' ? '' : "{$msg['level']}
") + . $msg['message'] . '
'; + }); + echo '
'; $_SESSION[Key::USER_MSG] = []; } } @@ -125,11 +116,8 @@ function page_head(string $title): void */ function page_foot(): void { - global $is_htmx; ?> -
'; ?> - -' . ($is_htmx ? '' : '') . ''; session_commit(); } diff --git a/src/user-config.dist.php b/src/user-config.dist.php index 97066e0..117ccbe 100644 --- a/src/user-config.dist.php +++ b/src/user-config.dist.php @@ -8,8 +8,7 @@ * On initial installation, rename this file to user-config.php and configure it as desired */ -use FeedReaderCentral\Feed; -use FeedReaderCentral\Security; +use FeedReaderCentral\{Feed, Security}; /** * Which security model should the application use? Options are: -- 2.45.1 From 3dc314b2e35b3e1111f35fca5bf42c30eec7c98c Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sun, 9 Jun 2024 18:01:04 -0400 Subject: [PATCH 12/15] Fix insert statements; other minor tweaks --- src/composer.json | 8 +---- src/composer.lock | 46 ++++++++++++++++-------- src/lib/Feed.php | 27 +++++++------- src/lib/Item.php | 49 ++++++++++++++++++------- src/lib/ParsedItem.php | 76 ++++++++++++++------------------------- src/lib/User.php | 7 ++-- src/public/feed/index.php | 2 +- src/public/feeds.php | 2 +- src/start.php | 2 ++ 9 files changed, 117 insertions(+), 102 deletions(-) diff --git a/src/composer.json b/src/composer.json index 6920e7c..6eab05b 100644 --- a/src/composer.json +++ b/src/composer.json @@ -1,14 +1,8 @@ { "name": "bit-badger/feed-reader-central", "minimum-stability": "dev", - "repositories": [ - { - "type": "vcs", - "url": "https://git.bitbadger.solutions/bit-badger/pdo-document" - } - ], "require": { - "bit-badger/pdo-document": "dev-develop", + "bit-badger/pdo-document": "^1", "ext-curl": "*", "ext-dom": "*", "ext-pdo": "*", diff --git a/src/composer.lock b/src/composer.lock index 7edaf93..32c046f 100644 --- a/src/composer.lock +++ b/src/composer.lock @@ -4,19 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e76207a80fdea0c9dc753a55e6f66bf0", + "content-hash": "f074a197e429ac24507becc14e0d99c3", "packages": [ { "name": "bit-badger/pdo-document", - "version": "dev-develop", + "version": "v1.0.0-alpha1", "source": { "type": "git", "url": "https://git.bitbadger.solutions/bit-badger/pdo-document", - "reference": "a10ecbb1cdfdf6dd8ab3c1884b09d9ba987b7ff5" + "reference": "f784f3e52cc1e4691fa347eefc82a2e4587c7f38" }, "require": { "ext-pdo": "*", - "netresearch/jsonmapper": "^4" + "netresearch/jsonmapper": "^4", + "php": ">=8.3" }, "require-dev": { "phpunit/phpunit": "^11" @@ -25,18 +26,35 @@ "autoload": { "psr-4": { "BitBadger\\PDODocument\\": "./src", - "BitBadger\\PDODocument\\Mapper\\": "./src/Mapper", - "BitBadger\\PDODocument\\Query\\": "./src/Query" + "BitBadger\\PDODocument\\Query\\": "./src/Query", + "BitBadger\\PDODocument\\Mapper\\": "./src/Mapper" } }, - "autoload-dev": { - "psr-4": { - "Test\\Unit\\": "./tests/unit", - "Test\\Integration\\": "./tests/integration", - "Test\\Integration\\SQLite\\": "./tests/integration/sqlite" + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel J. Summers", + "email": "daniel@bitbadger.solutions", + "homepage": "https://bitbadger.solutions", + "role": "Developer" } + ], + "description": "Treat SQLite (and soon PostgreSQL) as a document store", + "keywords": [ + "database", + "document", + "pdo", + "sqlite" + ], + "support": { + "email": "daniel@bitbadger.solutions", + "rss": "https://git.bitbadger.solutions/bit-badger/pdo-document.rss", + "source": "https://git.bitbadger.solutions/bit-badger/pdo-document" }, - "time": "2024-06-08T16:57:13+00:00" + "time": "2024-06-08T23:58:45+00:00" }, { "name": "netresearch/jsonmapper", @@ -93,9 +111,7 @@ "packages-dev": [], "aliases": [], "minimum-stability": "dev", - "stability-flags": { - "bit-badger/pdo-document": 20 - }, + "stability-flags": [], "prefer-stable": false, "prefer-lowest": false, "platform": { diff --git a/src/lib/Feed.php b/src/lib/Feed.php index d567d76..d6445d7 100644 --- a/src/lib/Feed.php +++ b/src/lib/Feed.php @@ -3,7 +3,7 @@ namespace FeedReaderCentral; use BitBadger\PDODocument\{ - Configuration, Custom, Document, DocumentException, DocumentList, Exists, Field, Find, Parameters, Patch, Query + Configuration, Custom, DocumentException, DocumentList, Exists, Field, Find, Parameters, Patch, Query }; use DateTimeInterface; @@ -50,14 +50,12 @@ class Feed */ public static function fromParsed(ParsedFeed $parsed): static { - $it = new static(); - $it->user_id = $_SESSION[Key::USER_ID]; - $it->url = $parsed->url; - $it->title = $parsed->title; - $it->updated_on = $parsed->updatedOn; - $it->checked_on = Data::formatDate('now'); - - return $it; + return new static( + user_id: $_SESSION[Key::USER_ID], + url: $parsed->url, + title: $parsed->title, + updated_on: $parsed->updatedOn, + checked_on: Data::formatDate('now')); } /** @@ -78,10 +76,10 @@ class Feed if ($existing) { if ($existing->published_on != $item->publishedOn || ($existing->updated_on != ($item->updatedOn ?? ''))) { - Patch::byId(Table::ITEM, $existing->id, $item->patchFields()); + Item::update($existing->id, $item); } } else { - Document::insert(Table::ITEM, Item::fromFeedItem($feedId, $item)); + Item::add($feedId, $item); } return ['ok' => true]; } catch (DocumentException $ex) { @@ -113,7 +111,7 @@ class Feed $sql .= ' AND ' . Query::whereByFields([$readField]); } elseif (PURGE_TYPE == self::PURGE_BY_DAYS) { $fields[] = Field::EQ('', Data::formatDate('-' . PURGE_NUMBER . ' day'), ':oldest'); - $sql .= " AND date(coalesce(data->>'updated_on', data->>'published_on)) < date(:oldest)"; + $sql .= " AND date(coalesce(data->>'updated_on', data->>'published_on')) < date(:oldest)"; } elseif (PURGE_TYPE == self::PURGE_BY_COUNT) { $fields[] = Field::EQ('', PURGE_NUMBER, ':keep'); $id = Configuration::$idField; @@ -189,7 +187,10 @@ class Feed return ['error' => "Already subscribed to feed $feed->url"]; } - Document::insert(Table::FEED, self::fromParsed($feed)); + Custom::nonQuery(<<<'SQL' + INSERT INTO feed (data) + VALUES (json_set(:data, '$.id', (SELECT coalesce(max(data->>'id'), 0) + 1 FROM feed))) + SQL, Parameters::json(':data', self::fromParsed($feed))); $doc = Find::firstByFields(Table::FEED, $fields, static::class); if (!$doc) return ['error' => 'Could not retrieve inserted feed']; diff --git a/src/lib/Item.php b/src/lib/Item.php index 79f8062..ecbd7e2 100644 --- a/src/lib/Item.php +++ b/src/lib/Item.php @@ -2,6 +2,8 @@ namespace FeedReaderCentral; +use BitBadger\PDODocument\{Custom, DocumentException, Parameters, Patch}; + /** * An item from a feed */ @@ -47,21 +49,42 @@ class Item } /** - * Create an item document from a parsed feed item + * Add an item * - * @param int $feedId The ID of the feed to which this item belongs - * @param ParsedItem $item The parsed feed item - * @return static The item document + * @param int $feedId The ID of the feed to which the item belongs + * @param ParsedItem $parsed The parsed item from the feed XML + * @throws DocumentException If any is encountered */ - public static function fromFeedItem(int $feedId, ParsedItem $item): static + public static function add(int $feedId, ParsedItem $parsed): void { - return new static( - feed_id: $feedId, - title: $item->title, - item_guid: $item->guid, - item_link: $item->link, - published_on: $item->publishedOn, - updated_on: $item->updatedOn, - content: $item->content); + Custom::nonQuery(<<<'SQL' + INSERT INTO item (data) + VALUES (json_set(:data, '$.id', (SELECT coalesce(max(data->>'id'), 0) + 1 FROM item))) + SQL, Parameters::json(':data', new static( + feed_id: $feedId, + title: $parsed->title, + item_guid: $parsed->guid, + item_link: $parsed->link, + published_on: $parsed->publishedOn, + updated_on: $parsed->updatedOn, + content: $parsed->content))); + } + + /** + * Update an item + * + * @param int $id The ID of the item to be updated + * @param ParsedItem $parsed The parsed item from the feed XML + * @throws DocumentException If any is encountered + */ + public static function update(int $id, ParsedItem $parsed): void + { + Patch::byId(Table::ITEM, $id, [ + 'title' => $parsed->title, + 'published_on' => $parsed->publishedOn, + 'updated_on' => $parsed->updatedOn, + 'content' => $parsed->content, + 'is_read' => 0 + ]); } } diff --git a/src/lib/ParsedItem.php b/src/lib/ParsedItem.php index 5d94350..7287506 100644 --- a/src/lib/ParsedItem.php +++ b/src/lib/ParsedItem.php @@ -9,39 +9,19 @@ use DOMNode; */ class ParsedItem { - /** @var string The title of the feed item */ - public string $title = ''; - - /** @var string The unique ID for the feed item */ - public string $guid = ''; - - /** @var string The link to the original content */ - public string $link = ''; - - /** @var string When this item was published */ - public string $publishedOn = ''; - - /** @var ?string When this item was last updated */ - public ?string $updatedOn = null; - - /** @var string The content for the item */ - public string $content = ''; - /** - * Get the fields needed to update the item in the database + * Constructor * - * @return array The fields needed tu update an item + * @param string $guid The unique ID for the feed item + * @param string $title The title of the feed item + * @param string $link The link to the original content + * @param string $publishedOn When this item was published + * @param string|null $updatedOn When this item was last updated + * @param string $content The content for the item */ - public function patchFields(): array - { - return [ - 'title' => $this->title, - 'published_on' => $this->publishedOn, - 'updated_on' => $this->updatedOn, - 'content' => $this->content, - 'is_read' => 0 - ]; - } + private function __construct(public string $guid = '', public string $title = '', public string $link = '', + public string $publishedOn = '', public ?string $updatedOn = null, + public string $content = '') { } /** * Construct a feed item from an Atom feed's `` tag @@ -64,15 +44,13 @@ class ParsedItem } if ($link == '' && str_starts_with($guid, 'http')) $link = $guid; - $item = new static(); - $item->guid = $guid; - $item->title = ParsedFeed::atomValue($node, 'title'); - $item->link = $link; - $item->publishedOn = Data::formatDate(ParsedFeed::atomValue($node, 'published')); - $item->updatedOn = Data::formatDate(ParsedFeed::atomValue($node, 'updated')); - $item->content = ParsedFeed::atomValue($node, 'content'); - - return $item; + return new static( + guid: $guid, + title: ParsedFeed::atomValue($node, 'title'), + link: $link, + publishedOn: Data::formatDate(ParsedFeed::atomValue($node, 'published')), + updatedOn: Data::formatDate(ParsedFeed::atomValue($node, 'updated')), + content: ParsedFeed::atomValue($node, 'content')); } /** @@ -87,16 +65,14 @@ class ParsedItem $updNodes = $node->getElementsByTagNameNS(ParsedFeed::ATOM_NS, 'updated'); $encNodes = $node->getElementsByTagNameNS(ParsedFeed::CONTENT_NS, 'encoded'); - $item = new static(); - $item->guid = $itemGuid == 'guid not found' ? ParsedFeed::rssValue($node, 'link') : $itemGuid; - $item->title = ParsedFeed::rssValue($node, 'title'); - $item->link = ParsedFeed::rssValue($node, 'link'); - $item->publishedOn = Data::formatDate(ParsedFeed::rssValue($node, 'pubDate')); - $item->updatedOn = Data::formatDate($updNodes->length > 0 ? $updNodes->item(0)->textContent : null); - $item->content = $encNodes->length > 0 - ? $encNodes->item(0)->textContent - : ParsedFeed::rssValue($node, 'description'); - - return $item; + return new static( + guid: $itemGuid == 'guid not found' ? ParsedFeed::rssValue($node, 'link') : $itemGuid, + title: ParsedFeed::rssValue($node, 'title'), + link: ParsedFeed::rssValue($node, 'link'), + publishedOn: Data::formatDate(ParsedFeed::rssValue($node, 'pubDate')), + updatedOn: Data::formatDate($updNodes->length > 0 ? $updNodes->item(0)->textContent : null), + content: $encNodes->length > 0 + ? $encNodes->item(0)->textContent + : ParsedFeed::rssValue($node, 'description')); } } diff --git a/src/lib/User.php b/src/lib/User.php index 4549ce2..69d1823 100644 --- a/src/lib/User.php +++ b/src/lib/User.php @@ -2,7 +2,7 @@ namespace FeedReaderCentral; -use BitBadger\PDODocument\{Custom, Document, DocumentException, Field, Find, Parameters, Query}; +use BitBadger\PDODocument\{Custom, DocumentException, Field, Find, Parameters, Query}; use BitBadger\PDODocument\Mapper\ExistsMapper; /** @@ -40,7 +40,10 @@ class User */ public static function add(string $email, string $password): void { - Document::insert(Table::USER, new User(email: $email, password: $password)); + Custom::nonQuery(<<<'SQL' + INSERT INTO user (data) + VALUES (json_set(:data, '$.id', (SELECT coalesce(max(data->>'id'), 0) + 1 FROM user))) + SQL, Parameters::json(':data', new User(email: $email, password: $password))); } /** diff --git a/src/public/feed/index.php b/src/public/feed/index.php index 2caaaf3..d7e29e2 100644 --- a/src/public/feed/index.php +++ b/src/public/feed/index.php @@ -29,7 +29,7 @@ if ($_SERVER['REQUEST_METHOD'] == 'DELETE') { if ($_SERVER['REQUEST_METHOD'] == 'POST') { try { - $isNew = $_POST['id'] == 'new'; + $isNew = $_POST['id'] == '-1'; if ($isNew) { $result = Feed::add($_POST['url']); } else { diff --git a/src/public/feeds.php b/src/public/feeds.php index 2abb3a4..8cd5cfc 100644 --- a/src/public/feeds.php +++ b/src/public/feeds.php @@ -19,7 +19,7 @@ $feeds = Custom::list(Query\Find::byFields(Table::FEED, [$field]) . " ORDER BY l $field->appendParameter([]), new DocumentMapper(Feed::class)); page_head('Your Feeds'); -echo '

Your Feeds

' . hx_get('/feed/?id=new', 'Add Feed') . '

'; +echo '

Your Feeds

' . hx_get('/feed/?id=-1', 'Add Feed') . '

'; foreach ($feeds->items() as /** @var Feed $feed */ $feed) { $item = Table::ITEM; $counts = Custom::single(<< Date: Sun, 9 Jun 2024 21:27:49 -0400 Subject: [PATCH 13/15] Add distro create script --- .gitignore | 2 ++ create_dist.sh | 14 ++++++++++++++ 2 files changed, 16 insertions(+) create mode 100755 create_dist.sh diff --git a/.gitignore b/.gitignore index ff87fff..7d5a505 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ vendor src/data/*.db* src/user-config.php +*.tar.bz2 +*.zip diff --git a/create_dist.sh b/create_dist.sh new file mode 100755 index 0000000..097ee13 --- /dev/null +++ b/create_dist.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# Create release distribution archives +mkdir tmp +cd tmp || exit +cp -r ../src/* . +rm ./data/* || true +rm -rf ./vendor/bit-badger/pdo-document/.git* || true +rm -rf ./vendor/bit-badger/pdo-document/tests || true +rm -rf ./vendor/bit-badger/pdo-document/composer.lock || true +rm ./user-config.php || true +zip -q -r ../frc-"$1".zip * +tar cfj ../frc-"$1".tar.bz2 * +cd .. || exit +rm -rf tmp \ No newline at end of file -- 2.45.1 From 3cafb318dcb99fa190c622104a693804ad4d087e Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Tue, 11 Jun 2024 21:03:27 -0400 Subject: [PATCH 14/15] Use auto IDs; drop to PHP 8.2 --- src/app-config.php | 3 ++- src/composer.json | 1 + src/composer.lock | 11 ++++++----- src/lib/Feed.php | 15 ++++++--------- src/lib/Item.php | 21 +++++++++------------ src/lib/ItemWithFeed.php | 4 ++-- src/lib/Key.php | 6 +++--- src/lib/ParsedFeed.php | 8 ++++---- src/lib/Security.php | 16 +++++++--------- src/lib/Table.php | 6 +++--- src/lib/User.php | 7 ++----- 11 files changed, 45 insertions(+), 53 deletions(-) diff --git a/src/app-config.php b/src/app-config.php index 21fdabb..6f7e106 100644 --- a/src/app-config.php +++ b/src/app-config.php @@ -2,7 +2,7 @@ /** The current Feed Reader Central version */ -use BitBadger\PDODocument\Configuration; +use BitBadger\PDODocument\{AutoId, Configuration}; use FeedReaderCentral\Data; const FRC_VERSION = '1.0.0-beta1'; @@ -27,6 +27,7 @@ require __DIR__ . '/vendor/autoload.php'; require 'user-config.php'; Configuration::$pdoDSN = 'sqlite:' . implode(DIRECTORY_SEPARATOR, [__DIR__, 'data', DATABASE_NAME]); +Configuration::$autoId = AutoId::Number; Data::ensureDb(); /** @var string The date the world wide web was created */ diff --git a/src/composer.json b/src/composer.json index 6eab05b..298c72f 100644 --- a/src/composer.json +++ b/src/composer.json @@ -2,6 +2,7 @@ "name": "bit-badger/feed-reader-central", "minimum-stability": "dev", "require": { + "php": ">=8.2", "bit-badger/pdo-document": "^1", "ext-curl": "*", "ext-dom": "*", diff --git a/src/composer.lock b/src/composer.lock index 32c046f..a51a4d6 100644 --- a/src/composer.lock +++ b/src/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f074a197e429ac24507becc14e0d99c3", + "content-hash": "4f0add59e384feb6787acf3685c9e031", "packages": [ { "name": "bit-badger/pdo-document", - "version": "v1.0.0-alpha1", + "version": "v1.0.0-alpha2", "source": { "type": "git", "url": "https://git.bitbadger.solutions/bit-badger/pdo-document", - "reference": "f784f3e52cc1e4691fa347eefc82a2e4587c7f38" + "reference": "330e27218756df8b93081a17dead8aaec789b071" }, "require": { "ext-pdo": "*", "netresearch/jsonmapper": "^4", - "php": ">=8.3" + "php": ">=8.2" }, "require-dev": { "phpunit/phpunit": "^11" @@ -54,7 +54,7 @@ "rss": "https://git.bitbadger.solutions/bit-badger/pdo-document.rss", "source": "https://git.bitbadger.solutions/bit-badger/pdo-document" }, - "time": "2024-06-08T23:58:45+00:00" + "time": "2024-06-11T11:07:56+00:00" }, { "name": "netresearch/jsonmapper", @@ -115,6 +115,7 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { + "php": ">=8.2", "ext-curl": "*", "ext-dom": "*", "ext-pdo": "*", diff --git a/src/lib/Feed.php b/src/lib/Feed.php index d6445d7..f24ed87 100644 --- a/src/lib/Feed.php +++ b/src/lib/Feed.php @@ -3,7 +3,7 @@ namespace FeedReaderCentral; use BitBadger\PDODocument\{ - Configuration, Custom, DocumentException, DocumentList, Exists, Field, Find, Parameters, Patch, Query + Configuration, Custom, Document, DocumentException, DocumentList, Exists, Field, Find, Parameters, Patch, Query }; use DateTimeInterface; @@ -15,16 +15,16 @@ class Feed // ***** CONSTANTS ***** /** @var int Do not purge items */ - public const int PURGE_NONE = 0; + public const PURGE_NONE = 0; /** @var int Purge all read items (will not purge unread items) */ - public const int PURGE_READ = 1; + public const PURGE_READ = 1; /** @var int Purge items older than the specified number of days */ - public const int PURGE_BY_DAYS = 2; + public const PURGE_BY_DAYS = 2; /** @var int Purge items in number greater than the specified number of items to keep */ - public const int PURGE_BY_COUNT = 3; + public const PURGE_BY_COUNT = 3; /** * Constructor @@ -187,10 +187,7 @@ class Feed return ['error' => "Already subscribed to feed $feed->url"]; } - Custom::nonQuery(<<<'SQL' - INSERT INTO feed (data) - VALUES (json_set(:data, '$.id', (SELECT coalesce(max(data->>'id'), 0) + 1 FROM feed))) - SQL, Parameters::json(':data', self::fromParsed($feed))); + Document::insert(Table::FEED, self::fromParsed($feed)); $doc = Find::firstByFields(Table::FEED, $fields, static::class); if (!$doc) return ['error' => 'Could not retrieve inserted feed']; diff --git a/src/lib/Item.php b/src/lib/Item.php index ecbd7e2..757d1cb 100644 --- a/src/lib/Item.php +++ b/src/lib/Item.php @@ -2,7 +2,7 @@ namespace FeedReaderCentral; -use BitBadger\PDODocument\{Custom, DocumentException, Parameters, Patch}; +use BitBadger\PDODocument\{Document, DocumentException, Patch}; /** * An item from a feed @@ -57,17 +57,14 @@ class Item */ public static function add(int $feedId, ParsedItem $parsed): void { - Custom::nonQuery(<<<'SQL' - INSERT INTO item (data) - VALUES (json_set(:data, '$.id', (SELECT coalesce(max(data->>'id'), 0) + 1 FROM item))) - SQL, Parameters::json(':data', new static( - feed_id: $feedId, - title: $parsed->title, - item_guid: $parsed->guid, - item_link: $parsed->link, - published_on: $parsed->publishedOn, - updated_on: $parsed->updatedOn, - content: $parsed->content))); + Document::insert(Table::ITEM, new static( + feed_id: $feedId, + title: $parsed->title, + item_guid: $parsed->guid, + item_link: $parsed->link, + published_on: $parsed->publishedOn, + updated_on: $parsed->updatedOn, + content: $parsed->content)); } /** diff --git a/src/lib/ItemWithFeed.php b/src/lib/ItemWithFeed.php index c7f7ec5..602626b 100644 --- a/src/lib/ItemWithFeed.php +++ b/src/lib/ItemWithFeed.php @@ -11,11 +11,11 @@ use BitBadger\PDODocument\Mapper\{DocumentMapper, ExistsMapper}; class ItemWithFeed extends Item { /** @var string The body of the `FROM` clause to join item and feed */ - public const string FROM_WITH_JOIN = Table::ITEM . ' INNER JOIN ' . Table::FEED + public const FROM_WITH_JOIN = Table::ITEM . ' INNER JOIN ' . Table::FEED . ' ON ' . Table::ITEM . ".data->>'feed_id' = " . Table::FEED . ".data->>'id'"; /** @var string The `SELECT` clause to add the feed as a property to the item's document */ - public const string SELECT_WITH_FEED = + public const SELECT_WITH_FEED = 'SELECT json_set(' . Table::ITEM . ".data, '$.feed', json(" . Table::FEED . '.data)) AS data FROM ' . self::FROM_WITH_JOIN; diff --git a/src/lib/Key.php b/src/lib/Key.php index 137e848..d68ba38 100644 --- a/src/lib/Key.php +++ b/src/lib/Key.php @@ -8,11 +8,11 @@ namespace FeedReaderCentral; class Key { /** @var string The $_SESSION key for the current user's e-mail address */ - public const string USER_EMAIL = 'FRC_USER_EMAIL'; + public const USER_EMAIL = 'FRC_USER_EMAIL'; /** @var string The $_SESSION key for the current user's ID */ - public const string USER_ID = 'FRC_USER_ID'; + public const 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'; + public const USER_MSG = 'FRC_USER_MSG'; } diff --git a/src/lib/ParsedFeed.php b/src/lib/ParsedFeed.php index a263bfb..947c389 100644 --- a/src/lib/ParsedFeed.php +++ b/src/lib/ParsedFeed.php @@ -22,16 +22,16 @@ class ParsedFeed public array $items = []; /** @var string The XML namespace for Atom feeds */ - public const string ATOM_NS = 'http://www.w3.org/2005/Atom'; + public const ATOM_NS = 'http://www.w3.org/2005/Atom'; /** @var string The XML namespace for the `` tag that allows HTML content in a feed */ - public const string CONTENT_NS = 'http://purl.org/rss/1.0/modules/content/'; + public const CONTENT_NS = 'http://purl.org/rss/1.0/modules/content/'; /** @var string The XML namespace for XHTML */ - public const string XHTML_NS = 'http://www.w3.org/1999/xhtml'; + public const XHTML_NS = 'http://www.w3.org/1999/xhtml'; /** @var string The user agent for Feed Reader Central's refresh requests */ - private const string USER_AGENT = + private const USER_AGENT = 'FeedReaderCentral/' . FRC_VERSION . ' +https://bitbadger.solutions/open-source/feed-reader-central'; /** diff --git a/src/lib/Security.php b/src/lib/Security.php index 68d9fce..8e74926 100644 --- a/src/lib/Security.php +++ b/src/lib/Security.php @@ -2,9 +2,7 @@ namespace FeedReaderCentral; -use BitBadger\PDODocument\DocumentException; -use BitBadger\PDODocument\Field; -use BitBadger\PDODocument\Patch; +use BitBadger\PDODocument\{DocumentException, Field, Patch}; /** * Security functions @@ -12,22 +10,22 @@ use BitBadger\PDODocument\Patch; class Security { /** @var int Run as a single user requiring no password */ - public const int SINGLE_USER = 0; + public const SINGLE_USER = 0; /** @var int Run as a single user requiring a password */ - public const int SINGLE_USER_WITH_PASSWORD = 1; + public const SINGLE_USER_WITH_PASSWORD = 1; /** @var int Require users to provide e-mail address and password */ - public const int MULTI_USER = 2; + public const MULTI_USER = 2; /** @var string The e-mail address for the single user */ - public const string SINGLE_USER_EMAIL = 'solouser@example.com'; + public const 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'; + public const SINGLE_USER_PASSWORD = 'no-password-required'; /** @var string The password algorithm to use for our passwords */ - public const string PW_ALGORITHM = PASSWORD_DEFAULT; + public const PW_ALGORITHM = PASSWORD_DEFAULT; /** * Verify a user's password diff --git a/src/lib/Table.php b/src/lib/Table.php index e0eeb0d..b4f62b0 100644 --- a/src/lib/Table.php +++ b/src/lib/Table.php @@ -8,11 +8,11 @@ namespace FeedReaderCentral; class Table { /** @var string The user table */ - public const string USER = 'frc_user'; + public const USER = 'frc_user'; /** @var string The feed table */ - public const string FEED = 'feed'; + public const FEED = 'feed'; /** @var string The item table */ - public const string ITEM = 'item'; + public const ITEM = 'item'; } diff --git a/src/lib/User.php b/src/lib/User.php index 69d1823..4549ce2 100644 --- a/src/lib/User.php +++ b/src/lib/User.php @@ -2,7 +2,7 @@ namespace FeedReaderCentral; -use BitBadger\PDODocument\{Custom, DocumentException, Field, Find, Parameters, Query}; +use BitBadger\PDODocument\{Custom, Document, DocumentException, Field, Find, Parameters, Query}; use BitBadger\PDODocument\Mapper\ExistsMapper; /** @@ -40,10 +40,7 @@ class User */ public static function add(string $email, string $password): void { - Custom::nonQuery(<<<'SQL' - INSERT INTO user (data) - VALUES (json_set(:data, '$.id', (SELECT coalesce(max(data->>'id'), 0) + 1 FROM user))) - SQL, Parameters::json(':data', new User(email: $email, password: $password))); + Document::insert(Table::USER, new User(email: $email, password: $password)); } /** -- 2.45.1 From 43ab5b55817e105413a564b7a5a65d0bfa439727 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Tue, 11 Jun 2024 21:58:27 -0400 Subject: [PATCH 15/15] Add server configs; update INSTALL verbiage --- INSTALLING.md | 47 ++++++++++++------- src/servers/fastcgi.Caddyfile | 9 ++++ .../frankenphp.Caddyfile} | 3 +- src/servers/htaccess | 5 ++ src/servers/nginx.conf | 27 +++++++++++ 5 files changed, 74 insertions(+), 17 deletions(-) create mode 100644 src/servers/fastcgi.Caddyfile rename src/{Caddyfile => servers/frankenphp.Caddyfile} (53%) create mode 100644 src/servers/htaccess create mode 100644 src/servers/nginx.conf diff --git a/INSTALLING.md b/INSTALLING.md index 047f8ea..79a3664 100644 --- a/INSTALLING.md +++ b/INSTALLING.md @@ -1,30 +1,46 @@ # Installation -## All Environments (FrankenPHP) +## Obtaining the Files -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. +Releases are named `frc-[version]` and are provided in both `.zip` and `.tar.bz2` formats on [the release page](https://git.bitbadger.solutions/bit-badger/feed-reader-central/releases) (starting with beta1). Select a folder on the server that will host this instance and unarchive the release there. -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` +## Selecting a Server -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`. +Configuration samples are provided in the `/servers` directory for [Apache 2](https://httpd.apache.org/docs/), [nginx](https://nginx.org/en/), and [Caddy](https://caddyserver.com/docs/). -Once those steps are complete, from the `/src` directory, run `frankenphp run`. +### Apache 2 -_(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.)_ +Configure a virtual host (if appropriate), then copy `/servers/htaccess` to `.htaccess` in the directory where the release archive was expanded. This file enables the extensionless rewrites required for the URLs to work. + +### nginx + +The configuration file `/servers/nginx.conf` is a full virtual host configuration. Update it with the paths for your installation, then add it to your configuration (on Debian/Ubuntu, copy it to `/etc/nginx/sites-available`, create a symlink in `/etc/nginx/sites-enabed`, and reload the nginx config). + +### Caddy (with FastCGI) + +`/servers/fastcgi.Caddyfile` contains configuration to host the site using Caddy. Ensure the PHP FPM address is correct, then rename it to `Caddyfile` and place it in the directory where the release archive was expanded. Running `caddy run` will serve the site with the given parameters. + +### Caddy (with FrankenPHP) + +_NOTE: This is currently not recommended. There is a known sequence of links that cause FrankenPHP to no longer respond. Hopefully this will be resolved before the beta moniker is dropped._ + +~~[FrankenPHP](https://frankenphp.dev) is a version of Caddy that runs PHP in its process. `/servers/frankenphp.Caddyfile` will configure the site to run with FrankenPHP.~~ + +~~Obtain a version 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`.~~ ## PHP Requirements -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.)_ +This is written to target PHP 8.2, 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 -## 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.dist.php`. Rename this file to `user-config.php`; this is where customizations and configuration of the instance's behavior are placed. @@ -38,7 +54,7 @@ There are three supported security models, designed around different ways the so ### 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`. +Data is stored in the `/data` subdirectory of the release extraction location, 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 `/data`. ### Date/Time Format @@ -51,4 +67,3 @@ Feed Reader Central tries to keep the database tidy by purging items that have b - `Feed::PURGE_READ` purges non-bookmarked read items for a feed whenever it is refreshed. This is the most aggressive purging strategy, but it is also the only one that will not purge unread items. - `Feed::PURGE_BY_DAYS` purges non-bookmarked items that are older than `PURGE_NUMBER` days old. This is the default value, and `PURGE_NUMBER`'s default value is 30; items will be kept for 30 days, read or unread. - `Feed::PURGE_BY_COUNT` purges items to preserve at most `PURGE_NUMBER` non-bookmarked items for each feed. - \ No newline at end of file diff --git a/src/servers/fastcgi.Caddyfile b/src/servers/fastcgi.Caddyfile new file mode 100644 index 0000000..04dd073 --- /dev/null +++ b/src/servers/fastcgi.Caddyfile @@ -0,0 +1,9 @@ +# Rename this to Caddyfile and place it in the directory where the release is unarchived +http://localhost:8205 { + root * ./public + # May change based on your PHP FPM configuration + php_fastcgi //unix/run/php/php8.3-fpm.sock { + try_files {path} {path}.php + } + file_server +} diff --git a/src/Caddyfile b/src/servers/frankenphp.Caddyfile similarity index 53% rename from src/Caddyfile rename to src/servers/frankenphp.Caddyfile index 96e6cbc..6f7a0b4 100644 --- a/src/Caddyfile +++ b/src/servers/frankenphp.Caddyfile @@ -1,9 +1,10 @@ +# Rename this to Caddyfile and place it in the directory where the release is unarchived { frankenphp order php_server before file_server } http://localhost:8205 { - root ./public + root * ./public try_files {path} {path}.php php_server } diff --git a/src/servers/htaccess b/src/servers/htaccess new file mode 100644 index 0000000..724b2da --- /dev/null +++ b/src/servers/htaccess @@ -0,0 +1,5 @@ +# Name this .htaccess and place it in the directory where the release is unarchived +RewriteEngine on +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +Rewrite "^(.*)" "$1.php" [QSA,L] diff --git a/src/servers/nginx.conf b/src/servers/nginx.conf new file mode 100644 index 0000000..f4566ad --- /dev/null +++ b/src/servers/nginx.conf @@ -0,0 +1,27 @@ +## nginx Configuration for Feed Reader Central using FastCGI Process Manager (FPM) +server { + server_name server.name; + listen 80; + listen [::]:80; + # /path/to/files is the root directory for the site + access_log /path/to/files/log/access.log; + error_log /path/to/files/log/error.log; + # /path/to/files/app is where the release distribution should be unarchived + root /path/to/files/app/public; + index index.php; + + location ~[^/]\.php(/|$) { + fastcgi_split_path_info ^(.+?\.php)(/.*)$; + if (!-f $document_root$fastcgi_script_name) { + return 404; + } + # This may need to change based on the configuration of PHP FPM + fastcgi_pass unix:/run/php/php8.3-fpm.sock; + fastcgi_index index.php; + include /etc/nginx/fastcgi_params; + } + + location / { + try_files $uri $uri/ $uri.php$is_args$args; + } +} -- 2.45.1