From 9f9627059a0b17de45a90e3c5fad9c1a49318151 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Wed, 7 Aug 2019 13:18:02 +0200 Subject: Make sure that bookmark sort is consistent, even with equal timestamps Fixes #1348 --- application/bookmark/LinkDB.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) (limited to 'application') diff --git a/application/bookmark/LinkDB.php b/application/bookmark/LinkDB.php index efde8468..f01c7ee6 100644 --- a/application/bookmark/LinkDB.php +++ b/application/bookmark/LinkDB.php @@ -102,7 +102,7 @@ class LinkDB implements Iterator, Countable, ArrayAccess $isLoggedIn, $hidePublicLinks ) { - + $this->datastore = $datastore; $this->loggedIn = $isLoggedIn; $this->hidePublicLinks = $hidePublicLinks; @@ -415,7 +415,7 @@ You use the community supported version of the original Shaarli project, by Seba $visibility = 'all', $untaggedonly = false ) { - + // Filter link database according to parameters. $searchtags = isset($filterRequest['searchtags']) ? escape($filterRequest['searchtags']) : ''; $searchterm = isset($filterRequest['searchterm']) ? escape($filterRequest['searchterm']) : ''; @@ -533,6 +533,9 @@ You use the community supported version of the original Shaarli project, by Seba if (isset($a['sticky']) && isset($b['sticky']) && $a['sticky'] !== $b['sticky']) { return $a['sticky'] ? -1 : 1; } + if ($a['created'] == $b['created']) { + return $a['id'] < $b['id'] ? 1 * $order : -1 * $order; + } return $a['created'] < $b['created'] ? 1 * $order : -1 * $order; }); -- cgit v1.2.3 From 0baa65813092459ff57dd599225f44e2a789ed8d Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Thu, 12 Sep 2019 19:32:30 +0200 Subject: Fix RSS permalink included in Markdown bloc Adds another line break before inserting RSS permalink to avoid including it in markdown blocs, such as blockquote. --- application/feed/FeedBuilder.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'application') diff --git a/application/feed/FeedBuilder.php b/application/feed/FeedBuilder.php index 7c859474..957c8273 100644 --- a/application/feed/FeedBuilder.php +++ b/application/feed/FeedBuilder.php @@ -157,7 +157,7 @@ class FeedBuilder $permalink = '' . t('Permalink') . ''; } $link['description'] = format_description($link['description'], $pageaddr); - $link['description'] .= PHP_EOL . '
— ' . $permalink; + $link['description'] .= PHP_EOL . PHP_EOL . '
— ' . $permalink; $pubDate = $link['created']; $link['pub_iso_date'] = $this->getIsoDate($pubDate); -- cgit v1.2.3 From 0b631e69d11e1860380deeeb684b82cab412518b Mon Sep 17 00:00:00 2001 From: nodiscc Date: Sat, 21 Sep 2019 16:48:24 +0000 Subject: thumbnailer: add soundcloud.com to list of common media domains OpenGraph thumbnails are well supported on soundcloud.com, displaying an album/track/artist cover image --- application/Thumbnailer.php | 1 + 1 file changed, 1 insertion(+) (limited to 'application') diff --git a/application/Thumbnailer.php b/application/Thumbnailer.php index d5f5ac28..314baf0d 100644 --- a/application/Thumbnailer.php +++ b/application/Thumbnailer.php @@ -27,6 +27,7 @@ class Thumbnailer 'instagram.com', 'pinterest.com', 'pinterest.fr', + 'soundcloud.com', 'tumblr.com', 'deviantart.com', ]; -- cgit v1.2.3 From def39d0dd7a81a4af9ad68b62c9e9823fbc2b38e Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Sat, 10 Aug 2019 12:31:32 +0200 Subject: Run Unit Tests against PHP 7.4 Bump PHPUnit version and fix unit test - Globals are handled differently and are persistent through tests - Tests without assertions are marked as risky: some of them are just meant to check that no error is raised. --- application/ApplicationUtils.php | 3 +++ application/api/ApiUtils.php | 4 ++++ 2 files changed, 7 insertions(+) (limited to 'application') diff --git a/application/ApplicationUtils.php b/application/ApplicationUtils.php index 7fe3cb32..3aa21829 100644 --- a/application/ApplicationUtils.php +++ b/application/ApplicationUtils.php @@ -150,6 +150,8 @@ class ApplicationUtils * @param string $minVersion minimum PHP required version * @param string $curVersion current PHP version (use PHP_VERSION) * + * @return bool true on success + * * @throws Exception the PHP version is not supported */ public static function checkPHPVersion($minVersion, $curVersion) @@ -163,6 +165,7 @@ class ApplicationUtils ); throw new Exception(sprintf($msg, $minVersion)); } + return true; } /** diff --git a/application/api/ApiUtils.php b/application/api/ApiUtils.php index 1e3ac02e..5ac07c4d 100644 --- a/application/api/ApiUtils.php +++ b/application/api/ApiUtils.php @@ -15,6 +15,8 @@ class ApiUtils * @param string $token JWT token extracted from the headers. * @param string $secret API secret set in the settings. * + * @return bool true on success + * * @throws ApiAuthorizationException the token is not valid. */ public static function validateJwtToken($token, $secret) @@ -45,6 +47,8 @@ class ApiUtils ) { throw new ApiAuthorizationException('Invalid JWT issued time'); } + + return true; } /** -- cgit v1.2.3 From 336a28fa4a09b968ce4705900bf57693e672f0bf Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Sat, 25 May 2019 15:46:47 +0200 Subject: Introduce Bookmark object and Service layer to retrieve them See https://github.com/shaarli/Shaarli/issues/1307 for details --- application/bookmark/Bookmark.php | 461 +++++++++++++++ application/bookmark/BookmarkArray.php | 259 +++++++++ application/bookmark/BookmarkFileService.php | 373 +++++++++++++ application/bookmark/BookmarkFilter.php | 468 ++++++++++++++++ application/bookmark/BookmarkIO.php | 108 ++++ application/bookmark/BookmarkInitializer.php | 59 ++ application/bookmark/BookmarkServiceInterface.php | 180 ++++++ application/bookmark/LinkDB.php | 578 ------------------- application/bookmark/LinkFilter.php | 449 --------------- application/bookmark/LinkUtils.php | 27 +- .../exception/BookmarkNotFoundException.php | 15 + .../bookmark/exception/EmptyDataStoreException.php | 7 + .../exception/InvalidBookmarkException.php | 30 + .../bookmark/exception/LinkNotFoundException.php | 15 - .../exception/NotWritableDataStoreException.php | 19 + application/formatter/BookmarkDefaultFormatter.php | 81 +++ application/formatter/BookmarkFormatter.php | 256 +++++++++ .../formatter/BookmarkMarkdownFormatter.php | 198 +++++++ application/formatter/BookmarkRawFormatter.php | 13 + application/formatter/FormatterFactory.php | 46 ++ application/legacy/LegacyLinkDB.php | 580 +++++++++++++++++++ application/legacy/LegacyLinkFilter.php | 451 +++++++++++++++ application/legacy/LegacyUpdater.php | 617 +++++++++++++++++++++ 23 files changed, 4225 insertions(+), 1065 deletions(-) create mode 100644 application/bookmark/Bookmark.php create mode 100644 application/bookmark/BookmarkArray.php create mode 100644 application/bookmark/BookmarkFileService.php create mode 100644 application/bookmark/BookmarkFilter.php create mode 100644 application/bookmark/BookmarkIO.php create mode 100644 application/bookmark/BookmarkInitializer.php create mode 100644 application/bookmark/BookmarkServiceInterface.php delete mode 100644 application/bookmark/LinkDB.php delete mode 100644 application/bookmark/LinkFilter.php create mode 100644 application/bookmark/exception/BookmarkNotFoundException.php create mode 100644 application/bookmark/exception/EmptyDataStoreException.php create mode 100644 application/bookmark/exception/InvalidBookmarkException.php delete mode 100644 application/bookmark/exception/LinkNotFoundException.php create mode 100644 application/bookmark/exception/NotWritableDataStoreException.php create mode 100644 application/formatter/BookmarkDefaultFormatter.php create mode 100644 application/formatter/BookmarkFormatter.php create mode 100644 application/formatter/BookmarkMarkdownFormatter.php create mode 100644 application/formatter/BookmarkRawFormatter.php create mode 100644 application/formatter/FormatterFactory.php create mode 100644 application/legacy/LegacyLinkDB.php create mode 100644 application/legacy/LegacyLinkFilter.php create mode 100644 application/legacy/LegacyUpdater.php (limited to 'application') diff --git a/application/bookmark/Bookmark.php b/application/bookmark/Bookmark.php new file mode 100644 index 00000000..b08e5d67 --- /dev/null +++ b/application/bookmark/Bookmark.php @@ -0,0 +1,461 @@ +id = $data['id']; + $this->shortUrl = $data['shorturl']; + $this->url = $data['url']; + $this->title = $data['title']; + $this->description = $data['description']; + $this->thumbnail = ! empty($data['thumbnail']) ? $data['thumbnail'] : null; + $this->sticky = ! empty($data['sticky']) ? $data['sticky'] : false; + $this->created = $data['created']; + if (is_array($data['tags'])) { + $this->tags = $data['tags']; + } else { + $this->tags = preg_split('/\s+/', $data['tags'], -1, PREG_SPLIT_NO_EMPTY); + } + if (! empty($data['updated'])) { + $this->updated = $data['updated']; + } + $this->private = $data['private'] ? true : false; + + return $this; + } + + /** + * Make sure that the current instance of Bookmark is valid and can be saved into the data store. + * A valid link requires: + * - an integer ID + * - a short URL (for permalinks) + * - a creation date + * + * This function also initialize optional empty fields: + * - the URL with the permalink + * - the title with the URL + * + * @throws InvalidBookmarkException + */ + public function validate() + { + if ($this->id === null + || ! is_int($this->id) + || empty($this->shortUrl) + || empty($this->created) + || ! $this->created instanceof DateTime + ) { + throw new InvalidBookmarkException($this); + } + if (empty($this->url)) { + $this->url = '?'. $this->shortUrl; + } + if (empty($this->title)) { + $this->title = $this->url; + } + } + + /** + * Set the Id. + * If they're not already initialized, this function also set: + * - created: with the current datetime + * - shortUrl: with a generated small hash from the date and the given ID + * + * @param int $id + * + * @return Bookmark + */ + public function setId($id) + { + $this->id = $id; + if (empty($this->created)) { + $this->created = new DateTime(); + } + if (empty($this->shortUrl)) { + $this->shortUrl = link_small_hash($this->created, $this->id); + } + + return $this; + } + + /** + * Get the Id. + * + * @return int + */ + public function getId() + { + return $this->id; + } + + /** + * Get the ShortUrl. + * + * @return string + */ + public function getShortUrl() + { + return $this->shortUrl; + } + + /** + * Get the Url. + * + * @return string + */ + public function getUrl() + { + return $this->url; + } + + /** + * Get the Title. + * + * @return string + */ + public function getTitle() + { + return $this->title; + } + + /** + * Get the Description. + * + * @return string + */ + public function getDescription() + { + return ! empty($this->description) ? $this->description : ''; + } + + /** + * Get the Created. + * + * @return DateTime + */ + public function getCreated() + { + return $this->created; + } + + /** + * Get the Updated. + * + * @return DateTime + */ + public function getUpdated() + { + return $this->updated; + } + + /** + * Set the ShortUrl. + * + * @param string $shortUrl + * + * @return Bookmark + */ + public function setShortUrl($shortUrl) + { + $this->shortUrl = $shortUrl; + + return $this; + } + + /** + * Set the Url. + * + * @param string $url + * @param array $allowedProtocols + * + * @return Bookmark + */ + public function setUrl($url, $allowedProtocols = []) + { + $url = trim($url); + if (! empty($url)) { + $url = whitelist_protocols($url, $allowedProtocols); + } + $this->url = $url; + + return $this; + } + + /** + * Set the Title. + * + * @param string $title + * + * @return Bookmark + */ + public function setTitle($title) + { + $this->title = trim($title); + + return $this; + } + + /** + * Set the Description. + * + * @param string $description + * + * @return Bookmark + */ + public function setDescription($description) + { + $this->description = $description; + + return $this; + } + + /** + * Set the Created. + * Note: you shouldn't set this manually except for special cases (like bookmark import) + * + * @param DateTime $created + * + * @return Bookmark + */ + public function setCreated($created) + { + $this->created = $created; + + return $this; + } + + /** + * Set the Updated. + * + * @param DateTime $updated + * + * @return Bookmark + */ + public function setUpdated($updated) + { + $this->updated = $updated; + + return $this; + } + + /** + * Get the Private. + * + * @return bool + */ + public function isPrivate() + { + return $this->private ? true : false; + } + + /** + * Set the Private. + * + * @param bool $private + * + * @return Bookmark + */ + public function setPrivate($private) + { + $this->private = $private ? true : false; + + return $this; + } + + /** + * Get the Tags. + * + * @return array + */ + public function getTags() + { + return is_array($this->tags) ? $this->tags : []; + } + + /** + * Set the Tags. + * + * @param array $tags + * + * @return Bookmark + */ + public function setTags($tags) + { + $this->setTagsString(implode(' ', $tags)); + + return $this; + } + + /** + * Get the Thumbnail. + * + * @return string|bool + */ + public function getThumbnail() + { + return !$this->isNote() ? $this->thumbnail : false; + } + + /** + * Set the Thumbnail. + * + * @param string|bool $thumbnail + * + * @return Bookmark + */ + public function setThumbnail($thumbnail) + { + $this->thumbnail = $thumbnail; + + return $this; + } + + /** + * Get the Sticky. + * + * @return bool + */ + public function isSticky() + { + return $this->sticky ? true : false; + } + + /** + * Set the Sticky. + * + * @param bool $sticky + * + * @return Bookmark + */ + public function setSticky($sticky) + { + $this->sticky = $sticky ? true : false; + + return $this; + } + + /** + * @return string Bookmark's tags as a string, separated by a space + */ + public function getTagsString() + { + return implode(' ', $this->getTags()); + } + + /** + * @return bool + */ + public function isNote() + { + // We check empty value to get a valid result if the link has not been saved yet + return empty($this->url) || $this->url[0] === '?'; + } + + /** + * Set tags from a string. + * Note: + * - tags must be separated whether by a space or a comma + * - multiple spaces will be removed + * - trailing dash in tags will be removed + * + * @param string $tags + * + * @return $this + */ + public function setTagsString($tags) + { + // Remove first '-' char in tags. + $tags = preg_replace('/(^| )\-/', '$1', $tags); + // Explode all tags separted by spaces or commas + $tags = preg_split('/[\s,]+/', $tags); + // Remove eventual empty values + $tags = array_values(array_filter($tags)); + + $this->tags = $tags; + + return $this; + } + + /** + * Rename a tag in tags list. + * + * @param string $fromTag + * @param string $toTag + */ + public function renameTag($fromTag, $toTag) + { + if (($pos = array_search($fromTag, $this->tags)) !== false) { + $this->tags[$pos] = trim($toTag); + } + } + + /** + * Delete a tag from tags list. + * + * @param string $tag + */ + public function deleteTag($tag) + { + if (($pos = array_search($tag, $this->tags)) !== false) { + unset($this->tags[$pos]); + $this->tags = array_values($this->tags); + } + } +} diff --git a/application/bookmark/BookmarkArray.php b/application/bookmark/BookmarkArray.php new file mode 100644 index 00000000..b427c91a --- /dev/null +++ b/application/bookmark/BookmarkArray.php @@ -0,0 +1,259 @@ +offset. + */ + protected $ids; + + /** + * @var int Position in the $this->keys array (for the Iterator interface) + */ + protected $position; + + /** + * @var array List of offset keys (for the Iterator interface implementation) + */ + protected $keys; + + /** + * @var array List of all recorded URLs (key=url, value=bookmark offset) + * for fast reserve search (url-->bookmark offset) + */ + protected $urls; + + public function __construct() + { + $this->ids = []; + $this->bookmarks = []; + $this->keys = []; + $this->urls = []; + $this->position = 0; + } + + /** + * Countable - Counts elements of an object + * + * @return int Number of bookmarks + */ + public function count() + { + return count($this->bookmarks); + } + + /** + * ArrayAccess - Assigns a value to the specified offset + * + * @param int $offset Bookmark ID + * @param Bookmark $value instance + * + * @throws InvalidBookmarkException + */ + public function offsetSet($offset, $value) + { + if (! $value instanceof Bookmark + || $value->getId() === null || empty($value->getUrl()) + || ($offset !== null && ! is_int($offset)) || ! is_int($value->getId()) + || $offset !== null && $offset !== $value->getId() + ) { + throw new InvalidBookmarkException($value); + } + + // If the bookmark exists, we reuse the real offset, otherwise new entry + if ($offset !== null) { + $existing = $this->getBookmarkOffset($offset); + } else { + $existing = $this->getBookmarkOffset($value->getId()); + } + + if ($existing !== null) { + $offset = $existing; + } else { + $offset = count($this->bookmarks); + } + + $this->bookmarks[$offset] = $value; + $this->urls[$value->getUrl()] = $offset; + $this->ids[$value->getId()] = $offset; + } + + /** + * ArrayAccess - Whether or not an offset exists + * + * @param int $offset Bookmark ID + * + * @return bool true if it exists, false otherwise + */ + public function offsetExists($offset) + { + return array_key_exists($this->getBookmarkOffset($offset), $this->bookmarks); + } + + /** + * ArrayAccess - Unsets an offset + * + * @param int $offset Bookmark ID + */ + public function offsetUnset($offset) + { + $realOffset = $this->getBookmarkOffset($offset); + $url = $this->bookmarks[$realOffset]->getUrl(); + unset($this->urls[$url]); + unset($this->ids[$realOffset]); + unset($this->bookmarks[$realOffset]); + } + + /** + * ArrayAccess - Returns the value at specified offset + * + * @param int $offset Bookmark ID + * + * @return Bookmark|null The Bookmark if found, null otherwise + */ + public function offsetGet($offset) + { + $realOffset = $this->getBookmarkOffset($offset); + return isset($this->bookmarks[$realOffset]) ? $this->bookmarks[$realOffset] : null; + } + + /** + * Iterator - Returns the current element + * + * @return Bookmark corresponding to the current position + */ + public function current() + { + return $this[$this->keys[$this->position]]; + } + + /** + * Iterator - Returns the key of the current element + * + * @return int Bookmark ID corresponding to the current position + */ + public function key() + { + return $this->keys[$this->position]; + } + + /** + * Iterator - Moves forward to next element + */ + public function next() + { + ++$this->position; + } + + /** + * Iterator - Rewinds the Iterator to the first element + * + * Entries are sorted by date (latest first) + */ + public function rewind() + { + $this->keys = array_keys($this->ids); + $this->position = 0; + } + + /** + * Iterator - Checks if current position is valid + * + * @return bool true if the current Bookmark ID exists, false otherwise + */ + public function valid() + { + return isset($this->keys[$this->position]); + } + + /** + * Returns a bookmark offset in bookmarks array from its unique ID. + * + * @param int $id Persistent ID of a bookmark. + * + * @return int Real offset in local array, or null if doesn't exist. + */ + protected function getBookmarkOffset($id) + { + if (isset($this->ids[$id])) { + return $this->ids[$id]; + } + return null; + } + + /** + * Return the next key for bookmark creation. + * E.g. If the last ID is 597, the next will be 598. + * + * @return int next ID. + */ + public function getNextId() + { + if (!empty($this->ids)) { + return max(array_keys($this->ids)) + 1; + } + return 0; + } + + /** + * @param $url + * + * @return Bookmark|null + */ + public function getByUrl($url) + { + if (! empty($url) + && isset($this->urls[$url]) + && isset($this->bookmarks[$this->urls[$url]]) + ) { + return $this->bookmarks[$this->urls[$url]]; + } + return null; + } + + /** + * Reorder links by creation date (newest first). + * + * Also update the urls and ids mapping arrays. + * + * @param string $order ASC|DESC + */ + public function reorder($order = 'DESC') + { + $order = $order === 'ASC' ? -1 : 1; + // Reorder array by dates. + usort($this->bookmarks, function ($a, $b) use ($order) { + /** @var $a Bookmark */ + /** @var $b Bookmark */ + if ($a->isSticky() !== $b->isSticky()) { + return $a->isSticky() ? -1 : 1; + } + return $a->getCreated() < $b->getCreated() ? 1 * $order : -1 * $order; + }); + + $this->urls = []; + $this->ids = []; + foreach ($this->bookmarks as $key => $bookmark) { + $this->urls[$bookmark->getUrl()] = $key; + $this->ids[$bookmark->getId()] = $key; + } + } +} diff --git a/application/bookmark/BookmarkFileService.php b/application/bookmark/BookmarkFileService.php new file mode 100644 index 00000000..a56cc92b --- /dev/null +++ b/application/bookmark/BookmarkFileService.php @@ -0,0 +1,373 @@ +conf = $conf; + $this->history = $history; + $this->bookmarksIO = new BookmarkIO($this->conf); + $this->isLoggedIn = $isLoggedIn; + + if (!$this->isLoggedIn && $this->conf->get('privacy.hide_public_links', false)) { + $this->bookmarks = []; + } else { + try { + $this->bookmarks = $this->bookmarksIO->read(); + } catch (EmptyDataStoreException $e) { + $this->bookmarks = new BookmarkArray(); + if ($isLoggedIn) { + $this->save(); + } + } + + if (! $this->bookmarks instanceof BookmarkArray) { + $this->migrate(); + exit( + 'Your data store has been migrated, please reload the page.'. PHP_EOL . + 'If this message keeps showing up, please delete data/updates.txt file.' + ); + } + } + + $this->bookmarkFilter = new BookmarkFilter($this->bookmarks); + } + + /** + * @inheritDoc + */ + public function findByHash($hash) + { + $bookmark = $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_HASH, $hash); + // PHP 7.3 introduced array_key_first() to avoid this hack + $first = reset($bookmark); + if (! $this->isLoggedIn && $first->isPrivate()) { + throw new Exception('Not authorized'); + } + + return $bookmark; + } + + /** + * @inheritDoc + */ + public function findByUrl($url) + { + return $this->bookmarks->getByUrl($url); + } + + /** + * @inheritDoc + */ + public function search($request = [], $visibility = null, $caseSensitive = false, $untaggedOnly = false) + { + if ($visibility === null) { + $visibility = $this->isLoggedIn ? BookmarkFilter::$ALL : BookmarkFilter::$PUBLIC; + } + + // Filter bookmark database according to parameters. + $searchtags = isset($request['searchtags']) ? $request['searchtags'] : ''; + $searchterm = isset($request['searchterm']) ? $request['searchterm'] : ''; + + return $this->bookmarkFilter->filter( + BookmarkFilter::$FILTER_TAG | BookmarkFilter::$FILTER_TEXT, + [$searchtags, $searchterm], + $caseSensitive, + $visibility, + $untaggedOnly + ); + } + + /** + * @inheritDoc + */ + public function get($id, $visibility = null) + { + if (! isset($this->bookmarks[$id])) { + throw new BookmarkNotFoundException(); + } + + if ($visibility === null) { + $visibility = $this->isLoggedIn ? 'all' : 'public'; + } + + $bookmark = $this->bookmarks[$id]; + if (($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private') + || (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public') + ) { + throw new Exception('Unauthorized'); + } + + return $bookmark; + } + + /** + * @inheritDoc + */ + public function set($bookmark, $save = true) + { + if ($this->isLoggedIn !== true) { + throw new Exception(t('You\'re not authorized to alter the datastore')); + } + if (! $bookmark instanceof Bookmark) { + throw new Exception(t('Provided data is invalid')); + } + if (! isset($this->bookmarks[$bookmark->getId()])) { + throw new BookmarkNotFoundException(); + } + $bookmark->validate(); + + $bookmark->setUpdated(new \DateTime()); + $this->bookmarks[$bookmark->getId()] = $bookmark; + if ($save === true) { + $this->save(); + $this->history->updateLink($bookmark); + } + return $this->bookmarks[$bookmark->getId()]; + } + + /** + * @inheritDoc + */ + public function add($bookmark, $save = true) + { + if ($this->isLoggedIn !== true) { + throw new Exception(t('You\'re not authorized to alter the datastore')); + } + if (! $bookmark instanceof Bookmark) { + throw new Exception(t('Provided data is invalid')); + } + if (! empty($bookmark->getId())) { + throw new Exception(t('This bookmarks already exists')); + } + $bookmark->setId($this->bookmarks->getNextId()); + $bookmark->validate(); + + $this->bookmarks[$bookmark->getId()] = $bookmark; + if ($save === true) { + $this->save(); + $this->history->addLink($bookmark); + } + return $this->bookmarks[$bookmark->getId()]; + } + + /** + * @inheritDoc + */ + public function addOrSet($bookmark, $save = true) + { + if ($this->isLoggedIn !== true) { + throw new Exception(t('You\'re not authorized to alter the datastore')); + } + if (! $bookmark instanceof Bookmark) { + throw new Exception('Provided data is invalid'); + } + if ($bookmark->getId() === null) { + return $this->add($bookmark, $save); + } + return $this->set($bookmark, $save); + } + + /** + * @inheritDoc + */ + public function remove($bookmark, $save = true) + { + if ($this->isLoggedIn !== true) { + throw new Exception(t('You\'re not authorized to alter the datastore')); + } + if (! $bookmark instanceof Bookmark) { + throw new Exception(t('Provided data is invalid')); + } + if (! isset($this->bookmarks[$bookmark->getId()])) { + throw new BookmarkNotFoundException(); + } + + unset($this->bookmarks[$bookmark->getId()]); + if ($save === true) { + $this->save(); + $this->history->deleteLink($bookmark); + } + } + + /** + * @inheritDoc + */ + public function exists($id, $visibility = null) + { + if (! isset($this->bookmarks[$id])) { + return false; + } + + if ($visibility === null) { + $visibility = $this->isLoggedIn ? 'all' : 'public'; + } + + $bookmark = $this->bookmarks[$id]; + if (($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private') + || (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public') + ) { + return false; + } + + return true; + } + + /** + * @inheritDoc + */ + public function count($visibility = null) + { + return count($this->search([], $visibility)); + } + + /** + * @inheritDoc + */ + public function save() + { + if (!$this->isLoggedIn) { + // TODO: raise an Exception instead + die('You are not authorized to change the database.'); + } + $this->bookmarks->reorder(); + $this->bookmarksIO->write($this->bookmarks); + invalidateCaches($this->conf->get('resource.page_cache')); + } + + /** + * @inheritDoc + */ + public function bookmarksCountPerTag($filteringTags = [], $visibility = null) + { + $bookmarks = $this->search(['searchtags' => $filteringTags], $visibility); + $tags = []; + $caseMapping = []; + foreach ($bookmarks as $bookmark) { + foreach ($bookmark->getTags() as $tag) { + if (empty($tag) || (! $this->isLoggedIn && startsWith($tag, '.'))) { + continue; + } + // The first case found will be displayed. + if (!isset($caseMapping[strtolower($tag)])) { + $caseMapping[strtolower($tag)] = $tag; + $tags[$caseMapping[strtolower($tag)]] = 0; + } + $tags[$caseMapping[strtolower($tag)]]++; + } + } + + /* + * Formerly used arsort(), which doesn't define the sort behaviour for equal values. + * Also, this function doesn't produce the same result between PHP 5.6 and 7. + * + * So we now use array_multisort() to sort tags by DESC occurrences, + * then ASC alphabetically for equal values. + * + * @see https://github.com/shaarli/Shaarli/issues/1142 + */ + $keys = array_keys($tags); + $tmpTags = array_combine($keys, $keys); + array_multisort($tags, SORT_DESC, $tmpTags, SORT_ASC, $tags); + return $tags; + } + + /** + * @inheritDoc + */ + public function days() + { + $bookmarkDays = []; + foreach ($this->search() as $bookmark) { + $bookmarkDays[$bookmark->getCreated()->format('Ymd')] = 0; + } + $bookmarkDays = array_keys($bookmarkDays); + sort($bookmarkDays); + + return $bookmarkDays; + } + + /** + * @inheritDoc + */ + public function filterDay($request) + { + return $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_DAY, $request); + } + + /** + * @inheritDoc + */ + public function initialize() + { + $initializer = new BookmarkInitializer($this); + $initializer->initialize(); + } + + /** + * Handles migration to the new database format (BookmarksArray). + */ + protected function migrate() + { + $bookmarkDb = new LegacyLinkDB( + $this->conf->get('resource.datastore'), + true, + false + ); + $updater = new LegacyUpdater( + UpdaterUtils::read_updates_file($this->conf->get('resource.updates')), + $bookmarkDb, + $this->conf, + true + ); + $newUpdates = $updater->update(); + if (! empty($newUpdates)) { + UpdaterUtils::write_updates_file( + $this->conf->get('resource.updates'), + $updater->getDoneUpdates() + ); + } + } +} diff --git a/application/bookmark/BookmarkFilter.php b/application/bookmark/BookmarkFilter.php new file mode 100644 index 00000000..fd556679 --- /dev/null +++ b/application/bookmark/BookmarkFilter.php @@ -0,0 +1,468 @@ +bookmarks = $bookmarks; + } + + /** + * Filter bookmarks according to parameters. + * + * @param string $type Type of filter (eg. tags, permalink, etc.). + * @param mixed $request Filter content. + * @param bool $casesensitive Optional: Perform case sensitive filter if true. + * @param string $visibility Optional: return only all/private/public bookmarks + * @param bool $untaggedonly Optional: return only untagged bookmarks. Applies only if $type includes FILTER_TAG + * + * @return Bookmark[] filtered bookmark list. + * + * @throws BookmarkNotFoundException + */ + public function filter($type, $request, $casesensitive = false, $visibility = 'all', $untaggedonly = false) + { + if (!in_array($visibility, ['all', 'public', 'private'])) { + $visibility = 'all'; + } + + switch ($type) { + case self::$FILTER_HASH: + return $this->filterSmallHash($request); + case self::$FILTER_TAG | self::$FILTER_TEXT: // == "vuotext" + $noRequest = empty($request) || (empty($request[0]) && empty($request[1])); + if ($noRequest) { + if ($untaggedonly) { + return $this->filterUntagged($visibility); + } + return $this->noFilter($visibility); + } + if ($untaggedonly) { + $filtered = $this->filterUntagged($visibility); + } else { + $filtered = $this->bookmarks; + } + if (!empty($request[0])) { + $filtered = (new BookmarkFilter($filtered))->filterTags($request[0], $casesensitive, $visibility); + } + if (!empty($request[1])) { + $filtered = (new BookmarkFilter($filtered))->filterFulltext($request[1], $visibility); + } + return $filtered; + case self::$FILTER_TEXT: + return $this->filterFulltext($request, $visibility); + case self::$FILTER_TAG: + if ($untaggedonly) { + return $this->filterUntagged($visibility); + } else { + return $this->filterTags($request, $casesensitive, $visibility); + } + case self::$FILTER_DAY: + return $this->filterDay($request); + default: + return $this->noFilter($visibility); + } + } + + /** + * Unknown filter, but handle private only. + * + * @param string $visibility Optional: return only all/private/public bookmarks + * + * @return Bookmark[] filtered bookmarks. + */ + private function noFilter($visibility = 'all') + { + if ($visibility === 'all') { + return $this->bookmarks; + } + + $out = array(); + foreach ($this->bookmarks as $key => $value) { + if ($value->isPrivate() && $visibility === 'private') { + $out[$key] = $value; + } elseif (!$value->isPrivate() && $visibility === 'public') { + $out[$key] = $value; + } + } + + return $out; + } + + /** + * Returns the shaare corresponding to a smallHash. + * + * @param string $smallHash permalink hash. + * + * @return array $filtered array containing permalink data. + * + * @throws \Shaarli\Bookmark\Exception\BookmarkNotFoundException if the smallhash doesn't match any link. + */ + private function filterSmallHash($smallHash) + { + foreach ($this->bookmarks as $key => $l) { + if ($smallHash == $l->getShortUrl()) { + // Yes, this is ugly and slow + return [$key => $l]; + } + } + + throw new BookmarkNotFoundException(); + } + + /** + * Returns the list of bookmarks corresponding to a full-text search + * + * Searches: + * - in the URLs, title and description; + * - are case-insensitive; + * - terms surrounded by quotes " are exact terms search. + * - terms starting with a dash - are excluded (except exact terms). + * + * Example: + * print_r($mydb->filterFulltext('hollandais')); + * + * mb_convert_case($val, MB_CASE_LOWER, 'UTF-8') + * - allows to perform searches on Unicode text + * - see https://github.com/shaarli/Shaarli/issues/75 for examples + * + * @param string $searchterms search query. + * @param string $visibility Optional: return only all/private/public bookmarks. + * + * @return array search results. + */ + private function filterFulltext($searchterms, $visibility = 'all') + { + if (empty($searchterms)) { + return $this->noFilter($visibility); + } + + $filtered = array(); + $search = mb_convert_case(html_entity_decode($searchterms), MB_CASE_LOWER, 'UTF-8'); + $exactRegex = '/"([^"]+)"/'; + // Retrieve exact search terms. + preg_match_all($exactRegex, $search, $exactSearch); + $exactSearch = array_values(array_filter($exactSearch[1])); + + // Remove exact search terms to get AND terms search. + $explodedSearchAnd = explode(' ', trim(preg_replace($exactRegex, '', $search))); + $explodedSearchAnd = array_values(array_filter($explodedSearchAnd)); + + // Filter excluding terms and update andSearch. + $excludeSearch = array(); + $andSearch = array(); + foreach ($explodedSearchAnd as $needle) { + if ($needle[0] == '-' && strlen($needle) > 1) { + $excludeSearch[] = substr($needle, 1); + } else { + $andSearch[] = $needle; + } + } + + // Iterate over every stored link. + foreach ($this->bookmarks as $id => $link) { + // ignore non private bookmarks when 'privatonly' is on. + if ($visibility !== 'all') { + if (!$link->isPrivate() && $visibility === 'private') { + continue; + } elseif ($link->isPrivate() && $visibility === 'public') { + continue; + } + } + + // Concatenate link fields to search across fields. + // Adds a '\' separator for exact search terms. + $content = mb_convert_case($link->getTitle(), MB_CASE_LOWER, 'UTF-8') .'\\'; + $content .= mb_convert_case($link->getDescription(), MB_CASE_LOWER, 'UTF-8') .'\\'; + $content .= mb_convert_case($link->getUrl(), MB_CASE_LOWER, 'UTF-8') .'\\'; + $content .= mb_convert_case($link->getTagsString(), MB_CASE_LOWER, 'UTF-8') .'\\'; + + // Be optimistic + $found = true; + + // First, we look for exact term search + for ($i = 0; $i < count($exactSearch) && $found; $i++) { + $found = strpos($content, $exactSearch[$i]) !== false; + } + + // Iterate over keywords, if keyword is not found, + // no need to check for the others. We want all or nothing. + for ($i = 0; $i < count($andSearch) && $found; $i++) { + $found = strpos($content, $andSearch[$i]) !== false; + } + + // Exclude terms. + for ($i = 0; $i < count($excludeSearch) && $found; $i++) { + $found = strpos($content, $excludeSearch[$i]) === false; + } + + if ($found) { + $filtered[$id] = $link; + } + } + + return $filtered; + } + + /** + * generate a regex fragment out of a tag + * + * @param string $tag to to generate regexs from. may start with '-' to negate, contain '*' as wildcard + * + * @return string generated regex fragment + */ + private static function tag2regex($tag) + { + $len = strlen($tag); + if (!$len || $tag === "-" || $tag === "*") { + // nothing to search, return empty regex + return ''; + } + if ($tag[0] === "-") { + // query is negated + $i = 1; // use offset to start after '-' character + $regex = '(?!'; // create negative lookahead + } else { + $i = 0; // start at first character + $regex = '(?='; // use positive lookahead + } + $regex .= '.*(?:^| )'; // before tag may only be a space or the beginning + // iterate over string, separating it into placeholder and content + for (; $i < $len; $i++) { + if ($tag[$i] === '*') { + // placeholder found + $regex .= '[^ ]*?'; + } else { + // regular characters + $offset = strpos($tag, '*', $i); + if ($offset === false) { + // no placeholder found, set offset to end of string + $offset = $len; + } + // subtract one, as we want to get before the placeholder or end of string + $offset -= 1; + // we got a tag name that we want to search for. escape any regex characters to prevent conflicts. + $regex .= preg_quote(substr($tag, $i, $offset - $i + 1), '/'); + // move $i on + $i = $offset; + } + } + $regex .= '(?:$| ))'; // after the tag may only be a space or the end + return $regex; + } + + /** + * Returns the list of bookmarks associated with a given list of tags + * + * You can specify one or more tags, separated by space or a comma, e.g. + * print_r($mydb->filterTags('linux programming')); + * + * @param string $tags list of tags separated by commas or blank spaces. + * @param bool $casesensitive ignore case if false. + * @param string $visibility Optional: return only all/private/public bookmarks. + * + * @return array filtered bookmarks. + */ + public function filterTags($tags, $casesensitive = false, $visibility = 'all') + { + // get single tags (we may get passed an array, even though the docs say different) + $inputTags = $tags; + if (!is_array($tags)) { + // we got an input string, split tags + $inputTags = preg_split('/(?:\s+)|,/', $inputTags, -1, PREG_SPLIT_NO_EMPTY); + } + + if (!count($inputTags)) { + // no input tags + return $this->noFilter($visibility); + } + + // If we only have public visibility, we can't look for hidden tags + if ($visibility === self::$PUBLIC) { + $inputTags = array_values(array_filter($inputTags, function ($tag) { + return ! startsWith($tag, '.'); + })); + + if (empty($inputTags)) { + return []; + } + } + + // build regex from all tags + $re = '/^' . implode(array_map("self::tag2regex", $inputTags)) . '.*$/'; + if (!$casesensitive) { + // make regex case insensitive + $re .= 'i'; + } + + // create resulting array + $filtered = []; + + // iterate over each link + foreach ($this->bookmarks as $key => $link) { + // check level of visibility + // ignore non private bookmarks when 'privateonly' is on. + if ($visibility !== 'all') { + if (!$link->isPrivate() && $visibility === 'private') { + continue; + } elseif ($link->isPrivate() && $visibility === 'public') { + continue; + } + } + $search = $link->getTagsString(); // build search string, start with tags of current link + if (strlen(trim($link->getDescription())) && strpos($link->getDescription(), '#') !== false) { + // description given and at least one possible tag found + $descTags = array(); + // find all tags in the form of #tag in the description + preg_match_all( + '/(?getDescription(), + $descTags + ); + if (count($descTags[1])) { + // there were some tags in the description, add them to the search string + $search .= ' ' . implode(' ', $descTags[1]); + } + }; + // match regular expression with search string + if (!preg_match($re, $search)) { + // this entry does _not_ match our regex + continue; + } + $filtered[$key] = $link; + } + return $filtered; + } + + /** + * Return only bookmarks without any tag. + * + * @param string $visibility return only all/private/public bookmarks. + * + * @return array filtered bookmarks. + */ + public function filterUntagged($visibility) + { + $filtered = []; + foreach ($this->bookmarks as $key => $link) { + if ($visibility !== 'all') { + if (!$link->isPrivate() && $visibility === 'private') { + continue; + } elseif ($link->isPrivate() && $visibility === 'public') { + continue; + } + } + + if (empty(trim($link->getTagsString()))) { + $filtered[$key] = $link; + } + } + + return $filtered; + } + + /** + * Returns the list of articles for a given day, chronologically sorted + * + * Day must be in the form 'YYYYMMDD' (e.g. '20120125'), e.g. + * print_r($mydb->filterDay('20120125')); + * + * @param string $day day to filter. + * + * @return array all link matching given day. + * + * @throws Exception if date format is invalid. + */ + public function filterDay($day) + { + if (!checkDateFormat('Ymd', $day)) { + throw new Exception('Invalid date format'); + } + + $filtered = array(); + foreach ($this->bookmarks as $key => $l) { + if ($l->getCreated()->format('Ymd') == $day) { + $filtered[$key] = $l; + } + } + + // sort by date ASC + return array_reverse($filtered, true); + } + + /** + * Convert a list of tags (str) to an array. Also + * - handle case sensitivity. + * - accepts spaces commas as separator. + * + * @param string $tags string containing a list of tags. + * @param bool $casesensitive will convert everything to lowercase if false. + * + * @return array filtered tags string. + */ + public static function tagsStrToArray($tags, $casesensitive) + { + // We use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek) + $tagsOut = $casesensitive ? $tags : mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8'); + $tagsOut = str_replace(',', ' ', $tagsOut); + + return preg_split('/\s+/', $tagsOut, -1, PREG_SPLIT_NO_EMPTY); + } +} diff --git a/application/bookmark/BookmarkIO.php b/application/bookmark/BookmarkIO.php new file mode 100644 index 00000000..ae9ffcb4 --- /dev/null +++ b/application/bookmark/BookmarkIO.php @@ -0,0 +1,108 @@ +'; + + /** + * LinksIO constructor. + * + * @param ConfigManager $conf instance + */ + public function __construct($conf) + { + $this->conf = $conf; + $this->datastore = $conf->get('resource.datastore'); + } + + /** + * Reads database from disk to memory + * + * @return BookmarkArray instance + * + * @throws NotWritableDataStoreException Data couldn't be loaded + * @throws EmptyDataStoreException Datastore doesn't exist + */ + public function read() + { + if (! file_exists($this->datastore)) { + throw new EmptyDataStoreException(); + } + + if (!is_writable($this->datastore)) { + throw new NotWritableDataStoreException($this->datastore); + } + + // Note that gzinflate is faster than gzuncompress. + // See: http://www.php.net/manual/en/function.gzdeflate.php#96439 + $links = unserialize(gzinflate(base64_decode( + substr(file_get_contents($this->datastore), + strlen(self::$phpPrefix), -strlen(self::$phpSuffix))))); + + if (empty($links)) { + if (filesize($this->datastore) > 100) { + throw new NotWritableDataStoreException($this->datastore); + } + throw new EmptyDataStoreException(); + } + + return $links; + } + + /** + * Saves the database from memory to disk + * + * @param BookmarkArray $links instance. + * + * @throws NotWritableDataStoreException the datastore is not writable + */ + public function write($links) + { + if (is_file($this->datastore) && !is_writeable($this->datastore)) { + // The datastore exists but is not writeable + throw new NotWritableDataStoreException($this->datastore); + } else if (!is_file($this->datastore) && !is_writeable(dirname($this->datastore))) { + // The datastore does not exist and its parent directory is not writeable + throw new NotWritableDataStoreException(dirname($this->datastore)); + } + + file_put_contents( + $this->datastore, + self::$phpPrefix.base64_encode(gzdeflate(serialize($links))).self::$phpSuffix + ); + + invalidateCaches($this->conf->get('resource.page_cache')); + } +} diff --git a/application/bookmark/BookmarkInitializer.php b/application/bookmark/BookmarkInitializer.php new file mode 100644 index 00000000..9eee9a35 --- /dev/null +++ b/application/bookmark/BookmarkInitializer.php @@ -0,0 +1,59 @@ +bookmarkService = $bookmarkService; + } + + /** + * Initialize the data store with default bookmarks + */ + public function initialize() + { + $bookmark = new Bookmark(); + $bookmark->setTitle(t('My secret stuff... - Pastebin.com')); + $bookmark->setUrl('http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=', []); + $bookmark->setDescription(t('Shhhh! I\'m a private link only YOU can see. You can delete me too.')); + $bookmark->setTagsString('secretstuff'); + $bookmark->setPrivate(true); + $this->bookmarkService->add($bookmark); + + $bookmark = new Bookmark(); + $bookmark->setTitle(t('The personal, minimalist, super-fast, database free, bookmarking service')); + $bookmark->setUrl('https://shaarli.readthedocs.io', []); + $bookmark->setDescription(t( + 'Welcome to Shaarli! This is your first public bookmark. ' + . 'To edit or delete me, you must first login. + +To learn how to use Shaarli, consult the link "Documentation" at the bottom of this page. + +You use the community supported version of the original Shaarli project, by Sebastien Sauvage.' + )); + $bookmark->setTagsString('opensource software'); + $this->bookmarkService->add($bookmark); + } +} diff --git a/application/bookmark/BookmarkServiceInterface.php b/application/bookmark/BookmarkServiceInterface.php new file mode 100644 index 00000000..7b7a4f09 --- /dev/null +++ b/application/bookmark/BookmarkServiceInterface.php @@ -0,0 +1,180 @@ + bookmarksCount + */ + public function bookmarksCountPerTag($filteringTags = [], $visibility = 'all'); + + /** + * Returns the list of days containing articles (oldest first) + * + * @return array containing days (in format YYYYMMDD). + */ + public function days(); + + /** + * Returns the list of articles for a given day. + * + * @param string $request day to filter. Format: YYYYMMDD. + * + * @return Bookmark[] list of shaare found. + * + * @throws BookmarkNotFoundException + */ + public function filterDay($request); + + /** + * Creates the default database after a fresh install. + */ + public function initialize(); +} diff --git a/application/bookmark/LinkDB.php b/application/bookmark/LinkDB.php deleted file mode 100644 index f01c7ee6..00000000 --- a/application/bookmark/LinkDB.php +++ /dev/null @@ -1,578 +0,0 @@ -link offset) - private $urls; - - /** - * @var array List of all links IDS mapped with their array offset. - * Map: id->offset. - */ - protected $ids; - - // List of offset keys (for the Iterator interface implementation) - private $keys; - - // Position in the $this->keys array (for the Iterator interface) - private $position; - - // Is the user logged in? (used to filter private links) - private $loggedIn; - - // Hide public links - private $hidePublicLinks; - - /** - * Creates a new LinkDB - * - * Checks if the datastore exists; else, attempts to create a dummy one. - * - * @param string $datastore datastore file path. - * @param boolean $isLoggedIn is the user logged in? - * @param boolean $hidePublicLinks if true all links are private. - */ - public function __construct( - $datastore, - $isLoggedIn, - $hidePublicLinks - ) { - - $this->datastore = $datastore; - $this->loggedIn = $isLoggedIn; - $this->hidePublicLinks = $hidePublicLinks; - $this->check(); - $this->read(); - } - - /** - * Countable - Counts elements of an object - */ - public function count() - { - return count($this->links); - } - - /** - * ArrayAccess - Assigns a value to the specified offset - */ - public function offsetSet($offset, $value) - { - // TODO: use exceptions instead of "die" - if (!$this->loggedIn) { - die(t('You are not authorized to add a link.')); - } - if (!isset($value['id']) || empty($value['url'])) { - die(t('Internal Error: A link should always have an id and URL.')); - } - if (($offset !== null && !is_int($offset)) || !is_int($value['id'])) { - die(t('You must specify an integer as a key.')); - } - if ($offset !== null && $offset !== $value['id']) { - die(t('Array offset and link ID must be equal.')); - } - - // If the link exists, we reuse the real offset, otherwise new entry - $existing = $this->getLinkOffset($offset); - if ($existing !== null) { - $offset = $existing; - } else { - $offset = count($this->links); - } - $this->links[$offset] = $value; - $this->urls[$value['url']] = $offset; - $this->ids[$value['id']] = $offset; - } - - /** - * ArrayAccess - Whether or not an offset exists - */ - public function offsetExists($offset) - { - return array_key_exists($this->getLinkOffset($offset), $this->links); - } - - /** - * ArrayAccess - Unsets an offset - */ - public function offsetUnset($offset) - { - if (!$this->loggedIn) { - // TODO: raise an exception - die('You are not authorized to delete a link.'); - } - $realOffset = $this->getLinkOffset($offset); - $url = $this->links[$realOffset]['url']; - unset($this->urls[$url]); - unset($this->ids[$realOffset]); - unset($this->links[$realOffset]); - } - - /** - * ArrayAccess - Returns the value at specified offset - */ - public function offsetGet($offset) - { - $realOffset = $this->getLinkOffset($offset); - return isset($this->links[$realOffset]) ? $this->links[$realOffset] : null; - } - - /** - * Iterator - Returns the current element - */ - public function current() - { - return $this[$this->keys[$this->position]]; - } - - /** - * Iterator - Returns the key of the current element - */ - public function key() - { - return $this->keys[$this->position]; - } - - /** - * Iterator - Moves forward to next element - */ - public function next() - { - ++$this->position; - } - - /** - * Iterator - Rewinds the Iterator to the first element - * - * Entries are sorted by date (latest first) - */ - public function rewind() - { - $this->keys = array_keys($this->ids); - $this->position = 0; - } - - /** - * Iterator - Checks if current position is valid - */ - public function valid() - { - return isset($this->keys[$this->position]); - } - - /** - * Checks if the DB directory and file exist - * - * If no DB file is found, creates a dummy DB. - */ - private function check() - { - if (file_exists($this->datastore)) { - return; - } - - // Create a dummy database for example - $this->links = array(); - $link = array( - 'id' => 1, - 'title' => t('The personal, minimalist, super-fast, database free, bookmarking service'), - 'url' => 'https://shaarli.readthedocs.io', - 'description' => t( - 'Welcome to Shaarli! This is your first public bookmark. ' - . 'To edit or delete me, you must first login. - -To learn how to use Shaarli, consult the link "Documentation" at the bottom of this page. - -You use the community supported version of the original Shaarli project, by Sebastien Sauvage.' - ), - 'private' => 0, - 'created' => new DateTime(), - 'tags' => 'opensource software', - 'sticky' => false, - ); - $link['shorturl'] = link_small_hash($link['created'], $link['id']); - $this->links[1] = $link; - - $link = array( - 'id' => 0, - 'title' => t('My secret stuff... - Pastebin.com'), - 'url' => 'http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=', - 'description' => t('Shhhh! I\'m a private link only YOU can see. You can delete me too.'), - 'private' => 1, - 'created' => new DateTime('1 minute ago'), - 'tags' => 'secretstuff', - 'sticky' => false, - ); - $link['shorturl'] = link_small_hash($link['created'], $link['id']); - $this->links[0] = $link; - - // Write database to disk - $this->write(); - } - - /** - * Reads database from disk to memory - */ - private function read() - { - // Public links are hidden and user not logged in => nothing to show - if ($this->hidePublicLinks && !$this->loggedIn) { - $this->links = array(); - return; - } - - $this->urls = []; - $this->ids = []; - $this->links = FileUtils::readFlatDB($this->datastore, []); - - $toremove = array(); - foreach ($this->links as $key => &$link) { - if (!$this->loggedIn && $link['private'] != 0) { - // Transition for not upgraded databases. - unset($this->links[$key]); - continue; - } - - // Sanitize data fields. - sanitizeLink($link); - - // Remove private tags if the user is not logged in. - if (!$this->loggedIn) { - $link['tags'] = preg_replace('/(^|\s+)\.[^($|\s)]+\s*/', ' ', $link['tags']); - } - - $link['real_url'] = $link['url']; - - $link['sticky'] = isset($link['sticky']) ? $link['sticky'] : false; - - // To be able to load links before running the update, and prepare the update - if (!isset($link['created'])) { - $link['id'] = $link['linkdate']; - $link['created'] = DateTime::createFromFormat(self::LINK_DATE_FORMAT, $link['linkdate']); - if (!empty($link['updated'])) { - $link['updated'] = DateTime::createFromFormat(self::LINK_DATE_FORMAT, $link['updated']); - } - $link['shorturl'] = smallHash($link['linkdate']); - } - - $this->urls[$link['url']] = $key; - $this->ids[$link['id']] = $key; - } - } - - /** - * Saves the database from memory to disk - * - * @throws IOException the datastore is not writable - */ - private function write() - { - $this->reorder(); - FileUtils::writeFlatDB($this->datastore, $this->links); - } - - /** - * Saves the database from memory to disk - * - * @param string $pageCacheDir page cache directory - */ - public function save($pageCacheDir) - { - if (!$this->loggedIn) { - // TODO: raise an Exception instead - die('You are not authorized to change the database.'); - } - - $this->write(); - - invalidateCaches($pageCacheDir); - } - - /** - * Returns the link for a given URL, or False if it does not exist. - * - * @param string $url URL to search for - * - * @return mixed the existing link if it exists, else 'false' - */ - public function getLinkFromUrl($url) - { - if (isset($this->urls[$url])) { - return $this->links[$this->urls[$url]]; - } - return false; - } - - /** - * Returns the shaare corresponding to a smallHash. - * - * @param string $request QUERY_STRING server parameter. - * - * @return array $filtered array containing permalink data. - * - * @throws LinkNotFoundException if the smallhash is malformed or doesn't match any link. - */ - public function filterHash($request) - { - $request = substr($request, 0, 6); - $linkFilter = new LinkFilter($this->links); - return $linkFilter->filter(LinkFilter::$FILTER_HASH, $request); - } - - /** - * Returns the list of articles for a given day. - * - * @param string $request day to filter. Format: YYYYMMDD. - * - * @return array list of shaare found. - */ - public function filterDay($request) - { - $linkFilter = new LinkFilter($this->links); - return $linkFilter->filter(LinkFilter::$FILTER_DAY, $request); - } - - /** - * Filter links according to search parameters. - * - * @param array $filterRequest Search request content. Supported keys: - * - searchtags: list of tags - * - searchterm: term search - * @param bool $casesensitive Optional: Perform case sensitive filter - * @param string $visibility return only all/private/public links - * @param bool $untaggedonly return only untagged links - * - * @return array filtered links, all links if no suitable filter was provided. - */ - public function filterSearch( - $filterRequest = array(), - $casesensitive = false, - $visibility = 'all', - $untaggedonly = false - ) { - - // Filter link database according to parameters. - $searchtags = isset($filterRequest['searchtags']) ? escape($filterRequest['searchtags']) : ''; - $searchterm = isset($filterRequest['searchterm']) ? escape($filterRequest['searchterm']) : ''; - - // Search tags + fullsearch - blank string parameter will return all links. - $type = LinkFilter::$FILTER_TAG | LinkFilter::$FILTER_TEXT; // == "vuotext" - $request = [$searchtags, $searchterm]; - - $linkFilter = new LinkFilter($this); - return $linkFilter->filter($type, $request, $casesensitive, $visibility, $untaggedonly); - } - - /** - * Returns the list tags appearing in the links with the given tags - * - * @param array $filteringTags tags selecting the links to consider - * @param string $visibility process only all/private/public links - * - * @return array tag => linksCount - */ - public function linksCountPerTag($filteringTags = [], $visibility = 'all') - { - $links = $this->filterSearch(['searchtags' => $filteringTags], false, $visibility); - $tags = []; - $caseMapping = []; - foreach ($links as $link) { - foreach (preg_split('/\s+/', $link['tags'], 0, PREG_SPLIT_NO_EMPTY) as $tag) { - if (empty($tag)) { - continue; - } - // The first case found will be displayed. - if (!isset($caseMapping[strtolower($tag)])) { - $caseMapping[strtolower($tag)] = $tag; - $tags[$caseMapping[strtolower($tag)]] = 0; - } - $tags[$caseMapping[strtolower($tag)]]++; - } - } - - /* - * Formerly used arsort(), which doesn't define the sort behaviour for equal values. - * Also, this function doesn't produce the same result between PHP 5.6 and 7. - * - * So we now use array_multisort() to sort tags by DESC occurrences, - * then ASC alphabetically for equal values. - * - * @see https://github.com/shaarli/Shaarli/issues/1142 - */ - $keys = array_keys($tags); - $tmpTags = array_combine($keys, $keys); - array_multisort($tags, SORT_DESC, $tmpTags, SORT_ASC, $tags); - return $tags; - } - - /** - * Rename or delete a tag across all links. - * - * @param string $from Tag to rename - * @param string $to New tag. If none is provided, the from tag will be deleted - * - * @return array|bool List of altered links or false on error - */ - public function renameTag($from, $to) - { - if (empty($from)) { - return false; - } - $delete = empty($to); - // True for case-sensitive tag search. - $linksToAlter = $this->filterSearch(['searchtags' => $from], true); - foreach ($linksToAlter as $key => &$value) { - $tags = preg_split('/\s+/', trim($value['tags'])); - if (($pos = array_search($from, $tags)) !== false) { - if ($delete) { - unset($tags[$pos]); // Remove tag. - } else { - $tags[$pos] = trim($to); - } - $value['tags'] = trim(implode(' ', array_unique($tags))); - $this[$value['id']] = $value; - } - } - - return $linksToAlter; - } - - /** - * Returns the list of days containing articles (oldest first) - * Output: An array containing days (in format YYYYMMDD). - */ - public function days() - { - $linkDays = array(); - foreach ($this->links as $link) { - $linkDays[$link['created']->format('Ymd')] = 0; - } - $linkDays = array_keys($linkDays); - sort($linkDays); - - return $linkDays; - } - - /** - * Reorder links by creation date (newest first). - * - * Also update the urls and ids mapping arrays. - * - * @param string $order ASC|DESC - */ - public function reorder($order = 'DESC') - { - $order = $order === 'ASC' ? -1 : 1; - // Reorder array by dates. - usort($this->links, function ($a, $b) use ($order) { - if (isset($a['sticky']) && isset($b['sticky']) && $a['sticky'] !== $b['sticky']) { - return $a['sticky'] ? -1 : 1; - } - if ($a['created'] == $b['created']) { - return $a['id'] < $b['id'] ? 1 * $order : -1 * $order; - } - return $a['created'] < $b['created'] ? 1 * $order : -1 * $order; - }); - - $this->urls = []; - $this->ids = []; - foreach ($this->links as $key => $link) { - $this->urls[$link['url']] = $key; - $this->ids[$link['id']] = $key; - } - } - - /** - * Return the next key for link creation. - * E.g. If the last ID is 597, the next will be 598. - * - * @return int next ID. - */ - public function getNextId() - { - if (!empty($this->ids)) { - return max(array_keys($this->ids)) + 1; - } - return 0; - } - - /** - * Returns a link offset in links array from its unique ID. - * - * @param int $id Persistent ID of a link. - * - * @return int Real offset in local array, or null if doesn't exist. - */ - protected function getLinkOffset($id) - { - if (isset($this->ids[$id])) { - return $this->ids[$id]; - } - return null; - } -} diff --git a/application/bookmark/LinkFilter.php b/application/bookmark/LinkFilter.php deleted file mode 100644 index 9b966307..00000000 --- a/application/bookmark/LinkFilter.php +++ /dev/null @@ -1,449 +0,0 @@ -links = $links; - } - - /** - * Filter links according to parameters. - * - * @param string $type Type of filter (eg. tags, permalink, etc.). - * @param mixed $request Filter content. - * @param bool $casesensitive Optional: Perform case sensitive filter if true. - * @param string $visibility Optional: return only all/private/public links - * @param string $untaggedonly Optional: return only untagged links. Applies only if $type includes FILTER_TAG - * - * @return array filtered link list. - */ - public function filter($type, $request, $casesensitive = false, $visibility = 'all', $untaggedonly = false) - { - if (!in_array($visibility, ['all', 'public', 'private'])) { - $visibility = 'all'; - } - - switch ($type) { - case self::$FILTER_HASH: - return $this->filterSmallHash($request); - case self::$FILTER_TAG | self::$FILTER_TEXT: // == "vuotext" - $noRequest = empty($request) || (empty($request[0]) && empty($request[1])); - if ($noRequest) { - if ($untaggedonly) { - return $this->filterUntagged($visibility); - } - return $this->noFilter($visibility); - } - if ($untaggedonly) { - $filtered = $this->filterUntagged($visibility); - } else { - $filtered = $this->links; - } - if (!empty($request[0])) { - $filtered = (new LinkFilter($filtered))->filterTags($request[0], $casesensitive, $visibility); - } - if (!empty($request[1])) { - $filtered = (new LinkFilter($filtered))->filterFulltext($request[1], $visibility); - } - return $filtered; - case self::$FILTER_TEXT: - return $this->filterFulltext($request, $visibility); - case self::$FILTER_TAG: - if ($untaggedonly) { - return $this->filterUntagged($visibility); - } else { - return $this->filterTags($request, $casesensitive, $visibility); - } - case self::$FILTER_DAY: - return $this->filterDay($request); - default: - return $this->noFilter($visibility); - } - } - - /** - * Unknown filter, but handle private only. - * - * @param string $visibility Optional: return only all/private/public links - * - * @return array filtered links. - */ - private function noFilter($visibility = 'all') - { - if ($visibility === 'all') { - return $this->links; - } - - $out = array(); - foreach ($this->links as $key => $value) { - if ($value['private'] && $visibility === 'private') { - $out[$key] = $value; - } elseif (!$value['private'] && $visibility === 'public') { - $out[$key] = $value; - } - } - - return $out; - } - - /** - * Returns the shaare corresponding to a smallHash. - * - * @param string $smallHash permalink hash. - * - * @return array $filtered array containing permalink data. - * - * @throws \Shaarli\Bookmark\Exception\LinkNotFoundException if the smallhash doesn't match any link. - */ - private function filterSmallHash($smallHash) - { - $filtered = array(); - foreach ($this->links as $key => $l) { - if ($smallHash == $l['shorturl']) { - // Yes, this is ugly and slow - $filtered[$key] = $l; - return $filtered; - } - } - - if (empty($filtered)) { - throw new LinkNotFoundException(); - } - - return $filtered; - } - - /** - * Returns the list of links corresponding to a full-text search - * - * Searches: - * - in the URLs, title and description; - * - are case-insensitive; - * - terms surrounded by quotes " are exact terms search. - * - terms starting with a dash - are excluded (except exact terms). - * - * Example: - * print_r($mydb->filterFulltext('hollandais')); - * - * mb_convert_case($val, MB_CASE_LOWER, 'UTF-8') - * - allows to perform searches on Unicode text - * - see https://github.com/shaarli/Shaarli/issues/75 for examples - * - * @param string $searchterms search query. - * @param string $visibility Optional: return only all/private/public links. - * - * @return array search results. - */ - private function filterFulltext($searchterms, $visibility = 'all') - { - if (empty($searchterms)) { - return $this->noFilter($visibility); - } - - $filtered = array(); - $search = mb_convert_case(html_entity_decode($searchterms), MB_CASE_LOWER, 'UTF-8'); - $exactRegex = '/"([^"]+)"/'; - // Retrieve exact search terms. - preg_match_all($exactRegex, $search, $exactSearch); - $exactSearch = array_values(array_filter($exactSearch[1])); - - // Remove exact search terms to get AND terms search. - $explodedSearchAnd = explode(' ', trim(preg_replace($exactRegex, '', $search))); - $explodedSearchAnd = array_values(array_filter($explodedSearchAnd)); - - // Filter excluding terms and update andSearch. - $excludeSearch = array(); - $andSearch = array(); - foreach ($explodedSearchAnd as $needle) { - if ($needle[0] == '-' && strlen($needle) > 1) { - $excludeSearch[] = substr($needle, 1); - } else { - $andSearch[] = $needle; - } - } - - $keys = array('title', 'description', 'url', 'tags'); - - // Iterate over every stored link. - foreach ($this->links as $id => $link) { - // ignore non private links when 'privatonly' is on. - if ($visibility !== 'all') { - if (!$link['private'] && $visibility === 'private') { - continue; - } elseif ($link['private'] && $visibility === 'public') { - continue; - } - } - - // Concatenate link fields to search across fields. - // Adds a '\' separator for exact search terms. - $content = ''; - foreach ($keys as $key) { - $content .= mb_convert_case($link[$key], MB_CASE_LOWER, 'UTF-8') . '\\'; - } - - // Be optimistic - $found = true; - - // First, we look for exact term search - for ($i = 0; $i < count($exactSearch) && $found; $i++) { - $found = strpos($content, $exactSearch[$i]) !== false; - } - - // Iterate over keywords, if keyword is not found, - // no need to check for the others. We want all or nothing. - for ($i = 0; $i < count($andSearch) && $found; $i++) { - $found = strpos($content, $andSearch[$i]) !== false; - } - - // Exclude terms. - for ($i = 0; $i < count($excludeSearch) && $found; $i++) { - $found = strpos($content, $excludeSearch[$i]) === false; - } - - if ($found) { - $filtered[$id] = $link; - } - } - - return $filtered; - } - - /** - * generate a regex fragment out of a tag - * - * @param string $tag to to generate regexs from. may start with '-' to negate, contain '*' as wildcard - * - * @return string generated regex fragment - */ - private static function tag2regex($tag) - { - $len = strlen($tag); - if (!$len || $tag === "-" || $tag === "*") { - // nothing to search, return empty regex - return ''; - } - if ($tag[0] === "-") { - // query is negated - $i = 1; // use offset to start after '-' character - $regex = '(?!'; // create negative lookahead - } else { - $i = 0; // start at first character - $regex = '(?='; // use positive lookahead - } - $regex .= '.*(?:^| )'; // before tag may only be a space or the beginning - // iterate over string, separating it into placeholder and content - for (; $i < $len; $i++) { - if ($tag[$i] === '*') { - // placeholder found - $regex .= '[^ ]*?'; - } else { - // regular characters - $offset = strpos($tag, '*', $i); - if ($offset === false) { - // no placeholder found, set offset to end of string - $offset = $len; - } - // subtract one, as we want to get before the placeholder or end of string - $offset -= 1; - // we got a tag name that we want to search for. escape any regex characters to prevent conflicts. - $regex .= preg_quote(substr($tag, $i, $offset - $i + 1), '/'); - // move $i on - $i = $offset; - } - } - $regex .= '(?:$| ))'; // after the tag may only be a space or the end - return $regex; - } - - /** - * Returns the list of links associated with a given list of tags - * - * You can specify one or more tags, separated by space or a comma, e.g. - * print_r($mydb->filterTags('linux programming')); - * - * @param string $tags list of tags separated by commas or blank spaces. - * @param bool $casesensitive ignore case if false. - * @param string $visibility Optional: return only all/private/public links. - * - * @return array filtered links. - */ - public function filterTags($tags, $casesensitive = false, $visibility = 'all') - { - // get single tags (we may get passed an array, even though the docs say different) - $inputTags = $tags; - if (!is_array($tags)) { - // we got an input string, split tags - $inputTags = preg_split('/(?:\s+)|,/', $inputTags, -1, PREG_SPLIT_NO_EMPTY); - } - - if (!count($inputTags)) { - // no input tags - return $this->noFilter($visibility); - } - - // build regex from all tags - $re = '/^' . implode(array_map("self::tag2regex", $inputTags)) . '.*$/'; - if (!$casesensitive) { - // make regex case insensitive - $re .= 'i'; - } - - // create resulting array - $filtered = array(); - - // iterate over each link - foreach ($this->links as $key => $link) { - // check level of visibility - // ignore non private links when 'privateonly' is on. - if ($visibility !== 'all') { - if (!$link['private'] && $visibility === 'private') { - continue; - } elseif ($link['private'] && $visibility === 'public') { - continue; - } - } - $search = $link['tags']; // build search string, start with tags of current link - if (strlen(trim($link['description'])) && strpos($link['description'], '#') !== false) { - // description given and at least one possible tag found - $descTags = array(); - // find all tags in the form of #tag in the description - preg_match_all( - '/(?links as $key => $link) { - if ($visibility !== 'all') { - if (!$link['private'] && $visibility === 'private') { - continue; - } elseif ($link['private'] && $visibility === 'public') { - continue; - } - } - - if (empty(trim($link['tags']))) { - $filtered[$key] = $link; - } - } - - return $filtered; - } - - /** - * Returns the list of articles for a given day, chronologically sorted - * - * Day must be in the form 'YYYYMMDD' (e.g. '20120125'), e.g. - * print_r($mydb->filterDay('20120125')); - * - * @param string $day day to filter. - * - * @return array all link matching given day. - * - * @throws Exception if date format is invalid. - */ - public function filterDay($day) - { - if (!checkDateFormat('Ymd', $day)) { - throw new Exception('Invalid date format'); - } - - $filtered = array(); - foreach ($this->links as $key => $l) { - if ($l['created']->format('Ymd') == $day) { - $filtered[$key] = $l; - } - } - - // sort by date ASC - return array_reverse($filtered, true); - } - - /** - * Convert a list of tags (str) to an array. Also - * - handle case sensitivity. - * - accepts spaces commas as separator. - * - * @param string $tags string containing a list of tags. - * @param bool $casesensitive will convert everything to lowercase if false. - * - * @return array filtered tags string. - */ - public static function tagsStrToArray($tags, $casesensitive) - { - // We use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek) - $tagsOut = $casesensitive ? $tags : mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8'); - $tagsOut = str_replace(',', ' ', $tagsOut); - - return preg_split('/\s+/', $tagsOut, -1, PREG_SPLIT_NO_EMPTY); - } -} diff --git a/application/bookmark/LinkUtils.php b/application/bookmark/LinkUtils.php index 77eb2d95..88379430 100644 --- a/application/bookmark/LinkUtils.php +++ b/application/bookmark/LinkUtils.php @@ -1,6 +1,6 @@ format(LinkDB::LINK_DATE_FORMAT) . $id); + return smallHash($date->format(Bookmark::LINK_DATE_FORMAT) . $id); } /** diff --git a/application/bookmark/exception/BookmarkNotFoundException.php b/application/bookmark/exception/BookmarkNotFoundException.php new file mode 100644 index 00000000..827a3d35 --- /dev/null +++ b/application/bookmark/exception/BookmarkNotFoundException.php @@ -0,0 +1,15 @@ +message = t('The link you are trying to reach does not exist or has been deleted.'); + } +} diff --git a/application/bookmark/exception/EmptyDataStoreException.php b/application/bookmark/exception/EmptyDataStoreException.php new file mode 100644 index 00000000..cd48c1e6 --- /dev/null +++ b/application/bookmark/exception/EmptyDataStoreException.php @@ -0,0 +1,7 @@ +getCreated() instanceof \DateTime) { + $created = $bookmark->getCreated()->format(\DateTime::ATOM); + } elseif (empty($bookmark->getCreated())) { + $created = ''; + } else { + $created = 'Not a DateTime object'; + } + $this->message = 'This bookmark is not valid'. PHP_EOL; + $this->message .= ' - ID: '. $bookmark->getId() . PHP_EOL; + $this->message .= ' - Title: '. $bookmark->getTitle() . PHP_EOL; + $this->message .= ' - Url: '. $bookmark->getUrl() . PHP_EOL; + $this->message .= ' - ShortUrl: '. $bookmark->getShortUrl() . PHP_EOL; + $this->message .= ' - Created: '. $created . PHP_EOL; + } else { + $this->message = 'The provided data is not a bookmark'. PHP_EOL; + $this->message .= var_export($bookmark, true); + } + } +} diff --git a/application/bookmark/exception/LinkNotFoundException.php b/application/bookmark/exception/LinkNotFoundException.php deleted file mode 100644 index f9414428..00000000 --- a/application/bookmark/exception/LinkNotFoundException.php +++ /dev/null @@ -1,15 +0,0 @@ -message = t('The link you are trying to reach does not exist or has been deleted.'); - } -} diff --git a/application/bookmark/exception/NotWritableDataStoreException.php b/application/bookmark/exception/NotWritableDataStoreException.php new file mode 100644 index 00000000..95f34b50 --- /dev/null +++ b/application/bookmark/exception/NotWritableDataStoreException.php @@ -0,0 +1,19 @@ +message = 'Couldn\'t load data from the data store file "'. $dataStore .'". '. + 'Your data might be corrupted, or your file isn\'t readable.'; + } +} diff --git a/application/formatter/BookmarkDefaultFormatter.php b/application/formatter/BookmarkDefaultFormatter.php new file mode 100644 index 00000000..7550c556 --- /dev/null +++ b/application/formatter/BookmarkDefaultFormatter.php @@ -0,0 +1,81 @@ +getTitle()); + } + + /** + * @inheritdoc + */ + public function formatDescription($bookmark) + { + $indexUrl = ! empty($this->contextData['index_url']) ? $this->contextData['index_url'] : ''; + return format_description(escape($bookmark->getDescription()), $indexUrl); + } + + /** + * @inheritdoc + */ + protected function formatTagList($bookmark) + { + return escape($bookmark->getTags()); + } + + /** + * @inheritdoc + */ + public function formatTagString($bookmark) + { + return implode(' ', $this->formatTagList($bookmark)); + } + + /** + * @inheritdoc + */ + public function formatUrl($bookmark) + { + if (! empty($this->contextData['index_url']) && ( + startsWith($bookmark->getUrl(), '?') || startsWith($bookmark->getUrl(), '/') + )) { + return $this->contextData['index_url'] . escape($bookmark->getUrl()); + } + return escape($bookmark->getUrl()); + } + + /** + * @inheritdoc + */ + protected function formatRealUrl($bookmark) + { + if (! empty($this->contextData['index_url']) && ( + startsWith($bookmark->getUrl(), '?') || startsWith($bookmark->getUrl(), '/') + )) { + return $this->contextData['index_url'] . escape($bookmark->getUrl()); + } + return escape($bookmark->getUrl()); + } + + /** + * @inheritdoc + */ + protected function formatThumbnail($bookmark) + { + return escape($bookmark->getThumbnail()); + } +} diff --git a/application/formatter/BookmarkFormatter.php b/application/formatter/BookmarkFormatter.php new file mode 100644 index 00000000..c82c3452 --- /dev/null +++ b/application/formatter/BookmarkFormatter.php @@ -0,0 +1,256 @@ +conf = $conf; + } + + /** + * Convert a Bookmark into an array usable by templates and plugins. + * + * All Bookmark attributes are formatted through a format method + * that can be overridden in a formatter extending this class. + * + * @param Bookmark $bookmark instance + * + * @return array formatted representation of a Bookmark + */ + public function format($bookmark) + { + $out['id'] = $this->formatId($bookmark); + $out['shorturl'] = $this->formatShortUrl($bookmark); + $out['url'] = $this->formatUrl($bookmark); + $out['real_url'] = $this->formatRealUrl($bookmark); + $out['title'] = $this->formatTitle($bookmark); + $out['description'] = $this->formatDescription($bookmark); + $out['thumbnail'] = $this->formatThumbnail($bookmark); + $out['taglist'] = $this->formatTagList($bookmark); + $out['tags'] = $this->formatTagString($bookmark); + $out['sticky'] = $bookmark->isSticky(); + $out['private'] = $bookmark->isPrivate(); + $out['class'] = $this->formatClass($bookmark); + $out['created'] = $this->formatCreated($bookmark); + $out['updated'] = $this->formatUpdated($bookmark); + $out['timestamp'] = $this->formatCreatedTimestamp($bookmark); + $out['updated_timestamp'] = $this->formatUpdatedTimestamp($bookmark); + return $out; + } + + /** + * Add additional data available to formatters. + * This is used for example to add `index_url` in description's links. + * + * @param string $key Context data key + * @param string $value Context data value + */ + public function addContextData($key, $value) + { + $this->contextData[$key] = $value; + } + + /** + * Format ID + * + * @param Bookmark $bookmark instance + * + * @return int formatted ID + */ + protected function formatId($bookmark) + { + return $bookmark->getId(); + } + + /** + * Format ShortUrl + * + * @param Bookmark $bookmark instance + * + * @return string formatted ShortUrl + */ + protected function formatShortUrl($bookmark) + { + return $bookmark->getShortUrl(); + } + + /** + * Format Url + * + * @param Bookmark $bookmark instance + * + * @return string formatted Url + */ + protected function formatUrl($bookmark) + { + return $bookmark->getUrl(); + } + + /** + * Format RealUrl + * Legacy: identical to Url + * + * @param Bookmark $bookmark instance + * + * @return string formatted RealUrl + */ + protected function formatRealUrl($bookmark) + { + return $bookmark->getUrl(); + } + + /** + * Format Title + * + * @param Bookmark $bookmark instance + * + * @return string formatted Title + */ + protected function formatTitle($bookmark) + { + return $bookmark->getTitle(); + } + + /** + * Format Description + * + * @param Bookmark $bookmark instance + * + * @return string formatted Description + */ + protected function formatDescription($bookmark) + { + return $bookmark->getDescription(); + } + + /** + * Format Thumbnail + * + * @param Bookmark $bookmark instance + * + * @return string formatted Thumbnail + */ + protected function formatThumbnail($bookmark) + { + return $bookmark->getThumbnail(); + } + + /** + * Format Tags + * + * @param Bookmark $bookmark instance + * + * @return array formatted Tags + */ + protected function formatTagList($bookmark) + { + return $bookmark->getTags(); + } + + /** + * Format TagString + * + * @param Bookmark $bookmark instance + * + * @return string formatted TagString + */ + protected function formatTagString($bookmark) + { + return implode(' ', $bookmark->getTags()); + } + + /** + * Format Class + * Used to add specific CSS class for a link + * + * @param Bookmark $bookmark instance + * + * @return string formatted Class + */ + protected function formatClass($bookmark) + { + return $bookmark->isPrivate() ? 'private' : ''; + } + + /** + * Format Created + * + * @param Bookmark $bookmark instance + * + * @return DateTime instance + */ + protected function formatCreated(Bookmark $bookmark) + { + return $bookmark->getCreated(); + } + + /** + * Format Updated + * + * @param Bookmark $bookmark instance + * + * @return DateTime instance + */ + protected function formatUpdated(Bookmark $bookmark) + { + return $bookmark->getUpdated(); + } + + /** + * Format CreatedTimestamp + * + * @param Bookmark $bookmark instance + * + * @return int formatted CreatedTimestamp + */ + protected function formatCreatedTimestamp(Bookmark $bookmark) + { + if (! empty($bookmark->getCreated())) { + return $bookmark->getCreated()->getTimestamp(); + } + return 0; + } + + /** + * Format UpdatedTimestamp + * + * @param Bookmark $bookmark instance + * + * @return int formatted UpdatedTimestamp + */ + protected function formatUpdatedTimestamp(Bookmark $bookmark) + { + if (! empty($bookmark->getUpdated())) { + return $bookmark->getUpdated()->getTimestamp(); + } + return 0; + } +} diff --git a/application/formatter/BookmarkMarkdownFormatter.php b/application/formatter/BookmarkMarkdownFormatter.php new file mode 100644 index 00000000..f60c61f4 --- /dev/null +++ b/application/formatter/BookmarkMarkdownFormatter.php @@ -0,0 +1,198 @@ +parsedown = new \Parsedown(); + $this->escape = $conf->get('security.markdown_escape', true); + $this->allowedProtocols = $conf->get('security.allowed_protocols', []); + } + + /** + * @inheritdoc + */ + public function formatDescription($bookmark) + { + if (in_array(self::NO_MD_TAG, $bookmark->getTags())) { + return parent::formatDescription($bookmark); + } + + $processedDescription = $bookmark->getDescription(); + $processedDescription = $this->filterProtocols($processedDescription); + $processedDescription = $this->formatHashTags($processedDescription); + $processedDescription = $this->parsedown + ->setMarkupEscaped($this->escape) + ->setBreaksEnabled(true) + ->text($processedDescription); + $processedDescription = $this->sanitizeHtml($processedDescription); + + if (!empty($processedDescription)) { + $processedDescription = '
'. $processedDescription . '
'; + } + + return $processedDescription; + } + + /** + * Remove the NO markdown tag if it is present + * + * @inheritdoc + */ + protected function formatTagList($bookmark) + { + $out = parent::formatTagList($bookmark); + if (($pos = array_search(self::NO_MD_TAG, $out)) !== false) { + unset($out[$pos]); + return array_values($out); + } + return $out; + } + + /** + * Replace not whitelisted protocols with http:// in given description. + * Also adds `index_url` to relative links if it's specified + * + * @param string $description input description text. + * + * @return string $description without malicious link. + */ + protected function filterProtocols($description) + { + $allowedProtocols = $this->allowedProtocols; + $indexUrl = ! empty($this->contextData['index_url']) ? $this->contextData['index_url'] : ''; + + return preg_replace_callback( + '#]\((.*?)\)#is', + function ($match) use ($allowedProtocols, $indexUrl) { + $link = startsWith($match[1], '?') || startsWith($match[1], '/') ? $indexUrl : ''; + $link .= whitelist_protocols($match[1], $allowedProtocols); + return ']('. $link.')'; + }, + $description + ); + } + + /** + * Replace hashtag in Markdown links format + * E.g. `#hashtag` becomes `[#hashtag](?addtag=hashtag)` + * It includes the index URL if specified. + * + * @param string $description + * + * @return string + */ + protected function formatHashTags($description) + { + $indexUrl = ! empty($this->contextData['index_url']) ? $this->contextData['index_url'] : ''; + + /* + * To support unicode: http://stackoverflow.com/a/35498078/1484919 + * \p{Pc} - to match underscore + * \p{N} - numeric character in any script + * \p{L} - letter from any language + * \p{Mn} - any non marking space (accents, umlauts, etc) + */ + $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui'; + $replacement = '$1[#$2]('. $indexUrl .'?addtag=$2)'; + + $descriptionLines = explode(PHP_EOL, $description); + $descriptionOut = ''; + $codeBlockOn = false; + $lineCount = 0; + + foreach ($descriptionLines as $descriptionLine) { + // Detect line of code: starting with 4 spaces, + // except lists which can start with +/*/- or `2.` after spaces. + $codeLineOn = preg_match('/^ +(?=[^\+\*\-])(?=(?!\d\.).)/', $descriptionLine) > 0; + // Detect and toggle block of code + if (!$codeBlockOn) { + $codeBlockOn = preg_match('/^```/', $descriptionLine) > 0; + } elseif (preg_match('/^```/', $descriptionLine) > 0) { + $codeBlockOn = false; + } + + if (!$codeBlockOn && !$codeLineOn) { + $descriptionLine = preg_replace($regex, $replacement, $descriptionLine); + } + + $descriptionOut .= $descriptionLine; + if ($lineCount++ < count($descriptionLines) - 1) { + $descriptionOut .= PHP_EOL; + } + } + + return $descriptionOut; + } + + /** + * Remove dangerous HTML tags (tags, iframe, etc.). + * Doesn't affect content (already escaped by Parsedown). + * + * @param string $description input description text. + * + * @return string given string escaped. + */ + protected function sanitizeHtml($description) + { + $escapeTags = array( + 'script', + 'style', + 'link', + 'iframe', + 'frameset', + 'frame', + ); + foreach ($escapeTags as $tag) { + $description = preg_replace_callback( + '#<\s*'. $tag .'[^>]*>(.*]*>)?#is', + function ($match) { + return escape($match[0]); + }, + $description + ); + } + $description = preg_replace( + '#(<[^>]+\s)on[a-z]*="?[^ "]*"?#is', + '$1', + $description + ); + return $description; + } +} diff --git a/application/formatter/BookmarkRawFormatter.php b/application/formatter/BookmarkRawFormatter.php new file mode 100644 index 00000000..bc372273 --- /dev/null +++ b/application/formatter/BookmarkRawFormatter.php @@ -0,0 +1,13 @@ +conf = $conf; + } + + /** + * Instanciate a BookmarkFormatter depending on the configuration or provided formatter type. + * + * @param string|null $type force a specific type regardless of the configuration + * + * @return BookmarkFormatter instance. + */ + public function getFormatter($type = null) + { + $type = $type ? $type : $this->conf->get('formatter', 'default'); + $className = '\\Shaarli\\Formatter\\Bookmark'. ucfirst($type) .'Formatter'; + if (!class_exists($className)) { + $className = '\\Shaarli\\Formatter\\BookmarkDefaultFormatter'; + } + + return new $className($this->conf); + } +} diff --git a/application/legacy/LegacyLinkDB.php b/application/legacy/LegacyLinkDB.php new file mode 100644 index 00000000..7ccf5e54 --- /dev/null +++ b/application/legacy/LegacyLinkDB.php @@ -0,0 +1,580 @@ +link offset) + private $urls; + + /** + * @var array List of all bookmarks IDS mapped with their array offset. + * Map: id->offset. + */ + protected $ids; + + // List of offset keys (for the Iterator interface implementation) + private $keys; + + // Position in the $this->keys array (for the Iterator interface) + private $position; + + // Is the user logged in? (used to filter private bookmarks) + private $loggedIn; + + // Hide public bookmarks + private $hidePublicLinks; + + /** + * Creates a new LinkDB + * + * Checks if the datastore exists; else, attempts to create a dummy one. + * + * @param string $datastore datastore file path. + * @param boolean $isLoggedIn is the user logged in? + * @param boolean $hidePublicLinks if true all bookmarks are private. + */ + public function __construct( + $datastore, + $isLoggedIn, + $hidePublicLinks + ) { + + $this->datastore = $datastore; + $this->loggedIn = $isLoggedIn; + $this->hidePublicLinks = $hidePublicLinks; + $this->check(); + $this->read(); + } + + /** + * Countable - Counts elements of an object + */ + public function count() + { + return count($this->links); + } + + /** + * ArrayAccess - Assigns a value to the specified offset + */ + public function offsetSet($offset, $value) + { + // TODO: use exceptions instead of "die" + if (!$this->loggedIn) { + die(t('You are not authorized to add a link.')); + } + if (!isset($value['id']) || empty($value['url'])) { + die(t('Internal Error: A link should always have an id and URL.')); + } + if (($offset !== null && !is_int($offset)) || !is_int($value['id'])) { + die(t('You must specify an integer as a key.')); + } + if ($offset !== null && $offset !== $value['id']) { + die(t('Array offset and link ID must be equal.')); + } + + // If the link exists, we reuse the real offset, otherwise new entry + $existing = $this->getLinkOffset($offset); + if ($existing !== null) { + $offset = $existing; + } else { + $offset = count($this->links); + } + $this->links[$offset] = $value; + $this->urls[$value['url']] = $offset; + $this->ids[$value['id']] = $offset; + } + + /** + * ArrayAccess - Whether or not an offset exists + */ + public function offsetExists($offset) + { + return array_key_exists($this->getLinkOffset($offset), $this->links); + } + + /** + * ArrayAccess - Unsets an offset + */ + public function offsetUnset($offset) + { + if (!$this->loggedIn) { + // TODO: raise an exception + die('You are not authorized to delete a link.'); + } + $realOffset = $this->getLinkOffset($offset); + $url = $this->links[$realOffset]['url']; + unset($this->urls[$url]); + unset($this->ids[$realOffset]); + unset($this->links[$realOffset]); + } + + /** + * ArrayAccess - Returns the value at specified offset + */ + public function offsetGet($offset) + { + $realOffset = $this->getLinkOffset($offset); + return isset($this->links[$realOffset]) ? $this->links[$realOffset] : null; + } + + /** + * Iterator - Returns the current element + */ + public function current() + { + return $this[$this->keys[$this->position]]; + } + + /** + * Iterator - Returns the key of the current element + */ + public function key() + { + return $this->keys[$this->position]; + } + + /** + * Iterator - Moves forward to next element + */ + public function next() + { + ++$this->position; + } + + /** + * Iterator - Rewinds the Iterator to the first element + * + * Entries are sorted by date (latest first) + */ + public function rewind() + { + $this->keys = array_keys($this->ids); + $this->position = 0; + } + + /** + * Iterator - Checks if current position is valid + */ + public function valid() + { + return isset($this->keys[$this->position]); + } + + /** + * Checks if the DB directory and file exist + * + * If no DB file is found, creates a dummy DB. + */ + private function check() + { + if (file_exists($this->datastore)) { + return; + } + + // Create a dummy database for example + $this->links = array(); + $link = array( + 'id' => 1, + 'title' => t('The personal, minimalist, super-fast, database free, bookmarking service'), + 'url' => 'https://shaarli.readthedocs.io', + 'description' => t( + 'Welcome to Shaarli! This is your first public bookmark. ' + . 'To edit or delete me, you must first login. + +To learn how to use Shaarli, consult the link "Documentation" at the bottom of this page. + +You use the community supported version of the original Shaarli project, by Sebastien Sauvage.' + ), + 'private' => 0, + 'created' => new DateTime(), + 'tags' => 'opensource software', + 'sticky' => false, + ); + $link['shorturl'] = link_small_hash($link['created'], $link['id']); + $this->links[1] = $link; + + $link = array( + 'id' => 0, + 'title' => t('My secret stuff... - Pastebin.com'), + 'url' => 'http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=', + 'description' => t('Shhhh! I\'m a private link only YOU can see. You can delete me too.'), + 'private' => 1, + 'created' => new DateTime('1 minute ago'), + 'tags' => 'secretstuff', + 'sticky' => false, + ); + $link['shorturl'] = link_small_hash($link['created'], $link['id']); + $this->links[0] = $link; + + // Write database to disk + $this->write(); + } + + /** + * Reads database from disk to memory + */ + private function read() + { + // Public bookmarks are hidden and user not logged in => nothing to show + if ($this->hidePublicLinks && !$this->loggedIn) { + $this->links = array(); + return; + } + + $this->urls = []; + $this->ids = []; + $this->links = FileUtils::readFlatDB($this->datastore, []); + + $toremove = array(); + foreach ($this->links as $key => &$link) { + if (!$this->loggedIn && $link['private'] != 0) { + // Transition for not upgraded databases. + unset($this->links[$key]); + continue; + } + + // Sanitize data fields. + sanitizeLink($link); + + // Remove private tags if the user is not logged in. + if (!$this->loggedIn) { + $link['tags'] = preg_replace('/(^|\s+)\.[^($|\s)]+\s*/', ' ', $link['tags']); + } + + $link['real_url'] = $link['url']; + + $link['sticky'] = isset($link['sticky']) ? $link['sticky'] : false; + + // To be able to load bookmarks before running the update, and prepare the update + if (!isset($link['created'])) { + $link['id'] = $link['linkdate']; + $link['created'] = DateTime::createFromFormat(self::LINK_DATE_FORMAT, $link['linkdate']); + if (!empty($link['updated'])) { + $link['updated'] = DateTime::createFromFormat(self::LINK_DATE_FORMAT, $link['updated']); + } + $link['shorturl'] = smallHash($link['linkdate']); + } + + $this->urls[$link['url']] = $key; + $this->ids[$link['id']] = $key; + } + } + + /** + * Saves the database from memory to disk + * + * @throws IOException the datastore is not writable + */ + private function write() + { + $this->reorder(); + FileUtils::writeFlatDB($this->datastore, $this->links); + } + + /** + * Saves the database from memory to disk + * + * @param string $pageCacheDir page cache directory + */ + public function save($pageCacheDir) + { + if (!$this->loggedIn) { + // TODO: raise an Exception instead + die('You are not authorized to change the database.'); + } + + $this->write(); + + invalidateCaches($pageCacheDir); + } + + /** + * Returns the link for a given URL, or False if it does not exist. + * + * @param string $url URL to search for + * + * @return mixed the existing link if it exists, else 'false' + */ + public function getLinkFromUrl($url) + { + if (isset($this->urls[$url])) { + return $this->links[$this->urls[$url]]; + } + return false; + } + + /** + * Returns the shaare corresponding to a smallHash. + * + * @param string $request QUERY_STRING server parameter. + * + * @return array $filtered array containing permalink data. + * + * @throws BookmarkNotFoundException if the smallhash is malformed or doesn't match any link. + */ + public function filterHash($request) + { + $request = substr($request, 0, 6); + $linkFilter = new LegacyLinkFilter($this->links); + return $linkFilter->filter(LegacyLinkFilter::$FILTER_HASH, $request); + } + + /** + * Returns the list of articles for a given day. + * + * @param string $request day to filter. Format: YYYYMMDD. + * + * @return array list of shaare found. + */ + public function filterDay($request) + { + $linkFilter = new LegacyLinkFilter($this->links); + return $linkFilter->filter(LegacyLinkFilter::$FILTER_DAY, $request); + } + + /** + * Filter bookmarks according to search parameters. + * + * @param array $filterRequest Search request content. Supported keys: + * - searchtags: list of tags + * - searchterm: term search + * @param bool $casesensitive Optional: Perform case sensitive filter + * @param string $visibility return only all/private/public bookmarks + * @param bool $untaggedonly return only untagged bookmarks + * + * @return array filtered bookmarks, all bookmarks if no suitable filter was provided. + */ + public function filterSearch( + $filterRequest = array(), + $casesensitive = false, + $visibility = 'all', + $untaggedonly = false + ) { + + // Filter link database according to parameters. + $searchtags = isset($filterRequest['searchtags']) ? escape($filterRequest['searchtags']) : ''; + $searchterm = isset($filterRequest['searchterm']) ? escape($filterRequest['searchterm']) : ''; + + // Search tags + fullsearch - blank string parameter will return all bookmarks. + $type = LegacyLinkFilter::$FILTER_TAG | LegacyLinkFilter::$FILTER_TEXT; // == "vuotext" + $request = [$searchtags, $searchterm]; + + $linkFilter = new LegacyLinkFilter($this); + return $linkFilter->filter($type, $request, $casesensitive, $visibility, $untaggedonly); + } + + /** + * Returns the list tags appearing in the bookmarks with the given tags + * + * @param array $filteringTags tags selecting the bookmarks to consider + * @param string $visibility process only all/private/public bookmarks + * + * @return array tag => linksCount + */ + public function linksCountPerTag($filteringTags = [], $visibility = 'all') + { + $links = $this->filterSearch(['searchtags' => $filteringTags], false, $visibility); + $tags = []; + $caseMapping = []; + foreach ($links as $link) { + foreach (preg_split('/\s+/', $link['tags'], 0, PREG_SPLIT_NO_EMPTY) as $tag) { + if (empty($tag)) { + continue; + } + // The first case found will be displayed. + if (!isset($caseMapping[strtolower($tag)])) { + $caseMapping[strtolower($tag)] = $tag; + $tags[$caseMapping[strtolower($tag)]] = 0; + } + $tags[$caseMapping[strtolower($tag)]]++; + } + } + + /* + * Formerly used arsort(), which doesn't define the sort behaviour for equal values. + * Also, this function doesn't produce the same result between PHP 5.6 and 7. + * + * So we now use array_multisort() to sort tags by DESC occurrences, + * then ASC alphabetically for equal values. + * + * @see https://github.com/shaarli/Shaarli/issues/1142 + */ + $keys = array_keys($tags); + $tmpTags = array_combine($keys, $keys); + array_multisort($tags, SORT_DESC, $tmpTags, SORT_ASC, $tags); + return $tags; + } + + /** + * Rename or delete a tag across all bookmarks. + * + * @param string $from Tag to rename + * @param string $to New tag. If none is provided, the from tag will be deleted + * + * @return array|bool List of altered bookmarks or false on error + */ + public function renameTag($from, $to) + { + if (empty($from)) { + return false; + } + $delete = empty($to); + // True for case-sensitive tag search. + $linksToAlter = $this->filterSearch(['searchtags' => $from], true); + foreach ($linksToAlter as $key => &$value) { + $tags = preg_split('/\s+/', trim($value['tags'])); + if (($pos = array_search($from, $tags)) !== false) { + if ($delete) { + unset($tags[$pos]); // Remove tag. + } else { + $tags[$pos] = trim($to); + } + $value['tags'] = trim(implode(' ', array_unique($tags))); + $this[$value['id']] = $value; + } + } + + return $linksToAlter; + } + + /** + * Returns the list of days containing articles (oldest first) + * Output: An array containing days (in format YYYYMMDD). + */ + public function days() + { + $linkDays = array(); + foreach ($this->links as $link) { + $linkDays[$link['created']->format('Ymd')] = 0; + } + $linkDays = array_keys($linkDays); + sort($linkDays); + + return $linkDays; + } + + /** + * Reorder bookmarks by creation date (newest first). + * + * Also update the urls and ids mapping arrays. + * + * @param string $order ASC|DESC + */ + public function reorder($order = 'DESC') + { + $order = $order === 'ASC' ? -1 : 1; + // Reorder array by dates. + usort($this->links, function ($a, $b) use ($order) { + if (isset($a['sticky']) && isset($b['sticky']) && $a['sticky'] !== $b['sticky']) { + return $a['sticky'] ? -1 : 1; + } + if ($a['created'] == $b['created']) { + return $a['id'] < $b['id'] ? 1 * $order : -1 * $order; + } + return $a['created'] < $b['created'] ? 1 * $order : -1 * $order; + }); + + $this->urls = []; + $this->ids = []; + foreach ($this->links as $key => $link) { + $this->urls[$link['url']] = $key; + $this->ids[$link['id']] = $key; + } + } + + /** + * Return the next key for link creation. + * E.g. If the last ID is 597, the next will be 598. + * + * @return int next ID. + */ + public function getNextId() + { + if (!empty($this->ids)) { + return max(array_keys($this->ids)) + 1; + } + return 0; + } + + /** + * Returns a link offset in bookmarks array from its unique ID. + * + * @param int $id Persistent ID of a link. + * + * @return int Real offset in local array, or null if doesn't exist. + */ + protected function getLinkOffset($id) + { + if (isset($this->ids[$id])) { + return $this->ids[$id]; + } + return null; + } +} diff --git a/application/legacy/LegacyLinkFilter.php b/application/legacy/LegacyLinkFilter.php new file mode 100644 index 00000000..7cf93d60 --- /dev/null +++ b/application/legacy/LegacyLinkFilter.php @@ -0,0 +1,451 @@ +links = $links; + } + + /** + * Filter links according to parameters. + * + * @param string $type Type of filter (eg. tags, permalink, etc.). + * @param mixed $request Filter content. + * @param bool $casesensitive Optional: Perform case sensitive filter if true. + * @param string $visibility Optional: return only all/private/public links + * @param string $untaggedonly Optional: return only untagged links. Applies only if $type includes FILTER_TAG + * + * @return array filtered link list. + */ + public function filter($type, $request, $casesensitive = false, $visibility = 'all', $untaggedonly = false) + { + if (!in_array($visibility, ['all', 'public', 'private'])) { + $visibility = 'all'; + } + + switch ($type) { + case self::$FILTER_HASH: + return $this->filterSmallHash($request); + case self::$FILTER_TAG | self::$FILTER_TEXT: // == "vuotext" + $noRequest = empty($request) || (empty($request[0]) && empty($request[1])); + if ($noRequest) { + if ($untaggedonly) { + return $this->filterUntagged($visibility); + } + return $this->noFilter($visibility); + } + if ($untaggedonly) { + $filtered = $this->filterUntagged($visibility); + } else { + $filtered = $this->links; + } + if (!empty($request[0])) { + $filtered = (new LegacyLinkFilter($filtered))->filterTags($request[0], $casesensitive, $visibility); + } + if (!empty($request[1])) { + $filtered = (new LegacyLinkFilter($filtered))->filterFulltext($request[1], $visibility); + } + return $filtered; + case self::$FILTER_TEXT: + return $this->filterFulltext($request, $visibility); + case self::$FILTER_TAG: + if ($untaggedonly) { + return $this->filterUntagged($visibility); + } else { + return $this->filterTags($request, $casesensitive, $visibility); + } + case self::$FILTER_DAY: + return $this->filterDay($request); + default: + return $this->noFilter($visibility); + } + } + + /** + * Unknown filter, but handle private only. + * + * @param string $visibility Optional: return only all/private/public links + * + * @return array filtered links. + */ + private function noFilter($visibility = 'all') + { + if ($visibility === 'all') { + return $this->links; + } + + $out = array(); + foreach ($this->links as $key => $value) { + if ($value['private'] && $visibility === 'private') { + $out[$key] = $value; + } elseif (!$value['private'] && $visibility === 'public') { + $out[$key] = $value; + } + } + + return $out; + } + + /** + * Returns the shaare corresponding to a smallHash. + * + * @param string $smallHash permalink hash. + * + * @return array $filtered array containing permalink data. + * + * @throws BookmarkNotFoundException if the smallhash doesn't match any link. + */ + private function filterSmallHash($smallHash) + { + $filtered = array(); + foreach ($this->links as $key => $l) { + if ($smallHash == $l['shorturl']) { + // Yes, this is ugly and slow + $filtered[$key] = $l; + return $filtered; + } + } + + if (empty($filtered)) { + throw new BookmarkNotFoundException(); + } + + return $filtered; + } + + /** + * Returns the list of links corresponding to a full-text search + * + * Searches: + * - in the URLs, title and description; + * - are case-insensitive; + * - terms surrounded by quotes " are exact terms search. + * - terms starting with a dash - are excluded (except exact terms). + * + * Example: + * print_r($mydb->filterFulltext('hollandais')); + * + * mb_convert_case($val, MB_CASE_LOWER, 'UTF-8') + * - allows to perform searches on Unicode text + * - see https://github.com/shaarli/Shaarli/issues/75 for examples + * + * @param string $searchterms search query. + * @param string $visibility Optional: return only all/private/public links. + * + * @return array search results. + */ + private function filterFulltext($searchterms, $visibility = 'all') + { + if (empty($searchterms)) { + return $this->noFilter($visibility); + } + + $filtered = array(); + $search = mb_convert_case(html_entity_decode($searchterms), MB_CASE_LOWER, 'UTF-8'); + $exactRegex = '/"([^"]+)"/'; + // Retrieve exact search terms. + preg_match_all($exactRegex, $search, $exactSearch); + $exactSearch = array_values(array_filter($exactSearch[1])); + + // Remove exact search terms to get AND terms search. + $explodedSearchAnd = explode(' ', trim(preg_replace($exactRegex, '', $search))); + $explodedSearchAnd = array_values(array_filter($explodedSearchAnd)); + + // Filter excluding terms and update andSearch. + $excludeSearch = array(); + $andSearch = array(); + foreach ($explodedSearchAnd as $needle) { + if ($needle[0] == '-' && strlen($needle) > 1) { + $excludeSearch[] = substr($needle, 1); + } else { + $andSearch[] = $needle; + } + } + + $keys = array('title', 'description', 'url', 'tags'); + + // Iterate over every stored link. + foreach ($this->links as $id => $link) { + // ignore non private links when 'privatonly' is on. + if ($visibility !== 'all') { + if (!$link['private'] && $visibility === 'private') { + continue; + } elseif ($link['private'] && $visibility === 'public') { + continue; + } + } + + // Concatenate link fields to search across fields. + // Adds a '\' separator for exact search terms. + $content = ''; + foreach ($keys as $key) { + $content .= mb_convert_case($link[$key], MB_CASE_LOWER, 'UTF-8') . '\\'; + } + + // Be optimistic + $found = true; + + // First, we look for exact term search + for ($i = 0; $i < count($exactSearch) && $found; $i++) { + $found = strpos($content, $exactSearch[$i]) !== false; + } + + // Iterate over keywords, if keyword is not found, + // no need to check for the others. We want all or nothing. + for ($i = 0; $i < count($andSearch) && $found; $i++) { + $found = strpos($content, $andSearch[$i]) !== false; + } + + // Exclude terms. + for ($i = 0; $i < count($excludeSearch) && $found; $i++) { + $found = strpos($content, $excludeSearch[$i]) === false; + } + + if ($found) { + $filtered[$id] = $link; + } + } + + return $filtered; + } + + /** + * generate a regex fragment out of a tag + * + * @param string $tag to to generate regexs from. may start with '-' to negate, contain '*' as wildcard + * + * @return string generated regex fragment + */ + private static function tag2regex($tag) + { + $len = strlen($tag); + if (!$len || $tag === "-" || $tag === "*") { + // nothing to search, return empty regex + return ''; + } + if ($tag[0] === "-") { + // query is negated + $i = 1; // use offset to start after '-' character + $regex = '(?!'; // create negative lookahead + } else { + $i = 0; // start at first character + $regex = '(?='; // use positive lookahead + } + $regex .= '.*(?:^| )'; // before tag may only be a space or the beginning + // iterate over string, separating it into placeholder and content + for (; $i < $len; $i++) { + if ($tag[$i] === '*') { + // placeholder found + $regex .= '[^ ]*?'; + } else { + // regular characters + $offset = strpos($tag, '*', $i); + if ($offset === false) { + // no placeholder found, set offset to end of string + $offset = $len; + } + // subtract one, as we want to get before the placeholder or end of string + $offset -= 1; + // we got a tag name that we want to search for. escape any regex characters to prevent conflicts. + $regex .= preg_quote(substr($tag, $i, $offset - $i + 1), '/'); + // move $i on + $i = $offset; + } + } + $regex .= '(?:$| ))'; // after the tag may only be a space or the end + return $regex; + } + + /** + * Returns the list of links associated with a given list of tags + * + * You can specify one or more tags, separated by space or a comma, e.g. + * print_r($mydb->filterTags('linux programming')); + * + * @param string $tags list of tags separated by commas or blank spaces. + * @param bool $casesensitive ignore case if false. + * @param string $visibility Optional: return only all/private/public links. + * + * @return array filtered links. + */ + public function filterTags($tags, $casesensitive = false, $visibility = 'all') + { + // get single tags (we may get passed an array, even though the docs say different) + $inputTags = $tags; + if (!is_array($tags)) { + // we got an input string, split tags + $inputTags = preg_split('/(?:\s+)|,/', $inputTags, -1, PREG_SPLIT_NO_EMPTY); + } + + if (!count($inputTags)) { + // no input tags + return $this->noFilter($visibility); + } + + // build regex from all tags + $re = '/^' . implode(array_map("self::tag2regex", $inputTags)) . '.*$/'; + if (!$casesensitive) { + // make regex case insensitive + $re .= 'i'; + } + + // create resulting array + $filtered = array(); + + // iterate over each link + foreach ($this->links as $key => $link) { + // check level of visibility + // ignore non private links when 'privateonly' is on. + if ($visibility !== 'all') { + if (!$link['private'] && $visibility === 'private') { + continue; + } elseif ($link['private'] && $visibility === 'public') { + continue; + } + } + $search = $link['tags']; // build search string, start with tags of current link + if (strlen(trim($link['description'])) && strpos($link['description'], '#') !== false) { + // description given and at least one possible tag found + $descTags = array(); + // find all tags in the form of #tag in the description + preg_match_all( + '/(?links as $key => $link) { + if ($visibility !== 'all') { + if (!$link['private'] && $visibility === 'private') { + continue; + } elseif ($link['private'] && $visibility === 'public') { + continue; + } + } + + if (empty(trim($link['tags']))) { + $filtered[$key] = $link; + } + } + + return $filtered; + } + + /** + * Returns the list of articles for a given day, chronologically sorted + * + * Day must be in the form 'YYYYMMDD' (e.g. '20120125'), e.g. + * print_r($mydb->filterDay('20120125')); + * + * @param string $day day to filter. + * + * @return array all link matching given day. + * + * @throws Exception if date format is invalid. + */ + public function filterDay($day) + { + if (!checkDateFormat('Ymd', $day)) { + throw new Exception('Invalid date format'); + } + + $filtered = array(); + foreach ($this->links as $key => $l) { + if ($l['created']->format('Ymd') == $day) { + $filtered[$key] = $l; + } + } + + // sort by date ASC + return array_reverse($filtered, true); + } + + /** + * Convert a list of tags (str) to an array. Also + * - handle case sensitivity. + * - accepts spaces commas as separator. + * + * @param string $tags string containing a list of tags. + * @param bool $casesensitive will convert everything to lowercase if false. + * + * @return array filtered tags string. + */ + public static function tagsStrToArray($tags, $casesensitive) + { + // We use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek) + $tagsOut = $casesensitive ? $tags : mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8'); + $tagsOut = str_replace(',', ' ', $tagsOut); + + return preg_split('/\s+/', $tagsOut, -1, PREG_SPLIT_NO_EMPTY); + } +} diff --git a/application/legacy/LegacyUpdater.php b/application/legacy/LegacyUpdater.php new file mode 100644 index 00000000..3a5de79f --- /dev/null +++ b/application/legacy/LegacyUpdater.php @@ -0,0 +1,617 @@ +doneUpdates = $doneUpdates; + $this->linkDB = $linkDB; + $this->conf = $conf; + $this->isLoggedIn = $isLoggedIn; + $this->session = &$session; + + // Retrieve all update methods. + $class = new ReflectionClass($this); + $this->methods = $class->getMethods(); + } + + /** + * Run all new updates. + * Update methods have to start with 'updateMethod' and return true (on success). + * + * @return array An array containing ran updates. + * + * @throws UpdaterException If something went wrong. + */ + public function update() + { + $updatesRan = array(); + + // If the user isn't logged in, exit without updating. + if ($this->isLoggedIn !== true) { + return $updatesRan; + } + + if ($this->methods === null) { + throw new UpdaterException(t('Couldn\'t retrieve updater class methods.')); + } + + foreach ($this->methods as $method) { + // Not an update method or already done, pass. + if (!startsWith($method->getName(), 'updateMethod') + || in_array($method->getName(), $this->doneUpdates) + ) { + continue; + } + + try { + $method->setAccessible(true); + $res = $method->invoke($this); + // Update method must return true to be considered processed. + if ($res === true) { + $updatesRan[] = $method->getName(); + } + } catch (Exception $e) { + throw new UpdaterException($method, $e); + } + } + + $this->doneUpdates = array_merge($this->doneUpdates, $updatesRan); + + return $updatesRan; + } + + /** + * @return array Updates methods already processed. + */ + public function getDoneUpdates() + { + return $this->doneUpdates; + } + + /** + * Move deprecated options.php to config.php. + * + * Milestone 0.9 (old versioning) - shaarli/Shaarli#41: + * options.php is not supported anymore. + */ + public function updateMethodMergeDeprecatedConfigFile() + { + if (is_file($this->conf->get('resource.data_dir') . '/options.php')) { + include $this->conf->get('resource.data_dir') . '/options.php'; + + // Load GLOBALS into config + $allowedKeys = array_merge(ConfigPhp::$ROOT_KEYS); + $allowedKeys[] = 'config'; + foreach ($GLOBALS as $key => $value) { + if (in_array($key, $allowedKeys)) { + $this->conf->set($key, $value); + } + } + $this->conf->write($this->isLoggedIn); + unlink($this->conf->get('resource.data_dir') . '/options.php'); + } + + return true; + } + + /** + * Move old configuration in PHP to the new config system in JSON format. + * + * Will rename 'config.php' into 'config.save.php' and create 'config.json.php'. + * It will also convert legacy setting keys to the new ones. + */ + public function updateMethodConfigToJson() + { + // JSON config already exists, nothing to do. + if ($this->conf->getConfigIO() instanceof ConfigJson) { + return true; + } + + $configPhp = new ConfigPhp(); + $configJson = new ConfigJson(); + $oldConfig = $configPhp->read($this->conf->getConfigFile() . '.php'); + rename($this->conf->getConfigFileExt(), $this->conf->getConfigFile() . '.save.php'); + $this->conf->setConfigIO($configJson); + $this->conf->reload(); + + $legacyMap = array_flip(ConfigPhp::$LEGACY_KEYS_MAPPING); + foreach (ConfigPhp::$ROOT_KEYS as $key) { + $this->conf->set($legacyMap[$key], $oldConfig[$key]); + } + + // Set sub config keys (config and plugins) + $subConfig = array('config', 'plugins'); + foreach ($subConfig as $sub) { + foreach ($oldConfig[$sub] as $key => $value) { + if (isset($legacyMap[$sub . '.' . $key])) { + $configKey = $legacyMap[$sub . '.' . $key]; + } else { + $configKey = $sub . '.' . $key; + } + $this->conf->set($configKey, $value); + } + } + + try { + $this->conf->write($this->isLoggedIn); + return true; + } catch (IOException $e) { + error_log($e->getMessage()); + return false; + } + } + + /** + * Escape settings which have been manually escaped in every request in previous versions: + * - general.title + * - general.header_link + * - redirector.url + * + * @return bool true if the update is successful, false otherwise. + */ + public function updateMethodEscapeUnescapedConfig() + { + try { + $this->conf->set('general.title', escape($this->conf->get('general.title'))); + $this->conf->set('general.header_link', escape($this->conf->get('general.header_link'))); + $this->conf->write($this->isLoggedIn); + } catch (Exception $e) { + error_log($e->getMessage()); + return false; + } + return true; + } + + /** + * Update the database to use the new ID system, which replaces linkdate primary keys. + * Also, creation and update dates are now DateTime objects (done by LinkDB). + * + * Since this update is very sensitve (changing the whole database), the datastore will be + * automatically backed up into the file datastore..php. + * + * LinkDB also adds the field 'shorturl' with the precedent format (linkdate smallhash), + * which will be saved by this method. + * + * @return bool true if the update is successful, false otherwise. + */ + public function updateMethodDatastoreIds() + { + $first = 'update'; + foreach ($this->linkDB as $key => $link) { + $first = $key; + break; + } + + // up to date database + if (is_int($first)) { + return true; + } + + $save = $this->conf->get('resource.data_dir') . '/datastore.' . date('YmdHis') . '.php'; + copy($this->conf->get('resource.datastore'), $save); + + $links = array(); + foreach ($this->linkDB as $offset => $value) { + $links[] = $value; + unset($this->linkDB[$offset]); + } + $links = array_reverse($links); + $cpt = 0; + foreach ($links as $l) { + unset($l['linkdate']); + $l['id'] = $cpt; + $this->linkDB[$cpt++] = $l; + } + + $this->linkDB->save($this->conf->get('resource.page_cache')); + $this->linkDB->reorder(); + + return true; + } + + /** + * Rename tags starting with a '-' to work with tag exclusion search. + */ + public function updateMethodRenameDashTags() + { + $linklist = $this->linkDB->filterSearch(); + foreach ($linklist as $key => $link) { + $link['tags'] = preg_replace('/(^| )\-/', '$1', $link['tags']); + $link['tags'] = implode(' ', array_unique(BookmarkFilter::tagsStrToArray($link['tags'], true))); + $this->linkDB[$key] = $link; + } + $this->linkDB->save($this->conf->get('resource.page_cache')); + return true; + } + + /** + * Initialize API settings: + * - api.enabled: true + * - api.secret: generated secret + */ + public function updateMethodApiSettings() + { + if ($this->conf->exists('api.secret')) { + return true; + } + + $this->conf->set('api.enabled', true); + $this->conf->set( + 'api.secret', + generate_api_secret( + $this->conf->get('credentials.login'), + $this->conf->get('credentials.salt') + ) + ); + $this->conf->write($this->isLoggedIn); + return true; + } + + /** + * New setting: theme name. If the default theme is used, nothing to do. + * + * If the user uses a custom theme, raintpl_tpl dir is updated to the parent directory, + * and the current theme is set as default in the theme setting. + * + * @return bool true if the update is successful, false otherwise. + */ + public function updateMethodDefaultTheme() + { + // raintpl_tpl isn't the root template directory anymore. + // We run the update only if this folder still contains the template files. + $tplDir = $this->conf->get('resource.raintpl_tpl'); + $tplFile = $tplDir . '/linklist.html'; + if (!file_exists($tplFile)) { + return true; + } + + $parent = dirname($tplDir); + $this->conf->set('resource.raintpl_tpl', $parent); + $this->conf->set('resource.theme', trim(str_replace($parent, '', $tplDir), '/')); + $this->conf->write($this->isLoggedIn); + + // Dependency injection gore + RainTPL::$tpl_dir = $tplDir; + + return true; + } + + /** + * Move the file to inc/user.css to data/user.css. + * + * Note: Due to hardcoded paths, it's not unit testable. But one line of code should be fine. + * + * @return bool true if the update is successful, false otherwise. + */ + public function updateMethodMoveUserCss() + { + if (!is_file('inc/user.css')) { + return true; + } + + return rename('inc/user.css', 'data/user.css'); + } + + /** + * * `markdown_escape` is a new setting, set to true as default. + * + * If the markdown plugin was already enabled, escaping is disabled to avoid + * breaking existing entries. + */ + public function updateMethodEscapeMarkdown() + { + if ($this->conf->exists('security.markdown_escape')) { + return true; + } + + if (in_array('markdown', $this->conf->get('general.enabled_plugins'))) { + $this->conf->set('security.markdown_escape', false); + } else { + $this->conf->set('security.markdown_escape', true); + } + $this->conf->write($this->isLoggedIn); + + return true; + } + + /** + * Add 'http://' to Piwik URL the setting is set. + * + * @return bool true if the update is successful, false otherwise. + */ + public function updateMethodPiwikUrl() + { + if (!$this->conf->exists('plugins.PIWIK_URL') || startsWith($this->conf->get('plugins.PIWIK_URL'), 'http')) { + return true; + } + + $this->conf->set('plugins.PIWIK_URL', 'http://' . $this->conf->get('plugins.PIWIK_URL')); + $this->conf->write($this->isLoggedIn); + + return true; + } + + /** + * Use ATOM feed as default. + */ + public function updateMethodAtomDefault() + { + if (!$this->conf->exists('feed.show_atom') || $this->conf->get('feed.show_atom') === true) { + return true; + } + + $this->conf->set('feed.show_atom', true); + $this->conf->write($this->isLoggedIn); + + return true; + } + + /** + * Update updates.check_updates_branch setting. + * + * If the current major version digit matches the latest branch + * major version digit, we set the branch to `latest`, + * otherwise we'll check updates on the `stable` branch. + * + * No update required for the dev version. + * + * Note: due to hardcoded URL and lack of dependency injection, this is not unit testable. + * + * FIXME! This needs to be removed when we switch to first digit major version + * instead of the second one since the versionning process will change. + */ + public function updateMethodCheckUpdateRemoteBranch() + { + if (SHAARLI_VERSION === 'dev' || $this->conf->get('updates.check_updates_branch') === 'latest') { + return true; + } + + // Get latest branch major version digit + $latestVersion = ApplicationUtils::getLatestGitVersionCode( + 'https://raw.githubusercontent.com/shaarli/Shaarli/latest/shaarli_version.php', + 5 + ); + if (preg_match('/(\d+)\.\d+$/', $latestVersion, $matches) === false) { + return false; + } + $latestMajor = $matches[1]; + + // Get current major version digit + preg_match('/(\d+)\.\d+$/', SHAARLI_VERSION, $matches); + $currentMajor = $matches[1]; + + if ($currentMajor === $latestMajor) { + $branch = 'latest'; + } else { + $branch = 'stable'; + } + $this->conf->set('updates.check_updates_branch', $branch); + $this->conf->write($this->isLoggedIn); + return true; + } + + /** + * Reset history store file due to date format change. + */ + public function updateMethodResetHistoryFile() + { + if (is_file($this->conf->get('resource.history'))) { + unlink($this->conf->get('resource.history')); + } + return true; + } + + /** + * Save the datastore -> the link order is now applied when bookmarks are saved. + */ + public function updateMethodReorderDatastore() + { + $this->linkDB->save($this->conf->get('resource.page_cache')); + return true; + } + + /** + * Change privateonly session key to visibility. + */ + public function updateMethodVisibilitySession() + { + if (isset($_SESSION['privateonly'])) { + unset($_SESSION['privateonly']); + $_SESSION['visibility'] = 'private'; + } + return true; + } + + /** + * Add download size and timeout to the configuration file + * + * @return bool true if the update is successful, false otherwise. + */ + public function updateMethodDownloadSizeAndTimeoutConf() + { + if ($this->conf->exists('general.download_max_size') + && $this->conf->exists('general.download_timeout') + ) { + return true; + } + + if (!$this->conf->exists('general.download_max_size')) { + $this->conf->set('general.download_max_size', 1024 * 1024 * 4); + } + + if (!$this->conf->exists('general.download_timeout')) { + $this->conf->set('general.download_timeout', 30); + } + + $this->conf->write($this->isLoggedIn); + return true; + } + + /** + * * Move thumbnails management to WebThumbnailer, coming with new settings. + */ + public function updateMethodWebThumbnailer() + { + if ($this->conf->exists('thumbnails.mode')) { + return true; + } + + $thumbnailsEnabled = extension_loaded('gd') && $this->conf->get('thumbnail.enable_thumbnails', true); + $this->conf->set('thumbnails.mode', $thumbnailsEnabled ? Thumbnailer::MODE_ALL : Thumbnailer::MODE_NONE); + $this->conf->set('thumbnails.width', 125); + $this->conf->set('thumbnails.height', 90); + $this->conf->remove('thumbnail'); + $this->conf->write(true); + + if ($thumbnailsEnabled) { + $this->session['warnings'][] = t( + 'You have enabled or changed thumbnails mode. Please synchronize them.' + ); + } + + return true; + } + + /** + * Set sticky = false on all bookmarks + * + * @return bool true if the update is successful, false otherwise. + */ + public function updateMethodSetSticky() + { + foreach ($this->linkDB as $key => $link) { + if (isset($link['sticky'])) { + return true; + } + $link['sticky'] = false; + $this->linkDB[$key] = $link; + } + + $this->linkDB->save($this->conf->get('resource.page_cache')); + + return true; + } + + /** + * Remove redirector settings. + */ + public function updateMethodRemoveRedirector() + { + $this->conf->remove('redirector'); + $this->conf->write(true); + return true; + } + + /** + * Migrate the legacy arrays to Bookmark objects. + * Also make a backup of the datastore. + */ + public function updateMethodMigrateDatabase() + { + $save = $this->conf->get('resource.data_dir') . '/datastore.' . date('YmdHis') . '_1.php'; + if (! copy($this->conf->get('resource.datastore'), $save)) { + die('Could not backup the datastore.'); + } + + $linksArray = new BookmarkArray(); + foreach ($this->linkDB as $key => $link) { + $linksArray[$key] = (new Bookmark())->fromArray($link); + } + $linksIo = new BookmarkIO($this->conf); + $linksIo->write($linksArray); + + return true; + } + + /** + * Write the `formatter` setting in config file. + * Use markdown if the markdown plugin is enabled, the default one otherwise. + * Also remove markdown plugin setting as it is now integrated to the core. + */ + public function updateMethodFormatterSetting() + { + if (!$this->conf->exists('formatter') || $this->conf->get('formatter') === 'default') { + $enabledPlugins = $this->conf->get('general.enabled_plugins'); + if (($pos = array_search('markdown', $enabledPlugins)) !== false) { + $formatter = 'markdown'; + unset($enabledPlugins[$pos]); + $this->conf->set('general.enabled_plugins', array_values($enabledPlugins)); + } else { + $formatter = 'default'; + } + $this->conf->set('formatter', $formatter); + $this->conf->write(true); + } + + return true; + } +} -- cgit v1.2.3 From cf92b4dd1521241eefc58eaf6dcd202cd83969d8 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Sat, 25 May 2019 15:52:27 +0200 Subject: Apply the new system (Bookmark + Service) to the whole code base See https://github.com/shaarli/Shaarli/issues/1307 --- application/History.php | 17 +- application/Utils.php | 2 +- application/api/ApiMiddleware.php | 11 +- application/api/ApiUtils.php | 79 ++-- application/api/controllers/ApiController.php | 8 +- application/api/controllers/HistoryController.php | 2 +- application/api/controllers/Info.php | 5 +- application/api/controllers/Links.php | 79 ++-- application/api/controllers/Tags.php | 44 +- application/bookmark/BookmarkArray.php | 2 +- application/config/ConfigManager.php | 2 + application/feed/FeedBuilder.php | 78 ++-- .../formatter/BookmarkMarkdownFormatter.php | 6 + application/netscape/NetscapeBookmarkUtils.php | 117 +++-- application/render/PageBuilder.php | 24 +- application/updater/Updater.php | 480 +-------------------- application/updater/UpdaterUtils.php | 65 +-- 17 files changed, 287 insertions(+), 734 deletions(-) (limited to 'application') diff --git a/application/History.php b/application/History.php index a5846652..4fd2f294 100644 --- a/application/History.php +++ b/application/History.php @@ -3,6 +3,7 @@ namespace Shaarli; use DateTime; use Exception; +use Shaarli\Bookmark\Bookmark; /** * Class History @@ -20,7 +21,7 @@ use Exception; * - UPDATED: link updated * - DELETED: link deleted * - SETTINGS: the settings have been updated through the UI. - * - IMPORT: bulk links import + * - IMPORT: bulk bookmarks import * * Note: new events are put at the beginning of the file and history array. */ @@ -96,31 +97,31 @@ class History /** * Add Event: new link. * - * @param array $link Link data. + * @param Bookmark $link Link data. */ public function addLink($link) { - $this->addEvent(self::CREATED, $link['id']); + $this->addEvent(self::CREATED, $link->getId()); } /** * Add Event: update existing link. * - * @param array $link Link data. + * @param Bookmark $link Link data. */ public function updateLink($link) { - $this->addEvent(self::UPDATED, $link['id']); + $this->addEvent(self::UPDATED, $link->getId()); } /** * Add Event: delete existing link. * - * @param array $link Link data. + * @param Bookmark $link Link data. */ public function deleteLink($link) { - $this->addEvent(self::DELETED, $link['id']); + $this->addEvent(self::DELETED, $link->getId()); } /** @@ -134,7 +135,7 @@ class History /** * Add Event: bulk import. * - * Note: we don't store links add/update one by one since it can have a huge impact on performances. + * Note: we don't store bookmarks add/update one by one since it can have a huge impact on performances. */ public function importLinks() { diff --git a/application/Utils.php b/application/Utils.php index 925e1a22..56f5b9a2 100644 --- a/application/Utils.php +++ b/application/Utils.php @@ -162,7 +162,7 @@ function generateLocation($referer, $host, $loopTerms = array()) $finalReferer = '?'; // No referer if it contains any value in $loopCriteria. - foreach ($loopTerms as $value) { + foreach (array_filter($loopTerms) as $value) { if (strpos($referer, $value) !== false) { return $finalReferer; } diff --git a/application/api/ApiMiddleware.php b/application/api/ApiMiddleware.php index 2d55bda6..4745ac94 100644 --- a/application/api/ApiMiddleware.php +++ b/application/api/ApiMiddleware.php @@ -3,6 +3,7 @@ namespace Shaarli\Api; use Shaarli\Api\Exceptions\ApiAuthorizationException; use Shaarli\Api\Exceptions\ApiException; +use Shaarli\Bookmark\BookmarkFileService; use Shaarli\Config\ConfigManager; use Slim\Container; use Slim\Http\Request; @@ -117,7 +118,7 @@ class ApiMiddleware } /** - * Instantiate a new LinkDB including private links, + * Instantiate a new LinkDB including private bookmarks, * and load in the Slim container. * * FIXME! LinkDB could use a refactoring to avoid this trick. @@ -126,10 +127,10 @@ class ApiMiddleware */ protected function setLinkDb($conf) { - $linkDb = new \Shaarli\Bookmark\LinkDB( - $conf->get('resource.datastore'), - true, - $conf->get('privacy.hide_public_links') + $linkDb = new BookmarkFileService( + $conf, + $this->container->get('history'), + true ); $this->container['db'] = $linkDb; } diff --git a/application/api/ApiUtils.php b/application/api/ApiUtils.php index 5ac07c4d..5156a5f7 100644 --- a/application/api/ApiUtils.php +++ b/application/api/ApiUtils.php @@ -2,6 +2,7 @@ namespace Shaarli\Api; use Shaarli\Api\Exceptions\ApiAuthorizationException; +use Shaarli\Bookmark\Bookmark; use Shaarli\Http\Base64Url; /** @@ -54,28 +55,28 @@ class ApiUtils /** * Format a Link for the REST API. * - * @param array $link Link data read from the datastore. - * @param string $indexUrl Shaarli's index URL (used for relative URL). + * @param Bookmark $bookmark Bookmark data read from the datastore. + * @param string $indexUrl Shaarli's index URL (used for relative URL). * * @return array Link data formatted for the REST API. */ - public static function formatLink($link, $indexUrl) + public static function formatLink($bookmark, $indexUrl) { - $out['id'] = $link['id']; + $out['id'] = $bookmark->getId(); // Not an internal link - if (! is_note($link['url'])) { - $out['url'] = $link['url']; + if (! $bookmark->isNote()) { + $out['url'] = $bookmark->getUrl(); } else { - $out['url'] = $indexUrl . $link['url']; + $out['url'] = $indexUrl . $bookmark->getUrl(); } - $out['shorturl'] = $link['shorturl']; - $out['title'] = $link['title']; - $out['description'] = $link['description']; - $out['tags'] = preg_split('/\s+/', $link['tags'], -1, PREG_SPLIT_NO_EMPTY); - $out['private'] = $link['private'] == true; - $out['created'] = $link['created']->format(\DateTime::ATOM); - if (! empty($link['updated'])) { - $out['updated'] = $link['updated']->format(\DateTime::ATOM); + $out['shorturl'] = $bookmark->getShortUrl(); + $out['title'] = $bookmark->getTitle(); + $out['description'] = $bookmark->getDescription(); + $out['tags'] = $bookmark->getTags(); + $out['private'] = $bookmark->isPrivate(); + $out['created'] = $bookmark->getCreated()->format(\DateTime::ATOM); + if (! empty($bookmark->getUpdated())) { + $out['updated'] = $bookmark->getUpdated()->format(\DateTime::ATOM); } else { $out['updated'] = ''; } @@ -83,7 +84,7 @@ class ApiUtils } /** - * Convert a link given through a request, to a valid link for LinkDB. + * Convert a link given through a request, to a valid Bookmark for the datastore. * * If no URL is provided, it will generate a local note URL. * If no title is provided, it will use the URL as title. @@ -91,50 +92,42 @@ class ApiUtils * @param array $input Request Link. * @param bool $defaultPrivate Request Link. * - * @return array Formatted link. + * @return Bookmark instance. */ public static function buildLinkFromRequest($input, $defaultPrivate) { - $input['url'] = ! empty($input['url']) ? cleanup_url($input['url']) : ''; + $bookmark = new Bookmark(); + $url = ! empty($input['url']) ? cleanup_url($input['url']) : ''; if (isset($input['private'])) { $private = filter_var($input['private'], FILTER_VALIDATE_BOOLEAN); } else { $private = $defaultPrivate; } - $link = [ - 'title' => ! empty($input['title']) ? $input['title'] : $input['url'], - 'url' => $input['url'], - 'description' => ! empty($input['description']) ? $input['description'] : '', - 'tags' => ! empty($input['tags']) ? implode(' ', $input['tags']) : '', - 'private' => $private, - 'created' => new \DateTime(), - ]; - return $link; + $bookmark->setTitle(! empty($input['title']) ? $input['title'] : ''); + $bookmark->setUrl($url); + $bookmark->setDescription(! empty($input['description']) ? $input['description'] : ''); + $bookmark->setTags(! empty($input['tags']) ? $input['tags'] : []); + $bookmark->setPrivate($private); + + return $bookmark; } /** * Update link fields using an updated link object. * - * @param array $oldLink data - * @param array $newLink data + * @param Bookmark $oldLink data + * @param Bookmark $newLink data * - * @return array $oldLink updated with $newLink values + * @return Bookmark $oldLink updated with $newLink values */ public static function updateLink($oldLink, $newLink) { - foreach (['title', 'url', 'description', 'tags', 'private'] as $field) { - $oldLink[$field] = $newLink[$field]; - } - $oldLink['updated'] = new \DateTime(); - - if (empty($oldLink['url'])) { - $oldLink['url'] = '?' . $oldLink['shorturl']; - } - - if (empty($oldLink['title'])) { - $oldLink['title'] = $oldLink['url']; - } + $oldLink->setTitle($newLink->getTitle()); + $oldLink->setUrl($newLink->getUrl()); + $oldLink->setDescription($newLink->getDescription()); + $oldLink->setTags($newLink->getTags()); + $oldLink->setPrivate($newLink->isPrivate()); return $oldLink; } @@ -143,7 +136,7 @@ class ApiUtils * Format a Tag for the REST API. * * @param string $tag Tag name - * @param int $occurrences Number of links using this tag + * @param int $occurrences Number of bookmarks using this tag * * @return array Link data formatted for the REST API. */ diff --git a/application/api/controllers/ApiController.php b/application/api/controllers/ApiController.php index a6e7cbab..c4b3d0c3 100644 --- a/application/api/controllers/ApiController.php +++ b/application/api/controllers/ApiController.php @@ -2,7 +2,7 @@ namespace Shaarli\Api\Controllers; -use Shaarli\Bookmark\LinkDB; +use Shaarli\Bookmark\BookmarkServiceInterface; use Shaarli\Config\ConfigManager; use Slim\Container; @@ -26,9 +26,9 @@ abstract class ApiController protected $conf; /** - * @var LinkDB + * @var BookmarkServiceInterface */ - protected $linkDb; + protected $bookmarkService; /** * @var HistoryController @@ -51,7 +51,7 @@ abstract class ApiController { $this->ci = $ci; $this->conf = $ci->get('conf'); - $this->linkDb = $ci->get('db'); + $this->bookmarkService = $ci->get('db'); $this->history = $ci->get('history'); if ($this->conf->get('dev.debug', false)) { $this->jsonStyle = JSON_PRETTY_PRINT; diff --git a/application/api/controllers/HistoryController.php b/application/api/controllers/HistoryController.php index 9afcfa26..505647a9 100644 --- a/application/api/controllers/HistoryController.php +++ b/application/api/controllers/HistoryController.php @@ -41,7 +41,7 @@ class HistoryController extends ApiController throw new ApiBadParametersException('Invalid offset'); } - // limit parameter is either a number of links or 'all' for everything. + // limit parameter is either a number of bookmarks or 'all' for everything. $limit = $request->getParam('limit'); if (empty($limit)) { $limit = count($history); diff --git a/application/api/controllers/Info.php b/application/api/controllers/Info.php index f37dcae5..12f6b2f0 100644 --- a/application/api/controllers/Info.php +++ b/application/api/controllers/Info.php @@ -2,6 +2,7 @@ namespace Shaarli\Api\Controllers; +use Shaarli\Bookmark\BookmarkFilter; use Slim\Http\Request; use Slim\Http\Response; @@ -26,8 +27,8 @@ class Info extends ApiController public function getInfo($request, $response) { $info = [ - 'global_counter' => count($this->linkDb), - 'private_counter' => count_private($this->linkDb), + 'global_counter' => $this->bookmarkService->count(), + 'private_counter' => $this->bookmarkService->count(BookmarkFilter::$PRIVATE), 'settings' => array( 'title' => $this->conf->get('general.title', 'Shaarli'), 'header_link' => $this->conf->get('general.header_link', '?'), diff --git a/application/api/controllers/Links.php b/application/api/controllers/Links.php index ffcfd4c7..29247950 100644 --- a/application/api/controllers/Links.php +++ b/application/api/controllers/Links.php @@ -11,7 +11,7 @@ use Slim\Http\Response; /** * Class Links * - * REST API Controller: all services related to links collection. + * REST API Controller: all services related to bookmarks collection. * * @package Api\Controllers * @see http://shaarli.github.io/api-documentation/#links-links-collection @@ -19,12 +19,12 @@ use Slim\Http\Response; class Links extends ApiController { /** - * @var int Number of links returned if no limit is provided. + * @var int Number of bookmarks returned if no limit is provided. */ public static $DEFAULT_LIMIT = 20; /** - * Retrieve a list of links, allowing different filters. + * Retrieve a list of bookmarks, allowing different filters. * * @param Request $request Slim request. * @param Response $response Slim response. @@ -36,33 +36,32 @@ class Links extends ApiController public function getLinks($request, $response) { $private = $request->getParam('visibility'); - $links = $this->linkDb->filterSearch( + $bookmarks = $this->bookmarkService->search( [ 'searchtags' => $request->getParam('searchtags', ''), 'searchterm' => $request->getParam('searchterm', ''), ], - false, $private ); - // Return links from the {offset}th link, starting from 0. + // Return bookmarks from the {offset}th link, starting from 0. $offset = $request->getParam('offset'); if (! empty($offset) && ! ctype_digit($offset)) { throw new ApiBadParametersException('Invalid offset'); } $offset = ! empty($offset) ? intval($offset) : 0; - if ($offset > count($links)) { + if ($offset > count($bookmarks)) { return $response->withJson([], 200, $this->jsonStyle); } - // limit parameter is either a number of links or 'all' for everything. + // limit parameter is either a number of bookmarks or 'all' for everything. $limit = $request->getParam('limit'); if (empty($limit)) { $limit = self::$DEFAULT_LIMIT; } elseif (ctype_digit($limit)) { $limit = intval($limit); } elseif ($limit === 'all') { - $limit = count($links); + $limit = count($bookmarks); } else { throw new ApiBadParametersException('Invalid limit'); } @@ -72,12 +71,12 @@ class Links extends ApiController $out = []; $index = 0; - foreach ($links as $link) { + foreach ($bookmarks as $bookmark) { if (count($out) >= $limit) { break; } if ($index++ >= $offset) { - $out[] = ApiUtils::formatLink($link, $indexUrl); + $out[] = ApiUtils::formatLink($bookmark, $indexUrl); } } @@ -97,11 +96,11 @@ class Links extends ApiController */ public function getLink($request, $response, $args) { - if (!isset($this->linkDb[$args['id']])) { + if (!$this->bookmarkService->exists($args['id'])) { throw new ApiLinkNotFoundException(); } $index = index_url($this->ci['environment']); - $out = ApiUtils::formatLink($this->linkDb[$args['id']], $index); + $out = ApiUtils::formatLink($this->bookmarkService->get($args['id']), $index); return $response->withJson($out, 200, $this->jsonStyle); } @@ -117,9 +116,11 @@ class Links extends ApiController public function postLink($request, $response) { $data = $request->getParsedBody(); - $link = ApiUtils::buildLinkFromRequest($data, $this->conf->get('privacy.default_private_links')); + $bookmark = ApiUtils::buildLinkFromRequest($data, $this->conf->get('privacy.default_private_links')); // duplicate by URL, return 409 Conflict - if (! empty($link['url']) && ! empty($dup = $this->linkDb->getLinkFromUrl($link['url']))) { + if (! empty($bookmark->getUrl()) + && ! empty($dup = $this->bookmarkService->findByUrl($bookmark->getUrl())) + ) { return $response->withJson( ApiUtils::formatLink($dup, index_url($this->ci['environment'])), 409, @@ -127,23 +128,9 @@ class Links extends ApiController ); } - $link['id'] = $this->linkDb->getNextId(); - $link['shorturl'] = link_small_hash($link['created'], $link['id']); - - // note: general relative URL - if (empty($link['url'])) { - $link['url'] = '?' . $link['shorturl']; - } - - if (empty($link['title'])) { - $link['title'] = $link['url']; - } - - $this->linkDb[$link['id']] = $link; - $this->linkDb->save($this->conf->get('resource.page_cache')); - $this->history->addLink($link); - $out = ApiUtils::formatLink($link, index_url($this->ci['environment'])); - $redirect = $this->ci->router->relativePathFor('getLink', ['id' => $link['id']]); + $this->bookmarkService->add($bookmark); + $out = ApiUtils::formatLink($bookmark, index_url($this->ci['environment'])); + $redirect = $this->ci->router->relativePathFor('getLink', ['id' => $bookmark->getId()]); return $response->withAddedHeader('Location', $redirect) ->withJson($out, 201, $this->jsonStyle); } @@ -161,18 +148,18 @@ class Links extends ApiController */ public function putLink($request, $response, $args) { - if (! isset($this->linkDb[$args['id']])) { + if (! $this->bookmarkService->exists($args['id'])) { throw new ApiLinkNotFoundException(); } $index = index_url($this->ci['environment']); $data = $request->getParsedBody(); - $requestLink = ApiUtils::buildLinkFromRequest($data, $this->conf->get('privacy.default_private_links')); + $requestBookmark = ApiUtils::buildLinkFromRequest($data, $this->conf->get('privacy.default_private_links')); // duplicate URL on a different link, return 409 Conflict - if (! empty($requestLink['url']) - && ! empty($dup = $this->linkDb->getLinkFromUrl($requestLink['url'])) - && $dup['id'] != $args['id'] + if (! empty($requestBookmark->getUrl()) + && ! empty($dup = $this->bookmarkService->findByUrl($requestBookmark->getUrl())) + && $dup->getId() != $args['id'] ) { return $response->withJson( ApiUtils::formatLink($dup, $index), @@ -181,13 +168,11 @@ class Links extends ApiController ); } - $responseLink = $this->linkDb[$args['id']]; - $responseLink = ApiUtils::updateLink($responseLink, $requestLink); - $this->linkDb[$responseLink['id']] = $responseLink; - $this->linkDb->save($this->conf->get('resource.page_cache')); - $this->history->updateLink($responseLink); + $responseBookmark = $this->bookmarkService->get($args['id']); + $responseBookmark = ApiUtils::updateLink($responseBookmark, $requestBookmark); + $this->bookmarkService->set($responseBookmark); - $out = ApiUtils::formatLink($responseLink, $index); + $out = ApiUtils::formatLink($responseBookmark, $index); return $response->withJson($out, 200, $this->jsonStyle); } @@ -204,13 +189,11 @@ class Links extends ApiController */ public function deleteLink($request, $response, $args) { - if (! isset($this->linkDb[$args['id']])) { + if (! $this->bookmarkService->exists($args['id'])) { throw new ApiLinkNotFoundException(); } - $link = $this->linkDb[$args['id']]; - unset($this->linkDb[(int) $args['id']]); - $this->linkDb->save($this->conf->get('resource.page_cache')); - $this->history->deleteLink($link); + $bookmark = $this->bookmarkService->get($args['id']); + $this->bookmarkService->remove($bookmark); return $response->withStatus(204); } diff --git a/application/api/controllers/Tags.php b/application/api/controllers/Tags.php index 82f3ef74..e60e00a7 100644 --- a/application/api/controllers/Tags.php +++ b/application/api/controllers/Tags.php @@ -5,6 +5,7 @@ namespace Shaarli\Api\Controllers; use Shaarli\Api\ApiUtils; use Shaarli\Api\Exceptions\ApiBadParametersException; use Shaarli\Api\Exceptions\ApiTagNotFoundException; +use Shaarli\Bookmark\BookmarkFilter; use Slim\Http\Request; use Slim\Http\Response; @@ -18,7 +19,7 @@ use Slim\Http\Response; class Tags extends ApiController { /** - * @var int Number of links returned if no limit is provided. + * @var int Number of bookmarks returned if no limit is provided. */ public static $DEFAULT_LIMIT = 'all'; @@ -35,7 +36,7 @@ class Tags extends ApiController public function getTags($request, $response) { $visibility = $request->getParam('visibility'); - $tags = $this->linkDb->linksCountPerTag([], $visibility); + $tags = $this->bookmarkService->bookmarksCountPerTag([], $visibility); // Return tags from the {offset}th tag, starting from 0. $offset = $request->getParam('offset'); @@ -47,7 +48,7 @@ class Tags extends ApiController return $response->withJson([], 200, $this->jsonStyle); } - // limit parameter is either a number of links or 'all' for everything. + // limit parameter is either a number of bookmarks or 'all' for everything. $limit = $request->getParam('limit'); if (empty($limit)) { $limit = self::$DEFAULT_LIMIT; @@ -87,7 +88,7 @@ class Tags extends ApiController */ public function getTag($request, $response, $args) { - $tags = $this->linkDb->linksCountPerTag(); + $tags = $this->bookmarkService->bookmarksCountPerTag(); if (!isset($tags[$args['tagName']])) { throw new ApiTagNotFoundException(); } @@ -111,7 +112,7 @@ class Tags extends ApiController */ public function putTag($request, $response, $args) { - $tags = $this->linkDb->linksCountPerTag(); + $tags = $this->bookmarkService->bookmarksCountPerTag(); if (! isset($tags[$args['tagName']])) { throw new ApiTagNotFoundException(); } @@ -121,13 +122,19 @@ class Tags extends ApiController throw new ApiBadParametersException('New tag name is required in the request body'); } - $updated = $this->linkDb->renameTag($args['tagName'], $data['name']); - $this->linkDb->save($this->conf->get('resource.page_cache')); - foreach ($updated as $link) { - $this->history->updateLink($link); + $bookmarks = $this->bookmarkService->search( + ['searchtags' => $args['tagName']], + BookmarkFilter::$ALL, + true + ); + foreach ($bookmarks as $bookmark) { + $bookmark->renameTag($args['tagName'], $data['name']); + $this->bookmarkService->set($bookmark, false); + $this->history->updateLink($bookmark); } + $this->bookmarkService->save(); - $tags = $this->linkDb->linksCountPerTag(); + $tags = $this->bookmarkService->bookmarksCountPerTag(); $out = ApiUtils::formatTag($data['name'], $tags[$data['name']]); return $response->withJson($out, 200, $this->jsonStyle); } @@ -145,15 +152,22 @@ class Tags extends ApiController */ public function deleteTag($request, $response, $args) { - $tags = $this->linkDb->linksCountPerTag(); + $tags = $this->bookmarkService->bookmarksCountPerTag(); if (! isset($tags[$args['tagName']])) { throw new ApiTagNotFoundException(); } - $updated = $this->linkDb->renameTag($args['tagName'], null); - $this->linkDb->save($this->conf->get('resource.page_cache')); - foreach ($updated as $link) { - $this->history->updateLink($link); + + $bookmarks = $this->bookmarkService->search( + ['searchtags' => $args['tagName']], + BookmarkFilter::$ALL, + true + ); + foreach ($bookmarks as $bookmark) { + $bookmark->deleteTag($args['tagName']); + $this->bookmarkService->set($bookmark, false); + $this->history->updateLink($bookmark); } + $this->bookmarkService->save(); return $response->withStatus(204); } diff --git a/application/bookmark/BookmarkArray.php b/application/bookmark/BookmarkArray.php index b427c91a..d87d43b4 100644 --- a/application/bookmark/BookmarkArray.php +++ b/application/bookmark/BookmarkArray.php @@ -118,7 +118,7 @@ class BookmarkArray implements \Iterator, \Countable, \ArrayAccess $realOffset = $this->getBookmarkOffset($offset); $url = $this->bookmarks[$realOffset]->getUrl(); unset($this->urls[$url]); - unset($this->ids[$realOffset]); + unset($this->ids[$offset]); unset($this->bookmarks[$realOffset]); } diff --git a/application/config/ConfigManager.php b/application/config/ConfigManager.php index c95e6800..e45bb4c3 100644 --- a/application/config/ConfigManager.php +++ b/application/config/ConfigManager.php @@ -389,6 +389,8 @@ class ConfigManager $this->setEmpty('translation.extensions', []); $this->setEmpty('plugins', array()); + + $this->setEmpty('formatter', 'markdown'); } /** diff --git a/application/feed/FeedBuilder.php b/application/feed/FeedBuilder.php index 957c8273..40bd4f15 100644 --- a/application/feed/FeedBuilder.php +++ b/application/feed/FeedBuilder.php @@ -2,6 +2,9 @@ namespace Shaarli\Feed; use DateTime; +use Shaarli\Bookmark\Bookmark; +use Shaarli\Bookmark\BookmarkServiceInterface; +use Shaarli\Formatter\BookmarkFormatter; /** * FeedBuilder class. @@ -26,15 +29,20 @@ class FeedBuilder public static $DEFAULT_LANGUAGE = 'en-en'; /** - * @var int Number of links to display in a feed by default. + * @var int Number of bookmarks to display in a feed by default. */ public static $DEFAULT_NB_LINKS = 50; /** - * @var \Shaarli\Bookmark\LinkDB instance. + * @var BookmarkServiceInterface instance. */ protected $linkDB; + /** + * @var BookmarkFormatter instance. + */ + protected $formatter; + /** * @var string RSS or ATOM feed. */ @@ -56,7 +64,7 @@ class FeedBuilder protected $isLoggedIn; /** - * @var boolean Use permalinks instead of direct links if true. + * @var boolean Use permalinks instead of direct bookmarks if true. */ protected $usePermalinks; @@ -78,16 +86,17 @@ class FeedBuilder /** * Feed constructor. * - * @param \Shaarli\Bookmark\LinkDB $linkDB LinkDB instance. + * @param BookmarkServiceInterface $linkDB LinkDB instance. + * @param BookmarkFormatter $formatter instance. * @param string $feedType Type of feed. * @param array $serverInfo $_SERVER. * @param array $userInput $_GET. - * @param boolean $isLoggedIn True if the user is currently logged in, - * false otherwise. + * @param boolean $isLoggedIn True if the user is currently logged in, false otherwise. */ - public function __construct($linkDB, $feedType, $serverInfo, $userInput, $isLoggedIn) + public function __construct($linkDB, $formatter, $feedType, $serverInfo, $userInput, $isLoggedIn) { $this->linkDB = $linkDB; + $this->formatter = $formatter; $this->feedType = $feedType; $this->serverInfo = $serverInfo; $this->userInput = $userInput; @@ -101,13 +110,13 @@ class FeedBuilder */ public function buildData() { - // Search for untagged links + // Search for untagged bookmarks if (isset($this->userInput['searchtags']) && empty($this->userInput['searchtags'])) { $this->userInput['searchtags'] = false; } // Optionally filter the results: - $linksToDisplay = $this->linkDB->filterSearch($this->userInput); + $linksToDisplay = $this->linkDB->search($this->userInput); $nblinksToDisplay = $this->getNbLinks(count($linksToDisplay)); @@ -118,6 +127,7 @@ class FeedBuilder } $pageaddr = escape(index_url($this->serverInfo)); + $this->formatter->addContextData('index_url', $pageaddr); $linkDisplayed = array(); for ($i = 0; $i < $nblinksToDisplay && $i < count($keys); $i++) { $linkDisplayed[$keys[$i]] = $this->buildItem($linksToDisplay[$keys[$i]], $pageaddr); @@ -139,54 +149,44 @@ class FeedBuilder /** * Build a feed item (one per shaare). * - * @param array $link Single link array extracted from LinkDB. - * @param string $pageaddr Index URL. + * @param Bookmark $link Single link array extracted from LinkDB. + * @param string $pageaddr Index URL. * * @return array Link array with feed attributes. */ protected function buildItem($link, $pageaddr) { - $link['guid'] = $pageaddr . '?' . $link['shorturl']; - // Prepend the root URL for notes - if (is_note($link['url'])) { - $link['url'] = $pageaddr . $link['url']; - } + $data = $this->formatter->format($link); + $data['guid'] = $pageaddr . '?' . $data['shorturl']; if ($this->usePermalinks === true) { - $permalink = '' . t('Direct link') . ''; + $permalink = ''. t('Direct link') .''; } else { - $permalink = '' . t('Permalink') . ''; + $permalink = ''. t('Permalink') .''; } - $link['description'] = format_description($link['description'], $pageaddr); - $link['description'] .= PHP_EOL . PHP_EOL . '
— ' . $permalink; + $data['description'] .= PHP_EOL . PHP_EOL . '
— ' . $permalink; - $pubDate = $link['created']; - $link['pub_iso_date'] = $this->getIsoDate($pubDate); + $data['pub_iso_date'] = $this->getIsoDate($data['created']); // atom:entry elements MUST contain exactly one atom:updated element. - if (!empty($link['updated'])) { - $upDate = $link['updated']; - $link['up_iso_date'] = $this->getIsoDate($upDate, DateTime::ATOM); + if (!empty($link->getUpdated())) { + $data['up_iso_date'] = $this->getIsoDate($data['updated'], DateTime::ATOM); } else { - $link['up_iso_date'] = $this->getIsoDate($pubDate, DateTime::ATOM); + $data['up_iso_date'] = $this->getIsoDate($data['created'], DateTime::ATOM); } // Save the more recent item. - if (empty($this->latestDate) || $this->latestDate < $pubDate) { - $this->latestDate = $pubDate; + if (empty($this->latestDate) || $this->latestDate < $data['created']) { + $this->latestDate = $data['created']; } - if (!empty($upDate) && $this->latestDate < $upDate) { - $this->latestDate = $upDate; + if (!empty($data['updated']) && $this->latestDate < $data['updated']) { + $this->latestDate = $data['updated']; } - $taglist = array_filter(explode(' ', $link['tags']), 'strlen'); - uasort($taglist, 'strcasecmp'); - $link['taglist'] = $taglist; - - return $link; + return $data; } /** - * Set this to true to use permalinks instead of direct links. + * Set this to true to use permalinks instead of direct bookmarks. * * @param boolean $usePermalinks true to force permalinks. */ @@ -273,11 +273,11 @@ class FeedBuilder * Returns the number of link to display according to 'nb' user input parameter. * * If 'nb' not set or invalid, default value: $DEFAULT_NB_LINKS. - * If 'nb' is set to 'all', display all filtered links (max parameter). + * If 'nb' is set to 'all', display all filtered bookmarks (max parameter). * - * @param int $max maximum number of links to display. + * @param int $max maximum number of bookmarks to display. * - * @return int number of links to display. + * @return int number of bookmarks to display. */ public function getNbLinks($max) { diff --git a/application/formatter/BookmarkMarkdownFormatter.php b/application/formatter/BookmarkMarkdownFormatter.php index f60c61f4..7797bfbf 100644 --- a/application/formatter/BookmarkMarkdownFormatter.php +++ b/application/formatter/BookmarkMarkdownFormatter.php @@ -57,6 +57,7 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter $processedDescription = $bookmark->getDescription(); $processedDescription = $this->filterProtocols($processedDescription); $processedDescription = $this->formatHashTags($processedDescription); + $processedDescription = $this->reverseEscapedHtml($processedDescription); $processedDescription = $this->parsedown ->setMarkupEscaped($this->escape) ->setBreaksEnabled(true) @@ -195,4 +196,9 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter ); return $description; } + + protected function reverseEscapedHtml($description) + { + return unescape($description); + } } diff --git a/application/netscape/NetscapeBookmarkUtils.php b/application/netscape/NetscapeBookmarkUtils.php index 28665941..d64eef7f 100644 --- a/application/netscape/NetscapeBookmarkUtils.php +++ b/application/netscape/NetscapeBookmarkUtils.php @@ -7,8 +7,10 @@ use DateTimeZone; use Exception; use Katzgrau\KLogger\Logger; use Psr\Log\LogLevel; -use Shaarli\Bookmark\LinkDB; +use Shaarli\Bookmark\Bookmark; +use Shaarli\Bookmark\BookmarkServiceInterface; use Shaarli\Config\ConfigManager; +use Shaarli\Formatter\BookmarkFormatter; use Shaarli\History; use Shaarli\NetscapeBookmarkParser\NetscapeBookmarkParser; @@ -20,41 +22,39 @@ class NetscapeBookmarkUtils { /** - * Filters links and adds Netscape-formatted fields + * Filters bookmarks and adds Netscape-formatted fields * * Added fields: * - timestamp link addition date, using the Unix epoch format * - taglist comma-separated tag list * - * @param LinkDB $linkDb Link datastore - * @param string $selection Which links to export: (all|private|public) - * @param bool $prependNoteUrl Prepend note permalinks with the server's URL - * @param string $indexUrl Absolute URL of the Shaarli index page + * @param BookmarkServiceInterface $bookmarkService Link datastore + * @param BookmarkFormatter $formatter instance + * @param string $selection Which bookmarks to export: (all|private|public) + * @param bool $prependNoteUrl Prepend note permalinks with the server's URL + * @param string $indexUrl Absolute URL of the Shaarli index page * - * @throws Exception Invalid export selection + * @return array The bookmarks to be exported, with additional fields + *@throws Exception Invalid export selection * - * @return array The links to be exported, with additional fields */ - public static function filterAndFormat($linkDb, $selection, $prependNoteUrl, $indexUrl) - { + public static function filterAndFormat( + $bookmarkService, + $formatter, + $selection, + $prependNoteUrl, + $indexUrl + ) { // see tpl/export.html for possible values if (!in_array($selection, array('all', 'public', 'private'))) { throw new Exception(t('Invalid export selection:') . ' "' . $selection . '"'); } $bookmarkLinks = array(); - foreach ($linkDb as $link) { - if ($link['private'] != 0 && $selection == 'public') { - continue; - } - if ($link['private'] == 0 && $selection == 'private') { - continue; - } - $date = $link['created']; - $link['timestamp'] = $date->getTimestamp(); - $link['taglist'] = str_replace(' ', ',', $link['tags']); - - if (is_note($link['url']) && $prependNoteUrl) { + foreach ($bookmarkService->search([], $selection) as $bookmark) { + $link = $formatter->format($bookmark); + $link['taglist'] = implode(',', $bookmark->getTags()); + if ($bookmark->isNote() && $prependNoteUrl) { $link['url'] = $indexUrl . $link['url']; } @@ -69,9 +69,9 @@ class NetscapeBookmarkUtils * * @param string $filename name of the file to import * @param int $filesize size of the file to import - * @param int $importCount how many links were imported - * @param int $overwriteCount how many links were overwritten - * @param int $skipCount how many links were skipped + * @param int $importCount how many bookmarks were imported + * @param int $overwriteCount how many bookmarks were overwritten + * @param int $skipCount how many bookmarks were skipped * @param int $duration how many seconds did the import take * * @return string Summary of the bookmark import status @@ -91,7 +91,7 @@ class NetscapeBookmarkUtils $status .= vsprintf( t( 'was successfully processed in %d seconds: ' - . '%d links imported, %d links overwritten, %d links skipped.' + . '%d bookmarks imported, %d bookmarks overwritten, %d bookmarks skipped.' ), [$duration, $importCount, $overwriteCount, $skipCount] ); @@ -102,15 +102,15 @@ class NetscapeBookmarkUtils /** * Imports Web bookmarks from an uploaded Netscape bookmark dump * - * @param array $post Server $_POST parameters - * @param array $files Server $_FILES parameters - * @param LinkDB $linkDb Loaded LinkDB instance - * @param ConfigManager $conf instance - * @param History $history History instance + * @param array $post Server $_POST parameters + * @param array $files Server $_FILES parameters + * @param BookmarkServiceInterface $bookmarkService Loaded LinkDB instance + * @param ConfigManager $conf instance + * @param History $history History instance * * @return string Summary of the bookmark import status */ - public static function import($post, $files, $linkDb, $conf, $history) + public static function import($post, $files, $bookmarkService, $conf, $history) { $start = time(); $filename = $files['filetoupload']['name']; @@ -121,10 +121,10 @@ class NetscapeBookmarkUtils return self::importStatus($filename, $filesize); } - // Overwrite existing links? + // Overwrite existing bookmarks? $overwrite = !empty($post['overwrite']); - // Add tags to all imported links? + // Add tags to all imported bookmarks? if (empty($post['default_tags'])) { $defaultTags = array(); } else { @@ -134,7 +134,7 @@ class NetscapeBookmarkUtils ); } - // links are imported as public by default + // bookmarks are imported as public by default $defaultPrivacy = 0; $parser = new NetscapeBookmarkParser( @@ -164,22 +164,18 @@ class NetscapeBookmarkUtils // use value from the imported file $private = $bkm['pub'] == '1' ? 0 : 1; } elseif ($post['privacy'] == 'private') { - // all imported links are private + // all imported bookmarks are private $private = 1; } elseif ($post['privacy'] == 'public') { - // all imported links are public + // all imported bookmarks are public $private = 0; } - $newLink = array( - 'title' => $bkm['title'], - 'url' => $bkm['uri'], - 'description' => $bkm['note'], - 'private' => $private, - 'tags' => $bkm['tags'] - ); - - $existingLink = $linkDb->getLinkFromUrl($bkm['uri']); + $link = $bookmarkService->findByUrl($bkm['uri']); + $existingLink = $link !== null; + if (! $existingLink) { + $link = new Bookmark(); + } if ($existingLink !== false) { if ($overwrite === false) { @@ -188,28 +184,25 @@ class NetscapeBookmarkUtils continue; } - // Overwrite an existing link, keep its date - $newLink['id'] = $existingLink['id']; - $newLink['created'] = $existingLink['created']; - $newLink['updated'] = new DateTime(); - $newLink['shorturl'] = $existingLink['shorturl']; - $linkDb[$existingLink['id']] = $newLink; - $importCount++; + $link->setUpdated(new DateTime()); $overwriteCount++; - continue; + } else { + $newLinkDate = new DateTime('@' . strval($bkm['time'])); + $newLinkDate->setTimezone(new DateTimeZone(date_default_timezone_get())); + $link->setCreated($newLinkDate); } - // Add a new link - @ used for UNIX timestamps - $newLinkDate = new DateTime('@' . strval($bkm['time'])); - $newLinkDate->setTimezone(new DateTimeZone(date_default_timezone_get())); - $newLink['created'] = $newLinkDate; - $newLink['id'] = $linkDb->getNextId(); - $newLink['shorturl'] = link_small_hash($newLink['created'], $newLink['id']); - $linkDb[$newLink['id']] = $newLink; + $link->setTitle($bkm['title']); + $link->setUrl($bkm['uri'], $conf->get('security.allowed_protocols')); + $link->setDescription($bkm['note']); + $link->setPrivate($private); + $link->setTagsString($bkm['tags']); + + $bookmarkService->addOrSet($link, false); $importCount++; } - $linkDb->save($conf->get('resource.page_cache')); + $bookmarkService->save(); $history->importLinks(); $duration = time() - $start; diff --git a/application/render/PageBuilder.php b/application/render/PageBuilder.php index 3f86fc26..65e85aaf 100644 --- a/application/render/PageBuilder.php +++ b/application/render/PageBuilder.php @@ -5,7 +5,7 @@ namespace Shaarli\Render; use Exception; use RainTPL; use Shaarli\ApplicationUtils; -use Shaarli\Bookmark\LinkDB; +use Shaarli\Bookmark\BookmarkServiceInterface; use Shaarli\Config\ConfigManager; use Shaarli\Thumbnailer; @@ -34,9 +34,9 @@ class PageBuilder protected $session; /** - * @var LinkDB $linkDB instance. + * @var BookmarkServiceInterface $bookmarkService instance. */ - protected $linkDB; + protected $bookmarkService; /** * @var null|string XSRF token @@ -52,18 +52,18 @@ class PageBuilder * PageBuilder constructor. * $tpl is initialized at false for lazy loading. * - * @param ConfigManager $conf Configuration Manager instance (reference). - * @param array $session $_SESSION array - * @param LinkDB $linkDB instance. - * @param string $token Session token - * @param bool $isLoggedIn + * @param ConfigManager $conf Configuration Manager instance (reference). + * @param array $session $_SESSION array + * @param BookmarkServiceInterface $linkDB instance. + * @param string $token Session token + * @param bool $isLoggedIn */ public function __construct(&$conf, $session, $linkDB = null, $token = null, $isLoggedIn = false) { $this->tpl = false; $this->conf = $conf; $this->session = $session; - $this->linkDB = $linkDB; + $this->bookmarkService = $linkDB; $this->token = $token; $this->isLoggedIn = $isLoggedIn; } @@ -125,8 +125,8 @@ class PageBuilder $this->tpl->assign('language', $this->conf->get('translation.language')); - if ($this->linkDB !== null) { - $this->tpl->assign('tags', $this->linkDB->linksCountPerTag()); + if ($this->bookmarkService !== null) { + $this->tpl->assign('tags', $this->bookmarkService->bookmarksCountPerTag()); } $this->tpl->assign( @@ -141,6 +141,8 @@ class PageBuilder unset($_SESSION['warnings']); } + $this->tpl->assign('formatter', $this->conf->get('formatter', 'default')); + // To be removed with a proper theme configuration. $this->tpl->assign('conf', $this->conf); } diff --git a/application/updater/Updater.php b/application/updater/Updater.php index beb9ea9b..95654d81 100644 --- a/application/updater/Updater.php +++ b/application/updater/Updater.php @@ -2,25 +2,14 @@ namespace Shaarli\Updater; -use Exception; -use RainTPL; -use ReflectionClass; -use ReflectionException; -use ReflectionMethod; -use Shaarli\ApplicationUtils; -use Shaarli\Bookmark\LinkDB; -use Shaarli\Bookmark\LinkFilter; -use Shaarli\Config\ConfigJson; use Shaarli\Config\ConfigManager; -use Shaarli\Config\ConfigPhp; -use Shaarli\Exceptions\IOException; -use Shaarli\Thumbnailer; +use Shaarli\Bookmark\BookmarkServiceInterface; use Shaarli\Updater\Exception\UpdaterException; /** - * Class updater. + * Class Updater. * Used to update stuff when a new Shaarli's version is reached. - * Update methods are ran only once, and the stored in a JSON file. + * Update methods are ran only once, and the stored in a TXT file. */ class Updater { @@ -30,9 +19,9 @@ class Updater protected $doneUpdates; /** - * @var LinkDB instance. + * @var BookmarkServiceInterface instance. */ - protected $linkDB; + protected $linkServices; /** * @var ConfigManager $conf Configuration Manager instance. @@ -45,36 +34,27 @@ class Updater protected $isLoggedIn; /** - * @var array $_SESSION - */ - protected $session; - - /** - * @var ReflectionMethod[] List of current class methods. + * @var \ReflectionMethod[] List of current class methods. */ protected $methods; /** * Object constructor. * - * @param array $doneUpdates Updates which are already done. - * @param LinkDB $linkDB LinkDB instance. - * @param ConfigManager $conf Configuration Manager instance. - * @param boolean $isLoggedIn True if the user is logged in. - * @param array $session $_SESSION (by reference) - * - * @throws ReflectionException + * @param array $doneUpdates Updates which are already done. + * @param BookmarkServiceInterface $linkDB LinksService instance. + * @param ConfigManager $conf Configuration Manager instance. + * @param boolean $isLoggedIn True if the user is logged in. */ - public function __construct($doneUpdates, $linkDB, $conf, $isLoggedIn, &$session = []) + public function __construct($doneUpdates, $linkDB, $conf, $isLoggedIn) { $this->doneUpdates = $doneUpdates; - $this->linkDB = $linkDB; + $this->linkServices = $linkDB; $this->conf = $conf; $this->isLoggedIn = $isLoggedIn; - $this->session = &$session; // Retrieve all update methods. - $class = new ReflectionClass($this); + $class = new \ReflectionClass($this); $this->methods = $class->getMethods(); } @@ -96,12 +76,12 @@ class Updater } if ($this->methods === null) { - throw new UpdaterException(t('Couldn\'t retrieve updater class methods.')); + throw new UpdaterException('Couldn\'t retrieve LegacyUpdater class methods.'); } foreach ($this->methods as $method) { // Not an update method or already done, pass. - if (!startsWith($method->getName(), 'updateMethod') + if (! startsWith($method->getName(), 'updateMethod') || in_array($method->getName(), $this->doneUpdates) ) { continue; @@ -114,7 +94,7 @@ class Updater if ($res === true) { $updatesRan[] = $method->getName(); } - } catch (Exception $e) { + } catch (\Exception $e) { throw new UpdaterException($method, $e); } } @@ -131,432 +111,4 @@ class Updater { return $this->doneUpdates; } - - /** - * Move deprecated options.php to config.php. - * - * Milestone 0.9 (old versioning) - shaarli/Shaarli#41: - * options.php is not supported anymore. - */ - public function updateMethodMergeDeprecatedConfigFile() - { - if (is_file($this->conf->get('resource.data_dir') . '/options.php')) { - include $this->conf->get('resource.data_dir') . '/options.php'; - - // Load GLOBALS into config - $allowedKeys = array_merge(ConfigPhp::$ROOT_KEYS); - $allowedKeys[] = 'config'; - foreach ($GLOBALS as $key => $value) { - if (in_array($key, $allowedKeys)) { - $this->conf->set($key, $value); - } - } - $this->conf->write($this->isLoggedIn); - unlink($this->conf->get('resource.data_dir') . '/options.php'); - } - - return true; - } - - /** - * Move old configuration in PHP to the new config system in JSON format. - * - * Will rename 'config.php' into 'config.save.php' and create 'config.json.php'. - * It will also convert legacy setting keys to the new ones. - */ - public function updateMethodConfigToJson() - { - // JSON config already exists, nothing to do. - if ($this->conf->getConfigIO() instanceof ConfigJson) { - return true; - } - - $configPhp = new ConfigPhp(); - $configJson = new ConfigJson(); - $oldConfig = $configPhp->read($this->conf->getConfigFile() . '.php'); - rename($this->conf->getConfigFileExt(), $this->conf->getConfigFile() . '.save.php'); - $this->conf->setConfigIO($configJson); - $this->conf->reload(); - - $legacyMap = array_flip(ConfigPhp::$LEGACY_KEYS_MAPPING); - foreach (ConfigPhp::$ROOT_KEYS as $key) { - $this->conf->set($legacyMap[$key], $oldConfig[$key]); - } - - // Set sub config keys (config and plugins) - $subConfig = array('config', 'plugins'); - foreach ($subConfig as $sub) { - foreach ($oldConfig[$sub] as $key => $value) { - if (isset($legacyMap[$sub . '.' . $key])) { - $configKey = $legacyMap[$sub . '.' . $key]; - } else { - $configKey = $sub . '.' . $key; - } - $this->conf->set($configKey, $value); - } - } - - try { - $this->conf->write($this->isLoggedIn); - return true; - } catch (IOException $e) { - error_log($e->getMessage()); - return false; - } - } - - /** - * Escape settings which have been manually escaped in every request in previous versions: - * - general.title - * - general.header_link - * - redirector.url - * - * @return bool true if the update is successful, false otherwise. - */ - public function updateMethodEscapeUnescapedConfig() - { - try { - $this->conf->set('general.title', escape($this->conf->get('general.title'))); - $this->conf->set('general.header_link', escape($this->conf->get('general.header_link'))); - $this->conf->write($this->isLoggedIn); - } catch (Exception $e) { - error_log($e->getMessage()); - return false; - } - return true; - } - - /** - * Update the database to use the new ID system, which replaces linkdate primary keys. - * Also, creation and update dates are now DateTime objects (done by LinkDB). - * - * Since this update is very sensitve (changing the whole database), the datastore will be - * automatically backed up into the file datastore..php. - * - * LinkDB also adds the field 'shorturl' with the precedent format (linkdate smallhash), - * which will be saved by this method. - * - * @return bool true if the update is successful, false otherwise. - */ - public function updateMethodDatastoreIds() - { - // up to date database - if (isset($this->linkDB[0])) { - return true; - } - - $save = $this->conf->get('resource.data_dir') . '/datastore.' . date('YmdHis') . '.php'; - copy($this->conf->get('resource.datastore'), $save); - - $links = array(); - foreach ($this->linkDB as $offset => $value) { - $links[] = $value; - unset($this->linkDB[$offset]); - } - $links = array_reverse($links); - $cpt = 0; - foreach ($links as $l) { - unset($l['linkdate']); - $l['id'] = $cpt; - $this->linkDB[$cpt++] = $l; - } - - $this->linkDB->save($this->conf->get('resource.page_cache')); - $this->linkDB->reorder(); - - return true; - } - - /** - * Rename tags starting with a '-' to work with tag exclusion search. - */ - public function updateMethodRenameDashTags() - { - $linklist = $this->linkDB->filterSearch(); - foreach ($linklist as $key => $link) { - $link['tags'] = preg_replace('/(^| )\-/', '$1', $link['tags']); - $link['tags'] = implode(' ', array_unique(LinkFilter::tagsStrToArray($link['tags'], true))); - $this->linkDB[$key] = $link; - } - $this->linkDB->save($this->conf->get('resource.page_cache')); - return true; - } - - /** - * Initialize API settings: - * - api.enabled: true - * - api.secret: generated secret - */ - public function updateMethodApiSettings() - { - if ($this->conf->exists('api.secret')) { - return true; - } - - $this->conf->set('api.enabled', true); - $this->conf->set( - 'api.secret', - generate_api_secret( - $this->conf->get('credentials.login'), - $this->conf->get('credentials.salt') - ) - ); - $this->conf->write($this->isLoggedIn); - return true; - } - - /** - * New setting: theme name. If the default theme is used, nothing to do. - * - * If the user uses a custom theme, raintpl_tpl dir is updated to the parent directory, - * and the current theme is set as default in the theme setting. - * - * @return bool true if the update is successful, false otherwise. - */ - public function updateMethodDefaultTheme() - { - // raintpl_tpl isn't the root template directory anymore. - // We run the update only if this folder still contains the template files. - $tplDir = $this->conf->get('resource.raintpl_tpl'); - $tplFile = $tplDir . '/linklist.html'; - if (!file_exists($tplFile)) { - return true; - } - - $parent = dirname($tplDir); - $this->conf->set('resource.raintpl_tpl', $parent); - $this->conf->set('resource.theme', trim(str_replace($parent, '', $tplDir), '/')); - $this->conf->write($this->isLoggedIn); - - // Dependency injection gore - RainTPL::$tpl_dir = $tplDir; - - return true; - } - - /** - * Move the file to inc/user.css to data/user.css. - * - * Note: Due to hardcoded paths, it's not unit testable. But one line of code should be fine. - * - * @return bool true if the update is successful, false otherwise. - */ - public function updateMethodMoveUserCss() - { - if (!is_file('inc/user.css')) { - return true; - } - - return rename('inc/user.css', 'data/user.css'); - } - - /** - * * `markdown_escape` is a new setting, set to true as default. - * - * If the markdown plugin was already enabled, escaping is disabled to avoid - * breaking existing entries. - */ - public function updateMethodEscapeMarkdown() - { - if ($this->conf->exists('security.markdown_escape')) { - return true; - } - - if (in_array('markdown', $this->conf->get('general.enabled_plugins'))) { - $this->conf->set('security.markdown_escape', false); - } else { - $this->conf->set('security.markdown_escape', true); - } - $this->conf->write($this->isLoggedIn); - - return true; - } - - /** - * Add 'http://' to Piwik URL the setting is set. - * - * @return bool true if the update is successful, false otherwise. - */ - public function updateMethodPiwikUrl() - { - if (!$this->conf->exists('plugins.PIWIK_URL') || startsWith($this->conf->get('plugins.PIWIK_URL'), 'http')) { - return true; - } - - $this->conf->set('plugins.PIWIK_URL', 'http://' . $this->conf->get('plugins.PIWIK_URL')); - $this->conf->write($this->isLoggedIn); - - return true; - } - - /** - * Use ATOM feed as default. - */ - public function updateMethodAtomDefault() - { - if (!$this->conf->exists('feed.show_atom') || $this->conf->get('feed.show_atom') === true) { - return true; - } - - $this->conf->set('feed.show_atom', true); - $this->conf->write($this->isLoggedIn); - - return true; - } - - /** - * Update updates.check_updates_branch setting. - * - * If the current major version digit matches the latest branch - * major version digit, we set the branch to `latest`, - * otherwise we'll check updates on the `stable` branch. - * - * No update required for the dev version. - * - * Note: due to hardcoded URL and lack of dependency injection, this is not unit testable. - * - * FIXME! This needs to be removed when we switch to first digit major version - * instead of the second one since the versionning process will change. - */ - public function updateMethodCheckUpdateRemoteBranch() - { - if (SHAARLI_VERSION === 'dev' || $this->conf->get('updates.check_updates_branch') === 'latest') { - return true; - } - - // Get latest branch major version digit - $latestVersion = ApplicationUtils::getLatestGitVersionCode( - 'https://raw.githubusercontent.com/shaarli/Shaarli/latest/shaarli_version.php', - 5 - ); - if (preg_match('/(\d+)\.\d+$/', $latestVersion, $matches) === false) { - return false; - } - $latestMajor = $matches[1]; - - // Get current major version digit - preg_match('/(\d+)\.\d+$/', SHAARLI_VERSION, $matches); - $currentMajor = $matches[1]; - - if ($currentMajor === $latestMajor) { - $branch = 'latest'; - } else { - $branch = 'stable'; - } - $this->conf->set('updates.check_updates_branch', $branch); - $this->conf->write($this->isLoggedIn); - return true; - } - - /** - * Reset history store file due to date format change. - */ - public function updateMethodResetHistoryFile() - { - if (is_file($this->conf->get('resource.history'))) { - unlink($this->conf->get('resource.history')); - } - return true; - } - - /** - * Save the datastore -> the link order is now applied when links are saved. - */ - public function updateMethodReorderDatastore() - { - $this->linkDB->save($this->conf->get('resource.page_cache')); - return true; - } - - /** - * Change privateonly session key to visibility. - */ - public function updateMethodVisibilitySession() - { - if (isset($_SESSION['privateonly'])) { - unset($_SESSION['privateonly']); - $_SESSION['visibility'] = 'private'; - } - return true; - } - - /** - * Add download size and timeout to the configuration file - * - * @return bool true if the update is successful, false otherwise. - */ - public function updateMethodDownloadSizeAndTimeoutConf() - { - if ($this->conf->exists('general.download_max_size') - && $this->conf->exists('general.download_timeout') - ) { - return true; - } - - if (!$this->conf->exists('general.download_max_size')) { - $this->conf->set('general.download_max_size', 1024 * 1024 * 4); - } - - if (!$this->conf->exists('general.download_timeout')) { - $this->conf->set('general.download_timeout', 30); - } - - $this->conf->write($this->isLoggedIn); - return true; - } - - /** - * * Move thumbnails management to WebThumbnailer, coming with new settings. - */ - public function updateMethodWebThumbnailer() - { - if ($this->conf->exists('thumbnails.mode')) { - return true; - } - - $thumbnailsEnabled = extension_loaded('gd') && $this->conf->get('thumbnail.enable_thumbnails', true); - $this->conf->set('thumbnails.mode', $thumbnailsEnabled ? Thumbnailer::MODE_ALL : Thumbnailer::MODE_NONE); - $this->conf->set('thumbnails.width', 125); - $this->conf->set('thumbnails.height', 90); - $this->conf->remove('thumbnail'); - $this->conf->write(true); - - if ($thumbnailsEnabled) { - $this->session['warnings'][] = t( - 'You have enabled or changed thumbnails mode. Please synchronize them.' - ); - } - - return true; - } - - /** - * Set sticky = false on all links - * - * @return bool true if the update is successful, false otherwise. - */ - public function updateMethodSetSticky() - { - foreach ($this->linkDB as $key => $link) { - if (isset($link['sticky'])) { - return true; - } - $link['sticky'] = false; - $this->linkDB[$key] = $link; - } - - $this->linkDB->save($this->conf->get('resource.page_cache')); - - return true; - } - - /** - * Remove redirector settings. - */ - public function updateMethodRemoveRedirector() - { - $this->conf->remove('redirector'); - $this->conf->write(true); - return true; - } } diff --git a/application/updater/UpdaterUtils.php b/application/updater/UpdaterUtils.php index 34d4f422..828a49fc 100644 --- a/application/updater/UpdaterUtils.php +++ b/application/updater/UpdaterUtils.php @@ -1,39 +1,44 @@ Date: Fri, 17 Jan 2020 21:34:12 +0100 Subject: Add and update unit test for the new system (Bookmark + Service) See #1307 --- application/bookmark/Bookmark.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'application') diff --git a/application/bookmark/Bookmark.php b/application/bookmark/Bookmark.php index b08e5d67..f9b21d3d 100644 --- a/application/bookmark/Bookmark.php +++ b/application/bookmark/Bookmark.php @@ -65,8 +65,8 @@ class Bookmark $this->url = $data['url']; $this->title = $data['title']; $this->description = $data['description']; - $this->thumbnail = ! empty($data['thumbnail']) ? $data['thumbnail'] : null; - $this->sticky = ! empty($data['sticky']) ? $data['sticky'] : false; + $this->thumbnail = isset($data['thumbnail']) ? $data['thumbnail'] : null; + $this->sticky = isset($data['sticky']) ? $data['sticky'] : false; $this->created = $data['created']; if (is_array($data['tags'])) { $this->tags = $data['tags']; -- cgit v1.2.3 From a39acb2518f272df8a601af72c13eabe2719dcb8 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Sat, 18 Jan 2020 11:33:23 +0100 Subject: Fix an issue with private tags and fix nomarkdown tag The new bookmark service wasn't handling private tags properly. nomarkdown tag is now shown only for logged in user in bookmarks, and hidden for everyone in tag clouds/lists. Fixes #726 --- application/bookmark/BookmarkFileService.php | 9 ++++-- application/formatter/BookmarkDefaultFormatter.php | 2 +- application/formatter/BookmarkFormatter.php | 35 ++++++++++++++++++++-- .../formatter/BookmarkMarkdownFormatter.php | 8 +++-- application/formatter/FormatterFactory.php | 11 +++++-- 5 files changed, 53 insertions(+), 12 deletions(-) (limited to 'application') diff --git a/application/bookmark/BookmarkFileService.php b/application/bookmark/BookmarkFileService.php index a56cc92b..9c59e139 100644 --- a/application/bookmark/BookmarkFileService.php +++ b/application/bookmark/BookmarkFileService.php @@ -8,6 +8,7 @@ use Exception; use Shaarli\Bookmark\Exception\BookmarkNotFoundException; use Shaarli\Bookmark\Exception\EmptyDataStoreException; use Shaarli\Config\ConfigManager; +use Shaarli\Formatter\BookmarkMarkdownFormatter; use Shaarli\History; use Shaarli\Legacy\LegacyLinkDB; use Shaarli\Legacy\LegacyUpdater; @@ -130,7 +131,7 @@ class BookmarkFileService implements BookmarkServiceInterface } if ($visibility === null) { - $visibility = $this->isLoggedIn ? 'all' : 'public'; + $visibility = $this->isLoggedIn ? BookmarkFilter::$ALL : BookmarkFilter::$PUBLIC; } $bookmark = $this->bookmarks[$id]; @@ -287,9 +288,13 @@ class BookmarkFileService implements BookmarkServiceInterface $caseMapping = []; foreach ($bookmarks as $bookmark) { foreach ($bookmark->getTags() as $tag) { - if (empty($tag) || (! $this->isLoggedIn && startsWith($tag, '.'))) { + if (empty($tag) + || (! $this->isLoggedIn && startsWith($tag, '.')) + || $tag === BookmarkMarkdownFormatter::NO_MD_TAG + ) { continue; } + // The first case found will be displayed. if (!isset($caseMapping[strtolower($tag)])) { $caseMapping[strtolower($tag)] = $tag; diff --git a/application/formatter/BookmarkDefaultFormatter.php b/application/formatter/BookmarkDefaultFormatter.php index 7550c556..c6c59064 100644 --- a/application/formatter/BookmarkDefaultFormatter.php +++ b/application/formatter/BookmarkDefaultFormatter.php @@ -34,7 +34,7 @@ class BookmarkDefaultFormatter extends BookmarkFormatter */ protected function formatTagList($bookmark) { - return escape($bookmark->getTags()); + return escape(parent::formatTagList($bookmark)); } /** diff --git a/application/formatter/BookmarkFormatter.php b/application/formatter/BookmarkFormatter.php index c82c3452..a80d83fc 100644 --- a/application/formatter/BookmarkFormatter.php +++ b/application/formatter/BookmarkFormatter.php @@ -20,6 +20,9 @@ abstract class BookmarkFormatter */ protected $conf; + /** @var bool */ + protected $isLoggedIn; + /** * @var array Additional parameters than can be used for specific formatting * e.g. index_url for Feed formatting @@ -30,9 +33,10 @@ abstract class BookmarkFormatter * LinkDefaultFormatter constructor. * @param ConfigManager $conf */ - public function __construct(ConfigManager $conf) + public function __construct(ConfigManager $conf, bool $isLoggedIn) { $this->conf = $conf; + $this->isLoggedIn = $isLoggedIn; } /** @@ -172,7 +176,7 @@ abstract class BookmarkFormatter */ protected function formatTagList($bookmark) { - return $bookmark->getTags(); + return $this->filterTagList($bookmark->getTags()); } /** @@ -184,7 +188,7 @@ abstract class BookmarkFormatter */ protected function formatTagString($bookmark) { - return implode(' ', $bookmark->getTags()); + return implode(' ', $this->formatTagList($bookmark)); } /** @@ -253,4 +257,29 @@ abstract class BookmarkFormatter } return 0; } + + /** + * Format tag list, e.g. remove private tags if the user is not logged in. + * + * @param array $tags + * + * @return array + */ + protected function filterTagList(array $tags): array + { + if ($this->isLoggedIn === true) { + return $tags; + } + + $out = []; + foreach ($tags as $tag) { + if (strpos($tag, '.') === 0) { + continue; + } + + $out[] = $tag; + } + + return $out; + } } diff --git a/application/formatter/BookmarkMarkdownFormatter.php b/application/formatter/BookmarkMarkdownFormatter.php index 7797bfbf..077e5312 100644 --- a/application/formatter/BookmarkMarkdownFormatter.php +++ b/application/formatter/BookmarkMarkdownFormatter.php @@ -36,10 +36,12 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter * LinkMarkdownFormatter constructor. * * @param ConfigManager $conf instance + * @param bool $isLoggedIn */ - public function __construct(ConfigManager $conf) + public function __construct(ConfigManager $conf, bool $isLoggedIn) { - parent::__construct($conf); + parent::__construct($conf, $isLoggedIn); + $this->parsedown = new \Parsedown(); $this->escape = $conf->get('security.markdown_escape', true); $this->allowedProtocols = $conf->get('security.allowed_protocols', []); @@ -79,7 +81,7 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter protected function formatTagList($bookmark) { $out = parent::formatTagList($bookmark); - if (($pos = array_search(self::NO_MD_TAG, $out)) !== false) { + if ($this->isLoggedIn === false && ($pos = array_search(self::NO_MD_TAG, $out)) !== false) { unset($out[$pos]); return array_values($out); } diff --git a/application/formatter/FormatterFactory.php b/application/formatter/FormatterFactory.php index 0d2c0466..5f282f68 100644 --- a/application/formatter/FormatterFactory.php +++ b/application/formatter/FormatterFactory.php @@ -16,14 +16,19 @@ class FormatterFactory /** @var ConfigManager instance */ protected $conf; + /** @var bool */ + protected $isLoggedIn; + /** * FormatterFactory constructor. * * @param ConfigManager $conf + * @param bool $isLoggedIn */ - public function __construct(ConfigManager $conf) + public function __construct(ConfigManager $conf, bool $isLoggedIn) { $this->conf = $conf; + $this->isLoggedIn = $isLoggedIn; } /** @@ -33,7 +38,7 @@ class FormatterFactory * * @return BookmarkFormatter instance. */ - public function getFormatter($type = null) + public function getFormatter(string $type = null) { $type = $type ? $type : $this->conf->get('formatter', 'default'); $className = '\\Shaarli\\Formatter\\Bookmark'. ucfirst($type) .'Formatter'; @@ -41,6 +46,6 @@ class FormatterFactory $className = '\\Shaarli\\Formatter\\BookmarkDefaultFormatter'; } - return new $className($this->conf); + return new $className($this->conf, $this->isLoggedIn); } } -- cgit v1.2.3 From 6c50a6ccceecf54850e62c312ab2397b84d89ab4 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Sat, 18 Jan 2020 17:50:11 +0100 Subject: Render login page through Slim controller --- application/container/ContainerBuilder.php | 77 ++++++++++++++++++++++ application/container/ShaarliContainer.php | 28 ++++++++ application/front/ShaarliMiddleware.php | 57 ++++++++++++++++ application/front/controllers/LoginController.php | 46 +++++++++++++ .../front/controllers/ShaarliController.php | 31 +++++++++ .../front/exceptions/LoginBannedException.php | 15 +++++ application/front/exceptions/ShaarliException.php | 23 +++++++ application/render/PageBuilder.php | 17 +++++ application/security/SessionManager.php | 6 ++ 9 files changed, 300 insertions(+) create mode 100644 application/container/ContainerBuilder.php create mode 100644 application/container/ShaarliContainer.php create mode 100644 application/front/ShaarliMiddleware.php create mode 100644 application/front/controllers/LoginController.php create mode 100644 application/front/controllers/ShaarliController.php create mode 100644 application/front/exceptions/LoginBannedException.php create mode 100644 application/front/exceptions/ShaarliException.php (limited to 'application') diff --git a/application/container/ContainerBuilder.php b/application/container/ContainerBuilder.php new file mode 100644 index 00000000..ff29825c --- /dev/null +++ b/application/container/ContainerBuilder.php @@ -0,0 +1,77 @@ +conf = $conf; + $this->session = $session; + $this->login = $login; + } + + public function build(): ShaarliContainer + { + $container = new ShaarliContainer(); + $container['conf'] = $this->conf; + $container['sessionManager'] = $this->session; + $container['loginManager'] = $this->login; + $container['plugins'] = function (ShaarliContainer $container): PluginManager { + return new PluginManager($container->conf); + }; + + $container['history'] = function (ShaarliContainer $container): History { + return new History($container->conf->get('resource.history')); + }; + + $container['bookmarkService'] = function (ShaarliContainer $container): BookmarkServiceInterface { + return new BookmarkFileService( + $container->conf, + $container->history, + $container->loginManager->isLoggedIn() + ); + }; + + $container['pageBuilder'] = function (ShaarliContainer $container): PageBuilder { + return new PageBuilder( + $container->conf, + $container->sessionManager->getSession(), + $container->bookmarkService, + $container->sessionManager->generateToken(), + $container->loginManager->isLoggedIn() + ); + }; + + return $container; + } +} diff --git a/application/container/ShaarliContainer.php b/application/container/ShaarliContainer.php new file mode 100644 index 00000000..f5483d5e --- /dev/null +++ b/application/container/ShaarliContainer.php @@ -0,0 +1,28 @@ +container = $container; + } + + /** + * Middleware execution: + * - execute the controller + * - return the response + * + * In case of error, the error template will be displayed with the exception message. + * + * @param Request $request Slim request + * @param Response $response Slim response + * @param callable $next Next action + * + * @return Response response. + */ + public function __invoke(Request $request, Response $response, callable $next) + { + try { + $response = $next($request, $response); + } catch (ShaarliException $e) { + $this->container->pageBuilder->assign('message', $e->getMessage()); + if ($this->container->conf->get('dev.debug', false)) { + $this->container->pageBuilder->assign( + 'stacktrace', + nl2br(get_class($this) .': '. $e->getTraceAsString()) + ); + } + + $response = $response->withStatus($e->getCode()); + $response = $response->write($this->container->pageBuilder->render('error')); + } + + return $response; + } +} diff --git a/application/front/controllers/LoginController.php b/application/front/controllers/LoginController.php new file mode 100644 index 00000000..47fa3ee3 --- /dev/null +++ b/application/front/controllers/LoginController.php @@ -0,0 +1,46 @@ +ci->loginManager->isLoggedIn() || $this->ci->conf->get('security.open_shaarli', false)) { + return $response->withRedirect('./'); + } + + $userCanLogin = $this->ci->loginManager->canLogin($request->getServerParams()); + if ($userCanLogin !== true) { + throw new LoginBannedException(); + } + + if ($request->getParam('username') !== null) { + $this->assignView('username', escape($request->getParam('username'))); + } + + $this + ->assignView('returnurl', escape($request->getServerParam('HTTP_REFERER'))) + ->assignView('remember_user_default', $this->ci->conf->get('privacy.remember_user_default', true)) + ->assignView('pagetitle', t('Login') .' - '. $this->ci->conf->get('general.title', 'Shaarli')) + ; + + return $response->write($this->ci->pageBuilder->render('loginform')); + } +} diff --git a/application/front/controllers/ShaarliController.php b/application/front/controllers/ShaarliController.php new file mode 100644 index 00000000..2a166c3c --- /dev/null +++ b/application/front/controllers/ShaarliController.php @@ -0,0 +1,31 @@ +ci = $ci; + } + + /** + * Assign variables to RainTPL template through the PageBuilder. + * + * @param mixed $value Value to assign to the template + */ + protected function assignView(string $name, $value): self + { + $this->ci->pageBuilder->assign($name, $value); + + return $this; + } +} diff --git a/application/front/exceptions/LoginBannedException.php b/application/front/exceptions/LoginBannedException.php new file mode 100644 index 00000000..b31a4a14 --- /dev/null +++ b/application/front/exceptions/LoginBannedException.php @@ -0,0 +1,15 @@ +tpl->draw($page); } + /** + * Render a specific page as string (using a template file). + * e.g. $pb->render('picwall'); + * + * @param string $page Template filename (without extension). + * + * @return string Processed template content + */ + public function render(string $page): string + { + if ($this->tpl === false) { + $this->initialize(); + } + + return $this->tpl->draw($page, true); + } + /** * Render a 404 page (uses the template : tpl/404.tpl) * usage: $PAGE->render404('The link was deleted') diff --git a/application/security/SessionManager.php b/application/security/SessionManager.php index b8b8ab8d..994fcbe5 100644 --- a/application/security/SessionManager.php +++ b/application/security/SessionManager.php @@ -196,4 +196,10 @@ class SessionManager } return true; } + + /** @return array Local reference to the global $_SESSION array */ + public function getSession(): array + { + return $this->session; + } } -- cgit v1.2.3 From 9e4cc28e2957e1f7df713d52a03e350d728dc58e Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Thu, 23 Jan 2020 20:05:41 +0100 Subject: Fix all existing links and redirection to ?do=login --- application/Utils.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'application') diff --git a/application/Utils.php b/application/Utils.php index 56f5b9a2..4b7fc546 100644 --- a/application/Utils.php +++ b/application/Utils.php @@ -159,7 +159,7 @@ function checkDateFormat($format, $string) */ function generateLocation($referer, $host, $loopTerms = array()) { - $finalReferer = '?'; + $finalReferer = './?'; // No referer if it contains any value in $loopCriteria. foreach (array_filter($loopTerms) as $value) { -- cgit v1.2.3 From 0498b209b551cad5595312583e5d6fb1bc3303a5 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Thu, 23 Jan 2020 20:06:32 +0100 Subject: Execute common plugin hooks before rendering login page --- application/container/ContainerBuilder.php | 4 +++ application/container/ShaarliContainer.php | 2 ++ application/front/controllers/LoginController.php | 2 +- .../front/controllers/ShaarliController.php | 38 ++++++++++++++++++++++ 4 files changed, 45 insertions(+), 1 deletion(-) (limited to 'application') diff --git a/application/container/ContainerBuilder.php b/application/container/ContainerBuilder.php index ff29825c..e2c78ccc 100644 --- a/application/container/ContainerBuilder.php +++ b/application/container/ContainerBuilder.php @@ -72,6 +72,10 @@ class ContainerBuilder ); }; + $container['pluginManager'] = function (ShaarliContainer $container): PluginManager { + return new PluginManager($container->conf); + }; + return $container; } } diff --git a/application/container/ShaarliContainer.php b/application/container/ShaarliContainer.php index f5483d5e..3fa9116e 100644 --- a/application/container/ShaarliContainer.php +++ b/application/container/ShaarliContainer.php @@ -7,6 +7,7 @@ namespace Shaarli\Container; use Shaarli\Bookmark\BookmarkServiceInterface; use Shaarli\Config\ConfigManager; use Shaarli\History; +use Shaarli\Plugin\PluginManager; use Shaarli\Render\PageBuilder; use Shaarli\Security\LoginManager; use Shaarli\Security\SessionManager; @@ -21,6 +22,7 @@ use Slim\Container; * @property History $history * @property BookmarkServiceInterface $bookmarkService * @property PageBuilder $pageBuilder + * @property PluginManager $pluginManager */ class ShaarliContainer extends Container { diff --git a/application/front/controllers/LoginController.php b/application/front/controllers/LoginController.php index 47fa3ee3..23efb592 100644 --- a/application/front/controllers/LoginController.php +++ b/application/front/controllers/LoginController.php @@ -41,6 +41,6 @@ class LoginController extends ShaarliController ->assignView('pagetitle', t('Login') .' - '. $this->ci->conf->get('general.title', 'Shaarli')) ; - return $response->write($this->ci->pageBuilder->render('loginform')); + return $response->write($this->render('loginform')); } } diff --git a/application/front/controllers/ShaarliController.php b/application/front/controllers/ShaarliController.php index 2a166c3c..99e66d53 100644 --- a/application/front/controllers/ShaarliController.php +++ b/application/front/controllers/ShaarliController.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Shaarli\Front\Controller; +use Shaarli\Bookmark\BookmarkFilter; use Shaarli\Container\ShaarliContainer; abstract class ShaarliController @@ -28,4 +29,41 @@ abstract class ShaarliController return $this; } + + protected function render(string $template): string + { + $this->assignView('linkcount', $this->ci->bookmarkService->count(BookmarkFilter::$ALL)); + $this->assignView('privateLinkcount', $this->ci->bookmarkService->count(BookmarkFilter::$PRIVATE)); + $this->assignView('plugin_errors', $this->ci->pluginManager->getErrors()); + + $this->executeDefaultHooks($template); + + return $this->ci->pageBuilder->render($template); + } + + /** + * Call plugin hooks for header, footer and includes, specifying which page will be rendered. + * Then assign generated data to RainTPL. + */ + protected function executeDefaultHooks(string $template): void + { + $common_hooks = [ + 'includes', + 'header', + 'footer', + ]; + + foreach ($common_hooks as $name) { + $plugin_data = []; + $this->ci->pluginManager->executeHooks( + 'render_' . $name, + $plugin_data, + [ + 'target' => $template, + 'loggedin' => $this->ci->loginManager->isLoggedIn() + ] + ); + $this->assignView('plugins_' . $name, $plugin_data); + } + } } -- cgit v1.2.3 From 27ceea2aeeed69b43fef4ebff35ec8004fcc2e45 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Sun, 26 Jan 2020 09:06:13 +0100 Subject: Rename ci attribute to container --- application/front/controllers/LoginController.php | 10 ++++++---- .../front/controllers/ShaarliController.php | 22 +++++++++++----------- 2 files changed, 17 insertions(+), 15 deletions(-) (limited to 'application') diff --git a/application/front/controllers/LoginController.php b/application/front/controllers/LoginController.php index 23efb592..ae3599e0 100644 --- a/application/front/controllers/LoginController.php +++ b/application/front/controllers/LoginController.php @@ -22,11 +22,13 @@ class LoginController extends ShaarliController { public function index(Request $request, Response $response): Response { - if ($this->ci->loginManager->isLoggedIn() || $this->ci->conf->get('security.open_shaarli', false)) { + if ($this->container->loginManager->isLoggedIn() + || $this->container->conf->get('security.open_shaarli', false) + ) { return $response->withRedirect('./'); } - $userCanLogin = $this->ci->loginManager->canLogin($request->getServerParams()); + $userCanLogin = $this->container->loginManager->canLogin($request->getServerParams()); if ($userCanLogin !== true) { throw new LoginBannedException(); } @@ -37,8 +39,8 @@ class LoginController extends ShaarliController $this ->assignView('returnurl', escape($request->getServerParam('HTTP_REFERER'))) - ->assignView('remember_user_default', $this->ci->conf->get('privacy.remember_user_default', true)) - ->assignView('pagetitle', t('Login') .' - '. $this->ci->conf->get('general.title', 'Shaarli')) + ->assignView('remember_user_default', $this->container->conf->get('privacy.remember_user_default', true)) + ->assignView('pagetitle', t('Login') .' - '. $this->container->conf->get('general.title', 'Shaarli')) ; return $response->write($this->render('loginform')); diff --git a/application/front/controllers/ShaarliController.php b/application/front/controllers/ShaarliController.php index 99e66d53..2b828588 100644 --- a/application/front/controllers/ShaarliController.php +++ b/application/front/controllers/ShaarliController.php @@ -10,12 +10,12 @@ use Shaarli\Container\ShaarliContainer; abstract class ShaarliController { /** @var ShaarliContainer */ - protected $ci; + protected $container; - /** @param ShaarliContainer $ci Slim container (extended for attribute completion). */ - public function __construct(ShaarliContainer $ci) + /** @param ShaarliContainer $container Slim container (extended for attribute completion). */ + public function __construct(ShaarliContainer $container) { - $this->ci = $ci; + $this->container = $container; } /** @@ -25,20 +25,20 @@ abstract class ShaarliController */ protected function assignView(string $name, $value): self { - $this->ci->pageBuilder->assign($name, $value); + $this->container->pageBuilder->assign($name, $value); return $this; } protected function render(string $template): string { - $this->assignView('linkcount', $this->ci->bookmarkService->count(BookmarkFilter::$ALL)); - $this->assignView('privateLinkcount', $this->ci->bookmarkService->count(BookmarkFilter::$PRIVATE)); - $this->assignView('plugin_errors', $this->ci->pluginManager->getErrors()); + $this->assignView('linkcount', $this->container->bookmarkService->count(BookmarkFilter::$ALL)); + $this->assignView('privateLinkcount', $this->container->bookmarkService->count(BookmarkFilter::$PRIVATE)); + $this->assignView('plugin_errors', $this->container->pluginManager->getErrors()); $this->executeDefaultHooks($template); - return $this->ci->pageBuilder->render($template); + return $this->container->pageBuilder->render($template); } /** @@ -55,12 +55,12 @@ abstract class ShaarliController foreach ($common_hooks as $name) { $plugin_data = []; - $this->ci->pluginManager->executeHooks( + $this->container->pluginManager->executeHooks( 'render_' . $name, $plugin_data, [ 'target' => $template, - 'loggedin' => $this->ci->loginManager->isLoggedIn() + 'loggedin' => $this->container->loginManager->isLoggedIn() ] ); $this->assignView('plugins_' . $name, $plugin_data); -- cgit v1.2.3 From 424530d9afbee2b2ba0d9b80679fbd0e4ec2d4e2 Mon Sep 17 00:00:00 2001 From: aguy Date: Fri, 28 Feb 2020 15:14:22 +0000 Subject: Add an exception to method 'whitelist_protocols' for url which started with '#' This is to allow local link for markdown, actually a local link write with this syntax : '[anchor](#local_link)' produce this html code: http://#local_link --- application/http/UrlUtils.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'application') diff --git a/application/http/UrlUtils.php b/application/http/UrlUtils.php index 4bc84b82..e8d1a283 100644 --- a/application/http/UrlUtils.php +++ b/application/http/UrlUtils.php @@ -73,7 +73,7 @@ function add_trailing_slash($url) */ function whitelist_protocols($url, $protocols) { - if (startsWith($url, '?') || startsWith($url, '/')) { + if (startsWith($url, '?') || startsWith($url, '/') || startsWith($url, '#')) { return $url; } $protocols = array_merge(['http', 'https'], $protocols); -- cgit v1.2.3 From cc2ded54e12e3f3140b895067af086cd71cc5dc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20NOBILI?= Date: Mon, 2 Mar 2020 17:08:19 +0100 Subject: ldap authentication, fixes shaarli/Shaarli#1343 --- application/security/LoginManager.php | 64 ++++++++++++++++++++++++++++++----- 1 file changed, 55 insertions(+), 9 deletions(-) (limited to 'application') diff --git a/application/security/LoginManager.php b/application/security/LoginManager.php index 0b0ce0b1..2cea3f10 100644 --- a/application/security/LoginManager.php +++ b/application/security/LoginManager.php @@ -1,6 +1,7 @@ configManager->get('credentials.salt')); + // Check login matches config + if ($login != $this->configManager->get('credentials.login')) { + return false; + } - if ($login != $this->configManager->get('credentials.login') - || $hash != $this->configManager->get('credentials.hash') - ) { + // Check credentials + try { + if (($this->configManager->get('ldap.host') != "" && $this->checkCredentialsFromLdap($login, $password)) + || ($this->configManager->get('ldap.host') == "" && $this->checkCredentialsFromLocalConfig($login, $password))) { + $this->sessionManager->storeLoginInfo($clientIpId); + logm( + $this->configManager->get('resource.log'), + $remoteIp, + 'Login successful' + ); + return true; + } + } + catch(Exception $exception) { logm( $this->configManager->get('resource.log'), $remoteIp, - 'Login failed for user ' . $login + 'Exception while checking credentials: ' . $exception ); - return false; } - $this->sessionManager->storeLoginInfo($clientIpId); logm( $this->configManager->get('resource.log'), $remoteIp, - 'Login successful' + 'Login failed for user ' . $login ); - return true; + return false; + } + + + /** + * Check user credentials from local config + * + * @param string $login Username + * @param string $password Password + * + * @return bool true if the provided credentials are valid, false otherwise + */ + public function checkCredentialsFromLocalConfig($login, $password) { + $hash = sha1($password . $login . $this->configManager->get('credentials.salt')); + + return $login == $this->configManager->get('credentials.login') + && $hash == $this->configManager->get('credentials.hash'); + } + + /** + * Check user credentials are valid through LDAP bind + * + * @param string $remoteIp Remote client IP address + * @param string $clientIpId Client IP address identifier + * @param string $login Username + * @param string $password Password + * + * @return bool true if the provided credentials are valid, false otherwise + */ + public function checkCredentialsFromLdap($login, $password, $connect = null, $bind = null) + { + $connect = $connect ?? function($host) { return ldap_connect($host); }; + $bind = $bind ?? function($handle, $dn, $password) { return ldap_bind($handle, $dn, $password); }; + return $bind($connect($this->configManager->get('ldap.host')), sprintf($this->configManager->get('ldap.dn'), $login), $password); } /** -- cgit v1.2.3 From 21e5df5ee8f302ab96d4ca46ac3070405dd9aafb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20NOBILI?= Date: Wed, 3 Jun 2020 10:34:32 +0200 Subject: Update application/security/LoginManager.php Co-authored-by: ArthurHoaro --- application/security/LoginManager.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) (limited to 'application') diff --git a/application/security/LoginManager.php b/application/security/LoginManager.php index 2cea3f10..16ef3878 100644 --- a/application/security/LoginManager.php +++ b/application/security/LoginManager.php @@ -147,8 +147,10 @@ class LoginManager // Check credentials try { - if (($this->configManager->get('ldap.host') != "" && $this->checkCredentialsFromLdap($login, $password)) - || ($this->configManager->get('ldap.host') == "" && $this->checkCredentialsFromLocalConfig($login, $password))) { + $useLdapLogin = !empty($this->configManager->get('ldap.host')); + if ((false === $useLdapLogin && $this->checkCredentialsFromLocalConfig($login, $password)) + || (true === $useLdapLogin && $this->checkCredentialsFromLdap($login, $password)) + ) { $this->sessionManager->storeLoginInfo($clientIpId); logm( $this->configManager->get('resource.log'), -- cgit v1.2.3 From 9ba6982ea312c49a5c2b2cc5ad09d350e46fd87f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20NOBILI?= Date: Wed, 3 Jun 2020 10:35:41 +0200 Subject: Update application/security/LoginManager.php Co-authored-by: ArthurHoaro --- application/security/LoginManager.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'application') diff --git a/application/security/LoginManager.php b/application/security/LoginManager.php index 16ef3878..e34e0efa 100644 --- a/application/security/LoginManager.php +++ b/application/security/LoginManager.php @@ -141,7 +141,7 @@ class LoginManager public function checkCredentials($remoteIp, $clientIpId, $login, $password) { // Check login matches config - if ($login != $this->configManager->get('credentials.login')) { + if ($login !== $this->configManager->get('credentials.login')) { return false; } -- cgit v1.2.3 From a69cfe0dd23fbd2e25c07ec92717998585a9560d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20NOBILI?= Date: Wed, 3 Jun 2020 10:36:04 +0200 Subject: Update application/security/LoginManager.php Co-authored-by: ArthurHoaro --- application/security/LoginManager.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) (limited to 'application') diff --git a/application/security/LoginManager.php b/application/security/LoginManager.php index e34e0efa..5f395a87 100644 --- a/application/security/LoginManager.php +++ b/application/security/LoginManager.php @@ -206,7 +206,12 @@ class LoginManager { $connect = $connect ?? function($host) { return ldap_connect($host); }; $bind = $bind ?? function($handle, $dn, $password) { return ldap_bind($handle, $dn, $password); }; - return $bind($connect($this->configManager->get('ldap.host')), sprintf($this->configManager->get('ldap.dn'), $login), $password); + + return $bind( + $connect($this->configManager->get('ldap.host')), + sprintf($this->configManager->get('ldap.dn'), $login), + $password + ); } /** -- cgit v1.2.3 From 8694e8411b19d499ff58d8168fba448c63a5e443 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Thu, 25 Jun 2020 16:18:25 +0200 Subject: LDAP - Force protocol LDAPv3 On Linux, php-ldap seems to rely on a library which still uses deprecated LDAPv2 as default version, causing authentication issues. See: https://stackoverflow.com/a/48238224/1484919 --- application/security/LoginManager.php | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) (limited to 'application') diff --git a/application/security/LoginManager.php b/application/security/LoginManager.php index 5f395a87..39ec9b2e 100644 --- a/application/security/LoginManager.php +++ b/application/security/LoginManager.php @@ -204,12 +204,20 @@ class LoginManager */ public function checkCredentialsFromLdap($login, $password, $connect = null, $bind = null) { - $connect = $connect ?? function($host) { return ldap_connect($host); }; - $bind = $bind ?? function($handle, $dn, $password) { return ldap_bind($handle, $dn, $password); }; + $connect = $connect ?? function($host) { + $resource = ldap_connect($host); + + ldap_set_option($resource, LDAP_OPT_PROTOCOL_VERSION, 3); + + return $resource; + }; + $bind = $bind ?? function($handle, $dn, $password) { + return ldap_bind($handle, $dn, $password); + }; return $bind( $connect($this->configManager->get('ldap.host')), - sprintf($this->configManager->get('ldap.dn'), $login), + sprintf($this->configManager->get('ldap.dn'), $login), $password ); } -- cgit v1.2.3 From bee33239ed444f9724422fe5234cd79997500519 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Thu, 23 Jan 2020 22:26:38 +0100 Subject: Fix all relative link to work with new URL --- application/legacy/LegacyUpdater.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'application') diff --git a/application/legacy/LegacyUpdater.php b/application/legacy/LegacyUpdater.php index 3a5de79f..8d5cd071 100644 --- a/application/legacy/LegacyUpdater.php +++ b/application/legacy/LegacyUpdater.php @@ -10,9 +10,9 @@ use ReflectionMethod; use Shaarli\ApplicationUtils; use Shaarli\Bookmark\Bookmark; use Shaarli\Bookmark\BookmarkArray; -use Shaarli\Bookmark\LinkDB; use Shaarli\Bookmark\BookmarkFilter; use Shaarli\Bookmark\BookmarkIO; +use Shaarli\Bookmark\LinkDB; use Shaarli\Config\ConfigJson; use Shaarli\Config\ConfigManager; use Shaarli\Config\ConfigPhp; @@ -534,7 +534,7 @@ class LegacyUpdater if ($thumbnailsEnabled) { $this->session['warnings'][] = t( - 'You have enabled or changed thumbnails mode. Please synchronize them.' + 'You have enabled or changed thumbnails mode. Please synchronize them.' ); } -- cgit v1.2.3 From 485b168a9677d160b0c0426e4f282b9bd0c632c1 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Sun, 26 Jan 2020 11:15:15 +0100 Subject: Process picwall rendering through Slim controller + UT --- application/bookmark/Bookmark.php | 2 +- application/container/ContainerBuilder.php | 5 ++ application/container/ShaarliContainer.php | 2 + .../front/controllers/PictureWallController.php | 72 ++++++++++++++++++++++ .../exceptions/ThumbnailsDisabledException.php | 15 +++++ application/updater/Updater.php | 18 +++++- 6 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 application/front/controllers/PictureWallController.php create mode 100644 application/front/exceptions/ThumbnailsDisabledException.php (limited to 'application') diff --git a/application/bookmark/Bookmark.php b/application/bookmark/Bookmark.php index f9b21d3d..83ddab82 100644 --- a/application/bookmark/Bookmark.php +++ b/application/bookmark/Bookmark.php @@ -346,7 +346,7 @@ class Bookmark /** * Get the Thumbnail. * - * @return string|bool + * @return string|bool|null */ public function getThumbnail() { diff --git a/application/container/ContainerBuilder.php b/application/container/ContainerBuilder.php index e2c78ccc..99c12334 100644 --- a/application/container/ContainerBuilder.php +++ b/application/container/ContainerBuilder.php @@ -7,6 +7,7 @@ namespace Shaarli\Container; use Shaarli\Bookmark\BookmarkFileService; use Shaarli\Bookmark\BookmarkServiceInterface; use Shaarli\Config\ConfigManager; +use Shaarli\Formatter\FormatterFactory; use Shaarli\History; use Shaarli\Plugin\PluginManager; use Shaarli\Render\PageBuilder; @@ -76,6 +77,10 @@ class ContainerBuilder return new PluginManager($container->conf); }; + $container['formatterFactory'] = function (ShaarliContainer $container): FormatterFactory { + return new FormatterFactory($container->conf, $container->loginManager->isLoggedIn()); + }; + return $container; } } diff --git a/application/container/ShaarliContainer.php b/application/container/ShaarliContainer.php index 3fa9116e..fdf2f77f 100644 --- a/application/container/ShaarliContainer.php +++ b/application/container/ShaarliContainer.php @@ -6,6 +6,7 @@ namespace Shaarli\Container; use Shaarli\Bookmark\BookmarkServiceInterface; use Shaarli\Config\ConfigManager; +use Shaarli\Formatter\FormatterFactory; use Shaarli\History; use Shaarli\Plugin\PluginManager; use Shaarli\Render\PageBuilder; @@ -23,6 +24,7 @@ use Slim\Container; * @property BookmarkServiceInterface $bookmarkService * @property PageBuilder $pageBuilder * @property PluginManager $pluginManager + * @property FormatterFactory $formatterFactory */ class ShaarliContainer extends Container { diff --git a/application/front/controllers/PictureWallController.php b/application/front/controllers/PictureWallController.php new file mode 100644 index 00000000..08d31b29 --- /dev/null +++ b/application/front/controllers/PictureWallController.php @@ -0,0 +1,72 @@ +container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) === Thumbnailer::MODE_NONE) { + throw new ThumbnailsDisabledException(); + } + + $this->assignView( + 'pagetitle', + t('Picture wall') .' - '. $this->container->conf->get('general.title', 'Shaarli') + ); + + // Optionally filter the results: + $links = $this->container->bookmarkService->search($request->getQueryParams()); + $linksToDisplay = []; + + // Get only bookmarks which have a thumbnail. + // Note: we do not retrieve thumbnails here, the request is too heavy. + $formatter = $this->container->formatterFactory->getFormatter('raw'); + foreach ($links as $key => $link) { + if (!empty($link->getThumbnail())) { + $linksToDisplay[] = $formatter->format($link); + } + } + + $data = $this->executeHooks($linksToDisplay); + foreach ($data as $key => $value) { + $this->assignView($key, $value); + } + + return $response->write($this->render('picwall')); + } + + /** + * @param mixed[] $linksToDisplay List of formatted bookmarks + * + * @return mixed[] Template data after active plugins render_picwall hook execution. + */ + protected function executeHooks(array $linksToDisplay): array + { + $data = [ + 'linksToDisplay' => $linksToDisplay, + ]; + $this->container->pluginManager->executeHooks( + 'render_picwall', + $data, + ['loggedin' => $this->container->loginManager->isLoggedIn()] + ); + + return $data; + } +} diff --git a/application/front/exceptions/ThumbnailsDisabledException.php b/application/front/exceptions/ThumbnailsDisabledException.php new file mode 100644 index 00000000..1b9cf5b7 --- /dev/null +++ b/application/front/exceptions/ThumbnailsDisabledException.php @@ -0,0 +1,15 @@ +doneUpdates; } + + /** + * With the Slim routing system, default header link should be `./` instead of `?`. + * Otherwise you can not go back to the home page. Example: `/picture-wall` -> `/picture-wall?` instead of `/`. + */ + public function updateMethodRelativeHomeLink(): bool + { + $link = trim($this->conf->get('general.header_link')); + if ($link[0] === '?') { + $link = './'. ltrim($link, '?'); + + $this->conf->set('general.header_link', $link, true, true); + } + + return true; + } } -- cgit v1.2.3 From b0428aa9b02b058b72c40b6e8dc2298d55bf692f Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Thu, 23 Jan 2020 21:13:41 +0100 Subject: Migrate cache purge function to a proper class And update dependencies and tests. Note that SESSION['tags'] has been removed a log ago --- application/bookmark/BookmarkFileService.php | 7 ++++- application/bookmark/BookmarkIO.php | 2 -- application/feed/Cache.php | 38 ----------------------- application/legacy/LegacyLinkDB.php | 4 ++- application/render/PageCacheManager.php | 45 ++++++++++++++++++++++++++++ 5 files changed, 54 insertions(+), 42 deletions(-) delete mode 100644 application/feed/Cache.php create mode 100644 application/render/PageCacheManager.php (limited to 'application') diff --git a/application/bookmark/BookmarkFileService.php b/application/bookmark/BookmarkFileService.php index 9c59e139..fef998fd 100644 --- a/application/bookmark/BookmarkFileService.php +++ b/application/bookmark/BookmarkFileService.php @@ -12,6 +12,7 @@ use Shaarli\Formatter\BookmarkMarkdownFormatter; use Shaarli\History; use Shaarli\Legacy\LegacyLinkDB; use Shaarli\Legacy\LegacyUpdater; +use Shaarli\Render\PageCacheManager; use Shaarli\Updater\UpdaterUtils; /** @@ -39,6 +40,9 @@ class BookmarkFileService implements BookmarkServiceInterface /** @var History instance */ protected $history; + /** @var PageCacheManager instance */ + protected $pageCacheManager; + /** @var bool true for logged in users. Default value to retrieve private bookmarks. */ protected $isLoggedIn; @@ -49,6 +53,7 @@ class BookmarkFileService implements BookmarkServiceInterface { $this->conf = $conf; $this->history = $history; + $this->pageCacheManager = new PageCacheManager($this->conf->get('resource.page_cache')); $this->bookmarksIO = new BookmarkIO($this->conf); $this->isLoggedIn = $isLoggedIn; @@ -275,7 +280,7 @@ class BookmarkFileService implements BookmarkServiceInterface } $this->bookmarks->reorder(); $this->bookmarksIO->write($this->bookmarks); - invalidateCaches($this->conf->get('resource.page_cache')); + $this->pageCacheManager->invalidateCaches(); } /** diff --git a/application/bookmark/BookmarkIO.php b/application/bookmark/BookmarkIO.php index ae9ffcb4..1026e2f9 100644 --- a/application/bookmark/BookmarkIO.php +++ b/application/bookmark/BookmarkIO.php @@ -102,7 +102,5 @@ class BookmarkIO $this->datastore, self::$phpPrefix.base64_encode(gzdeflate(serialize($links))).self::$phpSuffix ); - - invalidateCaches($this->conf->get('resource.page_cache')); } } diff --git a/application/feed/Cache.php b/application/feed/Cache.php deleted file mode 100644 index e5d43e61..00000000 --- a/application/feed/Cache.php +++ /dev/null @@ -1,38 +0,0 @@ -write(); - invalidateCaches($pageCacheDir); + $pageCacheManager = new PageCacheManager($pageCacheDir); + $pageCacheManager->invalidateCaches(); } /** diff --git a/application/render/PageCacheManager.php b/application/render/PageCacheManager.php new file mode 100644 index 00000000..bd91fe0d --- /dev/null +++ b/application/render/PageCacheManager.php @@ -0,0 +1,45 @@ +pageCacheDir = $pageCacheDir; + } + + /** + * Purges all cached pages + * + * @return string|null an error string if the directory is missing + */ + public function purgeCachedPages(): ?string + { + if (!is_dir($this->pageCacheDir)) { + $error = sprintf(t('Cannot purge %s: no directory'), $this->pageCacheDir); + error_log($error); + + return $error; + } + + array_map('unlink', glob($this->pageCacheDir . '/*.cache')); + + return null; + } + + /** + * Invalidates caches when the database is changed or the user logs out. + */ + public function invalidateCaches(): void + { + // Purge page cache shared by sessions. + $this->purgeCachedPages(); + } +} -- cgit v1.2.3 From 8e47af2b3620c920116ec056173277c039163ec1 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Thu, 23 Jan 2020 21:52:03 +0100 Subject: Process logout through Slim controller --- application/container/ContainerBuilder.php | 20 ++++++++++++-- application/container/ShaarliContainer.php | 3 +++ application/front/controllers/LogoutController.php | 31 ++++++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 application/front/controllers/LogoutController.php (limited to 'application') diff --git a/application/container/ContainerBuilder.php b/application/container/ContainerBuilder.php index 99c12334..c5c4a2c3 100644 --- a/application/container/ContainerBuilder.php +++ b/application/container/ContainerBuilder.php @@ -11,6 +11,7 @@ use Shaarli\Formatter\FormatterFactory; use Shaarli\History; use Shaarli\Plugin\PluginManager; use Shaarli\Render\PageBuilder; +use Shaarli\Render\PageCacheManager; use Shaarli\Security\LoginManager; use Shaarli\Security\SessionManager; @@ -34,19 +35,30 @@ class ContainerBuilder /** @var LoginManager */ protected $login; - public function __construct(ConfigManager $conf, SessionManager $session, LoginManager $login) - { + /** @var string */ + protected $webPath; + + public function __construct( + ConfigManager $conf, + SessionManager $session, + LoginManager $login, + string $webPath + ) { $this->conf = $conf; $this->session = $session; $this->login = $login; + $this->webPath = $webPath; } public function build(): ShaarliContainer { $container = new ShaarliContainer(); + $container['conf'] = $this->conf; $container['sessionManager'] = $this->session; $container['loginManager'] = $this->login; + $container['webPath'] = $this->webPath; + $container['plugins'] = function (ShaarliContainer $container): PluginManager { return new PluginManager($container->conf); }; @@ -81,6 +93,10 @@ class ContainerBuilder return new FormatterFactory($container->conf, $container->loginManager->isLoggedIn()); }; + $container['pageCacheManager'] = function (ShaarliContainer $container): PageCacheManager { + return new PageCacheManager($container->conf->get('resource.page_cache')); + }; + return $container; } } diff --git a/application/container/ShaarliContainer.php b/application/container/ShaarliContainer.php index fdf2f77f..af62e574 100644 --- a/application/container/ShaarliContainer.php +++ b/application/container/ShaarliContainer.php @@ -10,6 +10,7 @@ use Shaarli\Formatter\FormatterFactory; use Shaarli\History; use Shaarli\Plugin\PluginManager; use Shaarli\Render\PageBuilder; +use Shaarli\Render\PageCacheManager; use Shaarli\Security\LoginManager; use Shaarli\Security\SessionManager; use Slim\Container; @@ -20,11 +21,13 @@ use Slim\Container; * @property ConfigManager $conf * @property SessionManager $sessionManager * @property LoginManager $loginManager + * @property string $webPath * @property History $history * @property BookmarkServiceInterface $bookmarkService * @property PageBuilder $pageBuilder * @property PluginManager $pluginManager * @property FormatterFactory $formatterFactory + * @property PageCacheManager $pageCacheManager */ class ShaarliContainer extends Container { diff --git a/application/front/controllers/LogoutController.php b/application/front/controllers/LogoutController.php new file mode 100644 index 00000000..aba078c3 --- /dev/null +++ b/application/front/controllers/LogoutController.php @@ -0,0 +1,31 @@ +container->pageCacheManager->invalidateCaches(); + $this->container->sessionManager->logout(); + + // TODO: switch to a simple Cookie manager allowing to check the session, and create mocks. + setcookie(LoginManager::$STAY_SIGNED_IN_COOKIE, 'false', 0, $this->container->webPath); + + return $response->withRedirect('./'); + } +} -- cgit v1.2.3 From 03340c18ead651ef9e11f883745695f2edafbae3 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Tue, 12 May 2020 12:44:48 +0200 Subject: Slim router: handle add tag route --- application/bookmark/LinkUtils.php | 2 +- application/container/ShaarliContainer.php | 1 + .../formatter/BookmarkMarkdownFormatter.php | 4 +- application/front/controllers/TagController.php | 74 ++++++++++++++++++++++ 4 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 application/front/controllers/TagController.php (limited to 'application') diff --git a/application/bookmark/LinkUtils.php b/application/bookmark/LinkUtils.php index 88379430..98d9038a 100644 --- a/application/bookmark/LinkUtils.php +++ b/application/bookmark/LinkUtils.php @@ -220,7 +220,7 @@ function hashtag_autolink($description, $indexUrl = '') * \p{Mn} - any non marking space (accents, umlauts, etc) */ $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui'; - $replacement = '$1#$2'; + $replacement = '$1#$2'; return preg_replace($regex, $replacement, $description); } diff --git a/application/container/ShaarliContainer.php b/application/container/ShaarliContainer.php index af62e574..3995f669 100644 --- a/application/container/ShaarliContainer.php +++ b/application/container/ShaarliContainer.php @@ -18,6 +18,7 @@ use Slim\Container; /** * Extension of Slim container to document the injected objects. * + * @property mixed[] $environment $_SERVER automatically injected by Slim * @property ConfigManager $conf * @property SessionManager $sessionManager * @property LoginManager $loginManager diff --git a/application/formatter/BookmarkMarkdownFormatter.php b/application/formatter/BookmarkMarkdownFormatter.php index 077e5312..5d244d4c 100644 --- a/application/formatter/BookmarkMarkdownFormatter.php +++ b/application/formatter/BookmarkMarkdownFormatter.php @@ -114,7 +114,7 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter /** * Replace hashtag in Markdown links format - * E.g. `#hashtag` becomes `[#hashtag](?addtag=hashtag)` + * E.g. `#hashtag` becomes `[#hashtag](./add-tag/hashtag)` * It includes the index URL if specified. * * @param string $description @@ -133,7 +133,7 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter * \p{Mn} - any non marking space (accents, umlauts, etc) */ $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui'; - $replacement = '$1[#$2]('. $indexUrl .'?addtag=$2)'; + $replacement = '$1[#$2]('. $indexUrl .'./add-tag/$2)'; $descriptionLines = explode(PHP_EOL, $description); $descriptionOut = ''; diff --git a/application/front/controllers/TagController.php b/application/front/controllers/TagController.php new file mode 100644 index 00000000..598275b0 --- /dev/null +++ b/application/front/controllers/TagController.php @@ -0,0 +1,74 @@ +container->environment['HTTP_REFERER'] ?? null; + + // In case browser does not send HTTP_REFERER, we search a single tag + if (null === $referer) { + if (null !== $newTag) { + return $response->withRedirect('./?searchtags='. urlencode($newTag)); + } + + return $response->withRedirect('./'); + } + + $currentUrl = parse_url($this->container->environment['HTTP_REFERER']); + parse_str($currentUrl['query'] ?? '', $params); + + if (null === $newTag) { + return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params)); + } + + // Prevent redirection loop + if (isset($params['addtag'])) { + unset($params['addtag']); + } + + // Check if this tag is already in the search query and ignore it if it is. + // Each tag is always separated by a space + $currentTags = isset($params['searchtags']) ? explode(' ', $params['searchtags']) : []; + + $addtag = true; + foreach ($currentTags as $value) { + if ($value === $newTag) { + $addtag = false; + break; + } + } + + // Append the tag if necessary + if (true === $addtag) { + $currentTags[] = trim($newTag); + } + + $params['searchtags'] = trim(implode(' ', $currentTags)); + + // We also remove page (keeping the same page has no sense, since the results are different) + unset($params['page']); + + return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params)); + } +} -- cgit v1.2.3 From c266a89d0fbb0d60d2d7df0ec171b7cb022224f6 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Sun, 26 Jan 2020 14:35:25 +0100 Subject: Process tag cloud page through Slim controller --- application/Utils.php | 2 +- .../front/controllers/TagCloudController.php | 89 ++++++++++++++++++++++ application/security/SessionManager.php | 10 +++ 3 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 application/front/controllers/TagCloudController.php (limited to 'application') diff --git a/application/Utils.php b/application/Utils.php index 4b7fc546..4e97cdda 100644 --- a/application/Utils.php +++ b/application/Utils.php @@ -87,7 +87,7 @@ function endsWith($haystack, $needle, $case = true) * * @param mixed $input Data to escape: a single string or an array of strings. * - * @return string escaped. + * @return string|array escaped. */ function escape($input) { diff --git a/application/front/controllers/TagCloudController.php b/application/front/controllers/TagCloudController.php new file mode 100644 index 00000000..b6f4a0ce --- /dev/null +++ b/application/front/controllers/TagCloudController.php @@ -0,0 +1,89 @@ +container->loginManager->isLoggedIn() === true) { + $visibility = $this->container->sessionManager->getSessionParameter('visibility'); + } + + $searchTags = $request->getQueryParam('searchtags'); + $filteringTags = $searchTags !== null ? explode(' ', $searchTags) : []; + + $tags = $this->container->bookmarkService->bookmarksCountPerTag($filteringTags, $visibility ?? null); + + // We sort tags alphabetically, then choose a font size according to count. + // First, find max value. + $maxCount = 0; + foreach ($tags as $count) { + $maxCount = max($maxCount, $count); + } + + alphabetical_sort($tags, false, true); + + $logMaxCount = $maxCount > 1 ? log($maxCount, 30) : 1; + $tagList = []; + foreach ($tags as $key => $value) { + if (in_array($key, $filteringTags)) { + continue; + } + // Tag font size scaling: + // default 15 and 30 logarithm bases affect scaling, + // 2.2 and 0.8 are arbitrary font sizes in em. + $size = log($value, 15) / $logMaxCount * 2.2 + 0.8; + $tagList[$key] = [ + 'count' => $value, + 'size' => number_format($size, 2, '.', ''), + ]; + } + + $searchTags = implode(' ', escape($filteringTags)); + $data = [ + 'search_tags' => $searchTags, + 'tags' => $tagList, + ]; + $data = $this->executeHooks($data); + foreach ($data as $key => $value) { + $this->assignView($key, $value); + } + + $searchTags = !empty($searchTags) ? $searchTags .' - ' : ''; + $this->assignView( + 'pagetitle', + $searchTags. t('Tag cloud') .' - '. $this->container->conf->get('general.title', 'Shaarli') + ); + + return $response->write($this->render('tag.cloud')); + } + + /** + * @param mixed[] $data Template data + * + * @return mixed[] Template data after active plugins render_picwall hook execution. + */ + protected function executeHooks(array $data): array + { + $this->container->pluginManager->executeHooks( + 'render_tagcloud', + $data, + ['loggedin' => $this->container->loginManager->isLoggedIn()] + ); + + return $data; + } +} diff --git a/application/security/SessionManager.php b/application/security/SessionManager.php index 994fcbe5..4ae99168 100644 --- a/application/security/SessionManager.php +++ b/application/security/SessionManager.php @@ -202,4 +202,14 @@ class SessionManager { return $this->session; } + + /** + * @param mixed $default value which will be returned if the $key is undefined + * + * @return mixed Content stored in session + */ + public function getSessionParameter(string $key, $default = null) + { + return $this->session[$key] ?? $default; + } } -- cgit v1.2.3 From c79473bd84ab5aba7836d2caaf61847cabaf1e53 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Sat, 16 May 2020 13:13:00 +0200 Subject: Handle tag filtering in the Bookmark service --- application/bookmark/BookmarkFileService.php | 1 + application/front/controllers/TagCloudController.php | 3 --- 2 files changed, 1 insertion(+), 3 deletions(-) (limited to 'application') diff --git a/application/bookmark/BookmarkFileService.php b/application/bookmark/BookmarkFileService.php index fef998fd..3b3812af 100644 --- a/application/bookmark/BookmarkFileService.php +++ b/application/bookmark/BookmarkFileService.php @@ -296,6 +296,7 @@ class BookmarkFileService implements BookmarkServiceInterface if (empty($tag) || (! $this->isLoggedIn && startsWith($tag, '.')) || $tag === BookmarkMarkdownFormatter::NO_MD_TAG + || in_array($tag, $filteringTags, true) ) { continue; } diff --git a/application/front/controllers/TagCloudController.php b/application/front/controllers/TagCloudController.php index b6f4a0ce..9389c2b0 100644 --- a/application/front/controllers/TagCloudController.php +++ b/application/front/controllers/TagCloudController.php @@ -39,9 +39,6 @@ class TagCloudController extends ShaarliController $logMaxCount = $maxCount > 1 ? log($maxCount, 30) : 1; $tagList = []; foreach ($tags as $key => $value) { - if (in_array($key, $filteringTags)) { - continue; - } // Tag font size scaling: // default 15 and 30 logarithm bases affect scaling, // 2.2 and 0.8 are arbitrary font sizes in em. -- cgit v1.2.3 From 3772298ee7d8d0708f4e72798600accafa17740b Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Sat, 16 May 2020 13:33:39 +0200 Subject: Few optimizations and code readability for tag cloud controller --- .../front/controllers/TagCloudController.php | 52 +++++++++++++--------- 1 file changed, 31 insertions(+), 21 deletions(-) (limited to 'application') diff --git a/application/front/controllers/TagCloudController.php b/application/front/controllers/TagCloudController.php index 9389c2b0..93e3ae27 100644 --- a/application/front/controllers/TagCloudController.php +++ b/application/front/controllers/TagCloudController.php @@ -16,7 +16,13 @@ use Slim\Http\Response; */ class TagCloudController extends ShaarliController { - public function index(Request $request, Response $response): Response + /** + * Display the tag cloud through the template engine. + * This controller a few filters: + * - Visibility stored in the session for logged in users + * - `searchtags` query parameter: will return tags associated with filter in at least one bookmark + */ + public function cloud(Request $request, Response $response): Response { if ($this->container->loginManager->isLoggedIn() === true) { $visibility = $this->container->sessionManager->getSessionParameter('visibility'); @@ -27,27 +33,10 @@ class TagCloudController extends ShaarliController $tags = $this->container->bookmarkService->bookmarksCountPerTag($filteringTags, $visibility ?? null); - // We sort tags alphabetically, then choose a font size according to count. - // First, find max value. - $maxCount = 0; - foreach ($tags as $count) { - $maxCount = max($maxCount, $count); - } - + // TODO: the sorting should be handled by bookmarkService instead of the controller alphabetical_sort($tags, false, true); - $logMaxCount = $maxCount > 1 ? log($maxCount, 30) : 1; - $tagList = []; - foreach ($tags as $key => $value) { - // Tag font size scaling: - // default 15 and 30 logarithm bases affect scaling, - // 2.2 and 0.8 are arbitrary font sizes in em. - $size = log($value, 15) / $logMaxCount * 2.2 + 0.8; - $tagList[$key] = [ - 'count' => $value, - 'size' => number_format($size, 2, '.', ''), - ]; - } + $tagList = $this->formatTagsForCloud($tags); $searchTags = implode(' ', escape($filteringTags)); $data = [ @@ -62,12 +51,33 @@ class TagCloudController extends ShaarliController $searchTags = !empty($searchTags) ? $searchTags .' - ' : ''; $this->assignView( 'pagetitle', - $searchTags. t('Tag cloud') .' - '. $this->container->conf->get('general.title', 'Shaarli') + $searchTags . t('Tag cloud') .' - '. $this->container->conf->get('general.title', 'Shaarli') ); return $response->write($this->render('tag.cloud')); } + protected function formatTagsForCloud(array $tags): array + { + // We sort tags alphabetically, then choose a font size according to count. + // First, find max value. + $maxCount = count($tags) > 0 ? max($tags) : 0; + $logMaxCount = $maxCount > 1 ? log($maxCount, 30) : 1; + $tagList = []; + foreach ($tags as $key => $value) { + // Tag font size scaling: + // default 15 and 30 logarithm bases affect scaling, + // 2.2 and 0.8 are arbitrary font sizes in em. + $size = log($value, 15) / $logMaxCount * 2.2 + 0.8; + $tagList[$key] = [ + 'count' => $value, + 'size' => number_format($size, 2, '.', ''), + ]; + } + + return $tagList; + } + /** * @param mixed[] $data Template data * -- cgit v1.2.3 From 60ae241251b753fc052e50ebd95277dfcb074cb0 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Sat, 16 May 2020 14:56:22 +0200 Subject: Process tag list page through Slim controller --- .../front/controllers/TagCloudController.php | 59 ++++++++++++++++++---- 1 file changed, 48 insertions(+), 11 deletions(-) (limited to 'application') diff --git a/application/front/controllers/TagCloudController.php b/application/front/controllers/TagCloudController.php index 93e3ae27..1ff7c2e6 100644 --- a/application/front/controllers/TagCloudController.php +++ b/application/front/controllers/TagCloudController.php @@ -10,12 +10,15 @@ use Slim\Http\Response; /** * Class TagCloud * - * Slim controller used to render the tag cloud page. + * Slim controller used to render the tag cloud and tag list pages. * * @package Front\Controller */ class TagCloudController extends ShaarliController { + protected const TYPE_CLOUD = 'cloud'; + protected const TYPE_LIST = 'list'; + /** * Display the tag cloud through the template engine. * This controller a few filters: @@ -23,27 +26,54 @@ class TagCloudController extends ShaarliController * - `searchtags` query parameter: will return tags associated with filter in at least one bookmark */ public function cloud(Request $request, Response $response): Response + { + return $this->processRequest(static::TYPE_CLOUD, $request, $response); + } + + /** + * Display the tag list through the template engine. + * This controller a few filters: + * - Visibility stored in the session for logged in users + * - `searchtags` query parameter: will return tags associated with filter in at least one bookmark + * - `sort` query parameters: + * + `usage` (default): most used tags first + * + `alpha`: alphabetical order + */ + public function list(Request $request, Response $response): Response + { + return $this->processRequest(static::TYPE_LIST, $request, $response); + } + + /** + * Process the request for both tag cloud and tag list endpoints. + */ + protected function processRequest(string $type, Request $request, Response $response): Response { if ($this->container->loginManager->isLoggedIn() === true) { $visibility = $this->container->sessionManager->getSessionParameter('visibility'); } + $sort = $request->getQueryParam('sort'); $searchTags = $request->getQueryParam('searchtags'); $filteringTags = $searchTags !== null ? explode(' ', $searchTags) : []; $tags = $this->container->bookmarkService->bookmarksCountPerTag($filteringTags, $visibility ?? null); - // TODO: the sorting should be handled by bookmarkService instead of the controller - alphabetical_sort($tags, false, true); + if (static::TYPE_CLOUD === $type || 'alpha' === $sort) { + // TODO: the sorting should be handled by bookmarkService instead of the controller + alphabetical_sort($tags, false, true); + } - $tagList = $this->formatTagsForCloud($tags); + if (static::TYPE_CLOUD === $type) { + $tags = $this->formatTagsForCloud($tags); + } $searchTags = implode(' ', escape($filteringTags)); $data = [ 'search_tags' => $searchTags, - 'tags' => $tagList, + 'tags' => $tags, ]; - $data = $this->executeHooks($data); + $data = $this->executeHooks('tag' . $type, $data); foreach ($data as $key => $value) { $this->assignView($key, $value); } @@ -51,12 +81,19 @@ class TagCloudController extends ShaarliController $searchTags = !empty($searchTags) ? $searchTags .' - ' : ''; $this->assignView( 'pagetitle', - $searchTags . t('Tag cloud') .' - '. $this->container->conf->get('general.title', 'Shaarli') + $searchTags . t('Tag '. $type) .' - '. $this->container->conf->get('general.title', 'Shaarli') ); - return $response->write($this->render('tag.cloud')); + return $response->write($this->render('tag.'. $type)); } + /** + * Format the tags array for the tag cloud template. + * + * @param array $tags List of tags as key with count as value + * + * @return mixed[] List of tags as key, with count and expected font size in a subarray + */ protected function formatTagsForCloud(array $tags): array { // We sort tags alphabetically, then choose a font size according to count. @@ -81,12 +118,12 @@ class TagCloudController extends ShaarliController /** * @param mixed[] $data Template data * - * @return mixed[] Template data after active plugins render_picwall hook execution. + * @return mixed[] Template data after active plugins hook execution. */ - protected function executeHooks(array $data): array + protected function executeHooks(string $template, array $data): array { $this->container->pluginManager->executeHooks( - 'render_tagcloud', + 'render_'. $template, $data, ['loggedin' => $this->container->loginManager->isLoggedIn()] ); -- cgit v1.2.3 From 69e29ff65ef56b886748c58ba5b037cf217c4a1d Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Sun, 17 May 2020 11:06:39 +0200 Subject: Process daily page through Slim controller --- application/Utils.php | 8 +- application/bookmark/BookmarkFilter.php | 2 +- application/front/controllers/DailyController.php | 142 ++++++++++++++++++++++ 3 files changed, 147 insertions(+), 5 deletions(-) create mode 100644 application/front/controllers/DailyController.php (limited to 'application') diff --git a/application/Utils.php b/application/Utils.php index 4e97cdda..72c90049 100644 --- a/application/Utils.php +++ b/application/Utils.php @@ -294,15 +294,15 @@ function normalize_spaces($string) * Requires php-intl to display international datetimes, * otherwise default format '%c' will be returned. * - * @param DateTime $date to format. - * @param bool $time Displays time if true. - * @param bool $intl Use international format if true. + * @param DateTimeInterface $date to format. + * @param bool $time Displays time if true. + * @param bool $intl Use international format if true. * * @return bool|string Formatted date, or false if the input is invalid. */ function format_date($date, $time = true, $intl = true) { - if (! $date instanceof DateTime) { + if (! $date instanceof DateTimeInterface) { return false; } diff --git a/application/bookmark/BookmarkFilter.php b/application/bookmark/BookmarkFilter.php index fd556679..797a36b8 100644 --- a/application/bookmark/BookmarkFilter.php +++ b/application/bookmark/BookmarkFilter.php @@ -436,7 +436,7 @@ class BookmarkFilter throw new Exception('Invalid date format'); } - $filtered = array(); + $filtered = []; foreach ($this->bookmarks as $key => $l) { if ($l->getCreated()->format('Ymd') == $day) { $filtered[$key] = $l; diff --git a/application/front/controllers/DailyController.php b/application/front/controllers/DailyController.php new file mode 100644 index 00000000..c2fdaa55 --- /dev/null +++ b/application/front/controllers/DailyController.php @@ -0,0 +1,142 @@ +getQueryParam('day') ?? date('Ymd'); + + $availableDates = $this->container->bookmarkService->days(); + $nbAvailableDates = count($availableDates); + $index = array_search($day, $availableDates); + + if ($index === false && $nbAvailableDates > 0) { + // no bookmarks for day, but at least one day with bookmarks + $index = $nbAvailableDates - 1; + $day = $availableDates[$index]; + } + + if ($day === date('Ymd')) { + $this->assignView('dayDesc', t('Today')); + } elseif ($day === date('Ymd', strtotime('-1 days'))) { + $this->assignView('dayDesc', t('Yesterday')); + } + + if ($index !== false) { + if ($index >= 1) { + $previousDay = $availableDates[$index - 1]; + } + if ($index < $nbAvailableDates - 1) { + $nextDay = $availableDates[$index + 1]; + } + } + + try { + $linksToDisplay = $this->container->bookmarkService->filterDay($day); + } catch (\Exception $exc) { + $linksToDisplay = []; + } + + $formatter = $this->container->formatterFactory->getFormatter(); + // We pre-format some fields for proper output. + foreach ($linksToDisplay as $key => $bookmark) { + $linksToDisplay[$key] = $formatter->format($bookmark); + // This page is a bit specific, we need raw description to calculate the length + $linksToDisplay[$key]['formatedDescription'] = $linksToDisplay[$key]['description']; + $linksToDisplay[$key]['description'] = $bookmark->getDescription(); + } + + $dayDate = DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, $day.'_000000'); + $data = [ + 'linksToDisplay' => $linksToDisplay, + 'day' => $dayDate->getTimestamp(), + 'dayDate' => $dayDate, + 'previousday' => $previousDay ?? '', + 'nextday' => $nextDay ?? '', + ]; + + // Hooks are called before column construction so that plugins don't have to deal with columns. + $this->executeHooks($data); + + $data['cols'] = $this->calculateColumns($data['linksToDisplay']); + + foreach ($data as $key => $value) { + $this->assignView($key, $value); + } + + $mainTitle = $this->container->conf->get('general.title', 'Shaarli'); + $this->assignView( + 'pagetitle', + t('Daily') .' - '. format_date($dayDate, false) . ' - ' . $mainTitle + ); + + return $response->write($this->render('daily')); + } + + /** + * We need to spread the articles on 3 columns. + * did not want to use a JavaScript lib like http://masonry.desandro.com/ + * so I manually spread entries with a simple method: I roughly evaluate the + * height of a div according to title and description length. + */ + protected function calculateColumns(array $links): array + { + // Entries to display, for each column. + $columns = [[], [], []]; + // Rough estimate of columns fill. + $fill = [0, 0, 0]; + foreach ($links as $link) { + // Roughly estimate length of entry (by counting characters) + // Title: 30 chars = 1 line. 1 line is 30 pixels height. + // Description: 836 characters gives roughly 342 pixel height. + // This is not perfect, but it's usually OK. + $length = strlen($link['title'] ?? '') + (342 * strlen($link['description'] ?? '')) / 836; + if (! empty($link['thumbnail'])) { + $length += 100; // 1 thumbnails roughly takes 100 pixels height. + } + // Then put in column which is the less filled: + $smallest = min($fill); // find smallest value in array. + $index = array_search($smallest, $fill); // find index of this smallest value. + array_push($columns[$index], $link); // Put entry in this column. + $fill[$index] += $length; + } + + return $columns; + } + + /** + * @param mixed[] $data Variables passed to the template engine + * + * @return mixed[] Template data after active plugins render_picwall hook execution. + */ + protected function executeHooks(array $data): array + { + $this->container->pluginManager->executeHooks( + 'render_daily', + $data, + ['loggedin' => $this->container->loginManager->isLoggedIn()] + ); + + return $data; + } +} -- cgit v1.2.3 From e3d28be9673a9f8404ff907b8191209729ad690c Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Sun, 17 May 2020 11:29:17 +0200 Subject: Slim daily: minor bugfix with empty data --- application/front/controllers/DailyController.php | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) (limited to 'application') diff --git a/application/front/controllers/DailyController.php b/application/front/controllers/DailyController.php index c2fdaa55..271c0ee2 100644 --- a/application/front/controllers/DailyController.php +++ b/application/front/controllers/DailyController.php @@ -30,10 +30,13 @@ class DailyController extends ShaarliController $nbAvailableDates = count($availableDates); $index = array_search($day, $availableDates); - if ($index === false && $nbAvailableDates > 0) { + if ($index === false) { // no bookmarks for day, but at least one day with bookmarks - $index = $nbAvailableDates - 1; - $day = $availableDates[$index]; + $day = $availableDates[$nbAvailableDates - 1] ?? $day; + $previousDay = $availableDates[$nbAvailableDates - 2] ?? ''; + } else { + $previousDay = $availableDates[$index - 1] ?? ''; + $nextDay = $availableDates[$index + 1] ?? ''; } if ($day === date('Ymd')) { @@ -42,15 +45,6 @@ class DailyController extends ShaarliController $this->assignView('dayDesc', t('Yesterday')); } - if ($index !== false) { - if ($index >= 1) { - $previousDay = $availableDates[$index - 1]; - } - if ($index < $nbAvailableDates - 1) { - $nextDay = $availableDates[$index + 1]; - } - } - try { $linksToDisplay = $this->container->bookmarkService->filterDay($day); } catch (\Exception $exc) { -- cgit v1.2.3 From c4d5be53c2ae503c00da3cfe6b28d0ce9d2ca7f5 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Sun, 17 May 2020 14:16:32 +0200 Subject: Process Daily RSS feed through Slim controller The daily RSS template has been entirely rewritten to handle the whole feed through the template engine. --- application/bookmark/Bookmark.php | 15 ++--- application/bookmark/BookmarkFileService.php | 2 +- application/container/ContainerBuilder.php | 5 +- application/front/controllers/DailyController.php | 74 +++++++++++++++++++++++ application/http/HttpUtils.php | 15 +++-- application/legacy/LegacyLinkDB.php | 2 +- application/render/PageCacheManager.php | 17 +++++- 7 files changed, 115 insertions(+), 15 deletions(-) (limited to 'application') diff --git a/application/bookmark/Bookmark.php b/application/bookmark/Bookmark.php index 83ddab82..90ff5b16 100644 --- a/application/bookmark/Bookmark.php +++ b/application/bookmark/Bookmark.php @@ -3,6 +3,7 @@ namespace Shaarli\Bookmark; use DateTime; +use DateTimeInterface; use Shaarli\Bookmark\Exception\InvalidBookmarkException; /** @@ -42,10 +43,10 @@ class Bookmark /** @var bool Set to true if the bookmark is set as sticky */ protected $sticky; - /** @var DateTime Creation datetime */ + /** @var DateTimeInterface Creation datetime */ protected $created; - /** @var DateTime Update datetime */ + /** @var DateTimeInterface datetime */ protected $updated; /** @var bool True if the bookmark can only be seen while logged in */ @@ -100,7 +101,7 @@ class Bookmark || ! is_int($this->id) || empty($this->shortUrl) || empty($this->created) - || ! $this->created instanceof DateTime + || ! $this->created instanceof DateTimeInterface ) { throw new InvalidBookmarkException($this); } @@ -188,7 +189,7 @@ class Bookmark /** * Get the Created. * - * @return DateTime + * @return DateTimeInterface */ public function getCreated() { @@ -198,7 +199,7 @@ class Bookmark /** * Get the Updated. * - * @return DateTime + * @return DateTimeInterface */ public function getUpdated() { @@ -270,7 +271,7 @@ class Bookmark * Set the Created. * Note: you shouldn't set this manually except for special cases (like bookmark import) * - * @param DateTime $created + * @param DateTimeInterface $created * * @return Bookmark */ @@ -284,7 +285,7 @@ class Bookmark /** * Set the Updated. * - * @param DateTime $updated + * @param DateTimeInterface $updated * * @return Bookmark */ diff --git a/application/bookmark/BookmarkFileService.php b/application/bookmark/BookmarkFileService.php index 3b3812af..7439d8d8 100644 --- a/application/bookmark/BookmarkFileService.php +++ b/application/bookmark/BookmarkFileService.php @@ -53,7 +53,7 @@ class BookmarkFileService implements BookmarkServiceInterface { $this->conf = $conf; $this->history = $history; - $this->pageCacheManager = new PageCacheManager($this->conf->get('resource.page_cache')); + $this->pageCacheManager = new PageCacheManager($this->conf->get('resource.page_cache'), $isLoggedIn); $this->bookmarksIO = new BookmarkIO($this->conf); $this->isLoggedIn = $isLoggedIn; diff --git a/application/container/ContainerBuilder.php b/application/container/ContainerBuilder.php index c5c4a2c3..199f3f67 100644 --- a/application/container/ContainerBuilder.php +++ b/application/container/ContainerBuilder.php @@ -94,7 +94,10 @@ class ContainerBuilder }; $container['pageCacheManager'] = function (ShaarliContainer $container): PageCacheManager { - return new PageCacheManager($container->conf->get('resource.page_cache')); + return new PageCacheManager( + $container->conf->get('resource.page_cache'), + $container->loginManager->isLoggedIn() + ); }; return $container; diff --git a/application/front/controllers/DailyController.php b/application/front/controllers/DailyController.php index 271c0ee2..4a0735aa 100644 --- a/application/front/controllers/DailyController.php +++ b/application/front/controllers/DailyController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Shaarli\Front\Controller; use DateTime; +use DateTimeImmutable; use Shaarli\Bookmark\Bookmark; use Slim\Http\Request; use Slim\Http\Response; @@ -18,6 +19,8 @@ use Slim\Http\Response; */ class DailyController extends ShaarliController { + public static $DAILY_RSS_NB_DAYS = 8; + /** * Controller displaying all bookmarks published in a single day. * It take a `day` date query parameter (format YYYYMMDD). @@ -87,6 +90,77 @@ class DailyController extends ShaarliController return $response->write($this->render('daily')); } + /** + * Daily RSS feed: 1 RSS entry per day giving all the bookmarks on that day. + * Gives the last 7 days (which have bookmarks). + * This RSS feed cannot be filtered and does not trigger plugins yet. + */ + public function rss(Request $request, Response $response): Response + { + $response = $response->withHeader('Content-Type', 'application/rss+xml; charset=utf-8'); + + $pageUrl = page_url($this->container->environment); + $cache = $this->container->pageCacheManager->getCachePage($pageUrl); + + $cached = $cache->cachedVersion(); + if (!empty($cached)) { + return $response->write($cached); + } + + $days = []; + foreach ($this->container->bookmarkService->search() as $bookmark) { + $day = $bookmark->getCreated()->format('Ymd'); + + // Stop iterating after DAILY_RSS_NB_DAYS entries + if (count($days) === static::$DAILY_RSS_NB_DAYS && !isset($days[$day])) { + break; + } + + $days[$day][] = $bookmark; + } + + // Build the RSS feed. + $indexUrl = escape(index_url($this->container->environment)); + + $formatter = $this->container->formatterFactory->getFormatter(); + $formatter->addContextData('index_url', $indexUrl); + + $dataPerDay = []; + + /** @var Bookmark[] $bookmarks */ + foreach ($days as $day => $bookmarks) { + $dayDatetime = DateTimeImmutable::createFromFormat(Bookmark::LINK_DATE_FORMAT, $day.'_000000'); + $dataPerDay[$day] = [ + 'date' => $dayDatetime, + 'date_rss' => $dayDatetime->format(DateTime::RSS), + 'date_human' => format_date($dayDatetime, false, true), + 'absolute_url' => $indexUrl . '/daily?day=' . $day, + 'links' => [], + ]; + + foreach ($bookmarks as $key => $bookmark) { + $dataPerDay[$day]['links'][$key] = $formatter->format($bookmark); + + // Make permalink URL absolute + if ($bookmark->isNote()) { + $dataPerDay[$day]['links'][$key]['url'] = $indexUrl . $bookmark->getUrl(); + } + } + } + + $this->assignView('title', $this->container->conf->get('general.title', 'Shaarli')); + $this->assignView('index_url', $indexUrl); + $this->assignView('page_url', $pageUrl); + $this->assignView('hide_timestamps', $this->container->conf->get('privacy.hide_timestamps', false)); + $this->assignView('days', $dataPerDay); + + $rssContent = $this->render('dailyrss'); + + $cache->cache($rssContent); + + return $response->write($rssContent); + } + /** * We need to spread the articles on 3 columns. * did not want to use a JavaScript lib like http://masonry.desandro.com/ diff --git a/application/http/HttpUtils.php b/application/http/HttpUtils.php index 2ea9195d..f00c4336 100644 --- a/application/http/HttpUtils.php +++ b/application/http/HttpUtils.php @@ -369,7 +369,7 @@ function server_url($server) */ function index_url($server) { - $scriptname = $server['SCRIPT_NAME']; + $scriptname = $server['SCRIPT_NAME'] ?? ''; if (endsWith($scriptname, 'index.php')) { $scriptname = substr($scriptname, 0, -9); } @@ -377,7 +377,7 @@ function index_url($server) } /** - * Returns the absolute URL of the current script, with the query + * Returns the absolute URL of the current script, with current route and query * * If the resource is "index.php", then it is removed (for better-looking URLs) * @@ -387,10 +387,17 @@ function index_url($server) */ function page_url($server) { + $scriptname = $server['SCRIPT_NAME'] ?? ''; + if (endsWith($scriptname, 'index.php')) { + $scriptname = substr($scriptname, 0, -9); + } + + $route = ltrim($server['REQUEST_URI'] ?? '', $scriptname); if (! empty($server['QUERY_STRING'])) { - return index_url($server).'?'.$server['QUERY_STRING']; + return index_url($server) . $route . '?' . $server['QUERY_STRING']; } - return index_url($server); + + return index_url($server) . $route; } /** diff --git a/application/legacy/LegacyLinkDB.php b/application/legacy/LegacyLinkDB.php index 947005ad..7bf76fd4 100644 --- a/application/legacy/LegacyLinkDB.php +++ b/application/legacy/LegacyLinkDB.php @@ -353,7 +353,7 @@ You use the community supported version of the original Shaarli project, by Seba $this->write(); - $pageCacheManager = new PageCacheManager($pageCacheDir); + $pageCacheManager = new PageCacheManager($pageCacheDir, $this->loggedIn); $pageCacheManager->invalidateCaches(); } diff --git a/application/render/PageCacheManager.php b/application/render/PageCacheManager.php index bd91fe0d..97805c35 100644 --- a/application/render/PageCacheManager.php +++ b/application/render/PageCacheManager.php @@ -2,6 +2,8 @@ namespace Shaarli\Render; +use Shaarli\Feed\CachedPage; + /** * Cache utilities */ @@ -10,9 +12,13 @@ class PageCacheManager /** @var string Cache directory */ protected $pageCacheDir; - public function __construct(string $pageCacheDir) + /** @var bool */ + protected $isLoggedIn; + + public function __construct(string $pageCacheDir, bool $isLoggedIn) { $this->pageCacheDir = $pageCacheDir; + $this->isLoggedIn = $isLoggedIn; } /** @@ -42,4 +48,13 @@ class PageCacheManager // Purge page cache shared by sessions. $this->purgeCachedPages(); } + + public function getCachePage(string $pageUrl): CachedPage + { + return new CachedPage( + $this->pageCacheDir, + $pageUrl, + false === $this->isLoggedIn + ); + } } -- cgit v1.2.3 From f4929b1188b4bc5e92b925ebc44f5ad40bb1a4ed Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Mon, 18 May 2020 13:03:13 +0200 Subject: Make FeedBuilder instance creation independant of the request stack --- application/feed/FeedBuilder.php | 139 +++++++++++++++++++-------------------- 1 file changed, 66 insertions(+), 73 deletions(-) (limited to 'application') diff --git a/application/feed/FeedBuilder.php b/application/feed/FeedBuilder.php index 40bd4f15..bcf27c2c 100644 --- a/application/feed/FeedBuilder.php +++ b/application/feed/FeedBuilder.php @@ -43,21 +43,9 @@ class FeedBuilder */ protected $formatter; - /** - * @var string RSS or ATOM feed. - */ - protected $feedType; - - /** - * @var array $_SERVER - */ + /** @var mixed[] $_SERVER */ protected $serverInfo; - /** - * @var array $_GET - */ - protected $userInput; - /** * @var boolean True if the user is currently logged in, false otherwise. */ @@ -77,7 +65,6 @@ class FeedBuilder * @var string server locale. */ protected $locale; - /** * @var DateTime Latest item date. */ @@ -88,37 +75,36 @@ class FeedBuilder * * @param BookmarkServiceInterface $linkDB LinkDB instance. * @param BookmarkFormatter $formatter instance. - * @param string $feedType Type of feed. * @param array $serverInfo $_SERVER. - * @param array $userInput $_GET. * @param boolean $isLoggedIn True if the user is currently logged in, false otherwise. */ - public function __construct($linkDB, $formatter, $feedType, $serverInfo, $userInput, $isLoggedIn) + public function __construct($linkDB, $formatter, array $serverInfo, $isLoggedIn) { $this->linkDB = $linkDB; $this->formatter = $formatter; - $this->feedType = $feedType; $this->serverInfo = $serverInfo; - $this->userInput = $userInput; $this->isLoggedIn = $isLoggedIn; } /** * Build data for feed templates. * + * @param string $feedType Type of feed (RSS/ATOM). + * @param array $userInput $_GET. + * * @return array Formatted data for feeds templates. */ - public function buildData() + public function buildData(string $feedType, ?array $userInput) { // Search for untagged bookmarks - if (isset($this->userInput['searchtags']) && empty($this->userInput['searchtags'])) { - $this->userInput['searchtags'] = false; + if (isset($this->userInput['searchtags']) && empty($userInput['searchtags'])) { + $userInput['searchtags'] = false; } // Optionally filter the results: - $linksToDisplay = $this->linkDB->search($this->userInput); + $linksToDisplay = $this->linkDB->search($userInput); - $nblinksToDisplay = $this->getNbLinks(count($linksToDisplay)); + $nblinksToDisplay = $this->getNbLinks(count($linksToDisplay), $userInput); // Can't use array_keys() because $link is a LinkDB instance and not a real array. $keys = array(); @@ -130,11 +116,11 @@ class FeedBuilder $this->formatter->addContextData('index_url', $pageaddr); $linkDisplayed = array(); for ($i = 0; $i < $nblinksToDisplay && $i < count($keys); $i++) { - $linkDisplayed[$keys[$i]] = $this->buildItem($linksToDisplay[$keys[$i]], $pageaddr); + $linkDisplayed[$keys[$i]] = $this->buildItem($feedType, $linksToDisplay[$keys[$i]], $pageaddr); } - $data['language'] = $this->getTypeLanguage(); - $data['last_update'] = $this->getLatestDateFormatted(); + $data['language'] = $this->getTypeLanguage($feedType); + $data['last_update'] = $this->getLatestDateFormatted($feedType); $data['show_dates'] = !$this->hideDates || $this->isLoggedIn; // Remove leading slash from REQUEST_URI. $data['self_link'] = escape(server_url($this->serverInfo)) @@ -146,15 +132,46 @@ class FeedBuilder return $data; } + /** + * Set this to true to use permalinks instead of direct bookmarks. + * + * @param boolean $usePermalinks true to force permalinks. + */ + public function setUsePermalinks($usePermalinks) + { + $this->usePermalinks = $usePermalinks; + } + + /** + * Set this to true to hide timestamps in feeds. + * + * @param boolean $hideDates true to enable. + */ + public function setHideDates($hideDates) + { + $this->hideDates = $hideDates; + } + + /** + * Set the locale. Used to show feed language. + * + * @param string $locale The locale (eg. 'fr_FR.UTF8'). + */ + public function setLocale($locale) + { + $this->locale = strtolower($locale); + } + /** * Build a feed item (one per shaare). * + * @param string $feedType Type of feed (RSS/ATOM). * @param Bookmark $link Single link array extracted from LinkDB. * @param string $pageaddr Index URL. * * @return array Link array with feed attributes. */ - protected function buildItem($link, $pageaddr) + protected function buildItem(string $feedType, $link, $pageaddr) { $data = $this->formatter->format($link); $data['guid'] = $pageaddr . '?' . $data['shorturl']; @@ -165,13 +182,13 @@ class FeedBuilder } $data['description'] .= PHP_EOL . PHP_EOL . '
— ' . $permalink; - $data['pub_iso_date'] = $this->getIsoDate($data['created']); + $data['pub_iso_date'] = $this->getIsoDate($feedType, $data['created']); // atom:entry elements MUST contain exactly one atom:updated element. if (!empty($link->getUpdated())) { - $data['up_iso_date'] = $this->getIsoDate($data['updated'], DateTime::ATOM); + $data['up_iso_date'] = $this->getIsoDate($feedType, $data['updated'], DateTime::ATOM); } else { - $data['up_iso_date'] = $this->getIsoDate($data['created'], DateTime::ATOM); + $data['up_iso_date'] = $this->getIsoDate($feedType, $data['created'], DateTime::ATOM); } // Save the more recent item. @@ -185,52 +202,24 @@ class FeedBuilder return $data; } - /** - * Set this to true to use permalinks instead of direct bookmarks. - * - * @param boolean $usePermalinks true to force permalinks. - */ - public function setUsePermalinks($usePermalinks) - { - $this->usePermalinks = $usePermalinks; - } - - /** - * Set this to true to hide timestamps in feeds. - * - * @param boolean $hideDates true to enable. - */ - public function setHideDates($hideDates) - { - $this->hideDates = $hideDates; - } - - /** - * Set the locale. Used to show feed language. - * - * @param string $locale The locale (eg. 'fr_FR.UTF8'). - */ - public function setLocale($locale) - { - $this->locale = strtolower($locale); - } - /** * Get the language according to the feed type, based on the locale: * * - RSS format: en-us (default: 'en-en'). * - ATOM format: fr (default: 'en'). * + * @param string $feedType Type of feed (RSS/ATOM). + * * @return string The language. */ - public function getTypeLanguage() + protected function getTypeLanguage(string $feedType) { // Use the locale do define the language, if available. if (!empty($this->locale) && preg_match('/^\w{2}[_\-]\w{2}/', $this->locale)) { - $length = ($this->feedType === self::$FEED_RSS) ? 5 : 2; + $length = ($feedType === self::$FEED_RSS) ? 5 : 2; return str_replace('_', '-', substr($this->locale, 0, $length)); } - return ($this->feedType === self::$FEED_RSS) ? 'en-en' : 'en'; + return ($feedType === self::$FEED_RSS) ? 'en-en' : 'en'; } /** @@ -238,32 +227,35 @@ class FeedBuilder * * Return an empty string if invalid DateTime is passed. * + * @param string $feedType Type of feed (RSS/ATOM). + * * @return string Formatted date. */ - protected function getLatestDateFormatted() + protected function getLatestDateFormatted(string $feedType) { if (empty($this->latestDate) || !$this->latestDate instanceof DateTime) { return ''; } - $type = ($this->feedType == self::$FEED_RSS) ? DateTime::RSS : DateTime::ATOM; + $type = ($feedType == self::$FEED_RSS) ? DateTime::RSS : DateTime::ATOM; return $this->latestDate->format($type); } /** * Get ISO date from DateTime according to feed type. * + * @param string $feedType Type of feed (RSS/ATOM). * @param DateTime $date Date to format. * @param string|bool $format Force format. * * @return string Formatted date. */ - protected function getIsoDate(DateTime $date, $format = false) + protected function getIsoDate(string $feedType, DateTime $date, $format = false) { if ($format !== false) { return $date->format($format); } - if ($this->feedType == self::$FEED_RSS) { + if ($feedType == self::$FEED_RSS) { return $date->format(DateTime::RSS); } return $date->format(DateTime::ATOM); @@ -275,21 +267,22 @@ class FeedBuilder * If 'nb' not set or invalid, default value: $DEFAULT_NB_LINKS. * If 'nb' is set to 'all', display all filtered bookmarks (max parameter). * - * @param int $max maximum number of bookmarks to display. + * @param int $max maximum number of bookmarks to display. + * @param array $userInput $_GET. * * @return int number of bookmarks to display. */ - public function getNbLinks($max) + protected function getNbLinks($max, ?array $userInput) { - if (empty($this->userInput['nb'])) { + if (empty($userInput['nb'])) { return self::$DEFAULT_NB_LINKS; } - if ($this->userInput['nb'] == 'all') { + if ($userInput['nb'] == 'all') { return $max; } - $intNb = intval($this->userInput['nb']); + $intNb = intval($userInput['nb']); if (!is_int($intNb) || $intNb == 0) { return self::$DEFAULT_NB_LINKS; } -- cgit v1.2.3 From 7b2ba6ef820335df682fbe3dcfaceef3a62cf4a5 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Mon, 18 May 2020 17:17:36 +0200 Subject: RSS/ATOM feeds: process through Slim controller --- application/container/ContainerBuilder.php | 10 +++ application/container/ShaarliContainer.php | 2 + application/feed/FeedBuilder.php | 2 +- application/front/controllers/FeedController.php | 79 ++++++++++++++++++++++ .../front/controllers/ShaarliController.php | 14 ++++ 5 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 application/front/controllers/FeedController.php (limited to 'application') diff --git a/application/container/ContainerBuilder.php b/application/container/ContainerBuilder.php index 199f3f67..84406979 100644 --- a/application/container/ContainerBuilder.php +++ b/application/container/ContainerBuilder.php @@ -7,6 +7,7 @@ namespace Shaarli\Container; use Shaarli\Bookmark\BookmarkFileService; use Shaarli\Bookmark\BookmarkServiceInterface; use Shaarli\Config\ConfigManager; +use Shaarli\Feed\FeedBuilder; use Shaarli\Formatter\FormatterFactory; use Shaarli\History; use Shaarli\Plugin\PluginManager; @@ -100,6 +101,15 @@ class ContainerBuilder ); }; + $container['feedBuilder'] = function (ShaarliContainer $container): FeedBuilder { + return new FeedBuilder( + $container->bookmarkService, + $container->formatterFactory->getFormatter(), + $container->environment, + $container->loginManager->isLoggedIn() + ); + }; + return $container; } } diff --git a/application/container/ShaarliContainer.php b/application/container/ShaarliContainer.php index 3995f669..deb07197 100644 --- a/application/container/ShaarliContainer.php +++ b/application/container/ShaarliContainer.php @@ -6,6 +6,7 @@ namespace Shaarli\Container; use Shaarli\Bookmark\BookmarkServiceInterface; use Shaarli\Config\ConfigManager; +use Shaarli\Feed\FeedBuilder; use Shaarli\Formatter\FormatterFactory; use Shaarli\History; use Shaarli\Plugin\PluginManager; @@ -29,6 +30,7 @@ use Slim\Container; * @property PluginManager $pluginManager * @property FormatterFactory $formatterFactory * @property PageCacheManager $pageCacheManager + * @property FeedBuilder $feedBuilder */ class ShaarliContainer extends Container { diff --git a/application/feed/FeedBuilder.php b/application/feed/FeedBuilder.php index bcf27c2c..c97ae1ea 100644 --- a/application/feed/FeedBuilder.php +++ b/application/feed/FeedBuilder.php @@ -78,7 +78,7 @@ class FeedBuilder * @param array $serverInfo $_SERVER. * @param boolean $isLoggedIn True if the user is currently logged in, false otherwise. */ - public function __construct($linkDB, $formatter, array $serverInfo, $isLoggedIn) + public function __construct($linkDB, $formatter, $serverInfo, $isLoggedIn) { $this->linkDB = $linkDB; $this->formatter = $formatter; diff --git a/application/front/controllers/FeedController.php b/application/front/controllers/FeedController.php new file mode 100644 index 00000000..78d826d9 --- /dev/null +++ b/application/front/controllers/FeedController.php @@ -0,0 +1,79 @@ +processRequest(FeedBuilder::$FEED_ATOM, $request, $response); + } + + public function rss(Request $request, Response $response): Response + { + return $this->processRequest(FeedBuilder::$FEED_RSS, $request, $response); + } + + protected function processRequest(string $feedType, Request $request, Response $response): Response + { + $response = $response->withHeader('Content-Type', 'application/'. $feedType .'+xml; charset=utf-8'); + + $pageUrl = page_url($this->container->environment); + $cache = $this->container->pageCacheManager->getCachePage($pageUrl); + + $cached = $cache->cachedVersion(); + if (!empty($cached)) { + return $response->write($cached); + } + + // Generate data. + $this->container->feedBuilder->setLocale(strtolower(setlocale(LC_COLLATE, 0))); + $this->container->feedBuilder->setHideDates($this->container->conf->get('privacy.hide_timestamps', false)); + $this->container->feedBuilder->setUsePermalinks( + null !== $request->getParam('permalinks') || !$this->container->conf->get('feed.rss_permalinks') + ); + + $data = $this->container->feedBuilder->buildData($feedType, $request->getParams()); + + $this->executeHooks($data, $feedType); + $this->assignAllView($data); + + $content = $this->render('feed.'. $feedType); + + $cache->cache($content); + + return $response->write($content); + } + + /** + * @param mixed[] $data Template data + * + * @return mixed[] Template data after active plugins hook execution. + */ + protected function executeHooks(array $data, string $feedType): array + { + $this->container->pluginManager->executeHooks( + 'render_feed', + $data, + [ + 'loggedin' => $this->container->loginManager->isLoggedIn(), + 'target' => $feedType, + ] + ); + + return $data; + } +} diff --git a/application/front/controllers/ShaarliController.php b/application/front/controllers/ShaarliController.php index 2b828588..0c5d363e 100644 --- a/application/front/controllers/ShaarliController.php +++ b/application/front/controllers/ShaarliController.php @@ -30,6 +30,20 @@ abstract class ShaarliController return $this; } + /** + * Assign variables to RainTPL template through the PageBuilder. + * + * @param mixed $data Values to assign to the template and their keys + */ + protected function assignAllView(array $data): self + { + foreach ($data as $key => $value) { + $this->assignView($key, $value); + } + + return $this; + } + protected function render(string $template): string { $this->assignView('linkcount', $this->container->bookmarkService->count(BookmarkFilter::$ALL)); -- cgit v1.2.3 From 5ec4708ced1cdca01eddd7e52377ab5e5f8b3290 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Wed, 20 May 2020 10:47:20 +0200 Subject: Process OpenSearch controller through Slim Also it was missing on the default template feeds --- .../front/controllers/OpenSearchController.php | 28 ++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 application/front/controllers/OpenSearchController.php (limited to 'application') diff --git a/application/front/controllers/OpenSearchController.php b/application/front/controllers/OpenSearchController.php new file mode 100644 index 00000000..fa32c5f1 --- /dev/null +++ b/application/front/controllers/OpenSearchController.php @@ -0,0 +1,28 @@ +withHeader('Content-Type', 'application/opensearchdescription+xml; charset=utf-8'); + + $this->assignView('serverurl', index_url($this->container->environment)); + + return $response->write($this->render('opensearch')); + } +} -- cgit v1.2.3 From 893f5159c64e5bcff505c8367e6dc22cc2a7b14d Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Wed, 20 May 2020 14:38:31 +0200 Subject: Process remove tag endpoint through Slim controller --- application/front/controllers/TagController.php | 48 ++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) (limited to 'application') diff --git a/application/front/controllers/TagController.php b/application/front/controllers/TagController.php index 598275b0..a1d5ad5b 100644 --- a/application/front/controllers/TagController.php +++ b/application/front/controllers/TagController.php @@ -35,7 +35,7 @@ class TagController extends ShaarliController return $response->withRedirect('./'); } - $currentUrl = parse_url($this->container->environment['HTTP_REFERER']); + $currentUrl = parse_url($referer); parse_str($currentUrl['query'] ?? '', $params); if (null === $newTag) { @@ -71,4 +71,50 @@ class TagController extends ShaarliController return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params)); } + + /** + * Remove a tag from the current search through an HTTP redirection. + * + * @param array $args Should contain `tag` key as tag to remove from current search + */ + public function removeTag(Request $request, Response $response, array $args): Response + { + $referer = $this->container->environment['HTTP_REFERER'] ?? null; + + // If the referrer is not provided, we can update the search, so we failback on the bookmark list + if (empty($referer)) { + return $response->withRedirect('./'); + } + + $tagToRemove = $args['tag'] ?? null; + $currentUrl = parse_url($referer); + parse_str($currentUrl['query'] ?? '', $params); + + if (null === $tagToRemove) { + return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params)); + } + + // Prevent redirection loop + if (isset($params['removetag'])) { + unset($params['removetag']); + } + + if (isset($params['searchtags'])) { + $tags = explode(' ', $params['searchtags']); + // Remove value from array $tags. + $tags = array_diff($tags, [$tagToRemove]); + $params['searchtags'] = implode(' ', $tags); + + if (empty($params['searchtags'])) { + unset($params['searchtags']); + } + + // We also remove page (keeping the same page has no sense, since the results are different) + unset($params['page']); + } + + $queryParams = count($params) > 0 ? '?' . http_build_query($params) : ''; + + return $response->withRedirect(($currentUrl['path'] ?? './') . $queryParams); + } } -- cgit v1.2.3 From af290059d10319e76d1e7d78b592cab99c26d91a Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Fri, 22 May 2020 11:02:56 +0200 Subject: Process session filters through Slim controllers Including: - visibility - links per page - untagged only --- .../front/controllers/SessionFilterController.php | 81 ++++++++++++++++++++++ .../front/controllers/ShaarliController.php | 43 ++++++++++++ application/security/SessionManager.php | 33 +++++++++ 3 files changed, 157 insertions(+) create mode 100644 application/front/controllers/SessionFilterController.php (limited to 'application') diff --git a/application/front/controllers/SessionFilterController.php b/application/front/controllers/SessionFilterController.php new file mode 100644 index 00000000..a021dc37 --- /dev/null +++ b/application/front/controllers/SessionFilterController.php @@ -0,0 +1,81 @@ +getParam('nb') ?? null; + if (null === $linksPerPage || false === is_numeric($linksPerPage)) { + $linksPerPage = $this->container->conf->get('general.links_per_page', 20); + } + + $this->container->sessionManager->setSessionParameter( + SessionManager::KEY_LINKS_PER_PAGE, + abs(intval($linksPerPage)) + ); + + return $this->redirectFromReferer($response, ['linksperpage'], ['nb']); + } + + /** + * GET /visibility: allows to display only public or only private bookmarks in linklist + */ + public function visibility(Request $request, Response $response, array $args): Response + { + if (false === $this->container->loginManager->isLoggedIn()) { + return $this->redirectFromReferer($response, ['visibility']); + } + + $newVisibility = $args['visibility'] ?? null; + if (false === in_array($newVisibility, [BookmarkFilter::$PRIVATE, BookmarkFilter::$PUBLIC], true)) { + $newVisibility = null; + } + + $currentVisibility = $this->container->sessionManager->getSessionParameter(SessionManager::KEY_VISIBILITY); + + // Visibility not set or not already expected value, set expected value, otherwise reset it + if ($newVisibility !== null && (null === $currentVisibility || $currentVisibility !== $newVisibility)) { + // See only public bookmarks + $this->container->sessionManager->setSessionParameter( + SessionManager::KEY_VISIBILITY, + $newVisibility + ); + } else { + $this->container->sessionManager->deleteSessionParameter(SessionManager::KEY_VISIBILITY); + } + + return $this->redirectFromReferer($response, ['visibility']); + } + + /** + * GET /untagged-only: allows to display only bookmarks without any tag + */ + public function untaggedOnly(Request $request, Response $response): Response + { + $this->container->sessionManager->setSessionParameter( + SessionManager::KEY_UNTAGGED_ONLY, + empty($this->container->sessionManager->getSessionParameter(SessionManager::KEY_UNTAGGED_ONLY)) + ); + + return $this->redirectFromReferer($response, ['untaggedonly', 'untagged-only']); + } +} diff --git a/application/front/controllers/ShaarliController.php b/application/front/controllers/ShaarliController.php index 0c5d363e..bfff5fcf 100644 --- a/application/front/controllers/ShaarliController.php +++ b/application/front/controllers/ShaarliController.php @@ -6,6 +6,7 @@ namespace Shaarli\Front\Controller; use Shaarli\Bookmark\BookmarkFilter; use Shaarli\Container\ShaarliContainer; +use Slim\Http\Response; abstract class ShaarliController { @@ -80,4 +81,46 @@ abstract class ShaarliController $this->assignView('plugins_' . $name, $plugin_data); } } + + /** + * Generates a redirection to the previous page, based on the HTTP_REFERER. + * It fails back to the home page. + * + * @param array $loopTerms Terms to remove from path and query string to prevent direction loop. + * @param array $clearParams List of parameter to remove from the query string of the referrer. + */ + protected function redirectFromReferer(Response $response, array $loopTerms = [], array $clearParams = []): Response + { + $defaultPath = './'; + $referer = $this->container->environment['HTTP_REFERER'] ?? null; + + if (null !== $referer) { + $currentUrl = parse_url($referer); + parse_str($currentUrl['query'] ?? '', $params); + $path = $currentUrl['path'] ?? $defaultPath; + } else { + $params = []; + $path = $defaultPath; + } + + // Prevent redirection loop + if (isset($currentUrl)) { + foreach ($clearParams as $value) { + unset($params[$value]); + } + + $checkQuery = implode('', array_keys($params)); + foreach ($loopTerms as $value) { + if (strpos($path . $checkQuery, $value) !== false) { + $params = []; + $path = $defaultPath; + break; + } + } + } + + $queryString = count($params) > 0 ? '?'. http_build_query($params) : ''; + + return $response->withRedirect($path . $queryString); + } } diff --git a/application/security/SessionManager.php b/application/security/SessionManager.php index 4ae99168..8b77d362 100644 --- a/application/security/SessionManager.php +++ b/application/security/SessionManager.php @@ -8,6 +8,10 @@ use Shaarli\Config\ConfigManager; */ class SessionManager { + public const KEY_LINKS_PER_PAGE = 'LINKS_PER_PAGE'; + public const KEY_VISIBILITY = 'visibility'; + public const KEY_UNTAGGED_ONLY = 'untaggedonly'; + /** @var int Session expiration timeout, in seconds */ public static $SHORT_TIMEOUT = 3600; // 1 hour @@ -212,4 +216,33 @@ class SessionManager { return $this->session[$key] ?? $default; } + + /** + * Store a variable in user session. + * + * @param string $key Session key + * @param mixed $value Session value to store + * + * @return $this + */ + public function setSessionParameter(string $key, $value): self + { + $this->session[$key] = $value; + + return $this; + } + + /** + * Store a variable in user session. + * + * @param string $key Session key + * + * @return $this + */ + public function deleteSessionParameter(string $key): self + { + unset($this->session[$key]); + + return $this; + } } -- cgit v1.2.3 From 2899ebb5b5e82890c877151f5c02045266ac9973 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Fri, 22 May 2020 13:20:31 +0200 Subject: Initialize admin Slim controllers - Reorganize visitor controllers - Fix redirection with Slim's requests base path - Fix daily links --- application/front/ShaarliMiddleware.php | 7 +- .../front/controller/admin/LogoutController.php | 29 +++ .../controller/admin/SessionFilterController.php | 79 ++++++++ .../controller/admin/ShaarliAdminController.php | 21 +++ .../front/controller/visitor/DailyController.php | 208 ++++++++++++++++++++ .../front/controller/visitor/FeedController.php | 77 ++++++++ .../front/controller/visitor/LoginController.php | 46 +++++ .../controller/visitor/OpenSearchController.php | 26 +++ .../controller/visitor/PictureWallController.php | 70 +++++++ .../visitor/ShaarliVisitorController.php | 131 +++++++++++++ .../controller/visitor/TagCloudController.php | 131 +++++++++++++ .../front/controller/visitor/TagController.php | 118 ++++++++++++ application/front/controllers/DailyController.php | 210 --------------------- application/front/controllers/FeedController.php | 79 -------- application/front/controllers/LoginController.php | 48 ----- application/front/controllers/LogoutController.php | 31 --- .../front/controllers/OpenSearchController.php | 28 --- .../front/controllers/PictureWallController.php | 72 ------- .../front/controllers/SessionFilterController.php | 81 -------- .../front/controllers/ShaarliController.php | 126 ------------- .../front/controllers/TagCloudController.php | 133 ------------- application/front/controllers/TagController.php | 120 ------------ .../front/exceptions/LoginBannedException.php | 2 +- application/front/exceptions/ShaarliException.php | 23 --- .../front/exceptions/ShaarliFrontException.php | 23 +++ .../exceptions/ThumbnailsDisabledException.php | 2 +- .../front/exceptions/UnauthorizedException.php | 15 ++ 27 files changed, 981 insertions(+), 955 deletions(-) create mode 100644 application/front/controller/admin/LogoutController.php create mode 100644 application/front/controller/admin/SessionFilterController.php create mode 100644 application/front/controller/admin/ShaarliAdminController.php create mode 100644 application/front/controller/visitor/DailyController.php create mode 100644 application/front/controller/visitor/FeedController.php create mode 100644 application/front/controller/visitor/LoginController.php create mode 100644 application/front/controller/visitor/OpenSearchController.php create mode 100644 application/front/controller/visitor/PictureWallController.php create mode 100644 application/front/controller/visitor/ShaarliVisitorController.php create mode 100644 application/front/controller/visitor/TagCloudController.php create mode 100644 application/front/controller/visitor/TagController.php delete mode 100644 application/front/controllers/DailyController.php delete mode 100644 application/front/controllers/FeedController.php delete mode 100644 application/front/controllers/LoginController.php delete mode 100644 application/front/controllers/LogoutController.php delete mode 100644 application/front/controllers/OpenSearchController.php delete mode 100644 application/front/controllers/PictureWallController.php delete mode 100644 application/front/controllers/SessionFilterController.php delete mode 100644 application/front/controllers/ShaarliController.php delete mode 100644 application/front/controllers/TagCloudController.php delete mode 100644 application/front/controllers/TagController.php delete mode 100644 application/front/exceptions/ShaarliException.php create mode 100644 application/front/exceptions/ShaarliFrontException.php create mode 100644 application/front/exceptions/UnauthorizedException.php (limited to 'application') diff --git a/application/front/ShaarliMiddleware.php b/application/front/ShaarliMiddleware.php index fa6c6467..f8992e0b 100644 --- a/application/front/ShaarliMiddleware.php +++ b/application/front/ShaarliMiddleware.php @@ -3,7 +3,8 @@ namespace Shaarli\Front; use Shaarli\Container\ShaarliContainer; -use Shaarli\Front\Exception\ShaarliException; +use Shaarli\Front\Exception\ShaarliFrontException; +use Shaarli\Front\Exception\UnauthorizedException; use Slim\Http\Request; use Slim\Http\Response; @@ -39,7 +40,7 @@ class ShaarliMiddleware { try { $response = $next($request, $response); - } catch (ShaarliException $e) { + } catch (ShaarliFrontException $e) { $this->container->pageBuilder->assign('message', $e->getMessage()); if ($this->container->conf->get('dev.debug', false)) { $this->container->pageBuilder->assign( @@ -50,6 +51,8 @@ class ShaarliMiddleware $response = $response->withStatus($e->getCode()); $response = $response->write($this->container->pageBuilder->render('error')); + } catch (UnauthorizedException $e) { + return $response->withRedirect($request->getUri()->getBasePath() . '/login'); } return $response; diff --git a/application/front/controller/admin/LogoutController.php b/application/front/controller/admin/LogoutController.php new file mode 100644 index 00000000..41e81984 --- /dev/null +++ b/application/front/controller/admin/LogoutController.php @@ -0,0 +1,29 @@ +container->pageCacheManager->invalidateCaches(); + $this->container->sessionManager->logout(); + + // TODO: switch to a simple Cookie manager allowing to check the session, and create mocks. + setcookie(LoginManager::$STAY_SIGNED_IN_COOKIE, 'false', 0, $this->container->webPath); + + return $response->withRedirect('./'); + } +} diff --git a/application/front/controller/admin/SessionFilterController.php b/application/front/controller/admin/SessionFilterController.php new file mode 100644 index 00000000..69a16ec3 --- /dev/null +++ b/application/front/controller/admin/SessionFilterController.php @@ -0,0 +1,79 @@ +getParam('nb') ?? null; + if (null === $linksPerPage || false === is_numeric($linksPerPage)) { + $linksPerPage = $this->container->conf->get('general.links_per_page', 20); + } + + $this->container->sessionManager->setSessionParameter( + SessionManager::KEY_LINKS_PER_PAGE, + abs(intval($linksPerPage)) + ); + + return $this->redirectFromReferer($request, $response, ['linksperpage'], ['nb']); + } + + /** + * GET /visibility: allows to display only public or only private bookmarks in linklist + */ + public function visibility(Request $request, Response $response, array $args): Response + { + if (false === $this->container->loginManager->isLoggedIn()) { + return $this->redirectFromReferer($request, $response, ['visibility']); + } + + $newVisibility = $args['visibility'] ?? null; + if (false === in_array($newVisibility, [BookmarkFilter::$PRIVATE, BookmarkFilter::$PUBLIC], true)) { + $newVisibility = null; + } + + $currentVisibility = $this->container->sessionManager->getSessionParameter(SessionManager::KEY_VISIBILITY); + + // Visibility not set or not already expected value, set expected value, otherwise reset it + if ($newVisibility !== null && (null === $currentVisibility || $currentVisibility !== $newVisibility)) { + // See only public bookmarks + $this->container->sessionManager->setSessionParameter( + SessionManager::KEY_VISIBILITY, + $newVisibility + ); + } else { + $this->container->sessionManager->deleteSessionParameter(SessionManager::KEY_VISIBILITY); + } + + return $this->redirectFromReferer($request, $response, ['visibility']); + } + + /** + * GET /untagged-only: allows to display only bookmarks without any tag + */ + public function untaggedOnly(Request $request, Response $response): Response + { + $this->container->sessionManager->setSessionParameter( + SessionManager::KEY_UNTAGGED_ONLY, + empty($this->container->sessionManager->getSessionParameter(SessionManager::KEY_UNTAGGED_ONLY)) + ); + + return $this->redirectFromReferer($request, $response, ['untaggedonly', 'untagged-only']); + } +} diff --git a/application/front/controller/admin/ShaarliAdminController.php b/application/front/controller/admin/ShaarliAdminController.php new file mode 100644 index 00000000..ea703f62 --- /dev/null +++ b/application/front/controller/admin/ShaarliAdminController.php @@ -0,0 +1,21 @@ +container->loginManager->isLoggedIn()) { + throw new UnauthorizedException(); + } + } +} diff --git a/application/front/controller/visitor/DailyController.php b/application/front/controller/visitor/DailyController.php new file mode 100644 index 00000000..47e2503a --- /dev/null +++ b/application/front/controller/visitor/DailyController.php @@ -0,0 +1,208 @@ +getQueryParam('day') ?? date('Ymd'); + + $availableDates = $this->container->bookmarkService->days(); + $nbAvailableDates = count($availableDates); + $index = array_search($day, $availableDates); + + if ($index === false) { + // no bookmarks for day, but at least one day with bookmarks + $day = $availableDates[$nbAvailableDates - 1] ?? $day; + $previousDay = $availableDates[$nbAvailableDates - 2] ?? ''; + } else { + $previousDay = $availableDates[$index - 1] ?? ''; + $nextDay = $availableDates[$index + 1] ?? ''; + } + + if ($day === date('Ymd')) { + $this->assignView('dayDesc', t('Today')); + } elseif ($day === date('Ymd', strtotime('-1 days'))) { + $this->assignView('dayDesc', t('Yesterday')); + } + + try { + $linksToDisplay = $this->container->bookmarkService->filterDay($day); + } catch (\Exception $exc) { + $linksToDisplay = []; + } + + $formatter = $this->container->formatterFactory->getFormatter(); + // We pre-format some fields for proper output. + foreach ($linksToDisplay as $key => $bookmark) { + $linksToDisplay[$key] = $formatter->format($bookmark); + // This page is a bit specific, we need raw description to calculate the length + $linksToDisplay[$key]['formatedDescription'] = $linksToDisplay[$key]['description']; + $linksToDisplay[$key]['description'] = $bookmark->getDescription(); + } + + $dayDate = DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, $day.'_000000'); + $data = [ + 'linksToDisplay' => $linksToDisplay, + 'day' => $dayDate->getTimestamp(), + 'dayDate' => $dayDate, + 'previousday' => $previousDay ?? '', + 'nextday' => $nextDay ?? '', + ]; + + // Hooks are called before column construction so that plugins don't have to deal with columns. + $this->executeHooks($data); + + $data['cols'] = $this->calculateColumns($data['linksToDisplay']); + + foreach ($data as $key => $value) { + $this->assignView($key, $value); + } + + $mainTitle = $this->container->conf->get('general.title', 'Shaarli'); + $this->assignView( + 'pagetitle', + t('Daily') .' - '. format_date($dayDate, false) . ' - ' . $mainTitle + ); + + return $response->write($this->render('daily')); + } + + /** + * Daily RSS feed: 1 RSS entry per day giving all the bookmarks on that day. + * Gives the last 7 days (which have bookmarks). + * This RSS feed cannot be filtered and does not trigger plugins yet. + */ + public function rss(Request $request, Response $response): Response + { + $response = $response->withHeader('Content-Type', 'application/rss+xml; charset=utf-8'); + + $pageUrl = page_url($this->container->environment); + $cache = $this->container->pageCacheManager->getCachePage($pageUrl); + + $cached = $cache->cachedVersion(); + if (!empty($cached)) { + return $response->write($cached); + } + + $days = []; + foreach ($this->container->bookmarkService->search() as $bookmark) { + $day = $bookmark->getCreated()->format('Ymd'); + + // Stop iterating after DAILY_RSS_NB_DAYS entries + if (count($days) === static::$DAILY_RSS_NB_DAYS && !isset($days[$day])) { + break; + } + + $days[$day][] = $bookmark; + } + + // Build the RSS feed. + $indexUrl = escape(index_url($this->container->environment)); + + $formatter = $this->container->formatterFactory->getFormatter(); + $formatter->addContextData('index_url', $indexUrl); + + $dataPerDay = []; + + /** @var Bookmark[] $bookmarks */ + foreach ($days as $day => $bookmarks) { + $dayDatetime = DateTimeImmutable::createFromFormat(Bookmark::LINK_DATE_FORMAT, $day.'_000000'); + $dataPerDay[$day] = [ + 'date' => $dayDatetime, + 'date_rss' => $dayDatetime->format(DateTime::RSS), + 'date_human' => format_date($dayDatetime, false, true), + 'absolute_url' => $indexUrl . '/daily?day=' . $day, + 'links' => [], + ]; + + foreach ($bookmarks as $key => $bookmark) { + $dataPerDay[$day]['links'][$key] = $formatter->format($bookmark); + + // Make permalink URL absolute + if ($bookmark->isNote()) { + $dataPerDay[$day]['links'][$key]['url'] = $indexUrl . $bookmark->getUrl(); + } + } + } + + $this->assignView('title', $this->container->conf->get('general.title', 'Shaarli')); + $this->assignView('index_url', $indexUrl); + $this->assignView('page_url', $pageUrl); + $this->assignView('hide_timestamps', $this->container->conf->get('privacy.hide_timestamps', false)); + $this->assignView('days', $dataPerDay); + + $rssContent = $this->render('dailyrss'); + + $cache->cache($rssContent); + + return $response->write($rssContent); + } + + /** + * We need to spread the articles on 3 columns. + * did not want to use a JavaScript lib like http://masonry.desandro.com/ + * so I manually spread entries with a simple method: I roughly evaluate the + * height of a div according to title and description length. + */ + protected function calculateColumns(array $links): array + { + // Entries to display, for each column. + $columns = [[], [], []]; + // Rough estimate of columns fill. + $fill = [0, 0, 0]; + foreach ($links as $link) { + // Roughly estimate length of entry (by counting characters) + // Title: 30 chars = 1 line. 1 line is 30 pixels height. + // Description: 836 characters gives roughly 342 pixel height. + // This is not perfect, but it's usually OK. + $length = strlen($link['title'] ?? '') + (342 * strlen($link['description'] ?? '')) / 836; + if (! empty($link['thumbnail'])) { + $length += 100; // 1 thumbnails roughly takes 100 pixels height. + } + // Then put in column which is the less filled: + $smallest = min($fill); // find smallest value in array. + $index = array_search($smallest, $fill); // find index of this smallest value. + array_push($columns[$index], $link); // Put entry in this column. + $fill[$index] += $length; + } + + return $columns; + } + + /** + * @param mixed[] $data Variables passed to the template engine + * + * @return mixed[] Template data after active plugins render_picwall hook execution. + */ + protected function executeHooks(array $data): array + { + $this->container->pluginManager->executeHooks( + 'render_daily', + $data, + ['loggedin' => $this->container->loginManager->isLoggedIn()] + ); + + return $data; + } +} diff --git a/application/front/controller/visitor/FeedController.php b/application/front/controller/visitor/FeedController.php new file mode 100644 index 00000000..70664635 --- /dev/null +++ b/application/front/controller/visitor/FeedController.php @@ -0,0 +1,77 @@ +processRequest(FeedBuilder::$FEED_ATOM, $request, $response); + } + + public function rss(Request $request, Response $response): Response + { + return $this->processRequest(FeedBuilder::$FEED_RSS, $request, $response); + } + + protected function processRequest(string $feedType, Request $request, Response $response): Response + { + $response = $response->withHeader('Content-Type', 'application/'. $feedType .'+xml; charset=utf-8'); + + $pageUrl = page_url($this->container->environment); + $cache = $this->container->pageCacheManager->getCachePage($pageUrl); + + $cached = $cache->cachedVersion(); + if (!empty($cached)) { + return $response->write($cached); + } + + // Generate data. + $this->container->feedBuilder->setLocale(strtolower(setlocale(LC_COLLATE, 0))); + $this->container->feedBuilder->setHideDates($this->container->conf->get('privacy.hide_timestamps', false)); + $this->container->feedBuilder->setUsePermalinks( + null !== $request->getParam('permalinks') || !$this->container->conf->get('feed.rss_permalinks') + ); + + $data = $this->container->feedBuilder->buildData($feedType, $request->getParams()); + + $this->executeHooks($data, $feedType); + $this->assignAllView($data); + + $content = $this->render('feed.'. $feedType); + + $cache->cache($content); + + return $response->write($content); + } + + /** + * @param mixed[] $data Template data + * + * @return mixed[] Template data after active plugins hook execution. + */ + protected function executeHooks(array $data, string $feedType): array + { + $this->container->pluginManager->executeHooks( + 'render_feed', + $data, + [ + 'loggedin' => $this->container->loginManager->isLoggedIn(), + 'target' => $feedType, + ] + ); + + return $data; + } +} diff --git a/application/front/controller/visitor/LoginController.php b/application/front/controller/visitor/LoginController.php new file mode 100644 index 00000000..4de2f55d --- /dev/null +++ b/application/front/controller/visitor/LoginController.php @@ -0,0 +1,46 @@ +container->loginManager->isLoggedIn() + || $this->container->conf->get('security.open_shaarli', false) + ) { + return $response->withRedirect('./'); + } + + $userCanLogin = $this->container->loginManager->canLogin($request->getServerParams()); + if ($userCanLogin !== true) { + throw new LoginBannedException(); + } + + if ($request->getParam('username') !== null) { + $this->assignView('username', escape($request->getParam('username'))); + } + + $this + ->assignView('returnurl', escape($request->getServerParam('HTTP_REFERER'))) + ->assignView('remember_user_default', $this->container->conf->get('privacy.remember_user_default', true)) + ->assignView('pagetitle', t('Login') .' - '. $this->container->conf->get('general.title', 'Shaarli')) + ; + + return $response->write($this->render('loginform')); + } +} diff --git a/application/front/controller/visitor/OpenSearchController.php b/application/front/controller/visitor/OpenSearchController.php new file mode 100644 index 00000000..0fd68db6 --- /dev/null +++ b/application/front/controller/visitor/OpenSearchController.php @@ -0,0 +1,26 @@ +withHeader('Content-Type', 'application/opensearchdescription+xml; charset=utf-8'); + + $this->assignView('serverurl', index_url($this->container->environment)); + + return $response->write($this->render('opensearch')); + } +} diff --git a/application/front/controller/visitor/PictureWallController.php b/application/front/controller/visitor/PictureWallController.php new file mode 100644 index 00000000..4e1dce8c --- /dev/null +++ b/application/front/controller/visitor/PictureWallController.php @@ -0,0 +1,70 @@ +container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) === Thumbnailer::MODE_NONE) { + throw new ThumbnailsDisabledException(); + } + + $this->assignView( + 'pagetitle', + t('Picture wall') .' - '. $this->container->conf->get('general.title', 'Shaarli') + ); + + // Optionally filter the results: + $links = $this->container->bookmarkService->search($request->getQueryParams()); + $linksToDisplay = []; + + // Get only bookmarks which have a thumbnail. + // Note: we do not retrieve thumbnails here, the request is too heavy. + $formatter = $this->container->formatterFactory->getFormatter('raw'); + foreach ($links as $key => $link) { + if (!empty($link->getThumbnail())) { + $linksToDisplay[] = $formatter->format($link); + } + } + + $data = $this->executeHooks($linksToDisplay); + foreach ($data as $key => $value) { + $this->assignView($key, $value); + } + + return $response->write($this->render('picwall')); + } + + /** + * @param mixed[] $linksToDisplay List of formatted bookmarks + * + * @return mixed[] Template data after active plugins render_picwall hook execution. + */ + protected function executeHooks(array $linksToDisplay): array + { + $data = [ + 'linksToDisplay' => $linksToDisplay, + ]; + $this->container->pluginManager->executeHooks( + 'render_picwall', + $data, + ['loggedin' => $this->container->loginManager->isLoggedIn()] + ); + + return $data; + } +} diff --git a/application/front/controller/visitor/ShaarliVisitorController.php b/application/front/controller/visitor/ShaarliVisitorController.php new file mode 100644 index 00000000..655b3baa --- /dev/null +++ b/application/front/controller/visitor/ShaarliVisitorController.php @@ -0,0 +1,131 @@ +container = $container; + } + + /** + * Assign variables to RainTPL template through the PageBuilder. + * + * @param mixed $value Value to assign to the template + */ + protected function assignView(string $name, $value): self + { + $this->container->pageBuilder->assign($name, $value); + + return $this; + } + + /** + * Assign variables to RainTPL template through the PageBuilder. + * + * @param mixed $data Values to assign to the template and their keys + */ + protected function assignAllView(array $data): self + { + foreach ($data as $key => $value) { + $this->assignView($key, $value); + } + + return $this; + } + + protected function render(string $template): string + { + $this->assignView('linkcount', $this->container->bookmarkService->count(BookmarkFilter::$ALL)); + $this->assignView('privateLinkcount', $this->container->bookmarkService->count(BookmarkFilter::$PRIVATE)); + $this->assignView('plugin_errors', $this->container->pluginManager->getErrors()); + + $this->executeDefaultHooks($template); + + return $this->container->pageBuilder->render($template); + } + + /** + * Call plugin hooks for header, footer and includes, specifying which page will be rendered. + * Then assign generated data to RainTPL. + */ + protected function executeDefaultHooks(string $template): void + { + $common_hooks = [ + 'includes', + 'header', + 'footer', + ]; + + foreach ($common_hooks as $name) { + $plugin_data = []; + $this->container->pluginManager->executeHooks( + 'render_' . $name, + $plugin_data, + [ + 'target' => $template, + 'loggedin' => $this->container->loginManager->isLoggedIn() + ] + ); + $this->assignView('plugins_' . $name, $plugin_data); + } + } + + /** + * Generates a redirection to the previous page, based on the HTTP_REFERER. + * It fails back to the home page. + * + * @param array $loopTerms Terms to remove from path and query string to prevent direction loop. + * @param array $clearParams List of parameter to remove from the query string of the referrer. + */ + protected function redirectFromReferer( + Request $request, + Response $response, + array $loopTerms = [], + array $clearParams = [] + ): Response { + $defaultPath = $request->getUri()->getBasePath(); + $referer = $this->container->environment['HTTP_REFERER'] ?? null; + + if (null !== $referer) { + $currentUrl = parse_url($referer); + parse_str($currentUrl['query'] ?? '', $params); + $path = $currentUrl['path'] ?? $defaultPath; + } else { + $params = []; + $path = $defaultPath; + } + + // Prevent redirection loop + if (isset($currentUrl)) { + foreach ($clearParams as $value) { + unset($params[$value]); + } + + $checkQuery = implode('', array_keys($params)); + foreach ($loopTerms as $value) { + if (strpos($path . $checkQuery, $value) !== false) { + $params = []; + $path = $defaultPath; + break; + } + } + } + + $queryString = count($params) > 0 ? '?'. http_build_query($params) : ''; + + return $response->withRedirect($path . $queryString); + } +} diff --git a/application/front/controller/visitor/TagCloudController.php b/application/front/controller/visitor/TagCloudController.php new file mode 100644 index 00000000..15b6d7b7 --- /dev/null +++ b/application/front/controller/visitor/TagCloudController.php @@ -0,0 +1,131 @@ +processRequest(static::TYPE_CLOUD, $request, $response); + } + + /** + * Display the tag list through the template engine. + * This controller a few filters: + * - Visibility stored in the session for logged in users + * - `searchtags` query parameter: will return tags associated with filter in at least one bookmark + * - `sort` query parameters: + * + `usage` (default): most used tags first + * + `alpha`: alphabetical order + */ + public function list(Request $request, Response $response): Response + { + return $this->processRequest(static::TYPE_LIST, $request, $response); + } + + /** + * Process the request for both tag cloud and tag list endpoints. + */ + protected function processRequest(string $type, Request $request, Response $response): Response + { + if ($this->container->loginManager->isLoggedIn() === true) { + $visibility = $this->container->sessionManager->getSessionParameter('visibility'); + } + + $sort = $request->getQueryParam('sort'); + $searchTags = $request->getQueryParam('searchtags'); + $filteringTags = $searchTags !== null ? explode(' ', $searchTags) : []; + + $tags = $this->container->bookmarkService->bookmarksCountPerTag($filteringTags, $visibility ?? null); + + if (static::TYPE_CLOUD === $type || 'alpha' === $sort) { + // TODO: the sorting should be handled by bookmarkService instead of the controller + alphabetical_sort($tags, false, true); + } + + if (static::TYPE_CLOUD === $type) { + $tags = $this->formatTagsForCloud($tags); + } + + $searchTags = implode(' ', escape($filteringTags)); + $data = [ + 'search_tags' => $searchTags, + 'tags' => $tags, + ]; + $data = $this->executeHooks('tag' . $type, $data); + foreach ($data as $key => $value) { + $this->assignView($key, $value); + } + + $searchTags = !empty($searchTags) ? $searchTags .' - ' : ''; + $this->assignView( + 'pagetitle', + $searchTags . t('Tag '. $type) .' - '. $this->container->conf->get('general.title', 'Shaarli') + ); + + return $response->write($this->render('tag.'. $type)); + } + + /** + * Format the tags array for the tag cloud template. + * + * @param array $tags List of tags as key with count as value + * + * @return mixed[] List of tags as key, with count and expected font size in a subarray + */ + protected function formatTagsForCloud(array $tags): array + { + // We sort tags alphabetically, then choose a font size according to count. + // First, find max value. + $maxCount = count($tags) > 0 ? max($tags) : 0; + $logMaxCount = $maxCount > 1 ? log($maxCount, 30) : 1; + $tagList = []; + foreach ($tags as $key => $value) { + // Tag font size scaling: + // default 15 and 30 logarithm bases affect scaling, + // 2.2 and 0.8 are arbitrary font sizes in em. + $size = log($value, 15) / $logMaxCount * 2.2 + 0.8; + $tagList[$key] = [ + 'count' => $value, + 'size' => number_format($size, 2, '.', ''), + ]; + } + + return $tagList; + } + + /** + * @param mixed[] $data Template data + * + * @return mixed[] Template data after active plugins hook execution. + */ + protected function executeHooks(string $template, array $data): array + { + $this->container->pluginManager->executeHooks( + 'render_'. $template, + $data, + ['loggedin' => $this->container->loginManager->isLoggedIn()] + ); + + return $data; + } +} diff --git a/application/front/controller/visitor/TagController.php b/application/front/controller/visitor/TagController.php new file mode 100644 index 00000000..a0bc1d1b --- /dev/null +++ b/application/front/controller/visitor/TagController.php @@ -0,0 +1,118 @@ +container->environment['HTTP_REFERER'] ?? null; + + // In case browser does not send HTTP_REFERER, we search a single tag + if (null === $referer) { + if (null !== $newTag) { + return $response->withRedirect('./?searchtags='. urlencode($newTag)); + } + + return $response->withRedirect('./'); + } + + $currentUrl = parse_url($referer); + parse_str($currentUrl['query'] ?? '', $params); + + if (null === $newTag) { + return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params)); + } + + // Prevent redirection loop + if (isset($params['addtag'])) { + unset($params['addtag']); + } + + // Check if this tag is already in the search query and ignore it if it is. + // Each tag is always separated by a space + $currentTags = isset($params['searchtags']) ? explode(' ', $params['searchtags']) : []; + + $addtag = true; + foreach ($currentTags as $value) { + if ($value === $newTag) { + $addtag = false; + break; + } + } + + // Append the tag if necessary + if (true === $addtag) { + $currentTags[] = trim($newTag); + } + + $params['searchtags'] = trim(implode(' ', $currentTags)); + + // We also remove page (keeping the same page has no sense, since the results are different) + unset($params['page']); + + return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params)); + } + + /** + * Remove a tag from the current search through an HTTP redirection. + * + * @param array $args Should contain `tag` key as tag to remove from current search + */ + public function removeTag(Request $request, Response $response, array $args): Response + { + $referer = $this->container->environment['HTTP_REFERER'] ?? null; + + // If the referrer is not provided, we can update the search, so we failback on the bookmark list + if (empty($referer)) { + return $response->withRedirect('./'); + } + + $tagToRemove = $args['tag'] ?? null; + $currentUrl = parse_url($referer); + parse_str($currentUrl['query'] ?? '', $params); + + if (null === $tagToRemove) { + return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params)); + } + + // Prevent redirection loop + if (isset($params['removetag'])) { + unset($params['removetag']); + } + + if (isset($params['searchtags'])) { + $tags = explode(' ', $params['searchtags']); + // Remove value from array $tags. + $tags = array_diff($tags, [$tagToRemove]); + $params['searchtags'] = implode(' ', $tags); + + if (empty($params['searchtags'])) { + unset($params['searchtags']); + } + + // We also remove page (keeping the same page has no sense, since the results are different) + unset($params['page']); + } + + $queryParams = count($params) > 0 ? '?' . http_build_query($params) : ''; + + return $response->withRedirect(($currentUrl['path'] ?? './') . $queryParams); + } +} diff --git a/application/front/controllers/DailyController.php b/application/front/controllers/DailyController.php deleted file mode 100644 index 4a0735aa..00000000 --- a/application/front/controllers/DailyController.php +++ /dev/null @@ -1,210 +0,0 @@ -getQueryParam('day') ?? date('Ymd'); - - $availableDates = $this->container->bookmarkService->days(); - $nbAvailableDates = count($availableDates); - $index = array_search($day, $availableDates); - - if ($index === false) { - // no bookmarks for day, but at least one day with bookmarks - $day = $availableDates[$nbAvailableDates - 1] ?? $day; - $previousDay = $availableDates[$nbAvailableDates - 2] ?? ''; - } else { - $previousDay = $availableDates[$index - 1] ?? ''; - $nextDay = $availableDates[$index + 1] ?? ''; - } - - if ($day === date('Ymd')) { - $this->assignView('dayDesc', t('Today')); - } elseif ($day === date('Ymd', strtotime('-1 days'))) { - $this->assignView('dayDesc', t('Yesterday')); - } - - try { - $linksToDisplay = $this->container->bookmarkService->filterDay($day); - } catch (\Exception $exc) { - $linksToDisplay = []; - } - - $formatter = $this->container->formatterFactory->getFormatter(); - // We pre-format some fields for proper output. - foreach ($linksToDisplay as $key => $bookmark) { - $linksToDisplay[$key] = $formatter->format($bookmark); - // This page is a bit specific, we need raw description to calculate the length - $linksToDisplay[$key]['formatedDescription'] = $linksToDisplay[$key]['description']; - $linksToDisplay[$key]['description'] = $bookmark->getDescription(); - } - - $dayDate = DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, $day.'_000000'); - $data = [ - 'linksToDisplay' => $linksToDisplay, - 'day' => $dayDate->getTimestamp(), - 'dayDate' => $dayDate, - 'previousday' => $previousDay ?? '', - 'nextday' => $nextDay ?? '', - ]; - - // Hooks are called before column construction so that plugins don't have to deal with columns. - $this->executeHooks($data); - - $data['cols'] = $this->calculateColumns($data['linksToDisplay']); - - foreach ($data as $key => $value) { - $this->assignView($key, $value); - } - - $mainTitle = $this->container->conf->get('general.title', 'Shaarli'); - $this->assignView( - 'pagetitle', - t('Daily') .' - '. format_date($dayDate, false) . ' - ' . $mainTitle - ); - - return $response->write($this->render('daily')); - } - - /** - * Daily RSS feed: 1 RSS entry per day giving all the bookmarks on that day. - * Gives the last 7 days (which have bookmarks). - * This RSS feed cannot be filtered and does not trigger plugins yet. - */ - public function rss(Request $request, Response $response): Response - { - $response = $response->withHeader('Content-Type', 'application/rss+xml; charset=utf-8'); - - $pageUrl = page_url($this->container->environment); - $cache = $this->container->pageCacheManager->getCachePage($pageUrl); - - $cached = $cache->cachedVersion(); - if (!empty($cached)) { - return $response->write($cached); - } - - $days = []; - foreach ($this->container->bookmarkService->search() as $bookmark) { - $day = $bookmark->getCreated()->format('Ymd'); - - // Stop iterating after DAILY_RSS_NB_DAYS entries - if (count($days) === static::$DAILY_RSS_NB_DAYS && !isset($days[$day])) { - break; - } - - $days[$day][] = $bookmark; - } - - // Build the RSS feed. - $indexUrl = escape(index_url($this->container->environment)); - - $formatter = $this->container->formatterFactory->getFormatter(); - $formatter->addContextData('index_url', $indexUrl); - - $dataPerDay = []; - - /** @var Bookmark[] $bookmarks */ - foreach ($days as $day => $bookmarks) { - $dayDatetime = DateTimeImmutable::createFromFormat(Bookmark::LINK_DATE_FORMAT, $day.'_000000'); - $dataPerDay[$day] = [ - 'date' => $dayDatetime, - 'date_rss' => $dayDatetime->format(DateTime::RSS), - 'date_human' => format_date($dayDatetime, false, true), - 'absolute_url' => $indexUrl . '/daily?day=' . $day, - 'links' => [], - ]; - - foreach ($bookmarks as $key => $bookmark) { - $dataPerDay[$day]['links'][$key] = $formatter->format($bookmark); - - // Make permalink URL absolute - if ($bookmark->isNote()) { - $dataPerDay[$day]['links'][$key]['url'] = $indexUrl . $bookmark->getUrl(); - } - } - } - - $this->assignView('title', $this->container->conf->get('general.title', 'Shaarli')); - $this->assignView('index_url', $indexUrl); - $this->assignView('page_url', $pageUrl); - $this->assignView('hide_timestamps', $this->container->conf->get('privacy.hide_timestamps', false)); - $this->assignView('days', $dataPerDay); - - $rssContent = $this->render('dailyrss'); - - $cache->cache($rssContent); - - return $response->write($rssContent); - } - - /** - * We need to spread the articles on 3 columns. - * did not want to use a JavaScript lib like http://masonry.desandro.com/ - * so I manually spread entries with a simple method: I roughly evaluate the - * height of a div according to title and description length. - */ - protected function calculateColumns(array $links): array - { - // Entries to display, for each column. - $columns = [[], [], []]; - // Rough estimate of columns fill. - $fill = [0, 0, 0]; - foreach ($links as $link) { - // Roughly estimate length of entry (by counting characters) - // Title: 30 chars = 1 line. 1 line is 30 pixels height. - // Description: 836 characters gives roughly 342 pixel height. - // This is not perfect, but it's usually OK. - $length = strlen($link['title'] ?? '') + (342 * strlen($link['description'] ?? '')) / 836; - if (! empty($link['thumbnail'])) { - $length += 100; // 1 thumbnails roughly takes 100 pixels height. - } - // Then put in column which is the less filled: - $smallest = min($fill); // find smallest value in array. - $index = array_search($smallest, $fill); // find index of this smallest value. - array_push($columns[$index], $link); // Put entry in this column. - $fill[$index] += $length; - } - - return $columns; - } - - /** - * @param mixed[] $data Variables passed to the template engine - * - * @return mixed[] Template data after active plugins render_picwall hook execution. - */ - protected function executeHooks(array $data): array - { - $this->container->pluginManager->executeHooks( - 'render_daily', - $data, - ['loggedin' => $this->container->loginManager->isLoggedIn()] - ); - - return $data; - } -} diff --git a/application/front/controllers/FeedController.php b/application/front/controllers/FeedController.php deleted file mode 100644 index 78d826d9..00000000 --- a/application/front/controllers/FeedController.php +++ /dev/null @@ -1,79 +0,0 @@ -processRequest(FeedBuilder::$FEED_ATOM, $request, $response); - } - - public function rss(Request $request, Response $response): Response - { - return $this->processRequest(FeedBuilder::$FEED_RSS, $request, $response); - } - - protected function processRequest(string $feedType, Request $request, Response $response): Response - { - $response = $response->withHeader('Content-Type', 'application/'. $feedType .'+xml; charset=utf-8'); - - $pageUrl = page_url($this->container->environment); - $cache = $this->container->pageCacheManager->getCachePage($pageUrl); - - $cached = $cache->cachedVersion(); - if (!empty($cached)) { - return $response->write($cached); - } - - // Generate data. - $this->container->feedBuilder->setLocale(strtolower(setlocale(LC_COLLATE, 0))); - $this->container->feedBuilder->setHideDates($this->container->conf->get('privacy.hide_timestamps', false)); - $this->container->feedBuilder->setUsePermalinks( - null !== $request->getParam('permalinks') || !$this->container->conf->get('feed.rss_permalinks') - ); - - $data = $this->container->feedBuilder->buildData($feedType, $request->getParams()); - - $this->executeHooks($data, $feedType); - $this->assignAllView($data); - - $content = $this->render('feed.'. $feedType); - - $cache->cache($content); - - return $response->write($content); - } - - /** - * @param mixed[] $data Template data - * - * @return mixed[] Template data after active plugins hook execution. - */ - protected function executeHooks(array $data, string $feedType): array - { - $this->container->pluginManager->executeHooks( - 'render_feed', - $data, - [ - 'loggedin' => $this->container->loginManager->isLoggedIn(), - 'target' => $feedType, - ] - ); - - return $data; - } -} diff --git a/application/front/controllers/LoginController.php b/application/front/controllers/LoginController.php deleted file mode 100644 index ae3599e0..00000000 --- a/application/front/controllers/LoginController.php +++ /dev/null @@ -1,48 +0,0 @@ -container->loginManager->isLoggedIn() - || $this->container->conf->get('security.open_shaarli', false) - ) { - return $response->withRedirect('./'); - } - - $userCanLogin = $this->container->loginManager->canLogin($request->getServerParams()); - if ($userCanLogin !== true) { - throw new LoginBannedException(); - } - - if ($request->getParam('username') !== null) { - $this->assignView('username', escape($request->getParam('username'))); - } - - $this - ->assignView('returnurl', escape($request->getServerParam('HTTP_REFERER'))) - ->assignView('remember_user_default', $this->container->conf->get('privacy.remember_user_default', true)) - ->assignView('pagetitle', t('Login') .' - '. $this->container->conf->get('general.title', 'Shaarli')) - ; - - return $response->write($this->render('loginform')); - } -} diff --git a/application/front/controllers/LogoutController.php b/application/front/controllers/LogoutController.php deleted file mode 100644 index aba078c3..00000000 --- a/application/front/controllers/LogoutController.php +++ /dev/null @@ -1,31 +0,0 @@ -container->pageCacheManager->invalidateCaches(); - $this->container->sessionManager->logout(); - - // TODO: switch to a simple Cookie manager allowing to check the session, and create mocks. - setcookie(LoginManager::$STAY_SIGNED_IN_COOKIE, 'false', 0, $this->container->webPath); - - return $response->withRedirect('./'); - } -} diff --git a/application/front/controllers/OpenSearchController.php b/application/front/controllers/OpenSearchController.php deleted file mode 100644 index fa32c5f1..00000000 --- a/application/front/controllers/OpenSearchController.php +++ /dev/null @@ -1,28 +0,0 @@ -withHeader('Content-Type', 'application/opensearchdescription+xml; charset=utf-8'); - - $this->assignView('serverurl', index_url($this->container->environment)); - - return $response->write($this->render('opensearch')); - } -} diff --git a/application/front/controllers/PictureWallController.php b/application/front/controllers/PictureWallController.php deleted file mode 100644 index 08d31b29..00000000 --- a/application/front/controllers/PictureWallController.php +++ /dev/null @@ -1,72 +0,0 @@ -container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) === Thumbnailer::MODE_NONE) { - throw new ThumbnailsDisabledException(); - } - - $this->assignView( - 'pagetitle', - t('Picture wall') .' - '. $this->container->conf->get('general.title', 'Shaarli') - ); - - // Optionally filter the results: - $links = $this->container->bookmarkService->search($request->getQueryParams()); - $linksToDisplay = []; - - // Get only bookmarks which have a thumbnail. - // Note: we do not retrieve thumbnails here, the request is too heavy. - $formatter = $this->container->formatterFactory->getFormatter('raw'); - foreach ($links as $key => $link) { - if (!empty($link->getThumbnail())) { - $linksToDisplay[] = $formatter->format($link); - } - } - - $data = $this->executeHooks($linksToDisplay); - foreach ($data as $key => $value) { - $this->assignView($key, $value); - } - - return $response->write($this->render('picwall')); - } - - /** - * @param mixed[] $linksToDisplay List of formatted bookmarks - * - * @return mixed[] Template data after active plugins render_picwall hook execution. - */ - protected function executeHooks(array $linksToDisplay): array - { - $data = [ - 'linksToDisplay' => $linksToDisplay, - ]; - $this->container->pluginManager->executeHooks( - 'render_picwall', - $data, - ['loggedin' => $this->container->loginManager->isLoggedIn()] - ); - - return $data; - } -} diff --git a/application/front/controllers/SessionFilterController.php b/application/front/controllers/SessionFilterController.php deleted file mode 100644 index a021dc37..00000000 --- a/application/front/controllers/SessionFilterController.php +++ /dev/null @@ -1,81 +0,0 @@ -getParam('nb') ?? null; - if (null === $linksPerPage || false === is_numeric($linksPerPage)) { - $linksPerPage = $this->container->conf->get('general.links_per_page', 20); - } - - $this->container->sessionManager->setSessionParameter( - SessionManager::KEY_LINKS_PER_PAGE, - abs(intval($linksPerPage)) - ); - - return $this->redirectFromReferer($response, ['linksperpage'], ['nb']); - } - - /** - * GET /visibility: allows to display only public or only private bookmarks in linklist - */ - public function visibility(Request $request, Response $response, array $args): Response - { - if (false === $this->container->loginManager->isLoggedIn()) { - return $this->redirectFromReferer($response, ['visibility']); - } - - $newVisibility = $args['visibility'] ?? null; - if (false === in_array($newVisibility, [BookmarkFilter::$PRIVATE, BookmarkFilter::$PUBLIC], true)) { - $newVisibility = null; - } - - $currentVisibility = $this->container->sessionManager->getSessionParameter(SessionManager::KEY_VISIBILITY); - - // Visibility not set or not already expected value, set expected value, otherwise reset it - if ($newVisibility !== null && (null === $currentVisibility || $currentVisibility !== $newVisibility)) { - // See only public bookmarks - $this->container->sessionManager->setSessionParameter( - SessionManager::KEY_VISIBILITY, - $newVisibility - ); - } else { - $this->container->sessionManager->deleteSessionParameter(SessionManager::KEY_VISIBILITY); - } - - return $this->redirectFromReferer($response, ['visibility']); - } - - /** - * GET /untagged-only: allows to display only bookmarks without any tag - */ - public function untaggedOnly(Request $request, Response $response): Response - { - $this->container->sessionManager->setSessionParameter( - SessionManager::KEY_UNTAGGED_ONLY, - empty($this->container->sessionManager->getSessionParameter(SessionManager::KEY_UNTAGGED_ONLY)) - ); - - return $this->redirectFromReferer($response, ['untaggedonly', 'untagged-only']); - } -} diff --git a/application/front/controllers/ShaarliController.php b/application/front/controllers/ShaarliController.php deleted file mode 100644 index bfff5fcf..00000000 --- a/application/front/controllers/ShaarliController.php +++ /dev/null @@ -1,126 +0,0 @@ -container = $container; - } - - /** - * Assign variables to RainTPL template through the PageBuilder. - * - * @param mixed $value Value to assign to the template - */ - protected function assignView(string $name, $value): self - { - $this->container->pageBuilder->assign($name, $value); - - return $this; - } - - /** - * Assign variables to RainTPL template through the PageBuilder. - * - * @param mixed $data Values to assign to the template and their keys - */ - protected function assignAllView(array $data): self - { - foreach ($data as $key => $value) { - $this->assignView($key, $value); - } - - return $this; - } - - protected function render(string $template): string - { - $this->assignView('linkcount', $this->container->bookmarkService->count(BookmarkFilter::$ALL)); - $this->assignView('privateLinkcount', $this->container->bookmarkService->count(BookmarkFilter::$PRIVATE)); - $this->assignView('plugin_errors', $this->container->pluginManager->getErrors()); - - $this->executeDefaultHooks($template); - - return $this->container->pageBuilder->render($template); - } - - /** - * Call plugin hooks for header, footer and includes, specifying which page will be rendered. - * Then assign generated data to RainTPL. - */ - protected function executeDefaultHooks(string $template): void - { - $common_hooks = [ - 'includes', - 'header', - 'footer', - ]; - - foreach ($common_hooks as $name) { - $plugin_data = []; - $this->container->pluginManager->executeHooks( - 'render_' . $name, - $plugin_data, - [ - 'target' => $template, - 'loggedin' => $this->container->loginManager->isLoggedIn() - ] - ); - $this->assignView('plugins_' . $name, $plugin_data); - } - } - - /** - * Generates a redirection to the previous page, based on the HTTP_REFERER. - * It fails back to the home page. - * - * @param array $loopTerms Terms to remove from path and query string to prevent direction loop. - * @param array $clearParams List of parameter to remove from the query string of the referrer. - */ - protected function redirectFromReferer(Response $response, array $loopTerms = [], array $clearParams = []): Response - { - $defaultPath = './'; - $referer = $this->container->environment['HTTP_REFERER'] ?? null; - - if (null !== $referer) { - $currentUrl = parse_url($referer); - parse_str($currentUrl['query'] ?? '', $params); - $path = $currentUrl['path'] ?? $defaultPath; - } else { - $params = []; - $path = $defaultPath; - } - - // Prevent redirection loop - if (isset($currentUrl)) { - foreach ($clearParams as $value) { - unset($params[$value]); - } - - $checkQuery = implode('', array_keys($params)); - foreach ($loopTerms as $value) { - if (strpos($path . $checkQuery, $value) !== false) { - $params = []; - $path = $defaultPath; - break; - } - } - } - - $queryString = count($params) > 0 ? '?'. http_build_query($params) : ''; - - return $response->withRedirect($path . $queryString); - } -} diff --git a/application/front/controllers/TagCloudController.php b/application/front/controllers/TagCloudController.php deleted file mode 100644 index 1ff7c2e6..00000000 --- a/application/front/controllers/TagCloudController.php +++ /dev/null @@ -1,133 +0,0 @@ -processRequest(static::TYPE_CLOUD, $request, $response); - } - - /** - * Display the tag list through the template engine. - * This controller a few filters: - * - Visibility stored in the session for logged in users - * - `searchtags` query parameter: will return tags associated with filter in at least one bookmark - * - `sort` query parameters: - * + `usage` (default): most used tags first - * + `alpha`: alphabetical order - */ - public function list(Request $request, Response $response): Response - { - return $this->processRequest(static::TYPE_LIST, $request, $response); - } - - /** - * Process the request for both tag cloud and tag list endpoints. - */ - protected function processRequest(string $type, Request $request, Response $response): Response - { - if ($this->container->loginManager->isLoggedIn() === true) { - $visibility = $this->container->sessionManager->getSessionParameter('visibility'); - } - - $sort = $request->getQueryParam('sort'); - $searchTags = $request->getQueryParam('searchtags'); - $filteringTags = $searchTags !== null ? explode(' ', $searchTags) : []; - - $tags = $this->container->bookmarkService->bookmarksCountPerTag($filteringTags, $visibility ?? null); - - if (static::TYPE_CLOUD === $type || 'alpha' === $sort) { - // TODO: the sorting should be handled by bookmarkService instead of the controller - alphabetical_sort($tags, false, true); - } - - if (static::TYPE_CLOUD === $type) { - $tags = $this->formatTagsForCloud($tags); - } - - $searchTags = implode(' ', escape($filteringTags)); - $data = [ - 'search_tags' => $searchTags, - 'tags' => $tags, - ]; - $data = $this->executeHooks('tag' . $type, $data); - foreach ($data as $key => $value) { - $this->assignView($key, $value); - } - - $searchTags = !empty($searchTags) ? $searchTags .' - ' : ''; - $this->assignView( - 'pagetitle', - $searchTags . t('Tag '. $type) .' - '. $this->container->conf->get('general.title', 'Shaarli') - ); - - return $response->write($this->render('tag.'. $type)); - } - - /** - * Format the tags array for the tag cloud template. - * - * @param array $tags List of tags as key with count as value - * - * @return mixed[] List of tags as key, with count and expected font size in a subarray - */ - protected function formatTagsForCloud(array $tags): array - { - // We sort tags alphabetically, then choose a font size according to count. - // First, find max value. - $maxCount = count($tags) > 0 ? max($tags) : 0; - $logMaxCount = $maxCount > 1 ? log($maxCount, 30) : 1; - $tagList = []; - foreach ($tags as $key => $value) { - // Tag font size scaling: - // default 15 and 30 logarithm bases affect scaling, - // 2.2 and 0.8 are arbitrary font sizes in em. - $size = log($value, 15) / $logMaxCount * 2.2 + 0.8; - $tagList[$key] = [ - 'count' => $value, - 'size' => number_format($size, 2, '.', ''), - ]; - } - - return $tagList; - } - - /** - * @param mixed[] $data Template data - * - * @return mixed[] Template data after active plugins hook execution. - */ - protected function executeHooks(string $template, array $data): array - { - $this->container->pluginManager->executeHooks( - 'render_'. $template, - $data, - ['loggedin' => $this->container->loginManager->isLoggedIn()] - ); - - return $data; - } -} diff --git a/application/front/controllers/TagController.php b/application/front/controllers/TagController.php deleted file mode 100644 index a1d5ad5b..00000000 --- a/application/front/controllers/TagController.php +++ /dev/null @@ -1,120 +0,0 @@ -container->environment['HTTP_REFERER'] ?? null; - - // In case browser does not send HTTP_REFERER, we search a single tag - if (null === $referer) { - if (null !== $newTag) { - return $response->withRedirect('./?searchtags='. urlencode($newTag)); - } - - return $response->withRedirect('./'); - } - - $currentUrl = parse_url($referer); - parse_str($currentUrl['query'] ?? '', $params); - - if (null === $newTag) { - return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params)); - } - - // Prevent redirection loop - if (isset($params['addtag'])) { - unset($params['addtag']); - } - - // Check if this tag is already in the search query and ignore it if it is. - // Each tag is always separated by a space - $currentTags = isset($params['searchtags']) ? explode(' ', $params['searchtags']) : []; - - $addtag = true; - foreach ($currentTags as $value) { - if ($value === $newTag) { - $addtag = false; - break; - } - } - - // Append the tag if necessary - if (true === $addtag) { - $currentTags[] = trim($newTag); - } - - $params['searchtags'] = trim(implode(' ', $currentTags)); - - // We also remove page (keeping the same page has no sense, since the results are different) - unset($params['page']); - - return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params)); - } - - /** - * Remove a tag from the current search through an HTTP redirection. - * - * @param array $args Should contain `tag` key as tag to remove from current search - */ - public function removeTag(Request $request, Response $response, array $args): Response - { - $referer = $this->container->environment['HTTP_REFERER'] ?? null; - - // If the referrer is not provided, we can update the search, so we failback on the bookmark list - if (empty($referer)) { - return $response->withRedirect('./'); - } - - $tagToRemove = $args['tag'] ?? null; - $currentUrl = parse_url($referer); - parse_str($currentUrl['query'] ?? '', $params); - - if (null === $tagToRemove) { - return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params)); - } - - // Prevent redirection loop - if (isset($params['removetag'])) { - unset($params['removetag']); - } - - if (isset($params['searchtags'])) { - $tags = explode(' ', $params['searchtags']); - // Remove value from array $tags. - $tags = array_diff($tags, [$tagToRemove]); - $params['searchtags'] = implode(' ', $tags); - - if (empty($params['searchtags'])) { - unset($params['searchtags']); - } - - // We also remove page (keeping the same page has no sense, since the results are different) - unset($params['page']); - } - - $queryParams = count($params) > 0 ? '?' . http_build_query($params) : ''; - - return $response->withRedirect(($currentUrl['path'] ?? './') . $queryParams); - } -} diff --git a/application/front/exceptions/LoginBannedException.php b/application/front/exceptions/LoginBannedException.php index b31a4a14..79d0ea15 100644 --- a/application/front/exceptions/LoginBannedException.php +++ b/application/front/exceptions/LoginBannedException.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Shaarli\Front\Exception; -class LoginBannedException extends ShaarliException +class LoginBannedException extends ShaarliFrontException { public function __construct() { diff --git a/application/front/exceptions/ShaarliException.php b/application/front/exceptions/ShaarliException.php deleted file mode 100644 index 800bfbec..00000000 --- a/application/front/exceptions/ShaarliException.php +++ /dev/null @@ -1,23 +0,0 @@ - Date: Fri, 22 May 2020 13:47:02 +0200 Subject: Process tools page through Slim controller --- .../front/controller/admin/ToolsController.php | 49 ++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 application/front/controller/admin/ToolsController.php (limited to 'application') diff --git a/application/front/controller/admin/ToolsController.php b/application/front/controller/admin/ToolsController.php new file mode 100644 index 00000000..66db5ad9 --- /dev/null +++ b/application/front/controller/admin/ToolsController.php @@ -0,0 +1,49 @@ + index_url($this->container->environment), + 'sslenabled' => is_https($this->container->environment), + ]; + + $this->executeHooks($data); + + foreach ($data as $key => $value) { + $this->assignView($key, $value); + } + + $this->assignView('pagetitle', t('Tools') .' - '. $this->container->conf->get('general.title', 'Shaarli')); + + return $response->write($this->render('tools')); + } + + /** + * @param mixed[] $data Variables passed to the template engine + * + * @return mixed[] Template data after active plugins render_picwall hook execution. + */ + protected function executeHooks(array $data): array + { + $this->container->pluginManager->executeHooks( + 'render_tools', + $data + ); + + return $data; + } +} -- cgit v1.2.3 From ef00f9d2033f6de11e71bf3a909399cae6f73a9f Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Wed, 27 May 2020 13:35:48 +0200 Subject: Process password change controller through Slim --- .../front/controller/admin/PasswordController.php | 100 +++++++++++++++++++++ .../controller/admin/ShaarliAdminController.php | 59 ++++++++++++ .../visitor/ShaarliVisitorController.php | 8 ++ .../exceptions/OpenShaarliPasswordException.php | 18 ++++ .../front/exceptions/ShaarliFrontException.php | 4 +- .../front/exceptions/WrongTokenException.php | 18 ++++ application/render/PageBuilder.php | 26 ++++-- application/security/SessionManager.php | 4 + 8 files changed, 230 insertions(+), 7 deletions(-) create mode 100644 application/front/controller/admin/PasswordController.php create mode 100644 application/front/exceptions/OpenShaarliPasswordException.php create mode 100644 application/front/exceptions/WrongTokenException.php (limited to 'application') diff --git a/application/front/controller/admin/PasswordController.php b/application/front/controller/admin/PasswordController.php new file mode 100644 index 00000000..6e8f0bcb --- /dev/null +++ b/application/front/controller/admin/PasswordController.php @@ -0,0 +1,100 @@ +assignView( + 'pagetitle', + t('Change password') .' - '. $this->container->conf->get('general.title', 'Shaarli') + ); + } + + /** + * GET /password - Displays the change password template + */ + public function index(Request $request, Response $response): Response + { + return $response->write($this->render('changepassword')); + } + + /** + * POST /password - Change admin password - existing and new passwords need to be provided. + */ + public function change(Request $request, Response $response): Response + { + $this->checkToken($request); + + if ($this->container->conf->get('security.open_shaarli', false)) { + throw new OpenShaarliPasswordException(); + } + + $oldPassword = $request->getParam('oldpassword'); + $newPassword = $request->getParam('setpassword'); + + if (empty($newPassword) || empty($oldPassword)) { + $this->saveErrorMessage(t('You must provide the current and new password to change it.')); + + return $response + ->withStatus(400) + ->write($this->render('changepassword')) + ; + } + + // Make sure old password is correct. + $oldHash = sha1( + $oldPassword . + $this->container->conf->get('credentials.login') . + $this->container->conf->get('credentials.salt') + ); + + if ($oldHash !== $this->container->conf->get('credentials.hash')) { + $this->saveErrorMessage(t('The old password is not correct.')); + + return $response + ->withStatus(400) + ->write($this->render('changepassword')) + ; + } + + // Save new password + // Salt renders rainbow-tables attacks useless. + $this->container->conf->set('credentials.salt', sha1(uniqid('', true) .'_'. mt_rand())); + $this->container->conf->set( + 'credentials.hash', + sha1( + $newPassword + . $this->container->conf->get('credentials.login') + . $this->container->conf->get('credentials.salt') + ) + ); + + try { + $this->container->conf->write($this->container->loginManager->isLoggedIn()); + } catch (Throwable $e) { + throw new ShaarliFrontException($e->getMessage(), 500, $e); + } + + $this->saveSuccessMessage(t('Your password has been changed')); + + return $response->write($this->render('changepassword')); + } +} diff --git a/application/front/controller/admin/ShaarliAdminController.php b/application/front/controller/admin/ShaarliAdminController.php index ea703f62..3385006c 100644 --- a/application/front/controller/admin/ShaarliAdminController.php +++ b/application/front/controller/admin/ShaarliAdminController.php @@ -7,7 +7,19 @@ namespace Shaarli\Front\Controller\Admin; use Shaarli\Container\ShaarliContainer; use Shaarli\Front\Controller\Visitor\ShaarliVisitorController; use Shaarli\Front\Exception\UnauthorizedException; +use Shaarli\Front\Exception\WrongTokenException; +use Shaarli\Security\SessionManager; +use Slim\Http\Request; +/** + * Class ShaarliAdminController + * + * All admin controllers (for logged in users) MUST extend this abstract class. + * It makes sure that the user is properly logged in, and otherwise throw an exception + * which will redirect to the login page. + * + * @package Shaarli\Front\Controller\Admin + */ abstract class ShaarliAdminController extends ShaarliVisitorController { public function __construct(ShaarliContainer $container) @@ -18,4 +30,51 @@ abstract class ShaarliAdminController extends ShaarliVisitorController throw new UnauthorizedException(); } } + + /** + * Any persistent action to the config or data store must check the XSRF token validity. + */ + protected function checkToken(Request $request): void + { + if (!$this->container->sessionManager->checkToken($request->getParam('token'))) { + throw new WrongTokenException(); + } + } + + /** + * Save a SUCCESS message in user session, which will be displayed on any template page. + */ + protected function saveSuccessMessage(string $message): void + { + $this->saveMessage(SessionManager::KEY_SUCCESS_MESSAGES, $message); + } + + /** + * Save a WARNING message in user session, which will be displayed on any template page. + */ + protected function saveWarningMessage(string $message): void + { + $this->saveMessage(SessionManager::KEY_WARNING_MESSAGES, $message); + } + + /** + * Save an ERROR message in user session, which will be displayed on any template page. + */ + protected function saveErrorMessage(string $message): void + { + $this->saveMessage(SessionManager::KEY_ERROR_MESSAGES, $message); + } + + /** + * Use the sessionManager to save the provided message using the proper type. + * + * @param string $type successed/warnings/errors + */ + protected function saveMessage(string $type, string $message): void + { + $messages = $this->container->sessionManager->getSessionParameter($type) ?? []; + $messages[] = $message; + + $this->container->sessionManager->setSessionParameter($type, $messages); + } } diff --git a/application/front/controller/visitor/ShaarliVisitorController.php b/application/front/controller/visitor/ShaarliVisitorController.php index 655b3baa..f12915c1 100644 --- a/application/front/controller/visitor/ShaarliVisitorController.php +++ b/application/front/controller/visitor/ShaarliVisitorController.php @@ -9,6 +9,14 @@ use Shaarli\Container\ShaarliContainer; use Slim\Http\Request; use Slim\Http\Response; +/** + * Class ShaarliVisitorController + * + * All controllers accessible by visitors (non logged in users) should extend this abstract class. + * Contains a few helper function for template rendering, plugins, etc. + * + * @package Shaarli\Front\Controller\Visitor + */ abstract class ShaarliVisitorController { /** @var ShaarliContainer */ diff --git a/application/front/exceptions/OpenShaarliPasswordException.php b/application/front/exceptions/OpenShaarliPasswordException.php new file mode 100644 index 00000000..a6f0b3ae --- /dev/null +++ b/application/front/exceptions/OpenShaarliPasswordException.php @@ -0,0 +1,18 @@ +tpl->assign('thumbnails_width', $this->conf->get('thumbnails.width')); $this->tpl->assign('thumbnails_height', $this->conf->get('thumbnails.height')); - if (!empty($_SESSION['warnings'])) { - $this->tpl->assign('global_warnings', $_SESSION['warnings']); - unset($_SESSION['warnings']); - } - $this->tpl->assign('formatter', $this->conf->get('formatter', 'default')); // To be removed with a proper theme configuration. $this->tpl->assign('conf', $this->conf); } + protected function finalize(): void + { + // TODO: use the SessionManager + $messageKeys = [ + SessionManager::KEY_SUCCESS_MESSAGES, + SessionManager::KEY_WARNING_MESSAGES, + SessionManager::KEY_ERROR_MESSAGES + ]; + foreach ($messageKeys as $messageKey) { + if (!empty($_SESSION[$messageKey])) { + $this->tpl->assign('global_' . $messageKey, $_SESSION[$messageKey]); + unset($_SESSION[$messageKey]); + } + } + } + /** * The following assign() method is basically the same as RainTPL (except lazy loading) * @@ -196,6 +208,8 @@ class PageBuilder $this->initialize(); } + $this->finalize(); + $this->tpl->draw($page); } @@ -213,6 +227,8 @@ class PageBuilder $this->initialize(); } + $this->finalize(); + return $this->tpl->draw($page, true); } diff --git a/application/security/SessionManager.php b/application/security/SessionManager.php index 8b77d362..0ac17d9a 100644 --- a/application/security/SessionManager.php +++ b/application/security/SessionManager.php @@ -12,6 +12,10 @@ class SessionManager public const KEY_VISIBILITY = 'visibility'; public const KEY_UNTAGGED_ONLY = 'untaggedonly'; + public const KEY_SUCCESS_MESSAGES = 'successes'; + public const KEY_WARNING_MESSAGES = 'warnings'; + public const KEY_ERROR_MESSAGES = 'errors'; + /** @var int Session expiration timeout, in seconds */ public static $SHORT_TIMEOUT = 3600; // 1 hour -- cgit v1.2.3 From fdedbfd4a7fb547da0e0ce65c6180f74aad90691 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Wed, 27 May 2020 14:13:49 +0200 Subject: Test ShaarliAdminController --- application/front/controller/admin/ShaarliAdminController.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) (limited to 'application') diff --git a/application/front/controller/admin/ShaarliAdminController.php b/application/front/controller/admin/ShaarliAdminController.php index 3385006c..3bc5bb6b 100644 --- a/application/front/controller/admin/ShaarliAdminController.php +++ b/application/front/controller/admin/ShaarliAdminController.php @@ -34,11 +34,13 @@ abstract class ShaarliAdminController extends ShaarliVisitorController /** * Any persistent action to the config or data store must check the XSRF token validity. */ - protected function checkToken(Request $request): void + protected function checkToken(Request $request): bool { if (!$this->container->sessionManager->checkToken($request->getParam('token'))) { throw new WrongTokenException(); } + + return true; } /** -- cgit v1.2.3 From 66063ed1a18d739b1a60bfb163d8656417a4c529 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Sat, 30 May 2020 14:00:06 +0200 Subject: Process configure page through Slim controller --- .../front/controller/admin/ConfigureController.php | 120 +++++++++++++++++++++ application/render/PageBuilder.php | 4 + 2 files changed, 124 insertions(+) create mode 100644 application/front/controller/admin/ConfigureController.php (limited to 'application') diff --git a/application/front/controller/admin/ConfigureController.php b/application/front/controller/admin/ConfigureController.php new file mode 100644 index 00000000..b1d32270 --- /dev/null +++ b/application/front/controller/admin/ConfigureController.php @@ -0,0 +1,120 @@ +assignView('title', $this->container->conf->get('general.title', 'Shaarli')); + $this->assignView('theme', $this->container->conf->get('resource.theme')); + $this->assignView( + 'theme_available', + ThemeUtils::getThemes($this->container->conf->get('resource.raintpl_tpl')) + ); + $this->assignView('formatter_available', ['default', 'markdown']); + list($continents, $cities) = generateTimeZoneData( + timezone_identifiers_list(), + $this->container->conf->get('general.timezone') + ); + $this->assignView('continents', $continents); + $this->assignView('cities', $cities); + $this->assignView('retrieve_description', $this->container->conf->get('general.retrieve_description', false)); + $this->assignView('private_links_default', $this->container->conf->get('privacy.default_private_links', false)); + $this->assignView( + 'session_protection_disabled', + $this->container->conf->get('security.session_protection_disabled', false) + ); + $this->assignView('enable_rss_permalinks', $this->container->conf->get('feed.rss_permalinks', false)); + $this->assignView('enable_update_check', $this->container->conf->get('updates.check_updates', true)); + $this->assignView('hide_public_links', $this->container->conf->get('privacy.hide_public_links', false)); + $this->assignView('api_enabled', $this->container->conf->get('api.enabled', true)); + $this->assignView('api_secret', $this->container->conf->get('api.secret')); + $this->assignView('languages', Languages::getAvailableLanguages()); + $this->assignView('gd_enabled', extension_loaded('gd')); + $this->assignView('thumbnails_mode', $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE)); + $this->assignView('pagetitle', t('Configure') .' - '. $this->container->conf->get('general.title', 'Shaarli')); + + return $response->write($this->render('configure')); + } + + /** + * POST /configure - Update Shaarli's configuration + */ + public function save(Request $request, Response $response): Response + { + $this->checkToken($request); + + $continent = $request->getParam('continent'); + $city = $request->getParam('city'); + $tz = 'UTC'; + if (null !== $continent && null !== $city && isTimeZoneValid($continent, $city)) { + $tz = $continent . '/' . $city; + } + + $this->container->conf->set('general.timezone', $tz); + $this->container->conf->set('general.title', escape($request->getParam('title'))); + $this->container->conf->set('general.header_link', escape($request->getParam('titleLink'))); + $this->container->conf->set('general.retrieve_description', !empty($request->getParam('retrieveDescription'))); + $this->container->conf->set('resource.theme', escape($request->getParam('theme'))); + $this->container->conf->set( + 'security.session_protection_disabled', + !empty($request->getParam('disablesessionprotection')) + ); + $this->container->conf->set( + 'privacy.default_private_links', + !empty($request->getParam('privateLinkByDefault')) + ); + $this->container->conf->set('feed.rss_permalinks', !empty($request->getParam('enableRssPermalinks'))); + $this->container->conf->set('updates.check_updates', !empty($request->getParam('updateCheck'))); + $this->container->conf->set('privacy.hide_public_links', !empty($request->getParam('hidePublicLinks'))); + $this->container->conf->set('api.enabled', !empty($request->getParam('enableApi'))); + $this->container->conf->set('api.secret', escape($request->getParam('apiSecret'))); + $this->container->conf->set('formatter', escape($request->getParam('formatter'))); + + if (!empty($request->getParam('language'))) { + $this->container->conf->set('translation.language', escape($request->getParam('language'))); + } + + $thumbnailsMode = extension_loaded('gd') ? $request->getParam('enableThumbnails') : Thumbnailer::MODE_NONE; + if ($thumbnailsMode !== Thumbnailer::MODE_NONE + && $thumbnailsMode !== $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) + ) { + $this->saveWarningMessage(t( + 'You have enabled or changed thumbnails mode. ' + .'Please synchronize them.' + )); + } + $this->container->conf->set('thumbnails.mode', $thumbnailsMode); + + try { + $this->container->conf->write($this->container->loginManager->isLoggedIn()); + $this->container->history->updateSettings(); + $this->container->pageCacheManager->invalidateCaches(); + } catch (Throwable $e) { + // TODO: translation + stacktrace + $this->saveErrorMessage('ERROR while writing config file after configuration update.'); + } + + $this->saveSuccessMessage(t('Configuration was saved.')); + + return $response->withRedirect('./configure'); + } +} diff --git a/application/render/PageBuilder.php b/application/render/PageBuilder.php index 264cd33b..d90ed58b 100644 --- a/application/render/PageBuilder.php +++ b/application/render/PageBuilder.php @@ -143,6 +143,10 @@ class PageBuilder $this->tpl->assign('conf', $this->conf); } + /** + * Affect variable after controller processing. + * Used for alert messages. + */ protected function finalize(): void { // TODO: use the SessionManager -- cgit v1.2.3 From 8eac2e54882d8adae8cbb45386dca1b465242632 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Sat, 30 May 2020 15:51:14 +0200 Subject: Process manage tags page through Slim controller --- .../front/controller/admin/ConfigureController.php | 2 +- .../front/controller/admin/ManageTagController.php | 87 ++++++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 application/front/controller/admin/ManageTagController.php (limited to 'application') diff --git a/application/front/controller/admin/ConfigureController.php b/application/front/controller/admin/ConfigureController.php index b1d32270..5a482d8e 100644 --- a/application/front/controller/admin/ConfigureController.php +++ b/application/front/controller/admin/ConfigureController.php @@ -12,7 +12,7 @@ use Slim\Http\Response; use Throwable; /** - * Class PasswordController + * Class ConfigureController * * Slim controller used to handle Shaarli configuration page (display + save new config). */ diff --git a/application/front/controller/admin/ManageTagController.php b/application/front/controller/admin/ManageTagController.php new file mode 100644 index 00000000..e015e613 --- /dev/null +++ b/application/front/controller/admin/ManageTagController.php @@ -0,0 +1,87 @@ +getParam('fromtag') ?? ''; + + $this->assignView('fromtag', escape($fromTag)); + $this->assignView( + 'pagetitle', + t('Manage tags') .' - '. $this->container->conf->get('general.title', 'Shaarli') + ); + + return $response->write($this->render('changetag')); + } + + /** + * POST /manage-tags - Update or delete provided tag + */ + public function save(Request $request, Response $response): Response + { + $this->checkToken($request); + + $isDelete = null !== $request->getParam('deletetag') && null === $request->getParam('renametag'); + + $fromTag = escape(trim($request->getParam('fromtag') ?? '')); + $toTag = escape(trim($request->getParam('totag') ?? '')); + + if (0 === strlen($fromTag) || false === $isDelete && 0 === strlen($toTag)) { + $this->saveWarningMessage(t('Invalid tags provided.')); + + return $response->withRedirect('./manage-tags'); + } + + // TODO: move this to bookmark service + $count = 0; + $bookmarks = $this->container->bookmarkService->search(['searchtags' => $fromTag], BookmarkFilter::$ALL, true); + foreach ($bookmarks as $bookmark) { + if (false === $isDelete) { + $bookmark->renameTag($fromTag, $toTag); + } else { + $bookmark->deleteTag($fromTag); + } + + $this->container->bookmarkService->set($bookmark, false); + $this->container->history->updateLink($bookmark); + $count++; + } + + $this->container->bookmarkService->save(); + + if (true === $isDelete) { + $alert = sprintf( + t('The tag was removed from %d bookmark.', 'The tag was removed from %d bookmarks.', $count), + $count + ); + } else { + $alert = sprintf( + t('The tag was renamed in %d bookmark.', 'The tag was renamed in %d bookmarks.', $count), + $count + ); + } + + $this->saveSuccessMessage($alert); + + $redirect = true === $isDelete ? './manage-tags' : './?searchtags='. urlencode($toTag); + + return $response->withRedirect($redirect); + } +} -- cgit v1.2.3 From c22fa57a5505fe95fd01860e3d3dfbb089f869cd Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Sat, 6 Jun 2020 14:01:03 +0200 Subject: Handle shaare creation/edition/deletion through Slim controllers --- application/Utils.php | 4 + application/bookmark/LinkUtils.php | 106 --------- application/container/ContainerBuilder.php | 10 + application/container/ShaarliContainer.php | 4 + .../controller/admin/PostBookmarkController.php | 258 +++++++++++++++++++++ .../front/controller/admin/ToolsController.php | 2 +- .../front/controller/visitor/DailyController.php | 2 +- .../front/controller/visitor/FeedController.php | 2 +- .../visitor/ShaarliVisitorController.php | 14 +- application/http/HttpAccess.php | 39 ++++ application/http/HttpUtils.php | 106 +++++++++ 11 files changed, 432 insertions(+), 115 deletions(-) create mode 100644 application/front/controller/admin/PostBookmarkController.php create mode 100644 application/http/HttpAccess.php (limited to 'application') diff --git a/application/Utils.php b/application/Utils.php index 72c90049..9c9eaaa2 100644 --- a/application/Utils.php +++ b/application/Utils.php @@ -91,6 +91,10 @@ function endsWith($haystack, $needle, $case = true) */ function escape($input) { + if (null === $input) { + return null; + } + if (is_bool($input)) { return $input; } diff --git a/application/bookmark/LinkUtils.php b/application/bookmark/LinkUtils.php index 98d9038a..68914fca 100644 --- a/application/bookmark/LinkUtils.php +++ b/application/bookmark/LinkUtils.php @@ -2,112 +2,6 @@ use Shaarli\Bookmark\Bookmark; -/** - * Get cURL callback function for CURLOPT_WRITEFUNCTION - * - * @param string $charset to extract from the downloaded page (reference) - * @param string $title to extract from the downloaded page (reference) - * @param string $description to extract from the downloaded page (reference) - * @param string $keywords to extract from the downloaded page (reference) - * @param bool $retrieveDescription Automatically tries to retrieve description and keywords from HTML content - * @param string $curlGetInfo Optionally overrides curl_getinfo function - * - * @return Closure - */ -function get_curl_download_callback( - &$charset, - &$title, - &$description, - &$keywords, - $retrieveDescription, - $curlGetInfo = 'curl_getinfo' -) { - $isRedirected = false; - $currentChunk = 0; - $foundChunk = null; - - /** - * cURL callback function for CURLOPT_WRITEFUNCTION (called during the download). - * - * While downloading the remote page, we check that the HTTP code is 200 and content type is 'html/text' - * Then we extract the title and the charset and stop the download when it's done. - * - * @param resource $ch cURL resource - * @param string $data chunk of data being downloaded - * - * @return int|bool length of $data or false if we need to stop the download - */ - return function (&$ch, $data) use ( - $retrieveDescription, - $curlGetInfo, - &$charset, - &$title, - &$description, - &$keywords, - &$isRedirected, - &$currentChunk, - &$foundChunk - ) { - $currentChunk++; - $responseCode = $curlGetInfo($ch, CURLINFO_RESPONSE_CODE); - if (!empty($responseCode) && in_array($responseCode, [301, 302])) { - $isRedirected = true; - return strlen($data); - } - if (!empty($responseCode) && $responseCode !== 200) { - return false; - } - // After a redirection, the content type will keep the previous request value - // until it finds the next content-type header. - if (! $isRedirected || strpos(strtolower($data), 'content-type') !== false) { - $contentType = $curlGetInfo($ch, CURLINFO_CONTENT_TYPE); - } - if (!empty($contentType) && strpos($contentType, 'text/html') === false) { - return false; - } - if (!empty($contentType) && empty($charset)) { - $charset = header_extract_charset($contentType); - } - if (empty($charset)) { - $charset = html_extract_charset($data); - } - if (empty($title)) { - $title = html_extract_title($data); - $foundChunk = ! empty($title) ? $currentChunk : $foundChunk; - } - if ($retrieveDescription && empty($description)) { - $description = html_extract_tag('description', $data); - $foundChunk = ! empty($description) ? $currentChunk : $foundChunk; - } - if ($retrieveDescription && empty($keywords)) { - $keywords = html_extract_tag('keywords', $data); - if (! empty($keywords)) { - $foundChunk = $currentChunk; - // Keywords use the format tag1, tag2 multiple words, tag - // So we format them to match Shaarli's separator and glue multiple words with '-' - $keywords = implode(' ', array_map(function($keyword) { - return implode('-', preg_split('/\s+/', trim($keyword))); - }, explode(',', $keywords))); - } - } - - // We got everything we want, stop the download. - // If we already found either the title, description or keywords, - // it's highly unlikely that we'll found the other metas further than - // in the same chunk of data or the next one. So we also stop the download after that. - if ((!empty($responseCode) && !empty($contentType) && !empty($charset)) && $foundChunk !== null - && (! $retrieveDescription - || $foundChunk < $currentChunk - || (!empty($title) && !empty($description) && !empty($keywords)) - ) - ) { - return false; - } - - return strlen($data); - }; -} - /** * Extract title from an HTML document. * diff --git a/application/container/ContainerBuilder.php b/application/container/ContainerBuilder.php index 84406979..85126246 100644 --- a/application/container/ContainerBuilder.php +++ b/application/container/ContainerBuilder.php @@ -10,11 +10,13 @@ use Shaarli\Config\ConfigManager; use Shaarli\Feed\FeedBuilder; use Shaarli\Formatter\FormatterFactory; use Shaarli\History; +use Shaarli\Http\HttpAccess; use Shaarli\Plugin\PluginManager; use Shaarli\Render\PageBuilder; use Shaarli\Render\PageCacheManager; use Shaarli\Security\LoginManager; use Shaarli\Security\SessionManager; +use Shaarli\Thumbnailer; /** * Class ContainerBuilder @@ -110,6 +112,14 @@ class ContainerBuilder ); }; + $container['thumbnailer'] = function (ShaarliContainer $container): Thumbnailer { + return new Thumbnailer($container->conf); + }; + + $container['httpAccess'] = function (): HttpAccess { + return new HttpAccess(); + }; + return $container; } } diff --git a/application/container/ShaarliContainer.php b/application/container/ShaarliContainer.php index deb07197..fec398d0 100644 --- a/application/container/ShaarliContainer.php +++ b/application/container/ShaarliContainer.php @@ -9,11 +9,13 @@ use Shaarli\Config\ConfigManager; use Shaarli\Feed\FeedBuilder; use Shaarli\Formatter\FormatterFactory; use Shaarli\History; +use Shaarli\Http\HttpAccess; use Shaarli\Plugin\PluginManager; use Shaarli\Render\PageBuilder; use Shaarli\Render\PageCacheManager; use Shaarli\Security\LoginManager; use Shaarli\Security\SessionManager; +use Shaarli\Thumbnailer; use Slim\Container; /** @@ -31,6 +33,8 @@ use Slim\Container; * @property FormatterFactory $formatterFactory * @property PageCacheManager $pageCacheManager * @property FeedBuilder $feedBuilder + * @property Thumbnailer $thumbnailer + * @property HttpAccess $httpAccess */ class ShaarliContainer extends Container { diff --git a/application/front/controller/admin/PostBookmarkController.php b/application/front/controller/admin/PostBookmarkController.php new file mode 100644 index 00000000..dbe570e2 --- /dev/null +++ b/application/front/controller/admin/PostBookmarkController.php @@ -0,0 +1,258 @@ +assignView( + 'pagetitle', + t('Shaare a new link') .' - '. $this->container->conf->get('general.title', 'Shaarli') + ); + + return $response->write($this->render('addlink')); + } + + /** + * GET /shaare - Displays the bookmark form for creation. + * Note that if the URL is found in existing bookmarks, then it will be in edit mode. + */ + public function displayCreateForm(Request $request, Response $response): Response + { + $url = cleanup_url($request->getParam('post')); + + $linkIsNew = false; + // Check if URL is not already in database (in this case, we will edit the existing link) + $bookmark = $this->container->bookmarkService->findByUrl($url); + if (null === $bookmark) { + $linkIsNew = true; + // Get shaare data if it was provided in URL (e.g.: by the bookmarklet). + $title = $request->getParam('title'); + $description = $request->getParam('description'); + $tags = $request->getParam('tags'); + $private = filter_var($request->getParam('private'), FILTER_VALIDATE_BOOLEAN); + + // If this is an HTTP(S) link, we try go get the page to extract + // the title (otherwise we will to straight to the edit form.) + if (empty($title) && strpos(get_url_scheme($url) ?: '', 'http') !== false) { + $retrieveDescription = $this->container->conf->get('general.retrieve_description'); + // Short timeout to keep the application responsive + // The callback will fill $charset and $title with data from the downloaded page. + $this->container->httpAccess->getHttpResponse( + $url, + $this->container->conf->get('general.download_timeout', 30), + $this->container->conf->get('general.download_max_size', 4194304), + $this->container->httpAccess->getCurlDownloadCallback( + $charset, + $title, + $description, + $tags, + $retrieveDescription + ) + ); + if (! empty($title) && strtolower($charset) !== 'utf-8') { + $title = mb_convert_encoding($title, 'utf-8', $charset); + } + } + + if (empty($url) && empty($title)) { + $title = $this->container->conf->get('general.default_note_title', t('Note: ')); + } + + $link = escape([ + 'title' => $title, + 'url' => $url ?? '', + 'description' => $description ?? '', + 'tags' => $tags ?? '', + 'private' => $private, + ]); + } else { + $formatter = $this->container->formatterFactory->getFormatter('raw'); + $link = $formatter->format($bookmark); + } + + return $this->displayForm($link, $linkIsNew, $request, $response); + } + + /** + * GET /shaare-{id} - Displays the bookmark form in edition mode. + */ + public function displayEditForm(Request $request, Response $response, array $args): Response + { + $id = $args['id']; + try { + if (false === ctype_digit($id)) { + throw new BookmarkNotFoundException(); + } + $bookmark = $this->container->bookmarkService->get($id); // Read database + } catch (BookmarkNotFoundException $e) { + $this->saveErrorMessage(t('Bookmark not found')); + + return $response->withRedirect('./'); + } + + $formatter = $this->container->formatterFactory->getFormatter('raw'); + $link = $formatter->format($bookmark); + + return $this->displayForm($link, false, $request, $response); + } + + /** + * POST /shaare + */ + public function save(Request $request, Response $response): Response + { + $this->checkToken($request); + + // lf_id should only be present if the link exists. + $id = $request->getParam('lf_id') ? intval(escape($request->getParam('lf_id'))) : null; + if (null !== $id && true === $this->container->bookmarkService->exists($id)) { + // Edit + $bookmark = $this->container->bookmarkService->get($id); + } else { + // New link + $bookmark = new Bookmark(); + } + + $bookmark->setTitle($request->getParam('lf_title')); + $bookmark->setDescription($request->getParam('lf_description')); + $bookmark->setUrl($request->getParam('lf_url'), $this->container->conf->get('security.allowed_protocols', [])); + $bookmark->setPrivate(filter_var($request->getParam('lf_private'), FILTER_VALIDATE_BOOLEAN)); + $bookmark->setTagsString($request->getParam('lf_tags')); + + if ($this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE + && false === $bookmark->isNote() + ) { + $bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl())); + } + $this->container->bookmarkService->addOrSet($bookmark, false); + + // To preserve backward compatibility with 3rd parties, plugins still use arrays + $formatter = $this->container->formatterFactory->getFormatter('raw'); + $data = $formatter->format($bookmark); + $data = $this->executeHooks('save_link', $data); + + $bookmark->fromArray($data); + $this->container->bookmarkService->set($bookmark); + + // If we are called from the bookmarklet, we must close the popup: + if ($request->getParam('source') === 'bookmarklet') { + return $response->write(''); + } + + if (!empty($request->getParam('returnurl'))) { + $this->container->environment['HTTP_REFERER'] = escape($request->getParam('returnurl')); + } + + return $this->redirectFromReferer( + $request, + $response, + ['add-shaare', 'shaare'], ['addlink', 'post', 'edit_link'], + $bookmark->getShortUrl() + ); + } + + public function deleteBookmark(Request $request, Response $response): Response + { + $this->checkToken($request); + + $ids = escape(trim($request->getParam('lf_linkdate'))); + if (strpos($ids, ' ') !== false) { + // multiple, space-separated ids provided + $ids = array_values(array_filter(preg_split('/\s+/', $ids), 'strlen')); + } else { + $ids = [$ids]; + } + + // assert at least one id is given + if (0 === count($ids)) { + $this->saveErrorMessage(t('Invalid bookmark ID provided.')); + + return $this->redirectFromReferer($request, $response, [], ['delete-shaare']); + } + + $formatter = $this->container->formatterFactory->getFormatter('raw'); + foreach ($ids as $id) { + $id = (int) $id; + // TODO: check if it exists + $bookmark = $this->container->bookmarkService->get($id); + $data = $formatter->format($bookmark); + $this->container->pluginManager->executeHooks('delete_link', $data); + $this->container->bookmarkService->remove($bookmark, false); + } + + $this->container->bookmarkService->save(); + + // If we are called from the bookmarklet, we must close the popup: + if ($request->getParam('source') === 'bookmarklet') { + return $response->write(''); + } + + // Don't redirect to where we were previously because the datastore has changed. + return $response->withRedirect('./'); + } + + protected function displayForm(array $link, bool $isNew, Request $request, Response $response): Response + { + $tags = $this->container->bookmarkService->bookmarksCountPerTag(); + if ($this->container->conf->get('formatter') === 'markdown') { + $tags[BookmarkMarkdownFormatter::NO_MD_TAG] = 1; + } + + $data = [ + 'link' => $link, + 'link_is_new' => $isNew, + 'http_referer' => escape($this->container->environment['HTTP_REFERER'] ?? ''), + 'source' => $request->getParam('source') ?? '', + 'tags' => $tags, + 'default_private_links' => $this->container->conf->get('privacy.default_private_links', false), + ]; + + $data = $this->executeHooks('render_editlink', $data); + + foreach ($data as $key => $value) { + $this->assignView($key, $value); + } + + $editLabel = false === $isNew ? t('Edit') .' ' : ''; + $this->assignView( + 'pagetitle', + $editLabel . t('Shaare') .' - '. $this->container->conf->get('general.title', 'Shaarli') + ); + + return $response->write($this->render('editlink')); + } + + /** + * @param mixed[] $data Variables passed to the template engine + * + * @return mixed[] Template data after active plugins render_picwall hook execution. + */ + protected function executeHooks(string $hook, array $data): array + { + $this->container->pluginManager->executeHooks( + $hook, + $data + ); + + return $data; + } +} diff --git a/application/front/controller/admin/ToolsController.php b/application/front/controller/admin/ToolsController.php index 66db5ad9..d087f2cd 100644 --- a/application/front/controller/admin/ToolsController.php +++ b/application/front/controller/admin/ToolsController.php @@ -21,7 +21,7 @@ class ToolsController extends ShaarliAdminController 'sslenabled' => is_https($this->container->environment), ]; - $this->executeHooks($data); + $data = $this->executeHooks($data); foreach ($data as $key => $value) { $this->assignView($key, $value); diff --git a/application/front/controller/visitor/DailyController.php b/application/front/controller/visitor/DailyController.php index 47e2503a..e5c9ddac 100644 --- a/application/front/controller/visitor/DailyController.php +++ b/application/front/controller/visitor/DailyController.php @@ -71,7 +71,7 @@ class DailyController extends ShaarliVisitorController ]; // Hooks are called before column construction so that plugins don't have to deal with columns. - $this->executeHooks($data); + $data = $this->executeHooks($data); $data['cols'] = $this->calculateColumns($data['linksToDisplay']); diff --git a/application/front/controller/visitor/FeedController.php b/application/front/controller/visitor/FeedController.php index 70664635..f76f55fd 100644 --- a/application/front/controller/visitor/FeedController.php +++ b/application/front/controller/visitor/FeedController.php @@ -46,7 +46,7 @@ class FeedController extends ShaarliVisitorController $data = $this->container->feedBuilder->buildData($feedType, $request->getParams()); - $this->executeHooks($data, $feedType); + $data = $this->executeHooks($data, $feedType); $this->assignAllView($data); $content = $this->render('feed.'. $feedType); diff --git a/application/front/controller/visitor/ShaarliVisitorController.php b/application/front/controller/visitor/ShaarliVisitorController.php index f12915c1..98423d90 100644 --- a/application/front/controller/visitor/ShaarliVisitorController.php +++ b/application/front/controller/visitor/ShaarliVisitorController.php @@ -78,16 +78,16 @@ abstract class ShaarliVisitorController ]; foreach ($common_hooks as $name) { - $plugin_data = []; + $pluginData = []; $this->container->pluginManager->executeHooks( 'render_' . $name, - $plugin_data, + $pluginData, [ 'target' => $template, 'loggedin' => $this->container->loginManager->isLoggedIn() ] ); - $this->assignView('plugins_' . $name, $plugin_data); + $this->assignView('plugins_' . $name, $pluginData); } } @@ -102,9 +102,10 @@ abstract class ShaarliVisitorController Request $request, Response $response, array $loopTerms = [], - array $clearParams = [] + array $clearParams = [], + string $anchor = null ): Response { - $defaultPath = $request->getUri()->getBasePath(); + $defaultPath = rtrim($request->getUri()->getBasePath(), '/') . '/'; $referer = $this->container->environment['HTTP_REFERER'] ?? null; if (null !== $referer) { @@ -133,7 +134,8 @@ abstract class ShaarliVisitorController } $queryString = count($params) > 0 ? '?'. http_build_query($params) : ''; + $anchor = $anchor ? '#' . $anchor : ''; - return $response->withRedirect($path . $queryString); + return $response->withRedirect($path . $queryString . $anchor); } } diff --git a/application/http/HttpAccess.php b/application/http/HttpAccess.php new file mode 100644 index 00000000..81d9e076 --- /dev/null +++ b/application/http/HttpAccess.php @@ -0,0 +1,39 @@ + Date: Sat, 13 Jun 2020 11:22:14 +0200 Subject: Explicitly define base and asset path in templates With the new routes, all pages are not all at the same folder level anymore (e.g. /shaare and /shaare/123), so we can't just use './' everywhere. The most consistent way to handle this is to prefix all path with the proper variable, and handle the actual path in controllers. --- application/container/ShaarliContainer.php | 1 + application/front/ShaarliMiddleware.php | 2 ++ .../front/controller/visitor/ShaarliVisitorController.php | 15 ++++++++++++++- application/render/PageBuilder.php | 4 ++++ 4 files changed, 21 insertions(+), 1 deletion(-) (limited to 'application') diff --git a/application/container/ShaarliContainer.php b/application/container/ShaarliContainer.php index fec398d0..a95393cd 100644 --- a/application/container/ShaarliContainer.php +++ b/application/container/ShaarliContainer.php @@ -22,6 +22,7 @@ use Slim\Container; * Extension of Slim container to document the injected objects. * * @property mixed[] $environment $_SERVER automatically injected by Slim + * @property string $basePath Shaarli's instance base path (e.g. `/shaarli/`) * @property ConfigManager $conf * @property SessionManager $sessionManager * @property LoginManager $loginManager diff --git a/application/front/ShaarliMiddleware.php b/application/front/ShaarliMiddleware.php index f8992e0b..47aa61bb 100644 --- a/application/front/ShaarliMiddleware.php +++ b/application/front/ShaarliMiddleware.php @@ -39,6 +39,8 @@ class ShaarliMiddleware public function __invoke(Request $request, Response $response, callable $next) { try { + $this->container->basePath = rtrim($request->getUri()->getBasePath(), '/'); + $response = $next($request, $response); } catch (ShaarliFrontException $e) { $this->container->pageBuilder->assign('message', $e->getMessage()); diff --git a/application/front/controller/visitor/ShaarliVisitorController.php b/application/front/controller/visitor/ShaarliVisitorController.php index 98423d90..b90b1e8f 100644 --- a/application/front/controller/visitor/ShaarliVisitorController.php +++ b/application/front/controller/visitor/ShaarliVisitorController.php @@ -60,6 +60,19 @@ abstract class ShaarliVisitorController $this->assignView('privateLinkcount', $this->container->bookmarkService->count(BookmarkFilter::$PRIVATE)); $this->assignView('plugin_errors', $this->container->pluginManager->getErrors()); + /* + * Define base path (if Shaarli is installed in a domain's subfolder, e.g. `/shaarli`) + * and the asset path (subfolder/tpl/default for default theme). + * These MUST be used to create an internal link or to include an asset in templates. + */ + $this->assignView('base_path', $this->container->basePath); + $this->assignView( + 'asset_path', + $this->container->basePath . '/' . + rtrim($this->container->conf->get('resource.raintpl_tpl', 'tpl'), '/') . '/' . + $this->container->conf->get('resource.theme', 'default') + ); + $this->executeDefaultHooks($template); return $this->container->pageBuilder->render($template); @@ -105,7 +118,7 @@ abstract class ShaarliVisitorController array $clearParams = [], string $anchor = null ): Response { - $defaultPath = rtrim($request->getUri()->getBasePath(), '/') . '/'; + $defaultPath = $this->container->basePath . '/'; $referer = $this->container->environment['HTTP_REFERER'] ?? null; if (null !== $referer) { diff --git a/application/render/PageBuilder.php b/application/render/PageBuilder.php index d90ed58b..2779eb90 100644 --- a/application/render/PageBuilder.php +++ b/application/render/PageBuilder.php @@ -149,6 +149,10 @@ class PageBuilder */ protected function finalize(): void { + //FIXME - DEV _ REMOVE ME + $this->assign('base_path', '/Shaarli'); + $this->assign('asset_path', '/Shaarli/tpl/default'); + // TODO: use the SessionManager $messageKeys = [ SessionManager::KEY_SUCCESS_MESSAGES, -- cgit v1.2.3 From 9c75f877935fa6adec951a4d8d32b328aaab314f Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Sat, 13 Jun 2020 13:08:01 +0200 Subject: Use multi-level routes for existing controllers instead of 1 level everywhere Also prefix most admin routes with /admin/ --- application/container/ContainerBuilder.php | 10 ++++------ application/container/ShaarliContainer.php | 17 ++++++++--------- application/front/ShaarliMiddleware.php | 6 +++--- .../front/controller/admin/ConfigureController.php | 6 +++--- application/front/controller/admin/LogoutController.php | 4 ++-- .../front/controller/admin/ManageTagController.php | 10 +++++----- .../front/controller/admin/PasswordController.php | 4 ++-- .../front/controller/admin/PostBookmarkController.php | 17 ++++++++++------- .../front/controller/visitor/LoginController.php | 2 +- .../controller/visitor/ShaarliVisitorController.php | 13 +++++++++++++ application/front/controller/visitor/TagController.php | 8 +++++--- 11 files changed, 56 insertions(+), 41 deletions(-) (limited to 'application') diff --git a/application/container/ContainerBuilder.php b/application/container/ContainerBuilder.php index 85126246..72a85710 100644 --- a/application/container/ContainerBuilder.php +++ b/application/container/ContainerBuilder.php @@ -38,19 +38,17 @@ class ContainerBuilder /** @var LoginManager */ protected $login; - /** @var string */ - protected $webPath; + /** @var string|null */ + protected $basePath = null; public function __construct( ConfigManager $conf, SessionManager $session, - LoginManager $login, - string $webPath + LoginManager $login ) { $this->conf = $conf; $this->session = $session; $this->login = $login; - $this->webPath = $webPath; } public function build(): ShaarliContainer @@ -60,7 +58,7 @@ class ContainerBuilder $container['conf'] = $this->conf; $container['sessionManager'] = $this->session; $container['loginManager'] = $this->login; - $container['webPath'] = $this->webPath; + $container['basePath'] = $this->basePath; $container['plugins'] = function (ShaarliContainer $container): PluginManager { return new PluginManager($container->conf); diff --git a/application/container/ShaarliContainer.php b/application/container/ShaarliContainer.php index a95393cd..4b97aae2 100644 --- a/application/container/ShaarliContainer.php +++ b/application/container/ShaarliContainer.php @@ -21,21 +21,20 @@ use Slim\Container; /** * Extension of Slim container to document the injected objects. * - * @property mixed[] $environment $_SERVER automatically injected by Slim * @property string $basePath Shaarli's instance base path (e.g. `/shaarli/`) + * @property BookmarkServiceInterface $bookmarkService * @property ConfigManager $conf - * @property SessionManager $sessionManager - * @property LoginManager $loginManager - * @property string $webPath + * @property mixed[] $environment $_SERVER automatically injected by Slim + * @property FeedBuilder $feedBuilder + * @property FormatterFactory $formatterFactory * @property History $history - * @property BookmarkServiceInterface $bookmarkService + * @property HttpAccess $httpAccess + * @property LoginManager $loginManager * @property PageBuilder $pageBuilder - * @property PluginManager $pluginManager - * @property FormatterFactory $formatterFactory * @property PageCacheManager $pageCacheManager - * @property FeedBuilder $feedBuilder + * @property PluginManager $pluginManager + * @property SessionManager $sessionManager * @property Thumbnailer $thumbnailer - * @property HttpAccess $httpAccess */ class ShaarliContainer extends Container { diff --git a/application/front/ShaarliMiddleware.php b/application/front/ShaarliMiddleware.php index 47aa61bb..7ad610c7 100644 --- a/application/front/ShaarliMiddleware.php +++ b/application/front/ShaarliMiddleware.php @@ -38,9 +38,9 @@ class ShaarliMiddleware */ public function __invoke(Request $request, Response $response, callable $next) { - try { - $this->container->basePath = rtrim($request->getUri()->getBasePath(), '/'); + $this->container->basePath = rtrim($request->getUri()->getBasePath(), '/'); + try { $response = $next($request, $response); } catch (ShaarliFrontException $e) { $this->container->pageBuilder->assign('message', $e->getMessage()); @@ -54,7 +54,7 @@ class ShaarliMiddleware $response = $response->withStatus($e->getCode()); $response = $response->write($this->container->pageBuilder->render('error')); } catch (UnauthorizedException $e) { - return $response->withRedirect($request->getUri()->getBasePath() . '/login'); + return $response->withRedirect($this->container->basePath . '/login'); } return $response; diff --git a/application/front/controller/admin/ConfigureController.php b/application/front/controller/admin/ConfigureController.php index 5a482d8e..44971c43 100644 --- a/application/front/controller/admin/ConfigureController.php +++ b/application/front/controller/admin/ConfigureController.php @@ -19,7 +19,7 @@ use Throwable; class ConfigureController extends ShaarliAdminController { /** - * GET /configure - Displays the configuration page + * GET /admin/configure - Displays the configuration page */ public function index(Request $request, Response $response): Response { @@ -56,7 +56,7 @@ class ConfigureController extends ShaarliAdminController } /** - * POST /configure - Update Shaarli's configuration + * POST /admin/configure - Update Shaarli's configuration */ public function save(Request $request, Response $response): Response { @@ -115,6 +115,6 @@ class ConfigureController extends ShaarliAdminController $this->saveSuccessMessage(t('Configuration was saved.')); - return $response->withRedirect('./configure'); + return $this->redirect($response, '/admin/configure'); } } diff --git a/application/front/controller/admin/LogoutController.php b/application/front/controller/admin/LogoutController.php index 41e81984..c5984814 100644 --- a/application/front/controller/admin/LogoutController.php +++ b/application/front/controller/admin/LogoutController.php @@ -22,8 +22,8 @@ class LogoutController extends ShaarliAdminController $this->container->sessionManager->logout(); // TODO: switch to a simple Cookie manager allowing to check the session, and create mocks. - setcookie(LoginManager::$STAY_SIGNED_IN_COOKIE, 'false', 0, $this->container->webPath); + setcookie(LoginManager::$STAY_SIGNED_IN_COOKIE, 'false', 0, $this->container->basePath . '/'); - return $response->withRedirect('./'); + return $this->redirect($response, '/'); } } diff --git a/application/front/controller/admin/ManageTagController.php b/application/front/controller/admin/ManageTagController.php index e015e613..7dab288a 100644 --- a/application/front/controller/admin/ManageTagController.php +++ b/application/front/controller/admin/ManageTagController.php @@ -16,7 +16,7 @@ use Slim\Http\Response; class ManageTagController extends ShaarliAdminController { /** - * GET /manage-tags - Displays the manage tags page + * GET /admin/tags - Displays the manage tags page */ public function index(Request $request, Response $response): Response { @@ -32,7 +32,7 @@ class ManageTagController extends ShaarliAdminController } /** - * POST /manage-tags - Update or delete provided tag + * POST /admin/tags - Update or delete provided tag */ public function save(Request $request, Response $response): Response { @@ -46,7 +46,7 @@ class ManageTagController extends ShaarliAdminController if (0 === strlen($fromTag) || false === $isDelete && 0 === strlen($toTag)) { $this->saveWarningMessage(t('Invalid tags provided.')); - return $response->withRedirect('./manage-tags'); + return $this->redirect($response, '/admin/tags'); } // TODO: move this to bookmark service @@ -80,8 +80,8 @@ class ManageTagController extends ShaarliAdminController $this->saveSuccessMessage($alert); - $redirect = true === $isDelete ? './manage-tags' : './?searchtags='. urlencode($toTag); + $redirect = true === $isDelete ? '/admin/tags' : '/?searchtags='. urlencode($toTag); - return $response->withRedirect($redirect); + return $this->redirect($response, $redirect); } } diff --git a/application/front/controller/admin/PasswordController.php b/application/front/controller/admin/PasswordController.php index 6e8f0bcb..bcce01a6 100644 --- a/application/front/controller/admin/PasswordController.php +++ b/application/front/controller/admin/PasswordController.php @@ -29,7 +29,7 @@ class PasswordController extends ShaarliAdminController } /** - * GET /password - Displays the change password template + * GET /admin/password - Displays the change password template */ public function index(Request $request, Response $response): Response { @@ -37,7 +37,7 @@ class PasswordController extends ShaarliAdminController } /** - * POST /password - Change admin password - existing and new passwords need to be provided. + * POST /admin/password - Change admin password - existing and new passwords need to be provided. */ public function change(Request $request, Response $response): Response { diff --git a/application/front/controller/admin/PostBookmarkController.php b/application/front/controller/admin/PostBookmarkController.php index dbe570e2..f3ee5dea 100644 --- a/application/front/controller/admin/PostBookmarkController.php +++ b/application/front/controller/admin/PostBookmarkController.php @@ -19,7 +19,7 @@ use Slim\Http\Response; class PostBookmarkController extends ShaarliAdminController { /** - * GET /add-shaare - Displays the form used to create a new bookmark from an URL + * GET /admin/add-shaare - Displays the form used to create a new bookmark from an URL */ public function addShaare(Request $request, Response $response): Response { @@ -32,7 +32,7 @@ class PostBookmarkController extends ShaarliAdminController } /** - * GET /shaare - Displays the bookmark form for creation. + * GET /admin/shaare - Displays the bookmark form for creation. * Note that if the URL is found in existing bookmarks, then it will be in edit mode. */ public function displayCreateForm(Request $request, Response $response): Response @@ -93,7 +93,7 @@ class PostBookmarkController extends ShaarliAdminController } /** - * GET /shaare-{id} - Displays the bookmark form in edition mode. + * GET /admin/shaare/{id} - Displays the bookmark form in edition mode. */ public function displayEditForm(Request $request, Response $response, array $args): Response { @@ -106,7 +106,7 @@ class PostBookmarkController extends ShaarliAdminController } catch (BookmarkNotFoundException $e) { $this->saveErrorMessage(t('Bookmark not found')); - return $response->withRedirect('./'); + return $this->redirect($response, '/'); } $formatter = $this->container->formatterFactory->getFormatter('raw'); @@ -116,7 +116,7 @@ class PostBookmarkController extends ShaarliAdminController } /** - * POST /shaare + * POST /admin/shaare */ public function save(Request $request, Response $response): Response { @@ -170,11 +170,14 @@ class PostBookmarkController extends ShaarliAdminController ); } + /** + * GET /admin/shaare/delete + */ public function deleteBookmark(Request $request, Response $response): Response { $this->checkToken($request); - $ids = escape(trim($request->getParam('lf_linkdate'))); + $ids = escape(trim($request->getParam('id'))); if (strpos($ids, ' ') !== false) { // multiple, space-separated ids provided $ids = array_values(array_filter(preg_split('/\s+/', $ids), 'strlen')); @@ -207,7 +210,7 @@ class PostBookmarkController extends ShaarliAdminController } // Don't redirect to where we were previously because the datastore has changed. - return $response->withRedirect('./'); + return $this->redirect($response, '/'); } protected function displayForm(array $link, bool $isNew, Request $request, Response $response): Response diff --git a/application/front/controller/visitor/LoginController.php b/application/front/controller/visitor/LoginController.php index 4de2f55d..0db1f463 100644 --- a/application/front/controller/visitor/LoginController.php +++ b/application/front/controller/visitor/LoginController.php @@ -23,7 +23,7 @@ class LoginController extends ShaarliVisitorController if ($this->container->loginManager->isLoggedIn() || $this->container->conf->get('security.open_shaarli', false) ) { - return $response->withRedirect('./'); + return $this->redirect($response, '/'); } $userCanLogin = $this->container->loginManager->canLogin($request->getServerParams()); diff --git a/application/front/controller/visitor/ShaarliVisitorController.php b/application/front/controller/visitor/ShaarliVisitorController.php index b90b1e8f..b494a8e6 100644 --- a/application/front/controller/visitor/ShaarliVisitorController.php +++ b/application/front/controller/visitor/ShaarliVisitorController.php @@ -104,6 +104,19 @@ abstract class ShaarliVisitorController } } + /** + * Simple helper which prepend the base path to redirect path. + * + * @param Response $response + * @param string $path Absolute path, e.g.: `/`, or `/admin/shaare/123` regardless of install directory + * + * @return Response updated + */ + protected function redirect(Response $response, string $path): Response + { + return $response->withRedirect($this->container->basePath . $path); + } + /** * Generates a redirection to the previous page, based on the HTTP_REFERER. * It fails back to the home page. diff --git a/application/front/controller/visitor/TagController.php b/application/front/controller/visitor/TagController.php index a0bc1d1b..c176f43f 100644 --- a/application/front/controller/visitor/TagController.php +++ b/application/front/controller/visitor/TagController.php @@ -11,6 +11,8 @@ use Slim\Http\Response; * Class TagController * * Slim controller handle tags. + * + * TODO: check redirections with new helper */ class TagController extends ShaarliVisitorController { @@ -27,10 +29,10 @@ class TagController extends ShaarliVisitorController // In case browser does not send HTTP_REFERER, we search a single tag if (null === $referer) { if (null !== $newTag) { - return $response->withRedirect('./?searchtags='. urlencode($newTag)); + return $this->redirect($response, '/?searchtags='. urlencode($newTag)); } - return $response->withRedirect('./'); + return $this->redirect($response, '/'); } $currentUrl = parse_url($referer); @@ -81,7 +83,7 @@ class TagController extends ShaarliVisitorController // If the referrer is not provided, we can update the search, so we failback on the bookmark list if (empty($referer)) { - return $response->withRedirect('./'); + return $this->redirect($response, '/'); } $tagToRemove = $args['tag'] ?? null; -- cgit v1.2.3 From baa6979194573855b260593094983c33ec338dc7 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Sat, 13 Jun 2020 15:37:02 +0200 Subject: Improve ManageTagController coverage and error handling --- .../controller/admin/ManageShaareController.php | 281 +++++++++++++++++++++ .../controller/admin/PostBookmarkController.php | 261 ------------------- 2 files changed, 281 insertions(+), 261 deletions(-) create mode 100644 application/front/controller/admin/ManageShaareController.php delete mode 100644 application/front/controller/admin/PostBookmarkController.php (limited to 'application') diff --git a/application/front/controller/admin/ManageShaareController.php b/application/front/controller/admin/ManageShaareController.php new file mode 100644 index 00000000..620bbc40 --- /dev/null +++ b/application/front/controller/admin/ManageShaareController.php @@ -0,0 +1,281 @@ +assignView( + 'pagetitle', + t('Shaare a new link') .' - '. $this->container->conf->get('general.title', 'Shaarli') + ); + + return $response->write($this->render('addlink')); + } + + /** + * GET /admin/shaare - Displays the bookmark form for creation. + * Note that if the URL is found in existing bookmarks, then it will be in edit mode. + */ + public function displayCreateForm(Request $request, Response $response): Response + { + $url = cleanup_url($request->getParam('post')); + + $linkIsNew = false; + // Check if URL is not already in database (in this case, we will edit the existing link) + $bookmark = $this->container->bookmarkService->findByUrl($url); + if (null === $bookmark) { + $linkIsNew = true; + // Get shaare data if it was provided in URL (e.g.: by the bookmarklet). + $title = $request->getParam('title'); + $description = $request->getParam('description'); + $tags = $request->getParam('tags'); + $private = filter_var($request->getParam('private'), FILTER_VALIDATE_BOOLEAN); + + // If this is an HTTP(S) link, we try go get the page to extract + // the title (otherwise we will to straight to the edit form.) + if (empty($title) && strpos(get_url_scheme($url) ?: '', 'http') !== false) { + $retrieveDescription = $this->container->conf->get('general.retrieve_description'); + // Short timeout to keep the application responsive + // The callback will fill $charset and $title with data from the downloaded page. + $this->container->httpAccess->getHttpResponse( + $url, + $this->container->conf->get('general.download_timeout', 30), + $this->container->conf->get('general.download_max_size', 4194304), + $this->container->httpAccess->getCurlDownloadCallback( + $charset, + $title, + $description, + $tags, + $retrieveDescription + ) + ); + if (! empty($title) && strtolower($charset) !== 'utf-8') { + $title = mb_convert_encoding($title, 'utf-8', $charset); + } + } + + if (empty($url) && empty($title)) { + $title = $this->container->conf->get('general.default_note_title', t('Note: ')); + } + + $link = escape([ + 'title' => $title, + 'url' => $url ?? '', + 'description' => $description ?? '', + 'tags' => $tags ?? '', + 'private' => $private, + ]); + } else { + $formatter = $this->container->formatterFactory->getFormatter('raw'); + $link = $formatter->format($bookmark); + } + + return $this->displayForm($link, $linkIsNew, $request, $response); + } + + /** + * GET /admin/shaare/{id} - Displays the bookmark form in edition mode. + */ + public function displayEditForm(Request $request, Response $response, array $args): Response + { + $id = $args['id'] ?? ''; + try { + if (false === ctype_digit($id)) { + throw new BookmarkNotFoundException(); + } + $bookmark = $this->container->bookmarkService->get((int) $id); // Read database + } catch (BookmarkNotFoundException $e) { + $this->saveErrorMessage(sprintf( + t('Bookmark with identifier %s could not be found.'), + $id + )); + + return $this->redirect($response, '/'); + } + + $formatter = $this->container->formatterFactory->getFormatter('raw'); + $link = $formatter->format($bookmark); + + return $this->displayForm($link, false, $request, $response); + } + + /** + * POST /admin/shaare + */ + public function save(Request $request, Response $response): Response + { + $this->checkToken($request); + + // lf_id should only be present if the link exists. + $id = $request->getParam('lf_id') ? intval(escape($request->getParam('lf_id'))) : null; + if (null !== $id && true === $this->container->bookmarkService->exists($id)) { + // Edit + $bookmark = $this->container->bookmarkService->get($id); + } else { + // New link + $bookmark = new Bookmark(); + } + + $bookmark->setTitle($request->getParam('lf_title')); + $bookmark->setDescription($request->getParam('lf_description')); + $bookmark->setUrl($request->getParam('lf_url'), $this->container->conf->get('security.allowed_protocols', [])); + $bookmark->setPrivate(filter_var($request->getParam('lf_private'), FILTER_VALIDATE_BOOLEAN)); + $bookmark->setTagsString($request->getParam('lf_tags')); + + if ($this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE + && false === $bookmark->isNote() + ) { + $bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl())); + } + $this->container->bookmarkService->addOrSet($bookmark, false); + + // To preserve backward compatibility with 3rd parties, plugins still use arrays + $formatter = $this->container->formatterFactory->getFormatter('raw'); + $data = $formatter->format($bookmark); + $data = $this->executeHooks('save_link', $data); + + $bookmark->fromArray($data); + $this->container->bookmarkService->set($bookmark); + + // If we are called from the bookmarklet, we must close the popup: + if ($request->getParam('source') === 'bookmarklet') { + return $response->write(''); + } + + if (!empty($request->getParam('returnurl'))) { + $this->container->environment['HTTP_REFERER'] = escape($request->getParam('returnurl')); + } + + return $this->redirectFromReferer( + $request, + $response, + ['add-shaare', 'shaare'], ['addlink', 'post', 'edit_link'], + $bookmark->getShortUrl() + ); + } + + /** + * GET /admin/shaare/delete + */ + public function deleteBookmark(Request $request, Response $response): Response + { + $this->checkToken($request); + + $ids = escape(trim($request->getParam('id') ?? '')); + if (empty($ids) || strpos($ids, ' ') !== false) { + // multiple, space-separated ids provided + $ids = array_values(array_filter(preg_split('/\s+/', $ids), 'ctype_digit')); + } else { + $ids = [$ids]; + } + + // assert at least one id is given + if (0 === count($ids)) { + $this->saveErrorMessage(t('Invalid bookmark ID provided.')); + + return $this->redirectFromReferer($request, $response, [], ['delete-shaare']); + } + + $formatter = $this->container->formatterFactory->getFormatter('raw'); + $count = 0; + foreach ($ids as $id) { + try { + $bookmark = $this->container->bookmarkService->get((int) $id); + } catch (BookmarkNotFoundException $e) { + $this->saveErrorMessage(sprintf( + t('Bookmark with identifier %s could not be found.'), + $id + )); + + continue; + } + + $data = $formatter->format($bookmark); + $this->container->pluginManager->executeHooks('delete_link', $data); + $this->container->bookmarkService->remove($bookmark, false); + ++ $count; + } + + if ($count > 0) { + $this->container->bookmarkService->save(); + } + + // If we are called from the bookmarklet, we must close the popup: + if ($request->getParam('source') === 'bookmarklet') { + return $response->write(''); + } + + // Don't redirect to where we were previously because the datastore has changed. + return $this->redirect($response, '/'); + } + + /** + * Helper function used to display the shaare form whether it's a new or existing bookmark. + * + * @param array $link data used in template, either from parameters or from the data store + */ + protected function displayForm(array $link, bool $isNew, Request $request, Response $response): Response + { + $tags = $this->container->bookmarkService->bookmarksCountPerTag(); + if ($this->container->conf->get('formatter') === 'markdown') { + $tags[BookmarkMarkdownFormatter::NO_MD_TAG] = 1; + } + + $data = [ + 'link' => $link, + 'link_is_new' => $isNew, + 'http_referer' => escape($this->container->environment['HTTP_REFERER'] ?? ''), + 'source' => $request->getParam('source') ?? '', + 'tags' => $tags, + 'default_private_links' => $this->container->conf->get('privacy.default_private_links', false), + ]; + + $data = $this->executeHooks('render_editlink', $data); + + foreach ($data as $key => $value) { + $this->assignView($key, $value); + } + + $editLabel = false === $isNew ? t('Edit') .' ' : ''; + $this->assignView( + 'pagetitle', + $editLabel . t('Shaare') .' - '. $this->container->conf->get('general.title', 'Shaarli') + ); + + return $response->write($this->render('editlink')); + } + + /** + * @param mixed[] $data Variables passed to the template engine + * + * @return mixed[] Template data after active plugins render_picwall hook execution. + */ + protected function executeHooks(string $hook, array $data): array + { + $this->container->pluginManager->executeHooks( + $hook, + $data + ); + + return $data; + } +} diff --git a/application/front/controller/admin/PostBookmarkController.php b/application/front/controller/admin/PostBookmarkController.php deleted file mode 100644 index f3ee5dea..00000000 --- a/application/front/controller/admin/PostBookmarkController.php +++ /dev/null @@ -1,261 +0,0 @@ -assignView( - 'pagetitle', - t('Shaare a new link') .' - '. $this->container->conf->get('general.title', 'Shaarli') - ); - - return $response->write($this->render('addlink')); - } - - /** - * GET /admin/shaare - Displays the bookmark form for creation. - * Note that if the URL is found in existing bookmarks, then it will be in edit mode. - */ - public function displayCreateForm(Request $request, Response $response): Response - { - $url = cleanup_url($request->getParam('post')); - - $linkIsNew = false; - // Check if URL is not already in database (in this case, we will edit the existing link) - $bookmark = $this->container->bookmarkService->findByUrl($url); - if (null === $bookmark) { - $linkIsNew = true; - // Get shaare data if it was provided in URL (e.g.: by the bookmarklet). - $title = $request->getParam('title'); - $description = $request->getParam('description'); - $tags = $request->getParam('tags'); - $private = filter_var($request->getParam('private'), FILTER_VALIDATE_BOOLEAN); - - // If this is an HTTP(S) link, we try go get the page to extract - // the title (otherwise we will to straight to the edit form.) - if (empty($title) && strpos(get_url_scheme($url) ?: '', 'http') !== false) { - $retrieveDescription = $this->container->conf->get('general.retrieve_description'); - // Short timeout to keep the application responsive - // The callback will fill $charset and $title with data from the downloaded page. - $this->container->httpAccess->getHttpResponse( - $url, - $this->container->conf->get('general.download_timeout', 30), - $this->container->conf->get('general.download_max_size', 4194304), - $this->container->httpAccess->getCurlDownloadCallback( - $charset, - $title, - $description, - $tags, - $retrieveDescription - ) - ); - if (! empty($title) && strtolower($charset) !== 'utf-8') { - $title = mb_convert_encoding($title, 'utf-8', $charset); - } - } - - if (empty($url) && empty($title)) { - $title = $this->container->conf->get('general.default_note_title', t('Note: ')); - } - - $link = escape([ - 'title' => $title, - 'url' => $url ?? '', - 'description' => $description ?? '', - 'tags' => $tags ?? '', - 'private' => $private, - ]); - } else { - $formatter = $this->container->formatterFactory->getFormatter('raw'); - $link = $formatter->format($bookmark); - } - - return $this->displayForm($link, $linkIsNew, $request, $response); - } - - /** - * GET /admin/shaare/{id} - Displays the bookmark form in edition mode. - */ - public function displayEditForm(Request $request, Response $response, array $args): Response - { - $id = $args['id']; - try { - if (false === ctype_digit($id)) { - throw new BookmarkNotFoundException(); - } - $bookmark = $this->container->bookmarkService->get($id); // Read database - } catch (BookmarkNotFoundException $e) { - $this->saveErrorMessage(t('Bookmark not found')); - - return $this->redirect($response, '/'); - } - - $formatter = $this->container->formatterFactory->getFormatter('raw'); - $link = $formatter->format($bookmark); - - return $this->displayForm($link, false, $request, $response); - } - - /** - * POST /admin/shaare - */ - public function save(Request $request, Response $response): Response - { - $this->checkToken($request); - - // lf_id should only be present if the link exists. - $id = $request->getParam('lf_id') ? intval(escape($request->getParam('lf_id'))) : null; - if (null !== $id && true === $this->container->bookmarkService->exists($id)) { - // Edit - $bookmark = $this->container->bookmarkService->get($id); - } else { - // New link - $bookmark = new Bookmark(); - } - - $bookmark->setTitle($request->getParam('lf_title')); - $bookmark->setDescription($request->getParam('lf_description')); - $bookmark->setUrl($request->getParam('lf_url'), $this->container->conf->get('security.allowed_protocols', [])); - $bookmark->setPrivate(filter_var($request->getParam('lf_private'), FILTER_VALIDATE_BOOLEAN)); - $bookmark->setTagsString($request->getParam('lf_tags')); - - if ($this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE - && false === $bookmark->isNote() - ) { - $bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl())); - } - $this->container->bookmarkService->addOrSet($bookmark, false); - - // To preserve backward compatibility with 3rd parties, plugins still use arrays - $formatter = $this->container->formatterFactory->getFormatter('raw'); - $data = $formatter->format($bookmark); - $data = $this->executeHooks('save_link', $data); - - $bookmark->fromArray($data); - $this->container->bookmarkService->set($bookmark); - - // If we are called from the bookmarklet, we must close the popup: - if ($request->getParam('source') === 'bookmarklet') { - return $response->write(''); - } - - if (!empty($request->getParam('returnurl'))) { - $this->container->environment['HTTP_REFERER'] = escape($request->getParam('returnurl')); - } - - return $this->redirectFromReferer( - $request, - $response, - ['add-shaare', 'shaare'], ['addlink', 'post', 'edit_link'], - $bookmark->getShortUrl() - ); - } - - /** - * GET /admin/shaare/delete - */ - public function deleteBookmark(Request $request, Response $response): Response - { - $this->checkToken($request); - - $ids = escape(trim($request->getParam('id'))); - if (strpos($ids, ' ') !== false) { - // multiple, space-separated ids provided - $ids = array_values(array_filter(preg_split('/\s+/', $ids), 'strlen')); - } else { - $ids = [$ids]; - } - - // assert at least one id is given - if (0 === count($ids)) { - $this->saveErrorMessage(t('Invalid bookmark ID provided.')); - - return $this->redirectFromReferer($request, $response, [], ['delete-shaare']); - } - - $formatter = $this->container->formatterFactory->getFormatter('raw'); - foreach ($ids as $id) { - $id = (int) $id; - // TODO: check if it exists - $bookmark = $this->container->bookmarkService->get($id); - $data = $formatter->format($bookmark); - $this->container->pluginManager->executeHooks('delete_link', $data); - $this->container->bookmarkService->remove($bookmark, false); - } - - $this->container->bookmarkService->save(); - - // If we are called from the bookmarklet, we must close the popup: - if ($request->getParam('source') === 'bookmarklet') { - return $response->write(''); - } - - // Don't redirect to where we were previously because the datastore has changed. - return $this->redirect($response, '/'); - } - - protected function displayForm(array $link, bool $isNew, Request $request, Response $response): Response - { - $tags = $this->container->bookmarkService->bookmarksCountPerTag(); - if ($this->container->conf->get('formatter') === 'markdown') { - $tags[BookmarkMarkdownFormatter::NO_MD_TAG] = 1; - } - - $data = [ - 'link' => $link, - 'link_is_new' => $isNew, - 'http_referer' => escape($this->container->environment['HTTP_REFERER'] ?? ''), - 'source' => $request->getParam('source') ?? '', - 'tags' => $tags, - 'default_private_links' => $this->container->conf->get('privacy.default_private_links', false), - ]; - - $data = $this->executeHooks('render_editlink', $data); - - foreach ($data as $key => $value) { - $this->assignView($key, $value); - } - - $editLabel = false === $isNew ? t('Edit') .' ' : ''; - $this->assignView( - 'pagetitle', - $editLabel . t('Shaare') .' - '. $this->container->conf->get('general.title', 'Shaarli') - ); - - return $response->write($this->render('editlink')); - } - - /** - * @param mixed[] $data Variables passed to the template engine - * - * @return mixed[] Template data after active plugins render_picwall hook execution. - */ - protected function executeHooks(string $hook, array $data): array - { - $this->container->pluginManager->executeHooks( - $hook, - $data - ); - - return $data; - } -} -- cgit v1.2.3 From 7b8a6f2858248601d43c1b8247deb91b74392d2e Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Sat, 13 Jun 2020 19:40:32 +0200 Subject: Process change visibility action through Slim controller --- .../controller/admin/ManageShaareController.php | 70 +++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) (limited to 'application') diff --git a/application/front/controller/admin/ManageShaareController.php b/application/front/controller/admin/ManageShaareController.php index 620bbc40..ff330a99 100644 --- a/application/front/controller/admin/ManageShaareController.php +++ b/application/front/controller/admin/ManageShaareController.php @@ -174,7 +174,7 @@ class ManageShaareController extends ShaarliAdminController } /** - * GET /admin/shaare/delete + * GET /admin/shaare/delete - Delete one or multiple bookmarks (depending on `id` query parameter). */ public function deleteBookmark(Request $request, Response $response): Response { @@ -228,6 +228,74 @@ class ManageShaareController extends ShaarliAdminController return $this->redirect($response, '/'); } + /** + * GET /admin/shaare/visibility + * + * Change visibility (public/private) of one or multiple bookmarks (depending on `id` query parameter). + */ + public function changeVisibility(Request $request, Response $response): Response + { + $this->checkToken($request); + + $ids = trim(escape($request->getParam('id') ?? '')); + if (empty($ids) || strpos($ids, ' ') !== false) { + // multiple, space-separated ids provided + $ids = array_values(array_filter(preg_split('/\s+/', $ids), 'ctype_digit')); + } else { + // only a single id provided + $ids = [$ids]; + } + + // assert at least one id is given + if (0 === count($ids)) { + $this->saveErrorMessage(t('Invalid bookmark ID provided.')); + + return $this->redirectFromReferer($request, $response, [], ['change_visibility']); + } + + // assert that the visibility is valid + $visibility = $request->getParam('newVisibility'); + if (null === $visibility || false === in_array($visibility, ['public', 'private'], true)) { + $this->saveErrorMessage(t('Invalid visibility provided.')); + + return $this->redirectFromReferer($request, $response, [], ['change_visibility']); + } else { + $isPrivate = $visibility === 'private'; + } + + $formatter = $this->container->formatterFactory->getFormatter('raw'); + $count = 0; + + foreach ($ids as $id) { + try { + $bookmark = $this->container->bookmarkService->get((int) $id); + } catch (BookmarkNotFoundException $e) { + $this->saveErrorMessage(sprintf( + t('Bookmark with identifier %s could not be found.'), + $id + )); + + continue; + } + + $bookmark->setPrivate($isPrivate); + + // To preserve backward compatibility with 3rd parties, plugins still use arrays + $data = $formatter->format($bookmark); + $this->container->pluginManager->executeHooks('save_link', $data); + $bookmark->fromArray($data); + + $this->container->bookmarkService->set($bookmark, false); + ++$count; + } + + if ($count > 0) { + $this->container->bookmarkService->save(); + } + + return $this->redirectFromReferer($request, $response, ['/visibility'], ['change_visibility']); + } + /** * Helper function used to display the shaare form whether it's a new or existing bookmark. * -- cgit v1.2.3 From 3447d888d7881eed437117a6de2450abb96f6a76 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Mon, 15 Jun 2020 08:15:40 +0200 Subject: Pin bookmarks through Slim controller --- .../controller/admin/ManageShaareController.php | 36 ++++++++++++++++++++++ 1 file changed, 36 insertions(+) (limited to 'application') diff --git a/application/front/controller/admin/ManageShaareController.php b/application/front/controller/admin/ManageShaareController.php index ff330a99..bdfc5ca7 100644 --- a/application/front/controller/admin/ManageShaareController.php +++ b/application/front/controller/admin/ManageShaareController.php @@ -296,6 +296,42 @@ class ManageShaareController extends ShaarliAdminController return $this->redirectFromReferer($request, $response, ['/visibility'], ['change_visibility']); } + /** + * GET /admin/shaare/{id}/pin - Pin or unpin a bookmark. + */ + public function pinBookmark(Request $request, Response $response, array $args): Response + { + $this->checkToken($request); + + $id = $args['id'] ?? ''; + try { + if (false === ctype_digit($id)) { + throw new BookmarkNotFoundException(); + } + $bookmark = $this->container->bookmarkService->get((int) $id); // Read database + } catch (BookmarkNotFoundException $e) { + $this->saveErrorMessage(sprintf( + t('Bookmark with identifier %s could not be found.'), + $id + )); + + return $this->redirectFromReferer($request, $response, ['/pin'], ['pin']); + } + + $formatter = $this->container->formatterFactory->getFormatter('raw'); + + $bookmark->setSticky(!$bookmark->isSticky()); + + // To preserve backward compatibility with 3rd parties, plugins still use arrays + $data = $formatter->format($bookmark); + $this->container->pluginManager->executeHooks('save_link', $data); + $bookmark->fromArray($data); + + $this->container->bookmarkService->set($bookmark); + + return $this->redirectFromReferer($request, $response, ['/pin'], ['pin']); + } + /** * Helper function used to display the shaare form whether it's a new or existing bookmark. * -- cgit v1.2.3 From e8a10f312a5c44314292402bb44e6ee2e71f3d5d Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Wed, 17 Jun 2020 15:55:31 +0200 Subject: Use NetscapeBookmarkUtils object instance instead of static calls --- application/container/ContainerBuilder.php | 5 ++ application/container/ShaarliContainer.php | 2 + application/netscape/NetscapeBookmarkUtils.php | 118 ++++++++++++++----------- 3 files changed, 71 insertions(+), 54 deletions(-) (limited to 'application') diff --git a/application/container/ContainerBuilder.php b/application/container/ContainerBuilder.php index 72a85710..a4fd6a0c 100644 --- a/application/container/ContainerBuilder.php +++ b/application/container/ContainerBuilder.php @@ -11,6 +11,7 @@ use Shaarli\Feed\FeedBuilder; use Shaarli\Formatter\FormatterFactory; use Shaarli\History; use Shaarli\Http\HttpAccess; +use Shaarli\Netscape\NetscapeBookmarkUtils; use Shaarli\Plugin\PluginManager; use Shaarli\Render\PageBuilder; use Shaarli\Render\PageCacheManager; @@ -118,6 +119,10 @@ class ContainerBuilder return new HttpAccess(); }; + $container['netscapeBookmarkUtils'] = function (ShaarliContainer $container): NetscapeBookmarkUtils { + return new NetscapeBookmarkUtils($container->bookmarkService, $container->conf, $container->history); + }; + return $container; } } diff --git a/application/container/ShaarliContainer.php b/application/container/ShaarliContainer.php index 4b97aae2..b08fa4cb 100644 --- a/application/container/ShaarliContainer.php +++ b/application/container/ShaarliContainer.php @@ -10,6 +10,7 @@ use Shaarli\Feed\FeedBuilder; use Shaarli\Formatter\FormatterFactory; use Shaarli\History; use Shaarli\Http\HttpAccess; +use Shaarli\Netscape\NetscapeBookmarkUtils; use Shaarli\Plugin\PluginManager; use Shaarli\Render\PageBuilder; use Shaarli\Render\PageCacheManager; @@ -30,6 +31,7 @@ use Slim\Container; * @property History $history * @property HttpAccess $httpAccess * @property LoginManager $loginManager + * @property NetscapeBookmarkUtils $netscapeBookmarkUtils * @property PageBuilder $pageBuilder * @property PageCacheManager $pageCacheManager * @property PluginManager $pluginManager diff --git a/application/netscape/NetscapeBookmarkUtils.php b/application/netscape/NetscapeBookmarkUtils.php index d64eef7f..8557cca2 100644 --- a/application/netscape/NetscapeBookmarkUtils.php +++ b/application/netscape/NetscapeBookmarkUtils.php @@ -16,10 +16,24 @@ use Shaarli\NetscapeBookmarkParser\NetscapeBookmarkParser; /** * Utilities to import and export bookmarks using the Netscape format - * TODO: Not static, use a container. */ class NetscapeBookmarkUtils { + /** @var BookmarkServiceInterface */ + protected $bookmarkService; + + /** @var ConfigManager */ + protected $conf; + + /** @var History */ + protected $history; + + public function __construct(BookmarkServiceInterface $bookmarkService, ConfigManager $conf, History $history) + { + $this->bookmarkService = $bookmarkService; + $this->conf = $conf; + $this->history = $history; + } /** * Filters bookmarks and adds Netscape-formatted fields @@ -28,18 +42,16 @@ class NetscapeBookmarkUtils * - timestamp link addition date, using the Unix epoch format * - taglist comma-separated tag list * - * @param BookmarkServiceInterface $bookmarkService Link datastore * @param BookmarkFormatter $formatter instance * @param string $selection Which bookmarks to export: (all|private|public) * @param bool $prependNoteUrl Prepend note permalinks with the server's URL * @param string $indexUrl Absolute URL of the Shaarli index page * * @return array The bookmarks to be exported, with additional fields - *@throws Exception Invalid export selection * + * @throws Exception Invalid export selection */ - public static function filterAndFormat( - $bookmarkService, + public function filterAndFormat( $formatter, $selection, $prependNoteUrl, @@ -51,7 +63,7 @@ class NetscapeBookmarkUtils } $bookmarkLinks = array(); - foreach ($bookmarkService->search([], $selection) as $bookmark) { + foreach ($this->bookmarkService->search([], $selection) as $bookmark) { $link = $formatter->format($bookmark); $link['taglist'] = implode(',', $bookmark->getTags()); if ($bookmark->isNote() && $prependNoteUrl) { @@ -64,53 +76,15 @@ class NetscapeBookmarkUtils return $bookmarkLinks; } - /** - * Generates an import status summary - * - * @param string $filename name of the file to import - * @param int $filesize size of the file to import - * @param int $importCount how many bookmarks were imported - * @param int $overwriteCount how many bookmarks were overwritten - * @param int $skipCount how many bookmarks were skipped - * @param int $duration how many seconds did the import take - * - * @return string Summary of the bookmark import status - */ - private static function importStatus( - $filename, - $filesize, - $importCount = 0, - $overwriteCount = 0, - $skipCount = 0, - $duration = 0 - ) { - $status = sprintf(t('File %s (%d bytes) '), $filename, $filesize); - if ($importCount == 0 && $overwriteCount == 0 && $skipCount == 0) { - $status .= t('has an unknown file format. Nothing was imported.'); - } else { - $status .= vsprintf( - t( - 'was successfully processed in %d seconds: ' - . '%d bookmarks imported, %d bookmarks overwritten, %d bookmarks skipped.' - ), - [$duration, $importCount, $overwriteCount, $skipCount] - ); - } - return $status; - } - /** * Imports Web bookmarks from an uploaded Netscape bookmark dump * * @param array $post Server $_POST parameters * @param array $files Server $_FILES parameters - * @param BookmarkServiceInterface $bookmarkService Loaded LinkDB instance - * @param ConfigManager $conf instance - * @param History $history History instance * * @return string Summary of the bookmark import status */ - public static function import($post, $files, $bookmarkService, $conf, $history) + public function import($post, $files) { $start = time(); $filename = $files['filetoupload']['name']; @@ -141,11 +115,11 @@ class NetscapeBookmarkUtils true, // nested tag support $defaultTags, // additional user-specified tags strval(1 - $defaultPrivacy), // defaultPub = 1 - defaultPrivacy - $conf->get('resource.data_dir') // log path, will be overridden + $this->conf->get('resource.data_dir') // log path, will be overridden ); $logger = new Logger( - $conf->get('resource.data_dir'), - !$conf->get('dev.debug') ? LogLevel::INFO : LogLevel::DEBUG, + $this->conf->get('resource.data_dir'), + !$this->conf->get('dev.debug') ? LogLevel::INFO : LogLevel::DEBUG, [ 'prefix' => 'import.', 'extension' => 'log', @@ -171,7 +145,7 @@ class NetscapeBookmarkUtils $private = 0; } - $link = $bookmarkService->findByUrl($bkm['uri']); + $link = $this->bookmarkService->findByUrl($bkm['uri']); $existingLink = $link !== null; if (! $existingLink) { $link = new Bookmark(); @@ -193,20 +167,21 @@ class NetscapeBookmarkUtils } $link->setTitle($bkm['title']); - $link->setUrl($bkm['uri'], $conf->get('security.allowed_protocols')); + $link->setUrl($bkm['uri'], $this->conf->get('security.allowed_protocols')); $link->setDescription($bkm['note']); $link->setPrivate($private); $link->setTagsString($bkm['tags']); - $bookmarkService->addOrSet($link, false); + $this->bookmarkService->addOrSet($link, false); $importCount++; } - $bookmarkService->save(); - $history->importLinks(); + $this->bookmarkService->save(); + $this->history->importLinks(); $duration = time() - $start; - return self::importStatus( + + return $this->importStatus( $filename, $filesize, $importCount, @@ -215,4 +190,39 @@ class NetscapeBookmarkUtils $duration ); } + + /** + * Generates an import status summary + * + * @param string $filename name of the file to import + * @param int $filesize size of the file to import + * @param int $importCount how many bookmarks were imported + * @param int $overwriteCount how many bookmarks were overwritten + * @param int $skipCount how many bookmarks were skipped + * @param int $duration how many seconds did the import take + * + * @return string Summary of the bookmark import status + */ + protected function importStatus( + $filename, + $filesize, + $importCount = 0, + $overwriteCount = 0, + $skipCount = 0, + $duration = 0 + ) { + $status = sprintf(t('File %s (%d bytes) '), $filename, $filesize); + if ($importCount == 0 && $overwriteCount == 0 && $skipCount == 0) { + $status .= t('has an unknown file format. Nothing was imported.'); + } else { + $status .= vsprintf( + t( + 'was successfully processed in %d seconds: ' + . '%d bookmarks imported, %d bookmarks overwritten, %d bookmarks skipped.' + ), + [$duration, $importCount, $overwriteCount, $skipCount] + ); + } + return $status; + } } -- cgit v1.2.3 From c70ff64a61d62cc8d35a62f30596ecc2a3c578a3 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Wed, 17 Jun 2020 16:04:18 +0200 Subject: Process bookmark exports through Slim controllers --- .../front/controller/admin/ExportController.php | 92 ++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 application/front/controller/admin/ExportController.php (limited to 'application') diff --git a/application/front/controller/admin/ExportController.php b/application/front/controller/admin/ExportController.php new file mode 100644 index 00000000..8e0e5a56 --- /dev/null +++ b/application/front/controller/admin/ExportController.php @@ -0,0 +1,92 @@ +assignView('pagetitle', t('Export') .' - '. $this->container->conf->get('general.title', 'Shaarli')); + + return $response->write($this->render('export')); + } + + /** + * POST /admin/export - Process export, and serve download file named + * bookmarks_(all|private|public)_datetime.html + */ + public function export(Request $request, Response $response): Response + { + $selection = $request->getParam('selection'); + + if (empty($selection)) { + $this->saveErrorMessage(t('Please select an export mode.')); + + return $this->redirect($response, '/admin/export'); + } + + $prependNoteUrl = filter_var($request->getParam('prepend_note_url') ?? false, FILTER_VALIDATE_BOOLEAN); + + try { + $formatter = $this->container->formatterFactory->getFormatter('raw'); + + $this->assignView( + 'links', + $this->container->netscapeBookmarkUtils->filterAndFormat( + $formatter, + $selection, + $prependNoteUrl, + index_url($this->container->environment) + ) + ); + } catch (\Exception $exc) { + $this->saveErrorMessage($exc->getMessage()); + + return $this->redirect($response, '/admin/export'); + } + + $now = new DateTime(); + $response = $response->withHeader('Content-Type', 'text/html; charset=utf-8'); + $response = $response->withHeader( + 'Content-disposition', + 'attachment; filename=bookmarks_'.$selection.'_'.$now->format(Bookmark::LINK_DATE_FORMAT).'.html' + ); + + $this->assignView('date', $now->format(DateTime::RFC822)); + $this->assignView('eol', PHP_EOL); + $this->assignView('selection', $selection); + + return $response->write($this->render('export.bookmarks')); + } + + /** + * @param mixed[] $data Variables passed to the template engine + * + * @return mixed[] Template data after active plugins render_picwall hook execution. + */ + protected function executeHooks(array $data): array + { + $this->container->pluginManager->executeHooks( + 'render_tools', + $data + ); + + return $data; + } +} -- cgit v1.2.3 From 78657347c5b463d7c22bfc8c87b7db39fe058833 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Wed, 17 Jun 2020 19:08:02 +0200 Subject: Process bookmarks import through Slim controller --- .../front/controller/admin/ExportController.php | 17 +---- .../front/controller/admin/ImportController.php | 81 ++++++++++++++++++++++ application/netscape/NetscapeBookmarkUtils.php | 15 ++-- 3 files changed, 91 insertions(+), 22 deletions(-) create mode 100644 application/front/controller/admin/ImportController.php (limited to 'application') diff --git a/application/front/controller/admin/ExportController.php b/application/front/controller/admin/ExportController.php index 8e0e5a56..7afbfc23 100644 --- a/application/front/controller/admin/ExportController.php +++ b/application/front/controller/admin/ExportController.php @@ -33,6 +33,8 @@ class ExportController extends ShaarliAdminController */ public function export(Request $request, Response $response): Response { + $this->checkToken($request); + $selection = $request->getParam('selection'); if (empty($selection)) { @@ -74,19 +76,4 @@ class ExportController extends ShaarliAdminController return $response->write($this->render('export.bookmarks')); } - - /** - * @param mixed[] $data Variables passed to the template engine - * - * @return mixed[] Template data after active plugins render_picwall hook execution. - */ - protected function executeHooks(array $data): array - { - $this->container->pluginManager->executeHooks( - 'render_tools', - $data - ); - - return $data; - } } diff --git a/application/front/controller/admin/ImportController.php b/application/front/controller/admin/ImportController.php new file mode 100644 index 00000000..8c5305b9 --- /dev/null +++ b/application/front/controller/admin/ImportController.php @@ -0,0 +1,81 @@ +assignView( + 'maxfilesize', + get_max_upload_size( + ini_get('post_max_size'), + ini_get('upload_max_filesize'), + false + ) + ); + $this->assignView( + 'maxfilesizeHuman', + get_max_upload_size( + ini_get('post_max_size'), + ini_get('upload_max_filesize'), + true + ) + ); + $this->assignView('pagetitle', t('Import') .' - '. $this->container->conf->get('general.title', 'Shaarli')); + + return $response->write($this->render('import')); + } + + /** + * POST /admin/import - Process import file provided and create bookmarks + */ + public function import(Request $request, Response $response): Response + { + $this->checkToken($request); + + $file = ($request->getUploadedFiles() ?? [])['filetoupload'] ?? null; + if (!$file instanceof UploadedFileInterface) { + $this->saveErrorMessage(t('No import file provided.')); + + return $this->redirect($response, '/admin/import'); + } + + + // Import bookmarks from an uploaded file + if (0 === $file->getSize()) { + // The file is too big or some form field may be missing. + $msg = sprintf( + t( + 'The file you are trying to upload is probably bigger than what this webserver can accept' + .' (%s). Please upload in smaller chunks.' + ), + get_max_upload_size(ini_get('post_max_size'), ini_get('upload_max_filesize')) + ); + $this->saveErrorMessage($msg); + + return $this->redirect($response, '/admin/import'); + } + + $status = $this->container->netscapeBookmarkUtils->import($request->getParams(), $file); + + $this->saveSuccessMessage($status); + + return $this->redirect($response, '/admin/import'); + } +} diff --git a/application/netscape/NetscapeBookmarkUtils.php b/application/netscape/NetscapeBookmarkUtils.php index 8557cca2..b150f649 100644 --- a/application/netscape/NetscapeBookmarkUtils.php +++ b/application/netscape/NetscapeBookmarkUtils.php @@ -6,6 +6,7 @@ use DateTime; use DateTimeZone; use Exception; use Katzgrau\KLogger\Logger; +use Psr\Http\Message\UploadedFileInterface; use Psr\Log\LogLevel; use Shaarli\Bookmark\Bookmark; use Shaarli\Bookmark\BookmarkServiceInterface; @@ -79,20 +80,20 @@ class NetscapeBookmarkUtils /** * Imports Web bookmarks from an uploaded Netscape bookmark dump * - * @param array $post Server $_POST parameters - * @param array $files Server $_FILES parameters + * @param array $post Server $_POST parameters + * @param UploadedFileInterface $file File in PSR-7 object format * * @return string Summary of the bookmark import status */ - public function import($post, $files) + public function import($post, UploadedFileInterface $file) { $start = time(); - $filename = $files['filetoupload']['name']; - $filesize = $files['filetoupload']['size']; - $data = file_get_contents($files['filetoupload']['tmp_name']); + $filename = $file->getClientFilename(); + $filesize = $file->getSize(); + $data = (string) $file->getStream(); if (preg_match('//i', $data) === 0) { - return self::importStatus($filename, $filesize); + return $this->importStatus($filename, $filesize); } // Overwrite existing bookmarks? -- cgit v1.2.3 From 1b8620b1ad4e2c647ff2d032c8e7c6687b6647a1 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Sat, 20 Jun 2020 15:14:24 +0200 Subject: Process plugins administration page through Slim controllers --- application/container/ContainerBuilder.php | 7 +- .../front/controller/admin/PluginsController.php | 98 ++++++++++++++++++++++ application/plugin/PluginManager.php | 2 +- 3 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 application/front/controller/admin/PluginsController.php (limited to 'application') diff --git a/application/container/ContainerBuilder.php b/application/container/ContainerBuilder.php index a4fd6a0c..ba91fe8b 100644 --- a/application/container/ContainerBuilder.php +++ b/application/container/ContainerBuilder.php @@ -88,7 +88,12 @@ class ContainerBuilder }; $container['pluginManager'] = function (ShaarliContainer $container): PluginManager { - return new PluginManager($container->conf); + $pluginManager = new PluginManager($container->conf); + + // FIXME! Configuration is already injected + $pluginManager->load($container->conf->get('general.enabled_plugins')); + + return $pluginManager; }; $container['formatterFactory'] = function (ShaarliContainer $container): FormatterFactory { diff --git a/application/front/controller/admin/PluginsController.php b/application/front/controller/admin/PluginsController.php new file mode 100644 index 00000000..d5ec91f0 --- /dev/null +++ b/application/front/controller/admin/PluginsController.php @@ -0,0 +1,98 @@ +container->pluginManager->getPluginsMeta(); + + // Split plugins into 2 arrays: ordered enabled plugins and disabled. + $enabledPlugins = array_filter($pluginMeta, function ($v) { + return ($v['order'] ?? false) !== false; + }); + $enabledPlugins = load_plugin_parameter_values($enabledPlugins, $this->container->conf->get('plugins', [])); + uasort( + $enabledPlugins, + function ($a, $b) { + return $a['order'] - $b['order']; + } + ); + $disabledPlugins = array_filter($pluginMeta, function ($v) { + return ($v['order'] ?? false) === false; + }); + + $this->assignView('enabledPlugins', $enabledPlugins); + $this->assignView('disabledPlugins', $disabledPlugins); + $this->assignView( + 'pagetitle', + t('Plugin Administration') .' - '. $this->container->conf->get('general.title', 'Shaarli') + ); + + return $response->write($this->render('pluginsadmin')); + } + + /** + * POST /admin/plugins - Update Shaarli's configuration + */ + public function save(Request $request, Response $response): Response + { + $this->checkToken($request); + + try { + $parameters = $request->getParams() ?? []; + + $this->executeHooks($parameters); + + if (isset($parameters['parameters_form'])) { + unset($parameters['parameters_form']); + foreach ($parameters as $param => $value) { + $this->container->conf->set('plugins.'. $param, escape($value)); + } + } else { + $this->container->conf->set('general.enabled_plugins', save_plugin_config($parameters)); + } + + $this->container->conf->write($this->container->loginManager->isLoggedIn()); + $this->container->history->updateSettings(); + + $this->saveSuccessMessage(t('Setting successfully saved.')); + } catch (Exception $e) { + $this->saveErrorMessage( + t('ERROR while saving plugin configuration: ') . PHP_EOL . $e->getMessage() + ); + } + + return $this->redirect($response, '/admin/plugins'); + } + + /** + * @param mixed[] $data Variables passed to the template engine + * + * @return mixed[] Template data after active plugins render_picwall hook execution. + */ + protected function executeHooks(array $data): array + { + $this->container->pluginManager->executeHooks( + 'save_plugin_parameters', + $data + ); + + return $data; + } +} diff --git a/application/plugin/PluginManager.php b/application/plugin/PluginManager.php index f7b24a8e..591a9180 100644 --- a/application/plugin/PluginManager.php +++ b/application/plugin/PluginManager.php @@ -16,7 +16,7 @@ class PluginManager * * @var array $authorizedPlugins */ - private $authorizedPlugins; + private $authorizedPlugins = []; /** * List of loaded plugins. -- cgit v1.2.3 From 764d34a7d347d653414e5f5c632e02499edaef04 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Sun, 21 Jun 2020 12:21:31 +0200 Subject: Process token retrieve through Slim controller --- .../front/controller/admin/TokenController.php | 26 ++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 application/front/controller/admin/TokenController.php (limited to 'application') diff --git a/application/front/controller/admin/TokenController.php b/application/front/controller/admin/TokenController.php new file mode 100644 index 00000000..08d68d0a --- /dev/null +++ b/application/front/controller/admin/TokenController.php @@ -0,0 +1,26 @@ +withHeader('Content-Type', 'text/plain'); + + return $response->write($this->container->sessionManager->generateToken()); + } +} -- cgit v1.2.3 From 6132d64748dfc6806ed25f71d2e078a5ed29d071 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Sat, 27 Jun 2020 12:08:26 +0200 Subject: Process thumbnail synchronize page through Slim controllers --- application/Thumbnailer.php | 3 +- .../front/controller/admin/ConfigureController.php | 2 +- .../controller/admin/ThumbnailsController.php | 79 ++++++++++++++++++++++ application/legacy/LegacyUpdater.php | 2 +- 4 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 application/front/controller/admin/ThumbnailsController.php (limited to 'application') diff --git a/application/Thumbnailer.php b/application/Thumbnailer.php index 314baf0d..5aec23c8 100644 --- a/application/Thumbnailer.php +++ b/application/Thumbnailer.php @@ -4,7 +4,6 @@ namespace Shaarli; use Shaarli\Config\ConfigManager; use WebThumbnailer\Application\ConfigManager as WTConfigManager; -use WebThumbnailer\Exception\WebThumbnailerException; use WebThumbnailer\WebThumbnailer; /** @@ -90,7 +89,7 @@ class Thumbnailer try { return $this->wt->thumbnail($url); - } catch (WebThumbnailerException $e) { + } catch (\Throwable $e) { // Exceptions are only thrown in debug mode. error_log(get_class($e) . ': ' . $e->getMessage()); } diff --git a/application/front/controller/admin/ConfigureController.php b/application/front/controller/admin/ConfigureController.php index 44971c43..201a859b 100644 --- a/application/front/controller/admin/ConfigureController.php +++ b/application/front/controller/admin/ConfigureController.php @@ -99,7 +99,7 @@ class ConfigureController extends ShaarliAdminController ) { $this->saveWarningMessage(t( 'You have enabled or changed thumbnails mode. ' - .'Please synchronize them.' + .'Please synchronize them.' )); } $this->container->conf->set('thumbnails.mode', $thumbnailsMode); diff --git a/application/front/controller/admin/ThumbnailsController.php b/application/front/controller/admin/ThumbnailsController.php new file mode 100644 index 00000000..e5308510 --- /dev/null +++ b/application/front/controller/admin/ThumbnailsController.php @@ -0,0 +1,79 @@ +container->bookmarkService->search() as $bookmark) { + // A note or not HTTP(S) + if ($bookmark->isNote() || !startsWith(strtolower($bookmark->getUrl()), 'http')) { + continue; + } + + $ids[] = $bookmark->getId(); + } + + $this->assignView('ids', $ids); + $this->assignView( + 'pagetitle', + t('Thumbnails update') .' - '. $this->container->conf->get('general.title', 'Shaarli') + ); + + return $response->write($this->render('thumbnails')); + } + + /** + * PATCH /admin/shaare/{id}/thumbnail-update - Route for AJAX calls + */ + public function ajaxUpdate(Request $request, Response $response, array $args): Response + { + $id = $args['id'] ?? null; + + if (false === ctype_digit($id)) { + return $response->withStatus(400); + } + + try { + $bookmark = $this->container->bookmarkService->get($id); + } catch (BookmarkNotFoundException $e) { + return $response->withStatus(404); + } + + $bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl())); + $this->container->bookmarkService->set($bookmark); + + return $response->withJson($this->container->formatterFactory->getFormatter('raw')->format($bookmark)); + } + + /** + * @param mixed[] $data Variables passed to the template engine + * + * @return mixed[] Template data after active plugins render_picwall hook execution. + */ + protected function executeHooks(array $data): array + { + $this->container->pluginManager->executeHooks( + 'render_tools', + $data + ); + + return $data; + } +} diff --git a/application/legacy/LegacyUpdater.php b/application/legacy/LegacyUpdater.php index 8d5cd071..cbf6890f 100644 --- a/application/legacy/LegacyUpdater.php +++ b/application/legacy/LegacyUpdater.php @@ -534,7 +534,7 @@ class LegacyUpdater if ($thumbnailsEnabled) { $this->session['warnings'][] = t( - 'You have enabled or changed thumbnails mode. Please synchronize them.' + 'You have enabled or changed thumbnails mode. Please synchronize them.' ); } -- cgit v1.2.3 From 1a8ac737e52cb25a5c346232ee398f5908cee7d7 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Mon, 6 Jul 2020 08:04:35 +0200 Subject: Process main page (linklist) through Slim controller Including a bunch of improvements on the container, and helper used across new controllers. --- application/Router.php | 184 --------------- application/api/ApiMiddleware.php | 9 +- application/bookmark/BookmarkFileService.php | 2 +- application/container/ContainerBuilder.php | 11 + application/container/ShaarliContainer.php | 7 +- application/front/ShaarliMiddleware.php | 73 +++++- .../front/controller/admin/ConfigureController.php | 3 +- .../front/controller/admin/ExportController.php | 5 +- .../front/controller/admin/ImportController.php | 3 +- .../controller/admin/ManageShaareController.php | 5 +- .../front/controller/admin/ManageTagController.php | 3 +- .../front/controller/admin/PasswordController.php | 9 +- .../front/controller/admin/PluginsController.php | 3 +- .../controller/admin/ThumbnailsController.php | 18 +- .../front/controller/admin/ToolsController.php | 3 +- .../controller/visitor/BookmarkListController.php | 248 +++++++++++++++++++++ .../front/controller/visitor/DailyController.php | 5 +- .../front/controller/visitor/LoginController.php | 3 +- .../controller/visitor/OpenSearchController.php | 3 +- .../controller/visitor/PictureWallController.php | 3 +- application/legacy/LegacyController.php | 130 +++++++++++ application/legacy/LegacyRouter.php | 187 ++++++++++++++++ application/legacy/UnknowLegacyRouteException.php | 9 + application/render/PageBuilder.php | 9 + application/render/TemplatePage.php | 33 +++ application/updater/Updater.php | 43 +++- 26 files changed, 778 insertions(+), 233 deletions(-) delete mode 100644 application/Router.php create mode 100644 application/front/controller/visitor/BookmarkListController.php create mode 100644 application/legacy/LegacyController.php create mode 100644 application/legacy/LegacyRouter.php create mode 100644 application/legacy/UnknowLegacyRouteException.php create mode 100644 application/render/TemplatePage.php (limited to 'application') diff --git a/application/Router.php b/application/Router.php deleted file mode 100644 index d7187487..00000000 --- a/application/Router.php +++ /dev/null @@ -1,184 +0,0 @@ -getApiResponse(); } - return $response; + return $response + ->withHeader('Access-Control-Allow-Origin', '*') + ->withHeader( + 'Access-Control-Allow-Headers', + 'X-Requested-With, Content-Type, Accept, Origin, Authorization' + ) + ->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') + ; } /** diff --git a/application/bookmark/BookmarkFileService.php b/application/bookmark/BookmarkFileService.php index 7439d8d8..3d15d4c9 100644 --- a/application/bookmark/BookmarkFileService.php +++ b/application/bookmark/BookmarkFileService.php @@ -93,7 +93,7 @@ class BookmarkFileService implements BookmarkServiceInterface throw new Exception('Not authorized'); } - return $bookmark; + return $first; } /** diff --git a/application/container/ContainerBuilder.php b/application/container/ContainerBuilder.php index ba91fe8b..ccb87c3a 100644 --- a/application/container/ContainerBuilder.php +++ b/application/container/ContainerBuilder.php @@ -18,6 +18,8 @@ use Shaarli\Render\PageCacheManager; use Shaarli\Security\LoginManager; use Shaarli\Security\SessionManager; use Shaarli\Thumbnailer; +use Shaarli\Updater\Updater; +use Shaarli\Updater\UpdaterUtils; /** * Class ContainerBuilder @@ -128,6 +130,15 @@ class ContainerBuilder return new NetscapeBookmarkUtils($container->bookmarkService, $container->conf, $container->history); }; + $container['updater'] = function (ShaarliContainer $container): Updater { + return new Updater( + UpdaterUtils::read_updates_file($container->conf->get('resource.updates')), + $container->bookmarkService, + $container->conf, + $container->loginManager->isLoggedIn() + ); + }; + return $container; } } diff --git a/application/container/ShaarliContainer.php b/application/container/ShaarliContainer.php index b08fa4cb..09e7d5b1 100644 --- a/application/container/ShaarliContainer.php +++ b/application/container/ShaarliContainer.php @@ -17,15 +17,17 @@ use Shaarli\Render\PageCacheManager; use Shaarli\Security\LoginManager; use Shaarli\Security\SessionManager; use Shaarli\Thumbnailer; +use Shaarli\Updater\Updater; use Slim\Container; /** * Extension of Slim container to document the injected objects. * - * @property string $basePath Shaarli's instance base path (e.g. `/shaarli/`) + * @property string $basePath Shaarli's instance base path (e.g. `/shaarli/`) * @property BookmarkServiceInterface $bookmarkService * @property ConfigManager $conf - * @property mixed[] $environment $_SERVER automatically injected by Slim + * @property mixed[] $environment $_SERVER automatically injected by Slim + * @property callable $errorHandler Overrides default Slim error display * @property FeedBuilder $feedBuilder * @property FormatterFactory $formatterFactory * @property History $history @@ -37,6 +39,7 @@ use Slim\Container; * @property PluginManager $pluginManager * @property SessionManager $sessionManager * @property Thumbnailer $thumbnailer + * @property Updater $updater */ class ShaarliContainer extends Container { diff --git a/application/front/ShaarliMiddleware.php b/application/front/ShaarliMiddleware.php index 7ad610c7..baea6ef2 100644 --- a/application/front/ShaarliMiddleware.php +++ b/application/front/ShaarliMiddleware.php @@ -25,6 +25,8 @@ class ShaarliMiddleware /** * Middleware execution: + * - run updates + * - if not logged in open shaarli, redirect to login * - execute the controller * - return the response * @@ -36,27 +38,82 @@ class ShaarliMiddleware * * @return Response response. */ - public function __invoke(Request $request, Response $response, callable $next) + public function __invoke(Request $request, Response $response, callable $next): Response { $this->container->basePath = rtrim($request->getUri()->getBasePath(), '/'); try { - $response = $next($request, $response); + $this->runUpdates(); + $this->checkOpenShaarli($request, $response, $next); + + return $next($request, $response); } catch (ShaarliFrontException $e) { + // Possible functional error + $this->container->pageBuilder->reset(); $this->container->pageBuilder->assign('message', $e->getMessage()); + + $response = $response->withStatus($e->getCode()); + + return $response->write($this->container->pageBuilder->render('error')); + } catch (UnauthorizedException $e) { + return $response->withRedirect($this->container->basePath . '/login'); + } catch (\Throwable $e) { + // Unknown error encountered + $this->container->pageBuilder->reset(); if ($this->container->conf->get('dev.debug', false)) { + $this->container->pageBuilder->assign('message', $e->getMessage()); $this->container->pageBuilder->assign( 'stacktrace', - nl2br(get_class($this) .': '. $e->getTraceAsString()) + nl2br(get_class($e) .': '. PHP_EOL . $e->getTraceAsString()) ); + } else { + $this->container->pageBuilder->assign('message', t('An unexpected error occurred.')); } - $response = $response->withStatus($e->getCode()); - $response = $response->write($this->container->pageBuilder->render('error')); - } catch (UnauthorizedException $e) { - return $response->withRedirect($this->container->basePath . '/login'); + $response = $response->withStatus(500); + + return $response->write($this->container->pageBuilder->render('error')); + } + } + + /** + * Run the updater for every requests processed while logged in. + */ + protected function runUpdates(): void + { + if ($this->container->loginManager->isLoggedIn() !== true) { + return; + } + + $newUpdates = $this->container->updater->update(); + if (!empty($newUpdates)) { + $this->container->updater->writeUpdates( + $this->container->conf->get('resource.updates'), + $this->container->updater->getDoneUpdates() + ); + + $this->container->pageCacheManager->invalidateCaches(); + } + } + + /** + * Access is denied to most pages with `hide_public_links` + `force_login` settings. + */ + protected function checkOpenShaarli(Request $request, Response $response, callable $next): bool + { + if (// if the user isn't logged in + !$this->container->loginManager->isLoggedIn() + // and Shaarli doesn't have public content... + && $this->container->conf->get('privacy.hide_public_links') + // and is configured to enforce the login + && $this->container->conf->get('privacy.force_login') + // and the current page isn't already the login page + // and the user is not requesting a feed (which would lead to a different content-type as expected) + && !in_array($next->getName(), ['login', 'atom', 'rss'], true) + ) { + throw new UnauthorizedException(); } - return $response; + return true; } } diff --git a/application/front/controller/admin/ConfigureController.php b/application/front/controller/admin/ConfigureController.php index 201a859b..865fc2b0 100644 --- a/application/front/controller/admin/ConfigureController.php +++ b/application/front/controller/admin/ConfigureController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Shaarli\Front\Controller\Admin; use Shaarli\Languages; +use Shaarli\Render\TemplatePage; use Shaarli\Render\ThemeUtils; use Shaarli\Thumbnailer; use Slim\Http\Request; @@ -52,7 +53,7 @@ class ConfigureController extends ShaarliAdminController $this->assignView('thumbnails_mode', $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE)); $this->assignView('pagetitle', t('Configure') .' - '. $this->container->conf->get('general.title', 'Shaarli')); - return $response->write($this->render('configure')); + return $response->write($this->render(TemplatePage::CONFIGURE)); } /** diff --git a/application/front/controller/admin/ExportController.php b/application/front/controller/admin/ExportController.php index 7afbfc23..2be957fa 100644 --- a/application/front/controller/admin/ExportController.php +++ b/application/front/controller/admin/ExportController.php @@ -6,6 +6,7 @@ namespace Shaarli\Front\Controller\Admin; use DateTime; use Shaarli\Bookmark\Bookmark; +use Shaarli\Render\TemplatePage; use Slim\Http\Request; use Slim\Http\Response; @@ -24,7 +25,7 @@ class ExportController extends ShaarliAdminController { $this->assignView('pagetitle', t('Export') .' - '. $this->container->conf->get('general.title', 'Shaarli')); - return $response->write($this->render('export')); + return $response->write($this->render(TemplatePage::EXPORT)); } /** @@ -74,6 +75,6 @@ class ExportController extends ShaarliAdminController $this->assignView('eol', PHP_EOL); $this->assignView('selection', $selection); - return $response->write($this->render('export.bookmarks')); + return $response->write($this->render(TemplatePage::NETSCAPE_EXPORT_BOOKMARKS)); } } diff --git a/application/front/controller/admin/ImportController.php b/application/front/controller/admin/ImportController.php index 8c5305b9..758d5ef9 100644 --- a/application/front/controller/admin/ImportController.php +++ b/application/front/controller/admin/ImportController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Shaarli\Front\Controller\Admin; use Psr\Http\Message\UploadedFileInterface; +use Shaarli\Render\TemplatePage; use Slim\Http\Request; use Slim\Http\Response; @@ -39,7 +40,7 @@ class ImportController extends ShaarliAdminController ); $this->assignView('pagetitle', t('Import') .' - '. $this->container->conf->get('general.title', 'Shaarli')); - return $response->write($this->render('import')); + return $response->write($this->render(TemplatePage::IMPORT)); } /** diff --git a/application/front/controller/admin/ManageShaareController.php b/application/front/controller/admin/ManageShaareController.php index bdfc5ca7..3aa48423 100644 --- a/application/front/controller/admin/ManageShaareController.php +++ b/application/front/controller/admin/ManageShaareController.php @@ -7,6 +7,7 @@ namespace Shaarli\Front\Controller\Admin; use Shaarli\Bookmark\Bookmark; use Shaarli\Bookmark\Exception\BookmarkNotFoundException; use Shaarli\Formatter\BookmarkMarkdownFormatter; +use Shaarli\Render\TemplatePage; use Shaarli\Thumbnailer; use Slim\Http\Request; use Slim\Http\Response; @@ -28,7 +29,7 @@ class ManageShaareController extends ShaarliAdminController t('Shaare a new link') .' - '. $this->container->conf->get('general.title', 'Shaarli') ); - return $response->write($this->render('addlink')); + return $response->write($this->render(TemplatePage::ADDLINK)); } /** @@ -365,7 +366,7 @@ class ManageShaareController extends ShaarliAdminController $editLabel . t('Shaare') .' - '. $this->container->conf->get('general.title', 'Shaarli') ); - return $response->write($this->render('editlink')); + return $response->write($this->render(TemplatePage::EDIT_LINK)); } /** diff --git a/application/front/controller/admin/ManageTagController.php b/application/front/controller/admin/ManageTagController.php index 7dab288a..0380ef1f 100644 --- a/application/front/controller/admin/ManageTagController.php +++ b/application/front/controller/admin/ManageTagController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Shaarli\Front\Controller\Admin; use Shaarli\Bookmark\BookmarkFilter; +use Shaarli\Render\TemplatePage; use Slim\Http\Request; use Slim\Http\Response; @@ -28,7 +29,7 @@ class ManageTagController extends ShaarliAdminController t('Manage tags') .' - '. $this->container->conf->get('general.title', 'Shaarli') ); - return $response->write($this->render('changetag')); + return $response->write($this->render(TemplatePage::CHANGE_TAG)); } /** diff --git a/application/front/controller/admin/PasswordController.php b/application/front/controller/admin/PasswordController.php index bcce01a6..5ec0d24b 100644 --- a/application/front/controller/admin/PasswordController.php +++ b/application/front/controller/admin/PasswordController.php @@ -7,6 +7,7 @@ namespace Shaarli\Front\Controller\Admin; use Shaarli\Container\ShaarliContainer; use Shaarli\Front\Exception\OpenShaarliPasswordException; use Shaarli\Front\Exception\ShaarliFrontException; +use Shaarli\Render\TemplatePage; use Slim\Http\Request; use Slim\Http\Response; use Throwable; @@ -33,7 +34,7 @@ class PasswordController extends ShaarliAdminController */ public function index(Request $request, Response $response): Response { - return $response->write($this->render('changepassword')); + return $response->write($this->render(TemplatePage::CHANGE_PASSWORD)); } /** @@ -55,7 +56,7 @@ class PasswordController extends ShaarliAdminController return $response ->withStatus(400) - ->write($this->render('changepassword')) + ->write($this->render(TemplatePage::CHANGE_PASSWORD)) ; } @@ -71,7 +72,7 @@ class PasswordController extends ShaarliAdminController return $response ->withStatus(400) - ->write($this->render('changepassword')) + ->write($this->render(TemplatePage::CHANGE_PASSWORD)) ; } @@ -95,6 +96,6 @@ class PasswordController extends ShaarliAdminController $this->saveSuccessMessage(t('Your password has been changed')); - return $response->write($this->render('changepassword')); + return $response->write($this->render(TemplatePage::CHANGE_PASSWORD)); } } diff --git a/application/front/controller/admin/PluginsController.php b/application/front/controller/admin/PluginsController.php index d5ec91f0..44025395 100644 --- a/application/front/controller/admin/PluginsController.php +++ b/application/front/controller/admin/PluginsController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Shaarli\Front\Controller\Admin; use Exception; +use Shaarli\Render\TemplatePage; use Slim\Http\Request; use Slim\Http\Response; @@ -44,7 +45,7 @@ class PluginsController extends ShaarliAdminController t('Plugin Administration') .' - '. $this->container->conf->get('general.title', 'Shaarli') ); - return $response->write($this->render('pluginsadmin')); + return $response->write($this->render(TemplatePage::PLUGINS_ADMIN)); } /** diff --git a/application/front/controller/admin/ThumbnailsController.php b/application/front/controller/admin/ThumbnailsController.php index e5308510..81c87ed0 100644 --- a/application/front/controller/admin/ThumbnailsController.php +++ b/application/front/controller/admin/ThumbnailsController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Shaarli\Front\Controller\Admin; use Shaarli\Bookmark\Exception\BookmarkNotFoundException; +use Shaarli\Render\TemplatePage; use Slim\Http\Request; use Slim\Http\Response; @@ -36,7 +37,7 @@ class ThumbnailsController extends ShaarliAdminController t('Thumbnails update') .' - '. $this->container->conf->get('general.title', 'Shaarli') ); - return $response->write($this->render('thumbnails')); + return $response->write($this->render(TemplatePage::THUMBNAILS)); } /** @@ -61,19 +62,4 @@ class ThumbnailsController extends ShaarliAdminController return $response->withJson($this->container->formatterFactory->getFormatter('raw')->format($bookmark)); } - - /** - * @param mixed[] $data Variables passed to the template engine - * - * @return mixed[] Template data after active plugins render_picwall hook execution. - */ - protected function executeHooks(array $data): array - { - $this->container->pluginManager->executeHooks( - 'render_tools', - $data - ); - - return $data; - } } diff --git a/application/front/controller/admin/ToolsController.php b/application/front/controller/admin/ToolsController.php index d087f2cd..a476e898 100644 --- a/application/front/controller/admin/ToolsController.php +++ b/application/front/controller/admin/ToolsController.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Shaarli\Front\Controller\Admin; +use Shaarli\Render\TemplatePage; use Slim\Http\Request; use Slim\Http\Response; @@ -29,7 +30,7 @@ class ToolsController extends ShaarliAdminController $this->assignView('pagetitle', t('Tools') .' - '. $this->container->conf->get('general.title', 'Shaarli')); - return $response->write($this->render('tools')); + return $response->write($this->render(TemplatePage::TOOLS)); } /** diff --git a/application/front/controller/visitor/BookmarkListController.php b/application/front/controller/visitor/BookmarkListController.php new file mode 100644 index 00000000..a37a7f6b --- /dev/null +++ b/application/front/controller/visitor/BookmarkListController.php @@ -0,0 +1,248 @@ +processLegacyController($request, $response); + if (null !== $legacyResponse) { + return $legacyResponse; + } + + $formatter = $this->container->formatterFactory->getFormatter(); + + $searchTags = escape(normalize_spaces($request->getParam('searchtags') ?? '')); + $searchTerm = escape(normalize_spaces($request->getParam('searchterm') ?? ''));; + + // Filter bookmarks according search parameters. + $visibility = $this->container->sessionManager->getSessionParameter('visibility'); + $search = [ + 'searchtags' => $searchTags, + 'searchterm' => $searchTerm, + ]; + $linksToDisplay = $this->container->bookmarkService->search( + $search, + $visibility, + false, + !!$this->container->sessionManager->getSessionParameter('untaggedonly') + ) ?? []; + + // ---- Handle paging. + $keys = []; + foreach ($linksToDisplay as $key => $value) { + $keys[] = $key; + } + + $linksPerPage = $this->container->sessionManager->getSessionParameter('LINKS_PER_PAGE', 20) ?: 20; + + // Select articles according to paging. + $pageCount = (int) ceil(count($keys) / $linksPerPage) ?: 1; + $page = (int) $request->getParam('page') ?? 1; + $page = $page < 1 ? 1 : $page; + $page = $page > $pageCount ? $pageCount : $page; + + // Start index. + $i = ($page - 1) * $linksPerPage; + $end = $i + $linksPerPage; + + $linkDisp = []; + $save = false; + while ($i < $end && $i < count($keys)) { + $save = $this->updateThumbnail($linksToDisplay[$keys[$i]], false) || $save; + $link = $formatter->format($linksToDisplay[$keys[$i]]); + + $linkDisp[$keys[$i]] = $link; + $i++; + } + + if ($save) { + $this->container->bookmarkService->save(); + } + + // Compute paging navigation + $searchtagsUrl = $searchTags === '' ? '' : '&searchtags=' . urlencode($searchTags); + $searchtermUrl = $searchTerm === '' ? '' : '&searchterm=' . urlencode($searchTerm); + + $previous_page_url = ''; + if ($i !== count($keys)) { + $previous_page_url = '?page=' . ($page + 1) . $searchtermUrl . $searchtagsUrl; + } + $next_page_url = ''; + if ($page > 1) { + $next_page_url = '?page=' . ($page - 1) . $searchtermUrl . $searchtagsUrl; + } + + // Fill all template fields. + $data = array_merge( + $this->initializeTemplateVars(), + [ + 'previous_page_url' => $previous_page_url, + 'next_page_url' => $next_page_url, + 'page_current' => $page, + 'page_max' => $pageCount, + 'result_count' => count($linksToDisplay), + 'search_term' => $searchTerm, + 'search_tags' => $searchTags, + 'visibility' => $visibility, + 'links' => $linkDisp, + ] + ); + + if (!empty($searchTerm) || !empty($searchTags)) { + $data['pagetitle'] = t('Search: '); + $data['pagetitle'] .= ! empty($searchTerm) ? $searchTerm . ' ' : ''; + $bracketWrap = function ($tag) { + return '[' . $tag . ']'; + }; + $data['pagetitle'] .= ! empty($searchTags) + ? implode(' ', array_map($bracketWrap, preg_split('/\s+/', $searchTags))) . ' ' + : ''; + $data['pagetitle'] .= '- '; + } + + $data['pagetitle'] = ($data['pagetitle'] ?? '') . $this->container->conf->get('general.title', 'Shaarli'); + + $this->executeHooks($data); + $this->assignAllView($data); + + return $response->write($this->render(TemplatePage::LINKLIST)); + } + + /** + * GET /shaare/{hash} - Display a single shaare + */ + public function permalink(Request $request, Response $response, array $args): Response + { + try { + $bookmark = $this->container->bookmarkService->findByHash($args['hash']); + } catch (BookmarkNotFoundException $e) { + $this->assignView('error_message', $e->getMessage()); + + return $response->write($this->render(TemplatePage::ERROR_404)); + } + + $this->updateThumbnail($bookmark); + + $data = array_merge( + $this->initializeTemplateVars(), + [ + 'pagetitle' => $bookmark->getTitle() .' - '. $this->container->conf->get('general.title', 'Shaarli'), + 'links' => [$this->container->formatterFactory->getFormatter()->format($bookmark)], + ] + ); + + $this->executeHooks($data); + $this->assignAllView($data); + + return $response->write($this->render(TemplatePage::LINKLIST)); + } + + /** + * Update the thumbnail of a single bookmark if necessary. + */ + protected function updateThumbnail(Bookmark $bookmark, bool $writeDatastore = true): bool + { + // Logged in, thumbnails enabled, not a note, is HTTP + // and (never retrieved yet or no valid cache file) + if ($this->container->loginManager->isLoggedIn() + && $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE + && false !== $bookmark->getThumbnail() + && !$bookmark->isNote() + && (null === $bookmark->getThumbnail() || !is_file($bookmark->getThumbnail())) + && startsWith(strtolower($bookmark->getUrl()), 'http') + ) { + $bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl())); + $this->container->bookmarkService->set($bookmark, $writeDatastore); + + return true; + } + + return false; + } + + /** + * @param mixed[] $data Template vars to process in plugins, passed as reference. + */ + protected function executeHooks(array &$data): void + { + $this->container->pluginManager->executeHooks( + 'render_linklist', + $data, + ['loggedin' => $this->container->loginManager->isLoggedIn()] + ); + } + + /** + * @return string[] Default template variables without values. + */ + protected function initializeTemplateVars(): array + { + return [ + 'previous_page_url' => '', + 'next_page_url' => '', + 'page_max' => '', + 'search_tags' => '', + 'result_count' => '', + ]; + } + + /** + * Process legacy routes if necessary. They used query parameters. + * If no legacy routes is passed, return null. + */ + protected function processLegacyController(Request $request, Response $response): ?Response + { + // Legacy smallhash filter + $queryString = $this->container->environment['QUERY_STRING'] ?? null; + if (null !== $queryString && 1 === preg_match('/^([a-zA-Z0-9-_@]{6})($|&|#)/', $queryString, $match)) { + return $this->redirect($response, '/shaare/' . $match[1]); + } + + // Legacy controllers (mostly used for redirections) + if (null !== $request->getQueryParam('do')) { + $legacyController = new LegacyController($this->container); + + try { + return $legacyController->process($request, $response, $request->getQueryParam('do')); + } catch (UnknowLegacyRouteException $e) { + // We ignore legacy 404 + return null; + } + } + + // Legacy GET admin routes + $legacyGetRoutes = array_intersect( + LegacyController::LEGACY_GET_ROUTES, + array_keys($request->getQueryParams() ?? []) + ); + if (1 === count($legacyGetRoutes)) { + $legacyController = new LegacyController($this->container); + + return $legacyController->process($request, $response, $legacyGetRoutes[0]); + } + + return null; + } +} diff --git a/application/front/controller/visitor/DailyController.php b/application/front/controller/visitor/DailyController.php index e5c9ddac..05b4f095 100644 --- a/application/front/controller/visitor/DailyController.php +++ b/application/front/controller/visitor/DailyController.php @@ -7,6 +7,7 @@ namespace Shaarli\Front\Controller\Visitor; use DateTime; use DateTimeImmutable; use Shaarli\Bookmark\Bookmark; +use Shaarli\Render\TemplatePage; use Slim\Http\Request; use Slim\Http\Response; @@ -85,7 +86,7 @@ class DailyController extends ShaarliVisitorController t('Daily') .' - '. format_date($dayDate, false) . ' - ' . $mainTitle ); - return $response->write($this->render('daily')); + return $response->write($this->render(TemplatePage::DAILY)); } /** @@ -152,7 +153,7 @@ class DailyController extends ShaarliVisitorController $this->assignView('hide_timestamps', $this->container->conf->get('privacy.hide_timestamps', false)); $this->assignView('days', $dataPerDay); - $rssContent = $this->render('dailyrss'); + $rssContent = $this->render(TemplatePage::DAILY_RSS); $cache->cache($rssContent); diff --git a/application/front/controller/visitor/LoginController.php b/application/front/controller/visitor/LoginController.php index 0db1f463..a257766f 100644 --- a/application/front/controller/visitor/LoginController.php +++ b/application/front/controller/visitor/LoginController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Shaarli\Front\Controller\Visitor; use Shaarli\Front\Exception\LoginBannedException; +use Shaarli\Render\TemplatePage; use Slim\Http\Request; use Slim\Http\Response; @@ -41,6 +42,6 @@ class LoginController extends ShaarliVisitorController ->assignView('pagetitle', t('Login') .' - '. $this->container->conf->get('general.title', 'Shaarli')) ; - return $response->write($this->render('loginform')); + return $response->write($this->render(TemplatePage::LOGIN)); } } diff --git a/application/front/controller/visitor/OpenSearchController.php b/application/front/controller/visitor/OpenSearchController.php index 0fd68db6..36d60acf 100644 --- a/application/front/controller/visitor/OpenSearchController.php +++ b/application/front/controller/visitor/OpenSearchController.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Shaarli\Front\Controller\Visitor; +use Shaarli\Render\TemplatePage; use Slim\Http\Request; use Slim\Http\Response; @@ -21,6 +22,6 @@ class OpenSearchController extends ShaarliVisitorController $this->assignView('serverurl', index_url($this->container->environment)); - return $response->write($this->render('opensearch')); + return $response->write($this->render(TemplatePage::OPEN_SEARCH)); } } diff --git a/application/front/controller/visitor/PictureWallController.php b/application/front/controller/visitor/PictureWallController.php index 4e1dce8c..5ef2cb17 100644 --- a/application/front/controller/visitor/PictureWallController.php +++ b/application/front/controller/visitor/PictureWallController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Shaarli\Front\Controller\Visitor; use Shaarli\Front\Exception\ThumbnailsDisabledException; +use Shaarli\Render\TemplatePage; use Shaarli\Thumbnailer; use Slim\Http\Request; use Slim\Http\Response; @@ -46,7 +47,7 @@ class PictureWallController extends ShaarliVisitorController $this->assignView($key, $value); } - return $response->write($this->render('picwall')); + return $response->write($this->render(TemplatePage::PICTURE_WALL)); } /** diff --git a/application/legacy/LegacyController.php b/application/legacy/LegacyController.php new file mode 100644 index 00000000..a97b07b1 --- /dev/null +++ b/application/legacy/LegacyController.php @@ -0,0 +1,130 @@ +{$action}($request, $response); + } + + /** Legacy route: ?post= */ + public function post(Request $request, Response $response): Response + { + $parameters = count($request->getQueryParams()) > 0 ? '?' . http_build_query($request->getQueryParams()) : ''; + + if (!$this->container->loginManager->isLoggedIn()) { + return $this->redirect($response, '/login' . $parameters); + } + + return $this->redirect($response, '/admin/shaare' . $parameters); + } + + /** Legacy route: ?addlink= */ + protected function addlink(Request $request, Response $response): Response + { + if (!$this->container->loginManager->isLoggedIn()) { + return $this->redirect($response, '/login'); + } + + return $this->redirect($response, '/admin/add-shaare'); + } + + /** Legacy route: ?do=login */ + protected function login(Request $request, Response $response): Response + { + return $this->redirect($response, '/login'); + } + + /** Legacy route: ?do=logout */ + protected function logout(Request $request, Response $response): Response + { + return $this->redirect($response, '/logout'); + } + + /** Legacy route: ?do=picwall */ + protected function picwall(Request $request, Response $response): Response + { + return $this->redirect($response, '/picture-wall'); + } + + /** Legacy route: ?do=tagcloud */ + protected function tagcloud(Request $request, Response $response): Response + { + return $this->redirect($response, '/tags/cloud'); + } + + /** Legacy route: ?do=taglist */ + protected function taglist(Request $request, Response $response): Response + { + return $this->redirect($response, '/tags/list'); + } + + /** Legacy route: ?do=daily */ + protected function daily(Request $request, Response $response): Response + { + $dayParam = !empty($request->getParam('day')) ? '?day=' . escape($request->getParam('day')) : ''; + + return $this->redirect($response, '/daily' . $dayParam); + } + + /** Legacy route: ?do=rss */ + protected function rss(Request $request, Response $response): Response + { + return $this->feed($request, $response, FeedBuilder::$FEED_RSS); + } + + /** Legacy route: ?do=atom */ + protected function atom(Request $request, Response $response): Response + { + return $this->feed($request, $response, FeedBuilder::$FEED_ATOM); + } + + /** Legacy route: ?do=opensearch */ + protected function opensearch(Request $request, Response $response): Response + { + return $this->redirect($response, '/open-search'); + } + + /** Legacy route: ?do=dailyrss */ + protected function dailyrss(Request $request, Response $response): Response + { + return $this->redirect($response, '/daily-rss'); + } + + /** Legacy route: ?do=feed */ + protected function feed(Request $request, Response $response, string $feedType): Response + { + $parameters = count($request->getQueryParams()) > 0 ? '?' . http_build_query($request->getQueryParams()) : ''; + + return $this->redirect($response, '/feed/' . $feedType . $parameters); + } +} diff --git a/application/legacy/LegacyRouter.php b/application/legacy/LegacyRouter.php new file mode 100644 index 00000000..cea99154 --- /dev/null +++ b/application/legacy/LegacyRouter.php @@ -0,0 +1,187 @@ +isLoggedIn = $isLoggedIn; } + /** + * Reset current state of template rendering. + * Mostly useful for error handling. We remove everything, and display the error template. + */ + public function reset(): void + { + $this->tpl = false; + } + /** * Initialize all default tpl tags. */ diff --git a/application/render/TemplatePage.php b/application/render/TemplatePage.php new file mode 100644 index 00000000..8af8228a --- /dev/null +++ b/application/render/TemplatePage.php @@ -0,0 +1,33 @@ +doneUpdates = $doneUpdates; - $this->linkServices = $linkDB; + $this->bookmarkService = $linkDB; $this->conf = $conf; $this->isLoggedIn = $isLoggedIn; @@ -68,7 +68,7 @@ class Updater */ public function update() { - $updatesRan = array(); + $updatesRan = []; // If the user isn't logged in, exit without updating. if ($this->isLoggedIn !== true) { @@ -112,6 +112,16 @@ class Updater return $this->doneUpdates; } + public function readUpdates(string $updatesFilepath): array + { + return UpdaterUtils::read_updates_file($updatesFilepath); + } + + public function writeUpdates(string $updatesFilepath, array $updates): void + { + UpdaterUtils::write_updates_file($updatesFilepath, $updates); + } + /** * With the Slim routing system, default header link should be `./` instead of `?`. * Otherwise you can not go back to the home page. Example: `/picture-wall` -> `/picture-wall?` instead of `/`. @@ -127,4 +137,31 @@ class Updater return true; } + + /** + * With the Slim routing system, note bookmarks URL formatted `?abcdef` + * should be replaced with `/shaare/abcdef` + */ + public function updateMethodMigrateExistingNotesUrl(): bool + { + $updated = false; + + foreach ($this->bookmarkService->search() as $bookmark) { + if ($bookmark->isNote() + && startsWith($bookmark->getUrl(), '?') + && 1 === preg_match('/^\?([a-zA-Z0-9-_@]{6})($|&|#)/', $bookmark->getUrl(), $match) + ) { + $updated = true; + $bookmark = $bookmark->setUrl('/shaare/' . $match[1]); + + $this->bookmarkService->set($bookmark, false); + } + } + + if ($updated) { + $this->bookmarkService->save(); + } + + return true; + } } -- cgit v1.2.3 From c4ad3d4f061d05a01db25aa54dda830ba776792d Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Tue, 7 Jul 2020 10:15:56 +0200 Subject: Process Shaarli install through Slim controller --- application/bookmark/BookmarkFileService.php | 26 +++- application/bookmark/BookmarkInitializer.php | 12 +- application/bookmark/BookmarkServiceInterface.php | 13 ++ application/container/ContainerBuilder.php | 7 + application/container/ShaarliContainer.php | 3 + application/front/ShaarliMiddleware.php | 6 + .../front/controller/admin/LogoutController.php | 10 +- .../front/controller/visitor/InstallController.php | 173 +++++++++++++++++++++ .../front/exceptions/AlreadyInstalledException.php | 15 ++ .../exceptions/ResourcePermissionException.php | 13 ++ application/security/CookieManager.php | 33 ++++ application/security/LoginManager.php | 16 +- application/security/SessionManager.php | 16 +- application/updater/Updater.php | 30 ++-- 14 files changed, 340 insertions(+), 33 deletions(-) create mode 100644 application/front/controller/visitor/InstallController.php create mode 100644 application/front/exceptions/AlreadyInstalledException.php create mode 100644 application/front/exceptions/ResourcePermissionException.php create mode 100644 application/security/CookieManager.php (limited to 'application') diff --git a/application/bookmark/BookmarkFileService.php b/application/bookmark/BookmarkFileService.php index 3d15d4c9..6e04f3b7 100644 --- a/application/bookmark/BookmarkFileService.php +++ b/application/bookmark/BookmarkFileService.php @@ -46,6 +46,9 @@ class BookmarkFileService implements BookmarkServiceInterface /** @var bool true for logged in users. Default value to retrieve private bookmarks. */ protected $isLoggedIn; + /** @var bool Allow datastore alteration from not logged in users. */ + protected $anonymousPermission = false; + /** * @inheritDoc */ @@ -64,7 +67,7 @@ class BookmarkFileService implements BookmarkServiceInterface $this->bookmarks = $this->bookmarksIO->read(); } catch (EmptyDataStoreException $e) { $this->bookmarks = new BookmarkArray(); - if ($isLoggedIn) { + if ($this->isLoggedIn) { $this->save(); } } @@ -154,7 +157,7 @@ class BookmarkFileService implements BookmarkServiceInterface */ public function set($bookmark, $save = true) { - if ($this->isLoggedIn !== true) { + if (true !== $this->isLoggedIn && true !== $this->anonymousPermission) { throw new Exception(t('You\'re not authorized to alter the datastore')); } if (! $bookmark instanceof Bookmark) { @@ -179,7 +182,7 @@ class BookmarkFileService implements BookmarkServiceInterface */ public function add($bookmark, $save = true) { - if ($this->isLoggedIn !== true) { + if (true !== $this->isLoggedIn && true !== $this->anonymousPermission) { throw new Exception(t('You\'re not authorized to alter the datastore')); } if (! $bookmark instanceof Bookmark) { @@ -204,7 +207,7 @@ class BookmarkFileService implements BookmarkServiceInterface */ public function addOrSet($bookmark, $save = true) { - if ($this->isLoggedIn !== true) { + if (true !== $this->isLoggedIn && true !== $this->anonymousPermission) { throw new Exception(t('You\'re not authorized to alter the datastore')); } if (! $bookmark instanceof Bookmark) { @@ -221,7 +224,7 @@ class BookmarkFileService implements BookmarkServiceInterface */ public function remove($bookmark, $save = true) { - if ($this->isLoggedIn !== true) { + if (true !== $this->isLoggedIn && true !== $this->anonymousPermission) { throw new Exception(t('You\'re not authorized to alter the datastore')); } if (! $bookmark instanceof Bookmark) { @@ -274,10 +277,11 @@ class BookmarkFileService implements BookmarkServiceInterface */ public function save() { - if (!$this->isLoggedIn) { + if (true !== $this->isLoggedIn && true !== $this->anonymousPermission) { // TODO: raise an Exception instead die('You are not authorized to change the database.'); } + $this->bookmarks->reorder(); $this->bookmarksIO->write($this->bookmarks); $this->pageCacheManager->invalidateCaches(); @@ -357,6 +361,16 @@ class BookmarkFileService implements BookmarkServiceInterface $initializer->initialize(); } + public function enableAnonymousPermission(): void + { + $this->anonymousPermission = true; + } + + public function disableAnonymousPermission(): void + { + $this->anonymousPermission = false; + } + /** * Handles migration to the new database format (BookmarksArray). */ diff --git a/application/bookmark/BookmarkInitializer.php b/application/bookmark/BookmarkInitializer.php index 9eee9a35..479ee9a9 100644 --- a/application/bookmark/BookmarkInitializer.php +++ b/application/bookmark/BookmarkInitializer.php @@ -34,13 +34,15 @@ class BookmarkInitializer */ public function initialize() { + $this->bookmarkService->enableAnonymousPermission(); + $bookmark = new Bookmark(); $bookmark->setTitle(t('My secret stuff... - Pastebin.com')); - $bookmark->setUrl('http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=', []); + $bookmark->setUrl('http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8='); $bookmark->setDescription(t('Shhhh! I\'m a private link only YOU can see. You can delete me too.')); $bookmark->setTagsString('secretstuff'); $bookmark->setPrivate(true); - $this->bookmarkService->add($bookmark); + $this->bookmarkService->add($bookmark, false); $bookmark = new Bookmark(); $bookmark->setTitle(t('The personal, minimalist, super-fast, database free, bookmarking service')); @@ -54,6 +56,10 @@ To learn how to use Shaarli, consult the link "Documentation" at the bottom of t You use the community supported version of the original Shaarli project, by Sebastien Sauvage.' )); $bookmark->setTagsString('opensource software'); - $this->bookmarkService->add($bookmark); + $this->bookmarkService->add($bookmark, false); + + $this->bookmarkService->save(); + + $this->bookmarkService->disableAnonymousPermission(); } } diff --git a/application/bookmark/BookmarkServiceInterface.php b/application/bookmark/BookmarkServiceInterface.php index 7b7a4f09..37fbda89 100644 --- a/application/bookmark/BookmarkServiceInterface.php +++ b/application/bookmark/BookmarkServiceInterface.php @@ -177,4 +177,17 @@ interface BookmarkServiceInterface * Creates the default database after a fresh install. */ public function initialize(); + + /** + * Allow to write the datastore from anonymous session (not logged in). + * + * This covers a few specific use cases, such as datastore initialization, + * but it should be used carefully as it can lead to security issues. + */ + public function enableAnonymousPermission(); + + /** + * Disable anonymous permission. + */ + public function disableAnonymousPermission(); } diff --git a/application/container/ContainerBuilder.php b/application/container/ContainerBuilder.php index ccb87c3a..593aafb7 100644 --- a/application/container/ContainerBuilder.php +++ b/application/container/ContainerBuilder.php @@ -15,6 +15,7 @@ use Shaarli\Netscape\NetscapeBookmarkUtils; use Shaarli\Plugin\PluginManager; use Shaarli\Render\PageBuilder; use Shaarli\Render\PageCacheManager; +use Shaarli\Security\CookieManager; use Shaarli\Security\LoginManager; use Shaarli\Security\SessionManager; use Shaarli\Thumbnailer; @@ -38,6 +39,9 @@ class ContainerBuilder /** @var SessionManager */ protected $session; + /** @var CookieManager */ + protected $cookieManager; + /** @var LoginManager */ protected $login; @@ -47,11 +51,13 @@ class ContainerBuilder public function __construct( ConfigManager $conf, SessionManager $session, + CookieManager $cookieManager, LoginManager $login ) { $this->conf = $conf; $this->session = $session; $this->login = $login; + $this->cookieManager = $cookieManager; } public function build(): ShaarliContainer @@ -60,6 +66,7 @@ class ContainerBuilder $container['conf'] = $this->conf; $container['sessionManager'] = $this->session; + $container['cookieManager'] = $this->cookieManager; $container['loginManager'] = $this->login; $container['basePath'] = $this->basePath; diff --git a/application/container/ShaarliContainer.php b/application/container/ShaarliContainer.php index 09e7d5b1..c4fe753e 100644 --- a/application/container/ShaarliContainer.php +++ b/application/container/ShaarliContainer.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Shaarli\Container; +use http\Cookie; use Shaarli\Bookmark\BookmarkServiceInterface; use Shaarli\Config\ConfigManager; use Shaarli\Feed\FeedBuilder; @@ -14,6 +15,7 @@ use Shaarli\Netscape\NetscapeBookmarkUtils; use Shaarli\Plugin\PluginManager; use Shaarli\Render\PageBuilder; use Shaarli\Render\PageCacheManager; +use Shaarli\Security\CookieManager; use Shaarli\Security\LoginManager; use Shaarli\Security\SessionManager; use Shaarli\Thumbnailer; @@ -25,6 +27,7 @@ use Slim\Container; * * @property string $basePath Shaarli's instance base path (e.g. `/shaarli/`) * @property BookmarkServiceInterface $bookmarkService + * @property CookieManager $cookieManager * @property ConfigManager $conf * @property mixed[] $environment $_SERVER automatically injected by Slim * @property callable $errorHandler Overrides default Slim error display diff --git a/application/front/ShaarliMiddleware.php b/application/front/ShaarliMiddleware.php index baea6ef2..595182ac 100644 --- a/application/front/ShaarliMiddleware.php +++ b/application/front/ShaarliMiddleware.php @@ -43,6 +43,12 @@ class ShaarliMiddleware $this->container->basePath = rtrim($request->getUri()->getBasePath(), '/'); try { + if (!is_file($this->container->conf->getConfigFileExt()) + && !in_array($next->getName(), ['displayInstall', 'saveInstall'], true) + ) { + return $response->withRedirect($this->container->basePath . '/install'); + } + $this->runUpdates(); $this->checkOpenShaarli($request, $response, $next); diff --git a/application/front/controller/admin/LogoutController.php b/application/front/controller/admin/LogoutController.php index c5984814..28165129 100644 --- a/application/front/controller/admin/LogoutController.php +++ b/application/front/controller/admin/LogoutController.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Shaarli\Front\Controller\Admin; +use Shaarli\Security\CookieManager; use Shaarli\Security\LoginManager; use Slim\Http\Request; use Slim\Http\Response; @@ -20,9 +21,12 @@ class LogoutController extends ShaarliAdminController { $this->container->pageCacheManager->invalidateCaches(); $this->container->sessionManager->logout(); - - // TODO: switch to a simple Cookie manager allowing to check the session, and create mocks. - setcookie(LoginManager::$STAY_SIGNED_IN_COOKIE, 'false', 0, $this->container->basePath . '/'); + $this->container->cookieManager->setCookieParameter( + CookieManager::STAY_SIGNED_IN, + 'false', + 0, + $this->container->basePath . '/' + ); return $this->redirect($response, '/'); } diff --git a/application/front/controller/visitor/InstallController.php b/application/front/controller/visitor/InstallController.php new file mode 100644 index 00000000..aa032860 --- /dev/null +++ b/application/front/controller/visitor/InstallController.php @@ -0,0 +1,173 @@ +container->conf->getConfigFileExt())) { + throw new AlreadyInstalledException(); + } + } + + /** + * Display the install template page. + * Also test file permissions and sessions beforehand. + */ + public function index(Request $request, Response $response): Response + { + // Before installation, we'll make sure that permissions are set properly, and sessions are working. + $this->checkPermissions(); + + if (static::SESSION_TEST_VALUE + !== $this->container->sessionManager->getSessionParameter(static::SESSION_TEST_KEY) + ) { + $this->container->sessionManager->setSessionParameter(static::SESSION_TEST_KEY, static::SESSION_TEST_VALUE); + + return $this->redirect($response, '/install/session-test'); + } + + [$continents, $cities] = generateTimeZoneData(timezone_identifiers_list(), date_default_timezone_get()); + + $this->assignView('continents', $continents); + $this->assignView('cities', $cities); + $this->assignView('languages', Languages::getAvailableLanguages()); + + return $response->write($this->render('install')); + } + + /** + * Route checking that the session parameter has been properly saved between two distinct requests. + * If the session parameter is preserved, redirect to install template page, otherwise displays error. + */ + public function sessionTest(Request $request, Response $response): Response + { + // This part makes sure sessions works correctly. + // (Because on some hosts, session.save_path may not be set correctly, + // or we may not have write access to it.) + if (static::SESSION_TEST_VALUE + !== $this->container->sessionManager->getSessionParameter(static::SESSION_TEST_KEY) + ) { + // Step 2: Check if data in session is correct. + $msg = t( + '
Sessions do not seem to work correctly on your server.
'. + 'Make sure the variable "session.save_path" is set correctly in your PHP config, '. + 'and that you have write access to it.
'. + 'It currently points to %s.
'. + 'On some browsers, accessing your server via a hostname like \'localhost\' '. + 'or any custom hostname without a dot causes cookie storage to fail. '. + 'We recommend accessing your server via it\'s IP address or Fully Qualified Domain Name.
' + ); + $msg = sprintf($msg, $this->container->sessionManager->getSavePath()); + + $this->assignView('message', $msg); + + return $response->write($this->render('error')); + } + + return $this->redirect($response, '/install'); + } + + /** + * Save installation form and initialize config file and datastore if necessary. + */ + public function save(Request $request, Response $response): Response + { + $timezone = 'UTC'; + if (!empty($request->getParam('continent')) + && !empty($request->getParam('city')) + && isTimeZoneValid($request->getParam('continent'), $request->getParam('city')) + ) { + $timezone = $request->getParam('continent') . '/' . $request->getParam('city'); + } + $this->container->conf->set('general.timezone', $timezone); + + $login = $request->getParam('setlogin'); + $this->container->conf->set('credentials.login', $login); + $salt = sha1(uniqid('', true) .'_'. mt_rand()); + $this->container->conf->set('credentials.salt', $salt); + $this->container->conf->set('credentials.hash', sha1($request->getParam('setpassword') . $login . $salt)); + + if (!empty($request->getParam('title'))) { + $this->container->conf->set('general.title', escape($request->getParam('title'))); + } else { + $this->container->conf->set( + 'general.title', + 'Shared bookmarks on '.escape(index_url($this->container->environment)) + ); + } + + $this->container->conf->set('translation.language', escape($request->getParam('language'))); + $this->container->conf->set('updates.check_updates', !empty($request->getParam('updateCheck'))); + $this->container->conf->set('api.enabled', !empty($request->getParam('enableApi'))); + $this->container->conf->set( + 'api.secret', + generate_api_secret( + $this->container->conf->get('credentials.login'), + $this->container->conf->get('credentials.salt') + ) + ); + + try { + // Everything is ok, let's create config file. + $this->container->conf->write($this->container->loginManager->isLoggedIn()); + } catch (\Exception $e) { + $this->assignView('message', $e->getMessage()); + $this->assignView('stacktrace', $e->getTraceAsString()); + + return $response->write($this->render('error')); + } + + if ($this->container->bookmarkService->count(BookmarkFilter::$ALL) === 0) { + $this->container->bookmarkService->initialize(); + } + + $this->container->sessionManager->setSessionParameter( + SessionManager::KEY_SUCCESS_MESSAGES, + [t('Shaarli is now configured. Please login and start shaaring your bookmarks!')] + ); + + return $this->redirect($response, '/'); + } + + protected function checkPermissions(): bool + { + // Ensure Shaarli has proper access to its resources + $errors = ApplicationUtils::checkResourcePermissions($this->container->conf); + + if (empty($errors)) { + return true; + } + + // FIXME! Do not insert HTML here. + $message = '

'. t('Insufficient permissions:') .'

    '; + + foreach ($errors as $error) { + $message .= '
  • '.$error.'
  • '; + } + $message .= '
'; + + throw new ResourcePermissionException($message); + } +} diff --git a/application/front/exceptions/AlreadyInstalledException.php b/application/front/exceptions/AlreadyInstalledException.php new file mode 100644 index 00000000..4add86cf --- /dev/null +++ b/application/front/exceptions/AlreadyInstalledException.php @@ -0,0 +1,15 @@ +cookies = $cookies; + } + + public function setCookieParameter(string $key, string $value, int $expires, string $path): self + { + $this->cookies[$key] = $value; + + setcookie($key, $value, $expires, $path); + + return $this; + } + + public function getCookieParameter(string $key, string $default = null): ?string + { + return $this->cookies[$key] ?? $default; + } +} diff --git a/application/security/LoginManager.php b/application/security/LoginManager.php index 39ec9b2e..d74c3118 100644 --- a/application/security/LoginManager.php +++ b/application/security/LoginManager.php @@ -9,9 +9,6 @@ use Shaarli\Config\ConfigManager; */ class LoginManager { - /** @var string Name of the cookie set after logging in **/ - public static $STAY_SIGNED_IN_COOKIE = 'shaarli_staySignedIn'; - /** @var array A reference to the $_GLOBALS array */ protected $globals = []; @@ -32,17 +29,21 @@ class LoginManager /** @var string User sign-in token depending on remote IP and credentials */ protected $staySignedInToken = ''; + /** @var CookieManager */ + protected $cookieManager; /** * Constructor * * @param ConfigManager $configManager Configuration Manager instance * @param SessionManager $sessionManager SessionManager instance + * @param CookieManager $cookieManager CookieManager instance */ - public function __construct($configManager, $sessionManager) + public function __construct($configManager, $sessionManager, $cookieManager) { $this->configManager = $configManager; $this->sessionManager = $sessionManager; + $this->cookieManager = $cookieManager; $this->banManager = new BanManager( $this->configManager->get('security.trusted_proxies', []), $this->configManager->get('security.ban_after'), @@ -86,10 +87,9 @@ class LoginManager /** * Check user session state and validity (expiration) * - * @param array $cookie The $_COOKIE array * @param string $clientIpId Client IP address identifier */ - public function checkLoginState($cookie, $clientIpId) + public function checkLoginState($clientIpId) { if (! $this->configManager->exists('credentials.login')) { // Shaarli is not configured yet @@ -97,9 +97,7 @@ class LoginManager return; } - if (isset($cookie[self::$STAY_SIGNED_IN_COOKIE]) - && $cookie[self::$STAY_SIGNED_IN_COOKIE] === $this->staySignedInToken - ) { + if ($this->staySignedInToken === $this->cookieManager->getCookieParameter(CookieManager::STAY_SIGNED_IN)) { // The user client has a valid stay-signed-in cookie // Session information is updated with the current client information $this->sessionManager->storeLoginInfo($clientIpId); diff --git a/application/security/SessionManager.php b/application/security/SessionManager.php index 0ac17d9a..82771c24 100644 --- a/application/security/SessionManager.php +++ b/application/security/SessionManager.php @@ -31,16 +31,21 @@ class SessionManager /** @var bool Whether the user should stay signed in (LONG_TIMEOUT) */ protected $staySignedIn = false; + /** @var string */ + protected $savePath; + /** * Constructor * - * @param array $session The $_SESSION array (reference) - * @param ConfigManager $conf ConfigManager instance + * @param array $session The $_SESSION array (reference) + * @param ConfigManager $conf ConfigManager instance + * @param string $savePath Session save path returned by builtin function session_save_path() */ - public function __construct(& $session, $conf) + public function __construct(&$session, $conf, string $savePath) { $this->session = &$session; $this->conf = $conf; + $this->savePath = $savePath; } /** @@ -249,4 +254,9 @@ class SessionManager return $this; } + + public function getSavePath(): string + { + return $this->savePath; + } } diff --git a/application/updater/Updater.php b/application/updater/Updater.php index f73a7452..4c578528 100644 --- a/application/updater/Updater.php +++ b/application/updater/Updater.php @@ -38,6 +38,11 @@ class Updater */ protected $methods; + /** + * @var string $basePath Shaarli root directory (from HTTP Request) + */ + protected $basePath = null; + /** * Object constructor. * @@ -62,11 +67,13 @@ class Updater * Run all new updates. * Update methods have to start with 'updateMethod' and return true (on success). * + * @param string $basePath Shaarli root directory (from HTTP Request) + * * @return array An array containing ran updates. * * @throws UpdaterException If something went wrong. */ - public function update() + public function update(string $basePath = null) { $updatesRan = []; @@ -123,16 +130,14 @@ class Updater } /** - * With the Slim routing system, default header link should be `./` instead of `?`. - * Otherwise you can not go back to the home page. Example: `/picture-wall` -> `/picture-wall?` instead of `/`. + * With the Slim routing system, default header link should be `/subfolder/` instead of `?`. + * Otherwise you can not go back to the home page. + * Example: `/subfolder/picture-wall` -> `/subfolder/picture-wall?` instead of `/subfolder/`. */ public function updateMethodRelativeHomeLink(): bool { - $link = trim($this->conf->get('general.header_link')); - if ($link[0] === '?') { - $link = './'. ltrim($link, '?'); - - $this->conf->set('general.header_link', $link, true, true); + if ('?' === trim($this->conf->get('general.header_link'))) { + $this->conf->set('general.header_link', $this->basePath . '/', true, true); } return true; @@ -152,7 +157,7 @@ class Updater && 1 === preg_match('/^\?([a-zA-Z0-9-_@]{6})($|&|#)/', $bookmark->getUrl(), $match) ) { $updated = true; - $bookmark = $bookmark->setUrl('/shaare/' . $match[1]); + $bookmark = $bookmark->setUrl($this->basePath . '/shaare/' . $match[1]); $this->bookmarkService->set($bookmark, false); } @@ -164,4 +169,11 @@ class Updater return true; } + + public function setBasePath(string $basePath): self + { + $this->basePath = $basePath; + + return $this; + } } -- cgit v1.2.3 From a8c11451e8d885a243c1ad52012093ba8d121e2c Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Tue, 21 Jul 2020 20:33:33 +0200 Subject: Process login through Slim controller --- application/front/ShaarliMiddleware.php | 4 +- .../front/controller/visitor/LoginController.php | 127 +++++++++++++++++++-- .../front/exceptions/CantLoginException.php | 10 ++ application/security/SessionManager.php | 30 +++++ 4 files changed, 160 insertions(+), 11 deletions(-) create mode 100644 application/front/exceptions/CantLoginException.php (limited to 'application') diff --git a/application/front/ShaarliMiddleware.php b/application/front/ShaarliMiddleware.php index 595182ac..e9f5552d 100644 --- a/application/front/ShaarliMiddleware.php +++ b/application/front/ShaarliMiddleware.php @@ -62,7 +62,9 @@ class ShaarliMiddleware return $response->write($this->container->pageBuilder->render('error')); } catch (UnauthorizedException $e) { - return $response->withRedirect($this->container->basePath . '/login'); + $returnUrl = urlencode($this->container->environment['REQUEST_URI']); + + return $response->withRedirect($this->container->basePath . '/login?returnurl=' . $returnUrl); } catch (\Throwable $e) { // Unknown error encountered $this->container->pageBuilder->reset(); diff --git a/application/front/controller/visitor/LoginController.php b/application/front/controller/visitor/LoginController.php index a257766f..c40b8cc4 100644 --- a/application/front/controller/visitor/LoginController.php +++ b/application/front/controller/visitor/LoginController.php @@ -4,8 +4,12 @@ declare(strict_types=1); namespace Shaarli\Front\Controller\Visitor; +use Shaarli\Front\Exception\CantLoginException; use Shaarli\Front\Exception\LoginBannedException; +use Shaarli\Front\Exception\WrongTokenException; use Shaarli\Render\TemplatePage; +use Shaarli\Security\CookieManager; +use Shaarli\Security\SessionManager; use Slim\Http\Request; use Slim\Http\Response; @@ -19,29 +23,132 @@ use Slim\Http\Response; */ class LoginController extends ShaarliVisitorController { + /** + * GET /login - Display the login page. + */ public function index(Request $request, Response $response): Response { - if ($this->container->loginManager->isLoggedIn() - || $this->container->conf->get('security.open_shaarli', false) - ) { + try { + $this->checkLoginState(); + } catch (CantLoginException $e) { return $this->redirect($response, '/'); } - $userCanLogin = $this->container->loginManager->canLogin($request->getServerParams()); - if ($userCanLogin !== true) { - throw new LoginBannedException(); + if ($request->getParam('login') !== null) { + $this->assignView('username', escape($request->getParam('login'))); } - if ($request->getParam('username') !== null) { - $this->assignView('username', escape($request->getParam('username'))); - } + $returnUrl = $request->getParam('returnurl') ?? $this->container->environment['HTTP_REFERER'] ?? null; $this - ->assignView('returnurl', escape($request->getServerParam('HTTP_REFERER'))) + ->assignView('returnurl', escape($returnUrl)) ->assignView('remember_user_default', $this->container->conf->get('privacy.remember_user_default', true)) ->assignView('pagetitle', t('Login') .' - '. $this->container->conf->get('general.title', 'Shaarli')) ; return $response->write($this->render(TemplatePage::LOGIN)); } + + /** + * POST /login - Process login + */ + public function login(Request $request, Response $response): Response + { + if (!$this->container->sessionManager->checkToken($request->getParam('token'))) { + throw new WrongTokenException(); + } + + try { + $this->checkLoginState(); + } catch (CantLoginException $e) { + return $this->redirect($response, '/'); + } + + if (!$this->container->loginManager->checkCredentials( + $this->container->environment['REMOTE_ADDR'], + client_ip_id($this->container->environment), + $request->getParam('login'), + $request->getParam('password') + ) + ) { + $this->container->loginManager->handleFailedLogin($this->container->environment); + + $this->container->sessionManager->setSessionParameter( + SessionManager::KEY_ERROR_MESSAGES, + [t('Wrong login/password.')] + ); + + // Call controller directly instead of unnecessary redirection + return $this->index($request, $response); + } + + $this->container->loginManager->handleSuccessfulLogin($this->container->environment); + + $cookiePath = $this->container->basePath . '/'; + $expirationTime = $this->saveLongLastingSession($request, $cookiePath); + $this->renewUserSession($cookiePath, $expirationTime); + + // Force referer from given return URL + $this->container->environment['HTTP_REFERER'] = $request->getParam('returnurl'); + + return $this->redirectFromReferer($request, $response, ['login']); + } + + /** + * Make sure that the user is allowed to login and/or displaying the login page: + * - not already logged in + * - not open shaarli + * - not banned + */ + protected function checkLoginState(): bool + { + if ($this->container->loginManager->isLoggedIn() + || $this->container->conf->get('security.open_shaarli', false) + ) { + throw new CantLoginException(); + } + + if (true !== $this->container->loginManager->canLogin($this->container->environment)) { + throw new LoginBannedException(); + } + + return true; + } + + /** + * @return int Session duration in seconds + */ + protected function saveLongLastingSession(Request $request, string $cookiePath): int + { + if (empty($request->getParam('longlastingsession'))) { + // Standard session expiration (=when browser closes) + $expirationTime = 0; + } else { + // Keep the session cookie even after the browser closes + $this->container->sessionManager->setStaySignedIn(true); + $expirationTime = $this->container->sessionManager->extendSession(); + } + + $this->container->cookieManager->setCookieParameter( + CookieManager::STAY_SIGNED_IN, + $this->container->loginManager->getStaySignedInToken(), + $expirationTime, + $cookiePath + ); + + return $expirationTime; + } + + protected function renewUserSession(string $cookiePath, int $expirationTime): void + { + // Send cookie with the new expiration date to the browser + $this->container->sessionManager->destroy(); + $this->container->sessionManager->cookieParameters( + $expirationTime, + $cookiePath, + $this->container->environment['SERVER_NAME'] + ); + $this->container->sessionManager->start(); + $this->container->sessionManager->regenerateId(true); + } } diff --git a/application/front/exceptions/CantLoginException.php b/application/front/exceptions/CantLoginException.php new file mode 100644 index 00000000..cd16635d --- /dev/null +++ b/application/front/exceptions/CantLoginException.php @@ -0,0 +1,10 @@ +savePath; } + + /* + * Next public functions wrapping native PHP session API. + */ + + public function destroy(): bool + { + $this->session = []; + + return session_destroy(); + } + + public function start(): bool + { + if (session_status() === PHP_SESSION_ACTIVE) { + $this->destroy(); + } + + return session_start(); + } + + public function cookieParameters(int $lifeTime, string $path, string $domain): bool + { + return session_set_cookie_params($lifeTime, $path, $domain); + } + + public function regenerateId(bool $deleteOldSession = false): bool + { + return session_regenerate_id($deleteOldSession); + } } -- cgit v1.2.3 From fabff3835da26e6c95cea56b2a01a03749dec7c8 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Wed, 22 Jul 2020 18:12:10 +0200 Subject: Move PHP and config init to dedicated file in order to keep index.php as minimal as possible --- application/render/PageBuilder.php | 20 -------------------- application/security/SessionManager.php | 14 ++++++++++++++ 2 files changed, 14 insertions(+), 20 deletions(-) (limited to 'application') diff --git a/application/render/PageBuilder.php b/application/render/PageBuilder.php index 85e1d59d..471724c0 100644 --- a/application/render/PageBuilder.php +++ b/application/render/PageBuilder.php @@ -158,10 +158,6 @@ class PageBuilder */ protected function finalize(): void { - //FIXME - DEV _ REMOVE ME - $this->assign('base_path', '/Shaarli'); - $this->assign('asset_path', '/Shaarli/tpl/default'); - // TODO: use the SessionManager $messageKeys = [ SessionManager::KEY_SUCCESS_MESSAGES, @@ -248,20 +244,4 @@ class PageBuilder return $this->tpl->draw($page, true); } - - /** - * Render a 404 page (uses the template : tpl/404.tpl) - * usage: $PAGE->render404('The link was deleted') - * - * @param string $message A message to display what is not found - */ - public function render404($message = '') - { - if (empty($message)) { - $message = t('The page you are trying to reach does not exist or has been deleted.'); - } - header($_SERVER['SERVER_PROTOCOL'] . ' ' . t('404 Not Found')); - $this->tpl->assign('error_message', $message); - $this->renderPage('404'); - } } diff --git a/application/security/SessionManager.php b/application/security/SessionManager.php index 46219a3d..76b0afe8 100644 --- a/application/security/SessionManager.php +++ b/application/security/SessionManager.php @@ -48,6 +48,20 @@ class SessionManager $this->savePath = $savePath; } + /** + * Initialize XSRF token and links per page session variables. + */ + public function initialize(): void + { + if (!isset($this->session['tokens'])) { + $this->session['tokens'] = []; + } + + if (!isset($this->session['LINKS_PER_PAGE'])) { + $this->session['LINKS_PER_PAGE'] = $this->conf->get('general.links_per_page', 20); + } + } + /** * Define whether the user should stay signed in across browser sessions * -- cgit v1.2.3 From 3ee8351e438f13ccf36062ce956e0b4a4d5f4a29 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Thu, 23 Jul 2020 16:41:32 +0200 Subject: Multiple small fixes --- application/config/ConfigManager.php | 4 +++- application/container/ContainerBuilder.php | 1 - application/front/ShaarliMiddleware.php | 2 +- .../front/controller/admin/ConfigureController.php | 17 +++++++++++------ .../front/controller/visitor/InstallController.php | 13 +++++-------- application/front/controller/visitor/TagController.php | 2 -- application/legacy/LegacyUpdater.php | 3 ++- 7 files changed, 22 insertions(+), 20 deletions(-) (limited to 'application') diff --git a/application/config/ConfigManager.php b/application/config/ConfigManager.php index e45bb4c3..4c98be30 100644 --- a/application/config/ConfigManager.php +++ b/application/config/ConfigManager.php @@ -3,6 +3,7 @@ namespace Shaarli\Config; use Shaarli\Config\Exception\MissingFieldConfigException; use Shaarli\Config\Exception\UnauthorizedConfigException; +use Shaarli\Thumbnailer; /** * Class ConfigManager @@ -361,7 +362,7 @@ class ConfigManager $this->setEmpty('security.open_shaarli', false); $this->setEmpty('security.allowed_protocols', ['ftp', 'ftps', 'magnet']); - $this->setEmpty('general.header_link', '?'); + $this->setEmpty('general.header_link', '/'); $this->setEmpty('general.links_per_page', 20); $this->setEmpty('general.enabled_plugins', self::$DEFAULT_PLUGINS); $this->setEmpty('general.default_note_title', 'Note: '); @@ -381,6 +382,7 @@ class ConfigManager // default state of the 'remember me' checkbox of the login form $this->setEmpty('privacy.remember_user_default', true); + $this->setEmpty('thumbnails.mode', Thumbnailer::MODE_ALL); $this->setEmpty('thumbnails.width', '125'); $this->setEmpty('thumbnails.height', '90'); diff --git a/application/container/ContainerBuilder.php b/application/container/ContainerBuilder.php index 593aafb7..bfe93501 100644 --- a/application/container/ContainerBuilder.php +++ b/application/container/ContainerBuilder.php @@ -99,7 +99,6 @@ class ContainerBuilder $container['pluginManager'] = function (ShaarliContainer $container): PluginManager { $pluginManager = new PluginManager($container->conf); - // FIXME! Configuration is already injected $pluginManager->load($container->conf->get('general.enabled_plugins')); return $pluginManager; diff --git a/application/front/ShaarliMiddleware.php b/application/front/ShaarliMiddleware.php index e9f5552d..fd978e99 100644 --- a/application/front/ShaarliMiddleware.php +++ b/application/front/ShaarliMiddleware.php @@ -56,7 +56,7 @@ class ShaarliMiddleware } catch (ShaarliFrontException $e) { // Possible functional error $this->container->pageBuilder->reset(); - $this->container->pageBuilder->assign('message', $e->getMessage()); + $this->container->pageBuilder->assign('message', nl2br($e->getMessage())); $response = $response->withStatus($e->getCode()); diff --git a/application/front/controller/admin/ConfigureController.php b/application/front/controller/admin/ConfigureController.php index 865fc2b0..e675fcca 100644 --- a/application/front/controller/admin/ConfigureController.php +++ b/application/front/controller/admin/ConfigureController.php @@ -98,10 +98,10 @@ class ConfigureController extends ShaarliAdminController if ($thumbnailsMode !== Thumbnailer::MODE_NONE && $thumbnailsMode !== $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) ) { - $this->saveWarningMessage(t( - 'You have enabled or changed thumbnails mode. ' - .'Please synchronize them.' - )); + $this->saveWarningMessage( + t('You have enabled or changed thumbnails mode.') . + '' . t('Please synchronize them.') .'' + ); } $this->container->conf->set('thumbnails.mode', $thumbnailsMode); @@ -110,8 +110,13 @@ class ConfigureController extends ShaarliAdminController $this->container->history->updateSettings(); $this->container->pageCacheManager->invalidateCaches(); } catch (Throwable $e) { - // TODO: translation + stacktrace - $this->saveErrorMessage('ERROR while writing config file after configuration update.'); + $this->assignView('message', t('Error while writing config file after configuration update.')); + + if ($this->container->conf->get('dev.debug', false)) { + $this->assignView('stacktrace', $e->getMessage() . PHP_EOL . $e->getTraceAsString()); + } + + return $response->write($this->render('error')); } $this->saveSuccessMessage(t('Configuration was saved.')); diff --git a/application/front/controller/visitor/InstallController.php b/application/front/controller/visitor/InstallController.php index aa032860..94ebb4ae 100644 --- a/application/front/controller/visitor/InstallController.php +++ b/application/front/controller/visitor/InstallController.php @@ -128,13 +128,14 @@ class InstallController extends ShaarliVisitorController $this->container->conf->get('credentials.salt') ) ); + $this->container->conf->set('general.header_link', $this->container->basePath); try { // Everything is ok, let's create config file. $this->container->conf->write($this->container->loginManager->isLoggedIn()); } catch (\Exception $e) { - $this->assignView('message', $e->getMessage()); - $this->assignView('stacktrace', $e->getTraceAsString()); + $this->assignView('message', t('Error while writing config file after configuration update.')); + $this->assignView('stacktrace', $e->getMessage() . PHP_EOL . $e->getTraceAsString()); return $response->write($this->render('error')); } @@ -155,18 +156,14 @@ class InstallController extends ShaarliVisitorController { // Ensure Shaarli has proper access to its resources $errors = ApplicationUtils::checkResourcePermissions($this->container->conf); - if (empty($errors)) { return true; } - // FIXME! Do not insert HTML here. - $message = '

'. t('Insufficient permissions:') .'

    '; - + $message = t('Insufficient permissions:') . PHP_EOL; foreach ($errors as $error) { - $message .= '
  • '.$error.'
  • '; + $message .= PHP_EOL . $error; } - $message .= '
'; throw new ResourcePermissionException($message); } diff --git a/application/front/controller/visitor/TagController.php b/application/front/controller/visitor/TagController.php index c176f43f..de4e7ea2 100644 --- a/application/front/controller/visitor/TagController.php +++ b/application/front/controller/visitor/TagController.php @@ -11,8 +11,6 @@ use Slim\Http\Response; * Class TagController * * Slim controller handle tags. - * - * TODO: check redirections with new helper */ class TagController extends ShaarliVisitorController { diff --git a/application/legacy/LegacyUpdater.php b/application/legacy/LegacyUpdater.php index cbf6890f..0ab3a55b 100644 --- a/application/legacy/LegacyUpdater.php +++ b/application/legacy/LegacyUpdater.php @@ -534,7 +534,8 @@ class LegacyUpdater if ($thumbnailsEnabled) { $this->session['warnings'][] = t( - 'You have enabled or changed thumbnails mode. Please synchronize them.' + t('You have enabled or changed thumbnails mode.') . + '' . t('Please synchronize them.') . '' ); } -- cgit v1.2.3 From 8e9169cebaf5697344cb373d69fe429e8e0efd5d Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Thu, 23 Jul 2020 17:13:22 +0200 Subject: Update French translation --- application/front/controller/admin/PluginsController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'application') diff --git a/application/front/controller/admin/PluginsController.php b/application/front/controller/admin/PluginsController.php index 44025395..1eb7e635 100644 --- a/application/front/controller/admin/PluginsController.php +++ b/application/front/controller/admin/PluginsController.php @@ -75,7 +75,7 @@ class PluginsController extends ShaarliAdminController $this->saveSuccessMessage(t('Setting successfully saved.')); } catch (Exception $e) { $this->saveErrorMessage( - t('ERROR while saving plugin configuration: ') . PHP_EOL . $e->getMessage() + t('Error while saving plugin configuration: ') . PHP_EOL . $e->getMessage() ); } -- cgit v1.2.3 From 87ae3c4f08431e02869376cb57add257747910d1 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Fri, 24 Jul 2020 10:30:47 +0200 Subject: Fix default link and redirection in install controller --- application/front/controller/visitor/InstallController.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'application') diff --git a/application/front/controller/visitor/InstallController.php b/application/front/controller/visitor/InstallController.php index 94ebb4ae..5e3152c7 100644 --- a/application/front/controller/visitor/InstallController.php +++ b/application/front/controller/visitor/InstallController.php @@ -128,7 +128,7 @@ class InstallController extends ShaarliVisitorController $this->container->conf->get('credentials.salt') ) ); - $this->container->conf->set('general.header_link', $this->container->basePath); + $this->container->conf->set('general.header_link', $this->container->basePath . '/'); try { // Everything is ok, let's create config file. @@ -149,7 +149,7 @@ class InstallController extends ShaarliVisitorController [t('Shaarli is now configured. Please login and start shaaring your bookmarks!')] ); - return $this->redirect($response, '/'); + return $this->redirect($response, '/login'); } protected function checkPermissions(): bool -- cgit v1.2.3 From 204035bd3c91b9a5c39fcb6fc470e108b032dbd9 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Fri, 24 Jul 2020 12:48:53 +0200 Subject: Fix: visitor are allowed to chose nb of links per page --- .../controller/admin/SessionFilterController.php | 20 +------------ .../visitor/PublicSessionFilterController.php | 33 ++++++++++++++++++++++ 2 files changed, 34 insertions(+), 19 deletions(-) create mode 100644 application/front/controller/visitor/PublicSessionFilterController.php (limited to 'application') diff --git a/application/front/controller/admin/SessionFilterController.php b/application/front/controller/admin/SessionFilterController.php index 69a16ec3..081c0ba0 100644 --- a/application/front/controller/admin/SessionFilterController.php +++ b/application/front/controller/admin/SessionFilterController.php @@ -12,28 +12,10 @@ use Slim\Http\Response; /** * Class SessionFilterController * - * Slim controller used to handle filters stored in the user session, such as visibility, links per page, etc. + * Slim controller used to handle filters stored in the user session, such as visibility, etc. */ class SessionFilterController extends ShaarliAdminController { - /** - * GET /links-per-page: set the number of bookmarks to display per page in homepage - */ - public function linksPerPage(Request $request, Response $response): Response - { - $linksPerPage = $request->getParam('nb') ?? null; - if (null === $linksPerPage || false === is_numeric($linksPerPage)) { - $linksPerPage = $this->container->conf->get('general.links_per_page', 20); - } - - $this->container->sessionManager->setSessionParameter( - SessionManager::KEY_LINKS_PER_PAGE, - abs(intval($linksPerPage)) - ); - - return $this->redirectFromReferer($request, $response, ['linksperpage'], ['nb']); - } - /** * GET /visibility: allows to display only public or only private bookmarks in linklist */ diff --git a/application/front/controller/visitor/PublicSessionFilterController.php b/application/front/controller/visitor/PublicSessionFilterController.php new file mode 100644 index 00000000..35da0c5f --- /dev/null +++ b/application/front/controller/visitor/PublicSessionFilterController.php @@ -0,0 +1,33 @@ +getParam('nb') ?? null; + if (null === $linksPerPage || false === is_numeric($linksPerPage)) { + $linksPerPage = $this->container->conf->get('general.links_per_page', 20); + } + + $this->container->sessionManager->setSessionParameter( + SessionManager::KEY_LINKS_PER_PAGE, + abs(intval($linksPerPage)) + ); + + return $this->redirectFromReferer($request, $response, ['linksperpage'], ['nb']); + } +} -- cgit v1.2.3 From 9fbc42294e7667c5ef19cafa0d1fcfbc1c0f36a9 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Sun, 26 Jul 2020 14:43:10 +0200 Subject: New basePath: fix officiel plugin paths and vintage template --- application/config/ConfigPlugin.php | 17 ++++++++++- application/front/ShaarliMiddleware.php | 4 +-- .../controller/admin/ManageShaareController.php | 25 ++++------------ .../front/controller/admin/PluginsController.php | 17 +---------- .../front/controller/admin/ToolsController.php | 17 +---------- .../controller/visitor/BookmarkListController.php | 16 ++--------- .../front/controller/visitor/DailyController.php | 22 ++------------- .../front/controller/visitor/FeedController.php | 21 +------------- .../controller/visitor/PictureWallController.php | 23 ++------------- .../visitor/ShaarliVisitorController.php | 33 ++++++++++++---------- .../controller/visitor/TagCloudController.php | 24 ++-------------- application/plugin/PluginManager.php | 4 +++ application/render/PageBuilder.php | 32 ++++++++------------- 13 files changed, 70 insertions(+), 185 deletions(-) (limited to 'application') diff --git a/application/config/ConfigPlugin.php b/application/config/ConfigPlugin.php index dbb24937..ea8dfbda 100644 --- a/application/config/ConfigPlugin.php +++ b/application/config/ConfigPlugin.php @@ -1,6 +1,7 @@ $value) { // No duplicate order allowed. - if (in_array($value, $orders)) { + if (in_array($value, $orders, true)) { return false; } diff --git a/application/front/ShaarliMiddleware.php b/application/front/ShaarliMiddleware.php index fd978e99..92c0e911 100644 --- a/application/front/ShaarliMiddleware.php +++ b/application/front/ShaarliMiddleware.php @@ -60,7 +60,7 @@ class ShaarliMiddleware $response = $response->withStatus($e->getCode()); - return $response->write($this->container->pageBuilder->render('error')); + return $response->write($this->container->pageBuilder->render('error', $this->container->basePath)); } catch (UnauthorizedException $e) { $returnUrl = urlencode($this->container->environment['REQUEST_URI']); @@ -80,7 +80,7 @@ class ShaarliMiddleware $response = $response->withStatus(500); - return $response->write($this->container->pageBuilder->render('error')); + return $response->write($this->container->pageBuilder->render('error', $this->container->basePath)); } } diff --git a/application/front/controller/admin/ManageShaareController.php b/application/front/controller/admin/ManageShaareController.php index 3aa48423..33e1188e 100644 --- a/application/front/controller/admin/ManageShaareController.php +++ b/application/front/controller/admin/ManageShaareController.php @@ -152,7 +152,7 @@ class ManageShaareController extends ShaarliAdminController // To preserve backward compatibility with 3rd parties, plugins still use arrays $formatter = $this->container->formatterFactory->getFormatter('raw'); $data = $formatter->format($bookmark); - $data = $this->executeHooks('save_link', $data); + $this->executePageHooks('save_link', $data); $bookmark->fromArray($data); $this->container->bookmarkService->set($bookmark); @@ -211,7 +211,7 @@ class ManageShaareController extends ShaarliAdminController } $data = $formatter->format($bookmark); - $this->container->pluginManager->executeHooks('delete_link', $data); + $this->executePageHooks('delete_link', $data); $this->container->bookmarkService->remove($bookmark, false); ++ $count; } @@ -283,7 +283,7 @@ class ManageShaareController extends ShaarliAdminController // To preserve backward compatibility with 3rd parties, plugins still use arrays $data = $formatter->format($bookmark); - $this->container->pluginManager->executeHooks('save_link', $data); + $this->executePageHooks('save_link', $data); $bookmark->fromArray($data); $this->container->bookmarkService->set($bookmark, false); @@ -325,7 +325,7 @@ class ManageShaareController extends ShaarliAdminController // To preserve backward compatibility with 3rd parties, plugins still use arrays $data = $formatter->format($bookmark); - $this->container->pluginManager->executeHooks('save_link', $data); + $this->executePageHooks('save_link', $data); $bookmark->fromArray($data); $this->container->bookmarkService->set($bookmark); @@ -354,7 +354,7 @@ class ManageShaareController extends ShaarliAdminController 'default_private_links' => $this->container->conf->get('privacy.default_private_links', false), ]; - $data = $this->executeHooks('render_editlink', $data); + $this->executePageHooks('render_editlink', $data, TemplatePage::EDIT_LINK); foreach ($data as $key => $value) { $this->assignView($key, $value); @@ -368,19 +368,4 @@ class ManageShaareController extends ShaarliAdminController return $response->write($this->render(TemplatePage::EDIT_LINK)); } - - /** - * @param mixed[] $data Variables passed to the template engine - * - * @return mixed[] Template data after active plugins render_picwall hook execution. - */ - protected function executeHooks(string $hook, array $data): array - { - $this->container->pluginManager->executeHooks( - $hook, - $data - ); - - return $data; - } } diff --git a/application/front/controller/admin/PluginsController.php b/application/front/controller/admin/PluginsController.php index 1eb7e635..0e09116e 100644 --- a/application/front/controller/admin/PluginsController.php +++ b/application/front/controller/admin/PluginsController.php @@ -58,7 +58,7 @@ class PluginsController extends ShaarliAdminController try { $parameters = $request->getParams() ?? []; - $this->executeHooks($parameters); + $this->executePageHooks('save_plugin_parameters', $parameters); if (isset($parameters['parameters_form'])) { unset($parameters['parameters_form']); @@ -81,19 +81,4 @@ class PluginsController extends ShaarliAdminController return $this->redirect($response, '/admin/plugins'); } - - /** - * @param mixed[] $data Variables passed to the template engine - * - * @return mixed[] Template data after active plugins render_picwall hook execution. - */ - protected function executeHooks(array $data): array - { - $this->container->pluginManager->executeHooks( - 'save_plugin_parameters', - $data - ); - - return $data; - } } diff --git a/application/front/controller/admin/ToolsController.php b/application/front/controller/admin/ToolsController.php index a476e898..a87f20d2 100644 --- a/application/front/controller/admin/ToolsController.php +++ b/application/front/controller/admin/ToolsController.php @@ -22,7 +22,7 @@ class ToolsController extends ShaarliAdminController 'sslenabled' => is_https($this->container->environment), ]; - $data = $this->executeHooks($data); + $this->executePageHooks('render_tools', $data, TemplatePage::TOOLS); foreach ($data as $key => $value) { $this->assignView($key, $value); @@ -32,19 +32,4 @@ class ToolsController extends ShaarliAdminController return $response->write($this->render(TemplatePage::TOOLS)); } - - /** - * @param mixed[] $data Variables passed to the template engine - * - * @return mixed[] Template data after active plugins render_picwall hook execution. - */ - protected function executeHooks(array $data): array - { - $this->container->pluginManager->executeHooks( - 'render_tools', - $data - ); - - return $data; - } } diff --git a/application/front/controller/visitor/BookmarkListController.php b/application/front/controller/visitor/BookmarkListController.php index a37a7f6b..23c4fbae 100644 --- a/application/front/controller/visitor/BookmarkListController.php +++ b/application/front/controller/visitor/BookmarkListController.php @@ -124,7 +124,7 @@ class BookmarkListController extends ShaarliVisitorController $data['pagetitle'] = ($data['pagetitle'] ?? '') . $this->container->conf->get('general.title', 'Shaarli'); - $this->executeHooks($data); + $this->executePageHooks('render_linklist', $data, TemplatePage::LINKLIST); $this->assignAllView($data); return $response->write($this->render(TemplatePage::LINKLIST)); @@ -153,7 +153,7 @@ class BookmarkListController extends ShaarliVisitorController ] ); - $this->executeHooks($data); + $this->executePageHooks('render_linklist', $data, TemplatePage::LINKLIST); $this->assignAllView($data); return $response->write($this->render(TemplatePage::LINKLIST)); @@ -182,18 +182,6 @@ class BookmarkListController extends ShaarliVisitorController return false; } - /** - * @param mixed[] $data Template vars to process in plugins, passed as reference. - */ - protected function executeHooks(array &$data): void - { - $this->container->pluginManager->executeHooks( - 'render_linklist', - $data, - ['loggedin' => $this->container->loginManager->isLoggedIn()] - ); - } - /** * @return string[] Default template variables without values. */ diff --git a/application/front/controller/visitor/DailyController.php b/application/front/controller/visitor/DailyController.php index 05b4f095..808ca5f7 100644 --- a/application/front/controller/visitor/DailyController.php +++ b/application/front/controller/visitor/DailyController.php @@ -72,13 +72,11 @@ class DailyController extends ShaarliVisitorController ]; // Hooks are called before column construction so that plugins don't have to deal with columns. - $data = $this->executeHooks($data); + $this->executePageHooks('render_daily', $data, TemplatePage::DAILY); $data['cols'] = $this->calculateColumns($data['linksToDisplay']); - foreach ($data as $key => $value) { - $this->assignView($key, $value); - } + $this->assignAllView($data); $mainTitle = $this->container->conf->get('general.title', 'Shaarli'); $this->assignView( @@ -190,20 +188,4 @@ class DailyController extends ShaarliVisitorController return $columns; } - - /** - * @param mixed[] $data Variables passed to the template engine - * - * @return mixed[] Template data after active plugins render_picwall hook execution. - */ - protected function executeHooks(array $data): array - { - $this->container->pluginManager->executeHooks( - 'render_daily', - $data, - ['loggedin' => $this->container->loginManager->isLoggedIn()] - ); - - return $data; - } } diff --git a/application/front/controller/visitor/FeedController.php b/application/front/controller/visitor/FeedController.php index f76f55fd..da2848c2 100644 --- a/application/front/controller/visitor/FeedController.php +++ b/application/front/controller/visitor/FeedController.php @@ -46,7 +46,7 @@ class FeedController extends ShaarliVisitorController $data = $this->container->feedBuilder->buildData($feedType, $request->getParams()); - $data = $this->executeHooks($data, $feedType); + $this->executePageHooks('render_feed', $data, $feedType); $this->assignAllView($data); $content = $this->render('feed.'. $feedType); @@ -55,23 +55,4 @@ class FeedController extends ShaarliVisitorController return $response->write($content); } - - /** - * @param mixed[] $data Template data - * - * @return mixed[] Template data after active plugins hook execution. - */ - protected function executeHooks(array $data, string $feedType): array - { - $this->container->pluginManager->executeHooks( - 'render_feed', - $data, - [ - 'loggedin' => $this->container->loginManager->isLoggedIn(), - 'target' => $feedType, - ] - ); - - return $data; - } } diff --git a/application/front/controller/visitor/PictureWallController.php b/application/front/controller/visitor/PictureWallController.php index 5ef2cb17..3c57f8dd 100644 --- a/application/front/controller/visitor/PictureWallController.php +++ b/application/front/controller/visitor/PictureWallController.php @@ -42,30 +42,13 @@ class PictureWallController extends ShaarliVisitorController } } - $data = $this->executeHooks($linksToDisplay); + $data = ['linksToDisplay' => $linksToDisplay]; + $this->executePageHooks('render_picwall', $data, TemplatePage::PICTURE_WALL); + foreach ($data as $key => $value) { $this->assignView($key, $value); } return $response->write($this->render(TemplatePage::PICTURE_WALL)); } - - /** - * @param mixed[] $linksToDisplay List of formatted bookmarks - * - * @return mixed[] Template data after active plugins render_picwall hook execution. - */ - protected function executeHooks(array $linksToDisplay): array - { - $data = [ - 'linksToDisplay' => $linksToDisplay, - ]; - $this->container->pluginManager->executeHooks( - 'render_picwall', - $data, - ['loggedin' => $this->container->loginManager->isLoggedIn()] - ); - - return $data; - } } diff --git a/application/front/controller/visitor/ShaarliVisitorController.php b/application/front/controller/visitor/ShaarliVisitorController.php index b494a8e6..47057d97 100644 --- a/application/front/controller/visitor/ShaarliVisitorController.php +++ b/application/front/controller/visitor/ShaarliVisitorController.php @@ -60,22 +60,9 @@ abstract class ShaarliVisitorController $this->assignView('privateLinkcount', $this->container->bookmarkService->count(BookmarkFilter::$PRIVATE)); $this->assignView('plugin_errors', $this->container->pluginManager->getErrors()); - /* - * Define base path (if Shaarli is installed in a domain's subfolder, e.g. `/shaarli`) - * and the asset path (subfolder/tpl/default for default theme). - * These MUST be used to create an internal link or to include an asset in templates. - */ - $this->assignView('base_path', $this->container->basePath); - $this->assignView( - 'asset_path', - $this->container->basePath . '/' . - rtrim($this->container->conf->get('resource.raintpl_tpl', 'tpl'), '/') . '/' . - $this->container->conf->get('resource.theme', 'default') - ); - $this->executeDefaultHooks($template); - return $this->container->pageBuilder->render($template); + return $this->container->pageBuilder->render($template, $this->container->basePath); } /** @@ -97,13 +84,29 @@ abstract class ShaarliVisitorController $pluginData, [ 'target' => $template, - 'loggedin' => $this->container->loginManager->isLoggedIn() + 'loggedin' => $this->container->loginManager->isLoggedIn(), + 'basePath' => $this->container->basePath, ] ); $this->assignView('plugins_' . $name, $pluginData); } } + protected function executePageHooks(string $hook, array &$data, string $template = null): void + { + $params = [ + 'target' => $template, + 'loggedin' => $this->container->loginManager->isLoggedIn(), + 'basePath' => $this->container->basePath, + ]; + + $this->container->pluginManager->executeHooks( + $hook, + $data, + $params + ); + } + /** * Simple helper which prepend the base path to redirect path. * diff --git a/application/front/controller/visitor/TagCloudController.php b/application/front/controller/visitor/TagCloudController.php index 15b6d7b7..f9c529bc 100644 --- a/application/front/controller/visitor/TagCloudController.php +++ b/application/front/controller/visitor/TagCloudController.php @@ -71,10 +71,8 @@ class TagCloudController extends ShaarliVisitorController 'search_tags' => $searchTags, 'tags' => $tags, ]; - $data = $this->executeHooks('tag' . $type, $data); - foreach ($data as $key => $value) { - $this->assignView($key, $value); - } + $this->executePageHooks('render_tag' . $type, $data, 'tag.' . $type); + $this->assignAllView($data); $searchTags = !empty($searchTags) ? $searchTags .' - ' : ''; $this->assignView( @@ -82,7 +80,7 @@ class TagCloudController extends ShaarliVisitorController $searchTags . t('Tag '. $type) .' - '. $this->container->conf->get('general.title', 'Shaarli') ); - return $response->write($this->render('tag.'. $type)); + return $response->write($this->render('tag.' . $type)); } /** @@ -112,20 +110,4 @@ class TagCloudController extends ShaarliVisitorController return $tagList; } - - /** - * @param mixed[] $data Template data - * - * @return mixed[] Template data after active plugins hook execution. - */ - protected function executeHooks(string $template, array $data): array - { - $this->container->pluginManager->executeHooks( - 'render_'. $template, - $data, - ['loggedin' => $this->container->loginManager->isLoggedIn()] - ); - - return $data; - } } diff --git a/application/plugin/PluginManager.php b/application/plugin/PluginManager.php index 591a9180..b3e8b2f8 100644 --- a/application/plugin/PluginManager.php +++ b/application/plugin/PluginManager.php @@ -108,6 +108,10 @@ class PluginManager $data['_LOGGEDIN_'] = $params['loggedin']; } + if (isset($params['basePath'])) { + $data['_BASE_PATH_'] = $params['basePath']; + } + foreach ($this->loadedPlugins as $plugin) { $hookFunction = $this->buildHookName($hook, $plugin); diff --git a/application/render/PageBuilder.php b/application/render/PageBuilder.php index 471724c0..7a716673 100644 --- a/application/render/PageBuilder.php +++ b/application/render/PageBuilder.php @@ -3,6 +3,7 @@ namespace Shaarli\Render; use Exception; +use exceptions\MissingBasePathException; use RainTPL; use Shaarli\ApplicationUtils; use Shaarli\Bookmark\BookmarkServiceInterface; @@ -156,7 +157,7 @@ class PageBuilder * Affect variable after controller processing. * Used for alert messages. */ - protected function finalize(): void + protected function finalize(string $basePath): void { // TODO: use the SessionManager $messageKeys = [ @@ -170,6 +171,14 @@ class PageBuilder unset($_SESSION[$messageKey]); } } + + $this->assign('base_path', $basePath); + $this->assign( + 'asset_path', + $basePath . '/' . + rtrim($this->conf->get('resource.raintpl_tpl', 'tpl'), '/') . '/' . + $this->conf->get('resource.theme', 'default') + ); } /** @@ -209,23 +218,6 @@ class PageBuilder return true; } - /** - * Render a specific page (using a template file). - * e.g. $pb->renderPage('picwall'); - * - * @param string $page Template filename (without extension). - */ - public function renderPage($page) - { - if ($this->tpl === false) { - $this->initialize(); - } - - $this->finalize(); - - $this->tpl->draw($page); - } - /** * Render a specific page as string (using a template file). * e.g. $pb->render('picwall'); @@ -234,13 +226,13 @@ class PageBuilder * * @return string Processed template content */ - public function render(string $page): string + public function render(string $page, string $basePath): string { if ($this->tpl === false) { $this->initialize(); } - $this->finalize(); + $this->finalize($basePath); return $this->tpl->draw($page, true); } -- cgit v1.2.3 From a285668ec4456c4d413c1d6dec275f1d18bf3f15 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Mon, 27 Jul 2020 12:34:17 +0200 Subject: Fix redirection after post install login --- application/front/controller/visitor/LoginController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'application') diff --git a/application/front/controller/visitor/LoginController.php b/application/front/controller/visitor/LoginController.php index c40b8cc4..121ba40b 100644 --- a/application/front/controller/visitor/LoginController.php +++ b/application/front/controller/visitor/LoginController.php @@ -91,7 +91,7 @@ class LoginController extends ShaarliVisitorController // Force referer from given return URL $this->container->environment['HTTP_REFERER'] = $request->getParam('returnurl'); - return $this->redirectFromReferer($request, $response, ['login']); + return $this->redirectFromReferer($request, $response, ['login', 'install']); } /** -- cgit v1.2.3 From 301c7ab1a079d937ab41c6f52b8804e5731008e6 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Tue, 28 Jul 2020 20:46:11 +0200 Subject: Better support for notes permalink --- application/api/ApiUtils.php | 2 +- application/bookmark/Bookmark.php | 4 ++-- application/container/ContainerBuilder.php | 5 ++++- application/feed/FeedBuilder.php | 2 +- application/formatter/BookmarkDefaultFormatter.php | 22 ++++++++++++++-------- application/formatter/BookmarkFormatter.php | 6 ++++-- application/formatter/FormatterFactory.php | 2 +- .../controller/visitor/BookmarkListController.php | 6 +++++- .../front/controller/visitor/DailyController.php | 1 + application/netscape/NetscapeBookmarkUtils.php | 2 +- 10 files changed, 34 insertions(+), 18 deletions(-) (limited to 'application') diff --git a/application/api/ApiUtils.php b/application/api/ApiUtils.php index 5156a5f7..faebb8f5 100644 --- a/application/api/ApiUtils.php +++ b/application/api/ApiUtils.php @@ -67,7 +67,7 @@ class ApiUtils if (! $bookmark->isNote()) { $out['url'] = $bookmark->getUrl(); } else { - $out['url'] = $indexUrl . $bookmark->getUrl(); + $out['url'] = rtrim($indexUrl, '/') . '/' . ltrim($bookmark->getUrl(), '/'); } $out['shorturl'] = $bookmark->getShortUrl(); $out['title'] = $bookmark->getTitle(); diff --git a/application/bookmark/Bookmark.php b/application/bookmark/Bookmark.php index 90ff5b16..c6f2c515 100644 --- a/application/bookmark/Bookmark.php +++ b/application/bookmark/Bookmark.php @@ -106,7 +106,7 @@ class Bookmark throw new InvalidBookmarkException($this); } if (empty($this->url)) { - $this->url = '?'. $this->shortUrl; + $this->url = '/shaare/'. $this->shortUrl; } if (empty($this->title)) { $this->title = $this->url; @@ -406,7 +406,7 @@ class Bookmark public function isNote() { // We check empty value to get a valid result if the link has not been saved yet - return empty($this->url) || $this->url[0] === '?'; + return empty($this->url) || startsWith($this->url, '/shaare/') || $this->url[0] === '?'; } /** diff --git a/application/container/ContainerBuilder.php b/application/container/ContainerBuilder.php index bfe93501..2e8c1ee3 100644 --- a/application/container/ContainerBuilder.php +++ b/application/container/ContainerBuilder.php @@ -105,7 +105,10 @@ class ContainerBuilder }; $container['formatterFactory'] = function (ShaarliContainer $container): FormatterFactory { - return new FormatterFactory($container->conf, $container->loginManager->isLoggedIn()); + return new FormatterFactory( + $container->conf, + $container->loginManager->isLoggedIn() + ); }; $container['pageCacheManager'] = function (ShaarliContainer $container): PageCacheManager { diff --git a/application/feed/FeedBuilder.php b/application/feed/FeedBuilder.php index c97ae1ea..269ad877 100644 --- a/application/feed/FeedBuilder.php +++ b/application/feed/FeedBuilder.php @@ -174,7 +174,7 @@ class FeedBuilder protected function buildItem(string $feedType, $link, $pageaddr) { $data = $this->formatter->format($link); - $data['guid'] = $pageaddr . '?' . $data['shorturl']; + $data['guid'] = rtrim($pageaddr, '/') . '/shaare/' . $data['shorturl']; if ($this->usePermalinks === true) { $permalink = ''. t('Direct link') .''; } else { diff --git a/application/formatter/BookmarkDefaultFormatter.php b/application/formatter/BookmarkDefaultFormatter.php index c6c59064..08e710eb 100644 --- a/application/formatter/BookmarkDefaultFormatter.php +++ b/application/formatter/BookmarkDefaultFormatter.php @@ -50,11 +50,10 @@ class BookmarkDefaultFormatter extends BookmarkFormatter */ public function formatUrl($bookmark) { - if (! empty($this->contextData['index_url']) && ( - startsWith($bookmark->getUrl(), '?') || startsWith($bookmark->getUrl(), '/') - )) { - return $this->contextData['index_url'] . escape($bookmark->getUrl()); + if ($bookmark->isNote() && !empty($this->contextData['index_url'])) { + return rtrim($this->contextData['index_url'], '/') . '/' . escape(ltrim($bookmark->getUrl(), '/')); } + return escape($bookmark->getUrl()); } @@ -63,11 +62,18 @@ class BookmarkDefaultFormatter extends BookmarkFormatter */ protected function formatRealUrl($bookmark) { - if (! empty($this->contextData['index_url']) && ( - startsWith($bookmark->getUrl(), '?') || startsWith($bookmark->getUrl(), '/') - )) { - return $this->contextData['index_url'] . escape($bookmark->getUrl()); + if ($bookmark->isNote()) { + if (!empty($this->contextData['index_url'])) { + $prefix = rtrim($this->contextData['index_url'], '/') . '/'; + } + + if (!empty($this->contextData['base_path'])) { + $prefix = rtrim($this->contextData['base_path'], '/') . '/'; + } + + return escape($prefix ?? '') . escape(ltrim($bookmark->getUrl(), '/')); } + return escape($bookmark->getUrl()); } diff --git a/application/formatter/BookmarkFormatter.php b/application/formatter/BookmarkFormatter.php index a80d83fc..22ba7aae 100644 --- a/application/formatter/BookmarkFormatter.php +++ b/application/formatter/BookmarkFormatter.php @@ -3,8 +3,8 @@ namespace Shaarli\Formatter; use DateTime; -use Shaarli\Config\ConfigManager; use Shaarli\Bookmark\Bookmark; +use Shaarli\Config\ConfigManager; /** * Class BookmarkFormatter @@ -80,6 +80,8 @@ abstract class BookmarkFormatter public function addContextData($key, $value) { $this->contextData[$key] = $value; + + return $this; } /** @@ -128,7 +130,7 @@ abstract class BookmarkFormatter */ protected function formatRealUrl($bookmark) { - return $bookmark->getUrl(); + return $this->formatUrl($bookmark); } /** diff --git a/application/formatter/FormatterFactory.php b/application/formatter/FormatterFactory.php index 5f282f68..a029579f 100644 --- a/application/formatter/FormatterFactory.php +++ b/application/formatter/FormatterFactory.php @@ -38,7 +38,7 @@ class FormatterFactory * * @return BookmarkFormatter instance. */ - public function getFormatter(string $type = null) + public function getFormatter(string $type = null): BookmarkFormatter { $type = $type ? $type : $this->conf->get('formatter', 'default'); $className = '\\Shaarli\\Formatter\\Bookmark'. ucfirst($type) .'Formatter'; diff --git a/application/front/controller/visitor/BookmarkListController.php b/application/front/controller/visitor/BookmarkListController.php index 23c4fbae..2988bee6 100644 --- a/application/front/controller/visitor/BookmarkListController.php +++ b/application/front/controller/visitor/BookmarkListController.php @@ -32,6 +32,7 @@ class BookmarkListController extends ShaarliVisitorController } $formatter = $this->container->formatterFactory->getFormatter(); + $formatter->addContextData('base_path', $this->container->basePath); $searchTags = escape(normalize_spaces($request->getParam('searchtags') ?? '')); $searchTerm = escape(normalize_spaces($request->getParam('searchterm') ?? ''));; @@ -145,11 +146,14 @@ class BookmarkListController extends ShaarliVisitorController $this->updateThumbnail($bookmark); + $formatter = $this->container->formatterFactory->getFormatter(); + $formatter->addContextData('base_path', $this->container->basePath); + $data = array_merge( $this->initializeTemplateVars(), [ 'pagetitle' => $bookmark->getTitle() .' - '. $this->container->conf->get('general.title', 'Shaarli'), - 'links' => [$this->container->formatterFactory->getFormatter()->format($bookmark)], + 'links' => [$formatter->format($bookmark)], ] ); diff --git a/application/front/controller/visitor/DailyController.php b/application/front/controller/visitor/DailyController.php index 808ca5f7..54a4778f 100644 --- a/application/front/controller/visitor/DailyController.php +++ b/application/front/controller/visitor/DailyController.php @@ -54,6 +54,7 @@ class DailyController extends ShaarliVisitorController } $formatter = $this->container->formatterFactory->getFormatter(); + $formatter->addContextData('base_path', $this->container->basePath); // We pre-format some fields for proper output. foreach ($linksToDisplay as $key => $bookmark) { $linksToDisplay[$key] = $formatter->format($bookmark); diff --git a/application/netscape/NetscapeBookmarkUtils.php b/application/netscape/NetscapeBookmarkUtils.php index b150f649..b83f16f8 100644 --- a/application/netscape/NetscapeBookmarkUtils.php +++ b/application/netscape/NetscapeBookmarkUtils.php @@ -68,7 +68,7 @@ class NetscapeBookmarkUtils $link = $formatter->format($bookmark); $link['taglist'] = implode(',', $bookmark->getTags()); if ($bookmark->isNote() && $prependNoteUrl) { - $link['url'] = $indexUrl . $link['url']; + $link['url'] = rtrim($indexUrl, '/') . '/' . ltrim($link['url'], '/'); } $bookmarkLinks[] = $link; -- cgit v1.2.3 From 624123177f8673f978c49186b43fd96c6827d8a0 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Tue, 28 Jul 2020 21:09:22 +0200 Subject: Include empty basePath in formatting --- application/formatter/BookmarkDefaultFormatter.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'application') diff --git a/application/formatter/BookmarkDefaultFormatter.php b/application/formatter/BookmarkDefaultFormatter.php index 08e710eb..9d4a0fa0 100644 --- a/application/formatter/BookmarkDefaultFormatter.php +++ b/application/formatter/BookmarkDefaultFormatter.php @@ -50,7 +50,7 @@ class BookmarkDefaultFormatter extends BookmarkFormatter */ public function formatUrl($bookmark) { - if ($bookmark->isNote() && !empty($this->contextData['index_url'])) { + if ($bookmark->isNote() && isset($this->contextData['index_url'])) { return rtrim($this->contextData['index_url'], '/') . '/' . escape(ltrim($bookmark->getUrl(), '/')); } @@ -63,11 +63,11 @@ class BookmarkDefaultFormatter extends BookmarkFormatter protected function formatRealUrl($bookmark) { if ($bookmark->isNote()) { - if (!empty($this->contextData['index_url'])) { + if (isset($this->contextData['index_url'])) { $prefix = rtrim($this->contextData['index_url'], '/') . '/'; } - if (!empty($this->contextData['base_path'])) { + if (isset($this->contextData['base_path'])) { $prefix = rtrim($this->contextData['base_path'], '/') . '/'; } -- cgit v1.2.3 From f7f08ceec1b218e1525153e8bd3d0199f2fb1c9d Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Tue, 28 Jul 2020 22:24:41 +0200 Subject: Fix basePath in unit tests reference DB --- application/front/ShaarliMiddleware.php | 1 + application/updater/Updater.php | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) (limited to 'application') diff --git a/application/front/ShaarliMiddleware.php b/application/front/ShaarliMiddleware.php index 92c0e911..707489d0 100644 --- a/application/front/ShaarliMiddleware.php +++ b/application/front/ShaarliMiddleware.php @@ -93,6 +93,7 @@ class ShaarliMiddleware return; } + $this->container->updater->setBasePath($this->container->basePath); $newUpdates = $this->container->updater->update(); if (!empty($newUpdates)) { $this->container->updater->writeUpdates( diff --git a/application/updater/Updater.php b/application/updater/Updater.php index 4c578528..88a7bc7b 100644 --- a/application/updater/Updater.php +++ b/application/updater/Updater.php @@ -157,7 +157,7 @@ class Updater && 1 === preg_match('/^\?([a-zA-Z0-9-_@]{6})($|&|#)/', $bookmark->getUrl(), $match) ) { $updated = true; - $bookmark = $bookmark->setUrl($this->basePath . '/shaare/' . $match[1]); + $bookmark = $bookmark->setUrl('/shaare/' . $match[1]); $this->bookmarkService->set($bookmark, false); } -- cgit v1.2.3 From d6e5f04d3987e498c5cb859eed6bff33d67949df Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Sat, 1 Aug 2020 11:10:57 +0200 Subject: Remove anonymous permission and initialize bookmarks on login --- application/bookmark/BookmarkFileService.php | 36 ++++++++++------------ application/bookmark/BookmarkIO.php | 8 +++-- application/bookmark/BookmarkInitializer.php | 9 +----- application/bookmark/BookmarkServiceInterface.php | 14 --------- .../exception/DatastoreNotInitializedException.php | 10 ++++++ .../front/controller/visitor/InstallController.php | 5 --- 6 files changed, 33 insertions(+), 49 deletions(-) create mode 100644 application/bookmark/exception/DatastoreNotInitializedException.php (limited to 'application') diff --git a/application/bookmark/BookmarkFileService.php b/application/bookmark/BookmarkFileService.php index 6e04f3b7..b3a90ed4 100644 --- a/application/bookmark/BookmarkFileService.php +++ b/application/bookmark/BookmarkFileService.php @@ -6,6 +6,7 @@ namespace Shaarli\Bookmark; use Exception; use Shaarli\Bookmark\Exception\BookmarkNotFoundException; +use Shaarli\Bookmark\Exception\DatastoreNotInitializedException; use Shaarli\Bookmark\Exception\EmptyDataStoreException; use Shaarli\Config\ConfigManager; use Shaarli\Formatter\BookmarkMarkdownFormatter; @@ -46,9 +47,6 @@ class BookmarkFileService implements BookmarkServiceInterface /** @var bool true for logged in users. Default value to retrieve private bookmarks. */ protected $isLoggedIn; - /** @var bool Allow datastore alteration from not logged in users. */ - protected $anonymousPermission = false; - /** * @inheritDoc */ @@ -65,10 +63,16 @@ class BookmarkFileService implements BookmarkServiceInterface } else { try { $this->bookmarks = $this->bookmarksIO->read(); - } catch (EmptyDataStoreException $e) { + } catch (EmptyDataStoreException|DatastoreNotInitializedException $e) { $this->bookmarks = new BookmarkArray(); + if ($this->isLoggedIn) { - $this->save(); + // Datastore file does not exists, we initialize it with default bookmarks. + if ($e instanceof DatastoreNotInitializedException) { + $this->initialize(); + } else { + $this->save(); + } } } @@ -157,7 +161,7 @@ class BookmarkFileService implements BookmarkServiceInterface */ public function set($bookmark, $save = true) { - if (true !== $this->isLoggedIn && true !== $this->anonymousPermission) { + if (true !== $this->isLoggedIn) { throw new Exception(t('You\'re not authorized to alter the datastore')); } if (! $bookmark instanceof Bookmark) { @@ -182,7 +186,7 @@ class BookmarkFileService implements BookmarkServiceInterface */ public function add($bookmark, $save = true) { - if (true !== $this->isLoggedIn && true !== $this->anonymousPermission) { + if (true !== $this->isLoggedIn) { throw new Exception(t('You\'re not authorized to alter the datastore')); } if (! $bookmark instanceof Bookmark) { @@ -207,7 +211,7 @@ class BookmarkFileService implements BookmarkServiceInterface */ public function addOrSet($bookmark, $save = true) { - if (true !== $this->isLoggedIn && true !== $this->anonymousPermission) { + if (true !== $this->isLoggedIn) { throw new Exception(t('You\'re not authorized to alter the datastore')); } if (! $bookmark instanceof Bookmark) { @@ -224,7 +228,7 @@ class BookmarkFileService implements BookmarkServiceInterface */ public function remove($bookmark, $save = true) { - if (true !== $this->isLoggedIn && true !== $this->anonymousPermission) { + if (true !== $this->isLoggedIn) { throw new Exception(t('You\'re not authorized to alter the datastore')); } if (! $bookmark instanceof Bookmark) { @@ -277,7 +281,7 @@ class BookmarkFileService implements BookmarkServiceInterface */ public function save() { - if (true !== $this->isLoggedIn && true !== $this->anonymousPermission) { + if (true !== $this->isLoggedIn) { // TODO: raise an Exception instead die('You are not authorized to change the database.'); } @@ -359,16 +363,10 @@ class BookmarkFileService implements BookmarkServiceInterface { $initializer = new BookmarkInitializer($this); $initializer->initialize(); - } - public function enableAnonymousPermission(): void - { - $this->anonymousPermission = true; - } - - public function disableAnonymousPermission(): void - { - $this->anonymousPermission = false; + if (true === $this->isLoggedIn) { + $this->save(); + } } /** diff --git a/application/bookmark/BookmarkIO.php b/application/bookmark/BookmarkIO.php index 1026e2f9..6bf7f365 100644 --- a/application/bookmark/BookmarkIO.php +++ b/application/bookmark/BookmarkIO.php @@ -2,6 +2,7 @@ namespace Shaarli\Bookmark; +use Shaarli\Bookmark\Exception\DatastoreNotInitializedException; use Shaarli\Bookmark\Exception\EmptyDataStoreException; use Shaarli\Bookmark\Exception\NotWritableDataStoreException; use Shaarli\Config\ConfigManager; @@ -52,13 +53,14 @@ class BookmarkIO * * @return BookmarkArray instance * - * @throws NotWritableDataStoreException Data couldn't be loaded - * @throws EmptyDataStoreException Datastore doesn't exist + * @throws NotWritableDataStoreException Data couldn't be loaded + * @throws EmptyDataStoreException Datastore file exists but does not contain any bookmark + * @throws DatastoreNotInitializedException File does not exists */ public function read() { if (! file_exists($this->datastore)) { - throw new EmptyDataStoreException(); + throw new DatastoreNotInitializedException(); } if (!is_writable($this->datastore)) { diff --git a/application/bookmark/BookmarkInitializer.php b/application/bookmark/BookmarkInitializer.php index 479ee9a9..cd2d1724 100644 --- a/application/bookmark/BookmarkInitializer.php +++ b/application/bookmark/BookmarkInitializer.php @@ -6,8 +6,7 @@ namespace Shaarli\Bookmark; * Class BookmarkInitializer * * This class is used to initialized default bookmarks after a fresh install of Shaarli. - * It is no longer call when the data store is empty, - * because user might want to delete default bookmarks after the install. + * It should be only called if the datastore file does not exist(users might want to delete the default bookmarks). * * To prevent data corruption, it does not overwrite existing bookmarks, * even though there should not be any. @@ -34,8 +33,6 @@ class BookmarkInitializer */ public function initialize() { - $this->bookmarkService->enableAnonymousPermission(); - $bookmark = new Bookmark(); $bookmark->setTitle(t('My secret stuff... - Pastebin.com')); $bookmark->setUrl('http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8='); @@ -57,9 +54,5 @@ You use the community supported version of the original Shaarli project, by Seba )); $bookmark->setTagsString('opensource software'); $this->bookmarkService->add($bookmark, false); - - $this->bookmarkService->save(); - - $this->bookmarkService->disableAnonymousPermission(); } } diff --git a/application/bookmark/BookmarkServiceInterface.php b/application/bookmark/BookmarkServiceInterface.php index 37fbda89..ce8bd912 100644 --- a/application/bookmark/BookmarkServiceInterface.php +++ b/application/bookmark/BookmarkServiceInterface.php @@ -6,7 +6,6 @@ namespace Shaarli\Bookmark; use Shaarli\Bookmark\Exception\BookmarkNotFoundException; use Shaarli\Bookmark\Exception\NotWritableDataStoreException; use Shaarli\Config\ConfigManager; -use Shaarli\Exceptions\IOException; use Shaarli\History; /** @@ -177,17 +176,4 @@ interface BookmarkServiceInterface * Creates the default database after a fresh install. */ public function initialize(); - - /** - * Allow to write the datastore from anonymous session (not logged in). - * - * This covers a few specific use cases, such as datastore initialization, - * but it should be used carefully as it can lead to security issues. - */ - public function enableAnonymousPermission(); - - /** - * Disable anonymous permission. - */ - public function disableAnonymousPermission(); } diff --git a/application/bookmark/exception/DatastoreNotInitializedException.php b/application/bookmark/exception/DatastoreNotInitializedException.php new file mode 100644 index 00000000..f495049d --- /dev/null +++ b/application/bookmark/exception/DatastoreNotInitializedException.php @@ -0,0 +1,10 @@ +write($this->render('error')); } - if ($this->container->bookmarkService->count(BookmarkFilter::$ALL) === 0) { - $this->container->bookmarkService->initialize(); - } - $this->container->sessionManager->setSessionParameter( SessionManager::KEY_SUCCESS_MESSAGES, [t('Shaarli is now configured. Please login and start shaaring your bookmarks!')] -- cgit v1.2.3 From 1a68ae5a29bc33ab80c9cfbe043cb1213551533c Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Sat, 1 Aug 2020 11:14:03 +0200 Subject: Bookmark's thumbnails PHPDoc improvement --- application/bookmark/Bookmark.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'application') diff --git a/application/bookmark/Bookmark.php b/application/bookmark/Bookmark.php index c6f2c515..1beb8be2 100644 --- a/application/bookmark/Bookmark.php +++ b/application/bookmark/Bookmark.php @@ -37,7 +37,7 @@ class Bookmark /** @var array List of bookmark's tags */ protected $tags; - /** @var string Thumbnail's URL - false if no thumbnail could be found */ + /** @var string|bool|null Thumbnail's URL - initialized at null, false if no thumbnail could be found */ protected $thumbnail; /** @var bool Set to true if the bookmark is set as sticky */ @@ -347,7 +347,7 @@ class Bookmark /** * Get the Thumbnail. * - * @return string|bool|null + * @return string|bool|null Thumbnail's URL - initialized at null, false if no thumbnail could be found */ public function getThumbnail() { @@ -357,7 +357,7 @@ class Bookmark /** * Set the Thumbnail. * - * @param string|bool $thumbnail + * @param string|bool $thumbnail Thumbnail's URL - false if no thumbnail could be found * * @return Bookmark */ -- cgit v1.2.3 From bedbb845eec20363b928b424143787dbe988eefe Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Thu, 13 Aug 2020 11:08:13 +0200 Subject: Move all admin controller into a dedicated group Also handle authentication check in a new middleware for the admin group. --- application/front/ShaarliAdminMiddleware.php | 27 ++++++++++++++++++++++ application/front/ShaarliMiddleware.php | 12 +++++++++- .../controller/admin/SessionFilterController.php | 13 +---------- .../controller/admin/ShaarliAdminController.php | 9 -------- .../visitor/PublicSessionFilterController.php | 13 +++++++++++ application/legacy/LegacyController.php | 2 +- 6 files changed, 53 insertions(+), 23 deletions(-) create mode 100644 application/front/ShaarliAdminMiddleware.php (limited to 'application') diff --git a/application/front/ShaarliAdminMiddleware.php b/application/front/ShaarliAdminMiddleware.php new file mode 100644 index 00000000..35ce4a3b --- /dev/null +++ b/application/front/ShaarliAdminMiddleware.php @@ -0,0 +1,27 @@ +initBasePath($request); + + if (true !== $this->container->loginManager->isLoggedIn()) { + $returnUrl = urlencode($this->container->environment['REQUEST_URI']); + + return $response->withRedirect($this->container->basePath . '/login?returnurl=' . $returnUrl); + } + + return parent::__invoke($request, $response, $next); + } +} diff --git a/application/front/ShaarliMiddleware.php b/application/front/ShaarliMiddleware.php index 707489d0..a2a3837b 100644 --- a/application/front/ShaarliMiddleware.php +++ b/application/front/ShaarliMiddleware.php @@ -40,7 +40,7 @@ class ShaarliMiddleware */ public function __invoke(Request $request, Response $response, callable $next): Response { - $this->container->basePath = rtrim($request->getUri()->getBasePath(), '/'); + $this->initBasePath($request); try { if (!is_file($this->container->conf->getConfigFileExt()) @@ -125,4 +125,14 @@ class ShaarliMiddleware return true; } + + /** + * Initialize the URL base path if it hasn't been defined yet. + */ + protected function initBasePath(Request $request): void + { + if (null === $this->container->basePath) { + $this->container->basePath = rtrim($request->getUri()->getBasePath(), '/'); + } + } } diff --git a/application/front/controller/admin/SessionFilterController.php b/application/front/controller/admin/SessionFilterController.php index 081c0ba0..d9a7a2e0 100644 --- a/application/front/controller/admin/SessionFilterController.php +++ b/application/front/controller/admin/SessionFilterController.php @@ -17,7 +17,7 @@ use Slim\Http\Response; class SessionFilterController extends ShaarliAdminController { /** - * GET /visibility: allows to display only public or only private bookmarks in linklist + * GET /admin/visibility: allows to display only public or only private bookmarks in linklist */ public function visibility(Request $request, Response $response, array $args): Response { @@ -46,16 +46,5 @@ class SessionFilterController extends ShaarliAdminController return $this->redirectFromReferer($request, $response, ['visibility']); } - /** - * GET /untagged-only: allows to display only bookmarks without any tag - */ - public function untaggedOnly(Request $request, Response $response): Response - { - $this->container->sessionManager->setSessionParameter( - SessionManager::KEY_UNTAGGED_ONLY, - empty($this->container->sessionManager->getSessionParameter(SessionManager::KEY_UNTAGGED_ONLY)) - ); - return $this->redirectFromReferer($request, $response, ['untaggedonly', 'untagged-only']); - } } diff --git a/application/front/controller/admin/ShaarliAdminController.php b/application/front/controller/admin/ShaarliAdminController.php index 3bc5bb6b..3b5939bb 100644 --- a/application/front/controller/admin/ShaarliAdminController.php +++ b/application/front/controller/admin/ShaarliAdminController.php @@ -22,15 +22,6 @@ use Slim\Http\Request; */ abstract class ShaarliAdminController extends ShaarliVisitorController { - public function __construct(ShaarliContainer $container) - { - parent::__construct($container); - - if (true !== $this->container->loginManager->isLoggedIn()) { - throw new UnauthorizedException(); - } - } - /** * Any persistent action to the config or data store must check the XSRF token validity. */ diff --git a/application/front/controller/visitor/PublicSessionFilterController.php b/application/front/controller/visitor/PublicSessionFilterController.php index 35da0c5f..1a66362d 100644 --- a/application/front/controller/visitor/PublicSessionFilterController.php +++ b/application/front/controller/visitor/PublicSessionFilterController.php @@ -30,4 +30,17 @@ class PublicSessionFilterController extends ShaarliVisitorController return $this->redirectFromReferer($request, $response, ['linksperpage'], ['nb']); } + + /** + * GET /untagged-only: allows to display only bookmarks without any tag + */ + public function untaggedOnly(Request $request, Response $response): Response + { + $this->container->sessionManager->setSessionParameter( + SessionManager::KEY_UNTAGGED_ONLY, + empty($this->container->sessionManager->getSessionParameter(SessionManager::KEY_UNTAGGED_ONLY)) + ); + + return $this->redirectFromReferer($request, $response, ['untaggedonly', 'untagged-only']); + } } diff --git a/application/legacy/LegacyController.php b/application/legacy/LegacyController.php index a97b07b1..26465d2c 100644 --- a/application/legacy/LegacyController.php +++ b/application/legacy/LegacyController.php @@ -67,7 +67,7 @@ class LegacyController extends ShaarliVisitorController /** Legacy route: ?do=logout */ protected function logout(Request $request, Response $response): Response { - return $this->redirect($response, '/logout'); + return $this->redirect($response, '/admin/logout'); } /** Legacy route: ?do=picwall */ -- cgit v1.2.3 From 0c6fdbe12bbbb336348666b14b82096f24d5858b Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Fri, 21 Aug 2020 10:50:44 +0200 Subject: Move error handling to dedicated controller instead of middleware --- application/container/ContainerBuilder.php | 5 +++ application/front/ShaarliMiddleware.php | 26 +------------ .../front/controller/visitor/ErrorController.php | 45 ++++++++++++++++++++++ 3 files changed, 51 insertions(+), 25 deletions(-) create mode 100644 application/front/controller/visitor/ErrorController.php (limited to 'application') diff --git a/application/container/ContainerBuilder.php b/application/container/ContainerBuilder.php index 2e8c1ee3..4a1a6ea7 100644 --- a/application/container/ContainerBuilder.php +++ b/application/container/ContainerBuilder.php @@ -9,6 +9,7 @@ use Shaarli\Bookmark\BookmarkServiceInterface; use Shaarli\Config\ConfigManager; use Shaarli\Feed\FeedBuilder; use Shaarli\Formatter\FormatterFactory; +use Shaarli\Front\Controller\Visitor\ErrorController; use Shaarli\History; use Shaarli\Http\HttpAccess; use Shaarli\Netscape\NetscapeBookmarkUtils; @@ -148,6 +149,10 @@ class ContainerBuilder ); }; + $container['errorHandler'] = function (ShaarliContainer $container): ErrorController { + return new ErrorController($container); + }; + return $container; } } diff --git a/application/front/ShaarliMiddleware.php b/application/front/ShaarliMiddleware.php index a2a3837b..c015c0c6 100644 --- a/application/front/ShaarliMiddleware.php +++ b/application/front/ShaarliMiddleware.php @@ -3,7 +3,6 @@ namespace Shaarli\Front; use Shaarli\Container\ShaarliContainer; -use Shaarli\Front\Exception\ShaarliFrontException; use Shaarli\Front\Exception\UnauthorizedException; use Slim\Http\Request; use Slim\Http\Response; @@ -53,35 +52,12 @@ class ShaarliMiddleware $this->checkOpenShaarli($request, $response, $next); return $next($request, $response); - } catch (ShaarliFrontException $e) { - // Possible functional error - $this->container->pageBuilder->reset(); - $this->container->pageBuilder->assign('message', nl2br($e->getMessage())); - - $response = $response->withStatus($e->getCode()); - - return $response->write($this->container->pageBuilder->render('error', $this->container->basePath)); } catch (UnauthorizedException $e) { $returnUrl = urlencode($this->container->environment['REQUEST_URI']); return $response->withRedirect($this->container->basePath . '/login?returnurl=' . $returnUrl); - } catch (\Throwable $e) { - // Unknown error encountered - $this->container->pageBuilder->reset(); - if ($this->container->conf->get('dev.debug', false)) { - $this->container->pageBuilder->assign('message', $e->getMessage()); - $this->container->pageBuilder->assign( - 'stacktrace', - nl2br(get_class($e) .': '. PHP_EOL . $e->getTraceAsString()) - ); - } else { - $this->container->pageBuilder->assign('message', t('An unexpected error occurred.')); - } - - $response = $response->withStatus(500); - - return $response->write($this->container->pageBuilder->render('error', $this->container->basePath)); } + // Other exceptions are handled by ErrorController } /** diff --git a/application/front/controller/visitor/ErrorController.php b/application/front/controller/visitor/ErrorController.php new file mode 100644 index 00000000..10aa84c8 --- /dev/null +++ b/application/front/controller/visitor/ErrorController.php @@ -0,0 +1,45 @@ +container->pageBuilder->reset(); + + if ($throwable instanceof ShaarliFrontException) { + // Functional error + $this->assignView('message', nl2br($throwable->getMessage())); + + $response = $response->withStatus($throwable->getCode()); + } else { + // Internal error (any other Throwable) + if ($this->container->conf->get('dev.debug', false)) { + $this->assignView('message', $throwable->getMessage()); + $this->assignView( + 'stacktrace', + nl2br(get_class($throwable) .': '. PHP_EOL . $throwable->getTraceAsString()) + ); + } else { + $this->assignView('message', t('An unexpected error occurred.')); + } + + $response = $response->withStatus(500); + } + + + return $response->write($this->render('error')); + } +} -- cgit v1.2.3 From 7e3dc0ba98bf019c2804e5c74fb6061b16fb712f Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Thu, 27 Aug 2020 12:04:36 +0200 Subject: Better handling of plugin incompatibility If a PHP is raised while executing plugin hook, Shaarli will display an error instead of rendering the error page (or just ending in fatal error for default hooks). Also added phpErrorHandler which is handled differently that regular errorHandler by Slim.: --- application/container/ContainerBuilder.php | 3 +++ application/container/ShaarliContainer.php | 4 ++-- application/front/controller/visitor/ShaarliVisitorController.php | 3 ++- application/plugin/PluginManager.php | 7 ++++++- 4 files changed, 13 insertions(+), 4 deletions(-) (limited to 'application') diff --git a/application/container/ContainerBuilder.php b/application/container/ContainerBuilder.php index 4a1a6ea7..58067c99 100644 --- a/application/container/ContainerBuilder.php +++ b/application/container/ContainerBuilder.php @@ -152,6 +152,9 @@ class ContainerBuilder $container['errorHandler'] = function (ShaarliContainer $container): ErrorController { return new ErrorController($container); }; + $container['phpErrorHandler'] = function (ShaarliContainer $container): ErrorController { + return new ErrorController($container); + }; return $container; } diff --git a/application/container/ShaarliContainer.php b/application/container/ShaarliContainer.php index c4fe753e..9a9a974a 100644 --- a/application/container/ShaarliContainer.php +++ b/application/container/ShaarliContainer.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Shaarli\Container; -use http\Cookie; use Shaarli\Bookmark\BookmarkServiceInterface; use Shaarli\Config\ConfigManager; use Shaarli\Feed\FeedBuilder; @@ -30,7 +29,7 @@ use Slim\Container; * @property CookieManager $cookieManager * @property ConfigManager $conf * @property mixed[] $environment $_SERVER automatically injected by Slim - * @property callable $errorHandler Overrides default Slim error display + * @property callable $errorHandler Overrides default Slim exception display * @property FeedBuilder $feedBuilder * @property FormatterFactory $formatterFactory * @property History $history @@ -39,6 +38,7 @@ use Slim\Container; * @property NetscapeBookmarkUtils $netscapeBookmarkUtils * @property PageBuilder $pageBuilder * @property PageCacheManager $pageCacheManager + * @property callable $phpErrorHandler Overrides default Slim PHP error display * @property PluginManager $pluginManager * @property SessionManager $sessionManager * @property Thumbnailer $thumbnailer diff --git a/application/front/controller/visitor/ShaarliVisitorController.php b/application/front/controller/visitor/ShaarliVisitorController.php index 47057d97..f17c8ed3 100644 --- a/application/front/controller/visitor/ShaarliVisitorController.php +++ b/application/front/controller/visitor/ShaarliVisitorController.php @@ -58,10 +58,11 @@ abstract class ShaarliVisitorController { $this->assignView('linkcount', $this->container->bookmarkService->count(BookmarkFilter::$ALL)); $this->assignView('privateLinkcount', $this->container->bookmarkService->count(BookmarkFilter::$PRIVATE)); - $this->assignView('plugin_errors', $this->container->pluginManager->getErrors()); $this->executeDefaultHooks($template); + $this->assignView('plugin_errors', $this->container->pluginManager->getErrors()); + return $this->container->pageBuilder->render($template, $this->container->basePath); } diff --git a/application/plugin/PluginManager.php b/application/plugin/PluginManager.php index b3e8b2f8..2d93cb3a 100644 --- a/application/plugin/PluginManager.php +++ b/application/plugin/PluginManager.php @@ -116,7 +116,12 @@ class PluginManager $hookFunction = $this->buildHookName($hook, $plugin); if (function_exists($hookFunction)) { - $data = call_user_func($hookFunction, $data, $this->conf); + try { + $data = call_user_func($hookFunction, $data, $this->conf); + } catch (\Throwable $e) { + $error = $plugin . t(' [plugin incompatibility]: ') . $e->getMessage(); + $this->errors = array_unique(array_merge($this->errors, [$error])); + } } } } -- cgit v1.2.3 From ebc027ec0a385c6a529cae6df649a4bbe51852d2 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Thu, 27 Aug 2020 15:00:33 +0200 Subject: Japanese translation: add language to admin configuration page Also use ISO country code (JP) instead of JA. --- application/Languages.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'application') diff --git a/application/Languages.php b/application/Languages.php index 5cda802e..d83e0765 100644 --- a/application/Languages.php +++ b/application/Languages.php @@ -179,9 +179,10 @@ class Languages { return [ 'auto' => t('Automatic'), + 'de' => t('German'), 'en' => t('English'), 'fr' => t('French'), - 'de' => t('German'), + 'jp' => t('Japanese'), ]; } } -- cgit v1.2.3 From a8e210faa624517ee8b8978b7e659a0b3c689297 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Sat, 29 Aug 2020 10:06:40 +0200 Subject: Fixed: Pinned bookmarks are displayed first in ATOM/RSS feeds Fixes #1485 --- application/bookmark/BookmarkArray.php | 9 +++++---- application/bookmark/BookmarkFileService.php | 13 +++++++++++-- application/bookmark/BookmarkServiceInterface.php | 9 ++++++++- application/feed/FeedBuilder.php | 2 +- 4 files changed, 25 insertions(+), 8 deletions(-) (limited to 'application') diff --git a/application/bookmark/BookmarkArray.php b/application/bookmark/BookmarkArray.php index d87d43b4..3bd5eb20 100644 --- a/application/bookmark/BookmarkArray.php +++ b/application/bookmark/BookmarkArray.php @@ -234,16 +234,17 @@ class BookmarkArray implements \Iterator, \Countable, \ArrayAccess * * Also update the urls and ids mapping arrays. * - * @param string $order ASC|DESC + * @param string $order ASC|DESC + * @param bool $ignoreSticky If set to true, sticky bookmarks won't be first */ - public function reorder($order = 'DESC') + public function reorder(string $order = 'DESC', bool $ignoreSticky = false): void { $order = $order === 'ASC' ? -1 : 1; // Reorder array by dates. - usort($this->bookmarks, function ($a, $b) use ($order) { + usort($this->bookmarks, function ($a, $b) use ($order, $ignoreSticky) { /** @var $a Bookmark */ /** @var $b Bookmark */ - if ($a->isSticky() !== $b->isSticky()) { + if (false === $ignoreSticky && $a->isSticky() !== $b->isSticky()) { return $a->isSticky() ? -1 : 1; } return $a->getCreated() < $b->getCreated() ? 1 * $order : -1 * $order; diff --git a/application/bookmark/BookmarkFileService.php b/application/bookmark/BookmarkFileService.php index b3a90ed4..e3a61146 100644 --- a/application/bookmark/BookmarkFileService.php +++ b/application/bookmark/BookmarkFileService.php @@ -114,8 +114,13 @@ class BookmarkFileService implements BookmarkServiceInterface /** * @inheritDoc */ - public function search($request = [], $visibility = null, $caseSensitive = false, $untaggedOnly = false) - { + public function search( + $request = [], + $visibility = null, + $caseSensitive = false, + $untaggedOnly = false, + bool $ignoreSticky = false + ) { if ($visibility === null) { $visibility = $this->isLoggedIn ? BookmarkFilter::$ALL : BookmarkFilter::$PUBLIC; } @@ -124,6 +129,10 @@ class BookmarkFileService implements BookmarkServiceInterface $searchtags = isset($request['searchtags']) ? $request['searchtags'] : ''; $searchterm = isset($request['searchterm']) ? $request['searchterm'] : ''; + if ($ignoreSticky) { + $this->bookmarks->reorder('DESC', true); + } + return $this->bookmarkFilter->filter( BookmarkFilter::$FILTER_TAG | BookmarkFilter::$FILTER_TEXT, [$searchtags, $searchterm], diff --git a/application/bookmark/BookmarkServiceInterface.php b/application/bookmark/BookmarkServiceInterface.php index ce8bd912..b9b483eb 100644 --- a/application/bookmark/BookmarkServiceInterface.php +++ b/application/bookmark/BookmarkServiceInterface.php @@ -49,10 +49,17 @@ interface BookmarkServiceInterface * @param string $visibility * @param bool $caseSensitive * @param bool $untaggedOnly + * @param bool $ignoreSticky * * @return Bookmark[] */ - public function search($request = [], $visibility = null, $caseSensitive = false, $untaggedOnly = false); + public function search( + $request = [], + $visibility = null, + $caseSensitive = false, + $untaggedOnly = false, + bool $ignoreSticky = false + ); /** * Get a single bookmark by its ID. diff --git a/application/feed/FeedBuilder.php b/application/feed/FeedBuilder.php index 269ad877..3653c32f 100644 --- a/application/feed/FeedBuilder.php +++ b/application/feed/FeedBuilder.php @@ -102,7 +102,7 @@ class FeedBuilder } // Optionally filter the results: - $linksToDisplay = $this->linkDB->search($userInput); + $linksToDisplay = $this->linkDB->search($userInput, null, false, false, true); $nblinksToDisplay = $this->getNbLinks(count($linksToDisplay), $userInput); -- cgit v1.2.3 From 816ffba74b8bebffc620af50994833d783207a50 Mon Sep 17 00:00:00 2001 From: Keith Carangelo Date: Sat, 29 Aug 2020 11:02:59 -0400 Subject: Added $links_per_page variable to template and display on default --- application/render/PageBuilder.php | 2 ++ 1 file changed, 2 insertions(+) (limited to 'application') diff --git a/application/render/PageBuilder.php b/application/render/PageBuilder.php index 7a716673..21703639 100644 --- a/application/render/PageBuilder.php +++ b/application/render/PageBuilder.php @@ -149,6 +149,8 @@ class PageBuilder $this->tpl->assign('formatter', $this->conf->get('formatter', 'default')); + $this->tpl->assign('links_per_page', $_SESSION['LINKS_PER_PAGE']); + // To be removed with a proper theme configuration. $this->tpl->assign('conf', $this->conf); } -- cgit v1.2.3 From 63b0059ed55dceaa58396b7baeb2b490b57ce9cc Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Mon, 31 Aug 2020 13:58:09 +0200 Subject: Fix broken route to filter not tagged bookmarks Also display the filter for visitors. Fixes #1529 --- application/security/SessionManager.php | 1 - 1 file changed, 1 deletion(-) (limited to 'application') diff --git a/application/security/SessionManager.php b/application/security/SessionManager.php index 76b0afe8..36df8c1c 100644 --- a/application/security/SessionManager.php +++ b/application/security/SessionManager.php @@ -183,7 +183,6 @@ class SessionManager unset($this->session['expires_on']); unset($this->session['username']); unset($this->session['visibility']); - unset($this->session['untaggedonly']); } } -- cgit v1.2.3 From 4479aff18f4ff80e274b52548c08e9ed9379bd51 Mon Sep 17 00:00:00 2001 From: Keith Carangelo Date: Mon, 31 Aug 2020 09:20:03 -0400 Subject: Avoid using global variables Co-authored-by: ArthurHoaro --- application/render/PageBuilder.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'application') diff --git a/application/render/PageBuilder.php b/application/render/PageBuilder.php index 21703639..c52e3b76 100644 --- a/application/render/PageBuilder.php +++ b/application/render/PageBuilder.php @@ -149,7 +149,7 @@ class PageBuilder $this->tpl->assign('formatter', $this->conf->get('formatter', 'default')); - $this->tpl->assign('links_per_page', $_SESSION['LINKS_PER_PAGE']); + $this->tpl->assign('links_per_page', $this->session['LINKS_PER_PAGE']); // To be removed with a proper theme configuration. $this->tpl->assign('conf', $this->conf); -- cgit v1.2.3 From aca995e09cf9c210ffe45584fbe50dcedb8bdebb Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Tue, 1 Sep 2020 10:12:54 +0200 Subject: Fix support for legacy route login redirection Makes sure that the user is properly redirected to the bookmark form after login, even with legacy routes --- application/legacy/LegacyController.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) (limited to 'application') diff --git a/application/legacy/LegacyController.php b/application/legacy/LegacyController.php index 26465d2c..efc14409 100644 --- a/application/legacy/LegacyController.php +++ b/application/legacy/LegacyController.php @@ -42,7 +42,7 @@ class LegacyController extends ShaarliVisitorController $parameters = count($request->getQueryParams()) > 0 ? '?' . http_build_query($request->getQueryParams()) : ''; if (!$this->container->loginManager->isLoggedIn()) { - return $this->redirect($response, '/login' . $parameters); + return $this->redirect($response, '/login?returnurl=/admin/shaare' . $parameters); } return $this->redirect($response, '/admin/shaare' . $parameters); @@ -52,7 +52,7 @@ class LegacyController extends ShaarliVisitorController protected function addlink(Request $request, Response $response): Response { if (!$this->container->loginManager->isLoggedIn()) { - return $this->redirect($response, '/login'); + return $this->redirect($response, '/login?returnurl=/admin/add-shaare'); } return $this->redirect($response, '/admin/add-shaare'); @@ -61,7 +61,9 @@ class LegacyController extends ShaarliVisitorController /** Legacy route: ?do=login */ protected function login(Request $request, Response $response): Response { - return $this->redirect($response, '/login'); + $returnurl = $request->getQueryParam('returnurl'); + + return $this->redirect($response, '/login' . ($returnurl ? '?returnurl=' . $returnurl : '')); } /** Legacy route: ?do=logout */ -- cgit v1.2.3 From 9e2d47e519783a991962e1fe2c9f28d77756ae49 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Tue, 1 Sep 2020 10:40:18 +0200 Subject: Fix legacy redirection when Shaarli instance is under a subfolder --- application/legacy/LegacyController.php | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) (limited to 'application') diff --git a/application/legacy/LegacyController.php b/application/legacy/LegacyController.php index efc14409..75fa9d2c 100644 --- a/application/legacy/LegacyController.php +++ b/application/legacy/LegacyController.php @@ -40,30 +40,33 @@ class LegacyController extends ShaarliVisitorController public function post(Request $request, Response $response): Response { $parameters = count($request->getQueryParams()) > 0 ? '?' . http_build_query($request->getQueryParams()) : ''; + $route = '/admin/shaare'; if (!$this->container->loginManager->isLoggedIn()) { - return $this->redirect($response, '/login?returnurl=/admin/shaare' . $parameters); + return $this->redirect($response, '/login?returnurl='. $this->getBasePath() . $route . $parameters); } - return $this->redirect($response, '/admin/shaare' . $parameters); + return $this->redirect($response, $route . $parameters); } /** Legacy route: ?addlink= */ protected function addlink(Request $request, Response $response): Response { + $route = '/admin/add-shaare'; + if (!$this->container->loginManager->isLoggedIn()) { - return $this->redirect($response, '/login?returnurl=/admin/add-shaare'); + return $this->redirect($response, '/login?returnurl=' . $this->getBasePath() . $route); } - return $this->redirect($response, '/admin/add-shaare'); + return $this->redirect($response, $route); } /** Legacy route: ?do=login */ protected function login(Request $request, Response $response): Response { - $returnurl = $request->getQueryParam('returnurl'); + $returnUrl = $request->getQueryParam('returnurl'); - return $this->redirect($response, '/login' . ($returnurl ? '?returnurl=' . $returnurl : '')); + return $this->redirect($response, '/login' . ($returnUrl ? '?returnurl=' . $returnUrl : '')); } /** Legacy route: ?do=logout */ -- cgit v1.2.3 From 11aa4a7a29c5a6358584ce0f63c061fdb0704b18 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Tue, 1 Sep 2020 10:40:35 +0200 Subject: Support redirection of legacy route 'do=configure' --- application/legacy/LegacyController.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) (limited to 'application') diff --git a/application/legacy/LegacyController.php b/application/legacy/LegacyController.php index 75fa9d2c..e16dd0f4 100644 --- a/application/legacy/LegacyController.php +++ b/application/legacy/LegacyController.php @@ -132,4 +132,21 @@ class LegacyController extends ShaarliVisitorController return $this->redirect($response, '/feed/' . $feedType . $parameters); } + + /** Legacy route: ?do=configure */ + protected function configure(Request $request, Response $response): Response + { + $route = '/admin/configure'; + + if (!$this->container->loginManager->isLoggedIn()) { + return $this->redirect($response, '/login?returnurl=' . $this->getBasePath() . $route); + } + + return $this->redirect($response, $route); + } + + protected function getBasePath(): string + { + return $this->container->basePath ?: ''; + } } -- cgit v1.2.3 From 14fcfb521341fd7619cab0301cef699cb42d2080 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Tue, 1 Sep 2020 11:26:24 +0200 Subject: Fix login loop for private instances GET /login and POST /login have 2 distinct route name. Fixes #1533 --- application/front/ShaarliMiddleware.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'application') diff --git a/application/front/ShaarliMiddleware.php b/application/front/ShaarliMiddleware.php index c015c0c6..d1aa1399 100644 --- a/application/front/ShaarliMiddleware.php +++ b/application/front/ShaarliMiddleware.php @@ -94,7 +94,7 @@ class ShaarliMiddleware && $this->container->conf->get('privacy.force_login') // and the current page isn't already the login page // and the user is not requesting a feed (which would lead to a different content-type as expected) - && !in_array($next->getName(), ['login', 'atom', 'rss'], true) + && !in_array($next->getName(), ['login', 'processLogin', 'atom', 'rss'], true) ) { throw new UnauthorizedException(); } -- cgit v1.2.3 From ce7918386a00c4a10ad8c9942c8ac28ea1fae0c2 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Thu, 3 Sep 2020 10:09:32 +0200 Subject: Improve backward compatibility for LegacyRouter LegacyRouter is no longer used for routing, only in existing plugins to match the _PAGE_ parameter. So we change a few of its values there, to match the new ones defined in TemplatePage. @see discussion in shaarli/Shaarli#1537 --- .../front/controller/visitor/FeedController.php | 4 +- application/legacy/LegacyRouter.php | 134 +-------------------- 2 files changed, 7 insertions(+), 131 deletions(-) (limited to 'application') diff --git a/application/front/controller/visitor/FeedController.php b/application/front/controller/visitor/FeedController.php index da2848c2..8d8b546a 100644 --- a/application/front/controller/visitor/FeedController.php +++ b/application/front/controller/visitor/FeedController.php @@ -46,10 +46,10 @@ class FeedController extends ShaarliVisitorController $data = $this->container->feedBuilder->buildData($feedType, $request->getParams()); - $this->executePageHooks('render_feed', $data, $feedType); + $this->executePageHooks('render_feed', $data, 'feed.' . $feedType); $this->assignAllView($data); - $content = $this->render('feed.'. $feedType); + $content = $this->render('feed.' . $feedType); $cache->cache($content); diff --git a/application/legacy/LegacyRouter.php b/application/legacy/LegacyRouter.php index cea99154..0449c7e1 100644 --- a/application/legacy/LegacyRouter.php +++ b/application/legacy/LegacyRouter.php @@ -17,15 +17,15 @@ class LegacyRouter public static $PAGE_PICWALL = 'picwall'; - public static $PAGE_TAGCLOUD = 'tagcloud'; + public static $PAGE_TAGCLOUD = 'tag.cloud'; - public static $PAGE_TAGLIST = 'taglist'; + public static $PAGE_TAGLIST = 'tag.list'; public static $PAGE_DAILY = 'daily'; - public static $PAGE_FEED_ATOM = 'atom'; + public static $PAGE_FEED_ATOM = 'feed.atom'; - public static $PAGE_FEED_RSS = 'rss'; + public static $PAGE_FEED_RSS = 'feed.rss'; public static $PAGE_TOOLS = 'tools'; @@ -37,7 +37,7 @@ class LegacyRouter public static $PAGE_ADDLINK = 'addlink'; - public static $PAGE_EDITLINK = 'edit_link'; + public static $PAGE_EDITLINK = 'editlink'; public static $PAGE_DELETELINK = 'delete_link'; @@ -60,128 +60,4 @@ class LegacyRouter public static $PAGE_THUMBS_UPDATE = 'thumbs_update'; public static $GET_TOKEN = 'token'; - - /** - * Reproducing renderPage() if hell, to avoid regression. - * - * This highlights how bad this needs to be rewrite, - * but let's focus on plugins for now. - * - * @param string $query $_SERVER['QUERY_STRING']. - * @param array $get $_SERVER['GET']. - * @param bool $loggedIn true if authenticated user. - * - * @return string page found. - */ - public static function findPage($query, $get, $loggedIn) - { - $loggedIn = ($loggedIn === true) ? true : false; - - if (empty($query) && !isset($get['edit_link']) && !isset($get['post'])) { - return self::$PAGE_LINKLIST; - } - - if (startsWith($query, 'do=' . self::$PAGE_LOGIN) && $loggedIn === false) { - return self::$PAGE_LOGIN; - } - - if (startsWith($query, 'do=' . self::$PAGE_PICWALL)) { - return self::$PAGE_PICWALL; - } - - if (startsWith($query, 'do=' . self::$PAGE_TAGCLOUD)) { - return self::$PAGE_TAGCLOUD; - } - - if (startsWith($query, 'do=' . self::$PAGE_TAGLIST)) { - return self::$PAGE_TAGLIST; - } - - if (startsWith($query, 'do=' . self::$PAGE_OPENSEARCH)) { - return self::$PAGE_OPENSEARCH; - } - - if (startsWith($query, 'do=' . self::$PAGE_DAILY)) { - return self::$PAGE_DAILY; - } - - if (startsWith($query, 'do=' . self::$PAGE_FEED_ATOM)) { - return self::$PAGE_FEED_ATOM; - } - - if (startsWith($query, 'do=' . self::$PAGE_FEED_RSS)) { - return self::$PAGE_FEED_RSS; - } - - if (startsWith($query, 'do=' . self::$PAGE_THUMBS_UPDATE)) { - return self::$PAGE_THUMBS_UPDATE; - } - - if (startsWith($query, 'do=' . self::$AJAX_THUMB_UPDATE)) { - return self::$AJAX_THUMB_UPDATE; - } - - // At this point, only loggedin pages. - if (!$loggedIn) { - return self::$PAGE_LINKLIST; - } - - if (startsWith($query, 'do=' . self::$PAGE_TOOLS)) { - return self::$PAGE_TOOLS; - } - - if (startsWith($query, 'do=' . self::$PAGE_CHANGEPASSWORD)) { - return self::$PAGE_CHANGEPASSWORD; - } - - if (startsWith($query, 'do=' . self::$PAGE_CONFIGURE)) { - return self::$PAGE_CONFIGURE; - } - - if (startsWith($query, 'do=' . self::$PAGE_CHANGETAG)) { - return self::$PAGE_CHANGETAG; - } - - if (startsWith($query, 'do=' . self::$PAGE_ADDLINK)) { - return self::$PAGE_ADDLINK; - } - - if (isset($get['edit_link']) || isset($get['post'])) { - return self::$PAGE_EDITLINK; - } - - if (isset($get['delete_link'])) { - return self::$PAGE_DELETELINK; - } - - if (isset($get[self::$PAGE_CHANGE_VISIBILITY])) { - return self::$PAGE_CHANGE_VISIBILITY; - } - - if (startsWith($query, 'do=' . self::$PAGE_PINLINK)) { - return self::$PAGE_PINLINK; - } - - if (startsWith($query, 'do=' . self::$PAGE_EXPORT)) { - return self::$PAGE_EXPORT; - } - - if (startsWith($query, 'do=' . self::$PAGE_IMPORT)) { - return self::$PAGE_IMPORT; - } - - if (startsWith($query, 'do=' . self::$PAGE_PLUGINSADMIN)) { - return self::$PAGE_PLUGINSADMIN; - } - - if (startsWith($query, 'do=' . self::$PAGE_SAVE_PLUGINSADMIN)) { - return self::$PAGE_SAVE_PLUGINSADMIN; - } - - if (startsWith($query, 'do=' . self::$GET_TOKEN)) { - return self::$GET_TOKEN; - } - - return self::$PAGE_LINKLIST; - } } -- cgit v1.2.3 From 80b708a8780b91b092c3a372342959eeca742802 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Tue, 1 Sep 2020 13:33:50 +0200 Subject: Inject BookmarkServiceInterface in plugins data Related discussion: ilesinge/shaarli-related#7 --- .../controller/admin/ShaarliAdminController.php | 2 -- .../visitor/ShaarliVisitorController.php | 26 ++++++++++++---------- application/plugin/PluginManager.php | 4 ++++ 3 files changed, 18 insertions(+), 14 deletions(-) (limited to 'application') diff --git a/application/front/controller/admin/ShaarliAdminController.php b/application/front/controller/admin/ShaarliAdminController.php index 3b5939bb..c26c9cbe 100644 --- a/application/front/controller/admin/ShaarliAdminController.php +++ b/application/front/controller/admin/ShaarliAdminController.php @@ -4,9 +4,7 @@ declare(strict_types=1); namespace Shaarli\Front\Controller\Admin; -use Shaarli\Container\ShaarliContainer; use Shaarli\Front\Controller\Visitor\ShaarliVisitorController; -use Shaarli\Front\Exception\UnauthorizedException; use Shaarli\Front\Exception\WrongTokenException; use Shaarli\Security\SessionManager; use Slim\Http\Request; diff --git a/application/front/controller/visitor/ShaarliVisitorController.php b/application/front/controller/visitor/ShaarliVisitorController.php index f17c8ed3..cd27455b 100644 --- a/application/front/controller/visitor/ShaarliVisitorController.php +++ b/application/front/controller/visitor/ShaarliVisitorController.php @@ -78,16 +78,14 @@ abstract class ShaarliVisitorController 'footer', ]; + $parameters = $this->buildPluginParameters($template); + foreach ($common_hooks as $name) { $pluginData = []; $this->container->pluginManager->executeHooks( 'render_' . $name, $pluginData, - [ - 'target' => $template, - 'loggedin' => $this->container->loginManager->isLoggedIn(), - 'basePath' => $this->container->basePath, - ] + $parameters ); $this->assignView('plugins_' . $name, $pluginData); } @@ -95,19 +93,23 @@ abstract class ShaarliVisitorController protected function executePageHooks(string $hook, array &$data, string $template = null): void { - $params = [ - 'target' => $template, - 'loggedin' => $this->container->loginManager->isLoggedIn(), - 'basePath' => $this->container->basePath, - ]; - $this->container->pluginManager->executeHooks( $hook, $data, - $params + $this->buildPluginParameters($template) ); } + protected function buildPluginParameters(?string $template): array + { + return [ + 'target' => $template, + 'loggedin' => $this->container->loginManager->isLoggedIn(), + 'basePath' => $this->container->basePath, + 'bookmarkService' => $this->container->bookmarkService + ]; + } + /** * Simple helper which prepend the base path to redirect path. * diff --git a/application/plugin/PluginManager.php b/application/plugin/PluginManager.php index 2d93cb3a..7881e3be 100644 --- a/application/plugin/PluginManager.php +++ b/application/plugin/PluginManager.php @@ -112,6 +112,10 @@ class PluginManager $data['_BASE_PATH_'] = $params['basePath']; } + if (isset($params['bookmarkService'])) { + $data['_BOOKMARK_SERVICE_'] = $params['bookmarkService']; + } + foreach ($this->loadedPlugins as $plugin) { $hookFunction = $this->buildHookName($hook, $plugin); -- cgit v1.2.3 From d33cffdb2e195be118d99342aa42f1d15a186f27 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Thu, 3 Sep 2020 18:46:10 +0200 Subject: Fix: encoding in legacy route login redirection to post bookmark When a bookmark is post from a logged out user, he is first redirected to the login page with 'returnurl' containing the link, then redirected again when the login is processed. We need to reencode the posted URL, otherwise the browser does not handle the fragment as a part of the posted parameter. --- application/legacy/LegacyController.php | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) (limited to 'application') diff --git a/application/legacy/LegacyController.php b/application/legacy/LegacyController.php index e16dd0f4..826604e7 100644 --- a/application/legacy/LegacyController.php +++ b/application/legacy/LegacyController.php @@ -39,13 +39,23 @@ class LegacyController extends ShaarliVisitorController /** Legacy route: ?post= */ public function post(Request $request, Response $response): Response { - $parameters = count($request->getQueryParams()) > 0 ? '?' . http_build_query($request->getQueryParams()) : ''; $route = '/admin/shaare'; + $buildParameters = function (?array $parameters, bool $encode) { + if ($encode) { + $parameters = array_map('urlencode', $parameters); + } + + return count($parameters) > 0 ? '?' . http_build_query($parameters) : ''; + }; + if (!$this->container->loginManager->isLoggedIn()) { + $parameters = $buildParameters($request->getQueryParams(), true); return $this->redirect($response, '/login?returnurl='. $this->getBasePath() . $route . $parameters); } + $parameters = $buildParameters($request->getQueryParams(), false); + return $this->redirect($response, $route . $parameters); } -- cgit v1.2.3 From 27ddfec3c3847f10ab0de246f4a174b751c5f19e Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Sun, 6 Sep 2020 14:11:02 +0200 Subject: Fix visibility issue on daily page This filter (links by day) didn't apply any visibility parameter. Fixes #1543 --- application/bookmark/BookmarkFileService.php | 4 +++- application/bookmark/BookmarkFilter.php | 17 +++++++++++------ 2 files changed, 14 insertions(+), 7 deletions(-) (limited to 'application') diff --git a/application/bookmark/BookmarkFileService.php b/application/bookmark/BookmarkFileService.php index e3a61146..c9ec2609 100644 --- a/application/bookmark/BookmarkFileService.php +++ b/application/bookmark/BookmarkFileService.php @@ -362,7 +362,9 @@ class BookmarkFileService implements BookmarkServiceInterface */ public function filterDay($request) { - return $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_DAY, $request); + $visibility = $this->isLoggedIn ? BookmarkFilter::$ALL : BookmarkFilter::$PUBLIC; + + return $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_DAY, $request, false, $visibility); } /** diff --git a/application/bookmark/BookmarkFilter.php b/application/bookmark/BookmarkFilter.php index 797a36b8..6636bbfe 100644 --- a/application/bookmark/BookmarkFilter.php +++ b/application/bookmark/BookmarkFilter.php @@ -115,7 +115,7 @@ class BookmarkFilter return $this->filterTags($request, $casesensitive, $visibility); } case self::$FILTER_DAY: - return $this->filterDay($request); + return $this->filterDay($request, $visibility); default: return $this->noFilter($visibility); } @@ -425,21 +425,26 @@ class BookmarkFilter * print_r($mydb->filterDay('20120125')); * * @param string $day day to filter. - * + * @param string $visibility return only all/private/public bookmarks. + * @return array all link matching given day. * * @throws Exception if date format is invalid. */ - public function filterDay($day) + public function filterDay($day, $visibility) { if (!checkDateFormat('Ymd', $day)) { throw new Exception('Invalid date format'); } $filtered = []; - foreach ($this->bookmarks as $key => $l) { - if ($l->getCreated()->format('Ymd') == $day) { - $filtered[$key] = $l; + foreach ($this->bookmarks as $key => $bookmark) { + if ($visibility === static::$PUBLIC && $bookmark->isPrivate()) { + continue; + } + + if ($bookmark->getCreated()->format('Ymd') == $day) { + $filtered[$key] = $bookmark; } } -- cgit v1.2.3 From da7acb98302b99ec729bcde3e3c9f4bb164a1b34 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Sun, 6 Sep 2020 13:42:45 +0200 Subject: Improve default bookmarks after install Used @nodiscc suggestion in #1148 (slightly edited). It provides a description of what Shaarli does, Markdown rendering demo, and a thumbnail link. Fixes #1148 --- application/bookmark/BookmarkInitializer.php | 74 +++++++++++++++++++++++----- 1 file changed, 63 insertions(+), 11 deletions(-) (limited to 'application') diff --git a/application/bookmark/BookmarkInitializer.php b/application/bookmark/BookmarkInitializer.php index cd2d1724..815047e3 100644 --- a/application/bookmark/BookmarkInitializer.php +++ b/application/bookmark/BookmarkInitializer.php @@ -34,25 +34,77 @@ class BookmarkInitializer public function initialize() { $bookmark = new Bookmark(); - $bookmark->setTitle(t('My secret stuff... - Pastebin.com')); - $bookmark->setUrl('http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8='); - $bookmark->setDescription(t('Shhhh! I\'m a private link only YOU can see. You can delete me too.')); - $bookmark->setTagsString('secretstuff'); + $bookmark->setTitle('quicksilver (loop) on Vimeo ' . t('(private bookmark with thumbnail demo)')); + $bookmark->setUrl('https://vimeo.com/153493904'); + $bookmark->setDescription(t( +'Shaarli will automatically pick up the thumbnail for links to a variety of websites. + +Explore your new Shaarli instance by trying out controls and menus. +Visit the project on [Github](https://github.com/shaarli/Shaarli) or [the documentation](https://shaarli.readthedocs.io/en/master/) to learn more about Shaarli. + +Now you can edit or delete the default shaares. +' + )); + $bookmark->setTagsString('shaarli help thumbnail'); + $bookmark->setPrivate(true); + $this->bookmarkService->add($bookmark, false); + + $bookmark = new Bookmark(); + $bookmark->setTitle(t('Note: Shaare descriptions')); + $bookmark->setDescription(t( +'Adding a shaare without entering a URL creates a text-only "note" post such as this one. +This note is private, so you are the only one able to see it while logged in. + +You can use this to keep notes, post articles, code snippets, and much more. + +The Markdown formatting setting allows you to format your notes and bookmark description: + +### Title headings + +#### Multiple headings levels + * bullet lists + * _italic_ text + * **bold** text + * ~~strike through~~ text + * `code` blocks + * images + * [links](https://en.wikipedia.org/wiki/Markdown) + +Markdown also supports tables: + +| Name | Type | Color | Qty | +| ------- | --------- | ------ | ----- | +| Orange | Fruit | Orange | 126 | +| Apple | Fruit | Any | 62 | +| Lemon | Fruit | Yellow | 30 | +| Carrot | Vegetable | Red | 14 | +' + )); + $bookmark->setTagsString('shaarli help'); $bookmark->setPrivate(true); $this->bookmarkService->add($bookmark, false); $bookmark = new Bookmark(); - $bookmark->setTitle(t('The personal, minimalist, super-fast, database free, bookmarking service')); - $bookmark->setUrl('https://shaarli.readthedocs.io', []); + $bookmark->setTitle( + 'Shaarli - ' . t('The personal, minimalist, super-fast, database free, bookmarking service') + ); $bookmark->setDescription(t( - 'Welcome to Shaarli! This is your first public bookmark. ' - . 'To edit or delete me, you must first login. +'Welcome to Shaarli! + +Shaarli allows you to bookmark your favorite pages, and share them with others or store them privately. +You can add a description to your bookmarks, such as this one, and tag them. + +Create a new shaare by clicking the `+Shaare` button, or using any of the recommended tools (browser extension, mobile app, bookmarklet, REST API, etc.). -To learn how to use Shaarli, consult the link "Documentation" at the bottom of this page. +You can easily retrieve your links, even with thousands of them, using the internal search engine, or search through tags (e.g. this Shaare is tagged with `shaarli` and `help`). +Hashtags such as #shaarli #help are also supported. +You can also filter the available [RSS feed](/feed/atom) and picture wall by tag or plaintext search. -You use the community supported version of the original Shaarli project, by Sebastien Sauvage.' +We hope that you will enjoy using Shaarli, maintained with ❤️ by the community! +Feel free to open [an issue](https://github.com/shaarli/Shaarli/issues) if you have a suggestion or encounter an issue. +' )); - $bookmark->setTagsString('opensource software'); + $bookmark->setTagsString('shaarli help'); $this->bookmarkService->add($bookmark, false); } } -- cgit v1.2.3 From d52ab0b1e99aa0c494f389092dce1e926296032d Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Sat, 12 Sep 2020 12:42:19 +0200 Subject: Properly handle 404 errors Use 404 template instead of default Slim error page if the route is not found. Fixes #827 --- application/container/ContainerBuilder.php | 4 +++ application/container/ShaarliContainer.php | 9 ++++--- .../controller/visitor/ErrorNotFoundController.php | 29 ++++++++++++++++++++++ 3 files changed, 38 insertions(+), 4 deletions(-) create mode 100644 application/front/controller/visitor/ErrorNotFoundController.php (limited to 'application') diff --git a/application/container/ContainerBuilder.php b/application/container/ContainerBuilder.php index 58067c99..55bb51b5 100644 --- a/application/container/ContainerBuilder.php +++ b/application/container/ContainerBuilder.php @@ -10,6 +10,7 @@ use Shaarli\Config\ConfigManager; use Shaarli\Feed\FeedBuilder; use Shaarli\Formatter\FormatterFactory; use Shaarli\Front\Controller\Visitor\ErrorController; +use Shaarli\Front\Controller\Visitor\ErrorNotFoundController; use Shaarli\History; use Shaarli\Http\HttpAccess; use Shaarli\Netscape\NetscapeBookmarkUtils; @@ -149,6 +150,9 @@ class ContainerBuilder ); }; + $container['notFoundHandler'] = function (ShaarliContainer $container): ErrorNotFoundController { + return new ErrorNotFoundController($container); + }; $container['errorHandler'] = function (ShaarliContainer $container): ErrorController { return new ErrorController($container); }; diff --git a/application/container/ShaarliContainer.php b/application/container/ShaarliContainer.php index 9a9a974a..66e669aa 100644 --- a/application/container/ShaarliContainer.php +++ b/application/container/ShaarliContainer.php @@ -24,21 +24,22 @@ use Slim\Container; /** * Extension of Slim container to document the injected objects. * - * @property string $basePath Shaarli's instance base path (e.g. `/shaarli/`) + * @property string $basePath Shaarli's instance base path (e.g. `/shaarli/`) * @property BookmarkServiceInterface $bookmarkService * @property CookieManager $cookieManager * @property ConfigManager $conf - * @property mixed[] $environment $_SERVER automatically injected by Slim - * @property callable $errorHandler Overrides default Slim exception display + * @property mixed[] $environment $_SERVER automatically injected by Slim + * @property callable $errorHandler Overrides default Slim exception display * @property FeedBuilder $feedBuilder * @property FormatterFactory $formatterFactory * @property History $history * @property HttpAccess $httpAccess * @property LoginManager $loginManager * @property NetscapeBookmarkUtils $netscapeBookmarkUtils + * @property callable $notFoundHandler Overrides default Slim exception display * @property PageBuilder $pageBuilder * @property PageCacheManager $pageCacheManager - * @property callable $phpErrorHandler Overrides default Slim PHP error display + * @property callable $phpErrorHandler Overrides default Slim PHP error display * @property PluginManager $pluginManager * @property SessionManager $sessionManager * @property Thumbnailer $thumbnailer diff --git a/application/front/controller/visitor/ErrorNotFoundController.php b/application/front/controller/visitor/ErrorNotFoundController.php new file mode 100644 index 00000000..758dd83b --- /dev/null +++ b/application/front/controller/visitor/ErrorNotFoundController.php @@ -0,0 +1,29 @@ +getRequestTarget(), '/api/v1')) { + return $response->withStatus(404); + } + + // This is required because the middleware is ignored if the route is not found. + $this->container->basePath = rtrim($request->getUri()->getBasePath(), '/'); + + $this->assignView('error_message', t('Requested page could not be found.')); + + return $response->withStatus(404)->write($this->render('404')); + } +} -- cgit v1.2.3 From 4ff703e3691e6cb398e8d208c1f54ed61315e0e8 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Thu, 10 Sep 2020 14:08:19 +0200 Subject: Plugins: do not save metadata along plugin parameters Also prevent the token to be saved. Fixes #1550 --- .../front/controller/admin/PluginsController.php | 1 + application/plugin/PluginManager.php | 29 +++++++++++----------- 2 files changed, 16 insertions(+), 14 deletions(-) (limited to 'application') diff --git a/application/front/controller/admin/PluginsController.php b/application/front/controller/admin/PluginsController.php index 0e09116e..8e059681 100644 --- a/application/front/controller/admin/PluginsController.php +++ b/application/front/controller/admin/PluginsController.php @@ -62,6 +62,7 @@ class PluginsController extends ShaarliAdminController if (isset($parameters['parameters_form'])) { unset($parameters['parameters_form']); + unset($parameters['token']); foreach ($parameters as $param => $value) { $this->container->conf->set('plugins.'. $param, escape($value)); } diff --git a/application/plugin/PluginManager.php b/application/plugin/PluginManager.php index 7881e3be..1b2197c9 100644 --- a/application/plugin/PluginManager.php +++ b/application/plugin/PluginManager.php @@ -100,20 +100,17 @@ class PluginManager */ public function executeHooks($hook, &$data, $params = array()) { - if (!empty($params['target'])) { - $data['_PAGE_'] = $params['target']; - } - - if (isset($params['loggedin'])) { - $data['_LOGGEDIN_'] = $params['loggedin']; - } - - if (isset($params['basePath'])) { - $data['_BASE_PATH_'] = $params['basePath']; - } - - if (isset($params['bookmarkService'])) { - $data['_BOOKMARK_SERVICE_'] = $params['bookmarkService']; + $metadataParameters = [ + 'target' => '_PAGE_', + 'loggedin' => '_LOGGEDIN_', + 'basePath' => '_BASE_PATH_', + 'bookmarkService' => '_BOOKMARK_SERVICE_', + ]; + + foreach ($metadataParameters as $parameter => $metaKey) { + if (array_key_exists($parameter, $params)) { + $data[$metaKey] = $params[$parameter]; + } } foreach ($this->loadedPlugins as $plugin) { @@ -128,6 +125,10 @@ class PluginManager } } } + + foreach ($metadataParameters as $metaKey) { + unset($data[$metaKey]); + } } /** -- cgit v1.2.3 From 650a5f09cbeb1c1bef19810c6cc504c06d5b7e87 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Thu, 3 Sep 2020 14:51:41 +0200 Subject: Add manual configuration for root URL This new setting under 'general.root_url' allows to override automatic discovery of Shaarli instance's URL. Fixes #1339 --- application/feed/FeedBuilder.php | 6 +++--- application/http/HttpUtils.php | 8 ++++++-- 2 files changed, 9 insertions(+), 5 deletions(-) (limited to 'application') diff --git a/application/feed/FeedBuilder.php b/application/feed/FeedBuilder.php index 3653c32f..f6def630 100644 --- a/application/feed/FeedBuilder.php +++ b/application/feed/FeedBuilder.php @@ -122,9 +122,9 @@ class FeedBuilder $data['language'] = $this->getTypeLanguage($feedType); $data['last_update'] = $this->getLatestDateFormatted($feedType); $data['show_dates'] = !$this->hideDates || $this->isLoggedIn; - // Remove leading slash from REQUEST_URI. - $data['self_link'] = escape(server_url($this->serverInfo)) - . escape($this->serverInfo['REQUEST_URI']); + // Remove leading path from REQUEST_URI (already contained in $pageaddr). + $requestUri = preg_replace('#(.*?/)(feed.*)#', '$2', escape($this->serverInfo['REQUEST_URI'])); + $data['self_link'] = $pageaddr . $requestUri; $data['index_url'] = $pageaddr; $data['usepermalinks'] = $this->usePermalinks === true; $data['links'] = $linkDisplayed; diff --git a/application/http/HttpUtils.php b/application/http/HttpUtils.php index 4fc4e3dc..9f414073 100644 --- a/application/http/HttpUtils.php +++ b/application/http/HttpUtils.php @@ -369,7 +369,11 @@ function server_url($server) */ function index_url($server) { - $scriptname = $server['SCRIPT_NAME'] ?? ''; + if (defined('SHAARLI_ROOT_URL') && null !== SHAARLI_ROOT_URL) { + return rtrim(SHAARLI_ROOT_URL, '/') . '/'; + } + + $scriptname = !empty($server['SCRIPT_NAME']) ? $server['SCRIPT_NAME'] : '/'; if (endsWith($scriptname, 'index.php')) { $scriptname = substr($scriptname, 0, -9); } @@ -392,7 +396,7 @@ function page_url($server) $scriptname = substr($scriptname, 0, -9); } - $route = ltrim($server['REQUEST_URI'] ?? '', $scriptname); + $route = preg_replace('@^' . $scriptname . '@', '', $server['REQUEST_URI'] ?? ''); if (! empty($server['QUERY_STRING'])) { return index_url($server) . $route . '?' . $server['QUERY_STRING']; } -- cgit v1.2.3 From b93cfeba7b5ddb8b20d805017404e73eafd68c95 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Thu, 3 Sep 2020 14:52:34 +0200 Subject: Fix subfolder configuration in unit tests --- application/front/controller/visitor/DailyController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'application') diff --git a/application/front/controller/visitor/DailyController.php b/application/front/controller/visitor/DailyController.php index 54a4778f..07617cf1 100644 --- a/application/front/controller/visitor/DailyController.php +++ b/application/front/controller/visitor/DailyController.php @@ -132,7 +132,7 @@ class DailyController extends ShaarliVisitorController 'date' => $dayDatetime, 'date_rss' => $dayDatetime->format(DateTime::RSS), 'date_human' => format_date($dayDatetime, false, true), - 'absolute_url' => $indexUrl . '/daily?day=' . $day, + 'absolute_url' => $indexUrl . 'daily?day=' . $day, 'links' => [], ]; -- cgit v1.2.3 From 2785d85e0a7e952fe2de349659b36091fd5f1d51 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Tue, 22 Sep 2020 14:04:10 +0200 Subject: Fix redirection to referer after editing a link Fixes #1545 --- application/front/controller/admin/ManageShaareController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'application') diff --git a/application/front/controller/admin/ManageShaareController.php b/application/front/controller/admin/ManageShaareController.php index 33e1188e..ca2da9b5 100644 --- a/application/front/controller/admin/ManageShaareController.php +++ b/application/front/controller/admin/ManageShaareController.php @@ -169,7 +169,7 @@ class ManageShaareController extends ShaarliAdminController return $this->redirectFromReferer( $request, $response, - ['add-shaare', 'shaare'], ['addlink', 'post', 'edit_link'], + ['/admin/add-shaare', '/admin/shaare'], ['addlink', 'post', 'edit_link'], $bookmark->getShortUrl() ); } -- cgit v1.2.3 From abe033be855f76fde9e8576ce36460fbb23b1e57 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Tue, 22 Sep 2020 15:17:13 +0200 Subject: Fix invalid redirection using the path of an external domain Fixes #1554 --- application/front/controller/visitor/ShaarliVisitorController.php | 7 +++++++ 1 file changed, 7 insertions(+) (limited to 'application') diff --git a/application/front/controller/visitor/ShaarliVisitorController.php b/application/front/controller/visitor/ShaarliVisitorController.php index cd27455b..55c075a2 100644 --- a/application/front/controller/visitor/ShaarliVisitorController.php +++ b/application/front/controller/visitor/ShaarliVisitorController.php @@ -142,6 +142,13 @@ abstract class ShaarliVisitorController if (null !== $referer) { $currentUrl = parse_url($referer); + // If the referer is not related to Shaarli instance, redirect to default + if (isset($currentUrl['host']) + && strpos(index_url($this->container->environment), $currentUrl['host']) === false + ) { + return $response->withRedirect($defaultPath); + } + parse_str($currentUrl['query'] ?? '', $params); $path = $currentUrl['path'] ?? $defaultPath; } else { -- cgit v1.2.3 From 676571dab927b0fb9b3746c36f0d7540e8dba2b5 Mon Sep 17 00:00:00 2001 From: Christoph Stoettner Date: Tue, 29 Sep 2020 12:15:04 +0200 Subject: Workaround for hoster (ionos) The hoster writes the environment variable with bearer token to REDIRECT_HTTP_AUTHORIZATION and needs to provide RewriteBase / to .htaccess --- application/api/ApiMiddleware.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) (limited to 'application') diff --git a/application/api/ApiMiddleware.php b/application/api/ApiMiddleware.php index 09ce6445..da730e0c 100644 --- a/application/api/ApiMiddleware.php +++ b/application/api/ApiMiddleware.php @@ -107,7 +107,7 @@ class ApiMiddleware */ protected function checkToken($request) { - if (! $request->hasHeader('Authorization')) { + if (! $request->hasHeader('Authorization') && !isset($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) { throw new ApiAuthorizationException('JWT token not provided'); } @@ -115,7 +115,11 @@ class ApiMiddleware throw new ApiAuthorizationException('Token secret must be set in Shaarli\'s administration'); } - $authorization = $request->getHeaderLine('Authorization'); + if (isset($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) { + $authorization = $_SERVER['REDIRECT_HTTP_AUTHORIZATION']; + } else { + $authorization = $request->getHeaderLine('Authorization'); + } if (! preg_match('/^Bearer (.*)/i', $authorization, $matches)) { throw new ApiAuthorizationException('Invalid JWT header'); -- cgit v1.2.3 From ab58f2542072e6bf34acd862f6cfed84b33feb29 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Tue, 29 Sep 2020 15:00:11 +0200 Subject: Compatibility with PHP 8 --- application/config/ConfigJson.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'application') diff --git a/application/config/ConfigJson.php b/application/config/ConfigJson.php index 4509357c..c0c0dab9 100644 --- a/application/config/ConfigJson.php +++ b/application/config/ConfigJson.php @@ -46,7 +46,7 @@ class ConfigJson implements ConfigIO // JSON_PRETTY_PRINT is available from PHP 5.4. $print = defined('JSON_PRETTY_PRINT') ? JSON_PRETTY_PRINT : 0; $data = self::getPhpHeaders() . json_encode($conf, $print) . self::getPhpSuffix(); - if (!file_put_contents($filepath, $data)) { + if (empty($filepath) || !file_put_contents($filepath, $data)) { throw new \Shaarli\Exceptions\IOException( $filepath, t('Shaarli could not create the config file. '. -- cgit v1.2.3 From 1ea09a1b8b8b7f68ec8c7ef069393ee58a0e623a Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Sat, 26 Sep 2020 13:28:38 +0200 Subject: Fix warning if the encoding retrieved from external headers is invalid Also fixed the regex to support this failing header: charset="utf-8"\r\n" --- application/bookmark/LinkUtils.php | 2 +- application/front/controller/admin/ManageShaareController.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) (limited to 'application') diff --git a/application/bookmark/LinkUtils.php b/application/bookmark/LinkUtils.php index 68914fca..e7af4d55 100644 --- a/application/bookmark/LinkUtils.php +++ b/application/bookmark/LinkUtils.php @@ -26,7 +26,7 @@ function html_extract_title($html) */ function header_extract_charset($header) { - preg_match('/charset="?([^; ]+)/i', $header, $match); + preg_match('/charset=["\']?([^; "\']+)/i', $header, $match); if (! empty($match[1])) { return strtolower(trim($match[1])); } diff --git a/application/front/controller/admin/ManageShaareController.php b/application/front/controller/admin/ManageShaareController.php index ca2da9b5..ffb0dae4 100644 --- a/application/front/controller/admin/ManageShaareController.php +++ b/application/front/controller/admin/ManageShaareController.php @@ -69,7 +69,7 @@ class ManageShaareController extends ShaarliAdminController $retrieveDescription ) ); - if (! empty($title) && strtolower($charset) !== 'utf-8') { + if (! empty($title) && strtolower($charset) !== 'utf-8' && mb_check_encoding($charset)) { $title = mb_convert_encoding($title, 'utf-8', $charset); } } -- cgit v1.2.3 From d8ef4a893fc899471d415c4aa0bb1cad9ab9c2dc Mon Sep 17 00:00:00 2001 From: Christoph Stoettner Date: Wed, 30 Sep 2020 12:27:44 +0200 Subject: Change to ->container->environment --- application/api/ApiMiddleware.php | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) (limited to 'application') diff --git a/application/api/ApiMiddleware.php b/application/api/ApiMiddleware.php index da730e0c..f4a71f7c 100644 --- a/application/api/ApiMiddleware.php +++ b/application/api/ApiMiddleware.php @@ -3,7 +3,6 @@ namespace Shaarli\Api; use Shaarli\Api\Exceptions\ApiAuthorizationException; use Shaarli\Api\Exceptions\ApiException; -use Shaarli\Bookmark\BookmarkFileService; use Shaarli\Config\ConfigManager; use Slim\Container; use Slim\Http\Request; @@ -71,14 +70,7 @@ class ApiMiddleware $response = $e->getApiResponse(); } - return $response - ->withHeader('Access-Control-Allow-Origin', '*') - ->withHeader( - 'Access-Control-Allow-Headers', - 'X-Requested-With, Content-Type, Accept, Origin, Authorization' - ) - ->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') - ; + return $response; } /** @@ -107,16 +99,16 @@ class ApiMiddleware */ protected function checkToken($request) { - if (! $request->hasHeader('Authorization') && !isset($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) { + if (! $request->hasHeader('Authorization') && !isset($this->container->environment['REDIRECT_HTTP_AUTHORIZATION'])) { throw new ApiAuthorizationException('JWT token not provided'); } - + if (empty($this->conf->get('api.secret'))) { throw new ApiAuthorizationException('Token secret must be set in Shaarli\'s administration'); } - if (isset($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) { - $authorization = $_SERVER['REDIRECT_HTTP_AUTHORIZATION']; + if (isset($this->container->environment['REDIRECT_HTTP_AUTHORIZATION'])) { + $authorization = $this->container->environment['REDIRECT_HTTP_AUTHORIZATION']; } else { $authorization = $request->getHeaderLine('Authorization'); } @@ -129,7 +121,7 @@ class ApiMiddleware } /** - * Instantiate a new LinkDB including private bookmarks, + * Instantiate a new LinkDB including private links, * and load in the Slim container. * * FIXME! LinkDB could use a refactoring to avoid this trick. @@ -138,10 +130,10 @@ class ApiMiddleware */ protected function setLinkDb($conf) { - $linkDb = new BookmarkFileService( - $conf, - $this->container->get('history'), - true + $linkDb = new \Shaarli\Bookmark\LinkDB( + $conf->get('resource.datastore'), + true, + $conf->get('privacy.hide_public_links') ); $this->container['db'] = $linkDb; } -- cgit v1.2.3 From 25cb75552baaad62b093b0b38156fcb15dca7826 Mon Sep 17 00:00:00 2001 From: Christoph Stoettner Date: Wed, 30 Sep 2020 12:29:54 +0200 Subject: Fix identation --- application/api/ApiMiddleware.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) (limited to 'application') diff --git a/application/api/ApiMiddleware.php b/application/api/ApiMiddleware.php index f4a71f7c..7f1e7fca 100644 --- a/application/api/ApiMiddleware.php +++ b/application/api/ApiMiddleware.php @@ -102,16 +102,16 @@ class ApiMiddleware if (! $request->hasHeader('Authorization') && !isset($this->container->environment['REDIRECT_HTTP_AUTHORIZATION'])) { throw new ApiAuthorizationException('JWT token not provided'); } - + if (empty($this->conf->get('api.secret'))) { throw new ApiAuthorizationException('Token secret must be set in Shaarli\'s administration'); } - if (isset($this->container->environment['REDIRECT_HTTP_AUTHORIZATION'])) { - $authorization = $this->container->environment['REDIRECT_HTTP_AUTHORIZATION']; - } else { + if (isset($this->container->environment['REDIRECT_HTTP_AUTHORIZATION'])) { + $authorization = $this->container->environment['REDIRECT_HTTP_AUTHORIZATION']; + } else { $authorization = $request->getHeaderLine('Authorization'); - } + } if (! preg_match('/^Bearer (.*)/i', $authorization, $matches)) { throw new ApiAuthorizationException('Invalid JWT header'); -- cgit v1.2.3 From 80a3efe11677b1420a7bc45d9b623c2df24cdd79 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Wed, 30 Sep 2020 15:31:34 +0200 Subject: Fix a bug preventing to edit bookmark with ID #0 --- application/front/controller/admin/ManageShaareController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'application') diff --git a/application/front/controller/admin/ManageShaareController.php b/application/front/controller/admin/ManageShaareController.php index ffb0dae4..59ba2de9 100644 --- a/application/front/controller/admin/ManageShaareController.php +++ b/application/front/controller/admin/ManageShaareController.php @@ -127,7 +127,7 @@ class ManageShaareController extends ShaarliAdminController $this->checkToken($request); // lf_id should only be present if the link exists. - $id = $request->getParam('lf_id') ? intval(escape($request->getParam('lf_id'))) : null; + $id = $request->getParam('lf_id') !== null ? intval(escape($request->getParam('lf_id'))) : null; if (null !== $id && true === $this->container->bookmarkService->exists($id)) { // Edit $bookmark = $this->container->bookmarkService->get($id); -- cgit v1.2.3 From 255b2264a119f4b8cc9fe211c7740906701e15b4 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Wed, 30 Sep 2020 15:57:57 +0200 Subject: Revert unrelated changes and add unit tests --- application/api/ApiMiddleware.php | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) (limited to 'application') diff --git a/application/api/ApiMiddleware.php b/application/api/ApiMiddleware.php index 7f1e7fca..f5b53b01 100644 --- a/application/api/ApiMiddleware.php +++ b/application/api/ApiMiddleware.php @@ -3,6 +3,7 @@ namespace Shaarli\Api; use Shaarli\Api\Exceptions\ApiAuthorizationException; use Shaarli\Api\Exceptions\ApiException; +use Shaarli\Bookmark\BookmarkFileService; use Shaarli\Config\ConfigManager; use Slim\Container; use Slim\Http\Request; @@ -70,7 +71,14 @@ class ApiMiddleware $response = $e->getApiResponse(); } - return $response; + return $response + ->withHeader('Access-Control-Allow-Origin', '*') + ->withHeader( + 'Access-Control-Allow-Headers', + 'X-Requested-With, Content-Type, Accept, Origin, Authorization' + ) + ->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') + ; } /** @@ -99,7 +107,9 @@ class ApiMiddleware */ protected function checkToken($request) { - if (! $request->hasHeader('Authorization') && !isset($this->container->environment['REDIRECT_HTTP_AUTHORIZATION'])) { + if (!$request->hasHeader('Authorization') + && !isset($this->container->environment['REDIRECT_HTTP_AUTHORIZATION']) + ) { throw new ApiAuthorizationException('JWT token not provided'); } @@ -121,7 +131,7 @@ class ApiMiddleware } /** - * Instantiate a new LinkDB including private links, + * Instantiate a new LinkDB including private bookmarks, * and load in the Slim container. * * FIXME! LinkDB could use a refactoring to avoid this trick. @@ -130,10 +140,10 @@ class ApiMiddleware */ protected function setLinkDb($conf) { - $linkDb = new \Shaarli\Bookmark\LinkDB( - $conf->get('resource.datastore'), - true, - $conf->get('privacy.hide_public_links') + $linkDb = new BookmarkFileService( + $conf, + $this->container->get('history'), + true ); $this->container['db'] = $linkDb; } -- cgit v1.2.3 From 72fbbcd6794facea2cf06d9742359d190257b00f Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Tue, 6 Oct 2020 17:30:18 +0200 Subject: Security: fix multiple XSS vulnerabilities + fix search tags with special chars XSS vulnerabilities fixed in editlink, linklist, tag.cloud and tag.list. Also fixed tag search with special characters: urlencode function needs to be applied on raw data, before espaping, otherwise the rendered URL is wrong. --- application/Utils.php | 4 ++-- application/formatter/BookmarkFormatter.php | 26 ++++++++++++++++++++++ .../controller/admin/ManageShaareController.php | 10 ++++----- .../front/controller/admin/ManageTagController.php | 4 ++-- .../controller/visitor/BookmarkListController.php | 7 +++--- .../controller/visitor/TagCloudController.php | 12 ++++++++-- application/render/PageBuilder.php | 2 +- 7 files changed, 50 insertions(+), 15 deletions(-) (limited to 'application') diff --git a/application/Utils.php b/application/Utils.php index 9c9eaaa2..bcfda65c 100644 --- a/application/Utils.php +++ b/application/Utils.php @@ -95,14 +95,14 @@ function escape($input) return null; } - if (is_bool($input)) { + if (is_bool($input) || is_int($input) || is_float($input) || $input instanceof DateTimeInterface) { return $input; } if (is_array($input)) { $out = array(); foreach ($input as $key => $value) { - $out[$key] = escape($value); + $out[escape($key)] = escape($value); } return $out; } diff --git a/application/formatter/BookmarkFormatter.php b/application/formatter/BookmarkFormatter.php index 22ba7aae..0042dafe 100644 --- a/application/formatter/BookmarkFormatter.php +++ b/application/formatter/BookmarkFormatter.php @@ -58,7 +58,9 @@ abstract class BookmarkFormatter $out['title'] = $this->formatTitle($bookmark); $out['description'] = $this->formatDescription($bookmark); $out['thumbnail'] = $this->formatThumbnail($bookmark); + $out['urlencoded_taglist'] = $this->formatUrlEncodedTagList($bookmark); $out['taglist'] = $this->formatTagList($bookmark); + $out['urlencoded_tags'] = $this->formatUrlEncodedTagString($bookmark); $out['tags'] = $this->formatTagString($bookmark); $out['sticky'] = $bookmark->isSticky(); $out['private'] = $bookmark->isPrivate(); @@ -181,6 +183,18 @@ abstract class BookmarkFormatter return $this->filterTagList($bookmark->getTags()); } + /** + * Format Url Encoded Tags + * + * @param Bookmark $bookmark instance + * + * @return array formatted Tags + */ + protected function formatUrlEncodedTagList($bookmark) + { + return array_map('urlencode', $this->filterTagList($bookmark->getTags())); + } + /** * Format TagString * @@ -193,6 +207,18 @@ abstract class BookmarkFormatter return implode(' ', $this->formatTagList($bookmark)); } + /** + * Format TagString + * + * @param Bookmark $bookmark instance + * + * @return string formatted TagString + */ + protected function formatUrlEncodedTagString($bookmark) + { + return implode(' ', $this->formatUrlEncodedTagList($bookmark)); + } + /** * Format Class * Used to add specific CSS class for a link diff --git a/application/front/controller/admin/ManageShaareController.php b/application/front/controller/admin/ManageShaareController.php index 59ba2de9..bb083486 100644 --- a/application/front/controller/admin/ManageShaareController.php +++ b/application/front/controller/admin/ManageShaareController.php @@ -78,13 +78,13 @@ class ManageShaareController extends ShaarliAdminController $title = $this->container->conf->get('general.default_note_title', t('Note: ')); } - $link = escape([ + $link = [ 'title' => $title, 'url' => $url ?? '', 'description' => $description ?? '', 'tags' => $tags ?? '', 'private' => $private, - ]); + ]; } else { $formatter = $this->container->formatterFactory->getFormatter('raw'); $link = $formatter->format($bookmark); @@ -345,14 +345,14 @@ class ManageShaareController extends ShaarliAdminController $tags[BookmarkMarkdownFormatter::NO_MD_TAG] = 1; } - $data = [ + $data = escape([ 'link' => $link, 'link_is_new' => $isNew, - 'http_referer' => escape($this->container->environment['HTTP_REFERER'] ?? ''), + 'http_referer' => $this->container->environment['HTTP_REFERER'] ?? '', 'source' => $request->getParam('source') ?? '', 'tags' => $tags, 'default_private_links' => $this->container->conf->get('privacy.default_private_links', false), - ]; + ]); $this->executePageHooks('render_editlink', $data, TemplatePage::EDIT_LINK); diff --git a/application/front/controller/admin/ManageTagController.php b/application/front/controller/admin/ManageTagController.php index 0380ef1f..2065c3e2 100644 --- a/application/front/controller/admin/ManageTagController.php +++ b/application/front/controller/admin/ManageTagController.php @@ -41,8 +41,8 @@ class ManageTagController extends ShaarliAdminController $isDelete = null !== $request->getParam('deletetag') && null === $request->getParam('renametag'); - $fromTag = escape(trim($request->getParam('fromtag') ?? '')); - $toTag = escape(trim($request->getParam('totag') ?? '')); + $fromTag = trim($request->getParam('fromtag') ?? ''); + $toTag = trim($request->getParam('totag') ?? ''); if (0 === strlen($fromTag) || false === $isDelete && 0 === strlen($toTag)) { $this->saveWarningMessage(t('Invalid tags provided.')); diff --git a/application/front/controller/visitor/BookmarkListController.php b/application/front/controller/visitor/BookmarkListController.php index 2988bee6..18368751 100644 --- a/application/front/controller/visitor/BookmarkListController.php +++ b/application/front/controller/visitor/BookmarkListController.php @@ -34,7 +34,7 @@ class BookmarkListController extends ShaarliVisitorController $formatter = $this->container->formatterFactory->getFormatter(); $formatter->addContextData('base_path', $this->container->basePath); - $searchTags = escape(normalize_spaces($request->getParam('searchtags') ?? '')); + $searchTags = normalize_spaces($request->getParam('searchtags') ?? ''); $searchTerm = escape(normalize_spaces($request->getParam('searchterm') ?? ''));; // Filter bookmarks according search parameters. @@ -104,8 +104,9 @@ class BookmarkListController extends ShaarliVisitorController 'page_current' => $page, 'page_max' => $pageCount, 'result_count' => count($linksToDisplay), - 'search_term' => $searchTerm, - 'search_tags' => $searchTags, + 'search_term' => escape($searchTerm), + 'search_tags' => escape($searchTags), + 'search_tags_url' => array_map('urlencode', explode(' ', $searchTags)), 'visibility' => $visibility, 'links' => $linkDisp, ] diff --git a/application/front/controller/visitor/TagCloudController.php b/application/front/controller/visitor/TagCloudController.php index f9c529bc..76ed7690 100644 --- a/application/front/controller/visitor/TagCloudController.php +++ b/application/front/controller/visitor/TagCloudController.php @@ -66,10 +66,18 @@ class TagCloudController extends ShaarliVisitorController $tags = $this->formatTagsForCloud($tags); } + $tagsUrl = []; + foreach ($tags as $tag => $value) { + $tagsUrl[escape($tag)] = urlencode((string) $tag); + } + $searchTags = implode(' ', escape($filteringTags)); + $searchTagsUrl = urlencode(implode(' ', $filteringTags)); $data = [ - 'search_tags' => $searchTags, - 'tags' => $tags, + 'search_tags' => escape($searchTags), + 'search_tags_url' => $searchTagsUrl, + 'tags' => escape($tags), + 'tags_url' => $tagsUrl, ]; $this->executePageHooks('render_tag' . $type, $data, 'tag.' . $type); $this->assignAllView($data); diff --git a/application/render/PageBuilder.php b/application/render/PageBuilder.php index c52e3b76..41b357dd 100644 --- a/application/render/PageBuilder.php +++ b/application/render/PageBuilder.php @@ -137,7 +137,7 @@ class PageBuilder $this->tpl->assign('language', $this->conf->get('translation.language')); if ($this->bookmarkService !== null) { - $this->tpl->assign('tags', $this->bookmarkService->bookmarksCountPerTag()); + $this->tpl->assign('tags', escape($this->bookmarkService->bookmarksCountPerTag())); } $this->tpl->assign( -- cgit v1.2.3