]> git.immae.eu Git - github/shaarli/Shaarli.git/commitdiff
Merge pull request #1512 from shaarli/dependabot/npm_and_yarn/elliptic-6.5.3
authorArthurHoaro <arthur@hoa.ro>
Mon, 31 Aug 2020 12:06:32 +0000 (14:06 +0200)
committerGitHub <noreply@github.com>
Mon, 31 Aug 2020 12:06:32 +0000 (14:06 +0200)
Bump elliptic from 6.4.1 to 6.5.3

231 files changed:
.editorconfig
.github/mailmap
AUTHORS
CHANGELOG.md
application/Thumbnailer.php
application/Utils.php
application/api/ApiMiddleware.php
application/api/ApiUtils.php
application/bookmark/Bookmark.php
application/bookmark/BookmarkFileService.php
application/bookmark/BookmarkFilter.php
application/bookmark/BookmarkIO.php
application/bookmark/BookmarkInitializer.php
application/bookmark/BookmarkServiceInterface.php
application/bookmark/LinkUtils.php
application/bookmark/exception/DatastoreNotInitializedException.php [new file with mode: 0644]
application/config/ConfigManager.php
application/config/ConfigPlugin.php
application/container/ContainerBuilder.php
application/container/ShaarliContainer.php
application/feed/Cache.php [deleted file]
application/feed/FeedBuilder.php
application/formatter/BookmarkDefaultFormatter.php
application/formatter/BookmarkFormatter.php
application/formatter/BookmarkMarkdownFormatter.php
application/formatter/FormatterFactory.php
application/front/ShaarliAdminMiddleware.php [new file with mode: 0644]
application/front/ShaarliMiddleware.php
application/front/controller/admin/ConfigureController.php [new file with mode: 0644]
application/front/controller/admin/ExportController.php [new file with mode: 0644]
application/front/controller/admin/ImportController.php [new file with mode: 0644]
application/front/controller/admin/LogoutController.php [new file with mode: 0644]
application/front/controller/admin/ManageShaareController.php [new file with mode: 0644]
application/front/controller/admin/ManageTagController.php [new file with mode: 0644]
application/front/controller/admin/PasswordController.php [new file with mode: 0644]
application/front/controller/admin/PluginsController.php [new file with mode: 0644]
application/front/controller/admin/SessionFilterController.php [new file with mode: 0644]
application/front/controller/admin/ShaarliAdminController.php [new file with mode: 0644]
application/front/controller/admin/ThumbnailsController.php [new file with mode: 0644]
application/front/controller/admin/TokenController.php [new file with mode: 0644]
application/front/controller/admin/ToolsController.php [new file with mode: 0644]
application/front/controller/visitor/BookmarkListController.php [new file with mode: 0644]
application/front/controller/visitor/DailyController.php [new file with mode: 0644]
application/front/controller/visitor/ErrorController.php [new file with mode: 0644]
application/front/controller/visitor/FeedController.php [new file with mode: 0644]
application/front/controller/visitor/InstallController.php [new file with mode: 0644]
application/front/controller/visitor/LoginController.php [new file with mode: 0644]
application/front/controller/visitor/OpenSearchController.php [new file with mode: 0644]
application/front/controller/visitor/PictureWallController.php [new file with mode: 0644]
application/front/controller/visitor/PublicSessionFilterController.php [new file with mode: 0644]
application/front/controller/visitor/ShaarliVisitorController.php [new file with mode: 0644]
application/front/controller/visitor/TagCloudController.php [new file with mode: 0644]
application/front/controller/visitor/TagController.php [new file with mode: 0644]
application/front/controllers/LoginController.php [deleted file]
application/front/controllers/ShaarliController.php [deleted file]
application/front/exceptions/AlreadyInstalledException.php [new file with mode: 0644]
application/front/exceptions/CantLoginException.php [new file with mode: 0644]
application/front/exceptions/LoginBannedException.php
application/front/exceptions/OpenShaarliPasswordException.php [new file with mode: 0644]
application/front/exceptions/ResourcePermissionException.php [new file with mode: 0644]
application/front/exceptions/ShaarliFrontException.php [moved from application/front/exceptions/ShaarliException.php with 73% similarity]
application/front/exceptions/ThumbnailsDisabledException.php [new file with mode: 0644]
application/front/exceptions/UnauthorizedException.php [new file with mode: 0644]
application/front/exceptions/WrongTokenException.php [new file with mode: 0644]
application/http/HttpAccess.php [new file with mode: 0644]
application/http/HttpUtils.php
application/legacy/LegacyController.php [new file with mode: 0644]
application/legacy/LegacyLinkDB.php
application/legacy/LegacyRouter.php [moved from application/Router.php with 98% similarity]
application/legacy/LegacyUpdater.php
application/legacy/UnknowLegacyRouteException.php [new file with mode: 0644]
application/netscape/NetscapeBookmarkUtils.php
application/plugin/PluginManager.php
application/render/PageBuilder.php
application/render/PageCacheManager.php [new file with mode: 0644]
application/render/TemplatePage.php [new file with mode: 0644]
application/security/CookieManager.php [new file with mode: 0644]
application/security/LoginManager.php
application/security/SessionManager.php
application/updater/Updater.php
assets/common/js/thumbnails-update.js
assets/default/js/base.js
assets/default/scss/shaarli.scss
composer.json
composer.lock
doc/md/Plugin-System.md
doc/md/RSS-feeds.md
doc/md/Translations.md
inc/languages/fr/LC_MESSAGES/shaarli.po
index.php
init.php [new file with mode: 0644]
plugins/addlink_toolbar/addlink_toolbar.php
plugins/archiveorg/archiveorg.html
plugins/archiveorg/archiveorg.php
plugins/demo_plugin/demo_plugin.php
plugins/isso/isso.php
plugins/isso/isso_button.html [deleted file]
plugins/playvideos/playvideos.php
plugins/pubsubhubbub/pubsubhubbub.php
plugins/qrcode/qrcode.php
plugins/qrcode/shaarli-qrcode.js
plugins/wallabag/README.md
plugins/wallabag/wallabag.php
tests/PluginManagerTest.php
tests/api/controllers/links/GetLinkIdTest.php
tests/api/controllers/links/GetLinksTest.php
tests/api/controllers/links/PostLinkTest.php
tests/api/controllers/links/PutLinkTest.php
tests/bookmark/BookmarkFileServiceTest.php
tests/bookmark/BookmarkInitializerTest.php
tests/bookmark/BookmarkTest.php
tests/bookmark/LinkUtilsTest.php
tests/bootstrap.php
tests/config/ConfigPluginTest.php
tests/container/ContainerBuilderTest.php
tests/container/ShaarliTestContainer.php [new file with mode: 0644]
tests/feed/CachedPageTest.php
tests/feed/FeedBuilderTest.php
tests/formatter/BookmarkDefaultFormatterTest.php
tests/formatter/BookmarkMarkdownFormatterTest.php
tests/front/ShaarliAdminMiddlewareTest.php [new file with mode: 0644]
tests/front/ShaarliMiddlewareTest.php
tests/front/controller/LoginControllerTest.php [deleted file]
tests/front/controller/ShaarliControllerTest.php [deleted file]
tests/front/controller/admin/ConfigureControllerTest.php [new file with mode: 0644]
tests/front/controller/admin/ExportControllerTest.php [new file with mode: 0644]
tests/front/controller/admin/FrontAdminControllerMockHelper.php [new file with mode: 0644]
tests/front/controller/admin/ImportControllerTest.php [new file with mode: 0644]
tests/front/controller/admin/LogoutControllerTest.php [new file with mode: 0644]
tests/front/controller/admin/ManageShaareControllerTest/AddShaareTest.php [new file with mode: 0644]
tests/front/controller/admin/ManageShaareControllerTest/ChangeVisibilityBookmarkTest.php [new file with mode: 0644]
tests/front/controller/admin/ManageShaareControllerTest/DeleteBookmarkTest.php [new file with mode: 0644]
tests/front/controller/admin/ManageShaareControllerTest/DisplayCreateFormTest.php [new file with mode: 0644]
tests/front/controller/admin/ManageShaareControllerTest/DisplayEditFormTest.php [new file with mode: 0644]
tests/front/controller/admin/ManageShaareControllerTest/PinBookmarkTest.php [new file with mode: 0644]
tests/front/controller/admin/ManageShaareControllerTest/SaveBookmarkTest.php [new file with mode: 0644]
tests/front/controller/admin/ManageTagControllerTest.php [new file with mode: 0644]
tests/front/controller/admin/PasswordControllerTest.php [new file with mode: 0644]
tests/front/controller/admin/PluginsControllerTest.php [new file with mode: 0644]
tests/front/controller/admin/SessionFilterControllerTest.php [new file with mode: 0644]
tests/front/controller/admin/ShaarliAdminControllerTest.php [new file with mode: 0644]
tests/front/controller/admin/ThumbnailsControllerTest.php [new file with mode: 0644]
tests/front/controller/admin/TokenControllerTest.php [new file with mode: 0644]
tests/front/controller/admin/ToolsControllerTest.php [new file with mode: 0644]
tests/front/controller/visitor/BookmarkListControllerTest.php [new file with mode: 0644]
tests/front/controller/visitor/DailyControllerTest.php [new file with mode: 0644]
tests/front/controller/visitor/ErrorControllerTest.php [new file with mode: 0644]
tests/front/controller/visitor/FeedControllerTest.php [new file with mode: 0644]
tests/front/controller/visitor/FrontControllerMockHelper.php [new file with mode: 0644]
tests/front/controller/visitor/InstallControllerTest.php [new file with mode: 0644]
tests/front/controller/visitor/LoginControllerTest.php [new file with mode: 0644]
tests/front/controller/visitor/OpenSearchControllerTest.php [new file with mode: 0644]
tests/front/controller/visitor/PictureWallControllerTest.php [new file with mode: 0644]
tests/front/controller/visitor/PublicSessionFilterControllerTest.php [new file with mode: 0644]
tests/front/controller/visitor/ShaarliVisitorControllerTest.php [new file with mode: 0644]
tests/front/controller/visitor/TagCloudControllerTest.php [new file with mode: 0644]
tests/front/controller/visitor/TagControllerTest.php [new file with mode: 0644]
tests/http/HttpUtils/IndexUrlTest.php
tests/legacy/LegacyControllerTest.php [new file with mode: 0644]
tests/legacy/LegacyLinkDBTest.php
tests/legacy/LegacyRouterTest.php [moved from tests/RouterTest.php with 51% similarity]
tests/netscape/BookmarkExportTest.php
tests/netscape/BookmarkImportTest.php
tests/plugins/PluginAddlinkTest.php
tests/plugins/PluginPlayvideosTest.php
tests/plugins/PluginPubsubhubbubTest.php
tests/plugins/PluginQrcodeTest.php
tests/plugins/resources/hashtags.md [deleted file]
tests/plugins/resources/hashtags.raw [deleted file]
tests/plugins/resources/markdown.html [deleted file]
tests/plugins/resources/markdown.md [deleted file]
tests/plugins/test/test.php
tests/render/PageCacheManagerTest.php [moved from tests/feed/CacheTest.php with 68% similarity]
tests/security/LoginManagerTest.php
tests/security/SessionManagerTest.php
tests/updater/UpdaterTest.php
tests/utils/ReferenceLinkDB.php
tpl/default/404.html
tpl/default/addlink.html
tpl/default/changepassword.html
tpl/default/changetag.html
tpl/default/configure.html
tpl/default/daily.html
tpl/default/dailyrss.html
tpl/default/editlink.html
tpl/default/error.html
tpl/default/export.html
tpl/default/feed.atom.html
tpl/default/feed.rss.html
tpl/default/import.html
tpl/default/includes.html
tpl/default/install.html
tpl/default/linklist.html
tpl/default/linklist.paging.html
tpl/default/opensearch.html
tpl/default/page.footer.html
tpl/default/page.header.html
tpl/default/picwall.html
tpl/default/pluginsadmin.html
tpl/default/tag.cloud.html
tpl/default/tag.list.html
tpl/default/tag.sort.html
tpl/default/thumbnails.html
tpl/default/tools.html
tpl/vintage/404.html
tpl/vintage/addlink.html
tpl/vintage/changepassword.html
tpl/vintage/changetag.html
tpl/vintage/configure.html
tpl/vintage/daily.html
tpl/vintage/dailyrss.html
tpl/vintage/editlink.html
tpl/vintage/error.html
tpl/vintage/export.html
tpl/vintage/feed.atom.html
tpl/vintage/feed.rss.html
tpl/vintage/import.html
tpl/vintage/includes.html
tpl/vintage/install.html
tpl/vintage/linklist.html
tpl/vintage/linklist.paging.html
tpl/vintage/loginform.html
tpl/vintage/opensearch.html
tpl/vintage/page.footer.html
tpl/vintage/page.header.html
tpl/vintage/picwall.html
tpl/vintage/pluginsadmin.html
tpl/vintage/tag.cloud.html
tpl/vintage/thumbnails.html
tpl/vintage/tools.html
yarn.lock

index 34bd7994d68f53311b9b961692db7ba407503b76..c2ab80ebc8bc5e105ce209e23aa5cc3dcb84006f 100644 (file)
@@ -14,7 +14,7 @@ indent_size = 4
 indent_size = 2
 
 [*.php]
-max_line_length = 100
+max_line_length = 120
 
 [Dockerfile]
 max_line_length = 80
index 7633afcf23829481372b4810045db893ec4feaf5..366946e8c791e281a5be66258436545dfa2d712f 100644 (file)
@@ -1,13 +1,17 @@
-ArthurHoaro <arthur@hoa.ro>
+ArthurHoaro <arthur@hoa.ro> <arthur.hoareau@wizacha.com>
+ArthurHoaro <arthur@hoa.ro> Arthur
 Florian Eula <eula.florian@gmail.com> feula
 Florian Eula <eula.florian@gmail.com> <mr.pikzen@gmail.com>
 Immánuel Fodor <immanuelfactor+github@gmail.com>
 kalvn <kalvnthereal@gmail.com> <kalvn@users.noreply.github.com>
+kalvn <kalvnthereal@gmail.com> <kalvn@pm.me>
+Neros <contact@neros.fr> <NerosTie@users.noreply.github.com>
 Nicolas Danelon <hi@nicolasmd.com.ar> nicolasm
 Nicolas Danelon <hi@nicolasmd.com.ar> <nda@3818.com.ar>
 Nicolas Danelon <hi@nicolasmd.com.ar> <nicolasdanelon@gmail.com>
 Nicolas Danelon <hi@nicolasmd.com.ar> <nicolasdanelon@users.noreply.github.com>
 Sébastien Sauvage <sebsauvage@sebsauvage.net>
+Sébastien NOBILI <code@pipoprods.org> <s-code-github@pipoprods.org>
 Timo Van Neerden <fire@lehollandaisvolant.net>
 Timo Van Neerden <fire@lehollandaisvolant.net> lehollandaisvolant <levoltigeurhollandais@gmail.com>
 VirtualTam <virtualtam@flibidi.net> <tamisier.aurelien@gmail.com>
diff --git a/AUTHORS b/AUTHORS
index 505932185eb9319be5775a72e823aef21446ae7a..9c5028ebeb5250387744ada1f546faebd5d0796c 100644 (file)
--- a/AUTHORS
+++ b/AUTHORS
@@ -1,6 +1,6 @@
-   782 ArthurHoaro <arthur@hoa.ro>
-   401 VirtualTam <virtualtam@flibidi.net>
-   218 nodiscc <nodiscc@gmail.com>
+   903 ArthurHoaro <arthur@hoa.ro>
+   402 VirtualTam <virtualtam@flibidi.net>
+   250 nodiscc <nodiscc@gmail.com>
     56 Sébastien Sauvage <sebsauvage@sebsauvage.net>
     16 Luce Carević <lcarevic@access42.net>
     15 Florian Eula <eula.florian@gmail.com>
@@ -8,11 +8,12 @@
     12 Nicolas Danelon <hi@nicolasmd.com.ar>
      9 Willi Eggeling <thewilli@gmail.com>
      8 Christophe HENRY <christophe.henry@sbgodin.fr>
+     7 Lucas Cimon <lucas.cimon@gmail.com>
      6 B. van Berkum <dev@dotmpe.com>
+     6 kalvn <kalvnthereal@gmail.com>
      6 llune <llune@users.noreply.github.com>
-     5 Lucas Cimon <lucas.cimon@gmail.com>
      5 Mark Schmitz <kramred@gmail.com>
-     5 kalvn <kalvnthereal@gmail.com>
+     5 Sébastien NOBILI <code@pipoprods.org>
      4 Alexandre Alapetite <alexandre@alapetite.fr>
      4 David Sferruzza <david.sferruzza@gmail.com>
      4 Immánuel Fodor <immanuelfactor+github@gmail.com>
      2 Alexandre G.-Raymond <alex@ndre.gr>
      2 Chris Kuethe <chris.kuethe@gmail.com>
      2 Felix Bartels <felix@host-consultants.de>
+     2 Guillaume Virlet <github@virlet.org>
      2 Knah Tsaeb <Knah-Tsaeb@knah-tsaeb.org>
      2 Mathieu Chabanon <git@matchab.fr>
      2 MiloÅ¡ Jovanović <mjovanovic@gmail.com>
+     2 Neros <contact@neros.fr>
      2 Qwerty <champlywood@free.fr>
      2 Stephen Muth <smuth4@gmail.com>
      2 Timo Van Neerden <fire@lehollandaisvolant.net>
+     2 dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
+     2 flow.gunso <flow.gunso@gmail.com>
      2 julienCXX <software@chmodplusx.eu>
      2 philipp-r <philipp-r@users.noreply.github.com>
      2 pips <pips@e5150.fr>
      2 trailjeep <trailjeep@gmail.com>
+     2 yude <yudesleepy@gmail.com>
      1 Adrien Oliva <adrien.oliva@yapbreak.fr>
      1 Adrien le Maire <adrien@alemaire.be>
      1 Alexis J <alexis@effingo.be>
      1 Angristan <angristan@users.noreply.github.com>
      1 Bish Erbas <42714627+bisherbas@users.noreply.github.com>
      1 BoboTiG <bobotig@gmail.com>
+     1 Brendan M. Sleight <bms.git@barwap.com>
      1 Bronco <bronco@warriordudimanche.net>
      1 Buster One <37770318+buster-one@users.noreply.github.com>
      1 D Low <daniellowtw@gmail.com>
      1 Daniel Jakots <vigdis@chown.me>
+     1 David Foucher <dev@tyjak.net>
      1 Dennis Verspuij <dennisverspuij@users.noreply.github.com>
      1 Dimtion <zizou.xena@gmail.com>
      1 Fanch <fanch-github@qth.fr>
      1 Florian Voigt <flvoigt@me.com>
      1 Franck Kerbiriou <FranckKe@users.noreply.github.com>
      1 Gary Marigliano <gmarigliano93@gmail.com>
-     1 Guillaume Virlet <github@virlet.org>
      1 Jonathan Amiez <jonathan.amiez@gmail.com>
      1 Jonathan Druart <jonathan.druart@gmail.com>
      1 Julien Pivotto <roidelapluie@inuits.eu>
      1 Kevin Canévet <kevin@streamroot.io>
+     1 Kevin Masson <kevin.masson@methodinthemadness.eu>
      1 Knah Tsaeb <knah-tsaeb@knah-tsaeb.org>
      1 Lionel Martin <renarddesmers@gmail.com>
      1 Mark Gerarts <mark.gerarts@gmail.com>
      1 Marsup <marsup@gmail.com>
-     1 Neros <contact@neros.fr>
+     1 Paul van den Burg <github@paulvandenburg.nl>
      1 Rajat Hans <rajathans9@gmail.com>
      1 Sbgodin <Sbgodin@users.noreply.github.com>
+     1 Sebastien Wains <sebw@users.noreply.github.com>
      1 TsT <tst2005@gmail.com>
      1 agentcobra <agentcobra@free.fr>
+     1 aguy <aguytech@users.noreply.github.com>
      1 dimtion <zizou.xena@gmail.com>
      1 durcheinandr <jochen@durcheinandr.de>
      1 lapineige <lapineige@users.noreply.github.com>
+     1 rfolo9li <50079896+rfolo9li@users.noreply.github.com>
index abf802ead6c89a2663f843860b124d4226e49135..4bae5b487a846243ca282a6f88889bd36829cff8 100644 (file)
@@ -4,6 +4,66 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](http://keepachangelog.com/)
 and this project adheres to [Semantic Versioning](http://semver.org/).
 
+## [v0.12.0](https://github.com/shaarli/Shaarli/releases/tag/v0.12.0-beta) - UNRELEASED [beta 2020-08-27]
+
+**Save you `data/` folder before updating!**
+
+This is a beta version containing major changes, including new URLs for Shaarli and datastore format update.
+Be aware that by using a beta version you might encounter bugs, and that 3rd party themes or plugins might not be compatible.
+
+### Added
+- Thumbnailer: add soundcloud.com to list of common media domains
+- Markdown rendering is now integrated into Shaarli core
+- Add autofocus on tag cloud filter input
+- Japanese translations
+- Support for local anchor URL (startting with `#`)
+- LDAP authentication
+- Encapsulated PageCacheManager
+- Docs:
+  - add screenshots of all pages
+  - section about mkdocs
+  - Ulauncher extension
+- CI: run against PHP 7.4
+
+### Changed
+- Introduce Bookmark object and Service layer
+  - Save bookmark as objects in the datastore
+  - Handle bookmark as objects across the whole codebase (except templates and plugins)
+- Process all Shaarli page through Slim controller, with proper URL rewriting (see #1516)
+- ATOM feed: use instance name as author name instead of URL
+- Updated French translation
+- Docs:
+  - Troubleshooting page rewritten
+  - Updated unit tests page
+  - Updated Server security page
+
+### Fixed
+- Undefined index: thumbnail in daily page
+- Undefined index: thumbnail on OpenGraph headers
+- Undefined index: updated on linklist
+- Make sure that bookmark sort is consistent, even with equal timestamps
+- Code PHP version check as requirement bumped to PHP 7.1
+- Thumbnail images lazy loading
+- Markdown plugin: fix RSS feed direct link reverse
+- Fix RSS permalink included in Markdown bloc
+- Demo plugin: multiple typos
+- Makefile target for releases
+- Makefile target for html documentation
+- Session cookie setting being set while session is active
+- Deprecated use of implode
+- Division by zero in tag cloud
+- CI: deprecated linux distribution and sudo directive
+- Docker build: gcc is no longer included in python alpine image
+- Docs:
+  - Outdated Docker documentation for stable branch
+  - Outdated links
+  - Plugin description in meta files
+
+### Removed
+- Markdown plugin
+- Docs:
+  - emojione & twemoji removed
+
 ## [v0.11.1](https://github.com/shaarli/Shaarli/releases/tag/v0.11.1) - 2019-08-03
 
 Release to fix broken Docker build on the latest version.
index 314baf0df615c5e3edf15ec5e9b0e60eac61f792..5aec23c8d7b6bbf59305f3e651a689cd3d781a21 100644 (file)
@@ -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());
         }
index 4b7fc5464916495ec9f9189270edbd7d43d2b53c..9c9eaaa2611eff24d62d24eb2d30e2da372d4aad 100644 (file)
@@ -87,10 +87,14 @@ 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)
 {
+    if (null === $input) {
+        return null;
+    }
+
     if (is_bool($input)) {
         return $input;
     }
@@ -294,15 +298,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;
     }
 
index 4745ac94101db8efb3c83bc760dd154a3af0445e..09ce6445303bf5f9280e033c6004bf5e56f725c9 100644 (file)
@@ -71,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')
+        ;
     }
 
     /**
index 5156a5f783f0bc6767c0684f1c1a595959f3fbbd..faebb8f5f00685f4a2413d429a3a917c2adc2833 100644 (file)
@@ -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();
index f9b21d3d0b59dda47bf9f055607d258b7e13376b..1beb8be2e127a2b0b905e71e256b0279f8018598 100644 (file)
@@ -3,6 +3,7 @@
 namespace Shaarli\Bookmark;
 
 use DateTime;
+use DateTimeInterface;
 use Shaarli\Bookmark\Exception\InvalidBookmarkException;
 
 /**
@@ -36,16 +37,16 @@ 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 */
     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,12 +101,12 @@ class Bookmark
             || ! is_int($this->id)
             || empty($this->shortUrl)
             || empty($this->created)
-            || ! $this->created instanceof DateTime
+            || ! $this->created instanceof DateTimeInterface
         ) {
             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;
@@ -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
      */
@@ -346,7 +347,7 @@ class Bookmark
     /**
      * Get the Thumbnail.
      *
-     * @return string|bool
+     * @return string|bool|null Thumbnail's URL - initialized at null, false if no thumbnail could be found
      */
     public function getThumbnail()
     {
@@ -356,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
      */
@@ -405,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] === '?';
     }
 
     /**
index 9c59e1396a31418a957be70e9e776fcdd586978d..b3a90ed4623beecc7c75d96be635e33decfd6aed 100644 (file)
@@ -6,12 +6,14 @@ 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;
 use Shaarli\History;
 use Shaarli\Legacy\LegacyLinkDB;
 use Shaarli\Legacy\LegacyUpdater;
+use Shaarli\Render\PageCacheManager;
 use Shaarli\Updater\UpdaterUtils;
 
 /**
@@ -39,6 +41,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 +54,7 @@ class BookmarkFileService implements BookmarkServiceInterface
     {
         $this->conf = $conf;
         $this->history = $history;
+        $this->pageCacheManager = new PageCacheManager($this->conf->get('resource.page_cache'), $isLoggedIn);
         $this->bookmarksIO = new BookmarkIO($this->conf);
         $this->isLoggedIn = $isLoggedIn;
 
@@ -57,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 ($isLoggedIn) {
-                    $this->save();
+
+                if ($this->isLoggedIn) {
+                    // Datastore file does not exists, we initialize it with default bookmarks.
+                    if ($e instanceof DatastoreNotInitializedException) {
+                        $this->initialize();
+                    } else {
+                        $this->save();
+                    }
                 }
             }
 
@@ -88,7 +100,7 @@ class BookmarkFileService implements BookmarkServiceInterface
             throw new Exception('Not authorized');
         }
 
-        return $bookmark;
+        return $first;
     }
 
     /**
@@ -149,7 +161,7 @@ class BookmarkFileService implements BookmarkServiceInterface
      */
     public function set($bookmark, $save = true)
     {
-        if ($this->isLoggedIn !== true) {
+        if (true !== $this->isLoggedIn) {
             throw new Exception(t('You\'re not authorized to alter the datastore'));
         }
         if (! $bookmark instanceof Bookmark) {
@@ -174,7 +186,7 @@ class BookmarkFileService implements BookmarkServiceInterface
      */
     public function add($bookmark, $save = true)
     {
-        if ($this->isLoggedIn !== true) {
+        if (true !== $this->isLoggedIn) {
             throw new Exception(t('You\'re not authorized to alter the datastore'));
         }
         if (! $bookmark instanceof Bookmark) {
@@ -199,7 +211,7 @@ class BookmarkFileService implements BookmarkServiceInterface
      */
     public function addOrSet($bookmark, $save = true)
     {
-        if ($this->isLoggedIn !== true) {
+        if (true !== $this->isLoggedIn) {
             throw new Exception(t('You\'re not authorized to alter the datastore'));
         }
         if (! $bookmark instanceof Bookmark) {
@@ -216,7 +228,7 @@ class BookmarkFileService implements BookmarkServiceInterface
      */
     public function remove($bookmark, $save = true)
     {
-        if ($this->isLoggedIn !== true) {
+        if (true !== $this->isLoggedIn) {
             throw new Exception(t('You\'re not authorized to alter the datastore'));
         }
         if (! $bookmark instanceof Bookmark) {
@@ -269,13 +281,14 @@ class BookmarkFileService implements BookmarkServiceInterface
      */
     public function save()
     {
-        if (!$this->isLoggedIn) {
+        if (true !== $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'));
+        $this->pageCacheManager->invalidateCaches();
     }
 
     /**
@@ -291,6 +304,7 @@ class BookmarkFileService implements BookmarkServiceInterface
                 if (empty($tag)
                     || (! $this->isLoggedIn && startsWith($tag, '.'))
                     || $tag === BookmarkMarkdownFormatter::NO_MD_TAG
+                    || in_array($tag, $filteringTags, true)
                 ) {
                     continue;
                 }
@@ -349,6 +363,10 @@ class BookmarkFileService implements BookmarkServiceInterface
     {
         $initializer = new BookmarkInitializer($this);
         $initializer->initialize();
+
+        if (true === $this->isLoggedIn) {
+            $this->save();
+        }
     }
 
     /**
index fd5566790447838e0b7566e09f12695ffe680b0f..797a36b8ecd54c34a7aaea1ac63d3db0c2496bc8 100644 (file)
@@ -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;
index ae9ffcb4612bd1acbc633d51b3c48c46cd05ff76..6bf7f3654ebfdd41f87b9468f00c135c2725e9da 100644 (file)
@@ -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)) {
@@ -102,7 +104,5 @@ class BookmarkIO
             $this->datastore,
             self::$phpPrefix.base64_encode(gzdeflate(serialize($links))).self::$phpSuffix
         );
-
-        invalidateCaches($this->conf->get('resource.page_cache'));
     }
 }
index 9eee9a35bad09f3d9840d6bba035115dddf7075f..cd2d1724c1ad088193ffe51c1bde44a7d4ab2c2b 100644 (file)
@@ -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.
@@ -36,11 +35,11 @@ class BookmarkInitializer
     {
         $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 +53,6 @@ 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);
     }
 }
index 7b7a4f09e131df45e0e3c92fffe1272f37cc4de1..ce8bd912bf6b5a8d086646a9f1fd4f8fd297fe62 100644 (file)
@@ -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;
 
 /**
index 8837943037dd52468ff6e73bbbc39f8e669b1b04..68914fcab749a19b1ba15193decd99247158375a 100644 (file)
@@ -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.
  *
@@ -220,7 +114,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<a href="'. $indexUrl .'?addtag=$2" title="Hashtag $2">#$2</a>';
+    $replacement = '$1<a href="'. $indexUrl .'./add-tag/$2" title="Hashtag $2">#$2</a>';
     return preg_replace($regex, $replacement, $description);
 }
 
diff --git a/application/bookmark/exception/DatastoreNotInitializedException.php b/application/bookmark/exception/DatastoreNotInitializedException.php
new file mode 100644 (file)
index 0000000..f495049
--- /dev/null
@@ -0,0 +1,10 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Bookmark\Exception;
+
+class DatastoreNotInitializedException extends \Exception
+{
+
+}
index e45bb4c391a21d1c5d14ec00007bf830788ba144..4c98be3051e3fafa7f9aa7b0891392a2c116f7db 100644 (file)
@@ -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');
 
index dbb249374a7262053b5e03000d38b746c9772563..ea8dfbdade4f0f0776eff517545e1b3f96f536f3 100644 (file)
@@ -1,6 +1,7 @@
 <?php
 
 use Shaarli\Config\Exception\PluginConfigOrderException;
+use Shaarli\Plugin\PluginManager;
 
 /**
  * Plugin configuration helper functions.
@@ -19,6 +20,20 @@ use Shaarli\Config\Exception\PluginConfigOrderException;
  */
 function save_plugin_config($formData)
 {
+    // We can only save existing plugins
+    $directories = str_replace(
+        PluginManager::$PLUGINS_PATH . '/',
+        '',
+        glob(PluginManager::$PLUGINS_PATH . '/*')
+    );
+    $formData = array_filter(
+        $formData,
+        function ($value, string $key) use ($directories) {
+            return startsWith($key, 'order') || in_array($key, $directories);
+        },
+        ARRAY_FILTER_USE_BOTH
+    );
+
     // Make sure there are no duplicates in orders.
     if (!validate_plugin_order($formData)) {
         throw new PluginConfigOrderException();
@@ -69,7 +84,7 @@ function validate_plugin_order($formData)
     $orders = array();
     foreach ($formData as $key => $value) {
         // No duplicate order allowed.
-        if (in_array($value, $orders)) {
+        if (in_array($value, $orders, true)) {
             return false;
         }
 
index e2c78ccc44f4c47a412a4ba10a1e95c0c2f93fdb..58067c9945319d7d7bb664d37c0589fe71b8d377 100644 (file)
@@ -7,11 +7,21 @@ 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\Front\Controller\Visitor\ErrorController;
 use Shaarli\History;
+use Shaarli\Http\HttpAccess;
+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;
+use Shaarli\Updater\Updater;
+use Shaarli\Updater\UpdaterUtils;
 
 /**
  * Class ContainerBuilder
@@ -30,22 +40,37 @@ class ContainerBuilder
     /** @var SessionManager */
     protected $session;
 
+    /** @var CookieManager */
+    protected $cookieManager;
+
     /** @var LoginManager */
     protected $login;
 
-    public function __construct(ConfigManager $conf, SessionManager $session, LoginManager $login)
-    {
+    /** @var string|null */
+    protected $basePath = null;
+
+    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
     {
         $container = new ShaarliContainer();
+
         $container['conf'] = $this->conf;
         $container['sessionManager'] = $this->session;
+        $container['cookieManager'] = $this->cookieManager;
         $container['loginManager'] = $this->login;
+        $container['basePath'] = $this->basePath;
+
         $container['plugins'] = function (ShaarliContainer $container): PluginManager {
             return new PluginManager($container->conf);
         };
@@ -73,7 +98,62 @@ class ContainerBuilder
         };
 
         $container['pluginManager'] = function (ShaarliContainer $container): PluginManager {
-            return new PluginManager($container->conf);
+            $pluginManager = new PluginManager($container->conf);
+
+            $pluginManager->load($container->conf->get('general.enabled_plugins'));
+
+            return $pluginManager;
+        };
+
+        $container['formatterFactory'] = function (ShaarliContainer $container): FormatterFactory {
+            return new FormatterFactory(
+                $container->conf,
+                $container->loginManager->isLoggedIn()
+            );
+        };
+
+        $container['pageCacheManager'] = function (ShaarliContainer $container): PageCacheManager {
+            return new PageCacheManager(
+                $container->conf->get('resource.page_cache'),
+                $container->loginManager->isLoggedIn()
+            );
+        };
+
+        $container['feedBuilder'] = function (ShaarliContainer $container): FeedBuilder {
+            return new FeedBuilder(
+                $container->bookmarkService,
+                $container->formatterFactory->getFormatter(),
+                $container->environment,
+                $container->loginManager->isLoggedIn()
+            );
+        };
+
+        $container['thumbnailer'] = function (ShaarliContainer $container): Thumbnailer {
+            return new Thumbnailer($container->conf);
+        };
+
+        $container['httpAccess'] = function (): HttpAccess {
+            return new HttpAccess();
+        };
+
+        $container['netscapeBookmarkUtils'] = function (ShaarliContainer $container): NetscapeBookmarkUtils {
+            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()
+            );
+        };
+
+        $container['errorHandler'] = function (ShaarliContainer $container): ErrorController {
+            return new ErrorController($container);
+        };
+        $container['phpErrorHandler'] = function (ShaarliContainer $container): ErrorController {
+            return new ErrorController($container);
         };
 
         return $container;
index 3fa9116e3544ee2e6b73f8b552a9bf30b6b92aa7..9a9a974a6785bce8077e818607dc75e5dbee7e63 100644 (file)
@@ -6,23 +6,43 @@ namespace Shaarli\Container;
 
 use Shaarli\Bookmark\BookmarkServiceInterface;
 use Shaarli\Config\ConfigManager;
+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;
+use Shaarli\Security\CookieManager;
 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 BookmarkServiceInterface $bookmarkService
+ * @property CookieManager            $cookieManager
  * @property ConfigManager            $conf
- * @property SessionManager           $sessionManager
- * @property LoginManager             $loginManager
+ * @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 BookmarkServiceInterface $bookmarkService
+ * @property HttpAccess               $httpAccess
+ * @property LoginManager             $loginManager
+ * @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
+ * @property Updater                  $updater
  */
 class ShaarliContainer extends Container
 {
diff --git a/application/feed/Cache.php b/application/feed/Cache.php
deleted file mode 100644 (file)
index e5d43e6..0000000
+++ /dev/null
@@ -1,38 +0,0 @@
-<?php
-/**
- * Cache utilities
- */
-
-/**
- * Purges all cached pages
- *
- * @param string $pageCacheDir page cache directory
- *
- * @return mixed an error string if the directory is missing
- */
-function purgeCachedPages($pageCacheDir)
-{
-    if (! is_dir($pageCacheDir)) {
-        $error = sprintf(t('Cannot purge %s: no directory'), $pageCacheDir);
-        error_log($error);
-        return $error;
-    }
-
-    array_map('unlink', glob($pageCacheDir.'/*.cache'));
-}
-
-/**
- * Invalidates caches when the database is changed or the user logs out.
- *
- * @param string $pageCacheDir page cache directory
- */
-function invalidateCaches($pageCacheDir)
-{
-    // Purge cache attached to session.
-    if (isset($_SESSION['tags'])) {
-        unset($_SESSION['tags']);
-    }
-
-    // Purge page cache shared by sessions.
-    purgeCachedPages($pageCacheDir);
-}
index 40bd4f153393553bfda8803a3b81e739bb9e155b..269ad87722cfc5888070ce05dc5c730149a9129b 100644 (file)
@@ -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, $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,18 +132,49 @@ 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'];
+        $data['guid'] = rtrim($pageaddr, '/') . '/shaare/' . $data['shorturl'];
         if ($this->usePermalinks === true) {
             $permalink = '<a href="'. $data['url'] .'" title="'. t('Direct link') .'">'. t('Direct link') .'</a>';
         } else {
@@ -165,13 +182,13 @@ class FeedBuilder
         }
         $data['description'] .= PHP_EOL . PHP_EOL . '<br>&#8212; ' . $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;
         }
index c6c590647550e50c6ee688ec497d2a6c7167d497..9d4a0fa0235c591be9a29d7ad3714d15b97c1e49 100644 (file)
@@ -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() && isset($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 (isset($this->contextData['index_url'])) {
+                $prefix = rtrim($this->contextData['index_url'], '/') . '/';
+            }
+
+            if (isset($this->contextData['base_path'])) {
+                $prefix = rtrim($this->contextData['base_path'], '/') . '/';
+            }
+
+            return escape($prefix ?? '') . escape(ltrim($bookmark->getUrl(), '/'));
         }
+
         return escape($bookmark->getUrl());
     }
 
index a80d83fc1639006ebd915f90c8588213383f3b9c..22ba7aae78173338d501fd56e462e2891458d081 100644 (file)
@@ -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);
     }
 
     /**
index 077e5312b75b620fb2e2ace73cf353edb7cea99a..5d244d4c92de249721f0c1c6e18ab79ba2222752 100644 (file)
@@ -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 = '';
index 5f282f686b95ace2439c6db273be4ce78637f876..a029579f6908f5452056d0db8db16a87c57ee5d4 100644 (file)
@@ -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/ShaarliAdminMiddleware.php b/application/front/ShaarliAdminMiddleware.php
new file mode 100644 (file)
index 0000000..35ce4a3
--- /dev/null
@@ -0,0 +1,27 @@
+<?php
+
+namespace Shaarli\Front;
+
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Middleware used for controller requiring to be authenticated.
+ * It extends ShaarliMiddleware, and just make sure that the user is authenticated.
+ * Otherwise, it redirects to the login page.
+ */
+class ShaarliAdminMiddleware extends ShaarliMiddleware
+{
+    public function __invoke(Request $request, Response $response, callable $next): Response
+    {
+        $this->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);
+    }
+}
index fa6c64671d56b9db1ee43cd4c13f71ef15f30958..c015c0c6f0284fcfb9802a9938494c60763f721e 100644 (file)
@@ -3,7 +3,7 @@
 namespace Shaarli\Front;
 
 use Shaarli\Container\ShaarliContainer;
-use Shaarli\Front\Exception\ShaarliException;
+use Shaarli\Front\Exception\UnauthorizedException;
 use Slim\Http\Request;
 use Slim\Http\Response;
 
@@ -24,6 +24,8 @@ class ShaarliMiddleware
 
     /**
      * Middleware execution:
+     *   - run updates
+     *   - if not logged in open shaarli, redirect to login
      *   - execute the controller
      *   - return the response
      *
@@ -35,23 +37,78 @@ 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->initBasePath($request);
+
         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())
-                );
+            if (!is_file($this->container->conf->getConfigFileExt())
+                && !in_array($next->getName(), ['displayInstall', 'saveInstall'], true)
+            ) {
+                return $response->withRedirect($this->container->basePath . '/install');
             }
 
-            $response = $response->withStatus($e->getCode());
-            $response = $response->write($this->container->pageBuilder->render('error'));
+            $this->runUpdates();
+            $this->checkOpenShaarli($request, $response, $next);
+
+            return $next($request, $response);
+        } catch (UnauthorizedException $e) {
+            $returnUrl = urlencode($this->container->environment['REQUEST_URI']);
+
+            return $response->withRedirect($this->container->basePath . '/login?returnurl=' . $returnUrl);
+        }
+        // Other exceptions are handled by ErrorController
+    }
+
+    /**
+     * Run the updater for every requests processed while logged in.
+     */
+    protected function runUpdates(): void
+    {
+        if ($this->container->loginManager->isLoggedIn() !== true) {
+            return;
+        }
+
+        $this->container->updater->setBasePath($this->container->basePath);
+        $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;
+    }
+
+    /**
+     * 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/ConfigureController.php b/application/front/controller/admin/ConfigureController.php
new file mode 100644 (file)
index 0000000..e675fcc
--- /dev/null
@@ -0,0 +1,126 @@
+<?php
+
+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;
+use Slim\Http\Response;
+use Throwable;
+
+/**
+ * Class ConfigureController
+ *
+ * Slim controller used to handle Shaarli configuration page (display + save new config).
+ */
+class ConfigureController extends ShaarliAdminController
+{
+    /**
+     * GET /admin/configure - Displays the configuration page
+     */
+    public function index(Request $request, Response $response): Response
+    {
+        $this->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(TemplatePage::CONFIGURE));
+    }
+
+    /**
+     * POST /admin/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.') .
+                '<a href="'. $this->container->basePath .'/admin/thumbnails">' . t('Please synchronize them.') .'</a>'
+            );
+        }
+        $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) {
+            $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.'));
+
+        return $this->redirect($response, '/admin/configure');
+    }
+}
diff --git a/application/front/controller/admin/ExportController.php b/application/front/controller/admin/ExportController.php
new file mode 100644 (file)
index 0000000..2be957f
--- /dev/null
@@ -0,0 +1,80 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use DateTime;
+use Shaarli\Bookmark\Bookmark;
+use Shaarli\Render\TemplatePage;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Class ExportController
+ *
+ * Slim controller used to display Shaarli data export page,
+ * and process the bookmarks export as a Netscape Bookmarks file.
+ */
+class ExportController extends ShaarliAdminController
+{
+    /**
+     * GET /admin/export - Display export page
+     */
+    public function index(Request $request, Response $response): Response
+    {
+        $this->assignView('pagetitle', t('Export') .' - '. $this->container->conf->get('general.title', 'Shaarli'));
+
+        return $response->write($this->render(TemplatePage::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
+    {
+        $this->checkToken($request);
+
+        $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(TemplatePage::NETSCAPE_EXPORT_BOOKMARKS));
+    }
+}
diff --git a/application/front/controller/admin/ImportController.php b/application/front/controller/admin/ImportController.php
new file mode 100644 (file)
index 0000000..758d5ef
--- /dev/null
@@ -0,0 +1,82 @@
+<?php
+
+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;
+
+/**
+ * Class ImportController
+ *
+ * Slim controller used to display Shaarli data import page,
+ * and import bookmarks from Netscape Bookmarks file.
+ */
+class ImportController extends ShaarliAdminController
+{
+    /**
+     * GET /admin/import - Display import page
+     */
+    public function index(Request $request, Response $response): Response
+    {
+        $this->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(TemplatePage::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/front/controller/admin/LogoutController.php b/application/front/controller/admin/LogoutController.php
new file mode 100644 (file)
index 0000000..2816512
--- /dev/null
@@ -0,0 +1,33 @@
+<?php
+
+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;
+
+/**
+ * Class LogoutController
+ *
+ * Slim controller used to logout the user.
+ * It invalidates page cache and terminate the user session. Then it redirects to the homepage.
+ */
+class LogoutController extends ShaarliAdminController
+{
+    public function index(Request $request, Response $response): Response
+    {
+        $this->container->pageCacheManager->invalidateCaches();
+        $this->container->sessionManager->logout();
+        $this->container->cookieManager->setCookieParameter(
+            CookieManager::STAY_SIGNED_IN,
+            'false',
+            0,
+            $this->container->basePath . '/'
+        );
+
+        return $this->redirect($response, '/');
+    }
+}
diff --git a/application/front/controller/admin/ManageShaareController.php b/application/front/controller/admin/ManageShaareController.php
new file mode 100644 (file)
index 0000000..33e1188
--- /dev/null
@@ -0,0 +1,371 @@
+<?php
+
+declare(strict_types=1);
+
+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;
+
+/**
+ * Class PostBookmarkController
+ *
+ * Slim controller used to handle Shaarli create or edit bookmarks.
+ */
+class ManageShaareController extends ShaarliAdminController
+{
+    /**
+     * GET /admin/add-shaare - Displays the form used to create a new bookmark from an URL
+     */
+    public function addShaare(Request $request, Response $response): Response
+    {
+        $this->assignView(
+            'pagetitle',
+            t('Shaare a new link') .' - '. $this->container->conf->get('general.title', 'Shaarli')
+        );
+
+        return $response->write($this->render(TemplatePage::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);
+        $this->executePageHooks('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('<script>self.close();</script>');
+        }
+
+        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 - Delete one or multiple bookmarks (depending on `id` query parameter).
+     */
+    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->executePageHooks('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('<script>self.close();</script>');
+        }
+
+        // Don't redirect to where we were previously because the datastore has changed.
+        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->executePageHooks('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']);
+    }
+
+    /**
+     * 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->executePageHooks('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.
+     *
+     * @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),
+        ];
+
+        $this->executePageHooks('render_editlink', $data, TemplatePage::EDIT_LINK);
+
+        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(TemplatePage::EDIT_LINK));
+    }
+}
diff --git a/application/front/controller/admin/ManageTagController.php b/application/front/controller/admin/ManageTagController.php
new file mode 100644 (file)
index 0000000..0380ef1
--- /dev/null
@@ -0,0 +1,88 @@
+<?php
+
+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;
+
+/**
+ * Class ManageTagController
+ *
+ * Slim controller used to handle Shaarli manage tags page (rename and delete tags).
+ */
+class ManageTagController extends ShaarliAdminController
+{
+    /**
+     * GET /admin/tags - Displays the manage tags page
+     */
+    public function index(Request $request, Response $response): Response
+    {
+        $fromTag = $request->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(TemplatePage::CHANGE_TAG));
+    }
+
+    /**
+     * POST /admin/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 $this->redirect($response, '/admin/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 ? '/admin/tags' : '/?searchtags='. urlencode($toTag);
+
+        return $this->redirect($response, $redirect);
+    }
+}
diff --git a/application/front/controller/admin/PasswordController.php b/application/front/controller/admin/PasswordController.php
new file mode 100644 (file)
index 0000000..5ec0d24
--- /dev/null
@@ -0,0 +1,101 @@
+<?php
+
+declare(strict_types=1);
+
+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;
+
+/**
+ * Class PasswordController
+ *
+ * Slim controller used to handle passwords update.
+ */
+class PasswordController extends ShaarliAdminController
+{
+    public function __construct(ShaarliContainer $container)
+    {
+        parent::__construct($container);
+
+        $this->assignView(
+            'pagetitle',
+            t('Change password') .' - '. $this->container->conf->get('general.title', 'Shaarli')
+        );
+    }
+
+    /**
+     * GET /admin/password - Displays the change password template
+     */
+    public function index(Request $request, Response $response): Response
+    {
+        return $response->write($this->render(TemplatePage::CHANGE_PASSWORD));
+    }
+
+    /**
+     * POST /admin/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(TemplatePage::CHANGE_PASSWORD))
+            ;
+        }
+
+        // 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(TemplatePage::CHANGE_PASSWORD))
+            ;
+        }
+
+        // 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(TemplatePage::CHANGE_PASSWORD));
+    }
+}
diff --git a/application/front/controller/admin/PluginsController.php b/application/front/controller/admin/PluginsController.php
new file mode 100644 (file)
index 0000000..0e09116
--- /dev/null
@@ -0,0 +1,84 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Exception;
+use Shaarli\Render\TemplatePage;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Class PluginsController
+ *
+ * Slim controller used to handle Shaarli plugins configuration page (display + save new config).
+ */
+class PluginsController extends ShaarliAdminController
+{
+    /**
+     * GET /admin/plugins - Displays the configuration page
+     */
+    public function index(Request $request, Response $response): Response
+    {
+        $pluginMeta = $this->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(TemplatePage::PLUGINS_ADMIN));
+    }
+
+    /**
+     * POST /admin/plugins - Update Shaarli's configuration
+     */
+    public function save(Request $request, Response $response): Response
+    {
+        $this->checkToken($request);
+
+        try {
+            $parameters = $request->getParams() ?? [];
+
+            $this->executePageHooks('save_plugin_parameters', $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');
+    }
+}
diff --git a/application/front/controller/admin/SessionFilterController.php b/application/front/controller/admin/SessionFilterController.php
new file mode 100644 (file)
index 0000000..d9a7a2e
--- /dev/null
@@ -0,0 +1,50 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Shaarli\Bookmark\BookmarkFilter;
+use Shaarli\Security\SessionManager;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Class SessionFilterController
+ *
+ * Slim controller used to handle filters stored in the user session, such as visibility, etc.
+ */
+class SessionFilterController extends ShaarliAdminController
+{
+    /**
+     * GET /admin/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']);
+    }
+
+
+}
diff --git a/application/front/controller/admin/ShaarliAdminController.php b/application/front/controller/admin/ShaarliAdminController.php
new file mode 100644 (file)
index 0000000..3b5939b
--- /dev/null
@@ -0,0 +1,73 @@
+<?php
+
+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;
+
+/**
+ * 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
+{
+    /**
+     * Any persistent action to the config or data store must check the XSRF token validity.
+     */
+    protected function checkToken(Request $request): bool
+    {
+        if (!$this->container->sessionManager->checkToken($request->getParam('token'))) {
+            throw new WrongTokenException();
+        }
+
+        return true;
+    }
+
+    /**
+     * 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/admin/ThumbnailsController.php b/application/front/controller/admin/ThumbnailsController.php
new file mode 100644 (file)
index 0000000..81c87ed
--- /dev/null
@@ -0,0 +1,65 @@
+<?php
+
+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;
+
+/**
+ * Class ToolsController
+ *
+ * Slim controller used to handle thumbnails update.
+ */
+class ThumbnailsController extends ShaarliAdminController
+{
+    /**
+     * GET /admin/thumbnails - Display thumbnails update page
+     */
+    public function index(Request $request, Response $response): Response
+    {
+        $ids = [];
+        foreach ($this->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(TemplatePage::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));
+    }
+}
diff --git a/application/front/controller/admin/TokenController.php b/application/front/controller/admin/TokenController.php
new file mode 100644 (file)
index 0000000..08d68d0
--- /dev/null
@@ -0,0 +1,26 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Class TokenController
+ *
+ * Endpoint used to retrieve a XSRF token. Useful for AJAX requests.
+ */
+class TokenController extends ShaarliAdminController
+{
+    /**
+     * GET /admin/token
+     */
+    public function getToken(Request $request, Response $response): Response
+    {
+        $response = $response->withHeader('Content-Type', 'text/plain');
+
+        return $response->write($this->container->sessionManager->generateToken());
+    }
+}
diff --git a/application/front/controller/admin/ToolsController.php b/application/front/controller/admin/ToolsController.php
new file mode 100644 (file)
index 0000000..a87f20d
--- /dev/null
@@ -0,0 +1,35 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Shaarli\Render\TemplatePage;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Class ToolsController
+ *
+ * Slim controller used to display the tools page.
+ */
+class ToolsController extends ShaarliAdminController
+{
+    public function index(Request $request, Response $response): Response
+    {
+        $data = [
+            'pageabsaddr' => index_url($this->container->environment),
+            'sslenabled' => is_https($this->container->environment),
+        ];
+
+        $this->executePageHooks('render_tools', $data, TemplatePage::TOOLS);
+
+        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(TemplatePage::TOOLS));
+    }
+}
diff --git a/application/front/controller/visitor/BookmarkListController.php b/application/front/controller/visitor/BookmarkListController.php
new file mode 100644 (file)
index 0000000..2988bee
--- /dev/null
@@ -0,0 +1,240 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use Shaarli\Bookmark\Bookmark;
+use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
+use Shaarli\Legacy\LegacyController;
+use Shaarli\Legacy\UnknowLegacyRouteException;
+use Shaarli\Render\TemplatePage;
+use Shaarli\Thumbnailer;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Class BookmarkListController
+ *
+ * Slim controller used to render the bookmark list, the home page of Shaarli.
+ * It also displays permalinks, and process legacy routes based on GET parameters.
+ */
+class BookmarkListController extends ShaarliVisitorController
+{
+    /**
+     * GET / - Displays the bookmark list, with optional filter parameters.
+     */
+    public function index(Request $request, Response $response): Response
+    {
+        $legacyResponse = $this->processLegacyController($request, $response);
+        if (null !== $legacyResponse) {
+            return $legacyResponse;
+        }
+
+        $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') ?? ''));;
+
+        // 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->executePageHooks('render_linklist', $data, TemplatePage::LINKLIST);
+        $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);
+
+        $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' => [$formatter->format($bookmark)],
+            ]
+        );
+
+        $this->executePageHooks('render_linklist', $data, TemplatePage::LINKLIST);
+        $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;
+    }
+
+    /**
+     * @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
new file mode 100644 (file)
index 0000000..54a4778
--- /dev/null
@@ -0,0 +1,192 @@
+<?php
+
+declare(strict_types=1);
+
+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;
+
+/**
+ * Class DailyController
+ *
+ * Slim controller used to render the daily page.
+ */
+class DailyController extends ShaarliVisitorController
+{
+    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).
+     */
+    public function index(Request $request, Response $response): Response
+    {
+        $day = $request->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();
+        $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);
+            // 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->executePageHooks('render_daily', $data, TemplatePage::DAILY);
+
+        $data['cols'] = $this->calculateColumns($data['linksToDisplay']);
+
+        $this->assignAllView($data);
+
+        $mainTitle = $this->container->conf->get('general.title', 'Shaarli');
+        $this->assignView(
+            'pagetitle',
+            t('Daily') .' - '. format_date($dayDate, false) . ' - ' . $mainTitle
+        );
+
+        return $response->write($this->render(TemplatePage::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(TemplatePage::DAILY_RSS);
+
+        $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;
+    }
+}
diff --git a/application/front/controller/visitor/ErrorController.php b/application/front/controller/visitor/ErrorController.php
new file mode 100644 (file)
index 0000000..10aa84c
--- /dev/null
@@ -0,0 +1,45 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use Shaarli\Front\Exception\ShaarliFrontException;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Controller used to render the error page, with a provided exception.
+ * It is actually used as a Slim error handler.
+ */
+class ErrorController extends ShaarliVisitorController
+{
+    public function __invoke(Request $request, Response $response, \Throwable $throwable): Response
+    {
+        // Unknown error encountered
+        $this->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'));
+    }
+}
diff --git a/application/front/controller/visitor/FeedController.php b/application/front/controller/visitor/FeedController.php
new file mode 100644 (file)
index 0000000..da2848c
--- /dev/null
@@ -0,0 +1,58 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use Shaarli\Feed\FeedBuilder;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Class FeedController
+ *
+ * Slim controller handling ATOM and RSS feed.
+ */
+class FeedController extends ShaarliVisitorController
+{
+    public function atom(Request $request, Response $response): Response
+    {
+        return $this->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->executePageHooks('render_feed', $data, $feedType);
+        $this->assignAllView($data);
+
+        $content = $this->render('feed.'. $feedType);
+
+        $cache->cache($content);
+
+        return $response->write($content);
+    }
+}
diff --git a/application/front/controller/visitor/InstallController.php b/application/front/controller/visitor/InstallController.php
new file mode 100644 (file)
index 0000000..7cb3277
--- /dev/null
@@ -0,0 +1,165 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use Shaarli\ApplicationUtils;
+use Shaarli\Container\ShaarliContainer;
+use Shaarli\Front\Exception\AlreadyInstalledException;
+use Shaarli\Front\Exception\ResourcePermissionException;
+use Shaarli\Languages;
+use Shaarli\Security\SessionManager;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Slim controller used to render install page, and create initial configuration file.
+ */
+class InstallController extends ShaarliVisitorController
+{
+    public const SESSION_TEST_KEY = 'session_tested';
+    public const SESSION_TEST_VALUE = 'Working';
+
+    public function __construct(ShaarliContainer $container)
+    {
+        parent::__construct($container);
+
+        if (is_file($this->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(
+                '<pre>Sessions do not seem to work correctly on your server.<br>'.
+                'Make sure the variable "session.save_path" is set correctly in your PHP config, '.
+                'and that you have write access to it.<br>'.
+                'It currently points to %s.<br>'.
+                '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.<br>'
+            );
+            $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')
+            )
+        );
+        $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', t('Error while writing config file after configuration update.'));
+            $this->assignView('stacktrace', $e->getMessage() . PHP_EOL . $e->getTraceAsString());
+
+            return $response->write($this->render('error'));
+        }
+
+        $this->container->sessionManager->setSessionParameter(
+            SessionManager::KEY_SUCCESS_MESSAGES,
+            [t('Shaarli is now configured. Please login and start shaaring your bookmarks!')]
+        );
+
+        return $this->redirect($response, '/login');
+    }
+
+    protected function checkPermissions(): bool
+    {
+        // Ensure Shaarli has proper access to its resources
+        $errors = ApplicationUtils::checkResourcePermissions($this->container->conf);
+        if (empty($errors)) {
+            return true;
+        }
+
+        $message = t('Insufficient permissions:') . PHP_EOL;
+        foreach ($errors as $error) {
+            $message .= PHP_EOL . $error;
+        }
+
+        throw new ResourcePermissionException($message);
+    }
+}
diff --git a/application/front/controller/visitor/LoginController.php b/application/front/controller/visitor/LoginController.php
new file mode 100644 (file)
index 0000000..121ba40
--- /dev/null
@@ -0,0 +1,154 @@
+<?php
+
+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;
+
+/**
+ * Class LoginController
+ *
+ * Slim controller used to render the login page.
+ *
+ * The login page is not available if the user is banned
+ * or if open shaarli setting is enabled.
+ */
+class LoginController extends ShaarliVisitorController
+{
+    /**
+     * GET /login - Display the login page.
+     */
+    public function index(Request $request, Response $response): Response
+    {
+        try {
+            $this->checkLoginState();
+        } catch (CantLoginException $e) {
+            return $this->redirect($response, '/');
+        }
+
+        if ($request->getParam('login') !== null) {
+            $this->assignView('username', escape($request->getParam('login')));
+        }
+
+        $returnUrl = $request->getParam('returnurl') ?? $this->container->environment['HTTP_REFERER'] ?? null;
+
+        $this
+            ->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', 'install']);
+    }
+
+    /**
+     * 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/controller/visitor/OpenSearchController.php b/application/front/controller/visitor/OpenSearchController.php
new file mode 100644 (file)
index 0000000..36d60ac
--- /dev/null
@@ -0,0 +1,27 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use Shaarli\Render\TemplatePage;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Class OpenSearchController
+ *
+ * Slim controller used to render open search template.
+ * This allows to add Shaarli as a search engine within the browser.
+ */
+class OpenSearchController extends ShaarliVisitorController
+{
+    public function index(Request $request, Response $response): Response
+    {
+        $response = $response->withHeader('Content-Type', 'application/opensearchdescription+xml; charset=utf-8');
+
+        $this->assignView('serverurl', index_url($this->container->environment));
+
+        return $response->write($this->render(TemplatePage::OPEN_SEARCH));
+    }
+}
diff --git a/application/front/controller/visitor/PictureWallController.php b/application/front/controller/visitor/PictureWallController.php
new file mode 100644 (file)
index 0000000..3c57f8d
--- /dev/null
@@ -0,0 +1,54 @@
+<?php
+
+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;
+
+/**
+ * Class PicturesWallController
+ *
+ * Slim controller used to render the pictures wall page.
+ * If thumbnails mode is set to NONE, we just render the template without any image.
+ */
+class PictureWallController extends ShaarliVisitorController
+{
+    public function index(Request $request, Response $response): Response
+    {
+        if ($this->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 = ['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));
+    }
+}
diff --git a/application/front/controller/visitor/PublicSessionFilterController.php b/application/front/controller/visitor/PublicSessionFilterController.php
new file mode 100644 (file)
index 0000000..1a66362
--- /dev/null
@@ -0,0 +1,46 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use Shaarli\Security\SessionManager;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Slim controller used to handle filters stored in the visitor session, links per page, etc.
+ */
+class PublicSessionFilterController extends ShaarliVisitorController
+{
+    /**
+     * 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 /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/visitor/ShaarliVisitorController.php b/application/front/controller/visitor/ShaarliVisitorController.php
new file mode 100644 (file)
index 0000000..f17c8ed
--- /dev/null
@@ -0,0 +1,171 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use Shaarli\Bookmark\BookmarkFilter;
+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 */
+    protected $container;
+
+    /** @param ShaarliContainer $container Slim container (extended for attribute completion). */
+    public function __construct(ShaarliContainer $container)
+    {
+        $this->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->executeDefaultHooks($template);
+
+        $this->assignView('plugin_errors', $this->container->pluginManager->getErrors());
+
+        return $this->container->pageBuilder->render($template, $this->container->basePath);
+    }
+
+    /**
+     * 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) {
+            $pluginData = [];
+            $this->container->pluginManager->executeHooks(
+                'render_' . $name,
+                $pluginData,
+                [
+                    'target' => $template,
+                    '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.
+     *
+     * @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.
+     *
+     * @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 = [],
+        string $anchor = null
+    ): Response {
+        $defaultPath = $this->container->basePath . '/';
+        $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) : '';
+        $anchor = $anchor ? '#' . $anchor : '';
+
+        return $response->withRedirect($path . $queryString . $anchor);
+    }
+}
diff --git a/application/front/controller/visitor/TagCloudController.php b/application/front/controller/visitor/TagCloudController.php
new file mode 100644 (file)
index 0000000..f9c529b
--- /dev/null
@@ -0,0 +1,113 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Class TagCloud
+ *
+ * Slim controller used to render the tag cloud and tag list pages.
+ */
+class TagCloudController extends ShaarliVisitorController
+{
+    protected const TYPE_CLOUD = 'cloud';
+    protected const TYPE_LIST = 'list';
+
+    /**
+     * 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
+    {
+        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);
+
+        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,
+        ];
+        $this->executePageHooks('render_tag' . $type, $data, 'tag.' . $type);
+        $this->assignAllView($data);
+
+        $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<string, int> $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;
+    }
+}
diff --git a/application/front/controller/visitor/TagController.php b/application/front/controller/visitor/TagController.php
new file mode 100644 (file)
index 0000000..de4e7ea
--- /dev/null
@@ -0,0 +1,118 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Class TagController
+ *
+ * Slim controller handle tags.
+ */
+class TagController extends ShaarliVisitorController
+{
+    /**
+     * Add another tag in the current search through an HTTP redirection.
+     *
+     * @param array $args Should contain `newTag` key as tag to add to current search
+     */
+    public function addTag(Request $request, Response $response, array $args): Response
+    {
+        $newTag = $args['newTag'] ?? null;
+        $referer = $this->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 $this->redirect($response, '/?searchtags='. urlencode($newTag));
+            }
+
+            return $this->redirect($response, '/');
+        }
+
+        $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 $this->redirect($response, '/');
+        }
+
+        $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/LoginController.php b/application/front/controllers/LoginController.php
deleted file mode 100644 (file)
index ae3599e..0000000
+++ /dev/null
@@ -1,48 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Shaarli\Front\Controller;
-
-use Shaarli\Front\Exception\LoginBannedException;
-use Slim\Http\Request;
-use Slim\Http\Response;
-
-/**
- * Class LoginController
- *
- * Slim controller used to render the login page.
- *
- * The login page is not available if the user is banned
- * or if open shaarli setting is enabled.
- *
- * @package Front\Controller
- */
-class LoginController extends ShaarliController
-{
-    public function index(Request $request, Response $response): Response
-    {
-        if ($this->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/ShaarliController.php b/application/front/controllers/ShaarliController.php
deleted file mode 100644 (file)
index 2b82858..0000000
+++ /dev/null
@@ -1,69 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Shaarli\Front\Controller;
-
-use Shaarli\Bookmark\BookmarkFilter;
-use Shaarli\Container\ShaarliContainer;
-
-abstract class ShaarliController
-{
-    /** @var ShaarliContainer */
-    protected $container;
-
-    /** @param ShaarliContainer $container Slim container (extended for attribute completion). */
-    public function __construct(ShaarliContainer $container)
-    {
-        $this->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;
-    }
-
-    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);
-        }
-    }
-}
diff --git a/application/front/exceptions/AlreadyInstalledException.php b/application/front/exceptions/AlreadyInstalledException.php
new file mode 100644 (file)
index 0000000..4add86c
--- /dev/null
@@ -0,0 +1,15 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Exception;
+
+class AlreadyInstalledException extends ShaarliFrontException
+{
+    public function __construct()
+    {
+        $message = t('Shaarli has already been installed. Login to edit the configuration.');
+
+        parent::__construct($message, 401);
+    }
+}
diff --git a/application/front/exceptions/CantLoginException.php b/application/front/exceptions/CantLoginException.php
new file mode 100644 (file)
index 0000000..cd16635
--- /dev/null
@@ -0,0 +1,10 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Exception;
+
+class CantLoginException extends \Exception
+{
+
+}
index b31a4a14ead0015f49195b11c0e584193abfa24c..79d0ea152ad21428102cc99a9ee2117cac4509cc 100644 (file)
@@ -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/OpenShaarliPasswordException.php b/application/front/exceptions/OpenShaarliPasswordException.php
new file mode 100644 (file)
index 0000000..a6f0b3a
--- /dev/null
@@ -0,0 +1,18 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Exception;
+
+/**
+ * Class OpenShaarliPasswordException
+ *
+ * Raised if the user tries to change the admin password on an open shaarli instance.
+ */
+class OpenShaarliPasswordException extends ShaarliFrontException
+{
+    public function __construct()
+    {
+        parent::__construct(t('You are not supposed to change a password on an Open Shaarli.'), 403);
+    }
+}
diff --git a/application/front/exceptions/ResourcePermissionException.php b/application/front/exceptions/ResourcePermissionException.php
new file mode 100644 (file)
index 0000000..8fbf03b
--- /dev/null
@@ -0,0 +1,13 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Exception;
+
+class ResourcePermissionException extends ShaarliFrontException
+{
+    public function __construct(string $message)
+    {
+        parent::__construct($message, 500);
+    }
+}
similarity index 73%
rename from application/front/exceptions/ShaarliException.php
rename to application/front/exceptions/ShaarliFrontException.php
index 800bfbec34c619c09eb7d653aca58303758896d1..73847e6d17bf6ec2a33595766bd5ddce779d7523 100644 (file)
@@ -9,11 +9,11 @@ use Throwable;
 /**
  * Class ShaarliException
  *
- * Abstract exception class used to defined any custom exception thrown during front rendering.
+ * Exception class used to defined any custom exception thrown during front rendering.
  *
  * @package Front\Exception
  */
-abstract class ShaarliException extends \Exception
+class ShaarliFrontException extends \Exception
 {
     /** Override parent constructor to force $message and $httpCode parameters to be set. */
     public function __construct(string $message, int $httpCode, Throwable $previous = null)
diff --git a/application/front/exceptions/ThumbnailsDisabledException.php b/application/front/exceptions/ThumbnailsDisabledException.php
new file mode 100644 (file)
index 0000000..0ed337f
--- /dev/null
@@ -0,0 +1,15 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Exception;
+
+class ThumbnailsDisabledException extends ShaarliFrontException
+{
+    public function __construct()
+    {
+        $message = t('Picture wall unavailable (thumbnails are disabled).');
+
+        parent::__construct($message, 400);
+    }
+}
diff --git a/application/front/exceptions/UnauthorizedException.php b/application/front/exceptions/UnauthorizedException.php
new file mode 100644 (file)
index 0000000..4231094
--- /dev/null
@@ -0,0 +1,15 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Exception;
+
+/**
+ * Class UnauthorizedException
+ *
+ * Exception raised if the user tries to access a ShaarliAdminController while logged out.
+ */
+class UnauthorizedException extends \Exception
+{
+
+}
diff --git a/application/front/exceptions/WrongTokenException.php b/application/front/exceptions/WrongTokenException.php
new file mode 100644 (file)
index 0000000..4200272
--- /dev/null
@@ -0,0 +1,18 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Exception;
+
+/**
+ * Class OpenShaarliPasswordException
+ *
+ * Raised if the user tries to perform an action with an invalid XSRF token.
+ */
+class WrongTokenException extends ShaarliFrontException
+{
+    public function __construct()
+    {
+        parent::__construct(t('Wrong token.'), 403);
+    }
+}
diff --git a/application/http/HttpAccess.php b/application/http/HttpAccess.php
new file mode 100644 (file)
index 0000000..81d9e07
--- /dev/null
@@ -0,0 +1,39 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Http;
+
+/**
+ * Class HttpAccess
+ *
+ * This is mostly an OOP wrapper for HTTP functions defined in `HttpUtils`.
+ * It is used as dependency injection in Shaarli's container.
+ *
+ * @package Shaarli\Http
+ */
+class HttpAccess
+{
+    public function getHttpResponse($url, $timeout = 30, $maxBytes = 4194304, $curlWriteFunction = null)
+    {
+        return get_http_response($url, $timeout, $maxBytes, $curlWriteFunction);
+    }
+
+    public function getCurlDownloadCallback(
+        &$charset,
+        &$title,
+        &$description,
+        &$keywords,
+        $retrieveDescription,
+        $curlGetInfo = 'curl_getinfo'
+    ) {
+        return get_curl_download_callback(
+            $charset,
+            $title,
+            $description,
+            $keywords,
+            $retrieveDescription,
+            $curlGetInfo
+        );
+    }
+}
index 2ea9195d3550bfd4bfd912ef35cb4b669a5c83c2..4fc4e3dcff08f3457c7a2d541962c699979732c7 100644 (file)
@@ -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;
 }
 
 /**
@@ -477,3 +484,109 @@ function is_https($server)
 
     return ! empty($server['HTTPS']);
 }
+
+/**
+ * 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);
+    };
+}
diff --git a/application/legacy/LegacyController.php b/application/legacy/LegacyController.php
new file mode 100644 (file)
index 0000000..26465d2
--- /dev/null
@@ -0,0 +1,130 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Legacy;
+
+use Shaarli\Feed\FeedBuilder;
+use Shaarli\Front\Controller\Visitor\ShaarliVisitorController;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * We use this to maintain legacy routes, and redirect requests to the corresponding Slim route.
+ * Only public routes, and both `?addlink` and `?post` were kept here.
+ * Other routes will just display the linklist.
+ *
+ * @deprecated
+ */
+class LegacyController extends ShaarliVisitorController
+{
+    /** @var string[] Both `?post` and `?addlink` do not use `?do=` format. */
+    public const LEGACY_GET_ROUTES = [
+        'post',
+        'addlink',
+    ];
+
+    /**
+     * This method will call `$action` method, which will redirect to corresponding Slim route.
+     */
+    public function process(Request $request, Response $response, string $action): Response
+    {
+        if (!method_exists($this, $action)) {
+            throw new UnknowLegacyRouteException();
+        }
+
+        return $this->{$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, '/admin/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);
+    }
+}
index 7ccf5e54a9c4cb9dfb70974a99197a545ef2c92a..7bf76fd471087fe0477b935b4cb1bf771ae1ab46 100644 (file)
@@ -9,6 +9,7 @@ use Iterator;
 use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
 use Shaarli\Exceptions\IOException;
 use Shaarli\FileUtils;
+use Shaarli\Render\PageCacheManager;
 
 /**
  * Data storage for bookmarks.
@@ -352,7 +353,8 @@ You use the community supported version of the original Shaarli project, by Seba
 
         $this->write();
 
-        invalidateCaches($pageCacheDir);
+        $pageCacheManager = new PageCacheManager($pageCacheDir, $this->loggedIn);
+        $pageCacheManager->invalidateCaches();
     }
 
     /**
similarity index 98%
rename from application/Router.php
rename to application/legacy/LegacyRouter.php
index d7187487e6ff3a736e797623577af7bc9998ebf3..cea99154cd5885ad495156fd8cfcf68c746f89a5 100644 (file)
@@ -1,12 +1,15 @@
 <?php
-namespace Shaarli;
+
+namespace Shaarli\Legacy;
 
 /**
  * Class Router
  *
  * (only displayable pages here)
+ *
+ * @deprecated
  */
-class Router
+class LegacyRouter
 {
     public static $AJAX_THUMB_UPDATE = 'ajax_thumb_update';
 
index 3a5de79f871c0159a8beaca22d82a6593f02a08d..0ab3a55bd572898b07e51099fc8bd7ae23f5d7fe 100644 (file)
@@ -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,8 @@ class LegacyUpdater
 
         if ($thumbnailsEnabled) {
             $this->session['warnings'][] = t(
-                'You have enabled or changed thumbnails mode. <a href="?do=thumbs_update">Please synchronize them</a>.'
+                t('You have enabled or changed thumbnails mode.') .
+                '<a href="./admin/thumbnails">' . t('Please synchronize them.') . '</a>'
             );
         }
 
diff --git a/application/legacy/UnknowLegacyRouteException.php b/application/legacy/UnknowLegacyRouteException.php
new file mode 100644 (file)
index 0000000..ae1518a
--- /dev/null
@@ -0,0 +1,9 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Legacy;
+
+class UnknowLegacyRouteException extends \Exception
+{
+}
index d64eef7f802d05a3685a7a9f294287a58a0298b6..b83f16f8eb8e49895bddaac648d1046c25a083c7 100644 (file)
@@ -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;
@@ -16,10 +17,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 +43,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,11 +64,11 @@ 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) {
-                $link['url'] = $indexUrl . $link['url'];
+                $link['url'] = rtrim($indexUrl, '/') . '/' . ltrim($link['url'], '/');
             }
 
             $bookmarkLinks[] = $link;
@@ -64,61 +77,23 @@ 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
+     * @param array                 $post Server $_POST parameters
+     * @param UploadedFileInterface $file File in PSR-7 object format
      *
      * @return string Summary of the bookmark import status
      */
-    public static function import($post, $files, $bookmarkService, $conf, $history)
+    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('/<!DOCTYPE NETSCAPE-Bookmark-file-1>/i', $data) === 0) {
-            return self::importStatus($filename, $filesize);
+            return $this->importStatus($filename, $filesize);
         }
 
         // Overwrite existing bookmarks?
@@ -141,11 +116,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 +146,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 +168,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 +191,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;
+    }
 }
index f7b24a8e88c979031873ee1a8a59fe88503c4bf5..2d93cb3a2173c58bada5803b8ca4fd522819991f 100644 (file)
@@ -16,7 +16,7 @@ class PluginManager
      *
      * @var array $authorizedPlugins
      */
-    private $authorizedPlugins;
+    private $authorizedPlugins = [];
 
     /**
      * List of loaded plugins.
@@ -108,11 +108,20 @@ 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);
 
             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]));
+                }
             }
         }
     }
index f4fefda84f448297374fc1b5e47ad159f44c1bae..7a7166732392a5726227bd33d769f0dd8abbc96e 100644 (file)
@@ -3,10 +3,12 @@
 namespace Shaarli\Render;
 
 use Exception;
+use exceptions\MissingBasePathException;
 use RainTPL;
 use Shaarli\ApplicationUtils;
 use Shaarli\Bookmark\BookmarkServiceInterface;
 use Shaarli\Config\ConfigManager;
+use Shaarli\Security\SessionManager;
 use Shaarli\Thumbnailer;
 
 /**
@@ -68,6 +70,15 @@ class PageBuilder
         $this->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.
      */
@@ -136,17 +147,40 @@ class PageBuilder
         $this->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);
     }
 
+    /**
+     * Affect variable after controller processing.
+     * Used for alert messages.
+     */
+    protected function finalize(string $basePath): 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]);
+            }
+        }
+
+        $this->assign('base_path', $basePath);
+        $this->assign(
+            'asset_path',
+            $basePath . '/' .
+            rtrim($this->conf->get('resource.raintpl_tpl', 'tpl'), '/') . '/' .
+            $this->conf->get('resource.theme', 'default')
+        );
+    }
+
     /**
      * The following assign() method is basically the same as RainTPL (except lazy loading)
      *
@@ -184,21 +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->tpl->draw($page);
-    }
-
     /**
      * Render a specific page as string (using a template file).
      * e.g. $pb->render('picwall');
@@ -207,28 +226,14 @@ 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();
         }
 
-        return $this->tpl->draw($page, true);
-    }
+        $this->finalize($basePath);
 
-    /**
-     * 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');
+        return $this->tpl->draw($page, true);
     }
 }
diff --git a/application/render/PageCacheManager.php b/application/render/PageCacheManager.php
new file mode 100644 (file)
index 0000000..97805c3
--- /dev/null
@@ -0,0 +1,60 @@
+<?php
+
+namespace Shaarli\Render;
+
+use Shaarli\Feed\CachedPage;
+
+/**
+ * Cache utilities
+ */
+class PageCacheManager
+{
+    /** @var string Cache directory */
+    protected $pageCacheDir;
+
+    /** @var bool */
+    protected $isLoggedIn;
+
+    public function __construct(string $pageCacheDir, bool $isLoggedIn)
+    {
+        $this->pageCacheDir = $pageCacheDir;
+        $this->isLoggedIn = $isLoggedIn;
+    }
+
+    /**
+     * 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();
+    }
+
+    public function getCachePage(string $pageUrl): CachedPage
+    {
+        return new CachedPage(
+            $this->pageCacheDir,
+            $pageUrl,
+            false === $this->isLoggedIn
+        );
+    }
+}
diff --git a/application/render/TemplatePage.php b/application/render/TemplatePage.php
new file mode 100644 (file)
index 0000000..8af8228
--- /dev/null
@@ -0,0 +1,33 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Render;
+
+interface TemplatePage
+{
+    public const ERROR_404 = '404';
+    public const ADDLINK = 'addlink';
+    public const CHANGE_PASSWORD = 'changepassword';
+    public const CHANGE_TAG = 'changetag';
+    public const CONFIGURE = 'configure';
+    public const DAILY = 'daily';
+    public const DAILY_RSS = 'dailyrss';
+    public const EDIT_LINK = 'editlink';
+    public const ERROR = 'error';
+    public const EXPORT = 'export';
+    public const NETSCAPE_EXPORT_BOOKMARKS = 'export.bookmarks';
+    public const FEED_ATOM = 'feed.atom';
+    public const FEED_RSS = 'feed.rss';
+    public const IMPORT = 'import';
+    public const INSTALL = 'install';
+    public const LINKLIST = 'linklist';
+    public const LOGIN = 'loginform';
+    public const OPEN_SEARCH = 'opensearch';
+    public const PICTURE_WALL = 'picwall';
+    public const PLUGINS_ADMIN = 'pluginsadmin';
+    public const TAG_CLOUD = 'tag.cloud';
+    public const TAG_LIST = 'tag.list';
+    public const THUMBNAILS = 'thumbnails';
+    public const TOOLS = 'tools';
+}
diff --git a/application/security/CookieManager.php b/application/security/CookieManager.php
new file mode 100644 (file)
index 0000000..cde4746
--- /dev/null
@@ -0,0 +1,33 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Security;
+
+class CookieManager
+{
+    /** @var string Name of the cookie set after logging in **/
+    public const STAY_SIGNED_IN = 'shaarli_staySignedIn';
+
+    /** @var mixed $_COOKIE set by reference */
+    protected $cookies;
+
+    public function __construct(array &$cookies)
+    {
+        $this->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;
+    }
+}
index 39ec9b2e7fffa92688ab29dbc3e2a551a9b5967b..d74c3118c4eded1713a339ebc824d1978c83f93d 100644 (file)
@@ -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);
index 994fcbe52ceb30cd4086369c46c89b9d66832260..76b0afe84283b05a6fe2d17ebc4642eecfbbf11c 100644 (file)
@@ -8,6 +8,14 @@ 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';
+
+    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
 
@@ -23,16 +31,35 @@ 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;
+    }
+
+    /**
+     * 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);
+        }
     }
 
     /**
@@ -202,4 +229,78 @@ 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;
+    }
+
+    /**
+     * 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;
+    }
+
+    public function getSavePath(): string
+    {
+        return $this->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);
+    }
 }
index 95654d81da96c0d23c2d26424c47983b39c25961..88a7bc7b27337a0c572647f0d2ac1ef3027939db 100644 (file)
@@ -2,8 +2,8 @@
 
 namespace Shaarli\Updater;
 
-use Shaarli\Config\ConfigManager;
 use Shaarli\Bookmark\BookmarkServiceInterface;
+use Shaarli\Config\ConfigManager;
 use Shaarli\Updater\Exception\UpdaterException;
 
 /**
@@ -21,7 +21,7 @@ class Updater
     /**
      * @var BookmarkServiceInterface instance.
      */
-    protected $linkServices;
+    protected $bookmarkService;
 
     /**
      * @var ConfigManager $conf Configuration Manager instance.
@@ -38,6 +38,11 @@ class Updater
      */
     protected $methods;
 
+    /**
+     * @var string $basePath Shaarli root directory (from HTTP Request)
+     */
+    protected $basePath = null;
+
     /**
      * Object constructor.
      *
@@ -49,7 +54,7 @@ class Updater
     public function __construct($doneUpdates, $linkDB, $conf, $isLoggedIn)
     {
         $this->doneUpdates = $doneUpdates;
-        $this->linkServices = $linkDB;
+        $this->bookmarkService = $linkDB;
         $this->conf = $conf;
         $this->isLoggedIn = $isLoggedIn;
 
@@ -62,13 +67,15 @@ 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 = array();
+        $updatesRan = [];
 
         // If the user isn't logged in, exit without updating.
         if ($this->isLoggedIn !== true) {
@@ -111,4 +118,62 @@ 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 `/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
+    {
+        if ('?' === trim($this->conf->get('general.header_link'))) {
+            $this->conf->set('general.header_link', $this->basePath . '/', true, true);
+        }
+
+        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;
+    }
+
+    public function setBasePath(string $basePath): self
+    {
+        $this->basePath = $basePath;
+
+        return $this;
+    }
 }
index b66ca3ae1e5114bc8e3d2a73abb29d8b0d389a6d..3cd4c2a761f778989e682ee181421200abe012cc 100644 (file)
  * It contains a recursive call to retrieve the thumb of the next link when it succeed.
  * It also update the progress bar and other visual feedback elements.
  *
+ * @param {string} basePath Shaarli subfolder for XHR requests
  * @param {array}  ids      List of LinkID to update
  * @param {int}    i        Current index in ids
  * @param {object} elements List of DOM element to avoid retrieving them at each iteration
  */
-function updateThumb(ids, i, elements) {
+function updateThumb(basePath, ids, i, elements) {
   const xhr = new XMLHttpRequest();
-  xhr.open('POST', '?do=ajax_thumb_update');
+  xhr.open('PATCH', `${basePath}/admin/shaare/${ids[i]}/update-thumbnail`);
   xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
   xhr.responseType = 'json';
   xhr.onload = () => {
@@ -29,17 +30,18 @@ function updateThumb(ids, i, elements) {
       elements.current.innerHTML = i;
       elements.title.innerHTML = response.title;
       if (response.thumbnail !== false) {
-        elements.thumbnail.innerHTML = `<img src="${response.thumbnail}">`;
+        elements.thumbnail.innerHTML = `<img src="${basePath}/${response.thumbnail}">`;
       }
       if (i < ids.length) {
-        updateThumb(ids, i, elements);
+        updateThumb(basePath, ids, i, elements);
       }
     }
   };
-  xhr.send(`id=${ids[i]}`);
+  xhr.send();
 }
 
 (() => {
+  const basePath = document.querySelector('input[name="js_base_path"]').value;
   const ids = document.getElementsByName('ids')[0].value.split(',');
   const elements = {
     progressBar: document.querySelector('.progressbar > div'),
@@ -47,5 +49,5 @@ function updateThumb(ids, i, elements) {
     thumbnail: document.querySelector('.thumbnail-placeholder'),
     title: document.querySelector('.thumbnail-link-title'),
   };
-  updateThumb(ids, 0, elements);
+  updateThumb(basePath, ids, 0, elements);
 })();
index d5c29c695295ae574ec27ed5d06cf4b71bdd5f7a..0f29799d148d8f864ff009ef2f7034aa5bbf07c3 100644 (file)
@@ -25,12 +25,16 @@ function findParent(element, tagName, attributes) {
 /**
  * Ajax request to refresh the CSRF token.
  */
-function refreshToken() {
+function refreshToken(basePath) {
+  console.log('refresh');
   const xhr = new XMLHttpRequest();
-  xhr.open('GET', '?do=token');
+  xhr.open('GET', `${basePath}/admin/token`);
   xhr.onload = () => {
-    const token = document.getElementById('token');
-    token.setAttribute('value', xhr.responseText);
+    const elements = document.querySelectorAll('input[name="token"]');
+    [...elements].forEach((element) => {
+      console.log(element);
+      element.setAttribute('value', xhr.responseText);
+    });
   };
   xhr.send();
 }
@@ -215,6 +219,8 @@ function init(description) {
 }
 
 (() => {
+  const basePath = document.querySelector('input[name="js_base_path"]').value;
+
   /**
    * Handle responsive menu.
    * Source: http://purecss.io/layouts/tucked-menu-vertical/
@@ -461,7 +467,7 @@ function init(description) {
       });
 
       if (window.confirm(message)) {
-        window.location = `?delete_link&lf_linkdate=${ids.join('+')}&token=${token.value}`;
+        window.location = `${basePath}/admin/shaare/delete?id=${ids.join('+')}&token=${token.value}`;
       }
     });
   }
@@ -483,7 +489,8 @@ function init(description) {
         });
 
         const ids = links.map(item => item.id);
-        window.location = `?change_visibility&token=${token.value}&newVisibility=${visibility}&ids=${ids.join('+')}`;
+        window.location =
+          `${basePath}/admin/shaare/visibility?token=${token.value}&newVisibility=${visibility}&id=${ids.join('+')}`;
       });
     });
   }
@@ -546,7 +553,7 @@ function init(description) {
       const refreshedToken = document.getElementById('token').value;
       const fromtag = block.getAttribute('data-tag');
       const xhr = new XMLHttpRequest();
-      xhr.open('POST', '?do=changetag');
+      xhr.open('POST', `${basePath}/admin/tags`);
       xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
       xhr.onload = () => {
         if (xhr.status !== 200) {
@@ -558,8 +565,12 @@ function init(description) {
           input.setAttribute('value', totag);
           findParent(input, 'div', { class: 'rename-tag-form' }).style.display = 'none';
           block.querySelector('a.tag-link').innerHTML = htmlEntities(totag);
-          block.querySelector('a.tag-link').setAttribute('href', `?searchtags=${encodeURIComponent(totag)}`);
-          block.querySelector('a.rename-tag').setAttribute('href', `?do=changetag&fromtag=${encodeURIComponent(totag)}`);
+          block
+            .querySelector('a.tag-link')
+            .setAttribute('href', `${basePath}/?searchtags=${encodeURIComponent(totag)}`);
+          block
+            .querySelector('a.rename-tag')
+            .setAttribute('href', `${basePath}/admin/tags?fromtag=${encodeURIComponent(totag)}`);
 
           // Refresh awesomplete values
           existingTags = existingTags.map(tag => (tag === fromtag ? totag : tag));
@@ -567,7 +578,7 @@ function init(description) {
         }
       };
       xhr.send(`renametag=1&fromtag=${encodeURIComponent(fromtag)}&totag=${encodeURIComponent(totag)}&token=${refreshedToken}`);
-      refreshToken();
+      refreshToken(basePath);
     });
   });
 
@@ -593,13 +604,13 @@ function init(description) {
 
       if (confirm(`Are you sure you want to delete the tag "${tag}"?`)) {
         const xhr = new XMLHttpRequest();
-        xhr.open('POST', '?do=changetag');
+        xhr.open('POST', `${basePath}/admin/tags`);
         xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
         xhr.onload = () => {
           block.remove();
         };
         xhr.send(encodeURI(`deletetag=1&fromtag=${tag}&token=${refreshedToken}`));
-        refreshToken();
+        refreshToken(basePath);
 
         existingTags = existingTags.filter(tagItem => tagItem !== tag);
         awesomepletes = updateAwesompleteList('.rename-tag-input', existingTags, awesomepletes);
index 243ab1b27b050af425af433e0e79628350586403..759dff29828f9bce824d7f5f9e185a23c6b70458 100644 (file)
@@ -490,6 +490,10 @@ body,
   }
 }
 
+.header-alert-message {
+  text-align: center;
+}
+
 // CONTENT - GENERAL
 .container {
   position: relative;
index 6b670fa21636af4c395ddf5fbd5c09b388371bb4..738d9f5887f1a72b5b5b538ec6e03a7687db05df 100644 (file)
@@ -53,7 +53,8 @@
             "Shaarli\\Feed\\": "application/feed",
             "Shaarli\\Formatter\\": "application/formatter",
             "Shaarli\\Front\\": "application/front",
-            "Shaarli\\Front\\Controller\\": "application/front/controllers",
+            "Shaarli\\Front\\Controller\\Admin\\": "application/front/controller/admin",
+            "Shaarli\\Front\\Controller\\Visitor\\": "application/front/controller/visitor",
             "Shaarli\\Front\\Exception\\": "application/front/exceptions",
             "Shaarli\\Http\\": "application/http",
             "Shaarli\\Legacy\\": "application/legacy",
index b3373a3263c1c895814e929ab4363b28832323a9..ae7a9269f61e9b8c41351a96a9877f2004a36ce4 100644 (file)
         },
         {
             "name": "psr/log",
-            "version": "1.1.2",
+            "version": "1.1.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/php-fig/log.git",
-                "reference": "446d54b4cb6bf489fc9d75f55843658e6f25d801"
+                "reference": "0f73288fd15629204f9d42b7055f72dacbe811fc"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/php-fig/log/zipball/446d54b4cb6bf489fc9d75f55843658e6f25d801",
-                "reference": "446d54b4cb6bf489fc9d75f55843658e6f25d801",
+                "url": "https://api.github.com/repos/php-fig/log/zipball/0f73288fd15629204f9d42b7055f72dacbe811fc",
+                "reference": "0f73288fd15629204f9d42b7055f72dacbe811fc",
                 "shasum": ""
             },
             "require": {
                 "psr",
                 "psr-3"
             ],
-            "time": "2019-11-01T11:05:21+00:00"
+            "time": "2020-03-23T09:12:05+00:00"
         },
         {
             "name": "pubsubhubbub/publisher",
         },
         {
             "name": "phpdocumentor/reflection-common",
-            "version": "2.0.0",
+            "version": "2.1.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/phpDocumentor/ReflectionCommon.git",
-                "reference": "63a995caa1ca9e5590304cd845c15ad6d482a62a"
+                "reference": "6568f4687e5b41b054365f9ae03fcb1ed5f2069b"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/63a995caa1ca9e5590304cd845c15ad6d482a62a",
-                "reference": "63a995caa1ca9e5590304cd845c15ad6d482a62a",
+                "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/6568f4687e5b41b054365f9ae03fcb1ed5f2069b",
+                "reference": "6568f4687e5b41b054365f9ae03fcb1ed5f2069b",
                 "shasum": ""
             },
             "require": {
                 "php": ">=7.1"
             },
-            "require-dev": {
-                "phpunit/phpunit": "~6"
-            },
             "type": "library",
             "extra": {
                 "branch-alias": {
                 "reflection",
                 "static analysis"
             ],
-            "time": "2018-08-07T13:53:10+00:00"
+            "time": "2020-04-27T09:25:28+00:00"
         },
         {
             "name": "phpdocumentor/reflection-docblock",
         },
         {
             "name": "phpspec/prophecy",
-            "version": "1.10.1",
+            "version": "v1.10.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/phpspec/prophecy.git",
-                "reference": "cbe1df668b3fe136bcc909126a0f529a78d4cbbc"
+                "reference": "451c3cd1418cf640de218914901e51b064abb093"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/phpspec/prophecy/zipball/cbe1df668b3fe136bcc909126a0f529a78d4cbbc",
-                "reference": "cbe1df668b3fe136bcc909126a0f529a78d4cbbc",
+                "url": "https://api.github.com/repos/phpspec/prophecy/zipball/451c3cd1418cf640de218914901e51b064abb093",
+                "reference": "451c3cd1418cf640de218914901e51b064abb093",
                 "shasum": ""
             },
             "require": {
                 "doctrine/instantiator": "^1.0.2",
                 "php": "^5.3|^7.0",
                 "phpdocumentor/reflection-docblock": "^2.0|^3.0.2|^4.0|^5.0",
-                "sebastian/comparator": "^1.2.3|^2.0|^3.0",
-                "sebastian/recursion-context": "^1.0|^2.0|^3.0"
+                "sebastian/comparator": "^1.2.3|^2.0|^3.0|^4.0",
+                "sebastian/recursion-context": "^1.0|^2.0|^3.0|^4.0"
             },
             "require-dev": {
                 "phpspec/phpspec": "^2.5 || ^3.2",
                 "spy",
                 "stub"
             ],
-            "time": "2019-12-22T21:05:45+00:00"
+            "time": "2020-03-05T15:02:03+00:00"
         },
         {
             "name": "phpunit/php-code-coverage",
             "source": {
                 "type": "git",
                 "url": "https://github.com/Roave/SecurityAdvisories.git",
-                "reference": "67ac6ea8f4a078c3c9b7aec5d7ae70f098c37389"
+                "reference": "5a342e2dc0408d026b97ee3176b5b406e54e3766"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/67ac6ea8f4a078c3c9b7aec5d7ae70f098c37389",
-                "reference": "67ac6ea8f4a078c3c9b7aec5d7ae70f098c37389",
+                "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/5a342e2dc0408d026b97ee3176b5b406e54e3766",
+                "reference": "5a342e2dc0408d026b97ee3176b5b406e54e3766",
                 "shasum": ""
             },
             "conflict": {
                 "api-platform/core": ">=2.2,<2.2.10|>=2.3,<2.3.6",
                 "asymmetricrypt/asymmetricrypt": ">=0,<9.9.99",
                 "aws/aws-sdk-php": ">=3,<3.2.1",
+                "bagisto/bagisto": "<0.1.5",
+                "barrelstrength/sprout-base-email": "<3.9",
+                "bolt/bolt": "<3.6.10",
                 "brightlocal/phpwhois": "<=4.2.5",
+                "buddypress/buddypress": "<5.1.2",
                 "bugsnag/bugsnag-laravel": ">=2,<2.0.2",
                 "cakephp/cakephp": ">=1.3,<1.3.18|>=2,<2.4.99|>=2.5,<2.5.99|>=2.6,<2.6.12|>=2.7,<2.7.6|>=3,<3.5.18|>=3.6,<3.6.15|>=3.7,<3.7.7",
                 "cart2quote/module-quotation": ">=4.1.6,<=4.4.5|>=5,<5.4.4",
                 "cartalyst/sentry": "<=2.1.6",
+                "centreon/centreon": "<18.10.8|>=19,<19.4.5",
+                "cesnet/simplesamlphp-module-proxystatistics": "<3.1",
                 "codeigniter/framework": "<=3.0.6",
                 "composer/composer": "<=1-alpha.11",
                 "contao-components/mediaelement": ">=2.14.2,<2.21.1",
                 "doctrine/mongodb-odm": ">=1,<1.0.2",
                 "doctrine/mongodb-odm-bundle": ">=2,<3.0.1",
                 "doctrine/orm": ">=2,<2.4.8|>=2.5,<2.5.1",
+                "dolibarr/dolibarr": "<=10.0.6",
                 "dompdf/dompdf": ">=0.6,<0.6.2",
-                "drupal/core": ">=7,<7.69|>=8,<8.7.11|>=8.8,<8.8.1",
-                "drupal/drupal": ">=7,<7.69|>=8,<8.7.11|>=8.8,<8.8.1",
+                "drupal/core": ">=7,<7.69|>=8,<8.7.12|>=8.8,<8.8.4",
+                "drupal/drupal": ">=7,<7.69|>=8,<8.7.12|>=8.8,<8.8.4",
                 "endroid/qr-code-bundle": "<3.4.2",
+                "enshrined/svg-sanitize": "<0.13.1",
                 "erusev/parsedown": "<1.7.2",
-                "ezsystems/ezplatform-admin-ui": ">=1.3,<1.3.5|>=1.4,<1.4.4",
-                "ezsystems/ezpublish-kernel": ">=5.3,<5.3.12.1|>=5.4,<5.4.13.1|>=6,<6.7.9.1|>=6.8,<6.13.5.1|>=7,<7.2.4.1|>=7.3,<7.3.2.1",
-                "ezsystems/ezpublish-legacy": ">=5.3,<5.3.12.6|>=5.4,<5.4.12.3|>=2011,<2017.12.4.3|>=2018.6,<2018.6.1.4|>=2018.9,<2018.9.1.3",
+                "ezsystems/demobundle": ">=5.4,<5.4.6.1",
+                "ezsystems/ezdemo-ls-extension": ">=5.4,<5.4.2.1",
+                "ezsystems/ezfind-ls": ">=5.3,<5.3.6.1|>=5.4,<5.4.11.1|>=2017.12,<2017.12.0.1",
+                "ezsystems/ezplatform": ">=1.7,<1.7.9.1|>=1.13,<1.13.5.1|>=2.5,<2.5.4",
+                "ezsystems/ezplatform-admin-ui": ">=1.3,<1.3.5|>=1.4,<1.4.6",
+                "ezsystems/ezplatform-admin-ui-assets": ">=4,<4.2",
+                "ezsystems/ezplatform-user": ">=1,<1.0.1",
+                "ezsystems/ezpublish-kernel": ">=5.3,<5.3.12.1|>=5.4,<5.4.14.1|>=6,<6.7.9.1|>=6.8,<6.13.6.2|>=7,<7.2.4.1|>=7.3,<7.3.2.1|>=7.5,<7.5.6.2",
+                "ezsystems/ezpublish-legacy": ">=5.3,<5.3.12.6|>=5.4,<5.4.14.1|>=2011,<2017.12.7.2|>=2018.6,<2018.6.1.4|>=2018.9,<2018.9.1.3|>=2019.3,<2019.3.4.2",
                 "ezsystems/repository-forms": ">=2.3,<2.3.2.1",
                 "ezyang/htmlpurifier": "<4.1.1",
                 "firebase/php-jwt": "<2",
                 "fooman/tcpdf": "<6.2.22",
                 "fossar/tcpdf-parser": "<6.2.22",
+                "friendsofsymfony/oauth2-php": "<1.3",
                 "friendsofsymfony/rest-bundle": ">=1.2,<1.2.2",
                 "friendsofsymfony/user-bundle": ">=1.2,<1.3.5",
                 "fuel/core": "<1.8.1",
+                "getgrav/grav": "<1.7-beta.8",
                 "gree/jose": "<=2.2",
                 "gregwar/rst": "<1.0.3",
                 "guzzlehttp/guzzle": ">=4-rc.2,<4.2.4|>=5,<5.3.1|>=6,<6.2.1",
                 "illuminate/cookie": ">=4,<=4.0.11|>=4.1,<=4.1.31|>=4.2,<=4.2.22|>=5,<=5.0.35|>=5.1,<=5.1.46|>=5.2,<=5.2.45|>=5.3,<=5.3.31|>=5.4,<=5.4.36|>=5.5,<5.5.42|>=5.6,<5.6.30",
                 "illuminate/database": ">=4,<4.0.99|>=4.1,<4.1.29",
                 "illuminate/encryption": ">=4,<=4.0.11|>=4.1,<=4.1.31|>=4.2,<=4.2.22|>=5,<=5.0.35|>=5.1,<=5.1.46|>=5.2,<=5.2.45|>=5.3,<=5.3.31|>=5.4,<=5.4.36|>=5.5,<5.5.40|>=5.6,<5.6.15",
+                "illuminate/view": ">=7,<7.1.2",
                 "ivankristianto/phpwhois": "<=4.3",
                 "james-heinrich/getid3": "<1.9.9",
                 "joomla/session": "<1.3.1",
                 "kazist/phpwhois": "<=4.2.6",
                 "kreait/firebase-php": ">=3.2,<3.8.1",
                 "la-haute-societe/tcpdf": "<6.2.22",
-                "laravel/framework": ">=4,<4.0.99|>=4.1,<=4.1.31|>=4.2,<=4.2.22|>=5,<=5.0.35|>=5.1,<=5.1.46|>=5.2,<=5.2.45|>=5.3,<=5.3.31|>=5.4,<=5.4.36|>=5.5,<5.5.42|>=5.6,<5.6.30",
+                "laravel/framework": ">=4,<4.0.99|>=4.1,<=4.1.31|>=4.2,<=4.2.22|>=5,<=5.0.35|>=5.1,<=5.1.46|>=5.2,<=5.2.45|>=5.3,<=5.3.31|>=5.4,<=5.4.36|>=5.5,<5.5.42|>=5.6,<5.6.30|>=7,<7.1.2",
                 "laravel/socialite": ">=1,<1.0.99|>=2,<2.0.10",
                 "league/commonmark": "<0.18.3",
+                "librenms/librenms": "<1.53",
+                "magento/community-edition": ">=2,<2.2.10|>=2.3,<2.3.3",
                 "magento/magento1ce": "<1.9.4.3",
                 "magento/magento1ee": ">=1,<1.14.4.3",
                 "magento/product-community-edition": ">=2,<2.2.10|>=2.3,<2.3.2-p.2",
                 "monolog/monolog": ">=1.8,<1.12",
                 "namshi/jose": "<2.2",
+                "nzo/url-encryptor-bundle": ">=4,<4.3.2|>=5,<5.0.1",
                 "onelogin/php-saml": "<2.10.4",
+                "oneup/uploader-bundle": "<1.9.3|>=2,<2.1.5",
                 "openid/php-openid": "<2.3",
                 "oro/crm": ">=1.7,<1.7.4",
                 "oro/platform": ">=1.7,<1.7.4",
                 "paragonie/random_compat": "<2",
                 "paypal/merchant-sdk-php": "<3.12",
                 "pear/archive_tar": "<1.4.4",
+                "phpfastcache/phpfastcache": ">=5,<5.0.13",
                 "phpmailer/phpmailer": ">=5,<5.2.27|>=6,<6.0.6",
-                "phpoffice/phpexcel": "<=1.8.1",
-                "phpoffice/phpspreadsheet": "<=1.5",
+                "phpmyadmin/phpmyadmin": "<4.9.2",
+                "phpoffice/phpexcel": "<1.8.2",
+                "phpoffice/phpspreadsheet": "<1.8",
                 "phpunit/phpunit": ">=4.8.19,<4.8.28|>=5.0.10,<5.6.3",
                 "phpwhois/phpwhois": "<=4.2.5",
                 "phpxmlrpc/extras": "<0.6.1",
+                "pimcore/pimcore": "<6.3",
+                "prestashop/autoupgrade": ">=4,<4.10.1",
+                "prestashop/gamification": "<2.3.2",
+                "prestashop/ps_facetedsearch": "<3.4.1",
+                "privatebin/privatebin": "<1.2.2|>=1.3,<1.3.2",
                 "propel/propel": ">=2-alpha.1,<=2-alpha.7",
                 "propel/propel1": ">=1,<=1.7.1",
                 "pusher/pusher-php-server": "<2.2.1",
-                "robrichards/xmlseclibs": ">=1,<3.0.4",
+                "robrichards/xmlseclibs": "<3.0.4",
                 "sabre/dav": ">=1.6,<1.6.99|>=1.7,<1.7.11|>=1.8,<1.8.9",
                 "scheb/two-factor-bundle": ">=0,<3.26|>=4,<4.11",
                 "sensiolabs/connect": "<4.2.3",
                 "serluck/phpwhois": "<=4.2.6",
                 "shopware/shopware": "<5.3.7",
-                "silverstripe/cms": ">=3,<=3.0.11|>=3.1,<3.1.11",
+                "silverstripe/admin": ">=1.0.3,<1.0.4|>=1.1,<1.1.1",
+                "silverstripe/assets": ">=1,<1.4.7|>=1.5,<1.5.2",
+                "silverstripe/cms": "<4.3.6|>=4.4,<4.4.4",
+                "silverstripe/comments": ">=1.3,<1.9.99|>=2,<2.9.99|>=3,<3.1.1",
                 "silverstripe/forum": "<=0.6.1|>=0.7,<=0.7.3",
-                "silverstripe/framework": ">=3,<3.6.7|>=3.7,<3.7.3|>=4,<4.4",
+                "silverstripe/framework": "<4.4.5|>=4.5,<4.5.2",
                 "silverstripe/graphql": ">=2,<2.0.5|>=3,<3.1.2",
                 "silverstripe/registry": ">=2.1,<2.1.2|>=2.2,<2.2.1",
                 "silverstripe/restfulserver": ">=1,<1.0.9|>=2,<2.0.4",
+                "silverstripe/subsites": ">=2,<2.1.1",
+                "silverstripe/taxonomy": ">=1.3,<1.3.1|>=2,<2.0.1",
                 "silverstripe/userforms": "<3",
                 "simple-updates/phpwhois": "<=1",
                 "simplesamlphp/saml2": "<1.10.6|>=2,<2.3.8|>=3,<3.1.4",
-                "simplesamlphp/simplesamlphp": "<1.17.8",
+                "simplesamlphp/simplesamlphp": "<1.18.6",
                 "simplesamlphp/simplesamlphp-module-infocard": "<1.0.1",
+                "simplito/elliptic-php": "<1.0.6",
                 "slim/slim": "<2.6",
                 "smarty/smarty": "<3.1.33",
                 "socalnick/scn-social-auth": "<1.15.2",
                 "spoonity/tcpdf": "<6.2.22",
                 "squizlabs/php_codesniffer": ">=1,<2.8.1|>=3,<3.0.1",
+                "ssddanbrown/bookstack": "<0.29.2",
                 "stormpath/sdk": ">=0,<9.9.99",
-                "studio-42/elfinder": "<2.1.48",
+                "studio-42/elfinder": "<2.1.49",
                 "swiftmailer/swiftmailer": ">=4,<5.4.5",
                 "sylius/admin-bundle": ">=1,<1.0.17|>=1.1,<1.1.9|>=1.2,<1.2.2",
                 "sylius/grid": ">=1,<1.1.19|>=1.2,<1.2.18|>=1.3,<1.3.13|>=1.4,<1.4.5|>=1.5,<1.5.1",
                 "sylius/grid-bundle": ">=1,<1.1.19|>=1.2,<1.2.18|>=1.3,<1.3.13|>=1.4,<1.4.5|>=1.5,<1.5.1",
-                "sylius/sylius": ">=1,<1.1.18|>=1.2,<1.2.17|>=1.3,<1.3.12|>=1.4,<1.4.4",
+                "sylius/resource-bundle": "<1.3.13|>=1.4,<1.4.6|>=1.5,<1.5.1|>=1.6,<1.6.3",
+                "sylius/sylius": "<1.3.16|>=1.4,<1.4.12|>=1.5,<1.5.9|>=1.6,<1.6.5",
+                "symbiote/silverstripe-multivaluefield": ">=3,<3.0.99",
+                "symbiote/silverstripe-versionedfiles": "<=2.0.3",
                 "symfony/cache": ">=3.1,<3.4.35|>=4,<4.2.12|>=4.3,<4.3.8",
                 "symfony/dependency-injection": ">=2,<2.0.17|>=2.7,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7",
+                "symfony/error-handler": ">=4.4,<4.4.4|>=5,<5.0.4",
                 "symfony/form": ">=2.3,<2.3.35|>=2.4,<2.6.12|>=2.7,<2.7.50|>=2.8,<2.8.49|>=3,<3.4.20|>=4,<4.0.15|>=4.1,<4.1.9|>=4.2,<4.2.1",
                 "symfony/framework-bundle": ">=2,<2.3.18|>=2.4,<2.4.8|>=2.5,<2.5.2|>=2.7,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7",
-                "symfony/http-foundation": ">=2,<2.8.52|>=3,<3.4.35|>=4,<4.2.12|>=4.3,<4.3.8",
+                "symfony/http-foundation": ">=2,<2.8.52|>=3,<3.4.35|>=4,<4.2.12|>=4.3,<4.3.8|>=4.4,<4.4.7|>=5,<5.0.7",
                 "symfony/http-kernel": ">=2,<2.8.52|>=3,<3.4.35|>=4,<4.2.12|>=4.3,<4.3.8",
                 "symfony/intl": ">=2.7,<2.7.38|>=2.8,<2.8.31|>=3,<3.2.14|>=3.3,<3.3.13",
                 "symfony/mime": ">=4.3,<4.3.8",
                 "symfony/polyfill-php55": ">=1,<1.10",
                 "symfony/proxy-manager-bridge": ">=2.7,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7",
                 "symfony/routing": ">=2,<2.0.19",
-                "symfony/security": ">=2,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7",
+                "symfony/security": ">=2,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7|>=4.4,<4.4.7|>=5,<5.0.7",
                 "symfony/security-bundle": ">=2,<2.7.48|>=2.8,<2.8.41|>=3,<3.3.17|>=3.4,<3.4.11|>=4,<4.0.11",
                 "symfony/security-core": ">=2.4,<2.6.13|>=2.7,<2.7.9|>=2.7.30,<2.7.32|>=2.8,<2.8.37|>=3,<3.3.17|>=3.4,<3.4.7|>=4,<4.0.7",
                 "symfony/security-csrf": ">=2.4,<2.7.48|>=2.8,<2.8.41|>=3,<3.3.17|>=3.4,<3.4.11|>=4,<4.0.11",
                 "symfony/security-guard": ">=2.8,<2.8.41|>=3,<3.3.17|>=3.4,<3.4.11|>=4,<4.0.11",
-                "symfony/security-http": ">=2.3,<2.3.41|>=2.4,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.2.12|>=4.3,<4.3.8",
+                "symfony/security-http": ">=2.3,<2.3.41|>=2.4,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.2.12|>=4.3,<4.3.8|>=4.4,<4.4.7|>=5,<5.0.7",
                 "symfony/serializer": ">=2,<2.0.11",
-                "symfony/symfony": ">=2,<2.8.52|>=3,<3.4.35|>=4,<4.2.12|>=4.3,<4.3.8",
+                "symfony/symfony": ">=2,<2.8.52|>=3,<3.4.35|>=4,<4.2.12|>=4.3,<4.3.8|>=4.4,<4.4.7|>=5,<5.0.7",
                 "symfony/translation": ">=2,<2.0.17",
                 "symfony/validator": ">=2,<2.0.24|>=2.1,<2.1.12|>=2.2,<2.2.5|>=2.3,<2.3.3",
                 "symfony/var-exporter": ">=4.2,<4.2.12|>=4.3,<4.3.8",
                 "titon/framework": ">=0,<9.9.99",
                 "truckersmp/phpwhois": "<=4.3.1",
                 "twig/twig": "<1.38|>=2,<2.7",
-                "typo3/cms": ">=6.2,<6.2.30|>=7,<7.6.32|>=8,<8.7.30|>=9,<9.5.12|>=10,<10.2.1",
-                "typo3/cms-core": ">=8,<8.7.30|>=9,<9.5.12|>=10,<10.2.1",
+                "typo3/cms": ">=6.2,<6.2.30|>=7,<7.6.32|>=8,<8.7.30|>=9,<9.5.17|>=10,<10.4.2",
+                "typo3/cms-core": ">=8,<8.7.30|>=9,<9.5.17|>=10,<10.4.2",
                 "typo3/flow": ">=1,<1.0.4|>=1.1,<1.1.1|>=2,<2.0.1|>=2.3,<2.3.16|>=3,<3.0.10|>=3.1,<3.1.7|>=3.2,<3.2.7|>=3.3,<3.3.5",
                 "typo3/neos": ">=1.1,<1.1.3|>=1.2,<1.2.13|>=2,<2.0.4",
                 "typo3/phar-stream-wrapper": ">=1,<2.1.1|>=3,<3.1.1",
                 "ua-parser/uap-php": "<3.8",
+                "usmanhalalit/pixie": "<1.0.3|>=2,<2.0.2",
+                "verot/class.upload.php": "<=1.0.3|>=2,<=2.0.4",
                 "wallabag/tcpdf": "<6.2.22",
                 "willdurand/js-translation-bundle": "<2.1.1",
+                "yii2mod/yii2-cms": "<1.9.2",
                 "yiisoft/yii": ">=1.1.14,<1.1.15",
                 "yiisoft/yii2": "<2.0.15",
                 "yiisoft/yii2-bootstrap": "<2.0.4",
                 "yiisoft/yii2-gii": "<2.0.4",
                 "yiisoft/yii2-jui": "<2.0.4",
                 "yiisoft/yii2-redis": "<2.0.8",
+                "yourls/yourls": "<1.7.4",
                 "zendframework/zend-cache": ">=2.4,<2.4.8|>=2.5,<2.5.3",
                 "zendframework/zend-captcha": ">=2,<2.4.9|>=2.5,<2.5.2",
                 "zendframework/zend-crypt": ">=2,<2.4.9|>=2.5,<2.5.2",
                     "name": "Marco Pivetta",
                     "email": "ocramius@gmail.com",
                     "role": "maintainer"
+                },
+                {
+                    "name": "Ilya Tribusean",
+                    "email": "slash3b@gmail.com",
+                    "role": "maintainer"
                 }
             ],
             "description": "Prevents installation of composer packages with known security vulnerabilities: no API, simply require it",
-            "time": "2020-01-06T19:16:46+00:00"
+            "time": "2020-05-12T11:18:47+00:00"
         },
         {
             "name": "sebastian/code-unit-reverse-lookup",
         },
         {
             "name": "squizlabs/php_codesniffer",
-            "version": "3.5.3",
+            "version": "3.5.5",
             "source": {
                 "type": "git",
                 "url": "https://github.com/squizlabs/PHP_CodeSniffer.git",
-                "reference": "557a1fc7ac702c66b0bbfe16ab3d55839ef724cb"
+                "reference": "73e2e7f57d958e7228fce50dc0c61f58f017f9f6"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/557a1fc7ac702c66b0bbfe16ab3d55839ef724cb",
-                "reference": "557a1fc7ac702c66b0bbfe16ab3d55839ef724cb",
+                "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/73e2e7f57d958e7228fce50dc0c61f58f017f9f6",
+                "reference": "73e2e7f57d958e7228fce50dc0c61f58f017f9f6",
                 "shasum": ""
             },
             "require": {
                 "phpcs",
                 "standards"
             ],
-            "time": "2019-12-04T04:46:47+00:00"
+            "time": "2020-04-17T01:09:41+00:00"
         },
         {
             "name": "symfony/console",
-            "version": "v4.4.2",
+            "version": "v4.4.8",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/console.git",
-                "reference": "82437719dab1e6bdd28726af14cb345c2ec816d0"
+                "reference": "10bb3ee3c97308869d53b3e3d03f6ac23ff985f7"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/console/zipball/82437719dab1e6bdd28726af14cb345c2ec816d0",
-                "reference": "82437719dab1e6bdd28726af14cb345c2ec816d0",
+                "url": "https://api.github.com/repos/symfony/console/zipball/10bb3ee3c97308869d53b3e3d03f6ac23ff985f7",
+                "reference": "10bb3ee3c97308869d53b3e3d03f6ac23ff985f7",
                 "shasum": ""
             },
             "require": {
             ],
             "description": "Symfony Console Component",
             "homepage": "https://symfony.com",
-            "time": "2019-12-17T10:32:23+00:00"
+            "time": "2020-03-30T11:41:10+00:00"
         },
         {
             "name": "symfony/finder",
-            "version": "v4.4.2",
+            "version": "v4.4.8",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/finder.git",
-                "reference": "ce8743441da64c41e2a667b8eb66070444ed911e"
+                "reference": "5729f943f9854c5781984ed4907bbb817735776b"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/finder/zipball/ce8743441da64c41e2a667b8eb66070444ed911e",
-                "reference": "ce8743441da64c41e2a667b8eb66070444ed911e",
+                "url": "https://api.github.com/repos/symfony/finder/zipball/5729f943f9854c5781984ed4907bbb817735776b",
+                "reference": "5729f943f9854c5781984ed4907bbb817735776b",
                 "shasum": ""
             },
             "require": {
             ],
             "description": "Symfony Finder Component",
             "homepage": "https://symfony.com",
-            "time": "2019-11-17T21:56:56+00:00"
+            "time": "2020-03-27T16:54:36+00:00"
         },
         {
             "name": "symfony/polyfill-ctype",
-            "version": "v1.13.1",
+            "version": "v1.17.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-ctype.git",
-                "reference": "f8f0b461be3385e56d6de3dbb5a0df24c0c275e3"
+                "reference": "e94c8b1bbe2bc77507a1056cdb06451c75b427f9"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/f8f0b461be3385e56d6de3dbb5a0df24c0c275e3",
-                "reference": "f8f0b461be3385e56d6de3dbb5a0df24c0c275e3",
+                "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/e94c8b1bbe2bc77507a1056cdb06451c75b427f9",
+                "reference": "e94c8b1bbe2bc77507a1056cdb06451c75b427f9",
                 "shasum": ""
             },
             "require": {
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.13-dev"
+                    "dev-master": "1.17-dev"
                 }
             },
             "autoload": {
                 "polyfill",
                 "portable"
             ],
-            "time": "2019-11-27T13:56:44+00:00"
+            "time": "2020-05-12T16:14:59+00:00"
         },
         {
             "name": "symfony/polyfill-mbstring",
-            "version": "v1.13.1",
+            "version": "v1.17.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-mbstring.git",
-                "reference": "7b4aab9743c30be783b73de055d24a39cf4b954f"
+                "reference": "fa79b11539418b02fc5e1897267673ba2c19419c"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/7b4aab9743c30be783b73de055d24a39cf4b954f",
-                "reference": "7b4aab9743c30be783b73de055d24a39cf4b954f",
+                "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/fa79b11539418b02fc5e1897267673ba2c19419c",
+                "reference": "fa79b11539418b02fc5e1897267673ba2c19419c",
                 "shasum": ""
             },
             "require": {
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.13-dev"
+                    "dev-master": "1.17-dev"
                 }
             },
             "autoload": {
                 "portable",
                 "shim"
             ],
-            "time": "2019-11-27T14:18:11+00:00"
+            "time": "2020-05-12T16:47:27+00:00"
         },
         {
             "name": "symfony/polyfill-php73",
-            "version": "v1.13.1",
+            "version": "v1.17.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-php73.git",
-                "reference": "4b0e2222c55a25b4541305a053013d5647d3a25f"
+                "reference": "a760d8964ff79ab9bf057613a5808284ec852ccc"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/4b0e2222c55a25b4541305a053013d5647d3a25f",
-                "reference": "4b0e2222c55a25b4541305a053013d5647d3a25f",
+                "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/a760d8964ff79ab9bf057613a5808284ec852ccc",
+                "reference": "a760d8964ff79ab9bf057613a5808284ec852ccc",
                 "shasum": ""
             },
             "require": {
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.13-dev"
+                    "dev-master": "1.17-dev"
                 }
             },
             "autoload": {
                 "portable",
                 "shim"
             ],
-            "time": "2019-11-27T16:25:15+00:00"
+            "time": "2020-05-12T16:47:27+00:00"
         },
         {
             "name": "symfony/service-contracts",
         },
         {
             "name": "webmozart/assert",
-            "version": "1.6.0",
+            "version": "1.8.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/webmozart/assert.git",
-                "reference": "573381c0a64f155a0d9a23f4b0c797194805b925"
+                "reference": "ab2cb0b3b559010b75981b1bdce728da3ee90ad6"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/webmozart/assert/zipball/573381c0a64f155a0d9a23f4b0c797194805b925",
-                "reference": "573381c0a64f155a0d9a23f4b0c797194805b925",
+                "url": "https://api.github.com/repos/webmozart/assert/zipball/ab2cb0b3b559010b75981b1bdce728da3ee90ad6",
+                "reference": "ab2cb0b3b559010b75981b1bdce728da3ee90ad6",
                 "shasum": ""
             },
             "require": {
                 "symfony/polyfill-ctype": "^1.8"
             },
             "conflict": {
-                "vimeo/psalm": "<3.6.0"
+                "vimeo/psalm": "<3.9.1"
             },
             "require-dev": {
                 "phpunit/phpunit": "^4.8.36 || ^7.5.13"
                 "check",
                 "validate"
             ],
-            "time": "2019-11-24T13:36:37+00:00"
+            "time": "2020-04-18T12:12:48+00:00"
         }
     ],
     "aliases": [],
index d5b16e2d0d2a200129e709b80932ee3a3ceb7b4e..f264e8735d79042506678327f1ae4e503b2a5738 100644 (file)
@@ -131,7 +131,7 @@ If it's still not working, please [open an issue](https://github.com/shaarli/Sha
 | ------------- |:-------------:|
 | [render_header](#render_header) | Allow plugin to add content in page headers. |
 | [render_includes](#render_includes) | Allow plugin to include their own CSS files. |
-| [render_footer](#render_footer) | Allow plugin to add content in page footer and include their own JS files. | 
+| [render_footer](#render_footer) | Allow plugin to add content in page footer and include their own JS files. |
 | [render_linklist](#render_linklist) | It allows to add content at the begining and end of the page, after every link displayed and to alter link data. |
 | [render_editlink](#render_editlink) |  Allow to add fields in the form, or display elements. |
 | [render_tools](#render_tools) |  Allow to add content at the end of the page. |
@@ -515,7 +515,7 @@ Otherwise, you can use your own JS as long as this field is send by the form:
 
 ### Placeholder system
 
-In order to make plugins work with every custom themes, you need to add variable placeholder in your templates. 
+In order to make plugins work with every custom themes, you need to add variable placeholder in your templates.
 
 It's a RainTPL loop like this:
 
@@ -537,7 +537,7 @@ At the end of the menu:
 
 At the end of file, before clearing floating blocks:
 
-    {if="!empty($plugin_errors) && isLoggedIn()"}
+    {if="!empty($plugin_errors) && $is_logged_in"}
         <ul class="errors">
             {loop="plugin_errors"}
                 <li>{$value}</li>
index d943218e655eb0ca4c1533feb0542880dbf2e36d..ecbff09ab8b7648e153709599df5605b58fb2c12 100644 (file)
@@ -1,14 +1,14 @@
 ### Feeds options
 
-Feeds are available in ATOM with `?do=atom` and RSS with `do=RSS`.
+Feeds are available in ATOM with `/feed/atom` and RSS with `/feed/rss`.
 
 Options:
 
 - You can use `permalinks` in the feed URL to get permalink to Shaares instead of direct link to shaared URL.
-    - E.G. `https://my.shaarli.domain/?do=atom&permalinks`.
+    - E.G. `https://my.shaarli.domain/feed/atom?permalinks`.
 - You can use `nb` parameter in the feed URL to specify the number of Shaares you want in a feed (default if not specified: `50`). The keyword `all` is available if you want everything.
-    - `https://my.shaarli.domain/?do=atom&permalinks&nb=42`
-    - `https://my.shaarli.domain/?do=atom&permalinks&nb=all`
+    - `https://my.shaarli.domain/feed/atom?permalinks&nb=42`
+    - `https://my.shaarli.domain/feed/atom?permalinks&nb=all`
 
 ### RSS Feeds or Picture Wall for a specific search/tag
 
@@ -21,8 +21,8 @@ For example, if you want to subscribe only to links tagged `photography`:
 - Click on the `RSS Feed` button.
 - You are presented with an RSS feed showing only these links. Subscribe to it to receive only updates with this tag.
 - The same method **also works for a full-text search** (_Search_ box) **and for the Picture Wall** (want to only see pictures about `nature`?)
-- You can also build the URLs manually: 
+- You can also build the URLs manually:
     - `https://my.shaarli.domain/?do=rss&searchtags=nature`
-    - `https://my.shaarli.domain/links/?do=picwall&searchterm=poney`
+    - `https://my.shaarli.domain/links/picture-wall?searchterm=poney`
 
 ![](images/rss-filter-1.png) ![](images/rss-filter-2.png)
index 58b92da387732ef330a54e3ba030c6b0a81ee68d..c23ec9627ba3c9e17eb9dfcf3bce12043443ad55 100644 (file)
@@ -32,20 +32,20 @@ Here is a list :
 ```
 http://<replace_domain>/
 http://<replace_domain>/?nonope
-http://<replace_domain>/?do=addlink
-http://<replace_domain>/?do=changepasswd
-http://<replace_domain>/?do=changetag
-http://<replace_domain>/?do=configure
-http://<replace_domain>/?do=tools
-http://<replace_domain>/?do=daily
-http://<replace_domain>/?post
-http://<replace_domain>/?do=export
-http://<replace_domain>/?do=import
+http://<replace_domain>/admin/add-shaare
+http://<replace_domain>/admin/password
+http://<replace_domain>/admin/tags
+http://<replace_domain>/admin/configure
+http://<replace_domain>/admin/tools
+http://<replace_domain>/daily
+http://<replace_domain>/admin/shaare
+http://<replace_domain>/admin/export
+http://<replace_domain>/admin/import
 http://<replace_domain>/login
-http://<replace_domain>/?do=picwall
-http://<replace_domain>/?do=pluginadmin
-http://<replace_domain>/?do=tagcloud
-http://<replace_domain>/?do=taglist
+http://<replace_domain>/picture-wall
+http://<replace_domain>/admin/plugins
+http://<replace_domain>/tags/cloud
+http://<replace_domain>/tags/list
 ```
 
 #### Improve existing translation
index 026d0101dadd9ce1c89dae7b8e5610ef48d8b55d..fbb2fe64df4eb37b859c9a7896f8210ea84c20de 100644 (file)
@@ -1,24 +1,26 @@
 msgid ""
 msgstr ""
 "Project-Id-Version: Shaarli\n"
-"POT-Creation-Date: 2019-07-13 10:45+0200\n"
-"PO-Revision-Date: 2019-07-13 10:49+0200\n"
+"POT-Creation-Date: 2020-08-27 12:01+0200\n"
+"PO-Revision-Date: 2020-08-27 12:02+0200\n"
 "Last-Translator: \n"
 "Language-Team: Shaarli\n"
 "Language: fr_FR\n"
 "MIME-Version: 1.0\n"
 "Content-Type: text/plain; charset=UTF-8\n"
 "Content-Transfer-Encoding: 8bit\n"
-"X-Generator: Poedit 2.2.1\n"
+"X-Generator: Poedit 2.3\n"
 "X-Poedit-Basepath: ../../../..\n"
 "Plural-Forms: nplurals=2; plural=(n > 1);\n"
 "X-Poedit-SourceCharset: UTF-8\n"
 "X-Poedit-KeywordsList: t:1,2;t\n"
-"X-Poedit-SearchPath-0: .\n"
-"X-Poedit-SearchPathExcluded-0: node_modules\n"
-"X-Poedit-SearchPathExcluded-1: vendor\n"
+"X-Poedit-SearchPath-0: application\n"
+"X-Poedit-SearchPath-1: tmp\n"
+"X-Poedit-SearchPath-2: index.php\n"
+"X-Poedit-SearchPath-3: init.php\n"
+"X-Poedit-SearchPath-4: plugins\n"
 
-#: application/ApplicationUtils.php:159
+#: application/ApplicationUtils.php:161
 #, php-format
 msgid ""
 "Your PHP version is obsolete! Shaarli requires at least PHP %s, and thus "
@@ -29,27 +31,27 @@ msgstr ""
 "peut donc pas fonctionner. Votre version de PHP a des failles de sécurités "
 "connues et devrait Ãªtre mise Ã  jour au plus tôt."
 
-#: application/ApplicationUtils.php:189 application/ApplicationUtils.php:201
+#: application/ApplicationUtils.php:192 application/ApplicationUtils.php:204
 msgid "directory is not readable"
 msgstr "le répertoire n'est pas accessible en lecture"
 
-#: application/ApplicationUtils.php:204
+#: application/ApplicationUtils.php:207
 msgid "directory is not writable"
 msgstr "le répertoire n'est pas accessible en Ã©criture"
 
-#: application/ApplicationUtils.php:222
+#: application/ApplicationUtils.php:225
 msgid "file is not readable"
 msgstr "le fichier n'est pas accessible en lecture"
 
-#: application/ApplicationUtils.php:225
+#: application/ApplicationUtils.php:228
 msgid "file is not writable"
 msgstr "le fichier n'est pas accessible en Ã©criture"
 
-#: application/History.php:178
+#: application/History.php:179
 msgid "History file isn't readable or writable"
 msgstr "Le fichier d'historique n'est pas accessible en lecture ou en Ã©criture"
 
-#: application/History.php:189
+#: application/History.php:190
 msgid "Could not parse history file"
 msgstr "Format incorrect pour le fichier d'historique"
 
@@ -77,50 +79,61 @@ msgstr ""
 "l'extension php-gd doit Ãªtre chargée pour utiliser les miniatures. Les "
 "miniatures sont désormais désactivées. Rechargez la page."
 
-#: application/Utils.php:379 tests/UtilsTest.php:343
+#: application/Utils.php:383
 msgid "Setting not set"
 msgstr "Paramètre non défini"
 
-#: application/Utils.php:386 tests/UtilsTest.php:341 tests/UtilsTest.php:342
+#: application/Utils.php:390
 msgid "Unlimited"
 msgstr "Illimité"
 
-#: application/Utils.php:389 tests/UtilsTest.php:338 tests/UtilsTest.php:339
-#: tests/UtilsTest.php:353
+#: application/Utils.php:393
 msgid "B"
 msgstr "o"
 
-#: application/Utils.php:389 tests/UtilsTest.php:332 tests/UtilsTest.php:333
-#: tests/UtilsTest.php:340
+#: application/Utils.php:393
 msgid "kiB"
 msgstr "ko"
 
-#: application/Utils.php:389 tests/UtilsTest.php:334 tests/UtilsTest.php:335
-#: tests/UtilsTest.php:351 tests/UtilsTest.php:352
+#: application/Utils.php:393
 msgid "MiB"
 msgstr "Mo"
 
-#: application/Utils.php:389 tests/UtilsTest.php:336 tests/UtilsTest.php:337
+#: application/Utils.php:393
 msgid "GiB"
 msgstr "Go"
 
-#: application/bookmark/LinkDB.php:128
-msgid "You are not authorized to add a link."
-msgstr "Vous n'êtes pas autorisé Ã  ajouter un lien."
-
-#: application/bookmark/LinkDB.php:131
-msgid "Internal Error: A link should always have an id and URL."
-msgstr "Erreur interne : un lien devrait toujours avoir un ID et une URL."
-
-#: application/bookmark/LinkDB.php:134
-msgid "You must specify an integer as a key."
-msgstr "Vous devez utiliser un entier comme clé."
+#: application/bookmark/BookmarkFileService.php:165
+#: application/bookmark/BookmarkFileService.php:190
+#: application/bookmark/BookmarkFileService.php:215
+#: application/bookmark/BookmarkFileService.php:232
+msgid "You're not authorized to alter the datastore"
+msgstr "Vous n'êtes pas autorisé Ã  modifier les données"
+
+#: application/bookmark/BookmarkFileService.php:168
+#: application/bookmark/BookmarkFileService.php:193
+#: application/bookmark/BookmarkFileService.php:235
+msgid "Provided data is invalid"
+msgstr "Les informations fournies ne sont pas valides"
+
+#: application/bookmark/BookmarkFileService.php:196
+msgid "This bookmarks already exists"
+msgstr "Ce marque-page existe déjà."
+
+#: application/bookmark/BookmarkInitializer.php:37
+#: application/legacy/LegacyLinkDB.php:266
+msgid "My secret stuff... - Pastebin.com"
+msgstr "Mes trucs secrets... - Pastebin.com"
 
-#: application/bookmark/LinkDB.php:137
-msgid "Array offset and link ID must be equal."
-msgstr "La clé du tableau et l'ID du lien doivent Ãªtre identiques."
+#: application/bookmark/BookmarkInitializer.php:39
+#: application/legacy/LegacyLinkDB.php:268
+msgid "Shhhh! I'm a private link only YOU can see. You can delete me too."
+msgstr ""
+"Pssst ! Je suis un lien privé que VOUS Ãªtes le seul Ã  voir. Vous pouvez me "
+"supprimer aussi."
 
-#: application/bookmark/LinkDB.php:243
+#: application/bookmark/BookmarkInitializer.php:45
+#: application/legacy/LegacyLinkDB.php:246
 #: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15
 #: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:49
 #: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:15
@@ -131,7 +144,8 @@ msgstr ""
 "Le gestionnaire de marque-pages personnel, minimaliste, et sans base de "
 "données"
 
-#: application/bookmark/LinkDB.php:246
+#: application/bookmark/BookmarkInitializer.php:48
+#: application/legacy/LegacyLinkDB.php:249
 msgid ""
 "Welcome to Shaarli! This is your first public bookmark. To edit or delete "
 "me, you must first login.\n"
@@ -151,17 +165,7 @@ msgstr ""
 "Vous utilisez la version supportée par la communauté du projet original "
 "Shaarli de Sébastien Sauvage."
 
-#: application/bookmark/LinkDB.php:263
-msgid "My secret stuff... - Pastebin.com"
-msgstr "Mes trucs secrets... - Pastebin.com"
-
-#: application/bookmark/LinkDB.php:265
-msgid "Shhhh! I'm a private link only YOU can see. You can delete me too."
-msgstr ""
-"Pssst ! Je suis un lien privé que VOUS Ãªtes le seul Ã  voir. Vous pouvez me "
-"supprimer aussi."
-
-#: application/bookmark/exception/LinkNotFoundException.php:13
+#: application/bookmark/exception/BookmarkNotFoundException.php:13
 msgid "The link you are trying to reach does not exist or has been deleted."
 msgstr "Le lien que vous essayez de consulter n'existe pas ou a Ã©té supprimé."
 
@@ -173,8 +177,8 @@ msgstr ""
 "Shaarli n'a pas pu créer le fichier de configuration. Merci de vérifier que "
 "Shaarli a les droits d'écriture dans le dossier dans lequel il est installé."
 
-#: application/config/ConfigManager.php:135
-#: application/config/ConfigManager.php:162
+#: application/config/ConfigManager.php:136
+#: application/config/ConfigManager.php:163
 msgid "Invalid setting key parameter. String expected, got: "
 msgstr "Clé de paramétrage invalide. Chaîne de caractères obtenue, attendu : "
 
@@ -196,268 +200,346 @@ msgstr "Vous n'êtes pas autorisé Ã  modifier la configuration."
 msgid "Error accessing"
 msgstr "Une erreur s'est produite en accédant Ã "
 
-#: application/feed/Cache.php:16
-#, php-format
-msgid "Cannot purge %s: no directory"
-msgstr "Impossible de purger %s : le répertoire n'existe pas"
-
-#: application/feed/FeedBuilder.php:155
+#: application/feed/FeedBuilder.php:179
 msgid "Direct link"
 msgstr "Liens directs"
 
-#: application/feed/FeedBuilder.php:157
+#: application/feed/FeedBuilder.php:181
 #: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:96
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:177
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:179
 msgid "Permalink"
 msgstr "Permalien"
 
-#: application/netscape/NetscapeBookmarkUtils.php:42
-msgid "Invalid export selection:"
-msgstr "Sélection d'export invalide :"
+#: application/front/controller/admin/ConfigureController.php:54
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24
+msgid "Configure"
+msgstr "Configurer"
 
-#: application/netscape/NetscapeBookmarkUtils.php:87
-#, php-format
-msgid "File %s (%d bytes) "
-msgstr "Le fichier %s (%d octets) "
+#: application/front/controller/admin/ConfigureController.php:102
+#: application/legacy/LegacyUpdater.php:537
+msgid "You have enabled or changed thumbnails mode."
+msgstr "Vous avez activé ou changé le mode de miniatures."
 
-#: application/netscape/NetscapeBookmarkUtils.php:89
-msgid "has an unknown file format. Nothing was imported."
-msgstr "a un format inconnu. Rien n'a Ã©té importé."
+#: application/front/controller/admin/ConfigureController.php:103
+#: application/legacy/LegacyUpdater.php:538
+msgid "Please synchronize them."
+msgstr "Merci de les synchroniser."
+
+#: application/front/controller/admin/ConfigureController.php:113
+#: application/front/controller/visitor/InstallController.php:136
+msgid "Error while writing config file after configuration update."
+msgstr ""
+"Une erreur s'est produite lors de la sauvegarde du fichier de configuration."
+
+#: application/front/controller/admin/ConfigureController.php:122
+msgid "Configuration was saved."
+msgstr "La configuration a Ã©té sauvegardée."
+
+#: application/front/controller/admin/ExportController.php:26
+#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:64
+msgid "Export"
+msgstr "Exporter"
 
-#: application/netscape/NetscapeBookmarkUtils.php:93
+#: application/front/controller/admin/ExportController.php:42
+msgid "Please select an export mode."
+msgstr "Merci de choisir un mode d'export."
+
+#: application/front/controller/admin/ImportController.php:41
+#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:83
+msgid "Import"
+msgstr "Importer"
+
+#: application/front/controller/admin/ImportController.php:55
+msgid "No import file provided."
+msgstr "Aucun fichier Ã  importer n'a Ã©té fourni."
+
+#: application/front/controller/admin/ImportController.php:66
 #, php-format
 msgid ""
-"was successfully processed in %d seconds: %d links imported, %d links "
-"overwritten, %d links skipped."
+"The file you are trying to upload is probably bigger than what this "
+"webserver can accept (%s). Please upload in smaller chunks."
 msgstr ""
-"a Ã©té importé avec succès en %d secondes : %d liens importés, %d liens "
-"écrasés, %d liens ignorés."
+"Le fichier que vous essayer d'envoyer est probablement plus lourd que ce que "
+"le serveur web peut accepter (%s). Merci de l'envoyer en parties plus "
+"légères."
 
-#: application/plugin/exception/PluginFileNotFoundException.php:21
+#: application/front/controller/admin/ManageShaareController.php:29
+#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
+msgid "Shaare a new link"
+msgstr "Partager un nouveau lien"
+
+#: application/front/controller/admin/ManageShaareController.php:78
+msgid "Note: "
+msgstr "Note : "
+
+#: application/front/controller/admin/ManageShaareController.php:109
+#: application/front/controller/admin/ManageShaareController.php:206
+#: application/front/controller/admin/ManageShaareController.php:275
+#: application/front/controller/admin/ManageShaareController.php:315
 #, php-format
-msgid "Plugin \"%s\" files not found."
-msgstr "Les fichiers de l'extension \"%s\" sont introuvables."
+msgid "Bookmark with identifier %s could not be found."
+msgstr "Le lien avec l'identifiant %s n'a pas pu Ãªtre trouvé."
 
-#: application/render/PageBuilder.php:209
-msgid "The page you are trying to reach does not exist or has been deleted."
-msgstr "La page que vous essayez de consulter n'existe pas ou a Ã©té supprimée."
+#: application/front/controller/admin/ManageShaareController.php:194
+#: application/front/controller/admin/ManageShaareController.php:252
+msgid "Invalid bookmark ID provided."
+msgstr "ID du lien non valide."
 
-#: application/render/PageBuilder.php:211
-msgid "404 Not Found"
-msgstr "404 Introuvable"
+#: application/front/controller/admin/ManageShaareController.php:260
+msgid "Invalid visibility provided."
+msgstr "Visibilité du lien non valide."
 
-#: application/updater/Updater.php:99
-#, fuzzy
-#| msgid "Couldn't retrieve Updater class methods."
-msgid "Couldn't retrieve updater class methods."
-msgstr "Impossible de récupérer les méthodes de la classe Updater."
+#: application/front/controller/admin/ManageShaareController.php:363
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:171
+msgid "Edit"
+msgstr "Modifier"
 
-#: application/updater/Updater.php:526 index.php:1034
-msgid ""
-"You have enabled or changed thumbnails mode. <a href=\"?do=thumbs_update"
-"\">Please synchronize them</a>."
-msgstr ""
-"Vous avez activé ou changé le mode de miniatures. <a href=\"?do=thumbs_update"
-"\">Merci de les synchroniser</a>."
+#: application/front/controller/admin/ManageShaareController.php:366
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:28
+msgid "Shaare"
+msgstr "Shaare"
+
+#: application/front/controller/admin/ManageTagController.php:29
+#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
+msgid "Manage tags"
+msgstr "Gérer les tags"
+
+#: application/front/controller/admin/ManageTagController.php:48
+msgid "Invalid tags provided."
+msgstr "Les tags fournis ne sont pas valides."
+
+#: application/front/controller/admin/ManageTagController.php:72
+#, php-format
+msgid "The tag was removed from %d bookmark."
+msgid_plural "The tag was removed from %d bookmarks."
+msgstr[0] "Le tag a Ã©té supprimé du %d lien."
+msgstr[1] "Le tag a Ã©té supprimé de %d liens."
+
+#: application/front/controller/admin/ManageTagController.php:77
+#, php-format
+msgid "The tag was renamed in %d bookmark."
+msgid_plural "The tag was renamed in %d bookmarks."
+msgstr[0] "Le tag a Ã©té renommé dans %d lien."
+msgstr[1] "Le tag a Ã©té renommé dans %d liens."
 
-#: application/updater/UpdaterUtils.php:32
-msgid "Updates file path is not set, can't write updates."
+#: application/front/controller/admin/PasswordController.php:28
+#: tmp/changepassword.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29
+msgid "Change password"
+msgstr "Modifier le mot de passe"
+
+#: application/front/controller/admin/PasswordController.php:55
+msgid "You must provide the current and new password to change it."
 msgstr ""
-"Le chemin vers le fichier de mise Ã  jour n'est pas défini, impossible "
-"d'écrire les mises Ã  jour."
+"Vous devez fournir les mots de passe actuel et nouveau pour pouvoir le "
+"modifier."
+
+#: application/front/controller/admin/PasswordController.php:71
+msgid "The old password is not correct."
+msgstr "L'ancien mot de passe est incorrect."
 
-#: application/updater/UpdaterUtils.php:37
-msgid "Unable to write updates in "
-msgstr "Impossible d'écrire les mises Ã  jour dans "
+#: application/front/controller/admin/PasswordController.php:97
+msgid "Your password has been changed"
+msgstr "Votre mot de passe a Ã©té modifié"
 
-#: application/updater/exception/UpdaterException.php:51
-msgid "An error occurred while running the update "
-msgstr "Une erreur s'est produite lors de l'exécution de la mise Ã  jour "
+#: application/front/controller/admin/PluginsController.php:45
+msgid "Plugin Administration"
+msgstr "Administration des plugins"
 
-#: index.php:145
-msgid "Shared links on "
-msgstr "Liens partagés sur "
+#: application/front/controller/admin/PluginsController.php:75
+msgid "Setting successfully saved."
+msgstr "Les paramètres ont Ã©té sauvegardés avec succès."
 
-#: index.php:167
-msgid "Insufficient permissions:"
-msgstr "Permissions insuffisantes :"
+#: application/front/controller/admin/PluginsController.php:78
+msgid "Error while saving plugin configuration: "
+msgstr ""
+"Une erreur s'est produite lors de la sauvegarde de la configuration des "
+"plugins : "
 
-#: index.php:203
-msgid "I said: NO. You are banned for the moment. Go away."
-msgstr "NON. Vous Ãªtes banni pour le moment. Revenez plus tard."
+#: application/front/controller/admin/ThumbnailsController.php:37
+#: tmp/thumbnails.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
+msgid "Thumbnails update"
+msgstr "Mise Ã  jour des miniatures"
 
-#: index.php:275
-msgid "Wrong login/password."
-msgstr "Nom d'utilisateur ou mot de passe incorrect(s)."
+#: application/front/controller/admin/ToolsController.php:31
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:33
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:33
+msgid "Tools"
+msgstr "Outils"
+
+#: application/front/controller/visitor/BookmarkListController.php:115
+msgid "Search: "
+msgstr "Recherche : "
 
-#: index.php:398 index.php:404
+#: application/front/controller/visitor/DailyController.php:45
 msgid "Today"
 msgstr "Aujourd'hui"
 
-#: index.php:400
+#: application/front/controller/visitor/DailyController.php:47
 msgid "Yesterday"
 msgstr "Hier"
 
-#: index.php:484 tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:46
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:46
+#: application/front/controller/visitor/DailyController.php:85
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:48
 msgid "Daily"
 msgstr "Quotidien"
 
-#: index.php:593 tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
-#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:44
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:75
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:99
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:75
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:99
+#: application/front/controller/visitor/ErrorController.php:36
+msgid "An unexpected error occurred."
+msgstr "Une erreur inattendue s'est produite."
+
+#: application/front/controller/visitor/InstallController.php:73
+#, php-format
+msgid ""
+"<pre>Sessions do not seem to work correctly on your server.<br>Make sure the "
+"variable \"session.save_path\" is set correctly in your PHP config, and that "
+"you have write access to it.<br>It currently points to %s.<br>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.<br>"
+msgstr ""
+"<pre>Les sesssions ne semblent pas fonctionner sur ce serveur.<br>Assurez "
+"vous que la variable Â« session.save_path Â» est correctement définie dans "
+"votre fichier de configuration PHP, et que vous avez les droits d'écriture "
+"dessus.<br>Ce paramètre pointe actuellement sur %s.<br>Sur certains "
+"navigateurs, accéder Ã  votre serveur depuis un nom d'hôte comme Â« localhost "
+"» ou autre nom personnalisé sans point '.' entraine l'échec de la sauvegarde "
+"des cookies. Nous vous recommandons d'accéder Ã  votre serveur depuis son "
+"adresse IP ou un <em>Fully Qualified Domain Name</em>.<br>"
+
+#: application/front/controller/visitor/InstallController.php:144
+msgid ""
+"Shaarli is now configured. Please login and start shaaring your bookmarks!"
+msgstr ""
+"Shaarli est maintenant configuré. Vous pouvez vous connecter et commencez Ã  "
+"shaare vos liens !"
+
+#: application/front/controller/visitor/InstallController.php:158
+msgid "Insufficient permissions:"
+msgstr "Permissions insuffisantes :"
+
+#: application/front/controller/visitor/LoginController.php:46
+#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
+#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:77
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:101
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:77
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:101
 msgid "Login"
 msgstr "Connexion"
 
-#: index.php:608 tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:41
+#: application/front/controller/visitor/LoginController.php:78
+msgid "Wrong login/password."
+msgstr "Nom d'utilisateur ou mot de passe incorrect(s)."
+
+#: application/front/controller/visitor/PictureWallController.php:29
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:43
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:43
 msgid "Picture wall"
 msgstr "Mur d'images"
 
-#: index.php:683 tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:36
-#: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19
-msgid "Tag cloud"
-msgstr "Nuage de tags"
-
-#: index.php:715 tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19
-msgid "Tag list"
+#: application/front/controller/visitor/TagCloudController.php:80
+#, fuzzy
+#| msgid "Tag list"
+msgid "Tag "
 msgstr "Liste des tags"
 
-#: index.php:944 tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:31
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:31
-msgid "Tools"
-msgstr "Outils"
+#: application/front/exceptions/AlreadyInstalledException.php:11
+msgid "Shaarli has already been installed. Login to edit the configuration."
+msgstr ""
+"Shaarli est déjà installé. Connectez-vous pour modifier la configuration."
 
-#: index.php:952
+#: application/front/exceptions/LoginBannedException.php:11
+msgid ""
+"You have been banned after too many failed login attempts. Try again later."
+msgstr ""
+"Vous avez Ã©té banni après trop d'échecs d'authentification. Merci de "
+"réessayer plus tard."
+
+#: application/front/exceptions/OpenShaarliPasswordException.php:16
 msgid "You are not supposed to change a password on an Open Shaarli."
 msgstr ""
 "Vous n'êtes pas censé modifier le mot de passe d'un Shaarli en mode ouvert."
 
-#: index.php:957 index.php:1007 index.php:1094 index.php:1124 index.php:1234
-#: index.php:1281
+#: application/front/exceptions/ThumbnailsDisabledException.php:11
+msgid "Picture wall unavailable (thumbnails are disabled)."
+msgstr ""
+"Le mur d'images n'est pas disponible (les miniatures sont désactivées)."
+
+#: application/front/exceptions/WrongTokenException.php:16
 msgid "Wrong token."
 msgstr "Jeton invalide."
 
-#: index.php:966
-msgid "The old password is not correct."
-msgstr "L'ancien mot de passe est incorrect."
+#: application/legacy/LegacyLinkDB.php:131
+msgid "You are not authorized to add a link."
+msgstr "Vous n'êtes pas autorisé Ã  ajouter un lien."
 
-#: index.php:993
-msgid "Your password has been changed"
-msgstr "Votre mot de passe a Ã©té modifié"
+#: application/legacy/LegacyLinkDB.php:134
+msgid "Internal Error: A link should always have an id and URL."
+msgstr "Erreur interne : un lien devrait toujours avoir un ID et une URL."
 
-#: index.php:997
-#: tmp/changepassword.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29
-msgid "Change password"
-msgstr "Modifier le mot de passe"
+#: application/legacy/LegacyLinkDB.php:137
+msgid "You must specify an integer as a key."
+msgstr "Vous devez utiliser un entier comme clé."
 
-#: index.php:1054
-msgid "Configuration was saved."
-msgstr "La configuration a Ã©té sauvegardée."
+#: application/legacy/LegacyLinkDB.php:140
+msgid "Array offset and link ID must be equal."
+msgstr "La clé du tableau et l'ID du lien doivent Ãªtre identiques."
 
-#: index.php:1078 tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24
-msgid "Configure"
-msgstr "Configurer"
+#: application/legacy/LegacyUpdater.php:104
+msgid "Couldn't retrieve updater class methods."
+msgstr "Impossible de récupérer les méthodes de la classe Updater."
 
-#: index.php:1088 tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
-msgid "Manage tags"
-msgstr "Gérer les tags"
+#: application/legacy/LegacyUpdater.php:538
+msgid "<a href=\"./admin/thumbnails\">"
+msgstr "<a href=\"./admin/thumbnails\">"
 
-#: index.php:1107
-#, php-format
-msgid "The tag was removed from %d link."
-msgid_plural "The tag was removed from %d links."
-msgstr[0] "Le tag a Ã©té supprimé de %d lien."
-msgstr[1] "Le tag a Ã©té supprimé de %d liens."
+#: application/netscape/NetscapeBookmarkUtils.php:63
+msgid "Invalid export selection:"
+msgstr "Sélection d'export invalide :"
 
-#: index.php:1108
+#: application/netscape/NetscapeBookmarkUtils.php:215
 #, php-format
-msgid "The tag was renamed in %d link."
-msgid_plural "The tag was renamed in %d links."
-msgstr[0] "Le tag a Ã©té renommé dans %d lien."
-msgstr[1] "Le tag a Ã©té renommé dans %d liens."
-
-#: index.php:1115 tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
-msgid "Shaare a new link"
-msgstr "Partager un nouveau lien"
-
-#: index.php:1344 tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:169
-msgid "Edit"
-msgstr "Modifier"
-
-#: index.php:1344 index.php:1416
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:26
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:26
-msgid "Shaare"
-msgstr "Shaare"
-
-#: index.php:1385
-msgid "Note: "
-msgstr "Note : "
-
-#: index.php:1424
-msgid "Invalid link ID provided"
-msgstr "ID du lien non valide"
-
-#: index.php:1444 tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:65
-msgid "Export"
-msgstr "Exporter"
+msgid "File %s (%d bytes) "
+msgstr "Le fichier %s (%d octets) "
 
-#: index.php:1506 tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:83
-msgid "Import"
-msgstr "Importer"
+#: application/netscape/NetscapeBookmarkUtils.php:217
+msgid "has an unknown file format. Nothing was imported."
+msgstr "a un format inconnu. Rien n'a Ã©té importé."
 
-#: index.php:1516
+#: application/netscape/NetscapeBookmarkUtils.php:221
 #, php-format
 msgid ""
-"The file you are trying to upload is probably bigger than what this "
-"webserver can accept (%s). Please upload in smaller chunks."
+"was successfully processed in %d seconds: %d bookmarks imported, %d "
+"bookmarks overwritten, %d bookmarks skipped."
 msgstr ""
-"Le fichier que vous essayer d'envoyer est probablement plus lourd que ce que "
-"le serveur web peut accepter (%s). Merci de l'envoyer en parties plus "
-"légères."
-
-#: index.php:1561 tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:26
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:22
-msgid "Plugin administration"
-msgstr "Administration des plugins"
+"a Ã©té importé avec succès en %d secondes : %d liens importés, %d liens "
+"écrasés, %d liens ignorés."
 
-#: index.php:1616 tmp/thumbnails.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
-msgid "Thumbnails update"
-msgstr "Mise Ã  jour des miniatures"
+#: application/plugin/PluginManager.php:122
+msgid " [plugin incompatibility]: "
+msgstr " [incompatibilité de l'extension] : "
 
-#: index.php:1782
-msgid "Search: "
-msgstr "Recherche : "
+#: application/plugin/exception/PluginFileNotFoundException.php:21
+#, php-format
+msgid "Plugin \"%s\" files not found."
+msgstr "Les fichiers de l'extension \"%s\" sont introuvables."
 
-#: index.php:1825
+#: application/render/PageCacheManager.php:32
 #, php-format
-msgid ""
-"<pre>Sessions do not seem to work correctly on your server.<br>Make sure the "
-"variable \"session.save_path\" is set correctly in your PHP config, and that "
-"you have write access to it.<br>It currently points to %s.<br>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.<br>"
-msgstr ""
-"<pre>Les sesssions ne semblent pas fonctionner sur ce serveur.<br>Assurez "
-"vous que la variable Â« session.save_path Â» est correctement définie dans "
-"votre fichier de configuration PHP, et que vous avez les droits d'écriture "
-"dessus.<br>Ce paramètre pointe actuellement sur %s.<br>Sur certains "
-"navigateurs, accéder Ã  votre serveur depuis un nom d'hôte comme Â« localhost "
-"» ou autre nom personnalisé sans point '.' entraine l'échec de la sauvegarde "
-"des cookies. Nous vous recommandons d'accéder Ã  votre serveur depuis son "
-"adresse IP ou un <em>Fully Qualified Domain Name</em>.<br>"
+msgid "Cannot purge %s: no directory"
+msgstr "Impossible de purger %s : le répertoire n'existe pas"
 
-#: index.php:1835
-msgid "Click to try again."
-msgstr "Cliquer ici pour réessayer."
+#: application/updater/exception/UpdaterException.php:51
+msgid "An error occurred while running the update "
+msgstr "Une erreur s'est produite lors de l'exécution de la mise Ã  jour "
+
+#: index.php:62
+msgid "Shared bookmarks on "
+msgstr "Liens partagés sur "
 
 #: plugins/addlink_toolbar/addlink_toolbar.php:31
 msgid "URI"
@@ -472,11 +554,11 @@ msgstr "Shaare"
 msgid "Adds the addlink input on the linklist page."
 msgstr "Ajoute le formulaire d'ajout de liens sur la page principale."
 
-#: plugins/archiveorg/archiveorg.php:25
+#: plugins/archiveorg/archiveorg.php:26
 msgid "View on archive.org"
 msgstr "Voir sur archive.org"
 
-#: plugins/archiveorg/archiveorg.php:38
+#: plugins/archiveorg/archiveorg.php:39
 msgid "For each link, add an Archive.org icon."
 msgstr "Pour chaque lien, ajoute une icône pour Archive.org."
 
@@ -506,7 +588,7 @@ msgstr "Couleur de fond (gris léger)"
 msgid "Dark main color (e.g. visited links)"
 msgstr "Couleur principale sombre (ex : les liens visités)"
 
-#: plugins/demo_plugin/demo_plugin.php:482
+#: plugins/demo_plugin/demo_plugin.php:477
 msgid ""
 "A demo plugin covering all use cases for template designers and plugin "
 "developers."
@@ -514,11 +596,11 @@ msgstr ""
 "Une extension de démonstration couvrant tous les cas d'utilisation pour les "
 "designers de thèmes et les développeurs d'extensions."
 
-#: plugins/demo_plugin/demo_plugin.php:483
+#: plugins/demo_plugin/demo_plugin.php:478
 msgid "This is a parameter dedicated to the demo plugin. It'll be suffixed."
 msgstr "Ceci est un paramètre dédié au plugin de démo. Il sera suffixé."
 
-#: plugins/demo_plugin/demo_plugin.php:484
+#: plugins/demo_plugin/demo_plugin.php:479
 msgid "Other demo parameter"
 msgstr "Un autre paramètre de démo"
 
@@ -540,36 +622,6 @@ msgstr ""
 msgid "Isso server URL (without 'http://')"
 msgstr "URL du serveur Isso (sans 'http://')"
 
-#: plugins/markdown/markdown.php:163
-msgid "Description will be rendered with"
-msgstr "La description sera générée avec"
-
-#: plugins/markdown/markdown.php:164
-msgid "Markdown syntax documentation"
-msgstr "Documentation sur la syntaxe Markdown"
-
-#: plugins/markdown/markdown.php:165
-msgid "Markdown syntax"
-msgstr "la syntaxe Markdown"
-
-#: plugins/markdown/markdown.php:361
-msgid ""
-"Render shaare description with Markdown syntax.<br><strong>Warning</"
-"strong>:\n"
-"If your shaared descriptions contained HTML tags before enabling the "
-"markdown plugin,\n"
-"enabling it might break your page.\n"
-"See the <a href=\"https://github.com/shaarli/Shaarli/tree/master/plugins/"
-"markdown#html-rendering\">README</a>."
-msgstr ""
-"Utilise la syntaxe Markdown pour la description des liens."
-"<br><strong>Attention</strong> :\n"
-"Si vous aviez des descriptions contenant du HTML avant d'activer cette "
-"extension,\n"
-"l'activer pourrait déformer vos pages.\n"
-"Voir le <a href=\"https://github.com/shaarli/Shaarli/tree/master/plugins/"
-"markdown#html-rendering\">README</a>."
-
 #: plugins/piwik/piwik.php:23
 msgid ""
 "Piwik plugin error: Please define PIWIK_URL and PIWIK_SITEID in the plugin "
@@ -626,7 +678,7 @@ msgstr "Mauvaise réponse du hub %s"
 msgid "Enable PubSubHubbub feed publishing."
 msgstr "Active la publication de flux vers PubSubHubbub."
 
-#: plugins/qrcode/qrcode.php:72 plugins/wallabag/wallabag.php:68
+#: plugins/qrcode/qrcode.php:73 plugins/wallabag/wallabag.php:70
 msgid "For each link, add a QRCode icon."
 msgstr "Pour chaque lien, ajouter une icône de QRCode."
 
@@ -642,24 +694,14 @@ msgstr ""
 msgid "Save to wallabag"
 msgstr "Sauvegarder dans Wallabag"
 
-#: plugins/wallabag/wallabag.php:69
+#: plugins/wallabag/wallabag.php:71
 msgid "Wallabag API URL"
 msgstr "URL de l'API Wallabag"
 
-#: plugins/wallabag/wallabag.php:70
+#: plugins/wallabag/wallabag.php:72
 msgid "Wallabag API version (1 or 2)"
 msgstr "Version de l'API Wallabag (1 ou 2)"
 
-#: tests/LanguagesTest.php:214 tests/LanguagesTest.php:227
-#: tests/languages/fr/LanguagesFrTest.php:159
-#: tests/languages/fr/LanguagesFrTest.php:172
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:85
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:85
-msgid "Search"
-msgid_plural "Search"
-msgstr[0] "Rechercher"
-msgstr[1] "Rechercher"
-
 #: tmp/404.b91ef64efc3688266305ea9b42e5017e.rtpl.php:12
 msgid "Sorry, nothing to see here."
 msgstr "Désolé, il y a rien Ã  voir ici."
@@ -698,10 +740,11 @@ msgid "Rename"
 msgstr "Renommer"
 
 #: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:35
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:77
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:171
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:145
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:145
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:93
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:173
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:147
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:147
+#: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:67
 msgid "Delete"
 msgstr "Supprimer"
 
@@ -713,33 +756,6 @@ msgstr "Vous pouvez aussi modifier les tags dans la"
 msgid "tag list"
 msgstr "liste des tags"
 
-#: tmp/configure.90100d2eaf5d3705e14b9b4f78ecddc9.rtpl.php:143
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:312
-#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:31
-msgid "All"
-msgstr "Tous"
-
-#: tmp/configure.90100d2eaf5d3705e14b9b4f78ecddc9.rtpl.php:147
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:316
-msgid "Only common media hosts"
-msgstr "Seulement les hébergeurs de média connus"
-
-#: tmp/configure.90100d2eaf5d3705e14b9b4f78ecddc9.rtpl.php:151
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:320
-msgid "None"
-msgstr "Aucune"
-
-#: tmp/configure.90100d2eaf5d3705e14b9b4f78ecddc9.rtpl.php:158
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:297
-msgid "You need to enable the extension <code>php-gd</code> to use thumbnails."
-msgstr ""
-"Vous devez activer l'extension <code>php-gd</code> pour utiliser les "
-"miniatures."
-
-#: tmp/configure.90100d2eaf5d3705e14b9b4f78ecddc9.rtpl.php:162
-msgid "Synchonize thumbnails"
-msgstr "Synchroniser les miniatures"
-
 #: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29
 msgid "title"
 msgstr "titre"
@@ -756,109 +772,132 @@ msgstr "Valeur par défaut"
 msgid "Theme"
 msgstr "Thème"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:87
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:78
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:85
+msgid "Description formatter"
+msgstr "Format des descriptions"
+
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:114
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:77
 msgid "Language"
 msgstr "Langue"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:116
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:102
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:143
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:101
 msgid "Timezone"
 msgstr "Fuseau horaire"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:117
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:103
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:144
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:102
 msgid "Continent"
 msgstr "Continent"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:117
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:103
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:144
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:102
 msgid "City"
 msgstr "Ville"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:164
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:191
 msgid "Disable session cookie hijacking protection"
 msgstr "Désactiver la protection contre le détournement de cookies"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:166
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:193
 msgid "Check this if you get disconnected or if your IP address changes often"
 msgstr ""
 "Cocher cette case si vous Ãªtes souvent déconnecté ou si votre adresse IP "
 "change souvent"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:183
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:210
 msgid "Private links by default"
 msgstr "Liens privés par défaut"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:184
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:211
 msgid "All new links are private by default"
 msgstr "Tous les nouveaux liens sont privés par défaut"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:199
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:226
 msgid "RSS direct links"
 msgstr "Liens directs dans le flux RSS"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:200
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:227
 msgid "Check this to use direct URL instead of permalink in feeds"
 msgstr ""
 "Cocher cette case pour utiliser des liens directs au lieu des permaliens "
 "dans le flux RSS"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:215
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:242
 msgid "Hide public links"
 msgstr "Cacher les liens publics"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:216
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:243
 msgid "Do not show any links if the user is not logged in"
 msgstr "N'afficher aucun lien sans Ãªtre connecté"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:231
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:150
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:258
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:149
 msgid "Check updates"
 msgstr "Vérifier les mises Ã  jour"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:232
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:152
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:259
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:151
 msgid "Notify me when a new release is ready"
 msgstr "Me notifier lorsqu'une nouvelle version est disponible"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:247
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:274
 msgid "Automatically retrieve description for new bookmarks"
 msgstr "Récupérer automatiquement la description"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:248
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:275
 msgid "Shaarli will try to retrieve the description from meta HTML headers"
 msgstr ""
 "Shaarli essaiera de récupérer la description depuis les balises HTML meta "
 "dans les entêtes"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:263
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:169
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:290
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:168
 msgid "Enable REST API"
 msgstr "Activer l'API REST"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:264
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:170
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:291
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:169
 msgid "Allow third party software to use Shaarli such as mobile application"
 msgstr ""
 "Permet aux applications tierces d'utiliser Shaarli, par exemple les "
 "applications mobiles"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:279
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:306
 msgid "API secret"
 msgstr "Clé d'API secrète"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:293
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:320
 msgid "Enable thumbnails"
 msgstr "Activer les miniatures"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:301
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:324
+msgid "You need to enable the extension <code>php-gd</code> to use thumbnails."
+msgstr ""
+"Vous devez activer l'extension <code>php-gd</code> pour utiliser les "
+"miniatures."
+
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:328
 #: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:56
 msgid "Synchronize thumbnails"
 msgstr "Synchroniser les miniatures"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:328
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:72
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:339
+#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:30
+msgid "All"
+msgstr "Tous"
+
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:343
+msgid "Only common media hosts"
+msgstr "Seulement les hébergeurs de média connus"
+
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:347
+msgid "None"
+msgstr "Aucune"
+
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:355
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:88
 #: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:139
 #: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:199
 msgid "Save"
@@ -884,27 +923,27 @@ msgstr "Tous les liens d'un jour sur une page."
 msgid "Next day"
 msgstr "Jour suivant"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:18
 msgid "Edit Shaare"
 msgstr "Modifier le Shaare"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:18
 msgid "New Shaare"
 msgstr "Nouveau Shaare"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:23
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:26
 msgid "Created:"
 msgstr "Création :"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:26
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29
 msgid "URL"
 msgstr "URL"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:32
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:35
 msgid "Title"
 msgstr "Titre"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:38
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
 #: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42
 #: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:75
 #: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:99
@@ -912,17 +951,29 @@ msgstr "Titre"
 msgid "Description"
 msgstr "Description"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:44
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:47
 msgid "Tags"
 msgstr "Tags"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:57
-#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:167
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:60
+#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:35
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:169
 msgid "Private"
 msgstr "Privé"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:72
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:66
+msgid "Description will be rendered with"
+msgstr "La description sera générée avec"
+
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:68
+msgid "Markdown syntax documentation"
+msgstr "Documentation sur la syntaxe Markdown"
+
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:69
+msgid "Markdown syntax"
+msgstr "la syntaxe Markdown"
+
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:88
 msgid "Apply Changes"
 msgstr "Appliquer les changements"
 
@@ -930,19 +981,19 @@ msgstr "Appliquer les changements"
 msgid "Export Database"
 msgstr "Exporter les données"
 
-#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24
+#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:23
 msgid "Selection"
 msgstr "Choisir"
 
-#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
+#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:40
 msgid "Public"
 msgstr "Publics"
 
-#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:52
+#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:51
 msgid "Prepend note permalinks with this Shaarli instance's URL"
 msgstr "Préfixer les liens de note avec l'URL de l'instance de Shaarli"
 
-#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:53
+#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:52
 msgid "Useful to import bookmarks in a web browser"
 msgstr "Utile pour importer les marques-pages dans un navigateur"
 
@@ -993,29 +1044,29 @@ msgstr ""
 "Il semblerait que Ã§a soit la première fois que vous lancez Shaarli. Merci de "
 "le configurer."
 
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:33
-#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:30
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:165
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:165
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:32
+#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:167
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:167
 msgid "Username"
 msgstr "Nom d'utilisateur"
 
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48
-#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:34
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:166
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:166
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:47
+#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:20
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:168
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:168
 msgid "Password"
 msgstr "Mot de passe"
 
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:63
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:62
 msgid "Shaarli title"
 msgstr "Titre du Shaarli"
 
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:69
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:68
 msgid "My links"
 msgstr "Mes liens"
 
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:182
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:181
 msgid "Install"
 msgstr "Installer"
 
@@ -1034,21 +1085,31 @@ msgstr[0] "lien privé"
 msgstr[1] "liens privés"
 
 #: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:30
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:121
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:121
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:123
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:123
 msgid "Search text"
 msgstr "Recherche texte"
 
 #: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:37
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:128
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:128
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:130
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:130
 #: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
-#: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:64
+#: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:65
 #: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
 #: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:74
 msgid "Filter by tag"
 msgstr "Filtrer par tag"
 
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:46
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:87
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:139
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:87
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:139
+#: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:46
+#: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:45
+msgid "Search"
+msgstr "Rechercher"
+
 #: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:110
 msgid "Nothing found."
 msgstr "Aucun résultat."
@@ -1069,40 +1130,41 @@ msgid "tagged"
 msgstr "taggé"
 
 #: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:133
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:134
 msgid "Remove tag"
 msgstr "Retirer le tag"
 
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:142
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:144
 msgid "with status"
 msgstr "avec le statut"
 
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:153
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:155
 msgid "without any tag"
 msgstr "sans tag"
 
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:173
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:175
 #: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:43
 #: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:43
 msgid "Fold"
 msgstr "Replier"
 
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:175
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:177
 msgid "Edited: "
 msgstr "Modifié : "
 
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:179
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:181
 msgid "permalink"
 msgstr "permalien"
 
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:181
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:183
 msgid "Add tag"
 msgstr "Ajouter un tag"
 
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:183
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:185
 msgid "Toggle sticky"
 msgstr "Changer statut Ã©pinglé"
 
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:185
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:187
 msgid "Sticky"
 msgstr "Épinglé"
 
@@ -1145,16 +1207,9 @@ msgstr "Replier tout"
 msgid "Links per page"
 msgstr "Liens par page"
 
-#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15
-msgid ""
-"You have been banned after too many failed login attempts. Try again later."
-msgstr ""
-"Vous avez Ã©té banni après trop d'échecs d'authentification. Merci de "
-"réessayer plus tard."
-
-#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:169
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:169
+#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:25
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:171
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:171
 msgid "Remember me"
 msgstr "Rester connecté"
 
@@ -1185,62 +1240,64 @@ msgstr "Déplier tout"
 msgid "Are you sure you want to delete this link?"
 msgstr "Êtes-vous sûr de vouloir supprimer ce lien ?"
 
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:65
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:90
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:65
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:90
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:11
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:11
+msgid "Menu"
+msgstr "Menu"
+
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:38
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:38
+#: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19
+msgid "Tag cloud"
+msgstr "Nuage de tags"
+
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:67
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:92
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:67
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:92
 msgid "RSS Feed"
 msgstr "Flux RSS"
 
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:70
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:106
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:70
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:106
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:72
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:108
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:72
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:108
 msgid "Logout"
 msgstr "Déconnexion"
 
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:150
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:150
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:152
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:152
 msgid "Set public"
 msgstr "Rendre public"
 
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:155
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:155
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:157
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:157
 msgid "Set private"
 msgstr "Rendre privé"
 
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:187
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:187
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:189
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:189
 msgid "is available"
 msgstr "est disponible"
 
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:194
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:194
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:196
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:196
 msgid "Error"
 msgstr "Erreur"
 
-#: tmp/picwall.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
-msgid "Picture wall unavailable (thumbnails are disabled)."
-msgstr ""
-"Le mur d'images n'est pas disponible (les miniatures sont désactivées)."
+#: tmp/picwall.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15
+msgid "There is no cached thumbnail."
+msgstr "Il n'y a aucune miniature dans le cache."
 
-#: tmp/picwall.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24
-#, fuzzy
-#| msgid ""
-#| "You don't have any cached thumbnail. Try to <a href=\"?do=thumbs_update"
-#| "\">synchronize them</a>."
-msgid ""
-"There is no cached thumbnail. Try to <a href=\"?do=thumbs_update"
-"\">synchronize them</a>."
-msgstr ""
-"Il n'y a aucune miniature en cache. Essayer de <a href=\"?do=thumbs_update"
-"\">les synchroniser</a>."
+#: tmp/picwall.b91ef64efc3688266305ea9b42e5017e.rtpl.php:17
+msgid "Try to synchronize them."
+msgstr "Essayer de les synchroniser."
 
-#: tmp/picwall.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
+#: tmp/picwall.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
 msgid "Picture Wall"
 msgstr "Mur d'images"
 
-#: tmp/picwall.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
+#: tmp/picwall.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
 msgid "pics"
 msgstr "images"
 
@@ -1249,6 +1306,11 @@ msgid "You need to enable Javascript to change plugin loading order."
 msgstr ""
 "Vous devez activer Javascript pour pouvoir modifier l'ordre des extensions."
 
+#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:26
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:22
+msgid "Plugin administration"
+msgstr "Administration des plugins"
+
 #: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29
 msgid "Enabled Plugins"
 msgstr "Extensions activées"
@@ -1314,6 +1376,14 @@ msgstr "tags"
 msgid "List all links with those tags"
 msgstr "Lister tous les liens avec ces tags"
 
+#: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19
+msgid "Tag list"
+msgstr "Liste des tags"
+
+#: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:68
+msgid "Rename tag"
+msgstr "Renommer le tag"
+
 #: tmp/tag.sort.b91ef64efc3688266305ea9b42e5017e.rtpl.php:3
 #: tmp/tag.sort.cedf684561d925457130839629000a81.rtpl.php:3
 msgid "Sort by:"
@@ -1457,6 +1527,68 @@ msgstr ""
 "Glisser ce lien dans votre barre de favoris ou cliquer droit dessus et Â« "
 "Ajouter aux favoris Â»"
 
+#, fuzzy
+#~| msgid "Selection"
+#~ msgid ".ui-selecting"
+#~ msgstr "Choisir"
+
+#, fuzzy
+#~| msgid "Documentation"
+#~ msgid "document"
+#~ msgstr "Documentation"
+
+#~ msgid "The page you are trying to reach does not exist or has been deleted."
+#~ msgstr ""
+#~ "La page que vous essayez de consulter n'existe pas ou a Ã©té supprimée."
+
+#~ msgid "404 Not Found"
+#~ msgstr "404 Introuvable"
+
+#~ msgid "Updates file path is not set, can't write updates."
+#~ msgstr ""
+#~ "Le chemin vers le fichier de mise Ã  jour n'est pas défini, impossible "
+#~ "d'écrire les mises Ã  jour."
+
+#~ msgid "Unable to write updates in "
+#~ msgstr "Impossible d'écrire les mises Ã  jour dans "
+
+#~ msgid "I said: NO. You are banned for the moment. Go away."
+#~ msgstr "NON. Vous Ãªtes banni pour le moment. Revenez plus tard."
+
+#~ msgid "Click to try again."
+#~ msgstr "Cliquer ici pour réessayer."
+
+#~ msgid ""
+#~ "Render shaare description with Markdown syntax.<br><strong>Warning</"
+#~ "strong>:\n"
+#~ "If your shaared descriptions contained HTML tags before enabling the "
+#~ "markdown plugin,\n"
+#~ "enabling it might break your page.\n"
+#~ "See the <a href=\"https://github.com/shaarli/Shaarli/tree/master/plugins/"
+#~ "markdown#html-rendering\">README</a>."
+#~ msgstr ""
+#~ "Utilise la syntaxe Markdown pour la description des liens."
+#~ "<br><strong>Attention</strong> :\n"
+#~ "Si vous aviez des descriptions contenant du HTML avant d'activer cette "
+#~ "extension,\n"
+#~ "l'activer pourrait déformer vos pages.\n"
+#~ "Voir le <a href=\"https://github.com/shaarli/Shaarli/tree/master/plugins/"
+#~ "markdown#html-rendering\">README</a>."
+
+#~ msgid "Synchonize thumbnails"
+#~ msgstr "Synchroniser les miniatures"
+
+#, fuzzy
+#~| msgid ""
+#~| "You don't have any cached thumbnail. Try to <a href=\"?do=thumbs_update"
+#~| "\">synchronize them</a>."
+#~ msgid ""
+#~ "There is no cached thumbnail. Try to <a href=\"?do=thumbs_update"
+#~ "\">synchronize them</a>."
+#~ msgstr ""
+#~ "Il n'y a aucune miniature en cache. Essayer de <a href=\"?do=thumbs_update"
+#~ "\">les synchroniser</a>."
+
 #~ msgid ""
 #~ "You need to browse your Shaarli over <strong>HTTPS</strong> to use this "
 #~ "functionality."
index b53b16fefb383400f90eb1b99374b24c907daf68..e7471823252494c594be9b67eb835f26b290979d 100644 (file)
--- a/index.php
+++ b/index.php
  * Licence: http://www.opensource.org/licenses/zlib-license.php
  */
 
-// Set 'UTC' as the default timezone if it is not defined in php.ini
-// See http://php.net/manual/en/datetime.configuration.php#ini.date.timezone
-if (date_default_timezone_get() == '') {
-    date_default_timezone_set('UTC');
-}
-
-/*
- * PHP configuration
- */
-
-// http://server.com/x/shaarli --> /shaarli/
-define('WEB_PATH', substr($_SERVER['REQUEST_URI'], 0, 1+strrpos($_SERVER['REQUEST_URI'], '/', 0)));
-
-// High execution time in case of problematic imports/exports.
-ini_set('max_input_time', '60');
-
-// Try to set max upload file size and read
-ini_set('memory_limit', '128M');
-ini_set('post_max_size', '16M');
-ini_set('upload_max_filesize', '16M');
-
-// See all error except warnings
-error_reporting(E_ALL^E_WARNING);
-
-// 3rd-party libraries
-if (! file_exists(__DIR__ . '/vendor/autoload.php')) {
-    header('Content-Type: text/plain; charset=utf-8');
-    echo "Error: missing Composer configuration\n\n"
-        ."If you installed Shaarli through Git or using the development branch,\n"
-        ."please refer to the installation documentation to install PHP"
-        ." dependencies using Composer:\n"
-        ."- https://shaarli.readthedocs.io/en/master/Server-configuration/\n"
-        ."- https://shaarli.readthedocs.io/en/master/Download-and-Installation/";
-    exit;
-}
 require_once 'inc/rain.tpl.class.php';
 require_once __DIR__ . '/vendor/autoload.php';
 
 // Shaarli library
 require_once 'application/bookmark/LinkUtils.php';
 require_once 'application/config/ConfigPlugin.php';
-require_once 'application/feed/Cache.php';
 require_once 'application/http/HttpUtils.php';
 require_once 'application/http/UrlUtils.php';
-require_once 'application/updater/UpdaterUtils.php';
-require_once 'application/FileUtils.php';
 require_once 'application/TimeZone.php';
 require_once 'application/Utils.php';
 
-use Shaarli\ApplicationUtils;
-use Shaarli\Bookmark\Bookmark;
-use Shaarli\Bookmark\BookmarkFileService;
-use Shaarli\Bookmark\BookmarkFilter;
-use Shaarli\Bookmark\BookmarkServiceInterface;
-use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
+require_once __DIR__ . '/init.php';
+
 use Shaarli\Config\ConfigManager;
 use Shaarli\Container\ContainerBuilder;
-use Shaarli\Feed\CachedPage;
-use Shaarli\Feed\FeedBuilder;
-use Shaarli\Formatter\BookmarkMarkdownFormatter;
-use Shaarli\Formatter\FormatterFactory;
-use Shaarli\History;
 use Shaarli\Languages;
-use Shaarli\Netscape\NetscapeBookmarkUtils;
-use Shaarli\Plugin\PluginManager;
-use Shaarli\Render\PageBuilder;
-use Shaarli\Render\ThemeUtils;
-use Shaarli\Router;
+use Shaarli\Security\CookieManager;
 use Shaarli\Security\LoginManager;
 use Shaarli\Security\SessionManager;
-use Shaarli\Thumbnailer;
-use Shaarli\Updater\Updater;
-use Shaarli\Updater\UpdaterUtils;
 use Slim\App;
 
-// Ensure the PHP version is supported
-try {
-    ApplicationUtils::checkPHPVersion('7.1', PHP_VERSION);
-} catch (Exception $exc) {
-    header('Content-Type: text/plain; charset=utf-8');
-    echo $exc->getMessage();
-    exit;
-}
-
-define('SHAARLI_VERSION', ApplicationUtils::getVersion(__DIR__ .'/'. ApplicationUtils::$VERSION_FILE));
-
-// Force cookie path (but do not change lifetime)
-$cookie = session_get_cookie_params();
-$cookiedir = '';
-if (dirname($_SERVER['SCRIPT_NAME']) != '/') {
-    $cookiedir = dirname($_SERVER["SCRIPT_NAME"]).'/';
-}
-// Set default cookie expiration and path.
-session_set_cookie_params($cookie['lifetime'], $cookiedir, $_SERVER['SERVER_NAME']);
-// Set session parameters on server side.
-// Use cookies to store session.
-ini_set('session.use_cookies', 1);
-// Force cookies for session (phpsessionID forbidden in URL).
-ini_set('session.use_only_cookies', 1);
-// Prevent PHP form using sessionID in URL if cookies are disabled.
-ini_set('session.use_trans_sid', false);
-
-session_name('shaarli');
-// Start session if needed (Some server auto-start sessions).
-if (session_status() == PHP_SESSION_NONE) {
-    session_start();
-}
-
-// Regenerate session ID if invalid or not defined in cookie.
-if (isset($_COOKIE['shaarli']) && !SessionManager::checkId($_COOKIE['shaarli'])) {
-    session_regenerate_id(true);
-    $_COOKIE['shaarli'] = session_id();
-}
-
 $conf = new ConfigManager();
 
 // In dev mode, throw exception on any warning
@@ -133,20 +40,16 @@ if ($conf->get('dev.debug', false)) {
     // See all errors (for debugging only)
     error_reporting(-1);
 
-    set_error_handler(function($errno, $errstr, $errfile, $errline, array $errcontext) {
+    set_error_handler(function ($errno, $errstr, $errfile, $errline, array $errcontext) {
         throw new ErrorException($errstr, 0, $errno, $errfile, $errline);
     });
 }
 
-$sessionManager = new SessionManager($_SESSION, $conf);
-$loginManager = new LoginManager($conf, $sessionManager);
+$sessionManager = new SessionManager($_SESSION, $conf, session_save_path());
+$sessionManager->initialize();
+$cookieManager = new CookieManager($_COOKIE);
+$loginManager = new LoginManager($conf, $sessionManager, $cookieManager);
 $loginManager->generateStaySignedInToken($_SERVER['REMOTE_ADDR']);
-$clientIpId = client_ip_id($_SERVER);
-
-// LC_MESSAGES isn't defined without php-intl, in this case use LC_COLLATE locale instead.
-if (! defined('LC_MESSAGES')) {
-    define('LC_MESSAGES', LC_COLLATE);
-}
 
 // Sniff browser language and set date format accordingly.
 if (isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) {
@@ -157,1773 +60,76 @@ new Languages(setlocale(LC_MESSAGES, 0), $conf);
 
 $conf->setEmpty('general.timezone', date_default_timezone_get());
 $conf->setEmpty('general.title', t('Shared bookmarks on '). escape(index_url($_SERVER)));
+
 RainTPL::$tpl_dir = $conf->get('resource.raintpl_tpl').'/'.$conf->get('resource.theme').'/'; // template directory
 RainTPL::$cache_dir = $conf->get('resource.raintpl_tmp'); // cache directory
 
-$pluginManager = new PluginManager($conf);
-$pluginManager->load($conf->get('general.enabled_plugins'));
-
 date_default_timezone_set($conf->get('general.timezone', 'UTC'));
 
-ob_start();  // Output buffering for the page cache.
-
-// Prevent caching on client side or proxy: (yes, it's ugly)
-header("Last-Modified: " . gmdate("D, d M Y H:i:s") . " GMT");
-header("Cache-Control: no-store, no-cache, must-revalidate");
-header("Cache-Control: post-check=0, pre-check=0", false);
-header("Pragma: no-cache");
-
-if (! is_file($conf->getConfigFileExt())) {
-    // Ensure Shaarli has proper access to its resources
-    $errors = ApplicationUtils::checkResourcePermissions($conf);
-
-    if ($errors != array()) {
-        $message = '<p>'. t('Insufficient permissions:') .'</p><ul>';
-
-        foreach ($errors as $error) {
-            $message .= '<li>'.$error.'</li>';
-        }
-        $message .= '</ul>';
-
-        header('Content-Type: text/html; charset=utf-8');
-        echo $message;
-        exit;
-    }
-
-    // Display the installation form if no existing config is found
-    install($conf, $sessionManager, $loginManager);
-}
-
-$loginManager->checkLoginState($_COOKIE, $clientIpId);
-
-/**
- * Adapter function to ensure compatibility with third-party templates
- *
- * @see https://github.com/shaarli/Shaarli/pull/1086
- *
- * @return bool true when the user is logged in, false otherwise
- */
-function isLoggedIn()
-{
-    global $loginManager;
-    return $loginManager->isLoggedIn();
-}
-
-
-// ------------------------------------------------------------------------------------------
-// Process login form: Check if login/password is correct.
-if (isset($_POST['login'])) {
-    if (! $loginManager->canLogin($_SERVER)) {
-        die(t('I said: NO. You are banned for the moment. Go away.'));
-    }
-    if (isset($_POST['password'])
-        && $sessionManager->checkToken($_POST['token'])
-        && $loginManager->checkCredentials($_SERVER['REMOTE_ADDR'], $clientIpId, $_POST['login'], $_POST['password'])
-    ) {
-        $loginManager->handleSuccessfulLogin($_SERVER);
-
-        $cookiedir = '';
-        if (dirname($_SERVER['SCRIPT_NAME']) != '/') {
-            // Note: Never forget the trailing slash on the cookie path!
-            $cookiedir = dirname($_SERVER["SCRIPT_NAME"]) . '/';
-        }
-
-        if (!empty($_POST['longlastingsession'])) {
-            // Keep the session cookie even after the browser closes
-            $sessionManager->setStaySignedIn(true);
-            $expirationTime = $sessionManager->extendSession();
-
-            setcookie(
-                $loginManager::$STAY_SIGNED_IN_COOKIE,
-                $loginManager->getStaySignedInToken(),
-                $expirationTime,
-                WEB_PATH
-            );
-        } else {
-            // Standard session expiration (=when browser closes)
-            $expirationTime = 0;
-        }
-
-        // Send cookie with the new expiration date to the browser
-        session_destroy();
-        session_set_cookie_params($expirationTime, $cookiedir, $_SERVER['SERVER_NAME']);
-        session_start();
-        session_regenerate_id(true);
-
-        // Optional redirect after login:
-        if (isset($_GET['post'])) {
-            $uri = './?post='. urlencode($_GET['post']);
-            foreach (array('description', 'source', 'title', 'tags') as $param) {
-                if (!empty($_GET[$param])) {
-                    $uri .= '&'.$param.'='.urlencode($_GET[$param]);
-                }
-            }
-            header('Location: '. $uri);
-            exit;
-        }
-
-        if (isset($_GET['edit_link'])) {
-            header('Location: ./?edit_link='. escape($_GET['edit_link']));
-            exit;
-        }
-
-        if (isset($_POST['returnurl'])) {
-            // Prevent loops over login screen.
-            if (strpos($_POST['returnurl'], '/login') === false) {
-                header('Location: '. generateLocation($_POST['returnurl'], $_SERVER['HTTP_HOST']));
-                exit;
-            }
-        }
-        header('Location: ./?');
-        exit;
-    } else {
-        $loginManager->handleFailedLogin($_SERVER);
-        $redir = '?username='. urlencode($_POST['login']);
-        if (isset($_GET['post'])) {
-            $redir .= '&post=' . urlencode($_GET['post']);
-            foreach (array('description', 'source', 'title', 'tags') as $param) {
-                if (!empty($_GET[$param])) {
-                    $redir .= '&' . $param . '=' . urlencode($_GET[$param]);
-                }
-            }
-        }
-        // Redirect to login screen.
-        echo '<script>alert("'. t("Wrong login/password.") .'");document.location=\'./login'.$redir.'\';</script>';
-        exit;
-    }
-}
-
-// ------------------------------------------------------------------------------------------
-// Token management for XSRF protection
-// Token should be used in any form which acts on data (create,update,delete,import...).
-if (!isset($_SESSION['tokens'])) {
-    $_SESSION['tokens']=array();  // Token are attached to the session.
-}
-
-/**
- * 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.
- *
- * @param BookmarkServiceInterface $bookmarkService
- * @param ConfigManager            $conf            Configuration Manager instance
- * @param LoginManager             $loginManager    LoginManager instance
- */
-function showDailyRSS($bookmarkService, $conf, $loginManager)
-{
-    // Cache system
-    $query = $_SERVER['QUERY_STRING'];
-    $cache = new CachedPage(
-        $conf->get('config.PAGE_CACHE'),
-        page_url($_SERVER),
-        startsWith($query, 'do=dailyrss') && !$loginManager->isLoggedIn()
-    );
-    $cached = $cache->cachedVersion();
-    if (!empty($cached)) {
-        echo $cached;
-        exit;
-    }
-
-    /* Some Shaarlies may have very few bookmarks, so we need to look
-       back in time until we have enough days ($nb_of_days).
-    */
-    $nb_of_days = 7; // We take 7 days.
-    $today = date('Ymd');
-    $days = array();
-
-    foreach ($bookmarkService->search() as $bookmark) {
-        $day = $bookmark->getCreated()->format('Ymd'); // Extract day (without time)
-        if (strcmp($day, $today) < 0) {
-            if (empty($days[$day])) {
-                $days[$day] = array();
-            }
-            $days[$day][] = $bookmark;
-        }
-
-        if (count($days) > $nb_of_days) {
-            break; // Have we collected enough days?
-        }
-    }
-
-    // Build the RSS feed.
-    header('Content-Type: application/rss+xml; charset=utf-8');
-    $pageaddr = escape(index_url($_SERVER));
-    echo '<?xml version="1.0" encoding="UTF-8"?><rss version="2.0">';
-    echo '<channel>';
-    echo '<title>Daily - '. $conf->get('general.title') . '</title>';
-    echo '<link>'. $pageaddr .'</link>';
-    echo '<description>Daily shared bookmarks</description>';
-    echo '<language>en-en</language>';
-    echo '<copyright>'. $pageaddr .'</copyright>'. PHP_EOL;
-
-    $factory = new FormatterFactory($conf, $loginManager->isLoggedIn());
-    $formatter = $factory->getFormatter();
-    $formatter->addContextData('index_url', index_url($_SERVER));
-    // For each day.
-    /** @var Bookmark[] $bookmarks */
-    foreach ($days as $day => $bookmarks) {
-        $formattedBookmarks = [];
-        $dayDate = DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, $day.'_000000');
-        $absurl = escape(index_url($_SERVER).'?do=daily&day='.$day);  // Absolute URL of the corresponding "Daily" page.
-
-        // We pre-format some fields for proper output.
-        foreach ($bookmarks as $key => $bookmark) {
-            $formattedBookmarks[$key] = $formatter->format($bookmark);
-            // This page is a bit specific, we need raw description to calculate the length
-            $formattedBookmarks[$key]['formatedDescription'] = $formattedBookmarks[$key]['description'];
-            $formattedBookmarks[$key]['description'] = $bookmark->getDescription();
-
-            if ($bookmark->isNote()) {
-                $link['url'] = index_url($_SERVER) . $bookmark->getUrl();  // make permalink URL absolute
-            }
-        }
-
-        // Then build the HTML for this day:
-        $tpl = new RainTPL();
-        $tpl->assign('title', $conf->get('general.title'));
-        $tpl->assign('daydate', $dayDate->getTimestamp());
-        $tpl->assign('absurl', $absurl);
-        $tpl->assign('links', $formattedBookmarks);
-        $tpl->assign('rssdate', escape($dayDate->format(DateTime::RSS)));
-        $tpl->assign('hide_timestamps', $conf->get('privacy.hide_timestamps', false));
-        $tpl->assign('index_url', $pageaddr);
-        $html = $tpl->draw('dailyrss', true);
-
-        echo $html . PHP_EOL;
-    }
-    echo '</channel></rss><!-- Cached version of '. escape(page_url($_SERVER)) .' -->';
-
-    $cache->cache(ob_get_contents());
-    ob_end_flush();
-    exit;
-}
-
-/**
- * Show the 'Daily' page.
- *
- * @param PageBuilder              $pageBuilder     Template engine wrapper.
- * @param BookmarkServiceInterface $bookmarkService instance.
- * @param ConfigManager            $conf            Configuration Manager instance.
- * @param PluginManager            $pluginManager   Plugin Manager instance.
- * @param LoginManager             $loginManager    Login Manager instance
- */
-function showDaily($pageBuilder, $bookmarkService, $conf, $pluginManager, $loginManager)
-{
-    if (isset($_GET['day'])) {
-        $day = $_GET['day'];
-        if ($day === date('Ymd', strtotime('now'))) {
-            $pageBuilder->assign('dayDesc', t('Today'));
-        } elseif ($day === date('Ymd', strtotime('-1 days'))) {
-            $pageBuilder->assign('dayDesc', t('Yesterday'));
-        }
-    } else {
-        $day = date('Ymd', strtotime('now')); // Today, in format YYYYMMDD.
-        $pageBuilder->assign('dayDesc', t('Today'));
-    }
-
-    $days = $bookmarkService->days();
-    $i = array_search($day, $days);
-    if ($i === false && count($days)) {
-        // no bookmarks for day, but at least one day with bookmarks
-        $i = count($days) - 1;
-        $day = $days[$i];
-    }
-    $previousday = '';
-    $nextday = '';
-
-    if ($i !== false) {
-        if ($i >= 1) {
-             $previousday = $days[$i - 1];
-        }
-        if ($i < count($days) - 1) {
-            $nextday = $days[$i + 1];
-        }
-    }
-    try {
-        $linksToDisplay = $bookmarkService->filterDay($day);
-    } catch (Exception $exc) {
-        error_log($exc);
-        $linksToDisplay = [];
-    }
-
-    $factory = new FormatterFactory($conf, $loginManager->isLoggedIn());
-    $formatter = $factory->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 = array(
-        'pagetitle' => $conf->get('general.title') .' - '. format_date($dayDate, false),
-        'linksToDisplay' => $linksToDisplay,
-        'day' => $dayDate->getTimestamp(),
-        'dayDate' => $dayDate,
-        'previousday' => $previousday,
-        'nextday' => $nextday,
-    );
-
-    /* Hook is called before column construction so that plugins don't have
-       to deal with columns. */
-    $pluginManager->executeHooks('render_daily', $data, array('loggedin' => $loginManager->isLoggedIn()));
-
-    /* We need to spread the articles on 3 columns.
-       I 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.
-    */
-    $columns = array(array(), array(), array()); // Entries to display, for each column.
-    $fill = array(0, 0, 0);  // Rough estimate of columns fill.
-    foreach ($data['linksToDisplay'] as $key => $bookmark) {
-        // 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($bookmark['title']) + (342 * strlen($bookmark['description'])) / 836;
-        if (! empty($bookmark['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], $bookmark); // Put entry in this column.
-        $fill[$index] += $length;
-    }
-
-    $data['cols'] = $columns;
-
-    foreach ($data as $key => $value) {
-        $pageBuilder->assign($key, $value);
-    }
-
-    $pageBuilder->assign('pagetitle', t('Daily') .' - '. $conf->get('general.title', 'Shaarli'));
-    $pageBuilder->renderPage('daily');
-    exit;
-}
-
-/**
- * Renders the linklist
- *
- * @param pageBuilder              $PAGE          pageBuilder instance.
- * @param BookmarkServiceInterface $linkDb        instance.
- * @param ConfigManager            $conf          Configuration Manager instance.
- * @param PluginManager            $pluginManager Plugin Manager instance.
- */
-function showLinkList($PAGE, $linkDb, $conf, $pluginManager, $loginManager)
-{
-    buildLinkList($PAGE, $linkDb, $conf, $pluginManager, $loginManager);
-    $PAGE->renderPage('linklist');
-}
-
-/**
- * Render HTML page (according to URL parameters and user rights)
- *
- * @param ConfigManager            $conf           Configuration Manager instance.
- * @param PluginManager            $pluginManager  Plugin Manager instance,
- * @param BookmarkServiceInterface $bookmarkService
- * @param History                  $history        instance
- * @param SessionManager           $sessionManager SessionManager instance
- * @param LoginManager             $loginManager   LoginManager instance
- */
-function renderPage($conf, $pluginManager, $bookmarkService, $history, $sessionManager, $loginManager)
-{
-    $updater = new Updater(
-        UpdaterUtils::read_updates_file($conf->get('resource.updates')),
-        $bookmarkService,
-        $conf,
-        $loginManager->isLoggedIn()
-    );
-    try {
-        $newUpdates = $updater->update();
-        if (! empty($newUpdates)) {
-            UpdaterUtils::write_updates_file(
-                $conf->get('resource.updates'),
-                $updater->getDoneUpdates()
-            );
-        }
-    } catch (Exception $e) {
-        die($e->getMessage());
-    }
-
-    $PAGE = new PageBuilder($conf, $_SESSION, $bookmarkService, $sessionManager->generateToken(), $loginManager->isLoggedIn());
-    $PAGE->assign('linkcount', $bookmarkService->count(BookmarkFilter::$ALL));
-    $PAGE->assign('privateLinkcount', $bookmarkService->count(BookmarkFilter::$PRIVATE));
-    $PAGE->assign('plugin_errors', $pluginManager->getErrors());
-
-    // Determine which page will be rendered.
-    $query = (isset($_SERVER['QUERY_STRING'])) ? $_SERVER['QUERY_STRING'] : '';
-    $targetPage = Router::findPage($query, $_GET, $loginManager->isLoggedIn());
-
-    if (// if the user isn't logged in
-        !$loginManager->isLoggedIn() &&
-        // and Shaarli doesn't have public content...
-        $conf->get('privacy.hide_public_links') &&
-        // and is configured to enforce the login
-        $conf->get('privacy.force_login') &&
-        // and the current page isn't already the login page
-        $targetPage !== Router::$PAGE_LOGIN &&
-        // and the user is not requesting a feed (which would lead to a different content-type as expected)
-        $targetPage !== Router::$PAGE_FEED_ATOM &&
-        $targetPage !== Router::$PAGE_FEED_RSS
-    ) {
-        // force current page to be the login page
-        $targetPage = Router::$PAGE_LOGIN;
-    }
-
-    // Call plugin hooks for header, footer and includes, specifying which page will be rendered.
-    // Then assign generated data to RainTPL.
-    $common_hooks = array(
-        'includes',
-        'header',
-        'footer',
-    );
-
-    foreach ($common_hooks as $name) {
-        $plugin_data = array();
-        $pluginManager->executeHooks(
-            'render_' . $name,
-            $plugin_data,
-            array(
-                'target' => $targetPage,
-                'loggedin' => $loginManager->isLoggedIn()
-            )
-        );
-        $PAGE->assign('plugins_' . $name, $plugin_data);
-    }
-
-    // -------- Display login form.
-    if ($targetPage == Router::$PAGE_LOGIN) {
-        header('Location: ./login');
-        exit;
-    }
-    // -------- User wants to logout.
-    if (isset($_SERVER['QUERY_STRING']) && startsWith($_SERVER['QUERY_STRING'], 'do=logout')) {
-        invalidateCaches($conf->get('resource.page_cache'));
-        $sessionManager->logout();
-        setcookie(LoginManager::$STAY_SIGNED_IN_COOKIE, 'false', 0, WEB_PATH);
-        header('Location: ?');
-        exit;
-    }
-
-    // -------- Picture wall
-    if ($targetPage == Router::$PAGE_PICWALL) {
-        $PAGE->assign('pagetitle', t('Picture wall') .' - '. $conf->get('general.title', 'Shaarli'));
-        if (! $conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) === Thumbnailer::MODE_NONE) {
-            $PAGE->assign('linksToDisplay', []);
-            $PAGE->renderPage('picwall');
-            exit;
-        }
-
-        // Optionally filter the results:
-        $links = $bookmarkService->search($_GET);
-        $linksToDisplay = [];
-
-        // Get only bookmarks which have a thumbnail.
-        // Note: we do not retrieve thumbnails here, the request is too heavy.
-        $factory = new FormatterFactory($conf, $loginManager->isLoggedIn());
-        $formatter = $factory->getFormatter();
-        foreach ($links as $key => $link) {
-            if ($link->getThumbnail() !== false) {
-                $linksToDisplay[] = $formatter->format($link);
-            }
-        }
-
-        $data = [
-            'linksToDisplay' => $linksToDisplay,
-        ];
-        $pluginManager->executeHooks('render_picwall', $data, ['loggedin' => $loginManager->isLoggedIn()]);
-
-        foreach ($data as $key => $value) {
-            $PAGE->assign($key, $value);
-        }
-
-        $PAGE->renderPage('picwall');
-        exit;
-    }
-
-    // -------- Tag cloud
-    if ($targetPage == Router::$PAGE_TAGCLOUD) {
-        $visibility = ! empty($_SESSION['visibility']) ? $_SESSION['visibility'] : '';
-        $filteringTags = isset($_GET['searchtags']) ? explode(' ', $_GET['searchtags']) : [];
-        $tags = $bookmarkService->bookmarksCountPerTag($filteringTags, $visibility);
-
-        // We sort tags alphabetically, then choose a font size according to count.
-        // First, find max value.
-        $maxcount = 0;
-        foreach ($tags as $value) {
-            $maxcount = max($maxcount, $value);
-        }
-
-        alphabetical_sort($tags, false, true);
-
-        $logMaxCount = $maxcount > 1 ? log($maxcount, 30) : 1;
-        $tagList = array();
-        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] = array(
-                'count' => $value,
-                'size' => number_format($size, 2, '.', ''),
-            );
-        }
-
-        $searchTags = implode(' ', escape($filteringTags));
-        $data = array(
-            'search_tags' => $searchTags,
-            'tags' => $tagList,
-        );
-        $pluginManager->executeHooks('render_tagcloud', $data, array('loggedin' => $loginManager->isLoggedIn()));
-
-        foreach ($data as $key => $value) {
-            $PAGE->assign($key, $value);
-        }
-
-        $searchTags = ! empty($searchTags) ? $searchTags .' - ' : '';
-        $PAGE->assign('pagetitle', $searchTags. t('Tag cloud') .' - '. $conf->get('general.title', 'Shaarli'));
-        $PAGE->renderPage('tag.cloud');
-        exit;
-    }
-
-    // -------- Tag list
-    if ($targetPage == Router::$PAGE_TAGLIST) {
-        $visibility = ! empty($_SESSION['visibility']) ? $_SESSION['visibility'] : '';
-        $filteringTags = isset($_GET['searchtags']) ? explode(' ', $_GET['searchtags']) : [];
-        $tags = $bookmarkService->bookmarksCountPerTag($filteringTags, $visibility);
-        foreach ($filteringTags as $tag) {
-            if (array_key_exists($tag, $tags)) {
-                unset($tags[$tag]);
-            }
-        }
-
-        if (! empty($_GET['sort']) && $_GET['sort'] === 'alpha') {
-            alphabetical_sort($tags, false, true);
-        }
-
-        $searchTags = implode(' ', escape($filteringTags));
-        $data = [
-            'search_tags' => $searchTags,
-            'tags' => $tags,
-        ];
-        $pluginManager->executeHooks('render_taglist', $data, ['loggedin' => $loginManager->isLoggedIn()]);
-
-        foreach ($data as $key => $value) {
-            $PAGE->assign($key, $value);
-        }
-
-        $searchTags = ! empty($searchTags) ? $searchTags .' - ' : '';
-        $PAGE->assign('pagetitle', $searchTags . t('Tag list') .' - '. $conf->get('general.title', 'Shaarli'));
-        $PAGE->renderPage('tag.list');
-        exit;
-    }
-
-    // Daily page.
-    if ($targetPage == Router::$PAGE_DAILY) {
-        showDaily($PAGE, $bookmarkService, $conf, $pluginManager, $loginManager);
-    }
-
-    // ATOM and RSS feed.
-    if ($targetPage == Router::$PAGE_FEED_ATOM || $targetPage == Router::$PAGE_FEED_RSS) {
-        $feedType = $targetPage == Router::$PAGE_FEED_RSS ? FeedBuilder::$FEED_RSS : FeedBuilder::$FEED_ATOM;
-        header('Content-Type: application/'. $feedType .'+xml; charset=utf-8');
-
-        // Cache system
-        $query = $_SERVER['QUERY_STRING'];
-        $cache = new CachedPage(
-            $conf->get('resource.page_cache'),
-            page_url($_SERVER),
-            startsWith($query, 'do='. $targetPage) && !$loginManager->isLoggedIn()
-        );
-        $cached = $cache->cachedVersion();
-        if (!empty($cached)) {
-            echo $cached;
-            exit;
-        }
-
-        $factory = new FormatterFactory($conf, $loginManager->isLoggedIn());
-        // Generate data.
-        $feedGenerator = new FeedBuilder(
-            $bookmarkService,
-            $factory->getFormatter(),
-            $feedType,
-            $_SERVER,
-            $_GET,
-            $loginManager->isLoggedIn()
-        );
-        $feedGenerator->setLocale(strtolower(setlocale(LC_COLLATE, 0)));
-        $feedGenerator->setHideDates($conf->get('privacy.hide_timestamps') && !$loginManager->isLoggedIn());
-        $feedGenerator->setUsePermalinks(isset($_GET['permalinks']) || !$conf->get('feed.rss_permalinks'));
-        $data = $feedGenerator->buildData();
-
-        // Process plugin hook.
-        $pluginManager->executeHooks('render_feed', $data, array(
-            'loggedin' => $loginManager->isLoggedIn(),
-            'target' => $targetPage,
-        ));
-
-        // Render the template.
-        $PAGE->assignAll($data);
-        $PAGE->renderPage('feed.'. $feedType);
-        $cache->cache(ob_get_contents());
-        ob_end_flush();
-        exit;
-    }
-
-    // Display opensearch plugin (XML)
-    if ($targetPage == Router::$PAGE_OPENSEARCH) {
-        header('Content-Type: application/xml; charset=utf-8');
-        $PAGE->assign('serverurl', index_url($_SERVER));
-        $PAGE->renderPage('opensearch');
-        exit;
-    }
-
-    // -------- User clicks on a tag in a link: The tag is added to the list of searched tags (searchtags=...)
-    if (isset($_GET['addtag'])) {
-        // Get previous URL (http_referer) and add the tag to the searchtags parameters in query.
-        if (empty($_SERVER['HTTP_REFERER'])) {
-            // In case browser does not send HTTP_REFERER
-            header('Location: ?searchtags='.urlencode($_GET['addtag']));
-            exit;
-        }
-        parse_str(parse_url($_SERVER['HTTP_REFERER'], PHP_URL_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
-        if (isset($params['searchtags'])) {
-            $current_tags = explode(' ', $params['searchtags']);
-        } else {
-            $current_tags = array();
-        }
-        $addtag = true;
-        foreach ($current_tags as $value) {
-            if ($value === $_GET['addtag']) {
-                $addtag = false;
-                break;
-            }
-        }
-        // Append the tag if necessary
-        if (empty($params['searchtags'])) {
-            $params['searchtags'] = trim($_GET['addtag']);
-        } elseif ($addtag) {
-            $params['searchtags'] = trim($params['searchtags']).' '.trim($_GET['addtag']);
-        }
-
-        // We also remove page (keeping the same page has no sense, since the
-        // results are different)
-        unset($params['page']);
-
-        header('Location: ?'.http_build_query($params));
-        exit;
-    }
-
-    // -------- User clicks on a tag in result count: Remove the tag from the list of searched tags (searchtags=...)
-    if (isset($_GET['removetag'])) {
-        // Get previous URL (http_referer) and remove the tag from the searchtags parameters in query.
-        if (empty($_SERVER['HTTP_REFERER'])) {
-            header('Location: ?');
-            exit;
-        }
-
-        // In case browser does not send HTTP_REFERER
-        parse_str(parse_url($_SERVER['HTTP_REFERER'], PHP_URL_QUERY), $params);
+$loginManager->checkLoginState(client_ip_id($_SERVER));
 
-        // 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, array($_GET['removetag']));
-            $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']);
-        }
-        header('Location: ?'.http_build_query($params));
-        exit;
-    }
-
-    // -------- User wants to change the number of bookmarks per page (linksperpage=...)
-    if (isset($_GET['linksperpage'])) {
-        if (is_numeric($_GET['linksperpage'])) {
-            $_SESSION['LINKS_PER_PAGE']=abs(intval($_GET['linksperpage']));
-        }
-
-        if (! empty($_SERVER['HTTP_REFERER'])) {
-            $location = generateLocation($_SERVER['HTTP_REFERER'], $_SERVER['HTTP_HOST'], array('linksperpage'));
-        } else {
-            $location = '?';
-        }
-        header('Location: '. $location);
-        exit;
-    }
-
-    // -------- User wants to see only private bookmarks (toggle)
-    if (isset($_GET['visibility'])) {
-        if ($_GET['visibility'] === 'private') {
-            // Visibility not set or not already private, set private, otherwise reset it
-            if (empty($_SESSION['visibility']) || $_SESSION['visibility'] !== 'private') {
-                // See only private bookmarks
-                $_SESSION['visibility'] = 'private';
-            } else {
-                unset($_SESSION['visibility']);
-            }
-        } elseif ($_GET['visibility'] === 'public') {
-            if (empty($_SESSION['visibility']) || $_SESSION['visibility'] !== 'public') {
-                // See only public bookmarks
-                $_SESSION['visibility'] = 'public';
-            } else {
-                unset($_SESSION['visibility']);
-            }
-        }
-
-        if (! empty($_SERVER['HTTP_REFERER'])) {
-            $location = generateLocation($_SERVER['HTTP_REFERER'], $_SERVER['HTTP_HOST'], array('visibility'));
-        } else {
-            $location = '?';
-        }
-        header('Location: '. $location);
-        exit;
-    }
-
-    // -------- User wants to see only untagged bookmarks (toggle)
-    if (isset($_GET['untaggedonly'])) {
-        $_SESSION['untaggedonly'] = empty($_SESSION['untaggedonly']);
-
-        if (! empty($_SERVER['HTTP_REFERER'])) {
-            $location = generateLocation($_SERVER['HTTP_REFERER'], $_SERVER['HTTP_HOST'], array('untaggedonly'));
-        } else {
-            $location = '?';
-        }
-        header('Location: '. $location);
-        exit;
-    }
-
-    // -------- Handle other actions allowed for non-logged in users:
-    if (!$loginManager->isLoggedIn()) {
-        // User tries to post new link but is not logged in:
-        // Show login screen, then redirect to ?post=...
-        if (isset($_GET['post'])) {
-            header( // Redirect to login page, then back to post link.
-                'Location: /login?post='.urlencode($_GET['post']).
-                (!empty($_GET['title'])?'&title='.urlencode($_GET['title']):'').
-                (!empty($_GET['description'])?'&description='.urlencode($_GET['description']):'').
-                (!empty($_GET['tags'])?'&tags='.urlencode($_GET['tags']):'').
-                (!empty($_GET['source'])?'&source='.urlencode($_GET['source']):'')
-            );
-            exit;
-        }
-
-        showLinkList($PAGE, $bookmarkService, $conf, $pluginManager, $loginManager);
-        if (isset($_GET['edit_link'])) {
-            header('Location: /login?edit_link='. escape($_GET['edit_link']));
-            exit;
-        }
-
-        exit; // Never remove this one! All operations below are reserved for logged in user.
-    }
-
-    // -------- All other functions are reserved for the registered user:
-
-    // -------- Display the Tools menu if requested (import/export/bookmarklet...)
-    if ($targetPage == Router::$PAGE_TOOLS) {
-        $data = [
-            'pageabsaddr' => index_url($_SERVER),
-            'sslenabled' => is_https($_SERVER),
-        ];
-        $pluginManager->executeHooks('render_tools', $data);
-
-        foreach ($data as $key => $value) {
-            $PAGE->assign($key, $value);
-        }
-
-        $PAGE->assign('pagetitle', t('Tools') .' - '. $conf->get('general.title', 'Shaarli'));
-        $PAGE->renderPage('tools');
-        exit;
-    }
-
-    // -------- User wants to change his/her password.
-    if ($targetPage == Router::$PAGE_CHANGEPASSWORD) {
-        if ($conf->get('security.open_shaarli')) {
-            die(t('You are not supposed to change a password on an Open Shaarli.'));
-        }
-
-        if (!empty($_POST['setpassword']) && !empty($_POST['oldpassword'])) {
-            if (!$sessionManager->checkToken($_POST['token'])) {
-                die(t('Wrong token.')); // Go away!
-            }
-
-            // Make sure old password is correct.
-            $oldhash = sha1(
-                $_POST['oldpassword'].$conf->get('credentials.login').$conf->get('credentials.salt')
-            );
-            if ($oldhash != $conf->get('credentials.hash')) {
-                echo '<script>alert("'
-                    . t('The old password is not correct.')
-                    .'");document.location=\'?do=changepasswd\';</script>';
-                exit;
-            }
-            // Save new password
-            // Salt renders rainbow-tables attacks useless.
-            $conf->set('credentials.salt', sha1(uniqid('', true) .'_'. mt_rand()));
-            $conf->set(
-                'credentials.hash',
-                sha1(
-                    $_POST['setpassword']
-                    . $conf->get('credentials.login')
-                    . $conf->get('credentials.salt')
-                )
-            );
-            try {
-                $conf->write($loginManager->isLoggedIn());
-            } catch (Exception $e) {
-                error_log(
-                    'ERROR while writing config file after changing password.' . PHP_EOL .
-                    $e->getMessage()
-                );
-
-                // TODO: do not handle exceptions/errors in JS.
-                echo '<script>alert("'. $e->getMessage() .'");document.location=\'?do=tools\';</script>';
-                exit;
-            }
-            echo '<script>alert("'. t('Your password has been changed') .'");document.location=\'?do=tools\';</script>';
-            exit;
-        } else {
-            // show the change password form.
-            $PAGE->assign('pagetitle', t('Change password') .' - '. $conf->get('general.title', 'Shaarli'));
-            $PAGE->renderPage('changepassword');
-            exit;
-        }
-    }
-
-    // -------- User wants to change configuration
-    if ($targetPage == Router::$PAGE_CONFIGURE) {
-        if (!empty($_POST['title'])) {
-            if (!$sessionManager->checkToken($_POST['token'])) {
-                die(t('Wrong token.')); // Go away!
-            }
-            $tz = 'UTC';
-            if (!empty($_POST['continent']) && !empty($_POST['city'])
-                && isTimeZoneValid($_POST['continent'], $_POST['city'])
-            ) {
-                $tz = $_POST['continent'] . '/' . $_POST['city'];
-            }
-            $conf->set('general.timezone', $tz);
-            $conf->set('general.title', escape($_POST['title']));
-            $conf->set('general.header_link', escape($_POST['titleLink']));
-            $conf->set('general.retrieve_description', !empty($_POST['retrieveDescription']));
-            $conf->set('resource.theme', escape($_POST['theme']));
-            $conf->set('security.session_protection_disabled', !empty($_POST['disablesessionprotection']));
-            $conf->set('privacy.default_private_links', !empty($_POST['privateLinkByDefault']));
-            $conf->set('feed.rss_permalinks', !empty($_POST['enableRssPermalinks']));
-            $conf->set('updates.check_updates', !empty($_POST['updateCheck']));
-            $conf->set('privacy.hide_public_links', !empty($_POST['hidePublicLinks']));
-            $conf->set('api.enabled', !empty($_POST['enableApi']));
-            $conf->set('api.secret', escape($_POST['apiSecret']));
-            $conf->set('formatter', escape($_POST['formatter']));
-
-            if (! empty($_POST['language'])) {
-                $conf->set('translation.language', escape($_POST['language']));
-            }
-
-            $thumbnailsMode = extension_loaded('gd') ? $_POST['enableThumbnails'] : Thumbnailer::MODE_NONE;
-            if ($thumbnailsMode !== Thumbnailer::MODE_NONE
-                && $thumbnailsMode !== $conf->get('thumbnails.mode', Thumbnailer::MODE_NONE)
-            ) {
-                $_SESSION['warnings'][] = t(
-                    'You have enabled or changed thumbnails mode. '
-                    .'<a href="?do=thumbs_update">Please synchronize them</a>.'
-                );
-            }
-            $conf->set('thumbnails.mode', $thumbnailsMode);
-
-            try {
-                $conf->write($loginManager->isLoggedIn());
-                $history->updateSettings();
-                invalidateCaches($conf->get('resource.page_cache'));
-            } catch (Exception $e) {
-                error_log(
-                    'ERROR while writing config file after configuration update.' . PHP_EOL .
-                    $e->getMessage()
-                );
-
-                // TODO: do not handle exceptions/errors in JS.
-                echo '<script>alert("'. $e->getMessage() .'");document.location=\'?do=configure\';</script>';
-                exit;
-            }
-            echo '<script>alert("'. t('Configuration was saved.') .'");document.location=\'?do=configure\';</script>';
-            exit;
-        } else {
-            // Show the configuration form.
-            $PAGE->assign('title', $conf->get('general.title'));
-            $PAGE->assign('theme', $conf->get('resource.theme'));
-            $PAGE->assign('theme_available', ThemeUtils::getThemes($conf->get('resource.raintpl_tpl')));
-            $PAGE->assign('formatter_available', ['default', 'markdown']);
-            list($continents, $cities) = generateTimeZoneData(
-                timezone_identifiers_list(),
-                $conf->get('general.timezone')
-            );
-            $PAGE->assign('continents', $continents);
-            $PAGE->assign('cities', $cities);
-            $PAGE->assign('retrieve_description', $conf->get('general.retrieve_description'));
-            $PAGE->assign('private_links_default', $conf->get('privacy.default_private_links', false));
-            $PAGE->assign('session_protection_disabled', $conf->get('security.session_protection_disabled', false));
-            $PAGE->assign('enable_rss_permalinks', $conf->get('feed.rss_permalinks', false));
-            $PAGE->assign('enable_update_check', $conf->get('updates.check_updates', true));
-            $PAGE->assign('hide_public_links', $conf->get('privacy.hide_public_links', false));
-            $PAGE->assign('api_enabled', $conf->get('api.enabled', true));
-            $PAGE->assign('api_secret', $conf->get('api.secret'));
-            $PAGE->assign('languages', Languages::getAvailableLanguages());
-            $PAGE->assign('gd_enabled', extension_loaded('gd'));
-            $PAGE->assign('thumbnails_mode', $conf->get('thumbnails.mode', Thumbnailer::MODE_NONE));
-            $PAGE->assign('pagetitle', t('Configure') .' - '. $conf->get('general.title', 'Shaarli'));
-            $PAGE->renderPage('configure');
-            exit;
-        }
-    }
-
-    // -------- User wants to rename a tag or delete it
-    if ($targetPage == Router::$PAGE_CHANGETAG) {
-        if (empty($_POST['fromtag']) || (empty($_POST['totag']) && isset($_POST['renametag']))) {
-            $PAGE->assign('fromtag', ! empty($_GET['fromtag']) ? escape($_GET['fromtag']) : '');
-            $PAGE->assign('pagetitle', t('Manage tags') .' - '. $conf->get('general.title', 'Shaarli'));
-            $PAGE->renderPage('changetag');
-            exit;
-        }
-
-        if (!$sessionManager->checkToken($_POST['token'])) {
-            die(t('Wrong token.'));
-        }
-
-        $toTag = isset($_POST['totag']) ? escape($_POST['totag']) : null;
-        $fromTag = escape($_POST['fromtag']);
-        $count = 0;
-        $bookmarks = $bookmarkService->search(['searchtags' => $fromTag], BookmarkFilter::$ALL, true);
-        foreach ($bookmarks as $bookmark) {
-            if ($toTag) {
-                $bookmark->renameTag($fromTag, $toTag);
-            } else {
-                $bookmark->deleteTag($fromTag);
-            }
-            $bookmarkService->set($bookmark, false);
-            $history->updateLink($bookmark);
-            $count++;
-        }
-        $bookmarkService->save();
-        $delete = empty($_POST['totag']);
-        $redirect = $delete ? 'do=changetag' : 'searchtags='. urlencode(escape($_POST['totag']));
-        $alert = $delete
-            ? sprintf(t('The tag was removed from %d link.', 'The tag was removed from %d bookmarks.', $count), $count)
-            : sprintf(t('The tag was renamed in %d link.', 'The tag was renamed in %d bookmarks.', $count), $count);
-        echo '<script>alert("'. $alert .'");document.location=\'?'. $redirect .'\';</script>';
-        exit;
-    }
-
-    // -------- User wants to add a link without using the bookmarklet: Show form.
-    if ($targetPage == Router::$PAGE_ADDLINK) {
-        $PAGE->assign('pagetitle', t('Shaare a new link') .' - '. $conf->get('general.title', 'Shaarli'));
-        $PAGE->renderPage('addlink');
-        exit;
-    }
-
-    // -------- User clicked the "Save" button when editing a link: Save link to database.
-    if (isset($_POST['save_edit'])) {
-        // Go away!
-        if (! $sessionManager->checkToken($_POST['token'])) {
-            die(t('Wrong token.'));
-        }
-
-        // lf_id should only be present if the link exists.
-        $id = isset($_POST['lf_id']) ? intval(escape($_POST['lf_id'])) : null;
-        if ($id && $bookmarkService->exists($id)) {
-            // Edit
-            $bookmark = $bookmarkService->get($id);
-        } else {
-            // New link
-            $bookmark = new Bookmark();
-        }
-
-        $bookmark->setTitle($_POST['lf_title']);
-        $bookmark->setDescription($_POST['lf_description']);
-        $bookmark->setUrl($_POST['lf_url'], $conf->get('security.allowed_protocols'));
-        $bookmark->setPrivate(isset($_POST['lf_private']));
-        $bookmark->setTagsString($_POST['lf_tags']);
-
-        if ($conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE
-            && ! $bookmark->isNote()
-        ) {
-            $thumbnailer = new Thumbnailer($conf);
-            $bookmark->setThumbnail($thumbnailer->get($bookmark->getUrl()));
-        }
-        $bookmarkService->addOrSet($bookmark, false);
-
-        // To preserve backward compatibility with 3rd parties, plugins still use arrays
-        $factory = new FormatterFactory($conf, $loginManager->isLoggedIn());
-        $formatter = $factory->getFormatter('raw');
-        $data = $formatter->format($bookmark);
-        $pluginManager->executeHooks('save_link', $data);
-
-        $bookmark->fromArray($data);
-        $bookmarkService->set($bookmark);
-
-        // If we are called from the bookmarklet, we must close the popup:
-        if (isset($_GET['source']) && ($_GET['source']=='bookmarklet' || $_GET['source']=='firefoxsocialapi')) {
-            echo '<script>self.close();</script>';
-            exit;
-        }
-
-        $returnurl = !empty($_POST['returnurl']) ? $_POST['returnurl'] : '?';
-        $location = generateLocation($returnurl, $_SERVER['HTTP_HOST'], array('addlink', 'post', 'edit_link'));
-        // Scroll to the link which has been edited.
-        $location .= '#' . $bookmark->getShortUrl();
-        // After saving the link, redirect to the page the user was on.
-        header('Location: '. $location);
-        exit;
-    }
-
-    // -------- User clicked the "Delete" button when editing a link: Delete link from database.
-    if ($targetPage == Router::$PAGE_DELETELINK) {
-        if (! $sessionManager->checkToken($_GET['token'])) {
-            die(t('Wrong token.'));
-        }
-
-        $ids = trim($_GET['lf_linkdate']);
-        if (strpos($ids, ' ') !== false) {
-            // multiple, space-separated ids provided
-            $ids = array_values(array_filter(
-                preg_split('/\s+/', escape($ids)),
-                function ($item) {
-                    return $item !== '';
-                }
-            ));
-        } else {
-            // only a single id provided
-            $shortUrl = $bookmarkService->get($ids)->getShortUrl();
-            $ids = [$ids];
-        }
-        // assert at least one id is given
-        if (!count($ids)) {
-            die('no id provided');
-        }
-        $factory = new FormatterFactory($conf, $loginManager->isLoggedIn());
-        $formatter = $factory->getFormatter('raw');
-        foreach ($ids as $id) {
-            $id = (int) escape($id);
-            $bookmark = $bookmarkService->get($id);
-            $data = $formatter->format($bookmark);
-            $pluginManager->executeHooks('delete_link', $data);
-            $bookmarkService->remove($bookmark, false);
-        }
-        $bookmarkService->save();
-
-        // If we are called from the bookmarklet, we must close the popup:
-        if (isset($_GET['source']) && ($_GET['source']=='bookmarklet' || $_GET['source']=='firefoxsocialapi')) {
-            echo '<script>self.close();</script>';
-            exit;
-        }
-
-        $location = '?';
-        if (isset($_SERVER['HTTP_REFERER'])) {
-            // Don't redirect to where we were previously if it was a permalink or an edit_link, because it would 404.
-            $location = generateLocation(
-                $_SERVER['HTTP_REFERER'],
-                $_SERVER['HTTP_HOST'],
-                ['delete_link', 'edit_link', ! empty($shortUrl) ? $shortUrl : null]
-            );
-        }
-
-        header('Location: ' . $location); // After deleting the link, redirect to appropriate location
-        exit;
-    }
-
-    // -------- User clicked either "Set public" or "Set private" bulk operation
-    if ($targetPage == Router::$PAGE_CHANGE_VISIBILITY) {
-        if (! $sessionManager->checkToken($_GET['token'])) {
-            die(t('Wrong token.'));
-        }
-
-        $ids = trim($_GET['ids']);
-        if (strpos($ids, ' ') !== false) {
-            // multiple, space-separated ids provided
-            $ids = array_values(array_filter(preg_split('/\s+/', escape($ids))));
-        } else {
-            // only a single id provided
-            $ids = [$ids];
-        }
-
-        // assert at least one id is given
-        if (!count($ids)) {
-            die('no id provided');
-        }
-        // assert that the visibility is valid
-        if (!isset($_GET['newVisibility']) || !in_array($_GET['newVisibility'], ['public', 'private'])) {
-            die('invalid visibility');
-        } else {
-            $private = $_GET['newVisibility'] === 'private';
-        }
-        $factory = new FormatterFactory($conf, $loginManager->isLoggedIn());
-        $formatter = $factory->getFormatter('raw');
-        foreach ($ids as $id) {
-            $id = (int) escape($id);
-            $bookmark = $bookmarkService->get($id);
-            $bookmark->setPrivate($private);
-
-            // To preserve backward compatibility with 3rd parties, plugins still use arrays
-            $data = $formatter->format($bookmark);
-            $pluginManager->executeHooks('save_link', $data);
-            $bookmark->fromArray($data);
-
-            $bookmarkService->set($bookmark);
-        }
-        $bookmarkService->save();
-
-        $location = '?';
-        if (isset($_SERVER['HTTP_REFERER'])) {
-            $location = generateLocation(
-                $_SERVER['HTTP_REFERER'],
-                $_SERVER['HTTP_HOST']
-            );
-        }
-        header('Location: ' . $location); // After deleting the link, redirect to appropriate location
-        exit;
-    }
-
-    // -------- User clicked the "EDIT" button on a link: Display link edit form.
-    if (isset($_GET['edit_link'])) {
-        $id = (int) escape($_GET['edit_link']);
-        try {
-            $link = $bookmarkService->get($id);  // Read database
-        } catch (BookmarkNotFoundException $e) {
-            // Link not found in database.
-            header('Location: ?');
-            exit;
-        }
-
-        $factory = new FormatterFactory($conf, $loginManager->isLoggedIn());
-        $formatter = $factory->getFormatter('raw');
-        $formattedLink = $formatter->format($link);
-        $tags = $bookmarkService->bookmarksCountPerTag();
-        if ($conf->get('formatter') === 'markdown') {
-            $tags[BookmarkMarkdownFormatter::NO_MD_TAG] = 1;
-        }
-        $data = array(
-            'link' => $formattedLink,
-            'link_is_new' => false,
-            'http_referer' => (isset($_SERVER['HTTP_REFERER']) ? escape($_SERVER['HTTP_REFERER']) : ''),
-            'tags' => $tags,
-        );
-        $pluginManager->executeHooks('render_editlink', $data);
-
-        foreach ($data as $key => $value) {
-            $PAGE->assign($key, $value);
-        }
-
-        $PAGE->assign('pagetitle', t('Edit') .' '. t('Shaare') .' - '. $conf->get('general.title', 'Shaarli'));
-        $PAGE->renderPage('editlink');
-        exit;
-    }
-
-    // -------- User want to post a new link: Display link edit form.
-    if (isset($_GET['post'])) {
-        $url = cleanup_url($_GET['post']);
-
-        $link_is_new = false;
-        // Check if URL is not already in database (in this case, we will edit the existing link)
-        $bookmark = $bookmarkService->findByUrl($url);
-        if (! $bookmark) {
-            $link_is_new = true;
-            // Get title if it was provided in URL (by the bookmarklet).
-            $title = empty($_GET['title']) ? '' : escape($_GET['title']);
-            // Get description if it was provided in URL (by the bookmarklet). [Bronco added that]
-            $description = empty($_GET['description']) ? '' : escape($_GET['description']);
-            $tags = empty($_GET['tags']) ? '' : escape($_GET['tags']);
-            $private = !empty($_GET['private']) && $_GET['private'] === "1" ? 1 : 0;
-
-            // 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 = $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.
-                get_http_response(
-                    $url,
-                    $conf->get('general.download_timeout', 30),
-                    $conf->get('general.download_max_size', 4194304),
-                    get_curl_download_callback($charset, $title, $description, $tags, $retrieveDescription)
-                );
-                if (! empty($title) && strtolower($charset) != 'utf-8') {
-                    $title = mb_convert_encoding($title, 'utf-8', $charset);
-                }
-            }
-
-            if ($url == '') {
-                $title = $conf->get('general.default_note_title', t('Note: '));
-            }
-            $url = escape($url);
-            $title = escape($title);
-
-            $link = [
-                'title' => $title,
-                'url' => $url,
-                'description' => $description,
-                'tags' => $tags,
-                'private' => $private,
-            ];
-        } else {
-            $factory = new FormatterFactory($conf, $loginManager->isLoggedIn());
-            $formatter = $factory->getFormatter('raw');
-            $link = $formatter->format($bookmark);
-        }
-
-        $tags = $bookmarkService->bookmarksCountPerTag();
-        if ($conf->get('formatter') === 'markdown') {
-            $tags[BookmarkMarkdownFormatter::NO_MD_TAG] = 1;
-        }
-        $data = [
-            'link' => $link,
-            'link_is_new' => $link_is_new,
-            'http_referer' => (isset($_SERVER['HTTP_REFERER']) ? escape($_SERVER['HTTP_REFERER']) : ''),
-            'source' => (isset($_GET['source']) ? $_GET['source'] : ''),
-            'tags' => $tags,
-            'default_private_links' => $conf->get('privacy.default_private_links', false),
-        ];
-        $pluginManager->executeHooks('render_editlink', $data);
-
-        foreach ($data as $key => $value) {
-            $PAGE->assign($key, $value);
-        }
-
-        $PAGE->assign('pagetitle', t('Shaare') .' - '. $conf->get('general.title', 'Shaarli'));
-        $PAGE->renderPage('editlink');
-        exit;
-    }
-
-    if ($targetPage == Router::$PAGE_PINLINK) {
-        if (! isset($_GET['id']) || !$bookmarkService->exists($_GET['id'])) {
-            // FIXME! Use a proper error system.
-            $msg = t('Invalid link ID provided');
-            echo '<script>alert("'. $msg .'");document.location=\''. index_url($_SERVER) .'\';</script>';
-            exit;
-        }
-        if (! $sessionManager->checkToken($_GET['token'])) {
-            die('Wrong token.');
-        }
-
-        $link = $bookmarkService->get($_GET['id']);
-        $link->setSticky(! $link->isSticky());
-        $bookmarkService->set($link);
-        header('Location: '.index_url($_SERVER));
-        exit;
-    }
-
-    if ($targetPage == Router::$PAGE_EXPORT) {
-        // Export bookmarks as a Netscape Bookmarks file
-
-        if (empty($_GET['selection'])) {
-            $PAGE->assign('pagetitle', t('Export') .' - '. $conf->get('general.title', 'Shaarli'));
-            $PAGE->renderPage('export');
-            exit;
-        }
-
-        // export as bookmarks_(all|private|public)_YYYYmmdd_HHMMSS.html
-        $selection = $_GET['selection'];
-        if (isset($_GET['prepend_note_url'])) {
-            $prependNoteUrl = $_GET['prepend_note_url'];
-        } else {
-            $prependNoteUrl = false;
-        }
-
-        try {
-            $factory = new FormatterFactory($conf, $loginManager->isLoggedIn());
-            $formatter = $factory->getFormatter('raw');
-            $PAGE->assign(
-                'links',
-                NetscapeBookmarkUtils::filterAndFormat(
-                    $bookmarkService,
-                    $formatter,
-                    $selection,
-                    $prependNoteUrl,
-                    index_url($_SERVER)
-                )
-            );
-        } catch (Exception $exc) {
-            header('Content-Type: text/plain; charset=utf-8');
-            echo $exc->getMessage();
-            exit;
-        }
-        $now = new DateTime();
-        header('Content-Type: text/html; charset=utf-8');
-        header(
-            'Content-disposition: attachment; filename=bookmarks_'
-            .$selection.'_'.$now->format(Bookmark::LINK_DATE_FORMAT).'.html'
-        );
-        $PAGE->assign('date', $now->format(DateTime::RFC822));
-        $PAGE->assign('eol', PHP_EOL);
-        $PAGE->assign('selection', $selection);
-        $PAGE->renderPage('export.bookmarks');
-        exit;
-    }
-
-    if ($targetPage == Router::$PAGE_IMPORT) {
-        // Upload a Netscape bookmark dump to import its contents
-
-        if (! isset($_POST['token']) || ! isset($_FILES['filetoupload'])) {
-            // Show import dialog
-            $PAGE->assign(
-                'maxfilesize',
-                get_max_upload_size(
-                    ini_get('post_max_size'),
-                    ini_get('upload_max_filesize'),
-                    false
-                )
-            );
-            $PAGE->assign(
-                'maxfilesizeHuman',
-                get_max_upload_size(
-                    ini_get('post_max_size'),
-                    ini_get('upload_max_filesize'),
-                    true
-                )
-            );
-            $PAGE->assign('pagetitle', t('Import') .' - '. $conf->get('general.title', 'Shaarli'));
-            $PAGE->renderPage('import');
-            exit;
-        }
-
-        // Import bookmarks from an uploaded file
-        if (isset($_FILES['filetoupload']['size']) && $_FILES['filetoupload']['size'] == 0) {
-            // 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'))
-            );
-            echo '<script>alert("'. $msg .'");document.location=\'?do='.Router::$PAGE_IMPORT .'\';</script>';
-            exit;
-        }
-        if (! $sessionManager->checkToken($_POST['token'])) {
-            die('Wrong token.');
-        }
-        $status = NetscapeBookmarkUtils::import(
-            $_POST,
-            $_FILES,
-            $bookmarkService,
-            $conf,
-            $history
-        );
-        echo '<script>alert("'.$status.'");document.location=\'?do='
-             .Router::$PAGE_IMPORT .'\';</script>';
-        exit;
-    }
-
-    // Plugin administration page
-    if ($targetPage == Router::$PAGE_PLUGINSADMIN) {
-        $pluginMeta = $pluginManager->getPluginsMeta();
-
-        // Split plugins into 2 arrays: ordered enabled plugins and disabled.
-        $enabledPlugins = array_filter($pluginMeta, function ($v) {
-            return $v['order'] !== false;
-        });
-        // Load parameters.
-        $enabledPlugins = load_plugin_parameter_values($enabledPlugins, $conf->get('plugins', array()));
-        uasort(
-            $enabledPlugins,
-            function ($a, $b) {
-                return $a['order'] - $b['order'];
-            }
-        );
-        $disabledPlugins = array_filter($pluginMeta, function ($v) {
-            return $v['order'] === false;
-        });
-
-        $PAGE->assign('enabledPlugins', $enabledPlugins);
-        $PAGE->assign('disabledPlugins', $disabledPlugins);
-        $PAGE->assign('pagetitle', t('Plugin administration') .' - '. $conf->get('general.title', 'Shaarli'));
-        $PAGE->renderPage('pluginsadmin');
-        exit;
-    }
-
-    // Plugin administration form action
-    if ($targetPage == Router::$PAGE_SAVE_PLUGINSADMIN) {
-        try {
-            if (isset($_POST['parameters_form'])) {
-                $pluginManager->executeHooks('save_plugin_parameters', $_POST);
-                unset($_POST['parameters_form']);
-                foreach ($_POST as $param => $value) {
-                    $conf->set('plugins.'. $param, escape($value));
-                }
-            } else {
-                $conf->set('general.enabled_plugins', save_plugin_config($_POST));
-            }
-            $conf->write($loginManager->isLoggedIn());
-            $history->updateSettings();
-        } catch (Exception $e) {
-            error_log(
-                'ERROR while saving plugin configuration:.' . PHP_EOL .
-                $e->getMessage()
-            );
-
-            // TODO: do not handle exceptions/errors in JS.
-            echo '<script>alert("'
-                . $e->getMessage()
-                .'");document.location=\'?do='
-                . Router::$PAGE_PLUGINSADMIN
-                .'\';</script>';
-            exit;
-        }
-        header('Location: ?do='. Router::$PAGE_PLUGINSADMIN);
-        exit;
-    }
-
-    // Get a fresh token
-    if ($targetPage == Router::$GET_TOKEN) {
-        header('Content-Type:text/plain');
-        echo $sessionManager->generateToken();
-        exit;
-    }
-
-    // -------- Thumbnails Update
-    if ($targetPage == Router::$PAGE_THUMBS_UPDATE) {
-        $ids = [];
-        foreach ($bookmarkService->search() as $bookmark) {
-            // A note or not HTTP(S)
-            if ($bookmark->isNote() || ! startsWith(strtolower($bookmark->getUrl()), 'http')) {
-                continue;
-            }
-            $ids[] = $bookmark->getId();
-        }
-        $PAGE->assign('ids', $ids);
-        $PAGE->assign('pagetitle', t('Thumbnails update') .' - '. $conf->get('general.title', 'Shaarli'));
-        $PAGE->renderPage('thumbnails');
-        exit;
-    }
-
-    // -------- Single Thumbnail Update
-    if ($targetPage == Router::$AJAX_THUMB_UPDATE) {
-        if (! isset($_POST['id']) || ! ctype_digit($_POST['id'])) {
-            http_response_code(400);
-            exit;
-        }
-        $id = (int) $_POST['id'];
-        if (! $bookmarkService->exists($id)) {
-            http_response_code(404);
-            exit;
-        }
-        $thumbnailer = new Thumbnailer($conf);
-        $bookmark = $bookmarkService->get($id);
-        $bookmark->setThumbnail($thumbnailer->get($bookmark->getUrl()));
-        $bookmarkService->set($bookmark);
-
-        $factory = new FormatterFactory($conf, $loginManager->isLoggedIn());
-        echo json_encode($factory->getFormatter('raw')->format($bookmark));
-        exit;
-    }
-
-    // -------- Otherwise, simply display search form and bookmarks:
-    showLinkList($PAGE, $bookmarkService, $conf, $pluginManager, $loginManager);
-    exit;
-}
-
-/**
- * Template for the list of bookmarks (<div id="linklist">)
- * This function fills all the necessary fields in the $PAGE for the template 'linklist.html'
- *
- * @param pageBuilder              $PAGE          pageBuilder instance.
- * @param BookmarkServiceInterface $linkDb        LinkDB instance.
- * @param ConfigManager            $conf          Configuration Manager instance.
- * @param PluginManager            $pluginManager Plugin Manager instance.
- * @param LoginManager             $loginManager  LoginManager instance
- */
-function buildLinkList($PAGE, $linkDb, $conf, $pluginManager, $loginManager)
-{
-    $factory = new FormatterFactory($conf, $loginManager->isLoggedIn());
-    $formatter = $factory->getFormatter();
-
-    // Used in templates
-    if (isset($_GET['searchtags'])) {
-        if (! empty($_GET['searchtags'])) {
-            $searchtags = escape(normalize_spaces($_GET['searchtags']));
-        } else {
-            $searchtags = false;
-        }
-    } else {
-        $searchtags = '';
-    }
-    $searchterm = !empty($_GET['searchterm']) ? escape(normalize_spaces($_GET['searchterm'])) : '';
-
-    // Smallhash filter
-    if (! empty($_SERVER['QUERY_STRING'])
-        && preg_match('/^[a-zA-Z0-9-_@]{6}($|&|#)/', $_SERVER['QUERY_STRING'])) {
-        try {
-            $linksToDisplay = $linkDb->findByHash($_SERVER['QUERY_STRING']);
-        } catch (BookmarkNotFoundException $e) {
-            $PAGE->render404($e->getMessage());
-            exit;
-        }
-    } else {
-        // Filter bookmarks according search parameters.
-        $visibility = ! empty($_SESSION['visibility']) ? $_SESSION['visibility'] : null;
-        $request = [
-            'searchtags' => $searchtags,
-            'searchterm' => $searchterm,
-        ];
-        $linksToDisplay = $linkDb->search($request, $visibility, false, !empty($_SESSION['untaggedonly']));
-    }
-
-    // ---- Handle paging.
-    $keys = array();
-    foreach ($linksToDisplay as $key => $value) {
-        $keys[] = $key;
-    }
-
-    // Select articles according to paging.
-    $pagecount = ceil(count($keys) / $_SESSION['LINKS_PER_PAGE']);
-    $pagecount = $pagecount == 0 ? 1 : $pagecount;
-    $page= empty($_GET['page']) ? 1 : intval($_GET['page']);
-    $page = $page < 1 ? 1 : $page;
-    $page = $page > $pagecount ? $pagecount : $page;
-    // Start index.
-    $i = ($page-1) * $_SESSION['LINKS_PER_PAGE'];
-    $end = $i + $_SESSION['LINKS_PER_PAGE'];
-
-    $thumbnailsEnabled = $conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE;
-    if ($thumbnailsEnabled) {
-        $thumbnailer = new Thumbnailer($conf);
-    }
-
-    $linkDisp = array();
-    while ($i<$end && $i<count($keys)) {
-        $link = $formatter->format($linksToDisplay[$keys[$i]]);
-
-        // Logged in, thumbnails enabled, not a note,
-        // and (never retrieved yet or no valid cache file)
-        if ($loginManager->isLoggedIn()
-            && $thumbnailsEnabled
-            && !$linksToDisplay[$keys[$i]]->isNote()
-            && $linksToDisplay[$keys[$i]]->getThumbnail() !== false
-            && ! is_file($linksToDisplay[$keys[$i]]->getThumbnail())
-        ) {
-            $linksToDisplay[$keys[$i]]->setThumbnail($thumbnailer->get($link['url']));
-            $linkDb->set($linksToDisplay[$keys[$i]], false);
-            $updateDB = true;
-            $link['thumbnail'] = $linksToDisplay[$keys[$i]]->getThumbnail();
-        }
-
-        // Check for both signs of a note: starting with ? and 7 chars long.
-//        if ($link['url'][0] === '?' && strlen($link['url']) === 7) {
-//            $link['url'] = index_url($_SERVER) . $link['url'];
-//        }
-
-        $linkDisp[$keys[$i]] = $link;
-        $i++;
-    }
-
-    // If we retrieved new thumbnails, we update the database.
-    if (!empty($updateDB)) {
-        $linkDb->save();
-    }
+$containerBuilder = new ContainerBuilder($conf, $sessionManager, $cookieManager, $loginManager);
+$container = $containerBuilder->build();
+$app = new App($container);
 
-    // Compute paging navigation
-    $searchtagsUrl = $searchtags === '' ? '' : '&searchtags=' . urlencode($searchtags);
-    $searchtermUrl = empty($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;
-    }
+// Main Shaarli routes
+$app->group('', function () {
+    $this->get('/install', '\Shaarli\Front\Controller\Visitor\InstallController:index')->setName('displayInstall');
+    $this->get('/install/session-test', '\Shaarli\Front\Controller\Visitor\InstallController:sessionTest');
+    $this->post('/install', '\Shaarli\Front\Controller\Visitor\InstallController:save')->setName('saveInstall');
+
+    /* -- PUBLIC --*/
+    $this->get('/', '\Shaarli\Front\Controller\Visitor\BookmarkListController:index');
+    $this->get('/shaare/{hash}', '\Shaarli\Front\Controller\Visitor\BookmarkListController:permalink');
+    $this->get('/login', '\Shaarli\Front\Controller\Visitor\LoginController:index')->setName('login');
+    $this->post('/login', '\Shaarli\Front\Controller\Visitor\LoginController:login')->setName('processLogin');
+    $this->get('/picture-wall', '\Shaarli\Front\Controller\Visitor\PictureWallController:index');
+    $this->get('/tags/cloud', '\Shaarli\Front\Controller\Visitor\TagCloudController:cloud');
+    $this->get('/tags/list', '\Shaarli\Front\Controller\Visitor\TagCloudController:list');
+    $this->get('/daily', '\Shaarli\Front\Controller\Visitor\DailyController:index');
+    $this->get('/daily-rss', '\Shaarli\Front\Controller\Visitor\DailyController:rss')->setName('rss');
+    $this->get('/feed/atom', '\Shaarli\Front\Controller\Visitor\FeedController:atom')->setName('atom');
+    $this->get('/feed/rss', '\Shaarli\Front\Controller\Visitor\FeedController:rss');
+    $this->get('/open-search', '\Shaarli\Front\Controller\Visitor\OpenSearchController:index');
+
+    $this->get('/add-tag/{newTag}', '\Shaarli\Front\Controller\Visitor\TagController:addTag');
+    $this->get('/remove-tag/{tag}', '\Shaarli\Front\Controller\Visitor\TagController:removeTag');
+    $this->get('/links-per-page', '\Shaarli\Front\Controller\Visitor\PublicSessionFilterController:linksPerPage');
+    $this->get('/untagged-only', '\Shaarli\Front\Controller\Admin\PublicSessionFilterController:untaggedOnly');
+})->add('\Shaarli\Front\ShaarliMiddleware');
 
-    // Fill all template fields.
-    $data = array(
-        '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' => ! empty($_SESSION['visibility']) ? $_SESSION['visibility'] : '',
-        'links' => $linkDisp,
+$app->group('/admin', function () {
+    $this->get('/logout', '\Shaarli\Front\Controller\Admin\LogoutController:index');
+    $this->get('/tools', '\Shaarli\Front\Controller\Admin\ToolsController:index');
+    $this->get('/password', '\Shaarli\Front\Controller\Admin\PasswordController:index');
+    $this->post('/password', '\Shaarli\Front\Controller\Admin\PasswordController:change');
+    $this->get('/configure', '\Shaarli\Front\Controller\Admin\ConfigureController:index');
+    $this->post('/configure', '\Shaarli\Front\Controller\Admin\ConfigureController:save');
+    $this->get('/tags', '\Shaarli\Front\Controller\Admin\ManageTagController:index');
+    $this->post('/tags', '\Shaarli\Front\Controller\Admin\ManageTagController:save');
+    $this->get('/add-shaare', '\Shaarli\Front\Controller\Admin\ManageShaareController:addShaare');
+    $this->get('/shaare', '\Shaarli\Front\Controller\Admin\ManageShaareController:displayCreateForm');
+    $this->get('/shaare/{id:[0-9]+}', '\Shaarli\Front\Controller\Admin\ManageShaareController:displayEditForm');
+    $this->post('/shaare', '\Shaarli\Front\Controller\Admin\ManageShaareController:save');
+    $this->get('/shaare/delete', '\Shaarli\Front\Controller\Admin\ManageShaareController:deleteBookmark');
+    $this->get('/shaare/visibility', '\Shaarli\Front\Controller\Admin\ManageShaareController:changeVisibility');
+    $this->get('/shaare/{id:[0-9]+}/pin', '\Shaarli\Front\Controller\Admin\ManageShaareController:pinBookmark');
+    $this->patch(
+        '/shaare/{id:[0-9]+}/update-thumbnail',
+        '\Shaarli\Front\Controller\Admin\ThumbnailsController:ajaxUpdate'
     );
+    $this->get('/export', '\Shaarli\Front\Controller\Admin\ExportController:index');
+    $this->post('/export', '\Shaarli\Front\Controller\Admin\ExportController:export');
+    $this->get('/import', '\Shaarli\Front\Controller\Admin\ImportController:index');
+    $this->post('/import', '\Shaarli\Front\Controller\Admin\ImportController:import');
+    $this->get('/plugins', '\Shaarli\Front\Controller\Admin\PluginsController:index');
+    $this->post('/plugins', '\Shaarli\Front\Controller\Admin\PluginsController:save');
+    $this->get('/token', '\Shaarli\Front\Controller\Admin\TokenController:getToken');
+    $this->get('/thumbnails', '\Shaarli\Front\Controller\Admin\ThumbnailsController:index');
 
-    // If there is only a single link, we change on-the-fly the title of the page.
-    if (count($linksToDisplay) == 1) {
-        $data['pagetitle'] = $linksToDisplay[$keys[0]]->getTitle() .' - '. $conf->get('general.title');
-    } elseif (! 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'] .= '- '. $conf->get('general.title');
-    }
-
-    $pluginManager->executeHooks('render_linklist', $data, array('loggedin' => $loginManager->isLoggedIn()));
-
-    foreach ($data as $key => $value) {
-        $PAGE->assign($key, $value);
-    }
-
-    return;
-}
-
-/**
- * Installation
- * This function should NEVER be called if the file data/config.php exists.
- *
- * @param ConfigManager  $conf           Configuration Manager instance.
- * @param SessionManager $sessionManager SessionManager instance
- * @param LoginManager   $loginManager   LoginManager instance
- */
-function install($conf, $sessionManager, $loginManager)
-{
-    // On free.fr host, make sure the /sessions directory exists, otherwise login will not work.
-    if (endsWith($_SERVER['HTTP_HOST'], '.free.fr') && !is_dir($_SERVER['DOCUMENT_ROOT'].'/sessions')) {
-        mkdir($_SERVER['DOCUMENT_ROOT'].'/sessions', 0705);
-    }
-
-
-    // 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 (isset($_GET['test_session'])
-        && ( !isset($_SESSION) || !isset($_SESSION['session_tested']) || $_SESSION['session_tested']!='Working')) {
-        // Step 2: Check if data in session is correct.
-        $msg = t(
-            '<pre>Sessions do not seem to work correctly on your server.<br>'.
-            'Make sure the variable "session.save_path" is set correctly in your PHP config, '.
-            'and that you have write access to it.<br>'.
-            'It currently points to %s.<br>'.
-            '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.<br>'
-        );
-        $msg = sprintf($msg, session_save_path());
-        echo $msg;
-        echo '<br><a href="?">'. t('Click to try again.') .'</a></pre>';
-        die;
-    }
-    if (!isset($_SESSION['session_tested'])) {
-        // Step 1 : Try to store data in session and reload page.
-        $_SESSION['session_tested'] = 'Working';  // Try to set a variable in session.
-        header('Location: '.index_url($_SERVER).'?test_session');  // Redirect to check stored data.
-    }
-    if (isset($_GET['test_session'])) {
-        // Step 3: Sessions are OK. Remove test parameter from URL.
-        header('Location: '.index_url($_SERVER));
-    }
-
-
-    if (!empty($_POST['setlogin']) && !empty($_POST['setpassword'])) {
-        $tz = 'UTC';
-        if (!empty($_POST['continent']) && !empty($_POST['city'])
-            && isTimeZoneValid($_POST['continent'], $_POST['city'])
-        ) {
-            $tz = $_POST['continent'].'/'.$_POST['city'];
-        }
-        $conf->set('general.timezone', $tz);
-        $login = $_POST['setlogin'];
-        $conf->set('credentials.login', $login);
-        $salt = sha1(uniqid('', true) .'_'. mt_rand());
-        $conf->set('credentials.salt', $salt);
-        $conf->set('credentials.hash', sha1($_POST['setpassword'] . $login . $salt));
-        if (!empty($_POST['title'])) {
-            $conf->set('general.title', escape($_POST['title']));
-        } else {
-            $conf->set('general.title', 'Shared bookmarks on '.escape(index_url($_SERVER)));
-        }
-        $conf->set('translation.language', escape($_POST['language']));
-        $conf->set('updates.check_updates', !empty($_POST['updateCheck']));
-        $conf->set('api.enabled', !empty($_POST['enableApi']));
-        $conf->set(
-            'api.secret',
-            generate_api_secret(
-                $conf->get('credentials.login'),
-                $conf->get('credentials.salt')
-            )
-        );
-        try {
-            // Everything is ok, let's create config file.
-            $conf->write($loginManager->isLoggedIn());
-        } catch (Exception $e) {
-            error_log(
-                'ERROR while writing config file after installation.' . PHP_EOL .
-                    $e->getMessage()
-            );
-
-            // TODO: do not handle exceptions/errors in JS.
-            echo '<script>alert("'. $e->getMessage() .'");document.location=\'?\';</script>';
-            exit;
-        }
-
-        $history = new History($conf->get('resource.history'));
-        $bookmarkService = new BookmarkFileService($conf, $history, true);
-        if ($bookmarkService->count() === 0) {
-            $bookmarkService->initialize();
-        }
+    $this->get('/visibility/{visibility}', '\Shaarli\Front\Controller\Admin\SessionFilterController:visibility');
+})->add('\Shaarli\Front\ShaarliAdminMiddleware');
 
-        echo '<script>alert('
-            .'"Shaarli is now configured. '
-            .'Please enter your login/password and start shaaring your bookmarks!"'
-            .');document.location=\'./login\';</script>';
-        exit;
-    }
-
-    $PAGE = new PageBuilder($conf, $_SESSION, null, $sessionManager->generateToken());
-    list($continents, $cities) = generateTimeZoneData(timezone_identifiers_list(), date_default_timezone_get());
-    $PAGE->assign('continents', $continents);
-    $PAGE->assign('cities', $cities);
-    $PAGE->assign('languages', Languages::getAvailableLanguages());
-    $PAGE->renderPage('install');
-    exit;
-}
-
-if (!isset($_SESSION['LINKS_PER_PAGE'])) {
-    $_SESSION['LINKS_PER_PAGE'] = $conf->get('general.links_per_page', 20);
-}
-
-try {
-    $history = new History($conf->get('resource.history'));
-} catch (Exception $e) {
-    die($e->getMessage());
-}
-
-$linkDb = new BookmarkFileService($conf, $history, $loginManager->isLoggedIn());
-
-if (isset($_SERVER['QUERY_STRING']) && startsWith($_SERVER['QUERY_STRING'], 'do=dailyrss')) {
-    showDailyRSS($linkDb, $conf, $loginManager);
-    exit;
-}
-
-$containerBuilder = new ContainerBuilder($conf, $sessionManager, $loginManager);
-$container = $containerBuilder->build();
-$app = new App($container);
 
 // REST API routes
 $app->group('/api/v1', function () {
@@ -1942,25 +148,6 @@ $app->group('/api/v1', function () {
     $this->get('/history', '\Shaarli\Api\Controllers\HistoryController:getHistory')->setName('getHistory');
 })->add('\Shaarli\Api\ApiMiddleware');
 
-$app->group('', function () {
-    $this->get('/login', '\Shaarli\Front\Controller\LoginController:index')->setName('login');
-})->add('\Shaarli\Front\ShaarliMiddleware');
-
 $response = $app->run(true);
 
-// Hack to make Slim and Shaarli router work together:
-// If a Slim route isn't found and NOT API call, we call renderPage().
-if ($response->getStatusCode() == 404 && strpos($_SERVER['REQUEST_URI'], '/api/v1') === false) {
-    // We use UTF-8 for proper international characters handling.
-    header('Content-Type: text/html; charset=utf-8');
-    renderPage($conf, $pluginManager, $linkDb, $history, $sessionManager, $loginManager);
-} else {
-    $response = $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');
-    $app->respond($response);
-}
+$app->respond($response);
diff --git a/init.php b/init.php
new file mode 100644 (file)
index 0000000..f0b8436
--- /dev/null
+++ b/init.php
@@ -0,0 +1,85 @@
+<?php
+
+require_once __DIR__ . '/vendor/autoload.php';
+
+use Shaarli\ApplicationUtils;
+use Shaarli\Security\SessionManager;
+
+// Set 'UTC' as the default timezone if it is not defined in php.ini
+// See http://php.net/manual/en/datetime.configuration.php#ini.date.timezone
+if (date_default_timezone_get() == '') {
+    date_default_timezone_set('UTC');
+}
+
+// High execution time in case of problematic imports/exports.
+ini_set('max_input_time', '60');
+
+// Try to set max upload file size and read
+ini_set('memory_limit', '128M');
+ini_set('post_max_size', '16M');
+ini_set('upload_max_filesize', '16M');
+
+// See all error except warnings
+error_reporting(E_ALL^E_WARNING);
+
+// 3rd-party libraries
+if (! file_exists(__DIR__ . '/vendor/autoload.php')) {
+    header('Content-Type: text/plain; charset=utf-8');
+    echo "Error: missing Composer configuration\n\n"
+        ."If you installed Shaarli through Git or using the development branch,\n"
+        ."please refer to the installation documentation to install PHP"
+        ." dependencies using Composer:\n"
+        ."- https://shaarli.readthedocs.io/en/master/Server-configuration/\n"
+        ."- https://shaarli.readthedocs.io/en/master/Download-and-Installation/";
+    exit;
+}
+
+// Ensure the PHP version is supported
+try {
+    ApplicationUtils::checkPHPVersion('7.1', PHP_VERSION);
+} catch (Exception $exc) {
+    header('Content-Type: text/plain; charset=utf-8');
+    echo $exc->getMessage();
+    exit;
+}
+
+// Force cookie path (but do not change lifetime)
+$cookie = session_get_cookie_params();
+$cookiedir = '';
+if (dirname($_SERVER['SCRIPT_NAME']) != '/') {
+    $cookiedir = dirname($_SERVER["SCRIPT_NAME"]).'/';
+}
+// Set default cookie expiration and path.
+session_set_cookie_params($cookie['lifetime'], $cookiedir, $_SERVER['SERVER_NAME']);
+// Set session parameters on server side.
+// Use cookies to store session.
+ini_set('session.use_cookies', 1);
+// Force cookies for session (phpsessionID forbidden in URL).
+ini_set('session.use_only_cookies', 1);
+// Prevent PHP form using sessionID in URL if cookies are disabled.
+ini_set('session.use_trans_sid', false);
+
+define('SHAARLI_VERSION', ApplicationUtils::getVersion(__DIR__ .'/'. ApplicationUtils::$VERSION_FILE));
+
+session_name('shaarli');
+// Start session if needed (Some server auto-start sessions).
+if (session_status() == PHP_SESSION_NONE) {
+    session_start();
+}
+
+// Regenerate session ID if invalid or not defined in cookie.
+if (isset($_COOKIE['shaarli']) && !SessionManager::checkId($_COOKIE['shaarli'])) {
+    session_regenerate_id(true);
+    $_COOKIE['shaarli'] = session_id();
+}
+
+// LC_MESSAGES isn't defined without php-intl, in this case use LC_COLLATE locale instead.
+if (! defined('LC_MESSAGES')) {
+    define('LC_MESSAGES', LC_COLLATE);
+}
+
+// Prevent caching on client side or proxy: (yes, it's ugly)
+header("Last-Modified: " . gmdate("D, d M Y H:i:s") . " GMT");
+header("Cache-Control: no-store, no-cache, must-revalidate");
+header("Cache-Control: post-check=0, pre-check=0", false);
+header("Pragma: no-cache");
index 8bf4ed46d6cd5afded8fd8d551361b0e93bcef76..ab6ed6de0cc34f2eca752ea293c90b1b45655a4b 100644 (file)
@@ -5,7 +5,7 @@
  * Adds the addlink input on the linklist page.
  */
 
-use Shaarli\Router;
+use Shaarli\Render\TemplatePage;
 
 /**
  * When linklist is displayed, add play videos to header's toolbar.
@@ -16,11 +16,11 @@ use Shaarli\Router;
  */
 function hook_addlink_toolbar_render_header($data)
 {
-    if ($data['_PAGE_'] == Router::$PAGE_LINKLIST && $data['_LOGGEDIN_'] === true) {
+    if ($data['_PAGE_'] == TemplatePage::LINKLIST && $data['_LOGGEDIN_'] === true) {
         $form = array(
             'attr' => array(
                 'method' => 'GET',
-                'action' => '',
+                'action' => $data['_BASE_PATH_'] . '/admin/shaare',
                 'name'   => 'addform',
                 'class'  => 'addform',
             ),
index ad501f4799b5770b6bd6f77c550c49317ac55bbe..e37d887e85c0ab129fe132c68bdba1725b517611 100644 (file)
@@ -1,5 +1,5 @@
 <span>
   <a href="https://web.archive.org/web/%s">
-    <img class="linklist-plugin-icon" src="plugins/archiveorg/internetarchive.png" title="%s" alt="archive.org" />
+    <img class="linklist-plugin-icon" src="%s/archiveorg/internetarchive.png" title="%s" alt="archive.org" />
   </a>
 </span>
index 0ee1c73c9333ef0fd9635db3ededbc64fb44521e..f26e61292cea3c50e44d32e7f7ff408829d9e881 100644 (file)
@@ -17,12 +17,13 @@ use Shaarli\Plugin\PluginManager;
 function hook_archiveorg_render_linklist($data)
 {
     $archive_html = file_get_contents(PluginManager::$PLUGINS_PATH . '/archiveorg/archiveorg.html');
+    $path = ($data['_BASE_PATH_'] ?? '') . '/' . PluginManager::$PLUGINS_PATH;
 
     foreach ($data['links'] as &$value) {
         if ($value['private'] && preg_match('/^\?[a-zA-Z0-9-_@]{6}($|&|#)/', $value['real_url'])) {
             continue;
         }
-        $archive = sprintf($archive_html, $value['url'], t('View on archive.org'));
+        $archive = sprintf($archive_html, $value['url'], $path, t('View on archive.org'));
         $value['link_plugin'][] = $archive;
     }
 
index 8ae1b47969e15dafa1b142c68e98aaf19d8813b4..defb01f7e4457d05f95f0f95e939297300033904 100644 (file)
@@ -16,7 +16,7 @@
 
 use Shaarli\Config\ConfigManager;
 use Shaarli\Plugin\PluginManager;
-use Shaarli\Router;
+use Shaarli\Render\TemplatePage;
 
 /**
  * In the footer hook, there is a working example of a translation extension for Shaarli.
@@ -74,7 +74,7 @@ function demo_plugin_init($conf)
 function hook_demo_plugin_render_header($data)
 {
     // Only execute when linklist is rendered.
-    if ($data['_PAGE_'] == Router::$PAGE_LINKLIST) {
+    if ($data['_PAGE_'] == TemplatePage::LINKLIST) {
         // If loggedin
         if ($data['_LOGGEDIN_'] === true) {
             /*
@@ -118,7 +118,7 @@ function hook_demo_plugin_render_header($data)
         $form = array(
             'attr' => array(
                 'method' => 'GET',
-                'action' => '?',
+                'action' => $data['_BASE_PATH_'] . '/',
                 'class' => 'addform',
             ),
             'inputs' => array(
@@ -441,9 +441,9 @@ function hook_demo_plugin_delete_link($data)
 function hook_demo_plugin_render_feed($data)
 {
     foreach ($data['links'] as &$link) {
-        if ($data['_PAGE_'] == Router::$PAGE_FEED_ATOM) {
+        if ($data['_PAGE_'] == TemplatePage::FEED_ATOM) {
             $link['description'] .= ' - ATOM Feed' ;
-        } elseif ($data['_PAGE_'] == Router::$PAGE_FEED_RSS) {
+        } elseif ($data['_PAGE_'] == TemplatePage::FEED_RSS) {
             $link['description'] .= ' - RSS Feed';
         }
     }
index dab75dd55c5c0b06789da06e688664ef97256b54..16edd9a61e44236b53832ce150a3e1b938c5a4fe 100644 (file)
@@ -6,7 +6,7 @@
 
 use Shaarli\Config\ConfigManager;
 use Shaarli\Plugin\PluginManager;
-use Shaarli\Router;
+use Shaarli\Render\TemplatePage;
 
 /**
  * Display an error everywhere if the plugin is enabled without configuration.
@@ -76,7 +76,7 @@ function hook_isso_render_linklist($data, $conf)
  */
 function hook_isso_render_includes($data)
 {
-    if ($data['_PAGE_'] == Router::$PAGE_LINKLIST) {
+    if ($data['_PAGE_'] == TemplatePage::LINKLIST) {
         $data['css_files'][] = PluginManager::$PLUGINS_PATH . '/isso/isso.css';
     }
 
diff --git a/plugins/isso/isso_button.html b/plugins/isso/isso_button.html
deleted file mode 100644 (file)
index 3f82848..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-<span>
-  <a href="?%s#isso-thread">
-    <img class="linklist-plugin-icon" src="plugins/archiveorg/internetarchive.png" title="%s" alt="archive.org" />
-  </a>
-</span>
index 0341ed593abd19cb0afb5db4a092e4bb34944dd6..91a9c1e554c58437c5862adfd605019865096be7 100644 (file)
@@ -7,7 +7,7 @@
  */
 
 use Shaarli\Plugin\PluginManager;
-use Shaarli\Router;
+use Shaarli\Render\TemplatePage;
 
 /**
  * When linklist is displayed, add play videos to header's toolbar.
@@ -18,7 +18,7 @@ use Shaarli\Router;
  */
 function hook_playvideos_render_header($data)
 {
-    if ($data['_PAGE_'] == Router::$PAGE_LINKLIST) {
+    if ($data['_PAGE_'] == TemplatePage::LINKLIST) {
         $playvideo = array(
             'attr' => array(
                 'href' => '#',
@@ -42,7 +42,7 @@ function hook_playvideos_render_header($data)
  */
 function hook_playvideos_render_footer($data)
 {
-    if ($data['_PAGE_'] == Router::$PAGE_LINKLIST) {
+    if ($data['_PAGE_'] == TemplatePage::LINKLIST) {
         $data['js_files'][] = PluginManager::$PLUGINS_PATH . '/playvideos/jquery-1.11.2.min.js';
         $data['js_files'][] = PluginManager::$PLUGINS_PATH . '/playvideos/youtube_playlist.js';
     }
index 2878c0505be296b486ab2b9ee4547a2851c7986b..8fe6799ce6d00933445a9b7b7f1652dc13387af8 100644 (file)
@@ -13,7 +13,7 @@ use pubsubhubbub\publisher\Publisher;
 use Shaarli\Config\ConfigManager;
 use Shaarli\Feed\FeedBuilder;
 use Shaarli\Plugin\PluginManager;
-use Shaarli\Router;
+use Shaarli\Render\TemplatePage;
 
 /**
  * Plugin init function - set the hub to the default appspot one.
@@ -41,7 +41,7 @@ function pubsubhubbub_init($conf)
  */
 function hook_pubsubhubbub_render_feed($data, $conf)
 {
-    $feedType = $data['_PAGE_'] == Router::$PAGE_FEED_RSS ? FeedBuilder::$FEED_RSS : FeedBuilder::$FEED_ATOM;
+    $feedType = $data['_PAGE_'] == TemplatePage::FEED_RSS ? FeedBuilder::$FEED_RSS : FeedBuilder::$FEED_ATOM;
     $template = file_get_contents(PluginManager::$PLUGINS_PATH . '/pubsubhubbub/hub.'. $feedType .'.xml');
     $data['feed_plugins_header'][] = sprintf($template, $conf->get('plugins.PUBSUBHUB_URL'));
 
@@ -60,8 +60,8 @@ function hook_pubsubhubbub_render_feed($data, $conf)
 function hook_pubsubhubbub_save_link($data, $conf)
 {
     $feeds = array(
-        index_url($_SERVER) .'?do=atom',
-        index_url($_SERVER) .'?do=rss',
+        index_url($_SERVER) .'feed/atom',
+        index_url($_SERVER) .'feed/rss',
     );
 
     $httpPost = function_exists('curl_version') ? false : 'nocurl_http_post';
index c1d237d5d664532a6912e5ee5ba34c80ce59ddea..3b5dae344dd63c3b688fc2d40ee8ff2af99e8f09 100644 (file)
@@ -6,7 +6,7 @@
  */
 
 use Shaarli\Plugin\PluginManager;
-use Shaarli\Router;
+use Shaarli\Render\TemplatePage;
 
 /**
  * Add qrcode icon to link_plugin when rendering linklist.
@@ -19,11 +19,12 @@ function hook_qrcode_render_linklist($data)
 {
     $qrcode_html = file_get_contents(PluginManager::$PLUGINS_PATH . '/qrcode/qrcode.html');
 
+    $path = ($data['_BASE_PATH_'] ?? '') . '/' . PluginManager::$PLUGINS_PATH;
     foreach ($data['links'] as &$value) {
         $qrcode = sprintf(
             $qrcode_html,
             $value['url'],
-            PluginManager::$PLUGINS_PATH
+            $path
         );
         $value['link_plugin'][] = $qrcode;
     }
@@ -40,8 +41,8 @@ function hook_qrcode_render_linklist($data)
  */
 function hook_qrcode_render_footer($data)
 {
-    if ($data['_PAGE_'] == Router::$PAGE_LINKLIST) {
-        $data['js_files'][] = PluginManager::$PLUGINS_PATH . '/qrcode/shaarli-qrcode.js';
+    if ($data['_PAGE_'] == TemplatePage::LINKLIST) {
+        $data['js_files'][] =  PluginManager::$PLUGINS_PATH . '/qrcode/shaarli-qrcode.js';
     }
 
     return $data;
@@ -56,7 +57,7 @@ function hook_qrcode_render_footer($data)
  */
 function hook_qrcode_render_includes($data)
 {
-    if ($data['_PAGE_'] == Router::$PAGE_LINKLIST) {
+    if ($data['_PAGE_'] == TemplatePage::LINKLIST) {
         $data['css_files'][] = PluginManager::$PLUGINS_PATH . '/qrcode/qrcode.css';
     }
 
index fe77c4cdc47cbb4ae36471513908e950adfe8d6d..3316d6f63d272b2e5b13215e61aae4a27d19e459 100644 (file)
 
 // Show the QR-Code of a permalink (when the QR-Code icon is clicked).
 function showQrCode(caller,loading)
-{ 
+{
     // Dynamic javascript lib loading: We only load qr.js if the QR code icon is clicked:
     if (typeof(qr) == 'undefined') // Load qr.js only if not present.
     {
         if (!loading)  // If javascript lib is still loading, do not append script to body.
         {
-            var element = document.createElement("script");
-            element.src = "plugins/qrcode/qr-1.1.3.min.js";
+          var basePath = document.querySelector('input[name="js_base_path"]').value;
+          var element = document.createElement("script");
+            element.src = basePath + "/plugins/qrcode/qr-1.1.3.min.js";
             document.body.appendChild(element);
         }
         setTimeout(function() { showQrCode(caller,true);}, 200); // Retry in 200 milliseconds.
@@ -44,7 +45,7 @@ function showQrCode(caller,loading)
 
     // Remove previous qrcode if present.
     removeQrcode();
-    
+
     // Build the div which contains the QR-Code:
     var element = document.createElement('div');
     element.id = 'permalinkQrcode';
@@ -57,11 +58,11 @@ function showQrCode(caller,loading)
         // Damn IE
         element.setAttribute('onclick', 'this.parentNode.removeChild(this);' );
     }
-    
+
     // Build the QR-Code:
     var image = qr.image({size: 8,value: caller.dataset.permalink});
     if (image)
-    { 
+    {
         element.appendChild(image);
         element.innerHTML += "<br>Click to close";
         caller.parentNode.appendChild(element);
@@ -87,4 +88,4 @@ function removeQrcode()
         elem.parentNode.removeChild(elem);
     }
     return false;
-}
\ No newline at end of file
+}
index ea21a51925f33de0986a28550a167559e1406043..c53a04d9c721abdfb98647ecdc611ff78e61068c 100644 (file)
@@ -21,7 +21,7 @@ The directory structure should look like:
 
 To enable the plugin, you can either:
 
-  * enable it in the plugins administration page (`?do=pluginadmin`). 
+  * enable it in the plugins administration page (`/admin/plugins`).
   * add `wallabag` to your list of enabled plugins in `data/config.json.php` (`general.enabled_plugins` section).
 
 ### Configuration
index bc35df08dcde122f0ca11e8fad30dc17e6f16baf..805c1ad986aa9bc4c37b2531ca521f891e6cffd6 100644 (file)
@@ -45,12 +45,14 @@ function hook_wallabag_render_linklist($data, $conf)
     $wallabagHtml = file_get_contents(PluginManager::$PLUGINS_PATH . '/wallabag/wallabag.html');
 
     $linkTitle = t('Save to wallabag');
+    $path = ($data['_BASE_PATH_'] ?? '') . '/' . PluginManager::$PLUGINS_PATH;
+
     foreach ($data['links'] as &$value) {
         $wallabag = sprintf(
             $wallabagHtml,
             $wallabagInstance->getWallabagUrl(),
             urlencode($value['url']),
-            PluginManager::$PLUGINS_PATH,
+            $path,
             $linkTitle
         );
         $value['link_plugin'][] = $wallabag;
index 195d959c2fc4c3ab3e30d4a07b9b1ce57105a1f1..a5d5dbe988ea22ad525ed74a71b5c3d421c2d7ec 100644 (file)
@@ -25,7 +25,7 @@ class PluginManagerTest extends \PHPUnit\Framework\TestCase
      */
     protected $pluginManager;
 
-    public function setUp()
+    public function setUp(): void
     {
         $conf = new ConfigManager('');
         $this->pluginManager = new PluginManager($conf);
@@ -33,10 +33,8 @@ class PluginManagerTest extends \PHPUnit\Framework\TestCase
 
     /**
      * Test plugin loading and hook execution.
-     *
-     * @return void
      */
-    public function testPlugin()
+    public function testPlugin(): void
     {
         PluginManager::$PLUGINS_PATH = self::$pluginPath;
         $this->pluginManager->load(array(self::$pluginName));
@@ -56,10 +54,29 @@ class PluginManagerTest extends \PHPUnit\Framework\TestCase
         $this->assertEquals('loggedin', $data[1]);
     }
 
+    /**
+     * Test plugin loading and hook execution with an error: raise an incompatibility error.
+     */
+    public function testPluginWithPhpError(): void
+    {
+        PluginManager::$PLUGINS_PATH = self::$pluginPath;
+        $this->pluginManager->load(array(self::$pluginName));
+
+        $this->assertTrue(function_exists('hook_test_error'));
+
+        $data = [];
+        $this->pluginManager->executeHooks('error', $data);
+
+        $this->assertSame(
+            'test [plugin incompatibility]: Class \'Unknown\' not found',
+            $this->pluginManager->getErrors()[0]
+        );
+    }
+
     /**
      * Test missing plugin loading.
      */
-    public function testPluginNotFound()
+    public function testPluginNotFound(): void
     {
         $this->pluginManager->load(array());
         $this->pluginManager->load(array('nope', 'renope'));
@@ -69,7 +86,7 @@ class PluginManagerTest extends \PHPUnit\Framework\TestCase
     /**
      * Test plugin metadata loading.
      */
-    public function testGetPluginsMeta()
+    public function testGetPluginsMeta(): void
     {
         PluginManager::$PLUGINS_PATH = self::$pluginPath;
         $this->pluginManager->load(array(self::$pluginName));
index c26411ac57d11963f9b091609b5492067b37b03b..8bb81dc8f8f1a7c313da25d9c8ce54c1c2dd9d25 100644 (file)
@@ -102,7 +102,7 @@ class GetLinkIdTest extends \PHPUnit\Framework\TestCase
         $this->assertEquals($id, $data['id']);
 
         // Check link elements
-        $this->assertEquals('http://domain.tld/?WDWyig', $data['url']);
+        $this->assertEquals('http://domain.tld/shaare/WDWyig', $data['url']);
         $this->assertEquals('WDWyig', $data['shorturl']);
         $this->assertEquals('Link title: @website', $data['title']);
         $this->assertEquals(
index 4e2d55ac2f4fe89ff90ed5e560ec9f5d70787ffb..d02e6fad8284511a3a659402c9d0e79a30b2cdda 100644 (file)
@@ -109,7 +109,7 @@ class GetLinksTest extends \PHPUnit\Framework\TestCase
 
         // Check first element fields
         $first = $data[2];
-        $this->assertEquals('http://domain.tld/?WDWyig', $first['url']);
+        $this->assertEquals('http://domain.tld/shaare/WDWyig', $first['url']);
         $this->assertEquals('WDWyig', $first['shorturl']);
         $this->assertEquals('Link title: @website', $first['title']);
         $this->assertEquals(
index b2dd09eb0e780d3e8d80cdde82ae848e8d50e8b2..4e791a04191472ae33c67c175c5f51a977c0d4dd 100644 (file)
@@ -131,8 +131,8 @@ class PostLinkTest extends TestCase
         $this->assertEquals(self::NB_FIELDS_LINK, count($data));
         $this->assertEquals(43, $data['id']);
         $this->assertRegExp('/[\w_-]{6}/', $data['shorturl']);
-        $this->assertEquals('http://domain.tld/?' . $data['shorturl'], $data['url']);
-        $this->assertEquals('?' . $data['shorturl'], $data['title']);
+        $this->assertEquals('http://domain.tld/shaare/' . $data['shorturl'], $data['url']);
+        $this->assertEquals('/shaare/' . $data['shorturl'], $data['title']);
         $this->assertEquals('', $data['description']);
         $this->assertEquals([], $data['tags']);
         $this->assertEquals(true, $data['private']);
index cb63742e626bf1af07aea2542fd0443c811daef2..302cac0fb78a7f42205d2a1f8188b8a56644dee9 100644 (file)
@@ -114,8 +114,8 @@ class PutLinkTest extends \PHPUnit\Framework\TestCase
         $this->assertEquals(self::NB_FIELDS_LINK, count($data));
         $this->assertEquals($id, $data['id']);
         $this->assertEquals('WDWyig', $data['shorturl']);
-        $this->assertEquals('http://domain.tld/?WDWyig', $data['url']);
-        $this->assertEquals('?WDWyig', $data['title']);
+        $this->assertEquals('http://domain.tld/shaare/WDWyig', $data['url']);
+        $this->assertEquals('/shaare/WDWyig', $data['title']);
         $this->assertEquals('', $data['description']);
         $this->assertEquals([], $data['tags']);
         $this->assertEquals(true, $data['private']);
index 4900d41d80598b320e71784ff555739fb6444bdf..7b1906d3fd76a324a30667cab912f56cac6f4baa 100644 (file)
@@ -200,7 +200,7 @@ class BookmarkFileServiceTest extends TestCase
 
         $bookmark = $this->privateLinkDB->get(43);
         $this->assertEquals(43, $bookmark->getId());
-        $this->assertRegExp('/\?[\w\-]{6}/', $bookmark->getUrl());
+        $this->assertRegExp('#/shaare/[\w\-]{6}#', $bookmark->getUrl());
         $this->assertRegExp('/[\w\-]{6}/', $bookmark->getShortUrl());
         $this->assertEquals($bookmark->getUrl(), $bookmark->getTitle());
         $this->assertEmpty($bookmark->getDescription());
@@ -216,7 +216,7 @@ class BookmarkFileServiceTest extends TestCase
 
         $bookmark = $this->privateLinkDB->get(43);
         $this->assertEquals(43, $bookmark->getId());
-        $this->assertRegExp('/\?[\w\-]{6}/', $bookmark->getUrl());
+        $this->assertRegExp('#/shaare/[\w\-]{6}#', $bookmark->getUrl());
         $this->assertRegExp('/[\w\-]{6}/', $bookmark->getShortUrl());
         $this->assertEquals($bookmark->getUrl(), $bookmark->getTitle());
         $this->assertEmpty($bookmark->getDescription());
@@ -340,7 +340,7 @@ class BookmarkFileServiceTest extends TestCase
 
         $bookmark = $this->privateLinkDB->get(42);
         $this->assertEquals(42, $bookmark->getId());
-        $this->assertEquals('?WDWyig', $bookmark->getUrl());
+        $this->assertEquals('/shaare/WDWyig', $bookmark->getUrl());
         $this->assertEquals('1eYJ1Q', $bookmark->getShortUrl());
         $this->assertEquals('Note: I have a big ID but an old date', $bookmark->getTitle());
         $this->assertEquals('Used to test bookmarks reordering.', $bookmark->getDescription());
@@ -359,7 +359,7 @@ class BookmarkFileServiceTest extends TestCase
 
         $bookmark = $this->privateLinkDB->get(42);
         $this->assertEquals(42, $bookmark->getId());
-        $this->assertEquals('?WDWyig', $bookmark->getUrl());
+        $this->assertEquals('/shaare/WDWyig', $bookmark->getUrl());
         $this->assertEquals('1eYJ1Q', $bookmark->getShortUrl());
         $this->assertEquals('Note: I have a big ID but an old date', $bookmark->getTitle());
         $this->assertEquals('Used to test bookmarks reordering.', $bookmark->getDescription());
@@ -816,7 +816,6 @@ class BookmarkFileServiceTest extends TestCase
         );
         $this->assertEquals(
             [
-                'web' => 4,
                 'cartoon' => 2,
                 'gnu' => 1,
                 'dev' => 1,
@@ -833,7 +832,6 @@ class BookmarkFileServiceTest extends TestCase
         );
         $this->assertEquals(
             [
-                'web' => 1,
                 'html' => 1,
                 'w3c' => 1,
                 'css' => 1,
@@ -894,35 +892,35 @@ class BookmarkFileServiceTest extends TestCase
     public function testFilterHashValid()
     {
         $request = smallHash('20150310_114651');
-        $this->assertEquals(
-            1,
-            count($this->publicLinkDB->findByHash($request))
+        $this->assertSame(
+            $request,
+            $this->publicLinkDB->findByHash($request)->getShortUrl()
         );
         $request = smallHash('20150310_114633' . 8);
-        $this->assertEquals(
-            1,
-            count($this->publicLinkDB->findByHash($request))
+        $this->assertSame(
+            $request,
+            $this->publicLinkDB->findByHash($request)->getShortUrl()
         );
     }
 
     /**
      * Test filterHash() with an invalid smallhash.
-     *
-     * @expectedException \Shaarli\Bookmark\Exception\BookmarkNotFoundException
      */
     public function testFilterHashInValid1()
     {
+        $this->expectException(BookmarkNotFoundException::class);
+
         $request = 'blabla';
         $this->publicLinkDB->findByHash($request);
     }
 
     /**
      * Test filterHash() with an empty smallhash.
-     *
-     * @expectedException \Shaarli\Bookmark\Exception\BookmarkNotFoundException
      */
     public function testFilterHashInValid()
     {
+        $this->expectException(BookmarkNotFoundException::class);
+
         $this->publicLinkDB->findByHash('');
     }
 
@@ -968,7 +966,6 @@ class BookmarkFileServiceTest extends TestCase
     public function testCountLinkPerTagAllWithFilter()
     {
         $expected = [
-            'gnu' => 2,
             'hashtag' => 2,
             '-exclude' => 1,
             '.hidden' => 1,
@@ -991,7 +988,6 @@ class BookmarkFileServiceTest extends TestCase
     public function testCountLinkPerTagPublicWithFilter()
     {
         $expected = [
-            'gnu' => 2,
             'hashtag' => 2,
             '-exclude' => 1,
             '.hidden' => 1,
@@ -1015,7 +1011,6 @@ class BookmarkFileServiceTest extends TestCase
     {
         $expected = [
             'cartoon' => 1,
-            'dev' => 1,
             'tag1' => 1,
             'tag2' => 1,
             'tag3' => 1,
index d23eb0695b1fdde093b4fcb4b6274a8bd44cb44a..3906cc7f4e3f542531eaa0b53b3b174c07cfc18a 100644 (file)
@@ -3,7 +3,6 @@
 namespace Shaarli\Bookmark;
 
 use PHPUnit\Framework\TestCase;
-use ReferenceLinkDB;
 use Shaarli\Config\ConfigManager;
 use Shaarli\History;
 
@@ -54,9 +53,9 @@ class BookmarkInitializerTest extends TestCase
     }
 
     /**
-     * Test initialize() with an empty data store.
+     * Test initialize() with a data store containing bookmarks.
      */
-    public function testInitializeEmptyDataStore()
+    public function testInitializeNotEmptyDataStore(): void
     {
         $refDB = new \ReferenceLinkDB();
         $refDB->write(self::$testDatastore);
@@ -79,6 +78,8 @@ class BookmarkInitializerTest extends TestCase
         );
         $this->assertFalse($bookmark->isPrivate());
 
+        $this->bookmarkService->save();
+
         // Reload from file
         $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, true);
         $this->assertEquals($refDB->countLinks() + 2, $this->bookmarkService->count());
@@ -97,10 +98,13 @@ class BookmarkInitializerTest extends TestCase
     }
 
     /**
-     * Test initialize() with a data store containing bookmarks.
+     * Test initialize() with an a non existent datastore file .
      */
-    public function testInitializeNotEmptyDataStore()
+    public function testInitializeNonExistentDataStore(): void
     {
+        $this->conf->set('resource.datastore', static::$testDatastore . '_empty');
+        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, true);
+
         $this->initializer->initialize();
 
         $this->assertEquals(2, $this->bookmarkService->count());
index 9a3bbbfc5e920d4bec6be3e9ddd8670bef5758d7..4b6a3c07c6207e6b41f6ec65b75fb4c79f333de6 100644 (file)
@@ -124,8 +124,8 @@ class BookmarkTest extends TestCase
         $this->assertEquals(1, $bookmark->getId());
         $this->assertEquals('abc', $bookmark->getShortUrl());
         $this->assertEquals($date, $bookmark->getCreated());
-        $this->assertEquals('?abc', $bookmark->getUrl());
-        $this->assertEquals('?abc', $bookmark->getTitle());
+        $this->assertEquals('/shaare/abc', $bookmark->getUrl());
+        $this->assertEquals('/shaare/abc', $bookmark->getTitle());
         $this->assertEquals('', $bookmark->getDescription());
         $this->assertEquals([], $bookmark->getTags());
         $this->assertEquals('', $bookmark->getTagsString());
index 591976f2c30b1a293ec7f845972147a02c3420c4..7d4a7b89a20131d9827112ffbd85fb905c1dffd3 100644 (file)
@@ -3,8 +3,6 @@
 namespace Shaarli\Bookmark;
 
 use PHPUnit\Framework\TestCase;
-use ReferenceLinkDB;
-use Shaarli\Config\ConfigManager;
 
 require_once 'tests/utils/CurlUtils.php';
 
@@ -491,7 +489,7 @@ class LinkUtilsTest extends TestCase
      */
     private function getHashtagLink($hashtag, $index = '')
     {
-        $hashtagLink = '<a href="' . $index . '?addtag=$1" title="Hashtag $1">#$1</a>';
+        $hashtagLink = '<a href="' . $index . './add-tag/$1" title="Hashtag $1">#$1</a>';
         return str_replace('$1', $hashtag, $hashtagLink);
     }
 }
index 0afbcba61bb04da17b45b9ceb702a086ed816188..d4ddedd50a6f06d26d05a12121be7c29c6392a64 100644 (file)
@@ -18,7 +18,14 @@ require_once 'application/bookmark/LinkUtils.php';
 require_once 'application/Utils.php';
 require_once 'application/http/UrlUtils.php';
 require_once 'application/http/HttpUtils.php';
-require_once 'application/feed/Cache.php';
-require_once 'tests/utils/ReferenceLinkDB.php';
-require_once 'tests/utils/ReferenceHistory.php';
+require_once 'tests/container/ShaarliTestContainer.php';
+require_once 'tests/front/controller/visitor/FrontControllerMockHelper.php';
+require_once 'tests/front/controller/admin/FrontAdminControllerMockHelper.php';
+require_once 'tests/updater/DummyUpdater.php';
 require_once 'tests/utils/FakeBookmarkService.php';
+require_once 'tests/utils/FakeConfigManager.php';
+require_once 'tests/utils/ReferenceHistory.php';
+require_once 'tests/utils/ReferenceLinkDB.php';
+require_once 'tests/utils/ReferenceSessionIdHashes.php';
+
+\ReferenceSessionIdHashes::genAllHashes();
index d7a70e6886b54a889eed845560350eae05cefabe..b2cc00455284b8fe339caae0bd02b31f8f1ec696 100644 (file)
@@ -2,6 +2,7 @@
 namespace Shaarli\Config;
 
 use Shaarli\Config\Exception\PluginConfigOrderException;
+use Shaarli\Plugin\PluginManager;
 
 require_once 'application/config/ConfigPlugin.php';
 
@@ -17,23 +18,30 @@ class ConfigPluginTest extends \PHPUnit\Framework\TestCase
      */
     public function testSavePluginConfigValid()
     {
-        $data = array(
+        $data = [
             'order_plugin1' => 2,   // no plugin related
             'plugin2' => 0,         // new - at the end
             'plugin3' => 0,         // 2nd
             'order_plugin3' => 8,
             'plugin4' => 0,         // 1st
             'order_plugin4' => 5,
-        );
+        ];
 
-        $expected = array(
+        $expected = [
             'plugin3',
             'plugin4',
             'plugin2',
-        );
+        ];
+
+        mkdir($path = __DIR__ . '/folder');
+        PluginManager::$PLUGINS_PATH = $path;
+        array_map(function (string $plugin) use ($path) { touch($path . '/' . $plugin); }, $expected);
 
         $out = save_plugin_config($data);
         $this->assertEquals($expected, $out);
+
+        array_map(function (string $plugin) use ($path) { unlink($path . '/' . $plugin); }, $expected);
+        rmdir($path);
     }
 
     /**
index 9b97ed6d6f4b71c5b342189f406a7d57c075c637..c08010ae915a778e161a424d0ead26a097b20d5e 100644 (file)
@@ -7,10 +7,21 @@ namespace Shaarli\Container;
 use PHPUnit\Framework\TestCase;
 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;
+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;
+use Shaarli\Updater\Updater;
+use Slim\Http\Environment;
 
 class ContainerBuilderTest extends TestCase
 {
@@ -26,24 +37,50 @@ class ContainerBuilderTest extends TestCase
     /** @var ContainerBuilder */
     protected $containerBuilder;
 
+    /** @var CookieManager */
+    protected $cookieManager;
+
     public function setUp(): void
     {
         $this->conf = new ConfigManager('tests/utils/config/configJson');
         $this->sessionManager = $this->createMock(SessionManager::class);
+        $this->cookieManager = $this->createMock(CookieManager::class);
+
         $this->loginManager = $this->createMock(LoginManager::class);
+        $this->loginManager->method('isLoggedIn')->willReturn(true);
 
-        $this->containerBuilder = new ContainerBuilder($this->conf, $this->sessionManager, $this->loginManager);
+        $this->containerBuilder = new ContainerBuilder(
+            $this->conf,
+            $this->sessionManager,
+            $this->cookieManager,
+            $this->loginManager
+        );
     }
 
     public function testBuildContainer(): void
     {
         $container = $this->containerBuilder->build();
 
+        static::assertInstanceOf(BookmarkServiceInterface::class, $container->bookmarkService);
+        static::assertInstanceOf(CookieManager::class, $container->cookieManager);
         static::assertInstanceOf(ConfigManager::class, $container->conf);
-        static::assertInstanceOf(SessionManager::class, $container->sessionManager);
-        static::assertInstanceOf(LoginManager::class, $container->loginManager);
+        static::assertInstanceOf(ErrorController::class, $container->errorHandler);
+        static::assertInstanceOf(Environment::class, $container->environment);
+        static::assertInstanceOf(FeedBuilder::class, $container->feedBuilder);
+        static::assertInstanceOf(FormatterFactory::class, $container->formatterFactory);
         static::assertInstanceOf(History::class, $container->history);
-        static::assertInstanceOf(BookmarkServiceInterface::class, $container->bookmarkService);
+        static::assertInstanceOf(HttpAccess::class, $container->httpAccess);
+        static::assertInstanceOf(LoginManager::class, $container->loginManager);
+        static::assertInstanceOf(NetscapeBookmarkUtils::class, $container->netscapeBookmarkUtils);
         static::assertInstanceOf(PageBuilder::class, $container->pageBuilder);
+        static::assertInstanceOf(PageCacheManager::class, $container->pageCacheManager);
+        static::assertInstanceOf(ErrorController::class, $container->phpErrorHandler);
+        static::assertInstanceOf(PluginManager::class, $container->pluginManager);
+        static::assertInstanceOf(SessionManager::class, $container->sessionManager);
+        static::assertInstanceOf(Thumbnailer::class, $container->thumbnailer);
+        static::assertInstanceOf(Updater::class, $container->updater);
+
+        // Set by the middleware
+        static::assertNull($container->basePath);
     }
 }
diff --git a/tests/container/ShaarliTestContainer.php b/tests/container/ShaarliTestContainer.php
new file mode 100644 (file)
index 0000000..7dbe914
--- /dev/null
@@ -0,0 +1,42 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Container;
+
+use PHPUnit\Framework\MockObject\MockObject;
+use Shaarli\Bookmark\BookmarkServiceInterface;
+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;
+
+/**
+ * Test helper allowing auto-completion for MockObjects.
+ *
+ * @property mixed[]                             $environment     $_SERVER automatically injected by Slim
+ * @property MockObject|ConfigManager            $conf
+ * @property MockObject|SessionManager           $sessionManager
+ * @property MockObject|LoginManager             $loginManager
+ * @property MockObject|string                   $webPath
+ * @property MockObject|History                  $history
+ * @property MockObject|BookmarkServiceInterface $bookmarkService
+ * @property MockObject|PageBuilder              $pageBuilder
+ * @property MockObject|PluginManager            $pluginManager
+ * @property MockObject|FormatterFactory         $formatterFactory
+ * @property MockObject|PageCacheManager         $pageCacheManager
+ * @property MockObject|FeedBuilder              $feedBuilder
+ * @property MockObject|Thumbnailer              $thumbnailer
+ * @property MockObject|HttpAccess               $httpAccess
+ */
+class ShaarliTestContainer extends ShaarliContainer
+{
+
+}
index 363028a2dae3ba4779a197ec58a031f76a45382d..2e7164321d85e7ca0e487fa69a8a6c1f64faeb82 100644 (file)
@@ -11,7 +11,7 @@ class CachedPageTest extends \PHPUnit\Framework\TestCase
 {
     // test cache directory
     protected static $testCacheDir = 'sandbox/pagecache';
-    protected static $url = 'http://shaar.li/?do=atom';
+    protected static $url = 'http://shaar.li/feed/atom';
     protected static $filename;
 
     /**
@@ -42,8 +42,8 @@ class CachedPageTest extends \PHPUnit\Framework\TestCase
     {
         new CachedPage(self::$testCacheDir, '', true);
         new CachedPage(self::$testCacheDir, '', false);
-        new CachedPage(self::$testCacheDir, 'http://shaar.li/?do=rss', true);
-        new CachedPage(self::$testCacheDir, 'http://shaar.li/?do=atom', false);
+        new CachedPage(self::$testCacheDir, 'http://shaar.li/feed/rss', true);
+        new CachedPage(self::$testCacheDir, 'http://shaar.li/feed/atom', false);
         $this->addToAssertionCount(1);
     }
 
index 5467189192bed2ac6442df947758394fef57244e..5c2aaedb30f131f9689c14afd03300b459a7d0e2 100644 (file)
@@ -64,23 +64,6 @@ class FeedBuilderTest extends \PHPUnit\Framework\TestCase
         );
     }
 
-    /**
-     * Test GetTypeLanguage().
-     */
-    public function testGetTypeLanguage()
-    {
-        $feedBuilder = new FeedBuilder(null, self::$formatter, FeedBuilder::$FEED_ATOM, null, null, false);
-        $feedBuilder->setLocale(self::$LOCALE);
-        $this->assertEquals(self::$ATOM_LANGUAGUE, $feedBuilder->getTypeLanguage());
-        $feedBuilder = new FeedBuilder(null, self::$formatter, FeedBuilder::$FEED_RSS, null, null, false);
-        $feedBuilder->setLocale(self::$LOCALE);
-        $this->assertEquals(self::$RSS_LANGUAGE, $feedBuilder->getTypeLanguage());
-        $feedBuilder = new FeedBuilder(null, self::$formatter, FeedBuilder::$FEED_ATOM, null, null, false);
-        $this->assertEquals('en', $feedBuilder->getTypeLanguage());
-        $feedBuilder = new FeedBuilder(null, self::$formatter, FeedBuilder::$FEED_RSS, null, null, false);
-        $this->assertEquals('en-en', $feedBuilder->getTypeLanguage());
-    }
-
     /**
      * Test buildData with RSS feed.
      */
@@ -89,13 +72,11 @@ class FeedBuilderTest extends \PHPUnit\Framework\TestCase
         $feedBuilder = new FeedBuilder(
             self::$bookmarkService,
             self::$formatter,
-            FeedBuilder::$FEED_RSS,
-            self::$serverInfo,
-            null,
+            static::$serverInfo,
             false
         );
         $feedBuilder->setLocale(self::$LOCALE);
-        $data = $feedBuilder->buildData();
+        $data = $feedBuilder->buildData(FeedBuilder::$FEED_RSS, null);
         // Test headers (RSS)
         $this->assertEquals(self::$RSS_LANGUAGE, $data['language']);
         $this->assertRegExp('/Wed, 03 Aug 2016 09:30:33 \+\d{4}/', $data['last_update']);
@@ -109,15 +90,15 @@ class FeedBuilderTest extends \PHPUnit\Framework\TestCase
         $link = $data['links'][array_keys($data['links'])[2]];
         $this->assertEquals(41, $link['id']);
         $this->assertEquals(DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, '20150310_114651'), $link['created']);
-        $this->assertEquals('http://host.tld/?WDWyig', $link['guid']);
-        $this->assertEquals('http://host.tld/?WDWyig', $link['url']);
+        $this->assertEquals('http://host.tld/shaare/WDWyig', $link['guid']);
+        $this->assertEquals('http://host.tld/shaare/WDWyig', $link['url']);
         $this->assertRegExp('/Tue, 10 Mar 2015 11:46:51 \+\d{4}/', $link['pub_iso_date']);
         $pub = DateTime::createFromFormat(DateTime::RSS, $link['pub_iso_date']);
         $up = DateTime::createFromFormat(DateTime::ATOM, $link['up_iso_date']);
         $this->assertEquals($pub, $up);
         $this->assertContains('Stallman has a beard', $link['description']);
         $this->assertContains('Permalink', $link['description']);
-        $this->assertContains('http://host.tld/?WDWyig', $link['description']);
+        $this->assertContains('http://host.tld/shaare/WDWyig', $link['description']);
         $this->assertEquals(1, count($link['taglist']));
         $this->assertEquals('sTuff', $link['taglist'][0]);
 
@@ -140,13 +121,11 @@ class FeedBuilderTest extends \PHPUnit\Framework\TestCase
         $feedBuilder = new FeedBuilder(
             self::$bookmarkService,
             self::$formatter,
-            FeedBuilder::$FEED_ATOM,
-            self::$serverInfo,
-            null,
+            static::$serverInfo,
             false
         );
         $feedBuilder->setLocale(self::$LOCALE);
-        $data = $feedBuilder->buildData();
+        $data = $feedBuilder->buildData(FeedBuilder::$FEED_ATOM, null);
         $this->assertEquals(ReferenceLinkDB::$NB_LINKS_TOTAL, count($data['links']));
         $this->assertRegExp('/2016-08-03T09:30:33\+\d{2}:\d{2}/', $data['last_update']);
         $link = $data['links'][array_keys($data['links'])[2]];
@@ -166,13 +145,11 @@ class FeedBuilderTest extends \PHPUnit\Framework\TestCase
         $feedBuilder = new FeedBuilder(
             self::$bookmarkService,
             self::$formatter,
-            FeedBuilder::$FEED_ATOM,
-            self::$serverInfo,
-            $criteria,
+            static::$serverInfo,
             false
         );
         $feedBuilder->setLocale(self::$LOCALE);
-        $data = $feedBuilder->buildData();
+        $data = $feedBuilder->buildData(FeedBuilder::$FEED_ATOM, $criteria);
         $this->assertEquals(1, count($data['links']));
         $link = array_shift($data['links']);
         $this->assertEquals(41, $link['id']);
@@ -190,13 +167,11 @@ class FeedBuilderTest extends \PHPUnit\Framework\TestCase
         $feedBuilder = new FeedBuilder(
             self::$bookmarkService,
             self::$formatter,
-            FeedBuilder::$FEED_ATOM,
-            self::$serverInfo,
-            $criteria,
+            static::$serverInfo,
             false
         );
         $feedBuilder->setLocale(self::$LOCALE);
-        $data = $feedBuilder->buildData();
+        $data = $feedBuilder->buildData(FeedBuilder::$FEED_ATOM, $criteria);
         $this->assertEquals(3, count($data['links']));
         $link = $data['links'][array_keys($data['links'])[2]];
         $this->assertEquals(41, $link['id']);
@@ -211,29 +186,27 @@ class FeedBuilderTest extends \PHPUnit\Framework\TestCase
         $feedBuilder = new FeedBuilder(
             self::$bookmarkService,
             self::$formatter,
-            FeedBuilder::$FEED_ATOM,
-            self::$serverInfo,
-            null,
+            static::$serverInfo,
             false
         );
         $feedBuilder->setLocale(self::$LOCALE);
         $feedBuilder->setUsePermalinks(true);
-        $data = $feedBuilder->buildData();
+        $data = $feedBuilder->buildData(FeedBuilder::$FEED_ATOM, null);
         $this->assertEquals(ReferenceLinkDB::$NB_LINKS_TOTAL, count($data['links']));
         $this->assertTrue($data['usepermalinks']);
         // First link is a permalink
         $link = $data['links'][array_keys($data['links'])[2]];
         $this->assertEquals(41, $link['id']);
         $this->assertEquals(DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, '20150310_114651'), $link['created']);
-        $this->assertEquals('http://host.tld/?WDWyig', $link['guid']);
-        $this->assertEquals('http://host.tld/?WDWyig', $link['url']);
+        $this->assertEquals('http://host.tld/shaare/WDWyig', $link['guid']);
+        $this->assertEquals('http://host.tld/shaare/WDWyig', $link['url']);
         $this->assertContains('Direct link', $link['description']);
-        $this->assertContains('http://host.tld/?WDWyig', $link['description']);
+        $this->assertContains('http://host.tld/shaare/WDWyig', $link['description']);
         // Second link is a direct link
         $link = $data['links'][array_keys($data['links'])[3]];
         $this->assertEquals(8, $link['id']);
         $this->assertEquals(DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, '20150310_114633'), $link['created']);
-        $this->assertEquals('http://host.tld/?RttfEw', $link['guid']);
+        $this->assertEquals('http://host.tld/shaare/RttfEw', $link['guid']);
         $this->assertEquals('https://static.fsf.org/nosvn/faif-2.0.pdf', $link['url']);
         $this->assertContains('Direct link', $link['description']);
         $this->assertContains('https://static.fsf.org/nosvn/faif-2.0.pdf', $link['description']);
@@ -247,14 +220,12 @@ class FeedBuilderTest extends \PHPUnit\Framework\TestCase
         $feedBuilder = new FeedBuilder(
             self::$bookmarkService,
             self::$formatter,
-            FeedBuilder::$FEED_ATOM,
-            self::$serverInfo,
-            null,
+            static::$serverInfo,
             false
         );
         $feedBuilder->setLocale(self::$LOCALE);
         $feedBuilder->setHideDates(true);
-        $data = $feedBuilder->buildData();
+        $data = $feedBuilder->buildData(FeedBuilder::$FEED_ATOM, null);
         $this->assertEquals(ReferenceLinkDB::$NB_LINKS_TOTAL, count($data['links']));
         $this->assertFalse($data['show_dates']);
 
@@ -262,14 +233,12 @@ class FeedBuilderTest extends \PHPUnit\Framework\TestCase
         $feedBuilder = new FeedBuilder(
             self::$bookmarkService,
             self::$formatter,
-            FeedBuilder::$FEED_ATOM,
-            self::$serverInfo,
-            null,
+            static::$serverInfo,
             true
         );
         $feedBuilder->setLocale(self::$LOCALE);
         $feedBuilder->setHideDates(true);
-        $data = $feedBuilder->buildData();
+        $data = $feedBuilder->buildData(FeedBuilder::$FEED_ATOM, null);
         $this->assertEquals(ReferenceLinkDB::$NB_LINKS_TOTAL, count($data['links']));
         $this->assertTrue($data['show_dates']);
     }
@@ -289,13 +258,11 @@ class FeedBuilderTest extends \PHPUnit\Framework\TestCase
         $feedBuilder = new FeedBuilder(
             self::$bookmarkService,
             self::$formatter,
-            FeedBuilder::$FEED_ATOM,
             $serverInfo,
-            null,
             false
         );
         $feedBuilder->setLocale(self::$LOCALE);
-        $data = $feedBuilder->buildData();
+        $data = $feedBuilder->buildData(FeedBuilder::$FEED_ATOM, null);
 
         $this->assertEquals(
             'http://host.tld:8080/~user/shaarli/index.php?do=feed',
@@ -304,8 +271,8 @@ class FeedBuilderTest extends \PHPUnit\Framework\TestCase
 
         // Test first link (note link)
         $link = $data['links'][array_keys($data['links'])[2]];
-        $this->assertEquals('http://host.tld:8080/~user/shaarli/?WDWyig', $link['guid']);
-        $this->assertEquals('http://host.tld:8080/~user/shaarli/?WDWyig', $link['url']);
-        $this->assertContains('http://host.tld:8080/~user/shaarli/?addtag=hashtag', $link['description']);
+        $this->assertEquals('http://host.tld:8080/~user/shaarli/shaare/WDWyig', $link['guid']);
+        $this->assertEquals('http://host.tld:8080/~user/shaarli/shaare/WDWyig', $link['url']);
+        $this->assertContains('http://host.tld:8080/~user/shaarli/./add-tag/hashtag', $link['description']);
     }
 }
index 382a560efcb2785ecbf357d26c466fa6ff18ccf1..cf48b00b63ce2c397a89cf836d1d9b04e812e9b9 100644 (file)
@@ -123,7 +123,7 @@ class BookmarkDefaultFormatterTest extends TestCase
         $description[0] = 'This a &lt;strong&gt;description&lt;/strong&gt;<br />';
         $url = 'https://sub.domain.tld?query=here&amp;for=real#hash';
         $description[1] = 'text <a href="'. $url .'">'. $url .'</a> more text<br />';
-        $description[2] = 'Also, there is an <a href="?addtag=hashtag" '.
+        $description[2] = 'Also, there is an <a href="./add-tag/hashtag" '.
             'title="Hashtag hashtag">#hashtag</a> added<br />';
         $description[3] = '&nbsp; &nbsp; A &nbsp;N &nbsp;D KEEP &nbsp; &nbsp; '.
             'SPACES &nbsp; &nbsp;! &nbsp; <br />';
@@ -148,7 +148,7 @@ class BookmarkDefaultFormatterTest extends TestCase
         $this->assertEquals($root . $short, $link['url']);
         $this->assertEquals($root . $short, $link['real_url']);
         $this->assertEquals(
-            'Text <a href="'. $root .'?addtag=hashtag" title="Hashtag hashtag">'.
+            'Text <a href="'. $root .'./add-tag/hashtag" title="Hashtag hashtag">'.
             '#hashtag</a> more text',
             $link['description']
         );
index f1f12c04efa6ef3f82e9a1d4ba76a280c1e0ce71..3e72d1eea340988b4b69c4f94e9cfdc2907d2cbf 100644 (file)
@@ -125,7 +125,7 @@ class BookmarkMarkdownFormatterTest extends TestCase
         $description .= 'This a &lt;strong&gt;description&lt;/strong&gt;<br />'. PHP_EOL;
         $url = 'https://sub.domain.tld?query=here&amp;for=real#hash';
         $description .= 'text <a href="'. $url .'">'. $url .'</a> more text<br />'. PHP_EOL;
-        $description .= 'Also, there is an <a href="?addtag=hashtag">#hashtag</a> added<br />'. PHP_EOL;
+        $description .= 'Also, there is an <a href="./add-tag/hashtag">#hashtag</a> added<br />'. PHP_EOL;
         $description .= 'A  N  D KEEP     SPACES    !   ';
         $description .= '</p></div>';
 
@@ -146,7 +146,7 @@ class BookmarkMarkdownFormatterTest extends TestCase
         $this->formatter->addContextData('index_url', $root = 'https://domain.tld/hithere/');
 
         $description = '<div class="markdown"><p>';
-        $description .= 'Text <a href="'. $root .'?addtag=hashtag">#hashtag</a> more text';
+        $description .= 'Text <a href="'. $root .'./add-tag/hashtag">#hashtag</a> more text';
         $description .= '</p></div>';
 
         $link = $this->formatter->format($bookmark);
diff --git a/tests/front/ShaarliAdminMiddlewareTest.php b/tests/front/ShaarliAdminMiddlewareTest.php
new file mode 100644 (file)
index 0000000..7451330
--- /dev/null
@@ -0,0 +1,100 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front;
+
+use PHPUnit\Framework\TestCase;
+use Shaarli\Config\ConfigManager;
+use Shaarli\Container\ShaarliContainer;
+use Shaarli\Security\LoginManager;
+use Shaarli\Updater\Updater;
+use Slim\Http\Request;
+use Slim\Http\Response;
+use Slim\Http\Uri;
+
+class ShaarliAdminMiddlewareTest extends TestCase
+{
+    protected const TMP_MOCK_FILE = '.tmp';
+
+    /** @var ShaarliContainer */
+    protected $container;
+
+    /** @var ShaarliMiddleware  */
+    protected $middleware;
+
+    public function setUp(): void
+    {
+        $this->container = $this->createMock(ShaarliContainer::class);
+
+        touch(static::TMP_MOCK_FILE);
+
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf->method('getConfigFileExt')->willReturn(static::TMP_MOCK_FILE);
+
+        $this->container->loginManager = $this->createMock(LoginManager::class);
+        $this->container->updater = $this->createMock(Updater::class);
+
+        $this->container->environment = ['REQUEST_URI' => 'http://shaarli/subfolder/path'];
+
+        $this->middleware = new ShaarliAdminMiddleware($this->container);
+    }
+
+    public function tearDown(): void
+    {
+        unlink(static::TMP_MOCK_FILE);
+    }
+
+    /**
+     * Try to access an admin controller while logged out -> redirected to login page.
+     */
+    public function testMiddlewareWhileLoggedOut(): void
+    {
+        $this->container->loginManager->expects(static::once())->method('isLoggedIn')->willReturn(false);
+
+        $request = $this->createMock(Request::class);
+        $request->method('getUri')->willReturnCallback(function (): Uri {
+            $uri = $this->createMock(Uri::class);
+            $uri->method('getBasePath')->willReturn('/subfolder');
+
+            return $uri;
+        });
+
+        $response = new Response();
+
+        /** @var Response $result */
+        $result = $this->middleware->__invoke($request, $response, function () {});
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(
+            '/subfolder/login?returnurl=' . urlencode('http://shaarli/subfolder/path'),
+            $result->getHeader('location')[0]
+        );
+    }
+
+    /**
+     * Process controller while logged in.
+     */
+    public function testMiddlewareWhileLoggedIn(): void
+    {
+        $this->container->loginManager->method('isLoggedIn')->willReturn(true);
+
+        $request = $this->createMock(Request::class);
+        $request->method('getUri')->willReturnCallback(function (): Uri {
+            $uri = $this->createMock(Uri::class);
+            $uri->method('getBasePath')->willReturn('/subfolder');
+
+            return $uri;
+        });
+
+        $response = new Response();
+        $controller = function (Request $request, Response $response): Response {
+            return $response->withStatus(418); // I'm a tea pot
+        };
+
+        /** @var Response $result */
+        $result = $this->middleware->__invoke($request, $response, $controller);
+
+        static::assertSame(418, $result->getStatusCode());
+    }
+}
index 80974f373f3f7ad3a28b344961e067c21ab5a272..05aa34a9d5acfb30f28f8b3794730b989a538a7f 100644 (file)
@@ -8,12 +8,19 @@ use PHPUnit\Framework\TestCase;
 use Shaarli\Config\ConfigManager;
 use Shaarli\Container\ShaarliContainer;
 use Shaarli\Front\Exception\LoginBannedException;
+use Shaarli\Front\Exception\UnauthorizedException;
 use Shaarli\Render\PageBuilder;
+use Shaarli\Render\PageCacheManager;
+use Shaarli\Security\LoginManager;
+use Shaarli\Updater\Updater;
 use Slim\Http\Request;
 use Slim\Http\Response;
+use Slim\Http\Uri;
 
 class ShaarliMiddlewareTest extends TestCase
 {
+    protected const TMP_MOCK_FILE = '.tmp';
+
     /** @var ShaarliContainer */
     protected $container;
 
@@ -23,12 +30,37 @@ class ShaarliMiddlewareTest extends TestCase
     public function setUp(): void
     {
         $this->container = $this->createMock(ShaarliContainer::class);
+
+        touch(static::TMP_MOCK_FILE);
+
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf->method('getConfigFileExt')->willReturn(static::TMP_MOCK_FILE);
+
+        $this->container->loginManager = $this->createMock(LoginManager::class);
+
+        $this->container->environment = ['REQUEST_URI' => 'http://shaarli/subfolder/path'];
+
         $this->middleware = new ShaarliMiddleware($this->container);
     }
 
+    public function tearDown(): void
+    {
+        unlink(static::TMP_MOCK_FILE);
+    }
+
+    /**
+     * Test middleware execution with valid controller call
+     */
     public function testMiddlewareExecution(): void
     {
         $request = $this->createMock(Request::class);
+        $request->method('getUri')->willReturnCallback(function (): Uri {
+            $uri = $this->createMock(Uri::class);
+            $uri->method('getBasePath')->willReturn('/subfolder');
+
+            return $uri;
+        });
+
         $response = new Response();
         $controller = function (Request $request, Response $response): Response {
             return $response->withStatus(418); // I'm a tea pot
@@ -41,9 +73,20 @@ class ShaarliMiddlewareTest extends TestCase
         static::assertSame(418, $result->getStatusCode());
     }
 
-    public function testMiddlewareExecutionWithException(): void
+    /**
+     * Test middleware execution with controller throwing a known front exception.
+     * The exception should be thrown to be later handled by the error handler.
+     */
+    public function testMiddlewareExecutionWithFrontException(): void
     {
         $request = $this->createMock(Request::class);
+        $request->method('getUri')->willReturnCallback(function (): Uri {
+            $uri = $this->createMock(Uri::class);
+            $uri->method('getBasePath')->willReturn('/subfolder');
+
+            return $uri;
+        });
+
         $response = new Response();
         $controller = function (): void {
             $exception = new LoginBannedException();
@@ -57,14 +100,122 @@ class ShaarliMiddlewareTest extends TestCase
         });
         $this->container->pageBuilder = $pageBuilder;
 
-        $conf = $this->createMock(ConfigManager::class);
-        $this->container->conf = $conf;
+        $this->expectException(LoginBannedException::class);
+
+        $this->middleware->__invoke($request, $response, $controller);
+    }
+
+    /**
+     * Test middleware execution with controller throwing a not authorized exception
+     * The middle should send a redirection response to the login page.
+     */
+    public function testMiddlewareExecutionWithUnauthorizedException(): void
+    {
+        $request = $this->createMock(Request::class);
+        $request->method('getUri')->willReturnCallback(function (): Uri {
+            $uri = $this->createMock(Uri::class);
+            $uri->method('getBasePath')->willReturn('/subfolder');
+
+            return $uri;
+        });
+
+        $response = new Response();
+        $controller = function (): void {
+            throw new UnauthorizedException();
+        };
+
+        /** @var Response $result */
+        $result = $this->middleware->__invoke($request, $response, $controller);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(
+            '/subfolder/login?returnurl=' . urlencode('http://shaarli/subfolder/path'),
+            $result->getHeader('location')[0]
+        );
+    }
+
+    /**
+     * Test middleware execution with controller throwing a not authorized exception.
+     * The exception should be thrown to be later handled by the error handler.
+     */
+    public function testMiddlewareExecutionWithServerException(): void
+    {
+        $request = $this->createMock(Request::class);
+        $request->method('getUri')->willReturnCallback(function (): Uri {
+            $uri = $this->createMock(Uri::class);
+            $uri->method('getBasePath')->willReturn('/subfolder');
+
+            return $uri;
+        });
+
+        $dummyException = new class() extends \Exception {};
+
+        $response = new Response();
+        $controller = function () use ($dummyException): void {
+            throw $dummyException;
+        };
+
+        $parameters = [];
+        $this->container->pageBuilder = $this->createMock(PageBuilder::class);
+        $this->container->pageBuilder->method('render')->willReturnCallback(function (string $message): string {
+            return $message;
+        });
+        $this->container->pageBuilder
+            ->method('assign')
+            ->willReturnCallback(function (string $key, string $value) use (&$parameters): void {
+                $parameters[$key] = $value;
+            })
+        ;
+
+        $this->expectException(get_class($dummyException));
+
+        $this->middleware->__invoke($request, $response, $controller);
+    }
+
+    public function testMiddlewareExecutionWithUpdates(): void
+    {
+        $request = $this->createMock(Request::class);
+        $request->method('getUri')->willReturnCallback(function (): Uri {
+            $uri = $this->createMock(Uri::class);
+            $uri->method('getBasePath')->willReturn('/subfolder');
+
+            return $uri;
+        });
+
+        $response = new Response();
+        $controller = function (Request $request, Response $response): Response {
+            return $response->withStatus(418); // I'm a tea pot
+        };
+
+        $this->container->loginManager = $this->createMock(LoginManager::class);
+        $this->container->loginManager->method('isLoggedIn')->willReturn(true);
+
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf->method('get')->willReturnCallback(function (string $key): string {
+            return $key;
+        });
+        $this->container->conf->method('getConfigFileExt')->willReturn(static::TMP_MOCK_FILE);
+
+        $this->container->pageCacheManager = $this->createMock(PageCacheManager::class);
+        $this->container->pageCacheManager->expects(static::once())->method('invalidateCaches');
+
+        $this->container->updater = $this->createMock(Updater::class);
+        $this->container->updater
+            ->expects(static::once())
+            ->method('update')
+            ->willReturn(['update123'])
+        ;
+        $this->container->updater->method('getDoneUpdates')->willReturn($updates = ['update123', 'other']);
+        $this->container->updater
+            ->expects(static::once())
+            ->method('writeUpdates')
+            ->with('resource.updates', $updates)
+        ;
 
         /** @var Response $result */
         $result = $this->middleware->__invoke($request, $response, $controller);
 
         static::assertInstanceOf(Response::class, $result);
-        static::assertSame(401, $result->getStatusCode());
-        static::assertContains('error', (string) $result->getBody());
+        static::assertSame(418, $result->getStatusCode());
     }
 }
diff --git a/tests/front/controller/LoginControllerTest.php b/tests/front/controller/LoginControllerTest.php
deleted file mode 100644 (file)
index 8cf8ece..0000000
+++ /dev/null
@@ -1,178 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Shaarli\Front\Controller;
-
-use PHPUnit\Framework\TestCase;
-use Shaarli\Bookmark\BookmarkServiceInterface;
-use Shaarli\Config\ConfigManager;
-use Shaarli\Container\ShaarliContainer;
-use Shaarli\Front\Exception\LoginBannedException;
-use Shaarli\Plugin\PluginManager;
-use Shaarli\Render\PageBuilder;
-use Shaarli\Security\LoginManager;
-use Slim\Http\Request;
-use Slim\Http\Response;
-
-class LoginControllerTest extends TestCase
-{
-    /** @var ShaarliContainer */
-    protected $container;
-
-    /** @var LoginController */
-    protected $controller;
-
-    public function setUp(): void
-    {
-        $this->container = $this->createMock(ShaarliContainer::class);
-        $this->controller = new LoginController($this->container);
-    }
-
-    public function testValidControllerInvoke(): void
-    {
-        $this->createValidContainerMockSet();
-
-        $request = $this->createMock(Request::class);
-        $request->expects(static::once())->method('getServerParam')->willReturn('> referer');
-        $response = new Response();
-
-        $assignedVariables = [];
-        $this->container->pageBuilder
-            ->method('assign')
-            ->willReturnCallback(function ($key, $value) use (&$assignedVariables) {
-                $assignedVariables[$key] = $value;
-
-                return $this;
-            })
-        ;
-
-        $result = $this->controller->index($request, $response);
-
-        static::assertInstanceOf(Response::class, $result);
-        static::assertSame(200, $result->getStatusCode());
-        static::assertSame('loginform', (string) $result->getBody());
-
-        static::assertSame('&gt; referer', $assignedVariables['returnurl']);
-        static::assertSame(true, $assignedVariables['remember_user_default']);
-        static::assertSame('Login - Shaarli', $assignedVariables['pagetitle']);
-    }
-
-    public function testValidControllerInvokeWithUserName(): void
-    {
-        $this->createValidContainerMockSet();
-
-        $request = $this->createMock(Request::class);
-        $request->expects(static::once())->method('getServerParam')->willReturn('> referer');
-        $request->expects(static::exactly(2))->method('getParam')->willReturn('myUser>');
-        $response = new Response();
-
-        $assignedVariables = [];
-        $this->container->pageBuilder
-            ->method('assign')
-            ->willReturnCallback(function ($key, $value) use (&$assignedVariables) {
-                $assignedVariables[$key] = $value;
-
-                return $this;
-            })
-        ;
-
-        $result = $this->controller->index($request, $response);
-
-        static::assertInstanceOf(Response::class, $result);
-        static::assertSame(200, $result->getStatusCode());
-        static::assertSame('loginform', (string) $result->getBody());
-
-        static::assertSame('myUser&gt;', $assignedVariables['username']);
-        static::assertSame('&gt; referer', $assignedVariables['returnurl']);
-        static::assertSame(true, $assignedVariables['remember_user_default']);
-        static::assertSame('Login - Shaarli', $assignedVariables['pagetitle']);
-    }
-
-    public function testLoginControllerWhileLoggedIn(): void
-    {
-        $request = $this->createMock(Request::class);
-        $response = new Response();
-
-        $loginManager = $this->createMock(LoginManager::class);
-        $loginManager->expects(static::once())->method('isLoggedIn')->willReturn(true);
-        $this->container->loginManager = $loginManager;
-
-        $result = $this->controller->index($request, $response);
-
-        static::assertInstanceOf(Response::class, $result);
-        static::assertSame(302, $result->getStatusCode());
-        static::assertSame(['./'], $result->getHeader('Location'));
-    }
-
-    public function testLoginControllerOpenShaarli(): void
-    {
-        $this->createValidContainerMockSet();
-
-        $request = $this->createMock(Request::class);
-        $response = new Response();
-
-        $conf = $this->createMock(ConfigManager::class);
-        $conf->method('get')->willReturnCallback(function (string $parameter, $default) {
-            if ($parameter === 'security.open_shaarli') {
-                return true;
-            }
-            return $default;
-        });
-        $this->container->conf = $conf;
-
-        $result = $this->controller->index($request, $response);
-
-        static::assertInstanceOf(Response::class, $result);
-        static::assertSame(302, $result->getStatusCode());
-        static::assertSame(['./'], $result->getHeader('Location'));
-    }
-
-    public function testLoginControllerWhileBanned(): void
-    {
-        $this->createValidContainerMockSet();
-
-        $request = $this->createMock(Request::class);
-        $response = new Response();
-
-        $loginManager = $this->createMock(LoginManager::class);
-        $loginManager->method('isLoggedIn')->willReturn(false);
-        $loginManager->method('canLogin')->willReturn(false);
-        $this->container->loginManager = $loginManager;
-
-        $this->expectException(LoginBannedException::class);
-
-        $this->controller->index($request, $response);
-    }
-
-    protected function createValidContainerMockSet(): void
-    {
-        // User logged out
-        $loginManager = $this->createMock(LoginManager::class);
-        $loginManager->method('isLoggedIn')->willReturn(false);
-        $loginManager->method('canLogin')->willReturn(true);
-        $this->container->loginManager = $loginManager;
-
-        // Config
-        $conf = $this->createMock(ConfigManager::class);
-        $conf->method('get')->willReturnCallback(function (string $parameter, $default) {
-            return $default;
-        });
-        $this->container->conf = $conf;
-
-        // PageBuilder
-        $pageBuilder = $this->createMock(PageBuilder::class);
-        $pageBuilder
-            ->method('render')
-            ->willReturnCallback(function (string $template): string {
-                return $template;
-            })
-        ;
-        $this->container->pageBuilder = $pageBuilder;
-
-        $pluginManager = $this->createMock(PluginManager::class);
-        $this->container->pluginManager = $pluginManager;
-        $bookmarkService = $this->createMock(BookmarkServiceInterface::class);
-        $this->container->bookmarkService = $bookmarkService;
-    }
-}
diff --git a/tests/front/controller/ShaarliControllerTest.php b/tests/front/controller/ShaarliControllerTest.php
deleted file mode 100644 (file)
index 6fa3feb..0000000
+++ /dev/null
@@ -1,116 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Shaarli\Front\Controller;
-
-use PHPUnit\Framework\TestCase;
-use Shaarli\Bookmark\BookmarkFilter;
-use Shaarli\Bookmark\BookmarkServiceInterface;
-use Shaarli\Container\ShaarliContainer;
-use Shaarli\Plugin\PluginManager;
-use Shaarli\Render\PageBuilder;
-use Shaarli\Security\LoginManager;
-
-/**
- * Class ShaarliControllerTest
- *
- * This class is used to test default behavior of ShaarliController abstract class.
- * It uses a dummy non abstract controller.
- */
-class ShaarliControllerTest extends TestCase
-{
-    /** @var ShaarliContainer */
-    protected $container;
-
-    /** @var LoginController */
-    protected $controller;
-
-    /** @var mixed[] List of variable assigned to the template */
-    protected $assignedValues;
-
-    public function setUp(): void
-    {
-        $this->container = $this->createMock(ShaarliContainer::class);
-        $this->controller = new class($this->container) extends ShaarliController
-        {
-            public function assignView(string $key, $value): ShaarliController
-            {
-                return parent::assignView($key, $value);
-            }
-
-            public function render(string $template): string
-            {
-                return parent::render($template);
-            }
-        };
-        $this->assignedValues = [];
-    }
-
-    public function testAssignView(): void
-    {
-        $this->createValidContainerMockSet();
-
-        $self = $this->controller->assignView('variableName', 'variableValue');
-
-        static::assertInstanceOf(ShaarliController::class, $self);
-        static::assertSame('variableValue', $this->assignedValues['variableName']);
-    }
-
-    public function testRender(): void
-    {
-        $this->createValidContainerMockSet();
-
-        $render = $this->controller->render('templateName');
-
-        static::assertSame('templateName', $render);
-
-        static::assertSame(10, $this->assignedValues['linkcount']);
-        static::assertSame(5, $this->assignedValues['privateLinkcount']);
-        static::assertSame(['error'], $this->assignedValues['plugin_errors']);
-
-        static::assertSame('templateName', $this->assignedValues['plugins_includes']['render_includes']['target']);
-        static::assertTrue($this->assignedValues['plugins_includes']['render_includes']['loggedin']);
-        static::assertSame('templateName', $this->assignedValues['plugins_header']['render_header']['target']);
-        static::assertTrue($this->assignedValues['plugins_header']['render_header']['loggedin']);
-        static::assertSame('templateName', $this->assignedValues['plugins_footer']['render_footer']['target']);
-        static::assertTrue($this->assignedValues['plugins_footer']['render_footer']['loggedin']);
-    }
-
-    protected function createValidContainerMockSet(): void
-    {
-        $pageBuilder = $this->createMock(PageBuilder::class);
-        $pageBuilder
-            ->method('assign')
-            ->willReturnCallback(function (string $key, $value): void {
-                $this->assignedValues[$key] = $value;
-            });
-        $pageBuilder
-            ->method('render')
-            ->willReturnCallback(function (string $template): string {
-                return $template;
-            });
-        $this->container->pageBuilder = $pageBuilder;
-
-        $bookmarkService = $this->createMock(BookmarkServiceInterface::class);
-        $bookmarkService
-            ->method('count')
-            ->willReturnCallback(function (string $visibility): int {
-                return $visibility === BookmarkFilter::$PRIVATE ? 5 : 10;
-            });
-        $this->container->bookmarkService = $bookmarkService;
-
-        $pluginManager = $this->createMock(PluginManager::class);
-        $pluginManager
-            ->method('executeHooks')
-            ->willReturnCallback(function (string $hook, array &$data, array $params): array {
-                return $data[$hook] = $params;
-            });
-        $pluginManager->method('getErrors')->willReturn(['error']);
-        $this->container->pluginManager = $pluginManager;
-
-        $loginManager = $this->createMock(LoginManager::class);
-        $loginManager->method('isLoggedIn')->willReturn(true);
-        $this->container->loginManager = $loginManager;
-    }
-}
diff --git a/tests/front/controller/admin/ConfigureControllerTest.php b/tests/front/controller/admin/ConfigureControllerTest.php
new file mode 100644 (file)
index 0000000..f2f84ba
--- /dev/null
@@ -0,0 +1,252 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use PHPUnit\Framework\TestCase;
+use Shaarli\Config\ConfigManager;
+use Shaarli\Front\Exception\WrongTokenException;
+use Shaarli\Security\SessionManager;
+use Shaarli\Thumbnailer;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class ConfigureControllerTest extends TestCase
+{
+    use FrontAdminControllerMockHelper;
+
+    /** @var ConfigureController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->controller = new ConfigureController($this->container);
+    }
+
+    /**
+     * Test displaying configure page - it should display all config variables
+     */
+    public function testIndex(): void
+    {
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf->method('get')->willReturnCallback(function (string $key) {
+            return $key;
+        });
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('configure', (string) $result->getBody());
+
+        static::assertSame('Configure - general.title', $assignedVariables['pagetitle']);
+        static::assertSame('general.title', $assignedVariables['title']);
+        static::assertSame('resource.theme', $assignedVariables['theme']);
+        static::assertEmpty($assignedVariables['theme_available']);
+        static::assertSame(['default', 'markdown'], $assignedVariables['formatter_available']);
+        static::assertNotEmpty($assignedVariables['continents']);
+        static::assertNotEmpty($assignedVariables['cities']);
+        static::assertSame('general.retrieve_description', $assignedVariables['retrieve_description']);
+        static::assertSame('privacy.default_private_links', $assignedVariables['private_links_default']);
+        static::assertSame('security.session_protection_disabled', $assignedVariables['session_protection_disabled']);
+        static::assertSame('feed.rss_permalinks', $assignedVariables['enable_rss_permalinks']);
+        static::assertSame('updates.check_updates', $assignedVariables['enable_update_check']);
+        static::assertSame('privacy.hide_public_links', $assignedVariables['hide_public_links']);
+        static::assertSame('api.enabled', $assignedVariables['api_enabled']);
+        static::assertSame('api.secret', $assignedVariables['api_secret']);
+        static::assertCount(4, $assignedVariables['languages']);
+        static::assertArrayHasKey('gd_enabled', $assignedVariables);
+        static::assertSame('thumbnails.mode', $assignedVariables['thumbnails_mode']);
+    }
+
+    /**
+     * Test posting a new config - make sure that everything is saved properly, without errors.
+     */
+    public function testSaveNewConfig(): void
+    {
+        $session = [];
+        $this->assignSessionVars($session);
+
+        $parameters = [
+            'token' => 'token',
+            'continent' => 'Europe',
+            'city' => 'Moscow',
+            'title' => 'Shaarli',
+            'titleLink' => './',
+            'retrieveDescription' => 'on',
+            'theme' => 'vintage',
+            'disablesessionprotection' => null,
+            'privateLinkByDefault' => true,
+            'enableRssPermalinks' => true,
+            'updateCheck' => false,
+            'hidePublicLinks' => 'on',
+            'enableApi' => 'on',
+            'apiSecret' => 'abcdef',
+            'formatter' => 'markdown',
+            'language' => 'fr',
+            'enableThumbnails' => Thumbnailer::MODE_NONE,
+        ];
+
+        $parametersConfigMapping = [
+            'general.timezone' => $parameters['continent'] . '/' . $parameters['city'],
+            'general.title' => $parameters['title'],
+            'general.header_link' => $parameters['titleLink'],
+            'general.retrieve_description' => !!$parameters['retrieveDescription'],
+            'resource.theme' => $parameters['theme'],
+            'security.session_protection_disabled' => !!$parameters['disablesessionprotection'],
+            'privacy.default_private_links' => !!$parameters['privateLinkByDefault'],
+            'feed.rss_permalinks' => !!$parameters['enableRssPermalinks'],
+            'updates.check_updates' => !!$parameters['updateCheck'],
+            'privacy.hide_public_links' => !!$parameters['hidePublicLinks'],
+            'api.enabled' => !!$parameters['enableApi'],
+            'api.secret' => $parameters['apiSecret'],
+            'formatter' => $parameters['formatter'],
+            'translation.language' => $parameters['language'],
+            'thumbnails.mode' => $parameters['enableThumbnails'],
+        ];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->expects(static::atLeastOnce())
+            ->method('getParam')->willReturnCallback(function (string $key) use ($parameters) {
+                if (false === array_key_exists($key, $parameters)) {
+                    static::fail('unknown key: ' . $key);
+                }
+
+                return $parameters[$key];
+            }
+        );
+
+        $response = new Response();
+
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf
+            ->expects(static::atLeastOnce())
+            ->method('set')
+            ->willReturnCallback(function (string $key, $value) use ($parametersConfigMapping): void {
+                if (false === array_key_exists($key, $parametersConfigMapping)) {
+                    static::fail('unknown key: ' . $key);
+                }
+
+                static::assertSame($parametersConfigMapping[$key], $value);
+            }
+        );
+
+        $result = $this->controller->save($request, $response);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/admin/configure'], $result->getHeader('Location'));
+
+        static::assertArrayNotHasKey(SessionManager::KEY_WARNING_MESSAGES, $session);
+        static::assertArrayNotHasKey(SessionManager::KEY_ERROR_MESSAGES, $session);
+        static::assertArrayHasKey(SessionManager::KEY_SUCCESS_MESSAGES, $session);
+        static::assertSame(['Configuration was saved.'], $session[SessionManager::KEY_SUCCESS_MESSAGES]);
+    }
+
+    /**
+     * Test posting a new config - wrong token.
+     */
+    public function testSaveNewConfigWrongToken(): void
+    {
+        $this->container->sessionManager = $this->createMock(SessionManager::class);
+        $this->container->sessionManager->method('checkToken')->willReturn(false);
+
+        $this->container->conf->expects(static::never())->method('set');
+        $this->container->conf->expects(static::never())->method('write');
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->expectException(WrongTokenException::class);
+
+        $this->controller->save($request, $response);
+    }
+
+    /**
+     * Test posting a new config - thumbnail activation.
+     */
+    public function testSaveNewConfigThumbnailsActivation(): void
+    {
+        $session = [];
+        $this->assignSessionVars($session);
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->expects(static::atLeastOnce())
+            ->method('getParam')->willReturnCallback(function (string $key) {
+                if ('enableThumbnails' === $key) {
+                    return Thumbnailer::MODE_ALL;
+                }
+
+                return $key;
+            })
+        ;
+        $response = new Response();
+
+        $result = $this->controller->save($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/admin/configure'], $result->getHeader('Location'));
+
+        static::assertArrayNotHasKey(SessionManager::KEY_ERROR_MESSAGES, $session);
+        static::assertArrayHasKey(SessionManager::KEY_WARNING_MESSAGES, $session);
+        static::assertStringContainsString(
+            'You have enabled or changed thumbnails mode',
+            $session[SessionManager::KEY_WARNING_MESSAGES][0]
+        );
+        static::assertArrayHasKey(SessionManager::KEY_SUCCESS_MESSAGES, $session);
+        static::assertSame(['Configuration was saved.'], $session[SessionManager::KEY_SUCCESS_MESSAGES]);
+    }
+
+    /**
+     * Test posting a new config - thumbnail activation.
+     */
+    public function testSaveNewConfigThumbnailsAlreadyActive(): void
+    {
+        $session = [];
+        $this->assignSessionVars($session);
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->expects(static::atLeastOnce())
+            ->method('getParam')->willReturnCallback(function (string $key) {
+                if ('enableThumbnails' === $key) {
+                    return Thumbnailer::MODE_ALL;
+                }
+
+                return $key;
+            })
+        ;
+        $response = new Response();
+
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf
+            ->expects(static::atLeastOnce())
+            ->method('get')
+            ->willReturnCallback(function (string $key): string {
+                if ('thumbnails.mode' === $key) {
+                    return Thumbnailer::MODE_ALL;
+                }
+
+                return $key;
+            })
+        ;
+
+        $result = $this->controller->save($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/admin/configure'], $result->getHeader('Location'));
+
+        static::assertArrayNotHasKey(SessionManager::KEY_ERROR_MESSAGES, $session);
+        static::assertArrayNotHasKey(SessionManager::KEY_WARNING_MESSAGES, $session);
+        static::assertArrayHasKey(SessionManager::KEY_SUCCESS_MESSAGES, $session);
+        static::assertSame(['Configuration was saved.'], $session[SessionManager::KEY_SUCCESS_MESSAGES]);
+    }
+}
diff --git a/tests/front/controller/admin/ExportControllerTest.php b/tests/front/controller/admin/ExportControllerTest.php
new file mode 100644 (file)
index 0000000..50d9e37
--- /dev/null
@@ -0,0 +1,163 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use PHPUnit\Framework\TestCase;
+use Shaarli\Bookmark\Bookmark;
+use Shaarli\Formatter\BookmarkFormatter;
+use Shaarli\Formatter\BookmarkRawFormatter;
+use Shaarli\Netscape\NetscapeBookmarkUtils;
+use Shaarli\Security\SessionManager;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class ExportControllerTest extends TestCase
+{
+    use FrontAdminControllerMockHelper;
+
+    /** @var ExportController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->controller = new ExportController($this->container);
+    }
+
+    /**
+     * Test displaying export page
+     */
+    public function testIndex(): void
+    {
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('export', (string) $result->getBody());
+
+        static::assertSame('Export - Shaarli', $assignedVariables['pagetitle']);
+    }
+
+    /**
+     * Test posting an export request
+     */
+    public function testExportDefault(): void
+    {
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $parameters = [
+            'selection' => 'all',
+            'prepend_note_url' => 'on',
+        ];
+
+        $request = $this->createMock(Request::class);
+        $request->method('getParam')->willReturnCallback(function (string $key) use ($parameters) {
+            return $parameters[$key] ?? null;
+        });
+        $response = new Response();
+
+        $bookmarks = [
+            (new Bookmark())->setUrl('http://link1.tld')->setTitle('Title 1'),
+            (new Bookmark())->setUrl('http://link2.tld')->setTitle('Title 2'),
+        ];
+
+        $this->container->netscapeBookmarkUtils = $this->createMock(NetscapeBookmarkUtils::class);
+        $this->container->netscapeBookmarkUtils
+            ->expects(static::once())
+            ->method('filterAndFormat')
+            ->willReturnCallback(
+                function (
+                    BookmarkFormatter $formatter,
+                    string $selection,
+                    bool $prependNoteUrl,
+                    string $indexUrl
+                ) use ($parameters, $bookmarks): array {
+                    static::assertInstanceOf(BookmarkRawFormatter::class, $formatter);
+                    static::assertSame($parameters['selection'], $selection);
+                    static::assertTrue($prependNoteUrl);
+                    static::assertSame('http://shaarli', $indexUrl);
+
+                    return $bookmarks;
+                }
+            )
+        ;
+
+        $result = $this->controller->export($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('export.bookmarks', (string) $result->getBody());
+        static::assertSame(['text/html; charset=utf-8'], $result->getHeader('content-type'));
+        static::assertRegExp(
+            '/attachment; filename=bookmarks_all_[\d]{8}_[\d]{6}\.html/',
+            $result->getHeader('content-disposition')[0]
+        );
+
+        static::assertNotEmpty($assignedVariables['date']);
+        static::assertSame(PHP_EOL, $assignedVariables['eol']);
+        static::assertSame('all', $assignedVariables['selection']);
+        static::assertSame($bookmarks, $assignedVariables['links']);
+    }
+
+    /**
+     * Test posting an export request - without selection parameter
+     */
+    public function testExportSelectionMissing(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_ERROR_MESSAGES, ['Please select an export mode.'])
+        ;
+
+        $result = $this->controller->export($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/admin/export'], $result->getHeader('location'));
+    }
+
+    /**
+     * Test posting an export request - without selection parameter
+     */
+    public function testExportErrorEncountered(): void
+    {
+        $parameters = [
+            'selection' => 'all',
+        ];
+
+        $request = $this->createMock(Request::class);
+        $request->method('getParam')->willReturnCallback(function (string $key) use ($parameters) {
+            return $parameters[$key] ?? null;
+        });
+        $response = new Response();
+
+        $this->container->netscapeBookmarkUtils = $this->createMock(NetscapeBookmarkUtils::class);
+        $this->container->netscapeBookmarkUtils
+            ->expects(static::once())
+            ->method('filterAndFormat')
+            ->willThrowException(new \Exception($message = 'error message'));
+        ;
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_ERROR_MESSAGES, [$message])
+        ;
+
+        $result = $this->controller->export($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/admin/export'], $result->getHeader('location'));
+    }
+}
diff --git a/tests/front/controller/admin/FrontAdminControllerMockHelper.php b/tests/front/controller/admin/FrontAdminControllerMockHelper.php
new file mode 100644 (file)
index 0000000..2b9f2ef
--- /dev/null
@@ -0,0 +1,56 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Shaarli\Container\ShaarliTestContainer;
+use Shaarli\Front\Controller\Visitor\FrontControllerMockHelper;
+use Shaarli\History;
+
+/**
+ * Trait FrontControllerMockHelper
+ *
+ * Helper trait used to initialize the ShaarliContainer and mock its services for admin controller tests.
+ *
+ * @property ShaarliTestContainer $container
+ */
+trait FrontAdminControllerMockHelper
+{
+    use FrontControllerMockHelper {
+        FrontControllerMockHelper::createContainer as parentCreateContainer;
+    }
+
+    /**
+     * Mock the container instance
+     */
+    protected function createContainer(): void
+    {
+        $this->parentCreateContainer();
+
+        $this->container->history = $this->createMock(History::class);
+
+        $this->container->loginManager->method('isLoggedIn')->willReturn(true);
+        $this->container->sessionManager->method('checkToken')->willReturn(true);
+    }
+
+
+    /**
+     * Pass a reference of an array which will be populated by `sessionManager->setSessionParameter`
+     * calls during execution.
+     *
+     * @param mixed $variables Array reference to populate.
+     */
+    protected function assignSessionVars(array &$variables): void
+    {
+        $this->container->sessionManager
+            ->expects(static::atLeastOnce())
+            ->method('setSessionParameter')
+            ->willReturnCallback(function ($key, $value) use (&$variables) {
+                $variables[$key] = $value;
+
+                return $this->container->sessionManager;
+            })
+        ;
+    }
+}
diff --git a/tests/front/controller/admin/ImportControllerTest.php b/tests/front/controller/admin/ImportControllerTest.php
new file mode 100644 (file)
index 0000000..eb31fad
--- /dev/null
@@ -0,0 +1,148 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use PHPUnit\Framework\TestCase;
+use Psr\Http\Message\UploadedFileInterface;
+use Shaarli\Netscape\NetscapeBookmarkUtils;
+use Shaarli\Security\SessionManager;
+use Slim\Http\Request;
+use Slim\Http\Response;
+use Slim\Http\UploadedFile;
+
+class ImportControllerTest extends TestCase
+{
+    use FrontAdminControllerMockHelper;
+
+    /** @var ImportController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->controller = new ImportController($this->container);
+    }
+
+    /**
+     * Test displaying import page
+     */
+    public function testIndex(): void
+    {
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('import', (string) $result->getBody());
+
+        static::assertSame('Import - Shaarli', $assignedVariables['pagetitle']);
+        static::assertIsInt($assignedVariables['maxfilesize']);
+        static::assertRegExp('/\d+[KM]iB/', $assignedVariables['maxfilesizeHuman']);
+    }
+
+    /**
+     * Test importing a file with default and valid parameters
+     */
+    public function testImportDefault(): void
+    {
+        $parameters = [
+            'abc' => 'def',
+            'other' => 'param',
+        ];
+
+        $requestFile = new UploadedFile('file', 'name', 'type', 123);
+
+        $request = $this->createMock(Request::class);
+        $request->method('getParams')->willReturnCallback(function () use ($parameters) {
+            return $parameters;
+        });
+        $request->method('getUploadedFiles')->willReturn(['filetoupload' => $requestFile]);
+        $response = new Response();
+
+        $this->container->netscapeBookmarkUtils = $this->createMock(NetscapeBookmarkUtils::class);
+        $this->container->netscapeBookmarkUtils
+            ->expects(static::once())
+            ->method('import')
+            ->willReturnCallback(
+                function (
+                    array $post,
+                    UploadedFileInterface $file
+                ) use ($parameters, $requestFile): string {
+                    static::assertSame($parameters, $post);
+                    static::assertSame($requestFile, $file);
+
+                    return 'status';
+                }
+            )
+        ;
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_SUCCESS_MESSAGES, ['status'])
+        ;
+
+        $result = $this->controller->import($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/admin/import'], $result->getHeader('location'));
+    }
+
+    /**
+     * Test posting an import request - without import file
+     */
+    public function testImportFileMissing(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_ERROR_MESSAGES, ['No import file provided.'])
+        ;
+
+        $result = $this->controller->import($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/admin/import'], $result->getHeader('location'));
+    }
+
+    /**
+     * Test posting an import request - with an empty file
+     */
+    public function testImportEmptyFile(): void
+    {
+        $requestFile = new UploadedFile('file', 'name', 'type', 0);
+
+        $request = $this->createMock(Request::class);
+        $request->method('getUploadedFiles')->willReturn(['filetoupload' => $requestFile]);
+        $response = new Response();
+
+        $this->container->netscapeBookmarkUtils = $this->createMock(NetscapeBookmarkUtils::class);
+        $this->container->netscapeBookmarkUtils->expects(static::never())->method('filterAndFormat');
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->willReturnCallback(function (string $key, array $value): SessionManager {
+                static::assertSame(SessionManager::KEY_ERROR_MESSAGES, $key);
+                static::assertStringStartsWith('The file you are trying to upload is probably bigger', $value[0]);
+
+                return $this->container->sessionManager;
+            })
+        ;
+
+        $result = $this->controller->import($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/admin/import'], $result->getHeader('location'));
+    }
+}
diff --git a/tests/front/controller/admin/LogoutControllerTest.php b/tests/front/controller/admin/LogoutControllerTest.php
new file mode 100644 (file)
index 0000000..45e84dc
--- /dev/null
@@ -0,0 +1,51 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use PHPUnit\Framework\TestCase;
+use Shaarli\Security\CookieManager;
+use Shaarli\Security\LoginManager;
+use Shaarli\Security\SessionManager;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class LogoutControllerTest extends TestCase
+{
+    use FrontAdminControllerMockHelper;
+
+    /** @var LogoutController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->controller = new LogoutController($this->container);
+    }
+
+    public function testValidControllerInvoke(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->pageCacheManager->expects(static::once())->method('invalidateCaches');
+
+        $this->container->sessionManager = $this->createMock(SessionManager::class);
+        $this->container->sessionManager->expects(static::once())->method('logout');
+
+        $this->container->cookieManager = $this->createMock(CookieManager::class);
+        $this->container->cookieManager
+            ->expects(static::once())
+            ->method('setCookieParameter')
+            ->with(CookieManager::STAY_SIGNED_IN, 'false', 0, '/subfolder/')
+        ;
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+}
diff --git a/tests/front/controller/admin/ManageShaareControllerTest/AddShaareTest.php b/tests/front/controller/admin/ManageShaareControllerTest/AddShaareTest.php
new file mode 100644 (file)
index 0000000..7d5b752
--- /dev/null
@@ -0,0 +1,47 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin\ManageShaareControllerTest;
+
+use PHPUnit\Framework\TestCase;
+use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
+use Shaarli\Front\Controller\Admin\ManageShaareController;
+use Shaarli\Http\HttpAccess;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class AddShaareTest extends TestCase
+{
+    use FrontAdminControllerMockHelper;
+
+    /** @var ManageShaareController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->container->httpAccess = $this->createMock(HttpAccess::class);
+        $this->controller = new ManageShaareController($this->container);
+    }
+
+    /**
+     * Test displaying add link page
+     */
+    public function testAddShaare(): void
+    {
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $result = $this->controller->addShaare($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('addlink', (string) $result->getBody());
+
+        static::assertSame('Shaare a new link - Shaarli', $assignedVariables['pagetitle']);
+    }
+}
diff --git a/tests/front/controller/admin/ManageShaareControllerTest/ChangeVisibilityBookmarkTest.php b/tests/front/controller/admin/ManageShaareControllerTest/ChangeVisibilityBookmarkTest.php
new file mode 100644 (file)
index 0000000..5a61579
--- /dev/null
@@ -0,0 +1,418 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin\ManageShaareControllerTest;
+
+use PHPUnit\Framework\TestCase;
+use Shaarli\Bookmark\Bookmark;
+use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
+use Shaarli\Formatter\BookmarkFormatter;
+use Shaarli\Formatter\BookmarkRawFormatter;
+use Shaarli\Formatter\FormatterFactory;
+use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
+use Shaarli\Front\Controller\Admin\ManageShaareController;
+use Shaarli\Http\HttpAccess;
+use Shaarli\Security\SessionManager;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class ChangeVisibilityBookmarkTest extends TestCase
+{
+    use FrontAdminControllerMockHelper;
+
+    /** @var ManageShaareController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->container->httpAccess = $this->createMock(HttpAccess::class);
+        $this->controller = new ManageShaareController($this->container);
+    }
+
+    /**
+     * Change bookmark visibility - Set private - Single public bookmark with valid parameters
+     */
+    public function testSetSingleBookmarkPrivate(): void
+    {
+        $parameters = ['id' => '123', 'newVisibility' => 'private'];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters): ?string {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $bookmark = (new Bookmark())->setId(123)->setUrl('http://domain.tld')->setTitle('Title 123')->setPrivate(false);
+
+        static::assertFalse($bookmark->isPrivate());
+
+        $this->container->bookmarkService->expects(static::once())->method('get')->with(123)->willReturn($bookmark);
+        $this->container->bookmarkService->expects(static::once())->method('set')->with($bookmark, false);
+        $this->container->bookmarkService->expects(static::once())->method('save');
+        $this->container->formatterFactory = $this->createMock(FormatterFactory::class);
+        $this->container->formatterFactory
+            ->expects(static::once())
+            ->method('getFormatter')
+            ->with('raw')
+            ->willReturnCallback(function () use ($bookmark): BookmarkFormatter {
+                return new BookmarkRawFormatter($this->container->conf, true);
+            })
+        ;
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::once())
+            ->method('executeHooks')
+            ->with('save_link')
+        ;
+
+        $result = $this->controller->changeVisibility($request, $response);
+
+        static::assertTrue($bookmark->isPrivate());
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    /**
+     * Change bookmark visibility - Set public - Single private bookmark with valid parameters
+     */
+    public function testSetSingleBookmarkPublic(): void
+    {
+        $parameters = ['id' => '123', 'newVisibility' => 'public'];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters): ?string {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $bookmark = (new Bookmark())->setId(123)->setUrl('http://domain.tld')->setTitle('Title 123')->setPrivate(true);
+
+        static::assertTrue($bookmark->isPrivate());
+
+        $this->container->bookmarkService->expects(static::once())->method('get')->with(123)->willReturn($bookmark);
+        $this->container->bookmarkService->expects(static::once())->method('set')->with($bookmark, false);
+        $this->container->bookmarkService->expects(static::once())->method('save');
+        $this->container->formatterFactory = $this->createMock(FormatterFactory::class);
+        $this->container->formatterFactory
+            ->expects(static::once())
+            ->method('getFormatter')
+            ->with('raw')
+            ->willReturn(new BookmarkRawFormatter($this->container->conf, true))
+        ;
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::once())
+            ->method('executeHooks')
+            ->with('save_link')
+        ;
+
+        $result = $this->controller->changeVisibility($request, $response);
+
+        static::assertFalse($bookmark->isPrivate());
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    /**
+     * Change bookmark visibility - Set private on single already private bookmark
+     */
+    public function testSetSinglePrivateBookmarkPrivate(): void
+    {
+        $parameters = ['id' => '123', 'newVisibility' => 'private'];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters): ?string {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $bookmark = (new Bookmark())->setId(123)->setUrl('http://domain.tld')->setTitle('Title 123')->setPrivate(true);
+
+        static::assertTrue($bookmark->isPrivate());
+
+        $this->container->bookmarkService->expects(static::once())->method('get')->with(123)->willReturn($bookmark);
+        $this->container->bookmarkService->expects(static::once())->method('set')->with($bookmark, false);
+        $this->container->bookmarkService->expects(static::once())->method('save');
+        $this->container->formatterFactory = $this->createMock(FormatterFactory::class);
+        $this->container->formatterFactory
+            ->expects(static::once())
+            ->method('getFormatter')
+            ->with('raw')
+            ->willReturn(new BookmarkRawFormatter($this->container->conf, true))
+        ;
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::once())
+            ->method('executeHooks')
+            ->with('save_link')
+        ;
+
+        $result = $this->controller->changeVisibility($request, $response);
+
+        static::assertTrue($bookmark->isPrivate());
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    /**
+     * Change bookmark visibility - Set multiple bookmarks private
+     */
+    public function testSetMultipleBookmarksPrivate(): void
+    {
+        $parameters = ['id' => '123 456 789', 'newVisibility' => 'private'];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters): ?string {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $bookmarks = [
+            (new Bookmark())->setId(123)->setUrl('http://domain.tld')->setTitle('Title 123')->setPrivate(false),
+            (new Bookmark())->setId(456)->setUrl('http://domain.tld')->setTitle('Title 456')->setPrivate(true),
+            (new Bookmark())->setId(789)->setUrl('http://domain.tld')->setTitle('Title 789')->setPrivate(false),
+        ];
+
+        $this->container->bookmarkService
+            ->expects(static::exactly(3))
+            ->method('get')
+            ->withConsecutive([123], [456], [789])
+            ->willReturnOnConsecutiveCalls(...$bookmarks)
+        ;
+        $this->container->bookmarkService
+            ->expects(static::exactly(3))
+            ->method('set')
+            ->withConsecutive(...array_map(function (Bookmark $bookmark): array {
+                return [$bookmark, false];
+            }, $bookmarks))
+        ;
+        $this->container->bookmarkService->expects(static::once())->method('save');
+        $this->container->formatterFactory = $this->createMock(FormatterFactory::class);
+        $this->container->formatterFactory
+            ->expects(static::once())
+            ->method('getFormatter')
+            ->with('raw')
+            ->willReturn(new BookmarkRawFormatter($this->container->conf, true))
+        ;
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::exactly(3))
+            ->method('executeHooks')
+            ->with('save_link')
+        ;
+
+        $result = $this->controller->changeVisibility($request, $response);
+
+        static::assertTrue($bookmarks[0]->isPrivate());
+        static::assertTrue($bookmarks[1]->isPrivate());
+        static::assertTrue($bookmarks[2]->isPrivate());
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    /**
+     * Change bookmark visibility - Single bookmark not found.
+     */
+    public function testChangeVisibilitySingleBookmarkNotFound(): void
+    {
+        $parameters = ['id' => '123', 'newVisibility' => 'private'];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters): ?string {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('get')
+            ->willThrowException(new BookmarkNotFoundException())
+        ;
+        $this->container->bookmarkService->expects(static::never())->method('set');
+        $this->container->bookmarkService->expects(static::never())->method('save');
+        $this->container->formatterFactory = $this->createMock(FormatterFactory::class);
+        $this->container->formatterFactory
+            ->expects(static::once())
+            ->method('getFormatter')
+            ->with('raw')
+            ->willReturn(new BookmarkRawFormatter($this->container->conf, true))
+        ;
+
+        // Make sure that PluginManager hook is not triggered
+        $this->container->pluginManager
+            ->expects(static::never())
+            ->method('executeHooks')
+            ->with('save_link')
+        ;
+
+        $result = $this->controller->changeVisibility($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    /**
+     * Change bookmark visibility - Multiple bookmarks with one not found.
+     */
+    public function testChangeVisibilityMultipleBookmarksOneNotFound(): void
+    {
+        $parameters = ['id' => '123 456 789', 'newVisibility' => 'public'];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters): ?string {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $bookmarks = [
+            (new Bookmark())->setId(123)->setUrl('http://domain.tld')->setTitle('Title 123')->setPrivate(true),
+            (new Bookmark())->setId(789)->setUrl('http://domain.tld')->setTitle('Title 789')->setPrivate(false),
+        ];
+
+        $this->container->bookmarkService
+            ->expects(static::exactly(3))
+            ->method('get')
+            ->withConsecutive([123], [456], [789])
+            ->willReturnCallback(function (int $id) use ($bookmarks): Bookmark {
+                if ($id === 123) {
+                    return $bookmarks[0];
+                }
+                if ($id === 789) {
+                    return $bookmarks[1];
+                }
+                throw new BookmarkNotFoundException();
+            })
+        ;
+        $this->container->bookmarkService
+            ->expects(static::exactly(2))
+            ->method('set')
+            ->withConsecutive(...array_map(function (Bookmark $bookmark): array {
+                return [$bookmark, false];
+            }, $bookmarks))
+        ;
+        $this->container->bookmarkService->expects(static::once())->method('save');
+
+        // Make sure that PluginManager hook is not triggered
+        $this->container->pluginManager
+            ->expects(static::exactly(2))
+            ->method('executeHooks')
+            ->with('save_link')
+        ;
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_ERROR_MESSAGES, ['Bookmark with identifier 456 could not be found.'])
+        ;
+
+        $result = $this->controller->changeVisibility($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    /**
+     * Change bookmark visibility - Invalid ID
+     */
+    public function testChangeVisibilityInvalidId(): void
+    {
+        $parameters = ['id' => 'nope not an ID', 'newVisibility' => 'private'];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters): ?string {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_ERROR_MESSAGES, ['Invalid bookmark ID provided.'])
+        ;
+
+        $result = $this->controller->changeVisibility($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    /**
+     * Change bookmark visibility - Empty ID
+     */
+    public function testChangeVisibilityEmptyId(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_ERROR_MESSAGES, ['Invalid bookmark ID provided.'])
+        ;
+
+        $result = $this->controller->changeVisibility($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    /**
+     * Change bookmark visibility - with invalid visibility
+     */
+    public function testChangeVisibilityWithInvalidVisibility(): void
+    {
+        $parameters = ['id' => '123', 'newVisibility' => 'invalid'];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters): ?string {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_ERROR_MESSAGES, ['Invalid visibility provided.'])
+        ;
+
+        $result = $this->controller->changeVisibility($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+}
diff --git a/tests/front/controller/admin/ManageShaareControllerTest/DeleteBookmarkTest.php b/tests/front/controller/admin/ManageShaareControllerTest/DeleteBookmarkTest.php
new file mode 100644 (file)
index 0000000..dee622b
--- /dev/null
@@ -0,0 +1,376 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin\ManageShaareControllerTest;
+
+use PHPUnit\Framework\TestCase;
+use Shaarli\Bookmark\Bookmark;
+use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
+use Shaarli\Formatter\BookmarkFormatter;
+use Shaarli\Formatter\FormatterFactory;
+use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
+use Shaarli\Front\Controller\Admin\ManageShaareController;
+use Shaarli\Http\HttpAccess;
+use Shaarli\Security\SessionManager;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class DeleteBookmarkTest extends TestCase
+{
+    use FrontAdminControllerMockHelper;
+
+    /** @var ManageShaareController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->container->httpAccess = $this->createMock(HttpAccess::class);
+        $this->controller = new ManageShaareController($this->container);
+    }
+
+    /**
+     * Delete bookmark - Single bookmark with valid parameters
+     */
+    public function testDeleteSingleBookmark(): void
+    {
+        $parameters = ['id' => '123'];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters): ?string {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $bookmark = (new Bookmark())->setId(123)->setUrl('http://domain.tld')->setTitle('Title 123');
+
+        $this->container->bookmarkService->expects(static::once())->method('get')->with(123)->willReturn($bookmark);
+        $this->container->bookmarkService->expects(static::once())->method('remove')->with($bookmark, false);
+        $this->container->bookmarkService->expects(static::once())->method('save');
+        $this->container->formatterFactory = $this->createMock(FormatterFactory::class);
+        $this->container->formatterFactory
+            ->expects(static::once())
+            ->method('getFormatter')
+            ->with('raw')
+            ->willReturnCallback(function () use ($bookmark): BookmarkFormatter {
+                $formatter = $this->createMock(BookmarkFormatter::class);
+                $formatter
+                    ->expects(static::once())
+                    ->method('format')
+                    ->with($bookmark)
+                    ->willReturn(['formatted' => $bookmark])
+                ;
+
+                return $formatter;
+            })
+        ;
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::once())
+            ->method('executeHooks')
+            ->with('delete_link', ['formatted' => $bookmark])
+        ;
+
+        $result = $this->controller->deleteBookmark($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    /**
+     * Delete bookmark - Multiple bookmarks with valid parameters
+     */
+    public function testDeleteMultipleBookmarks(): void
+    {
+        $parameters = ['id' => '123 456 789'];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters): ?string {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $bookmarks = [
+            (new Bookmark())->setId(123)->setUrl('http://domain.tld')->setTitle('Title 123'),
+            (new Bookmark())->setId(456)->setUrl('http://domain.tld')->setTitle('Title 456'),
+            (new Bookmark())->setId(789)->setUrl('http://domain.tld')->setTitle('Title 789'),
+        ];
+
+        $this->container->bookmarkService
+            ->expects(static::exactly(3))
+            ->method('get')
+            ->withConsecutive([123], [456], [789])
+            ->willReturnOnConsecutiveCalls(...$bookmarks)
+        ;
+        $this->container->bookmarkService
+            ->expects(static::exactly(3))
+            ->method('remove')
+            ->withConsecutive(...array_map(function (Bookmark $bookmark): array {
+                return [$bookmark, false];
+            }, $bookmarks))
+        ;
+        $this->container->bookmarkService->expects(static::once())->method('save');
+        $this->container->formatterFactory = $this->createMock(FormatterFactory::class);
+        $this->container->formatterFactory
+            ->expects(static::once())
+            ->method('getFormatter')
+            ->with('raw')
+            ->willReturnCallback(function () use ($bookmarks): BookmarkFormatter {
+                $formatter = $this->createMock(BookmarkFormatter::class);
+
+                $formatter
+                    ->expects(static::exactly(3))
+                    ->method('format')
+                    ->withConsecutive(...array_map(function (Bookmark $bookmark): array {
+                        return [$bookmark];
+                    }, $bookmarks))
+                    ->willReturnOnConsecutiveCalls(...array_map(function (Bookmark $bookmark): array {
+                        return ['formatted' => $bookmark];
+                    }, $bookmarks))
+                ;
+
+                return $formatter;
+            })
+        ;
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::exactly(3))
+            ->method('executeHooks')
+            ->with('delete_link')
+        ;
+
+        $result = $this->controller->deleteBookmark($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    /**
+     * Delete bookmark - Single bookmark not found in the data store
+     */
+    public function testDeleteSingleBookmarkNotFound(): void
+    {
+        $parameters = ['id' => '123'];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters): ?string {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('get')
+            ->willThrowException(new BookmarkNotFoundException())
+        ;
+        $this->container->bookmarkService->expects(static::never())->method('remove');
+        $this->container->bookmarkService->expects(static::never())->method('save');
+        $this->container->formatterFactory = $this->createMock(FormatterFactory::class);
+        $this->container->formatterFactory
+            ->expects(static::once())
+            ->method('getFormatter')
+            ->with('raw')
+            ->willReturnCallback(function (): BookmarkFormatter {
+                $formatter = $this->createMock(BookmarkFormatter::class);
+
+                $formatter->expects(static::never())->method('format');
+
+                return $formatter;
+            })
+        ;
+        // Make sure that PluginManager hook is not triggered
+        $this->container->pluginManager
+            ->expects(static::never())
+            ->method('executeHooks')
+            ->with('delete_link')
+        ;
+
+        $result = $this->controller->deleteBookmark($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    /**
+     * Delete bookmark - Multiple bookmarks with one not found in the data store
+     */
+    public function testDeleteMultipleBookmarksOneNotFound(): void
+    {
+        $parameters = ['id' => '123 456 789'];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters): ?string {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $bookmarks = [
+            (new Bookmark())->setId(123)->setUrl('http://domain.tld')->setTitle('Title 123'),
+            (new Bookmark())->setId(789)->setUrl('http://domain.tld')->setTitle('Title 789'),
+        ];
+
+        $this->container->bookmarkService
+            ->expects(static::exactly(3))
+            ->method('get')
+            ->withConsecutive([123], [456], [789])
+            ->willReturnCallback(function (int $id) use ($bookmarks): Bookmark {
+                if ($id === 123) {
+                    return $bookmarks[0];
+                }
+                if ($id === 789) {
+                    return $bookmarks[1];
+                }
+                throw new BookmarkNotFoundException();
+            })
+        ;
+        $this->container->bookmarkService
+            ->expects(static::exactly(2))
+            ->method('remove')
+            ->withConsecutive(...array_map(function (Bookmark $bookmark): array {
+                return [$bookmark, false];
+            }, $bookmarks))
+        ;
+        $this->container->bookmarkService->expects(static::once())->method('save');
+        $this->container->formatterFactory = $this->createMock(FormatterFactory::class);
+        $this->container->formatterFactory
+            ->expects(static::once())
+            ->method('getFormatter')
+            ->with('raw')
+            ->willReturnCallback(function () use ($bookmarks): BookmarkFormatter {
+                $formatter = $this->createMock(BookmarkFormatter::class);
+
+                $formatter
+                    ->expects(static::exactly(2))
+                    ->method('format')
+                    ->withConsecutive(...array_map(function (Bookmark $bookmark): array {
+                        return [$bookmark];
+                    }, $bookmarks))
+                    ->willReturnOnConsecutiveCalls(...array_map(function (Bookmark $bookmark): array {
+                        return ['formatted' => $bookmark];
+                    }, $bookmarks))
+                ;
+
+                return $formatter;
+            })
+        ;
+
+        // Make sure that PluginManager hook is not triggered
+        $this->container->pluginManager
+            ->expects(static::exactly(2))
+            ->method('executeHooks')
+            ->with('delete_link')
+        ;
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_ERROR_MESSAGES, ['Bookmark with identifier 456 could not be found.'])
+        ;
+
+        $result = $this->controller->deleteBookmark($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    /**
+     * Delete bookmark - Invalid ID
+     */
+    public function testDeleteInvalidId(): void
+    {
+        $parameters = ['id' => 'nope not an ID'];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters): ?string {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_ERROR_MESSAGES, ['Invalid bookmark ID provided.'])
+        ;
+
+        $result = $this->controller->deleteBookmark($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    /**
+     * Delete bookmark - Empty ID
+     */
+    public function testDeleteEmptyId(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_ERROR_MESSAGES, ['Invalid bookmark ID provided.'])
+        ;
+
+        $result = $this->controller->deleteBookmark($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    /**
+     * Delete bookmark - from bookmarklet
+     */
+    public function testDeleteBookmarkFromBookmarklet(): void
+    {
+        $parameters = [
+            'id' => '123',
+            'source' => 'bookmarklet',
+        ];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters): ?string {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $this->container->formatterFactory = $this->createMock(FormatterFactory::class);
+        $this->container->formatterFactory
+            ->expects(static::once())
+            ->method('getFormatter')
+            ->willReturnCallback(function (): BookmarkFormatter {
+                $formatter = $this->createMock(BookmarkFormatter::class);
+                $formatter->method('format')->willReturn(['formatted']);
+
+                return $formatter;
+            })
+        ;
+
+        $result = $this->controller->deleteBookmark($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('<script>self.close();</script>', (string) $result->getBody('location'));
+    }
+}
diff --git a/tests/front/controller/admin/ManageShaareControllerTest/DisplayCreateFormTest.php b/tests/front/controller/admin/ManageShaareControllerTest/DisplayCreateFormTest.php
new file mode 100644 (file)
index 0000000..777583d
--- /dev/null
@@ -0,0 +1,315 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin\ManageShaareControllerTest;
+
+use PHPUnit\Framework\TestCase;
+use Shaarli\Bookmark\Bookmark;
+use Shaarli\Config\ConfigManager;
+use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
+use Shaarli\Front\Controller\Admin\ManageShaareController;
+use Shaarli\Http\HttpAccess;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class DisplayCreateFormTest extends TestCase
+{
+    use FrontAdminControllerMockHelper;
+
+    /** @var ManageShaareController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->container->httpAccess = $this->createMock(HttpAccess::class);
+        $this->controller = new ManageShaareController($this->container);
+    }
+
+    /**
+     * Test displaying bookmark create form
+     * Ensure that every step of the standard workflow works properly.
+     */
+    public function testDisplayCreateFormWithUrl(): void
+    {
+        $this->container->environment = [
+            'HTTP_REFERER' => $referer = 'http://shaarli/subfolder/controller/?searchtag=abc'
+        ];
+
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $url = 'http://url.tld/other?part=3&utm_ad=pay#hash';
+        $expectedUrl = str_replace('&utm_ad=pay', '', $url);
+        $remoteTitle = 'Remote Title';
+        $remoteDesc = 'Sometimes the meta description is relevant.';
+        $remoteTags = 'abc def';
+
+        $request = $this->createMock(Request::class);
+        $request->method('getParam')->willReturnCallback(function (string $key) use ($url): ?string {
+            return $key === 'post' ? $url : null;
+        });
+        $response = new Response();
+
+        $this->container->httpAccess
+            ->expects(static::once())
+            ->method('getCurlDownloadCallback')
+            ->willReturnCallback(
+                function (&$charset, &$title, &$description, &$tags) use (
+                    $remoteTitle,
+                    $remoteDesc,
+                    $remoteTags
+                ): callable {
+                    return function () use (
+                        &$charset,
+                        &$title,
+                        &$description,
+                        &$tags,
+                        $remoteTitle,
+                        $remoteDesc,
+                        $remoteTags
+                    ): void {
+                        $charset = 'ISO-8859-1';
+                        $title = $remoteTitle;
+                        $description = $remoteDesc;
+                        $tags = $remoteTags;
+                    };
+                }
+            )
+        ;
+        $this->container->httpAccess
+            ->expects(static::once())
+            ->method('getHttpResponse')
+            ->with($expectedUrl, 30, 4194304)
+            ->willReturnCallback(function($url, $timeout, $maxBytes, $callback): void {
+                $callback();
+            })
+        ;
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('bookmarksCountPerTag')
+            ->willReturn($tags = ['tag1' => 2, 'tag2' => 1])
+        ;
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::at(0))
+            ->method('executeHooks')
+            ->willReturnCallback(function (string $hook, array $data) use ($remoteTitle, $remoteDesc): array {
+                static::assertSame('render_editlink', $hook);
+                static::assertSame($remoteTitle, $data['link']['title']);
+                static::assertSame($remoteDesc, $data['link']['description']);
+
+                return $data;
+            })
+        ;
+
+        $result = $this->controller->displayCreateForm($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('editlink', (string) $result->getBody());
+
+        static::assertSame('Shaare - Shaarli', $assignedVariables['pagetitle']);
+
+        static::assertSame($expectedUrl, $assignedVariables['link']['url']);
+        static::assertSame($remoteTitle, $assignedVariables['link']['title']);
+        static::assertSame($remoteDesc, $assignedVariables['link']['description']);
+        static::assertSame($remoteTags, $assignedVariables['link']['tags']);
+        static::assertFalse($assignedVariables['link']['private']);
+
+        static::assertTrue($assignedVariables['link_is_new']);
+        static::assertSame($referer, $assignedVariables['http_referer']);
+        static::assertSame($tags, $assignedVariables['tags']);
+        static::assertArrayHasKey('source', $assignedVariables);
+        static::assertArrayHasKey('default_private_links', $assignedVariables);
+    }
+
+    /**
+     * Test displaying bookmark create form
+     * Ensure all available query parameters are handled properly.
+     */
+    public function testDisplayCreateFormWithFullParameters(): void
+    {
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $parameters = [
+            'post' => 'http://url.tld/other?part=3&utm_ad=pay#hash',
+            'title' => 'Provided Title',
+            'description' => 'Provided description.',
+            'tags' => 'abc def',
+            'private' => '1',
+            'source' => 'apps',
+        ];
+        $expectedUrl = str_replace('&utm_ad=pay', '', $parameters['post']);
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters): ?string {
+                return $parameters[$key] ?? null;
+            });
+        $response = new Response();
+
+        $result = $this->controller->displayCreateForm($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('editlink', (string) $result->getBody());
+
+        static::assertSame('Shaare - Shaarli', $assignedVariables['pagetitle']);
+
+        static::assertSame($expectedUrl, $assignedVariables['link']['url']);
+        static::assertSame($parameters['title'], $assignedVariables['link']['title']);
+        static::assertSame($parameters['description'], $assignedVariables['link']['description']);
+        static::assertSame($parameters['tags'], $assignedVariables['link']['tags']);
+        static::assertTrue($assignedVariables['link']['private']);
+        static::assertTrue($assignedVariables['link_is_new']);
+        static::assertSame($parameters['source'], $assignedVariables['source']);
+    }
+
+    /**
+     * Test displaying bookmark create form
+     * Without any parameter.
+     */
+    public function testDisplayCreateFormEmpty(): void
+    {
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->httpAccess->expects(static::never())->method('getHttpResponse');
+        $this->container->httpAccess->expects(static::never())->method('getCurlDownloadCallback');
+
+        $result = $this->controller->displayCreateForm($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('editlink', (string) $result->getBody());
+        static::assertSame('', $assignedVariables['link']['url']);
+        static::assertSame('Note: ', $assignedVariables['link']['title']);
+        static::assertSame('', $assignedVariables['link']['description']);
+        static::assertSame('', $assignedVariables['link']['tags']);
+        static::assertFalse($assignedVariables['link']['private']);
+        static::assertTrue($assignedVariables['link_is_new']);
+    }
+
+    /**
+     * Test displaying bookmark create form
+     * URL not using HTTP protocol: do not try to retrieve the title
+     */
+    public function testDisplayCreateFormNotHttp(): void
+    {
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $url = 'magnet://kubuntu.torrent';
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($url): ?string {
+                return $key === 'post' ? $url : null;
+            });
+        $response = new Response();
+
+        $this->container->httpAccess->expects(static::never())->method('getHttpResponse');
+        $this->container->httpAccess->expects(static::never())->method('getCurlDownloadCallback');
+
+        $result = $this->controller->displayCreateForm($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('editlink', (string) $result->getBody());
+        static::assertSame($url, $assignedVariables['link']['url']);
+        static::assertTrue($assignedVariables['link_is_new']);
+    }
+
+    /**
+     * Test displaying bookmark create form
+     * When markdown formatter is enabled, the no markdown tag should be added to existing tags.
+     */
+    public function testDisplayCreateFormWithMarkdownEnabled(): void
+    {
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf
+            ->expects(static::atLeastOnce())
+            ->method('get')->willReturnCallback(function (string $key): ?string {
+                if ($key === 'formatter') {
+                    return 'markdown';
+                }
+
+                return $key;
+            })
+        ;
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $result = $this->controller->displayCreateForm($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('editlink', (string) $result->getBody());
+        static::assertSame(['nomarkdown' => 1], $assignedVariables['tags']);
+    }
+
+    /**
+     * Test displaying bookmark create form
+     * When an existing URL is submitted, we want to edit the existing link.
+     */
+    public function testDisplayCreateFormWithExistingUrl(): void
+    {
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $url = 'http://url.tld/other?part=3&utm_ad=pay#hash';
+        $expectedUrl = str_replace('&utm_ad=pay', '', $url);
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($url): ?string {
+                return $key === 'post' ? $url : null;
+            });
+        $response = new Response();
+
+        $this->container->httpAccess->expects(static::never())->method('getHttpResponse');
+        $this->container->httpAccess->expects(static::never())->method('getCurlDownloadCallback');
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('findByUrl')
+            ->with($expectedUrl)
+            ->willReturn(
+                (new Bookmark())
+                    ->setId($id = 23)
+                    ->setUrl($expectedUrl)
+                    ->setTitle($title = 'Bookmark Title')
+                    ->setDescription($description = 'Bookmark description.')
+                    ->setTags($tags = ['abc', 'def'])
+                    ->setPrivate(true)
+                    ->setCreated($createdAt = new \DateTime('2020-06-10 18:45:44'))
+            )
+        ;
+
+        $result = $this->controller->displayCreateForm($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('editlink', (string) $result->getBody());
+
+        static::assertSame('Edit Shaare - Shaarli', $assignedVariables['pagetitle']);
+        static::assertFalse($assignedVariables['link_is_new']);
+
+        static::assertSame($id, $assignedVariables['link']['id']);
+        static::assertSame($expectedUrl, $assignedVariables['link']['url']);
+        static::assertSame($title, $assignedVariables['link']['title']);
+        static::assertSame($description, $assignedVariables['link']['description']);
+        static::assertSame(implode(' ', $tags), $assignedVariables['link']['tags']);
+        static::assertTrue($assignedVariables['link']['private']);
+        static::assertSame($createdAt, $assignedVariables['link']['created']);
+    }
+}
diff --git a/tests/front/controller/admin/ManageShaareControllerTest/DisplayEditFormTest.php b/tests/front/controller/admin/ManageShaareControllerTest/DisplayEditFormTest.php
new file mode 100644 (file)
index 0000000..1a1cdcf
--- /dev/null
@@ -0,0 +1,155 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin\ManageShaareControllerTest;
+
+use PHPUnit\Framework\TestCase;
+use Shaarli\Bookmark\Bookmark;
+use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
+use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
+use Shaarli\Front\Controller\Admin\ManageShaareController;
+use Shaarli\Http\HttpAccess;
+use Shaarli\Security\SessionManager;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class DisplayEditFormTest extends TestCase
+{
+    use FrontAdminControllerMockHelper;
+
+    /** @var ManageShaareController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->container->httpAccess = $this->createMock(HttpAccess::class);
+        $this->controller = new ManageShaareController($this->container);
+    }
+
+    /**
+     * Test displaying bookmark edit form
+     * When an existing ID is provided, ensure that default workflow works properly.
+     */
+    public function testDisplayEditFormDefault(): void
+    {
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $id = 11;
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->httpAccess->expects(static::never())->method('getHttpResponse');
+        $this->container->httpAccess->expects(static::never())->method('getCurlDownloadCallback');
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('get')
+            ->with($id)
+            ->willReturn(
+                (new Bookmark())
+                    ->setId($id)
+                    ->setUrl($url = 'http://domain.tld')
+                    ->setTitle($title = 'Bookmark Title')
+                    ->setDescription($description = 'Bookmark description.')
+                    ->setTags($tags = ['abc', 'def'])
+                    ->setPrivate(true)
+                    ->setCreated($createdAt = new \DateTime('2020-06-10 18:45:44'))
+            )
+        ;
+
+        $result = $this->controller->displayEditForm($request, $response, ['id' => (string) $id]);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('editlink', (string) $result->getBody());
+
+        static::assertSame('Edit Shaare - Shaarli', $assignedVariables['pagetitle']);
+        static::assertFalse($assignedVariables['link_is_new']);
+
+        static::assertSame($id, $assignedVariables['link']['id']);
+        static::assertSame($url, $assignedVariables['link']['url']);
+        static::assertSame($title, $assignedVariables['link']['title']);
+        static::assertSame($description, $assignedVariables['link']['description']);
+        static::assertSame(implode(' ', $tags), $assignedVariables['link']['tags']);
+        static::assertTrue($assignedVariables['link']['private']);
+        static::assertSame($createdAt, $assignedVariables['link']['created']);
+    }
+
+    /**
+     * Test displaying bookmark edit form
+     * Invalid ID provided.
+     */
+    public function testDisplayEditFormInvalidId(): void
+    {
+        $id = 'invalid';
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_ERROR_MESSAGES, ['Bookmark with identifier invalid could not be found.'])
+        ;
+
+        $result = $this->controller->displayEditForm($request, $response, ['id' => $id]);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    /**
+     * Test displaying bookmark edit form
+     * ID not provided.
+     */
+    public function testDisplayEditFormIdNotProvided(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_ERROR_MESSAGES, ['Bookmark with identifier  could not be found.'])
+        ;
+
+        $result = $this->controller->displayEditForm($request, $response, []);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    /**
+     * Test displaying bookmark edit form
+     * Bookmark not found.
+     */
+    public function testDisplayEditFormBookmarkNotFound(): void
+    {
+        $id = 123;
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('get')
+            ->with($id)
+            ->willThrowException(new BookmarkNotFoundException())
+        ;
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_ERROR_MESSAGES, ['Bookmark with identifier 123 could not be found.'])
+        ;
+
+        $result = $this->controller->displayEditForm($request, $response, ['id' => (string) $id]);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+}
diff --git a/tests/front/controller/admin/ManageShaareControllerTest/PinBookmarkTest.php b/tests/front/controller/admin/ManageShaareControllerTest/PinBookmarkTest.php
new file mode 100644 (file)
index 0000000..1607b47
--- /dev/null
@@ -0,0 +1,145 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin\ManageShaareControllerTest;
+
+use PHPUnit\Framework\TestCase;
+use Shaarli\Bookmark\Bookmark;
+use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
+use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
+use Shaarli\Front\Controller\Admin\ManageShaareController;
+use Shaarli\Http\HttpAccess;
+use Shaarli\Security\SessionManager;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class PinBookmarkTest extends TestCase
+{
+    use FrontAdminControllerMockHelper;
+
+    /** @var ManageShaareController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->container->httpAccess = $this->createMock(HttpAccess::class);
+        $this->controller = new ManageShaareController($this->container);
+    }
+
+    /**
+     * Test pin bookmark - with valid input
+     *
+     * @dataProvider initialStickyValuesProvider()
+     */
+    public function testPinBookmarkIsStickyNull(?bool $sticky, bool $expectedValue): void
+    {
+        $id = 123;
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $bookmark = (new Bookmark())
+            ->setId(123)
+            ->setUrl('http://domain.tld')
+            ->setTitle('Title 123')
+            ->setSticky($sticky)
+        ;
+
+        $this->container->bookmarkService->expects(static::once())->method('get')->with(123)->willReturn($bookmark);
+        $this->container->bookmarkService->expects(static::once())->method('set')->with($bookmark, true);
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::once())
+            ->method('executeHooks')
+            ->with('save_link')
+        ;
+
+        $result = $this->controller->pinBookmark($request, $response, ['id' => (string) $id]);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+
+        static::assertSame($expectedValue, $bookmark->isSticky());
+    }
+
+    public function initialStickyValuesProvider(): array
+    {
+        // [initialStickyState, isStickyAfterPin]
+        return [[null, true], [false, true], [true, false]];
+    }
+
+    /**
+     * Test pin bookmark - invalid bookmark ID
+     */
+    public function testDisplayEditFormInvalidId(): void
+    {
+        $id = 'invalid';
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_ERROR_MESSAGES, ['Bookmark with identifier invalid could not be found.'])
+        ;
+
+        $result = $this->controller->pinBookmark($request, $response, ['id' => $id]);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    /**
+     * Test pin bookmark - Bookmark ID not provided
+     */
+    public function testDisplayEditFormIdNotProvided(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_ERROR_MESSAGES, ['Bookmark with identifier  could not be found.'])
+        ;
+
+        $result = $this->controller->pinBookmark($request, $response, []);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    /**
+     * Test pin bookmark - bookmark not found
+     */
+    public function testDisplayEditFormBookmarkNotFound(): void
+    {
+        $id = 123;
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('get')
+            ->with($id)
+            ->willThrowException(new BookmarkNotFoundException())
+        ;
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_ERROR_MESSAGES, ['Bookmark with identifier 123 could not be found.'])
+        ;
+
+        $result = $this->controller->pinBookmark($request, $response, ['id' => (string) $id]);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+}
diff --git a/tests/front/controller/admin/ManageShaareControllerTest/SaveBookmarkTest.php b/tests/front/controller/admin/ManageShaareControllerTest/SaveBookmarkTest.php
new file mode 100644 (file)
index 0000000..dabcd60
--- /dev/null
@@ -0,0 +1,282 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin\ManageShaareControllerTest;
+
+use PHPUnit\Framework\TestCase;
+use Shaarli\Bookmark\Bookmark;
+use Shaarli\Config\ConfigManager;
+use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
+use Shaarli\Front\Controller\Admin\ManageShaareController;
+use Shaarli\Front\Exception\WrongTokenException;
+use Shaarli\Http\HttpAccess;
+use Shaarli\Security\SessionManager;
+use Shaarli\Thumbnailer;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class SaveBookmarkTest extends TestCase
+{
+    use FrontAdminControllerMockHelper;
+
+    /** @var ManageShaareController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->container->httpAccess = $this->createMock(HttpAccess::class);
+        $this->controller = new ManageShaareController($this->container);
+    }
+
+    /**
+     * Test save a new bookmark
+     */
+    public function testSaveBookmark(): void
+    {
+        $id = 21;
+        $parameters = [
+            'lf_url' => 'http://url.tld/other?part=3#hash',
+            'lf_title' => 'Provided Title',
+            'lf_description' => 'Provided description.',
+            'lf_tags' => 'abc def',
+            'lf_private' => '1',
+            'returnurl' => 'http://shaarli.tld/subfolder/admin/add-shaare'
+        ];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters): ?string {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $checkBookmark = function (Bookmark $bookmark) use ($parameters) {
+            static::assertSame($parameters['lf_url'], $bookmark->getUrl());
+            static::assertSame($parameters['lf_title'], $bookmark->getTitle());
+            static::assertSame($parameters['lf_description'], $bookmark->getDescription());
+            static::assertSame($parameters['lf_tags'], $bookmark->getTagsString());
+            static::assertTrue($bookmark->isPrivate());
+        };
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('addOrSet')
+            ->willReturnCallback(function (Bookmark $bookmark, bool $save) use ($checkBookmark, $id): void {
+                static::assertFalse($save);
+
+                $checkBookmark($bookmark);
+
+                $bookmark->setId($id);
+            })
+        ;
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('set')
+            ->willReturnCallback(function (Bookmark $bookmark, bool $save) use ($checkBookmark, $id): void {
+                static::assertTrue($save);
+
+                $checkBookmark($bookmark);
+
+                static::assertSame($id, $bookmark->getId());
+            })
+        ;
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::at(0))
+            ->method('executeHooks')
+            ->willReturnCallback(function (string $hook, array $data) use ($parameters, $id): array {
+                static::assertSame('save_link', $hook);
+
+                static::assertSame($id, $data['id']);
+                static::assertSame($parameters['lf_url'], $data['url']);
+                static::assertSame($parameters['lf_title'], $data['title']);
+                static::assertSame($parameters['lf_description'], $data['description']);
+                static::assertSame($parameters['lf_tags'], $data['tags']);
+                static::assertTrue($data['private']);
+
+                return $data;
+            })
+        ;
+
+        $result = $this->controller->save($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertRegExp('@/subfolder/#[\w\-]{6}@', $result->getHeader('location')[0]);
+    }
+
+
+    /**
+     * Test save an existing bookmark
+     */
+    public function testSaveExistingBookmark(): void
+    {
+        $id = 21;
+        $parameters = [
+            'lf_id' => (string) $id,
+            'lf_url' => 'http://url.tld/other?part=3#hash',
+            'lf_title' => 'Provided Title',
+            'lf_description' => 'Provided description.',
+            'lf_tags' => 'abc def',
+            'lf_private' => '1',
+            'returnurl' => 'http://shaarli.tld/subfolder/?page=2'
+        ];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters): ?string {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $checkBookmark = function (Bookmark $bookmark) use ($parameters, $id) {
+            static::assertSame($id, $bookmark->getId());
+            static::assertSame($parameters['lf_url'], $bookmark->getUrl());
+            static::assertSame($parameters['lf_title'], $bookmark->getTitle());
+            static::assertSame($parameters['lf_description'], $bookmark->getDescription());
+            static::assertSame($parameters['lf_tags'], $bookmark->getTagsString());
+            static::assertTrue($bookmark->isPrivate());
+        };
+
+        $this->container->bookmarkService->expects(static::atLeastOnce())->method('exists')->willReturn(true);
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('get')
+            ->willReturn((new Bookmark())->setId($id)->setUrl('http://other.url'))
+        ;
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('addOrSet')
+            ->willReturnCallback(function (Bookmark $bookmark, bool $save) use ($checkBookmark, $id): void {
+                static::assertFalse($save);
+
+                $checkBookmark($bookmark);
+            })
+        ;
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('set')
+            ->willReturnCallback(function (Bookmark $bookmark, bool $save) use ($checkBookmark, $id): void {
+                static::assertTrue($save);
+
+                $checkBookmark($bookmark);
+
+                static::assertSame($id, $bookmark->getId());
+            })
+        ;
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::at(0))
+            ->method('executeHooks')
+            ->willReturnCallback(function (string $hook, array $data) use ($parameters, $id): array {
+                static::assertSame('save_link', $hook);
+
+                static::assertSame($id, $data['id']);
+                static::assertSame($parameters['lf_url'], $data['url']);
+                static::assertSame($parameters['lf_title'], $data['title']);
+                static::assertSame($parameters['lf_description'], $data['description']);
+                static::assertSame($parameters['lf_tags'], $data['tags']);
+                static::assertTrue($data['private']);
+
+                return $data;
+            })
+        ;
+
+        $result = $this->controller->save($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertRegExp('@/subfolder/\?page=2#[\w\-]{6}@', $result->getHeader('location')[0]);
+    }
+
+    /**
+     * Test save a bookmark - try to retrieve the thumbnail
+     */
+    public function testSaveBookmarkWithThumbnail(): void
+    {
+        $parameters = ['lf_url' => 'http://url.tld/other?part=3#hash'];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters): ?string {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf->method('get')->willReturnCallback(function (string $key, $default) {
+            return $key === 'thumbnails.mode' ? Thumbnailer::MODE_ALL : $default;
+        });
+
+        $this->container->thumbnailer = $this->createMock(Thumbnailer::class);
+        $this->container->thumbnailer
+            ->expects(static::once())
+            ->method('get')
+            ->with($parameters['lf_url'])
+            ->willReturn($thumb = 'http://thumb.url')
+        ;
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('addOrSet')
+            ->willReturnCallback(function (Bookmark $bookmark, bool $save) use ($thumb): void {
+                static::assertSame($thumb, $bookmark->getThumbnail());
+            })
+        ;
+
+        $result = $this->controller->save($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+    }
+
+    /**
+     * Change the password with a wrong existing password
+     */
+    public function testSaveBookmarkFromBookmarklet(): void
+    {
+        $parameters = ['source' => 'bookmarklet'];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters): ?string {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $result = $this->controller->save($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('<script>self.close();</script>', (string) $result->getBody());
+    }
+
+    /**
+     * Change the password with a wrong existing password
+     */
+    public function testSaveBookmarkWrongToken(): void
+    {
+        $this->container->sessionManager = $this->createMock(SessionManager::class);
+        $this->container->sessionManager->method('checkToken')->willReturn(false);
+
+        $this->container->bookmarkService->expects(static::never())->method('addOrSet');
+        $this->container->bookmarkService->expects(static::never())->method('set');
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->expectException(WrongTokenException::class);
+
+        $this->controller->save($request, $response);
+    }
+
+}
diff --git a/tests/front/controller/admin/ManageTagControllerTest.php b/tests/front/controller/admin/ManageTagControllerTest.php
new file mode 100644 (file)
index 0000000..09ba0b4
--- /dev/null
@@ -0,0 +1,272 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use PHPUnit\Framework\TestCase;
+use Shaarli\Bookmark\Bookmark;
+use Shaarli\Bookmark\BookmarkFilter;
+use Shaarli\Front\Exception\WrongTokenException;
+use Shaarli\Security\SessionManager;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class ManageTagControllerTest extends TestCase
+{
+    use FrontAdminControllerMockHelper;
+
+    /** @var ManageTagController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->controller = new ManageTagController($this->container);
+    }
+
+    /**
+     * Test displaying manage tag page
+     */
+    public function testIndex(): void
+    {
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $request = $this->createMock(Request::class);
+        $request->method('getParam')->with('fromtag')->willReturn('fromtag');
+        $response = new Response();
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('changetag', (string) $result->getBody());
+
+        static::assertSame('fromtag', $assignedVariables['fromtag']);
+        static::assertSame('Manage tags - Shaarli', $assignedVariables['pagetitle']);
+    }
+
+    /**
+     * Test posting a tag update - rename tag - valid info provided.
+     */
+    public function testSaveRenameTagValid(): void
+    {
+        $session = [];
+        $this->assignSessionVars($session);
+
+        $requestParameters = [
+            'renametag' => 'rename',
+            'fromtag' => 'old-tag',
+            'totag' => 'new-tag',
+        ];
+        $request = $this->createMock(Request::class);
+        $request
+            ->expects(static::atLeastOnce())
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($requestParameters): ?string {
+                return $requestParameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $bookmark1 = $this->createMock(Bookmark::class);
+        $bookmark2 = $this->createMock(Bookmark::class);
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('search')
+            ->with(['searchtags' => 'old-tag'], BookmarkFilter::$ALL, true)
+            ->willReturnCallback(function () use ($bookmark1, $bookmark2): array {
+                $bookmark1->expects(static::once())->method('renameTag')->with('old-tag', 'new-tag');
+                $bookmark2->expects(static::once())->method('renameTag')->with('old-tag', 'new-tag');
+
+                return [$bookmark1, $bookmark2];
+            })
+        ;
+        $this->container->bookmarkService
+            ->expects(static::exactly(2))
+            ->method('set')
+            ->withConsecutive([$bookmark1, false], [$bookmark2, false])
+        ;
+        $this->container->bookmarkService->expects(static::once())->method('save');
+
+        $result = $this->controller->save($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/?searchtags=new-tag'], $result->getHeader('location'));
+
+        static::assertArrayNotHasKey(SessionManager::KEY_ERROR_MESSAGES, $session);
+        static::assertArrayNotHasKey(SessionManager::KEY_WARNING_MESSAGES, $session);
+        static::assertArrayHasKey(SessionManager::KEY_SUCCESS_MESSAGES, $session);
+        static::assertSame(['The tag was renamed in 2 bookmarks.'], $session[SessionManager::KEY_SUCCESS_MESSAGES]);
+    }
+
+    /**
+     * Test posting a tag update - delete tag - valid info provided.
+     */
+    public function testSaveDeleteTagValid(): void
+    {
+        $session = [];
+        $this->assignSessionVars($session);
+
+        $requestParameters = [
+            'deletetag' => 'delete',
+            'fromtag' => 'old-tag',
+        ];
+        $request = $this->createMock(Request::class);
+        $request
+            ->expects(static::atLeastOnce())
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($requestParameters): ?string {
+                return $requestParameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $bookmark1 = $this->createMock(Bookmark::class);
+        $bookmark2 = $this->createMock(Bookmark::class);
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('search')
+            ->with(['searchtags' => 'old-tag'], BookmarkFilter::$ALL, true)
+            ->willReturnCallback(function () use ($bookmark1, $bookmark2): array {
+                $bookmark1->expects(static::once())->method('deleteTag')->with('old-tag');
+                $bookmark2->expects(static::once())->method('deleteTag')->with('old-tag');
+
+                return [$bookmark1, $bookmark2];
+            })
+        ;
+        $this->container->bookmarkService
+            ->expects(static::exactly(2))
+            ->method('set')
+            ->withConsecutive([$bookmark1, false], [$bookmark2, false])
+        ;
+        $this->container->bookmarkService->expects(static::once())->method('save');
+
+        $result = $this->controller->save($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/admin/tags'], $result->getHeader('location'));
+
+        static::assertArrayNotHasKey(SessionManager::KEY_ERROR_MESSAGES, $session);
+        static::assertArrayNotHasKey(SessionManager::KEY_WARNING_MESSAGES, $session);
+        static::assertArrayHasKey(SessionManager::KEY_SUCCESS_MESSAGES, $session);
+        static::assertSame(['The tag was removed from 2 bookmarks.'], $session[SessionManager::KEY_SUCCESS_MESSAGES]);
+    }
+
+    /**
+     * Test posting a tag update - wrong token.
+     */
+    public function testSaveWrongToken(): void
+    {
+        $this->container->sessionManager = $this->createMock(SessionManager::class);
+        $this->container->sessionManager->method('checkToken')->willReturn(false);
+
+        $this->container->conf->expects(static::never())->method('set');
+        $this->container->conf->expects(static::never())->method('write');
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->expectException(WrongTokenException::class);
+
+        $this->controller->save($request, $response);
+    }
+
+    /**
+     * Test posting a tag update - rename tag - missing "FROM" tag.
+     */
+    public function testSaveRenameTagMissingFrom(): void
+    {
+        $session = [];
+        $this->assignSessionVars($session);
+
+        $requestParameters = [
+            'renametag' => 'rename',
+        ];
+        $request = $this->createMock(Request::class);
+        $request
+            ->expects(static::atLeastOnce())
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($requestParameters): ?string {
+                return $requestParameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $result = $this->controller->save($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/admin/tags'], $result->getHeader('location'));
+
+        static::assertArrayNotHasKey(SessionManager::KEY_ERROR_MESSAGES, $session);
+        static::assertArrayHasKey(SessionManager::KEY_WARNING_MESSAGES, $session);
+        static::assertArrayNotHasKey(SessionManager::KEY_SUCCESS_MESSAGES, $session);
+        static::assertSame(['Invalid tags provided.'], $session[SessionManager::KEY_WARNING_MESSAGES]);
+    }
+
+    /**
+     * Test posting a tag update - delete tag - missing "FROM" tag.
+     */
+    public function testSaveDeleteTagMissingFrom(): void
+    {
+        $session = [];
+        $this->assignSessionVars($session);
+
+        $requestParameters = [
+            'deletetag' => 'delete',
+        ];
+        $request = $this->createMock(Request::class);
+        $request
+            ->expects(static::atLeastOnce())
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($requestParameters): ?string {
+                return $requestParameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $result = $this->controller->save($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/admin/tags'], $result->getHeader('location'));
+
+        static::assertArrayNotHasKey(SessionManager::KEY_ERROR_MESSAGES, $session);
+        static::assertArrayHasKey(SessionManager::KEY_WARNING_MESSAGES, $session);
+        static::assertArrayNotHasKey(SessionManager::KEY_SUCCESS_MESSAGES, $session);
+        static::assertSame(['Invalid tags provided.'], $session[SessionManager::KEY_WARNING_MESSAGES]);
+    }
+
+    /**
+     * Test posting a tag update - rename tag - missing "TO" tag.
+     */
+    public function testSaveRenameTagMissingTo(): void
+    {
+        $session = [];
+        $this->assignSessionVars($session);
+
+        $requestParameters = [
+            'renametag' => 'rename',
+            'fromtag' => 'old-tag'
+        ];
+        $request = $this->createMock(Request::class);
+        $request
+            ->expects(static::atLeastOnce())
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($requestParameters): ?string {
+                return $requestParameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $result = $this->controller->save($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/admin/tags'], $result->getHeader('location'));
+
+        static::assertArrayNotHasKey(SessionManager::KEY_ERROR_MESSAGES, $session);
+        static::assertArrayHasKey(SessionManager::KEY_WARNING_MESSAGES, $session);
+        static::assertArrayNotHasKey(SessionManager::KEY_SUCCESS_MESSAGES, $session);
+        static::assertSame(['Invalid tags provided.'], $session[SessionManager::KEY_WARNING_MESSAGES]);
+    }
+}
diff --git a/tests/front/controller/admin/PasswordControllerTest.php b/tests/front/controller/admin/PasswordControllerTest.php
new file mode 100644 (file)
index 0000000..9a01089
--- /dev/null
@@ -0,0 +1,203 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use PHPUnit\Framework\TestCase;
+use Shaarli\Config\ConfigManager;
+use Shaarli\Front\Exception\OpenShaarliPasswordException;
+use Shaarli\Front\Exception\WrongTokenException;
+use Shaarli\Security\SessionManager;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class PasswordControllerTest extends TestCase
+{
+    use FrontAdminControllerMockHelper;
+
+    /** @var PasswordController */
+    protected $controller;
+
+    /** @var mixed[] Variables assigned to the template */
+    protected $assignedVariables = [];
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+        $this->assignTemplateVars($this->assignedVariables);
+
+        $this->controller = new PasswordController($this->container);
+    }
+
+    /**
+     * Test displaying the change password page.
+     */
+    public function testGetPage(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('changepassword', (string) $result->getBody());
+        static::assertSame('Change password - Shaarli', $this->assignedVariables['pagetitle']);
+    }
+
+    /**
+     * Change the password with valid parameters
+     */
+    public function testPostNewPasswordDefault(): void
+    {
+        $request = $this->createMock(Request::class);
+        $request->method('getParam')->willReturnCallback(function (string $key): string {
+             if ('oldpassword' === $key) {
+                 return 'old';
+             }
+             if ('setpassword' === $key) {
+                 return 'new';
+             }
+
+             return $key;
+        });
+        $response = new Response();
+
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf->method('get')->willReturnCallback(function (string $key, $default) {
+            if ('credentials.hash' === $key) {
+                return sha1('old' . 'credentials.login' . 'credentials.salt');
+            }
+
+            return strpos($key, 'credentials') !== false ? $key : $default;
+        });
+        $this->container->conf->expects(static::once())->method('write')->with(true);
+
+        $this->container->conf
+            ->method('set')
+            ->willReturnCallback(function (string $key, string $value) {
+                if ('credentials.hash' === $key) {
+                    static::assertSame(sha1('new' . 'credentials.login' . 'credentials.salt'), $value);
+                }
+            })
+        ;
+
+        $result = $this->controller->change($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('changepassword', (string) $result->getBody());
+        static::assertSame('Change password - Shaarli', $this->assignedVariables['pagetitle']);
+    }
+
+    /**
+     * Change the password with a wrong existing password
+     */
+    public function testPostNewPasswordWrongOldPassword(): void
+    {
+        $request = $this->createMock(Request::class);
+        $request->method('getParam')->willReturnCallback(function (string $key): string {
+            if ('oldpassword' === $key) {
+                return 'wrong';
+            }
+            if ('setpassword' === $key) {
+                return 'new';
+            }
+
+            return $key;
+        });
+        $response = new Response();
+
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf->method('get')->willReturnCallback(function (string $key, $default) {
+            if ('credentials.hash' === $key) {
+                return sha1('old' . 'credentials.login' . 'credentials.salt');
+            }
+
+            return strpos($key, 'credentials') !== false ? $key : $default;
+        });
+
+        $this->container->conf->expects(static::never())->method('set');
+        $this->container->conf->expects(static::never())->method('write');
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_ERROR_MESSAGES, ['The old password is not correct.'])
+        ;
+
+        $result = $this->controller->change($request, $response);
+
+        static::assertSame(400, $result->getStatusCode());
+        static::assertSame('changepassword', (string) $result->getBody());
+        static::assertSame('Change password - Shaarli', $this->assignedVariables['pagetitle']);
+    }
+
+    /**
+     * Change the password with a wrong existing password
+     */
+    public function testPostNewPasswordWrongToken(): void
+    {
+        $this->container->sessionManager = $this->createMock(SessionManager::class);
+        $this->container->sessionManager->method('checkToken')->willReturn(false);
+
+        $this->container->conf->expects(static::never())->method('set');
+        $this->container->conf->expects(static::never())->method('write');
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->expectException(WrongTokenException::class);
+
+        $this->controller->change($request, $response);
+    }
+
+    /**
+     * Change the password with an empty new password
+     */
+    public function testPostNewEmptyPassword(): void
+    {
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_ERROR_MESSAGES, ['You must provide the current and new password to change it.'])
+        ;
+
+        $this->container->conf->expects(static::never())->method('set');
+        $this->container->conf->expects(static::never())->method('write');
+
+        $request = $this->createMock(Request::class);
+        $request->method('getParam')->willReturnCallback(function (string $key): string {
+            if ('oldpassword' === $key) {
+                return 'old';
+            }
+            if ('setpassword' === $key) {
+                return '';
+            }
+
+            return $key;
+        });
+        $response = new Response();
+
+        $result = $this->controller->change($request, $response);
+
+        static::assertSame(400, $result->getStatusCode());
+        static::assertSame('changepassword', (string) $result->getBody());
+        static::assertSame('Change password - Shaarli', $this->assignedVariables['pagetitle']);
+    }
+
+    /**
+     * Change the password on an open shaarli
+     */
+    public function testPostNewPasswordOnOpenShaarli(): void
+    {
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf->method('get')->with('security.open_shaarli')->willReturn(true);
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->expectException(OpenShaarliPasswordException::class);
+
+        $this->controller->change($request, $response);
+    }
+}
diff --git a/tests/front/controller/admin/PluginsControllerTest.php b/tests/front/controller/admin/PluginsControllerTest.php
new file mode 100644 (file)
index 0000000..5b59285
--- /dev/null
@@ -0,0 +1,204 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use PHPUnit\Framework\TestCase;
+use Shaarli\Config\ConfigManager;
+use Shaarli\Front\Exception\WrongTokenException;
+use Shaarli\Plugin\PluginManager;
+use Shaarli\Security\SessionManager;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class PluginsControllerTest extends TestCase
+{
+    use FrontAdminControllerMockHelper;
+
+    const PLUGIN_NAMES = ['plugin1', 'plugin2', 'plugin3', 'plugin4'];
+
+    /** @var PluginsController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->controller = new PluginsController($this->container);
+
+        mkdir($path = __DIR__ . '/folder');
+        PluginManager::$PLUGINS_PATH = $path;
+        array_map(function (string $plugin) use ($path) { touch($path . '/' . $plugin); }, static::PLUGIN_NAMES);
+    }
+
+    public function tearDown()
+    {
+        $path = __DIR__ . '/folder';
+        array_map(function (string $plugin) use ($path) { unlink($path . '/' . $plugin); }, static::PLUGIN_NAMES);
+        rmdir($path);
+    }
+
+    /**
+     * Test displaying plugins admin page
+     */
+    public function testIndex(): void
+    {
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $data = [
+            'plugin1' => ['order' => 2, 'other' => 'field'],
+            'plugin2' => ['order' => 1],
+            'plugin3' => ['order' => false, 'abc' => 'def'],
+            'plugin4' => [],
+        ];
+
+        $this->container->pluginManager
+            ->expects(static::once())
+            ->method('getPluginsMeta')
+            ->willReturn($data);
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('pluginsadmin', (string) $result->getBody());
+
+        static::assertSame('Plugin Administration - Shaarli', $assignedVariables['pagetitle']);
+        static::assertSame(
+            ['plugin2' => $data['plugin2'], 'plugin1' => $data['plugin1']],
+            $assignedVariables['enabledPlugins']
+        );
+        static::assertSame(
+            ['plugin3' => $data['plugin3'], 'plugin4' => $data['plugin4']],
+            $assignedVariables['disabledPlugins']
+        );
+    }
+
+    /**
+     * Test save plugins admin page
+     */
+    public function testSaveEnabledPlugins(): void
+    {
+        $parameters = [
+            'plugin1' => 'on',
+            'order_plugin1' => '2',
+            'plugin2' => 'on',
+        ];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->expects(static::atLeastOnce())
+            ->method('getParams')
+            ->willReturnCallback(function () use ($parameters): array {
+                return $parameters;
+            })
+        ;
+        $response = new Response();
+
+        $this->container->pluginManager
+            ->expects(static::once())
+            ->method('executeHooks')
+            ->with('save_plugin_parameters', $parameters)
+        ;
+        $this->container->conf
+            ->expects(static::atLeastOnce())
+            ->method('set')
+            ->with('general.enabled_plugins', ['plugin1', 'plugin2'])
+        ;
+
+        $result = $this->controller->save($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/admin/plugins'], $result->getHeader('location'));
+    }
+
+    /**
+     * Test save plugin parameters
+     */
+    public function testSavePluginParameters(): void
+    {
+        $parameters = [
+            'parameters_form' => true,
+            'parameter1' => 'blip',
+            'parameter2' => 'blop',
+        ];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->expects(static::atLeastOnce())
+            ->method('getParams')
+            ->willReturnCallback(function () use ($parameters): array {
+                return $parameters;
+            })
+        ;
+        $response = new Response();
+
+        $this->container->pluginManager
+            ->expects(static::once())
+            ->method('executeHooks')
+            ->with('save_plugin_parameters', $parameters)
+        ;
+        $this->container->conf
+            ->expects(static::atLeastOnce())
+            ->method('set')
+            ->withConsecutive(['plugins.parameter1', 'blip'], ['plugins.parameter2', 'blop'])
+        ;
+
+        $result = $this->controller->save($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/admin/plugins'], $result->getHeader('location'));
+    }
+
+    /**
+     * Test save plugin parameters - error encountered
+     */
+    public function testSaveWithError(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf
+            ->expects(static::atLeastOnce())
+            ->method('write')
+            ->willThrowException(new \Exception($message = 'error message'))
+        ;
+
+        $this->container->sessionManager = $this->createMock(SessionManager::class);
+        $this->container->sessionManager->method('checkToken')->willReturn(true);
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(
+                SessionManager::KEY_ERROR_MESSAGES,
+                ['Error while saving plugin configuration: ' . PHP_EOL . $message]
+            )
+        ;
+
+        $result = $this->controller->save($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/admin/plugins'], $result->getHeader('location'));
+    }
+
+    /**
+     * Test save plugin parameters - wrong token
+     */
+    public function testSaveWrongToken(): void
+    {
+        $this->container->sessionManager = $this->createMock(SessionManager::class);
+        $this->container->sessionManager->method('checkToken')->willReturn(false);
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->expectException(WrongTokenException::class);
+
+        $this->controller->save($request, $response);
+    }
+}
diff --git a/tests/front/controller/admin/SessionFilterControllerTest.php b/tests/front/controller/admin/SessionFilterControllerTest.php
new file mode 100644 (file)
index 0000000..d306c6e
--- /dev/null
@@ -0,0 +1,177 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use PHPUnit\Framework\TestCase;
+use Shaarli\Security\LoginManager;
+use Shaarli\Security\SessionManager;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class SessionFilterControllerTest extends TestCase
+{
+    use FrontAdminControllerMockHelper;
+
+    /** @var SessionFilterController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->controller = new SessionFilterController($this->container);
+    }
+
+    /**
+     * Visibility - Default call for private filter while logged in without current value
+     */
+    public function testVisibility(): void
+    {
+        $arg = ['visibility' => 'private'];
+
+        $this->container->environment = ['HTTP_REFERER' => 'http://shaarli/subfolder/controller/?searchtag=abc'];
+
+        $this->container->loginManager->method('isLoggedIn')->willReturn(true);
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_VISIBILITY, 'private')
+        ;
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $result = $this->controller->visibility($request, $response, $arg);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/controller/?searchtag=abc'], $result->getHeader('location'));
+    }
+
+    /**
+     * Visibility - Toggle off private visibility
+     */
+    public function testVisibilityToggleOff(): void
+    {
+        $arg = ['visibility' => 'private'];
+
+        $this->container->environment = ['HTTP_REFERER' => 'http://shaarli/subfolder/controller/?searchtag=abc'];
+
+        $this->container->loginManager->method('isLoggedIn')->willReturn(true);
+        $this->container->sessionManager
+            ->method('getSessionParameter')
+            ->with(SessionManager::KEY_VISIBILITY)
+            ->willReturn('private')
+        ;
+        $this->container->sessionManager
+            ->expects(static::never())
+            ->method('setSessionParameter')
+        ;
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('deleteSessionParameter')
+            ->with(SessionManager::KEY_VISIBILITY)
+        ;
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $result = $this->controller->visibility($request, $response, $arg);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/controller/?searchtag=abc'], $result->getHeader('location'));
+    }
+
+    /**
+     * Visibility - Change private to public
+     */
+    public function testVisibilitySwitch(): void
+    {
+        $arg = ['visibility' => 'private'];
+
+        $this->container->loginManager->method('isLoggedIn')->willReturn(true);
+        $this->container->sessionManager
+            ->method('getSessionParameter')
+            ->with(SessionManager::KEY_VISIBILITY)
+            ->willReturn('public')
+        ;
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_VISIBILITY, 'private')
+        ;
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $result = $this->controller->visibility($request, $response, $arg);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    /**
+     * Visibility - With invalid value - should remove any visibility setting
+     */
+    public function testVisibilityInvalidValue(): void
+    {
+        $arg = ['visibility' => 'test'];
+
+        $this->container->environment = ['HTTP_REFERER' => 'http://shaarli/subfolder/controller/?searchtag=abc'];
+
+        $this->container->loginManager->method('isLoggedIn')->willReturn(true);
+        $this->container->sessionManager
+            ->expects(static::never())
+            ->method('setSessionParameter')
+        ;
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('deleteSessionParameter')
+            ->with(SessionManager::KEY_VISIBILITY)
+        ;
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $result = $this->controller->visibility($request, $response, $arg);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/controller/?searchtag=abc'], $result->getHeader('location'));
+    }
+
+    /**
+     * Visibility - Try to change visibility while logged out
+     */
+    public function testVisibilityLoggedOut(): void
+    {
+        $arg = ['visibility' => 'test'];
+
+        $this->container->environment = ['HTTP_REFERER' => 'http://shaarli/subfolder/controller/?searchtag=abc'];
+
+        $this->container->loginManager = $this->createMock(LoginManager::class);
+        $this->container->loginManager->method('isLoggedIn')->willReturn(false);
+        $this->container->sessionManager
+            ->expects(static::never())
+            ->method('setSessionParameter')
+        ;
+        $this->container->sessionManager
+            ->expects(static::never())
+            ->method('deleteSessionParameter')
+            ->with(SessionManager::KEY_VISIBILITY)
+        ;
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $result = $this->controller->visibility($request, $response, $arg);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/controller/?searchtag=abc'], $result->getHeader('location'));
+    }
+}
diff --git a/tests/front/controller/admin/ShaarliAdminControllerTest.php b/tests/front/controller/admin/ShaarliAdminControllerTest.php
new file mode 100644 (file)
index 0000000..fff427c
--- /dev/null
@@ -0,0 +1,184 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use PHPUnit\Framework\TestCase;
+use Shaarli\Front\Exception\WrongTokenException;
+use Shaarli\Security\SessionManager;
+use Slim\Http\Request;
+
+/**
+ * Class ShaarliControllerTest
+ *
+ * This class is used to test default behavior of ShaarliAdminController abstract class.
+ * It uses a dummy non abstract controller.
+ */
+class ShaarliAdminControllerTest extends TestCase
+{
+    use FrontAdminControllerMockHelper;
+
+    /** @var ShaarliAdminController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->controller = new class($this->container) extends ShaarliAdminController
+        {
+            public function checkToken(Request $request): bool
+            {
+                return parent::checkToken($request);
+            }
+
+            public function saveSuccessMessage(string $message): void
+            {
+                parent::saveSuccessMessage($message);
+            }
+
+            public function saveWarningMessage(string $message): void
+            {
+                parent::saveWarningMessage($message);
+            }
+
+            public function saveErrorMessage(string $message): void
+            {
+                parent::saveErrorMessage($message);
+            }
+        };
+    }
+
+    /**
+     * Trigger controller's checkToken with a valid token.
+     */
+    public function testCheckTokenWithValidToken(): void
+    {
+        $request = $this->createMock(Request::class);
+        $request->method('getParam')->with('token')->willReturn($token = '12345');
+
+        $this->container->sessionManager = $this->createMock(SessionManager::class);
+        $this->container->sessionManager->method('checkToken')->with($token)->willReturn(true);
+
+        static::assertTrue($this->controller->checkToken($request));
+    }
+
+    /**
+     * Trigger controller's checkToken with na valid token should raise an exception.
+     */
+    public function testCheckTokenWithNotValidToken(): void
+    {
+        $request = $this->createMock(Request::class);
+        $request->method('getParam')->with('token')->willReturn($token = '12345');
+
+        $this->container->sessionManager = $this->createMock(SessionManager::class);
+        $this->container->sessionManager->method('checkToken')->with($token)->willReturn(false);
+
+        $this->expectException(WrongTokenException::class);
+
+        $this->controller->checkToken($request);
+    }
+
+    /**
+     * Test saveSuccessMessage() with a first message.
+     */
+    public function testSaveSuccessMessage(): void
+    {
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_SUCCESS_MESSAGES, [$message = 'bravo!'])
+        ;
+
+        $this->controller->saveSuccessMessage($message);
+    }
+
+    /**
+     * Test saveSuccessMessage() with existing messages.
+     */
+    public function testSaveSuccessMessageWithExistingMessages(): void
+    {
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('getSessionParameter')
+            ->with(SessionManager::KEY_SUCCESS_MESSAGES)
+            ->willReturn(['success1', 'success2'])
+        ;
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_SUCCESS_MESSAGES, ['success1', 'success2', $message = 'bravo!'])
+        ;
+
+        $this->controller->saveSuccessMessage($message);
+    }
+
+    /**
+     * Test saveWarningMessage() with a first message.
+     */
+    public function testSaveWarningMessage(): void
+    {
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_WARNING_MESSAGES, [$message = 'warning!'])
+        ;
+
+        $this->controller->saveWarningMessage($message);
+    }
+
+    /**
+     * Test saveWarningMessage() with existing messages.
+     */
+    public function testSaveWarningMessageWithExistingMessages(): void
+    {
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('getSessionParameter')
+            ->with(SessionManager::KEY_WARNING_MESSAGES)
+            ->willReturn(['warning1', 'warning2'])
+        ;
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_WARNING_MESSAGES, ['warning1', 'warning2', $message = 'warning!'])
+        ;
+
+        $this->controller->saveWarningMessage($message);
+    }
+
+    /**
+     * Test saveErrorMessage() with a first message.
+     */
+    public function testSaveErrorMessage(): void
+    {
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_ERROR_MESSAGES, [$message = 'error!'])
+        ;
+
+        $this->controller->saveErrorMessage($message);
+    }
+
+    /**
+     * Test saveErrorMessage() with existing messages.
+     */
+    public function testSaveErrorMessageWithExistingMessages(): void
+    {
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('getSessionParameter')
+            ->with(SessionManager::KEY_ERROR_MESSAGES)
+            ->willReturn(['error1', 'error2'])
+        ;
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_ERROR_MESSAGES, ['error1', 'error2', $message = 'error!'])
+        ;
+
+        $this->controller->saveErrorMessage($message);
+    }
+}
diff --git a/tests/front/controller/admin/ThumbnailsControllerTest.php b/tests/front/controller/admin/ThumbnailsControllerTest.php
new file mode 100644 (file)
index 0000000..0c0c8a8
--- /dev/null
@@ -0,0 +1,154 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use PHPUnit\Framework\TestCase;
+use Shaarli\Bookmark\Bookmark;
+use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
+use Shaarli\Thumbnailer;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class ThumbnailsControllerTest extends TestCase
+{
+    use FrontAdminControllerMockHelper;
+
+    /** @var ThumbnailsController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->controller = new ThumbnailsController($this->container);
+    }
+
+    /**
+     * Test displaying the thumbnails update page
+     * Note that only non-note and HTTP bookmarks should be returned.
+     */
+    public function testIndex(): void
+    {
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('search')
+            ->willReturn([
+                (new Bookmark())->setId(1)->setUrl('http://url1.tld')->setTitle('Title 1'),
+                (new Bookmark())->setId(2)->setUrl('?abcdef')->setTitle('Note 1'),
+                (new Bookmark())->setId(3)->setUrl('http://url2.tld')->setTitle('Title 2'),
+                (new Bookmark())->setId(4)->setUrl('ftp://domain.tld', ['ftp'])->setTitle('FTP'),
+            ])
+        ;
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('thumbnails', (string) $result->getBody());
+
+        static::assertSame('Thumbnails update - Shaarli', $assignedVariables['pagetitle']);
+        static::assertSame([1, 3], $assignedVariables['ids']);
+    }
+
+    /**
+     * Test updating a bookmark thumbnail with valid parameters
+     */
+    public function testAjaxUpdateValid(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $bookmark = (new Bookmark())
+            ->setId($id = 123)
+            ->setUrl($url = 'http://url1.tld')
+            ->setTitle('Title 1')
+            ->setThumbnail(false)
+        ;
+
+        $this->container->thumbnailer = $this->createMock(Thumbnailer::class);
+        $this->container->thumbnailer
+            ->expects(static::once())
+            ->method('get')
+            ->with($url)
+            ->willReturn($thumb = 'http://img.tld/pic.png')
+        ;
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('get')
+            ->with($id)
+            ->willReturn($bookmark)
+        ;
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('set')
+            ->willReturnCallback(function (Bookmark $bookmark) use ($thumb) {
+                static::assertSame($thumb, $bookmark->getThumbnail());
+            })
+        ;
+
+        $result = $this->controller->ajaxUpdate($request, $response, ['id' => (string) $id]);
+
+        static::assertSame(200, $result->getStatusCode());
+
+        $payload = json_decode((string) $result->getBody(), true);
+
+        static::assertSame($id, $payload['id']);
+        static::assertSame($url, $payload['url']);
+        static::assertSame($thumb, $payload['thumbnail']);
+    }
+
+    /**
+     * Test updating a bookmark thumbnail - Invalid ID
+     */
+    public function testAjaxUpdateInvalidId(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $result = $this->controller->ajaxUpdate($request, $response, ['id' => 'nope']);
+
+        static::assertSame(400, $result->getStatusCode());
+    }
+
+    /**
+     * Test updating a bookmark thumbnail - No ID
+     */
+    public function testAjaxUpdateNoId(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $result = $this->controller->ajaxUpdate($request, $response, []);
+
+        static::assertSame(400, $result->getStatusCode());
+    }
+
+    /**
+     * Test updating a bookmark thumbnail with valid parameters
+     */
+    public function testAjaxUpdateBookmarkNotFound(): void
+    {
+        $id = 123;
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('get')
+            ->with($id)
+            ->willThrowException(new BookmarkNotFoundException())
+        ;
+
+        $result = $this->controller->ajaxUpdate($request, $response, ['id' => (string) $id]);
+
+        static::assertSame(404, $result->getStatusCode());
+    }
+}
diff --git a/tests/front/controller/admin/TokenControllerTest.php b/tests/front/controller/admin/TokenControllerTest.php
new file mode 100644 (file)
index 0000000..04b0c0f
--- /dev/null
@@ -0,0 +1,41 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use PHPUnit\Framework\TestCase;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class TokenControllerTest extends TestCase
+{
+    use FrontAdminControllerMockHelper;
+
+    /** @var TokenController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->controller = new TokenController($this->container);
+    }
+
+    public function testGetToken(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('generateToken')
+            ->willReturn($token = 'token1234')
+        ;
+
+        $result = $this->controller->getToken($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame($token, (string) $result->getBody());
+    }
+}
diff --git a/tests/front/controller/admin/ToolsControllerTest.php b/tests/front/controller/admin/ToolsControllerTest.php
new file mode 100644 (file)
index 0000000..fc756f0
--- /dev/null
@@ -0,0 +1,69 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use PHPUnit\Framework\TestCase;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class ToolsControllerTestControllerTest extends TestCase
+{
+    use FrontAdminControllerMockHelper;
+
+    /** @var ToolsController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->controller = new ToolsController($this->container);
+    }
+
+    public function testDefaultInvokeWithHttps(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->environment = [
+            'SERVER_NAME' => 'shaarli',
+            'SERVER_PORT' => 443,
+            'HTTPS' => 'on',
+        ];
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('tools', (string) $result->getBody());
+        static::assertSame('https://shaarli', $assignedVariables['pageabsaddr']);
+        static::assertTrue($assignedVariables['sslenabled']);
+    }
+
+    public function testDefaultInvokeWithoutHttps(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->environment = [
+            'SERVER_NAME' => 'shaarli',
+            'SERVER_PORT' => 80,
+        ];
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('tools', (string) $result->getBody());
+        static::assertSame('http://shaarli', $assignedVariables['pageabsaddr']);
+        static::assertFalse($assignedVariables['sslenabled']);
+    }
+}
diff --git a/tests/front/controller/visitor/BookmarkListControllerTest.php b/tests/front/controller/visitor/BookmarkListControllerTest.php
new file mode 100644 (file)
index 0000000..5daaa2c
--- /dev/null
@@ -0,0 +1,448 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use PHPUnit\Framework\TestCase;
+use Shaarli\Bookmark\Bookmark;
+use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
+use Shaarli\Config\ConfigManager;
+use Shaarli\Security\LoginManager;
+use Shaarli\Thumbnailer;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class BookmarkListControllerTest extends TestCase
+{
+    use FrontControllerMockHelper;
+
+    /** @var BookmarkListController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->controller = new BookmarkListController($this->container);
+    }
+
+    /**
+     * Test rendering list of bookmarks with default parameters (first page).
+     */
+    public function testIndexDefaultFirstPage(): void
+    {
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('search')
+            ->with(
+                ['searchtags' => '', 'searchterm' => ''],
+                null,
+                false,
+                false
+            )
+            ->willReturn([
+                (new Bookmark())->setId(1)->setUrl('http://url1.tld')->setTitle('Title 1'),
+                (new Bookmark())->setId(2)->setUrl('http://url2.tld')->setTitle('Title 2'),
+                (new Bookmark())->setId(3)->setUrl('http://url3.tld')->setTitle('Title 3'),
+            ]
+        );
+
+        $this->container->sessionManager
+            ->method('getSessionParameter')
+            ->willReturnCallback(function (string $parameter, $default = null) {
+                if ('LINKS_PER_PAGE' === $parameter) {
+                    return 2;
+                }
+
+                return $default;
+            })
+        ;
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('linklist', (string) $result->getBody());
+
+        static::assertSame('Shaarli', $assignedVariables['pagetitle']);
+        static::assertSame('?page=2', $assignedVariables['previous_page_url']);
+        static::assertSame('', $assignedVariables['next_page_url']);
+        static::assertSame(2, $assignedVariables['page_max']);
+        static::assertSame('', $assignedVariables['search_tags']);
+        static::assertSame(3, $assignedVariables['result_count']);
+        static::assertSame(1, $assignedVariables['page_current']);
+        static::assertSame('', $assignedVariables['search_term']);
+        static::assertNull($assignedVariables['visibility']);
+        static::assertCount(2, $assignedVariables['links']);
+
+        $link = $assignedVariables['links'][0];
+
+        static::assertSame(1, $link['id']);
+        static::assertSame('http://url1.tld', $link['url']);
+        static::assertSame('Title 1', $link['title']);
+
+        $link = $assignedVariables['links'][1];
+
+        static::assertSame(2, $link['id']);
+        static::assertSame('http://url2.tld', $link['url']);
+        static::assertSame('Title 2', $link['title']);
+    }
+
+    /**
+     * Test rendering list of bookmarks with default parameters (second page).
+     */
+    public function testIndexDefaultSecondPage(): void
+    {
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $request = $this->createMock(Request::class);
+        $request->method('getParam')->willReturnCallback(function (string $key) {
+            if ('page' === $key) {
+                return '2';
+            }
+
+            return null;
+        });
+        $response = new Response();
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('search')
+            ->with(
+                ['searchtags' => '', 'searchterm' => ''],
+                null,
+                false,
+                false
+            )
+            ->willReturn([
+                (new Bookmark())->setId(1)->setUrl('http://url1.tld')->setTitle('Title 1'),
+                (new Bookmark())->setId(2)->setUrl('http://url2.tld')->setTitle('Title 2'),
+                (new Bookmark())->setId(3)->setUrl('http://url3.tld')->setTitle('Title 3'),
+            ])
+        ;
+
+        $this->container->sessionManager
+            ->method('getSessionParameter')
+            ->willReturnCallback(function (string $parameter, $default = null) {
+                if ('LINKS_PER_PAGE' === $parameter) {
+                    return 2;
+                }
+
+                return $default;
+            })
+        ;
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('linklist', (string) $result->getBody());
+
+        static::assertSame('Shaarli', $assignedVariables['pagetitle']);
+        static::assertSame('', $assignedVariables['previous_page_url']);
+        static::assertSame('?page=1', $assignedVariables['next_page_url']);
+        static::assertSame(2, $assignedVariables['page_max']);
+        static::assertSame('', $assignedVariables['search_tags']);
+        static::assertSame(3, $assignedVariables['result_count']);
+        static::assertSame(2, $assignedVariables['page_current']);
+        static::assertSame('', $assignedVariables['search_term']);
+        static::assertNull($assignedVariables['visibility']);
+        static::assertCount(1, $assignedVariables['links']);
+
+        $link = $assignedVariables['links'][2];
+
+        static::assertSame(3, $link['id']);
+        static::assertSame('http://url3.tld', $link['url']);
+        static::assertSame('Title 3', $link['title']);
+    }
+
+    /**
+     * Test rendering list of bookmarks with filters.
+     */
+    public function testIndexDefaultWithFilters(): void
+    {
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $request = $this->createMock(Request::class);
+        $request->method('getParam')->willReturnCallback(function (string $key) {
+            if ('searchtags' === $key) {
+                return 'abc def';
+            }
+            if ('searchterm' === $key) {
+                return 'ghi jkl';
+            }
+
+            return null;
+        });
+        $response = new Response();
+
+        $this->container->sessionManager
+            ->method('getSessionParameter')
+            ->willReturnCallback(function (string $key, $default) {
+                if ('LINKS_PER_PAGE' === $key) {
+                    return 2;
+                }
+                if ('visibility' === $key) {
+                    return 'private';
+                }
+                if ('untaggedonly' === $key) {
+                    return true;
+                }
+
+                return $default;
+            })
+        ;
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('search')
+            ->with(
+                ['searchtags' => 'abc def', 'searchterm' => 'ghi jkl'],
+                'private',
+                false,
+                true
+            )
+            ->willReturn([
+                (new Bookmark())->setId(1)->setUrl('http://url1.tld')->setTitle('Title 1'),
+                (new Bookmark())->setId(2)->setUrl('http://url2.tld')->setTitle('Title 2'),
+                (new Bookmark())->setId(3)->setUrl('http://url3.tld')->setTitle('Title 3'),
+            ])
+        ;
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('linklist', (string) $result->getBody());
+
+        static::assertSame('Search: ghi jkl [abc] [def] - Shaarli', $assignedVariables['pagetitle']);
+        static::assertSame('?page=2&searchterm=ghi+jkl&searchtags=abc+def', $assignedVariables['previous_page_url']);
+    }
+
+    /**
+     * Test displaying a permalink with valid parameters
+     */
+    public function testPermalinkValid(): void
+    {
+        $hash = 'abcdef';
+
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('findByHash')
+            ->with($hash)
+            ->willReturn((new Bookmark())->setId(123)->setTitle('Title 1')->setUrl('http://url1.tld'))
+        ;
+
+        $result = $this->controller->permalink($request, $response, ['hash' => $hash]);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('linklist', (string) $result->getBody());
+
+        static::assertSame('Title 1 - Shaarli', $assignedVariables['pagetitle']);
+        static::assertCount(1, $assignedVariables['links']);
+
+        $link = $assignedVariables['links'][0];
+
+        static::assertSame(123, $link['id']);
+        static::assertSame('http://url1.tld', $link['url']);
+        static::assertSame('Title 1', $link['title']);
+    }
+
+    /**
+     * Test displaying a permalink with an unknown small hash : renders a 404 template error
+     */
+    public function testPermalinkNotFound(): void
+    {
+        $hash = 'abcdef';
+
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('findByHash')
+            ->with($hash)
+            ->willThrowException(new BookmarkNotFoundException())
+        ;
+
+        $result = $this->controller->permalink($request, $response, ['hash' => $hash]);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('404', (string) $result->getBody());
+
+        static::assertSame(
+            'The link you are trying to reach does not exist or has been deleted.',
+            $assignedVariables['error_message']
+        );
+    }
+
+    /**
+     * Test getting link list with thumbnail updates.
+     *   -> 2 thumbnails update, only 1 datastore write
+     */
+    public function testThumbnailUpdateFromLinkList(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->loginManager = $this->createMock(LoginManager::class);
+        $this->container->loginManager->method('isLoggedIn')->willReturn(true);
+
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf
+            ->method('get')
+            ->willReturnCallback(function (string $key, $default) {
+                return $key === 'thumbnails.mode' ? Thumbnailer::MODE_ALL : $default;
+            })
+        ;
+
+        $this->container->thumbnailer = $this->createMock(Thumbnailer::class);
+        $this->container->thumbnailer
+            ->expects(static::exactly(2))
+            ->method('get')
+            ->withConsecutive(['https://url2.tld'], ['https://url4.tld'])
+        ;
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('search')
+            ->willReturn([
+                (new Bookmark())->setId(1)->setUrl('https://url1.tld')->setTitle('Title 1')->setThumbnail(false),
+                $b1 = (new Bookmark())->setId(2)->setUrl('https://url2.tld')->setTitle('Title 2'),
+                (new Bookmark())->setId(3)->setUrl('https://url3.tld')->setTitle('Title 3')->setThumbnail(false),
+                $b2 = (new Bookmark())->setId(2)->setUrl('https://url4.tld')->setTitle('Title 4'),
+                (new Bookmark())->setId(2)->setUrl('ftp://url5.tld', ['ftp'])->setTitle('Title 5'),
+            ])
+        ;
+        $this->container->bookmarkService
+            ->expects(static::exactly(2))
+            ->method('set')
+            ->withConsecutive([$b1, false], [$b2, false])
+        ;
+        $this->container->bookmarkService->expects(static::once())->method('save');
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('linklist', (string) $result->getBody());
+    }
+
+    /**
+     * Test getting a permalink with thumbnail update.
+     */
+    public function testThumbnailUpdateFromPermalink(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->loginManager = $this->createMock(LoginManager::class);
+        $this->container->loginManager->method('isLoggedIn')->willReturn(true);
+
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf
+            ->method('get')
+            ->willReturnCallback(function (string $key, $default) {
+                return $key === 'thumbnails.mode' ? Thumbnailer::MODE_ALL : $default;
+            })
+        ;
+
+        $this->container->thumbnailer = $this->createMock(Thumbnailer::class);
+        $this->container->thumbnailer->expects(static::once())->method('get')->withConsecutive(['https://url.tld']);
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('findByHash')
+            ->willReturn($bookmark = (new Bookmark())->setId(2)->setUrl('https://url.tld')->setTitle('Title 1'))
+        ;
+        $this->container->bookmarkService->expects(static::once())->method('set')->with($bookmark, true);
+        $this->container->bookmarkService->expects(static::never())->method('save');
+
+        $result = $this->controller->permalink($request, $response, ['hash' => 'abc']);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('linklist', (string) $result->getBody());
+    }
+
+    /**
+     * Trigger legacy controller in link list controller: permalink
+     */
+    public function testLegacyControllerPermalink(): void
+    {
+        $hash = 'abcdef';
+        $this->container->environment['QUERY_STRING'] = $hash;
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame('/subfolder/shaare/' . $hash, $result->getHeader('location')[0]);
+    }
+
+    /**
+     * Trigger legacy controller in link list controller: ?do= query parameter
+     */
+    public function testLegacyControllerDoPage(): void
+    {
+        $request = $this->createMock(Request::class);
+        $request->method('getQueryParam')->with('do')->willReturn('picwall');
+        $response = new Response();
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame('/subfolder/picture-wall', $result->getHeader('location')[0]);
+    }
+
+    /**
+     * Trigger legacy controller in link list controller: ?do= query parameter with unknown legacy route
+     */
+    public function testLegacyControllerUnknownDoPage(): void
+    {
+        $request = $this->createMock(Request::class);
+        $request->method('getQueryParam')->with('do')->willReturn('nope');
+        $response = new Response();
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('linklist', (string) $result->getBody());
+    }
+
+    /**
+     * Trigger legacy controller in link list controller: other GET route (e.g. ?post)
+     */
+    public function testLegacyControllerGetParameter(): void
+    {
+        $request = $this->createMock(Request::class);
+        $request->method('getQueryParams')->willReturn(['post' => $url = 'http://url.tld']);
+        $response = new Response();
+
+        $this->container->loginManager = $this->createMock(LoginManager::class);
+        $this->container->loginManager->method('isLoggedIn')->willReturn(true);
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(
+            '/subfolder/admin/shaare?post=' . urlencode($url),
+            $result->getHeader('location')[0]
+        );
+    }
+}
diff --git a/tests/front/controller/visitor/DailyControllerTest.php b/tests/front/controller/visitor/DailyControllerTest.php
new file mode 100644 (file)
index 0000000..b802c62
--- /dev/null
@@ -0,0 +1,476 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use PHPUnit\Framework\TestCase;
+use Shaarli\Bookmark\Bookmark;
+use Shaarli\Feed\CachedPage;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class DailyControllerTest extends TestCase
+{
+    use FrontControllerMockHelper;
+
+    /** @var DailyController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->controller = new DailyController($this->container);
+        DailyController::$DAILY_RSS_NB_DAYS = 2;
+    }
+
+    public function testValidIndexControllerInvokeDefault(): void
+    {
+        $currentDay = new \DateTimeImmutable('2020-05-13');
+
+        $request = $this->createMock(Request::class);
+        $request->method('getQueryParam')->willReturn($currentDay->format('Ymd'));
+        $response = new Response();
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        // Links dataset: 2 links with thumbnails
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('days')
+            ->willReturnCallback(function () use ($currentDay): array {
+               return [
+                   '20200510',
+                   $currentDay->format('Ymd'),
+                   '20200516',
+               ];
+            })
+        ;
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('filterDay')
+            ->willReturnCallback(function (): array {
+                return [
+                    (new Bookmark())
+                        ->setId(1)
+                        ->setUrl('http://url.tld')
+                        ->setTitle(static::generateString(50))
+                        ->setDescription(static::generateString(500))
+                    ,
+                    (new Bookmark())
+                        ->setId(2)
+                        ->setUrl('http://url2.tld')
+                        ->setTitle(static::generateString(50))
+                        ->setDescription(static::generateString(500))
+                    ,
+                    (new Bookmark())
+                        ->setId(3)
+                        ->setUrl('http://url3.tld')
+                        ->setTitle(static::generateString(50))
+                        ->setDescription(static::generateString(500))
+                    ,
+                ];
+            })
+        ;
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::at(0))
+            ->method('executeHooks')
+            ->willReturnCallback(function (string $hook, array $data, array $param) use ($currentDay): array {
+                static::assertSame('render_daily', $hook);
+
+                static::assertArrayHasKey('linksToDisplay', $data);
+                static::assertCount(3, $data['linksToDisplay']);
+                static::assertSame(1, $data['linksToDisplay'][0]['id']);
+                static::assertSame($currentDay->getTimestamp(), $data['day']);
+                static::assertSame('20200510', $data['previousday']);
+                static::assertSame('20200516', $data['nextday']);
+
+                static::assertArrayHasKey('loggedin', $param);
+
+                return $data;
+            })
+        ;
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('daily', (string) $result->getBody());
+        static::assertSame(
+            'Daily - '. format_date($currentDay, false, true) .' - Shaarli',
+            $assignedVariables['pagetitle']
+        );
+        static::assertEquals($currentDay, $assignedVariables['dayDate']);
+        static::assertEquals($currentDay->getTimestamp(), $assignedVariables['day']);
+        static::assertCount(3, $assignedVariables['linksToDisplay']);
+
+        $link = $assignedVariables['linksToDisplay'][0];
+
+        static::assertSame(1, $link['id']);
+        static::assertSame('http://url.tld', $link['url']);
+        static::assertNotEmpty($link['title']);
+        static::assertNotEmpty($link['description']);
+        static::assertNotEmpty($link['formatedDescription']);
+
+        $link = $assignedVariables['linksToDisplay'][1];
+
+        static::assertSame(2, $link['id']);
+        static::assertSame('http://url2.tld', $link['url']);
+        static::assertNotEmpty($link['title']);
+        static::assertNotEmpty($link['description']);
+        static::assertNotEmpty($link['formatedDescription']);
+
+        $link = $assignedVariables['linksToDisplay'][2];
+
+        static::assertSame(3, $link['id']);
+        static::assertSame('http://url3.tld', $link['url']);
+        static::assertNotEmpty($link['title']);
+        static::assertNotEmpty($link['description']);
+        static::assertNotEmpty($link['formatedDescription']);
+
+        static::assertCount(3, $assignedVariables['cols']);
+        static::assertCount(1, $assignedVariables['cols'][0]);
+        static::assertCount(1, $assignedVariables['cols'][1]);
+        static::assertCount(1, $assignedVariables['cols'][2]);
+
+        $link = $assignedVariables['cols'][0][0];
+
+        static::assertSame(1, $link['id']);
+        static::assertSame('http://url.tld', $link['url']);
+        static::assertNotEmpty($link['title']);
+        static::assertNotEmpty($link['description']);
+        static::assertNotEmpty($link['formatedDescription']);
+
+        $link = $assignedVariables['cols'][1][0];
+
+        static::assertSame(2, $link['id']);
+        static::assertSame('http://url2.tld', $link['url']);
+        static::assertNotEmpty($link['title']);
+        static::assertNotEmpty($link['description']);
+        static::assertNotEmpty($link['formatedDescription']);
+
+        $link = $assignedVariables['cols'][2][0];
+
+        static::assertSame(3, $link['id']);
+        static::assertSame('http://url3.tld', $link['url']);
+        static::assertNotEmpty($link['title']);
+        static::assertNotEmpty($link['description']);
+        static::assertNotEmpty($link['formatedDescription']);
+    }
+
+    /**
+     * Daily page - test that everything goes fine with no future or past bookmarks
+     */
+    public function testValidIndexControllerInvokeNoFutureOrPast(): void
+    {
+        $currentDay = new \DateTimeImmutable('2020-05-13');
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        // Links dataset: 2 links with thumbnails
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('days')
+            ->willReturnCallback(function () use ($currentDay): array {
+                return [
+                    $currentDay->format($currentDay->format('Ymd')),
+                ];
+            })
+        ;
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('filterDay')
+            ->willReturnCallback(function (): array {
+                return [
+                    (new Bookmark())
+                        ->setId(1)
+                        ->setUrl('http://url.tld')
+                        ->setTitle(static::generateString(50))
+                        ->setDescription(static::generateString(500))
+                    ,
+                ];
+            })
+        ;
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::at(0))
+            ->method('executeHooks')
+            ->willReturnCallback(function (string $hook, array $data, array $param) use ($currentDay): array {
+                static::assertSame('render_daily', $hook);
+
+                static::assertArrayHasKey('linksToDisplay', $data);
+                static::assertCount(1, $data['linksToDisplay']);
+                static::assertSame(1, $data['linksToDisplay'][0]['id']);
+                static::assertSame($currentDay->getTimestamp(), $data['day']);
+                static::assertEmpty($data['previousday']);
+                static::assertEmpty($data['nextday']);
+
+                static::assertArrayHasKey('loggedin', $param);
+
+                return $data;
+            });
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('daily', (string) $result->getBody());
+        static::assertSame(
+            'Daily - '. format_date($currentDay, false, true) .' - Shaarli',
+            $assignedVariables['pagetitle']
+        );
+        static::assertCount(1, $assignedVariables['linksToDisplay']);
+
+        $link = $assignedVariables['linksToDisplay'][0];
+        static::assertSame(1, $link['id']);
+    }
+
+    /**
+     * Daily page - test that height adjustment in columns is working
+     */
+    public function testValidIndexControllerInvokeHeightAdjustment(): void
+    {
+        $currentDay = new \DateTimeImmutable('2020-05-13');
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        // Links dataset: 2 links with thumbnails
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('days')
+            ->willReturnCallback(function () use ($currentDay): array {
+                return [
+                    $currentDay->format($currentDay->format('Ymd')),
+                ];
+            })
+        ;
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('filterDay')
+            ->willReturnCallback(function (): array {
+                return [
+                    (new Bookmark())->setId(1)->setUrl('http://url.tld')->setTitle('title'),
+                    (new Bookmark())
+                        ->setId(2)
+                        ->setUrl('http://url.tld')
+                        ->setTitle(static::generateString(50))
+                        ->setDescription(static::generateString(5000))
+                    ,
+                    (new Bookmark())->setId(3)->setUrl('http://url.tld')->setTitle('title'),
+                    (new Bookmark())->setId(4)->setUrl('http://url.tld')->setTitle('title'),
+                    (new Bookmark())->setId(5)->setUrl('http://url.tld')->setTitle('title'),
+                    (new Bookmark())->setId(6)->setUrl('http://url.tld')->setTitle('title'),
+                    (new Bookmark())->setId(7)->setUrl('http://url.tld')->setTitle('title'),
+                ];
+            })
+        ;
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::at(0))
+            ->method('executeHooks')
+            ->willReturnCallback(function (string $hook, array $data, array $param): array {
+                return $data;
+            })
+        ;
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('daily', (string) $result->getBody());
+        static::assertCount(7, $assignedVariables['linksToDisplay']);
+
+        $columnIds = function (array $column): array {
+            return array_map(function (array $item): int { return $item['id']; }, $column);
+        };
+
+        static::assertSame([1, 4, 6], $columnIds($assignedVariables['cols'][0]));
+        static::assertSame([2], $columnIds($assignedVariables['cols'][1]));
+        static::assertSame([3, 5, 7], $columnIds($assignedVariables['cols'][2]));
+    }
+
+    /**
+     * Daily page - no bookmark
+     */
+    public function testValidIndexControllerInvokeNoBookmark(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        // Links dataset: 2 links with thumbnails
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('days')
+            ->willReturnCallback(function (): array {
+                return [];
+            })
+        ;
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('filterDay')
+            ->willReturnCallback(function (): array {
+                return [];
+            })
+        ;
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::at(0))
+            ->method('executeHooks')
+            ->willReturnCallback(function (string $hook, array $data, array $param): array {
+                return $data;
+            })
+        ;
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('daily', (string) $result->getBody());
+        static::assertCount(0, $assignedVariables['linksToDisplay']);
+        static::assertSame('Today', $assignedVariables['dayDesc']);
+        static::assertEquals((new \DateTime())->setTime(0, 0)->getTimestamp(), $assignedVariables['day']);
+        static::assertEquals((new \DateTime())->setTime(0, 0), $assignedVariables['dayDate']);
+    }
+
+    /**
+     * Daily RSS - default behaviour
+     */
+    public function testValidRssControllerInvokeDefault(): void
+    {
+        $dates = [
+            new \DateTimeImmutable('2020-05-17'),
+            new \DateTimeImmutable('2020-05-15'),
+            new \DateTimeImmutable('2020-05-13'),
+        ];
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->bookmarkService->expects(static::once())->method('search')->willReturn([
+            (new Bookmark())->setId(1)->setCreated($dates[0])->setUrl('http://domain.tld/1'),
+            (new Bookmark())->setId(2)->setCreated($dates[1])->setUrl('http://domain.tld/2'),
+            (new Bookmark())->setId(3)->setCreated($dates[1])->setUrl('http://domain.tld/3'),
+            (new Bookmark())->setId(4)->setCreated($dates[2])->setUrl('http://domain.tld/4'),
+        ]);
+
+        $this->container->pageCacheManager
+            ->expects(static::once())
+            ->method('getCachePage')
+            ->willReturnCallback(function (): CachedPage {
+                $cachedPage = $this->createMock(CachedPage::class);
+                $cachedPage->expects(static::once())->method('cache')->with('dailyrss');
+
+                return $cachedPage;
+            }
+        );
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $result = $this->controller->rss($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertStringContainsString('application/rss', $result->getHeader('Content-Type')[0]);
+        static::assertSame('dailyrss', (string) $result->getBody());
+        static::assertSame('Shaarli', $assignedVariables['title']);
+        static::assertSame('http://shaarli', $assignedVariables['index_url']);
+        static::assertSame('http://shaarli/daily-rss', $assignedVariables['page_url']);
+        static::assertFalse($assignedVariables['hide_timestamps']);
+        static::assertCount(2, $assignedVariables['days']);
+
+        $day = $assignedVariables['days'][$dates[0]->format('Ymd')];
+
+        static::assertEquals($dates[0], $day['date']);
+        static::assertSame($dates[0]->format(\DateTime::RSS), $day['date_rss']);
+        static::assertSame(format_date($dates[0], false), $day['date_human']);
+        static::assertSame('http://shaarli/daily?day='. $dates[0]->format('Ymd'), $day['absolute_url']);
+        static::assertCount(1, $day['links']);
+        static::assertSame(1, $day['links'][0]['id']);
+        static::assertSame('http://domain.tld/1', $day['links'][0]['url']);
+        static::assertEquals($dates[0], $day['links'][0]['created']);
+
+        $day = $assignedVariables['days'][$dates[1]->format('Ymd')];
+
+        static::assertEquals($dates[1], $day['date']);
+        static::assertSame($dates[1]->format(\DateTime::RSS), $day['date_rss']);
+        static::assertSame(format_date($dates[1], false), $day['date_human']);
+        static::assertSame('http://shaarli/daily?day='. $dates[1]->format('Ymd'), $day['absolute_url']);
+        static::assertCount(2, $day['links']);
+
+        static::assertSame(2, $day['links'][0]['id']);
+        static::assertSame('http://domain.tld/2', $day['links'][0]['url']);
+        static::assertEquals($dates[1], $day['links'][0]['created']);
+        static::assertSame(3, $day['links'][1]['id']);
+        static::assertSame('http://domain.tld/3', $day['links'][1]['url']);
+        static::assertEquals($dates[1], $day['links'][1]['created']);
+    }
+
+    /**
+     * Daily RSS - trigger cache rendering
+     */
+    public function testValidRssControllerInvokeTriggerCache(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->pageCacheManager->method('getCachePage')->willReturnCallback(function (): CachedPage {
+            $cachedPage = $this->createMock(CachedPage::class);
+            $cachedPage->method('cachedVersion')->willReturn('this is cache!');
+
+            return $cachedPage;
+        });
+
+        $this->container->bookmarkService->expects(static::never())->method('search');
+
+        $result = $this->controller->rss($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertStringContainsString('application/rss', $result->getHeader('Content-Type')[0]);
+        static::assertSame('this is cache!', (string) $result->getBody());
+    }
+
+    /**
+     * Daily RSS - No bookmark
+     */
+    public function testValidRssControllerInvokeNoBookmark(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->bookmarkService->expects(static::once())->method('search')->willReturn([]);
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $result = $this->controller->rss($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertStringContainsString('application/rss', $result->getHeader('Content-Type')[0]);
+        static::assertSame('dailyrss', (string) $result->getBody());
+        static::assertSame('Shaarli', $assignedVariables['title']);
+        static::assertSame('http://shaarli', $assignedVariables['index_url']);
+        static::assertSame('http://shaarli/daily-rss', $assignedVariables['page_url']);
+        static::assertFalse($assignedVariables['hide_timestamps']);
+        static::assertCount(0, $assignedVariables['days']);
+    }
+}
diff --git a/tests/front/controller/visitor/ErrorControllerTest.php b/tests/front/controller/visitor/ErrorControllerTest.php
new file mode 100644 (file)
index 0000000..e497bfe
--- /dev/null
@@ -0,0 +1,70 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use PHPUnit\Framework\TestCase;
+use Shaarli\Front\Exception\ShaarliFrontException;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class ErrorControllerTest extends TestCase
+{
+    use FrontControllerMockHelper;
+
+    /** @var ErrorController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->controller = new ErrorController($this->container);
+    }
+
+    /**
+     * Test displaying error with a ShaarliFrontException: display exception message and use its code for HTTTP code
+     */
+    public function testDisplayFrontExceptionError(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $message = 'error message';
+        $errorCode = 418;
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $result = ($this->controller)(
+            $request,
+            $response,
+            new class($message, $errorCode) extends ShaarliFrontException {}
+        );
+
+        static::assertSame($errorCode, $result->getStatusCode());
+        static::assertSame($message, $assignedVariables['message']);
+        static::assertArrayNotHasKey('stacktrace', $assignedVariables);
+    }
+
+    /**
+     * Test displaying error with any exception (no debug): only display an error occurred with HTTP 500.
+     */
+    public function testDisplayAnyExceptionErrorNoDebug(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $result = ($this->controller)($request, $response, new \Exception('abc'));
+
+        static::assertSame(500, $result->getStatusCode());
+        static::assertSame('An unexpected error occurred.', $assignedVariables['message']);
+        static::assertArrayNotHasKey('stacktrace', $assignedVariables);
+    }
+}
diff --git a/tests/front/controller/visitor/FeedControllerTest.php b/tests/front/controller/visitor/FeedControllerTest.php
new file mode 100644 (file)
index 0000000..fb417e2
--- /dev/null
@@ -0,0 +1,145 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use PHPUnit\Framework\TestCase;
+use Shaarli\Feed\FeedBuilder;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class FeedControllerTest extends TestCase
+{
+    use FrontControllerMockHelper;
+
+    /** @var FeedController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->container->feedBuilder = $this->createMock(FeedBuilder::class);
+
+        $this->controller = new FeedController($this->container);
+    }
+
+    /**
+     * Feed Controller - RSS default behaviour
+     */
+    public function testDefaultRssController(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->feedBuilder->expects(static::once())->method('setLocale');
+        $this->container->feedBuilder->expects(static::once())->method('setHideDates')->with(false);
+        $this->container->feedBuilder->expects(static::once())->method('setUsePermalinks')->with(true);
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $this->container->feedBuilder->method('buildData')->willReturn(['content' => 'data']);
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::at(0))
+            ->method('executeHooks')
+            ->willReturnCallback(function (string $hook, array $data, array $param): void {
+                static::assertSame('render_feed', $hook);
+                static::assertSame('data', $data['content']);
+
+                static::assertArrayHasKey('loggedin', $param);
+                static::assertSame('rss', $param['target']);
+            })
+        ;
+
+        $result = $this->controller->rss($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertStringContainsString('application/rss', $result->getHeader('Content-Type')[0]);
+        static::assertSame('feed.rss', (string) $result->getBody());
+        static::assertSame('data', $assignedVariables['content']);
+    }
+
+    /**
+     * Feed Controller - ATOM default behaviour
+     */
+    public function testDefaultAtomController(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->feedBuilder->expects(static::once())->method('setLocale');
+        $this->container->feedBuilder->expects(static::once())->method('setHideDates')->with(false);
+        $this->container->feedBuilder->expects(static::once())->method('setUsePermalinks')->with(true);
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $this->container->feedBuilder->method('buildData')->willReturn(['content' => 'data']);
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::at(0))
+            ->method('executeHooks')
+            ->willReturnCallback(function (string $hook, array $data, array $param): void {
+                static::assertSame('render_feed', $hook);
+                static::assertSame('data', $data['content']);
+
+                static::assertArrayHasKey('loggedin', $param);
+                static::assertSame('atom', $param['target']);
+            })
+        ;
+
+        $result = $this->controller->atom($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertStringContainsString('application/atom', $result->getHeader('Content-Type')[0]);
+        static::assertSame('feed.atom', (string) $result->getBody());
+        static::assertSame('data', $assignedVariables['content']);
+    }
+
+    /**
+     * Feed Controller - ATOM with parameters
+     */
+    public function testAtomControllerWithParameters(): void
+    {
+        $request = $this->createMock(Request::class);
+        $request->method('getParams')->willReturn(['parameter' => 'value']);
+        $response = new Response();
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $this->container->feedBuilder
+            ->method('buildData')
+            ->with('atom', ['parameter' => 'value'])
+            ->willReturn(['content' => 'data'])
+        ;
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::at(0))
+            ->method('executeHooks')
+            ->willReturnCallback(function (string $hook, array $data, array $param): void {
+                static::assertSame('render_feed', $hook);
+                static::assertSame('data', $data['content']);
+
+                static::assertArrayHasKey('loggedin', $param);
+                static::assertSame('atom', $param['target']);
+            })
+        ;
+
+        $result = $this->controller->atom($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertStringContainsString('application/atom', $result->getHeader('Content-Type')[0]);
+        static::assertSame('feed.atom', (string) $result->getBody());
+        static::assertSame('data', $assignedVariables['content']);
+    }
+}
diff --git a/tests/front/controller/visitor/FrontControllerMockHelper.php b/tests/front/controller/visitor/FrontControllerMockHelper.php
new file mode 100644 (file)
index 0000000..e0bd4ec
--- /dev/null
@@ -0,0 +1,119 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use PHPUnit\Framework\MockObject\MockObject;
+use Shaarli\Bookmark\BookmarkServiceInterface;
+use Shaarli\Config\ConfigManager;
+use Shaarli\Container\ShaarliTestContainer;
+use Shaarli\Formatter\BookmarkFormatter;
+use Shaarli\Formatter\BookmarkRawFormatter;
+use Shaarli\Formatter\FormatterFactory;
+use Shaarli\Plugin\PluginManager;
+use Shaarli\Render\PageBuilder;
+use Shaarli\Render\PageCacheManager;
+use Shaarli\Security\LoginManager;
+use Shaarli\Security\SessionManager;
+
+/**
+ * Trait FrontControllerMockHelper
+ *
+ * Helper trait used to initialize the ShaarliContainer and mock its services for controller tests.
+ *
+ * @property ShaarliTestContainer $container
+ * @package Shaarli\Front\Controller
+ */
+trait FrontControllerMockHelper
+{
+    /** @var ShaarliTestContainer */
+    protected $container;
+
+    /**
+     * Mock the container instance and initialize container's services used by tests
+     */
+    protected function createContainer(): void
+    {
+        $this->container = $this->createMock(ShaarliTestContainer::class);
+
+        $this->container->loginManager = $this->createMock(LoginManager::class);
+
+        // Config
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf->method('get')->willReturnCallback(function (string $parameter, $default) {
+            return $default === null ? $parameter : $default;
+        });
+
+        // PageBuilder
+        $this->container->pageBuilder = $this->createMock(PageBuilder::class);
+        $this->container->pageBuilder
+            ->method('render')
+            ->willReturnCallback(function (string $template): string {
+                return $template;
+            })
+        ;
+
+        // Plugin Manager
+        $this->container->pluginManager = $this->createMock(PluginManager::class);
+
+        // BookmarkService
+        $this->container->bookmarkService = $this->createMock(BookmarkServiceInterface::class);
+
+        // Formatter
+        $this->container->formatterFactory = $this->createMock(FormatterFactory::class);
+        $this->container->formatterFactory
+            ->method('getFormatter')
+            ->willReturnCallback(function (): BookmarkFormatter {
+                return new BookmarkRawFormatter($this->container->conf, true);
+            })
+        ;
+
+        // CacheManager
+        $this->container->pageCacheManager = $this->createMock(PageCacheManager::class);
+
+        // SessionManager
+        $this->container->sessionManager = $this->createMock(SessionManager::class);
+
+        // $_SERVER
+        $this->container->environment = [
+            'SERVER_NAME' => 'shaarli',
+            'SERVER_PORT' => '80',
+            'REQUEST_URI' => '/daily-rss',
+            'REMOTE_ADDR' => '1.2.3.4',
+        ];
+
+        $this->container->basePath = '/subfolder';
+    }
+
+    /**
+     * Pass a reference of an array which will be populated by `pageBuilder->assign` calls during execution.
+     *
+     * @param mixed $variables Array reference to populate.
+     */
+    protected function assignTemplateVars(array &$variables): void
+    {
+        $this->container->pageBuilder
+            ->expects(static::atLeastOnce())
+            ->method('assign')
+            ->willReturnCallback(function ($key, $value) use (&$variables) {
+                $variables[$key] = $value;
+
+                return $this;
+            })
+        ;
+    }
+
+    protected static function generateString(int $length): string
+    {
+        // bin2hex(random_bytes) generates string twice as long as given parameter
+        $length = (int) ceil($length / 2);
+
+        return bin2hex(random_bytes($length));
+    }
+
+    /**
+     * Force to be used in PHPUnit context.
+     */
+    protected abstract function createMock($originalClassName): MockObject;
+}
diff --git a/tests/front/controller/visitor/InstallControllerTest.php b/tests/front/controller/visitor/InstallControllerTest.php
new file mode 100644 (file)
index 0000000..3b85536
--- /dev/null
@@ -0,0 +1,262 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use PHPUnit\Framework\TestCase;
+use Shaarli\Config\ConfigManager;
+use Shaarli\Front\Exception\AlreadyInstalledException;
+use Shaarli\Security\SessionManager;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class InstallControllerTest extends TestCase
+{
+    use FrontControllerMockHelper;
+
+    const MOCK_FILE = '.tmp';
+
+    /** @var InstallController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf->method('getConfigFileExt')->willReturn(static::MOCK_FILE);
+        $this->container->conf->method('get')->willReturnCallback(function (string $key, $default) {
+            if ($key === 'resource.raintpl_tpl') {
+                return '.';
+            }
+
+            return $default ?? $key;
+        });
+
+        $this->controller = new InstallController($this->container);
+    }
+
+    protected function tearDown(): void
+    {
+        if (file_exists(static::MOCK_FILE)) {
+            unlink(static::MOCK_FILE);
+        }
+    }
+
+    /**
+     * Test displaying install page with valid session.
+     */
+    public function testInstallIndexWithValidSession(): void
+    {
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->sessionManager = $this->createMock(SessionManager::class);
+        $this->container->sessionManager
+            ->method('getSessionParameter')
+            ->willReturnCallback(function (string $key, $default) {
+                return $key === 'session_tested' ? 'Working' : $default;
+            })
+        ;
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('install', (string) $result->getBody());
+
+        static::assertIsArray($assignedVariables['continents']);
+        static::assertSame('Africa', $assignedVariables['continents'][0]);
+        static::assertSame('UTC', $assignedVariables['continents']['selected']);
+
+        static::assertIsArray($assignedVariables['cities']);
+        static::assertSame(['continent' => 'Africa', 'city' => 'Abidjan'], $assignedVariables['cities'][0]);
+        static::assertSame('UTC', $assignedVariables['continents']['selected']);
+
+        static::assertIsArray($assignedVariables['languages']);
+        static::assertSame('Automatic', $assignedVariables['languages']['auto']);
+        static::assertSame('French', $assignedVariables['languages']['fr']);
+    }
+
+    /**
+     * Instantiate the install controller with an existing config file: exception.
+     */
+    public function testInstallWithExistingConfigFile(): void
+    {
+        $this->expectException(AlreadyInstalledException::class);
+
+        touch(static::MOCK_FILE);
+
+        $this->controller = new InstallController($this->container);
+    }
+
+    /**
+     * Call controller without session yet defined, redirect to test session install page.
+     */
+    public function testInstallRedirectToSessionTest(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->sessionManager = $this->createMock(SessionManager::class);
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(InstallController::SESSION_TEST_KEY, InstallController::SESSION_TEST_VALUE)
+        ;
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame('/subfolder/install/session-test', $result->getHeader('location')[0]);
+    }
+
+    /**
+     * Call controller in session test mode: valid session then redirect to install page.
+     */
+    public function testInstallSessionTestValid(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->sessionManager = $this->createMock(SessionManager::class);
+        $this->container->sessionManager
+            ->method('getSessionParameter')
+            ->with(InstallController::SESSION_TEST_KEY)
+            ->willReturn(InstallController::SESSION_TEST_VALUE)
+        ;
+
+        $result = $this->controller->sessionTest($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame('/subfolder/install', $result->getHeader('location')[0]);
+    }
+
+    /**
+     * Call controller in session test mode: invalid session then redirect to error page.
+     */
+    public function testInstallSessionTestError(): void
+    {
+        $assignedVars = [];
+        $this->assignTemplateVars($assignedVars);
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->sessionManager = $this->createMock(SessionManager::class);
+        $this->container->sessionManager
+            ->method('getSessionParameter')
+            ->with(InstallController::SESSION_TEST_KEY)
+            ->willReturn('KO')
+        ;
+
+        $result = $this->controller->sessionTest($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('error', (string) $result->getBody());
+        static::assertStringStartsWith(
+            '<pre>Sessions do not seem to work correctly on your server',
+            $assignedVars['message']
+        );
+    }
+
+    /**
+     * Test saving valid data from install form. Also initialize datastore.
+     */
+    public function testSaveInstallValid(): void
+    {
+        $providedParameters = [
+            'continent' => 'Europe',
+            'city' => 'Berlin',
+            'setlogin' => 'bob',
+            'setpassword' => 'password',
+            'title' => 'Shaarli',
+            'language' => 'fr',
+            'updateCheck' => true,
+            'enableApi' => true,
+        ];
+
+        $expectedSettings = [
+            'general.timezone' => 'Europe/Berlin',
+            'credentials.login' => 'bob',
+            'credentials.salt' => '_NOT_EMPTY',
+            'credentials.hash' => '_NOT_EMPTY',
+            'general.title' => 'Shaarli',
+            'translation.language' => 'en',
+            'updates.check_updates' => true,
+            'api.enabled' => true,
+            'api.secret' => '_NOT_EMPTY',
+            'general.header_link' => '/subfolder',
+        ];
+
+        $request = $this->createMock(Request::class);
+        $request->method('getParam')->willReturnCallback(function (string $key) use ($providedParameters) {
+            return $providedParameters[$key] ?? null;
+        });
+        $response = new Response();
+
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf
+            ->method('get')
+            ->willReturnCallback(function (string $key, $value) {
+                if ($key === 'credentials.login') {
+                    return 'bob';
+                } elseif ($key === 'credentials.salt') {
+                    return 'salt';
+                }
+
+                return $value;
+            })
+        ;
+        $this->container->conf
+            ->expects(static::exactly(count($expectedSettings)))
+            ->method('set')
+            ->willReturnCallback(function (string $key, $value) use ($expectedSettings) {
+                if ($expectedSettings[$key] ?? null === '_NOT_EMPTY') {
+                    static::assertNotEmpty($value);
+                } else {
+                    static::assertSame($expectedSettings[$key], $value);
+                }
+            })
+        ;
+        $this->container->conf->expects(static::once())->method('write');
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_SUCCESS_MESSAGES)
+        ;
+
+        $result = $this->controller->save($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame('/subfolder/login', $result->getHeader('location')[0]);
+    }
+
+    /**
+     * Test default settings (timezone and title).
+     * Also check that bookmarks are not initialized if
+     */
+    public function testSaveInstallDefaultValues(): void
+    {
+        $confSettings = [];
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->conf->method('set')->willReturnCallback(function (string $key, $value) use (&$confSettings) {
+            $confSettings[$key] = $value;
+        });
+
+        $result = $this->controller->save($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame('/subfolder/login', $result->getHeader('location')[0]);
+
+        static::assertSame('UTC', $confSettings['general.timezone']);
+        static::assertSame('Shared bookmarks on http://shaarli', $confSettings['general.title']);
+    }
+}
diff --git a/tests/front/controller/visitor/LoginControllerTest.php b/tests/front/controller/visitor/LoginControllerTest.php
new file mode 100644 (file)
index 0000000..0a21f93
--- /dev/null
@@ -0,0 +1,404 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use PHPUnit\Framework\TestCase;
+use Shaarli\Config\ConfigManager;
+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;
+
+class LoginControllerTest extends TestCase
+{
+    use FrontControllerMockHelper;
+
+    /** @var LoginController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->container->cookieManager = $this->createMock(CookieManager::class);
+        $this->container->sessionManager->method('checkToken')->willReturn(true);
+
+        $this->controller = new LoginController($this->container);
+    }
+
+    /**
+     * Test displaying login form with valid parameters.
+     */
+    public function testValidControllerInvoke(): void
+    {
+        $request = $this->createMock(Request::class);
+        $request
+            ->expects(static::atLeastOnce())
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) {
+                return 'returnurl' === $key ? '> referer' : null;
+            })
+        ;
+        $response = new Response();
+
+        $assignedVariables = [];
+        $this->container->pageBuilder
+            ->method('assign')
+            ->willReturnCallback(function ($key, $value) use (&$assignedVariables) {
+                $assignedVariables[$key] = $value;
+
+                return $this;
+            })
+        ;
+
+        $this->container->loginManager->method('canLogin')->willReturn(true);
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame(TemplatePage::LOGIN, (string) $result->getBody());
+
+        static::assertSame('&gt; referer', $assignedVariables['returnurl']);
+        static::assertSame(true, $assignedVariables['remember_user_default']);
+        static::assertSame('Login - Shaarli', $assignedVariables['pagetitle']);
+    }
+
+    /**
+     * Test displaying login form with username defined in the request.
+     */
+    public function testValidControllerInvokeWithUserName(): void
+    {
+        $this->container->environment = ['HTTP_REFERER' => '> referer'];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->expects(static::atLeastOnce())
+            ->method('getParam')
+            ->willReturnCallback(function (string $key, $default) {
+                if ('login' === $key) {
+                    return 'myUser>';
+                }
+
+                return $default;
+            })
+        ;
+        $response = new Response();
+
+        $assignedVariables = [];
+        $this->container->pageBuilder
+            ->method('assign')
+            ->willReturnCallback(function ($key, $value) use (&$assignedVariables) {
+                $assignedVariables[$key] = $value;
+
+                return $this;
+            })
+        ;
+
+        $this->container->loginManager->expects(static::once())->method('canLogin')->willReturn(true);
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('loginform', (string) $result->getBody());
+
+        static::assertSame('myUser&gt;', $assignedVariables['username']);
+        static::assertSame('&gt; referer', $assignedVariables['returnurl']);
+        static::assertSame(true, $assignedVariables['remember_user_default']);
+        static::assertSame('Login - Shaarli', $assignedVariables['pagetitle']);
+    }
+
+    /**
+     * Test displaying login page while being logged in.
+     */
+    public function testLoginControllerWhileLoggedIn(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->loginManager->expects(static::once())->method('isLoggedIn')->willReturn(true);
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('Location'));
+    }
+
+    /**
+     * Test displaying login page with open shaarli configured: redirect to homepage.
+     */
+    public function testLoginControllerOpenShaarli(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $conf = $this->createMock(ConfigManager::class);
+        $conf->method('get')->willReturnCallback(function (string $parameter, $default) {
+            if ($parameter === 'security.open_shaarli') {
+                return true;
+            }
+            return $default;
+        });
+        $this->container->conf = $conf;
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('Location'));
+    }
+
+    /**
+     * Test displaying login page while being banned.
+     */
+    public function testLoginControllerWhileBanned(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->loginManager->method('isLoggedIn')->willReturn(false);
+        $this->container->loginManager->method('canLogin')->willReturn(false);
+
+        $this->expectException(LoginBannedException::class);
+
+        $this->controller->index($request, $response);
+    }
+
+    /**
+     * Test processing login with valid parameters.
+     */
+    public function testProcessLoginWithValidParameters(): void
+    {
+        $parameters = [
+            'login' => 'bob',
+            'password' => 'pass',
+        ];
+        $request = $this->createMock(Request::class);
+        $request
+            ->expects(static::atLeastOnce())
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters) {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $this->container->loginManager->method('canLogin')->willReturn(true);
+        $this->container->loginManager->expects(static::once())->method('handleSuccessfulLogin');
+        $this->container->loginManager
+            ->expects(static::once())
+            ->method('checkCredentials')
+            ->with('1.2.3.4', '1.2.3.4', 'bob', 'pass')
+            ->willReturn(true)
+        ;
+        $this->container->loginManager->method('getStaySignedInToken')->willReturn(bin2hex(random_bytes(8)));
+
+        $this->container->sessionManager->expects(static::never())->method('extendSession');
+        $this->container->sessionManager->expects(static::once())->method('destroy');
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('cookieParameters')
+            ->with(0, '/subfolder/', 'shaarli')
+        ;
+        $this->container->sessionManager->expects(static::once())->method('start');
+        $this->container->sessionManager->expects(static::once())->method('regenerateId')->with(true);
+
+        $result = $this->controller->login($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame('/subfolder/', $result->getHeader('location')[0]);
+    }
+
+    /**
+     * Test processing login with return URL.
+     */
+    public function testProcessLoginWithReturnUrl(): void
+    {
+        $parameters = [
+            'returnurl' => 'http://shaarli/subfolder/admin/shaare',
+        ];
+        $request = $this->createMock(Request::class);
+        $request
+            ->expects(static::atLeastOnce())
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters) {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $this->container->loginManager->method('canLogin')->willReturn(true);
+        $this->container->loginManager->expects(static::once())->method('handleSuccessfulLogin');
+        $this->container->loginManager->expects(static::once())->method('checkCredentials')->willReturn(true);
+        $this->container->loginManager->method('getStaySignedInToken')->willReturn(bin2hex(random_bytes(8)));
+
+        $result = $this->controller->login($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame('/subfolder/admin/shaare', $result->getHeader('location')[0]);
+    }
+
+    /**
+     * Test processing login with remember me session enabled.
+     */
+    public function testProcessLoginLongLastingSession(): void
+    {
+        $parameters = [
+            'longlastingsession' => true,
+        ];
+        $request = $this->createMock(Request::class);
+        $request
+            ->expects(static::atLeastOnce())
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters) {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $this->container->loginManager->method('canLogin')->willReturn(true);
+        $this->container->loginManager->expects(static::once())->method('handleSuccessfulLogin');
+        $this->container->loginManager->expects(static::once())->method('checkCredentials')->willReturn(true);
+        $this->container->loginManager->method('getStaySignedInToken')->willReturn(bin2hex(random_bytes(8)));
+
+        $this->container->sessionManager->expects(static::once())->method('destroy');
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('cookieParameters')
+            ->with(42, '/subfolder/', 'shaarli')
+        ;
+        $this->container->sessionManager->expects(static::once())->method('start');
+        $this->container->sessionManager->expects(static::once())->method('regenerateId')->with(true);
+        $this->container->sessionManager->expects(static::once())->method('extendSession')->willReturn(42);
+
+        $this->container->cookieManager = $this->createMock(CookieManager::class);
+        $this->container->cookieManager
+            ->expects(static::once())
+            ->method('setCookieParameter')
+            ->willReturnCallback(function (string $name): CookieManager {
+                static::assertSame(CookieManager::STAY_SIGNED_IN, $name);
+
+                return $this->container->cookieManager;
+            })
+        ;
+
+        $result = $this->controller->login($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame('/subfolder/', $result->getHeader('location')[0]);
+    }
+
+    /**
+     * Test processing login with invalid credentials
+     */
+    public function testProcessLoginWrongCredentials(): void
+    {
+        $parameters = [
+            'returnurl' => 'http://shaarli/subfolder/admin/shaare',
+        ];
+        $request = $this->createMock(Request::class);
+        $request
+            ->expects(static::atLeastOnce())
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters) {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $this->container->loginManager->method('canLogin')->willReturn(true);
+        $this->container->loginManager->expects(static::once())->method('handleFailedLogin');
+        $this->container->loginManager->expects(static::once())->method('checkCredentials')->willReturn(false);
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_ERROR_MESSAGES, ['Wrong login/password.'])
+        ;
+
+        $result = $this->controller->login($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame(TemplatePage::LOGIN, (string) $result->getBody());
+    }
+
+    /**
+     * Test processing login with wrong token
+     */
+    public function testProcessLoginWrongToken(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->sessionManager = $this->createMock(SessionManager::class);
+        $this->container->sessionManager->method('checkToken')->willReturn(false);
+
+        $this->expectException(WrongTokenException::class);
+
+        $this->controller->login($request, $response);
+    }
+
+    /**
+     * Test processing login with wrong token
+     */
+    public function testProcessLoginAlreadyLoggedIn(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->loginManager->method('isLoggedIn')->willReturn(true);
+        $this->container->loginManager->expects(static::never())->method('handleSuccessfulLogin');
+        $this->container->loginManager->expects(static::never())->method('handleFailedLogin');
+
+        $result = $this->controller->login($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame('/subfolder/', $result->getHeader('location')[0]);
+    }
+
+    /**
+     * Test processing login with wrong token
+     */
+    public function testProcessLoginInOpenShaarli(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf->method('get')->willReturnCallback(function (string $key, $value) {
+            return 'security.open_shaarli' === $key ? true : $value;
+        });
+
+        $this->container->loginManager->expects(static::never())->method('handleSuccessfulLogin');
+        $this->container->loginManager->expects(static::never())->method('handleFailedLogin');
+
+        $result = $this->controller->login($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame('/subfolder/', $result->getHeader('location')[0]);
+    }
+
+    /**
+     * Test processing login while being banned
+     */
+    public function testProcessLoginWhileBanned(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->loginManager->method('canLogin')->willReturn(false);
+        $this->container->loginManager->expects(static::never())->method('handleSuccessfulLogin');
+        $this->container->loginManager->expects(static::never())->method('handleFailedLogin');
+
+        $this->expectException(LoginBannedException::class);
+
+        $this->controller->login($request, $response);
+    }
+}
diff --git a/tests/front/controller/visitor/OpenSearchControllerTest.php b/tests/front/controller/visitor/OpenSearchControllerTest.php
new file mode 100644 (file)
index 0000000..5f9f5b1
--- /dev/null
@@ -0,0 +1,44 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use PHPUnit\Framework\TestCase;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class OpenSearchControllerTest extends TestCase
+{
+    use FrontControllerMockHelper;
+
+    /** @var OpenSearchController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->controller = new OpenSearchController($this->container);
+    }
+
+    public function testOpenSearchController(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertStringContainsString(
+            'application/opensearchdescription+xml',
+            $result->getHeader('Content-Type')[0]
+        );
+        static::assertSame('opensearch', (string) $result->getBody());
+        static::assertSame('http://shaarli', $assignedVariables['serverurl']);
+    }
+}
diff --git a/tests/front/controller/visitor/PictureWallControllerTest.php b/tests/front/controller/visitor/PictureWallControllerTest.php
new file mode 100644 (file)
index 0000000..3dc3f29
--- /dev/null
@@ -0,0 +1,121 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use PHPUnit\Framework\TestCase;
+use Shaarli\Bookmark\Bookmark;
+use Shaarli\Config\ConfigManager;
+use Shaarli\Front\Exception\ThumbnailsDisabledException;
+use Shaarli\Thumbnailer;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class PictureWallControllerTest extends TestCase
+{
+    use FrontControllerMockHelper;
+
+    /** @var PictureWallController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->controller = new PictureWallController($this->container);
+    }
+
+    public function testValidControllerInvokeDefault(): void
+    {
+        $request = $this->createMock(Request::class);
+        $request->expects(static::once())->method('getQueryParams')->willReturn([]);
+        $response = new Response();
+
+        // ConfigManager: thumbnails are enabled
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf->method('get')->willReturnCallback(function (string $parameter, $default) {
+            if ($parameter === 'thumbnails.mode') {
+                return Thumbnailer::MODE_COMMON;
+            }
+
+            return $default;
+        });
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        // Links dataset: 2 links with thumbnails
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('search')
+            ->willReturnCallback(function (array $parameters, ?string $visibility): array {
+                // Visibility is set through the container, not the call
+                static::assertNull($visibility);
+
+                // No query parameters
+                if (count($parameters) === 0) {
+                    return [
+                        (new Bookmark())->setId(1)->setUrl('http://url.tld')->setThumbnail('thumb1'),
+                        (new Bookmark())->setId(2)->setUrl('http://url2.tld'),
+                        (new Bookmark())->setId(3)->setUrl('http://url3.tld')->setThumbnail('thumb2'),
+                    ];
+                }
+            })
+        ;
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::at(0))
+            ->method('executeHooks')
+            ->willReturnCallback(function (string $hook, array $data, array $param): array {
+                static::assertSame('render_picwall', $hook);
+                static::assertArrayHasKey('linksToDisplay', $data);
+                static::assertCount(2, $data['linksToDisplay']);
+                static::assertSame(1, $data['linksToDisplay'][0]['id']);
+                static::assertSame(3, $data['linksToDisplay'][1]['id']);
+                static::assertArrayHasKey('loggedin', $param);
+
+                return $data;
+            });
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('picwall', (string) $result->getBody());
+        static::assertSame('Picture wall - Shaarli', $assignedVariables['pagetitle']);
+        static::assertCount(2, $assignedVariables['linksToDisplay']);
+
+        $link = $assignedVariables['linksToDisplay'][0];
+
+        static::assertSame(1, $link['id']);
+        static::assertSame('http://url.tld', $link['url']);
+        static::assertSame('thumb1', $link['thumbnail']);
+
+        $link = $assignedVariables['linksToDisplay'][1];
+
+        static::assertSame(3, $link['id']);
+        static::assertSame('http://url3.tld', $link['url']);
+        static::assertSame('thumb2', $link['thumbnail']);
+    }
+
+    public function testControllerWithThumbnailsDisabled(): void
+    {
+        $this->expectException(ThumbnailsDisabledException::class);
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        // ConfigManager: thumbnails are disabled
+        $this->container->conf->method('get')->willReturnCallback(function (string $parameter, $default) {
+            if ($parameter === 'thumbnails.mode') {
+                return Thumbnailer::MODE_NONE;
+            }
+
+            return $default;
+        });
+
+        $this->controller->index($request, $response);
+    }
+}
diff --git a/tests/front/controller/visitor/PublicSessionFilterControllerTest.php b/tests/front/controller/visitor/PublicSessionFilterControllerTest.php
new file mode 100644 (file)
index 0000000..0635275
--- /dev/null
@@ -0,0 +1,122 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use PHPUnit\Framework\TestCase;
+use Shaarli\Security\SessionManager;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class PublicSessionFilterControllerTest extends TestCase
+{
+    use FrontControllerMockHelper;
+
+    /** @var PublicSessionFilterController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->controller = new PublicSessionFilterController($this->container);
+    }
+
+    /**
+     * Link per page - Default call with valid parameter and a referer.
+     */
+    public function testLinksPerPage(): void
+    {
+        $this->container->environment = ['HTTP_REFERER' => 'http://shaarli/subfolder/controller/?searchtag=abc'];
+
+        $request = $this->createMock(Request::class);
+        $request->method('getParam')->with('nb')->willReturn('8');
+        $response = new Response();
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_LINKS_PER_PAGE, 8)
+        ;
+
+        $result = $this->controller->linksPerPage($request, $response);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/controller/?searchtag=abc'], $result->getHeader('location'));
+    }
+
+    /**
+     * Link per page - Invalid value, should use default value (20)
+     */
+    public function testLinksPerPageNotValid(): void
+    {
+        $request = $this->createMock(Request::class);
+        $request->method('getParam')->with('nb')->willReturn('test');
+        $response = new Response();
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_LINKS_PER_PAGE, 20)
+        ;
+
+        $result = $this->controller->linksPerPage($request, $response);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    /**
+     * Untagged only - valid call
+     */
+    public function testUntaggedOnly(): void
+    {
+        $this->container->environment = ['HTTP_REFERER' => 'http://shaarli/subfolder/controller/?searchtag=abc'];
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_UNTAGGED_ONLY, true)
+        ;
+
+        $result = $this->controller->untaggedOnly($request, $response);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/controller/?searchtag=abc'], $result->getHeader('location'));
+    }
+
+    /**
+     * Untagged only - toggle off
+     */
+    public function testUntaggedOnlyToggleOff(): void
+    {
+        $this->container->environment = ['HTTP_REFERER' => 'http://shaarli/subfolder/controller/?searchtag=abc'];
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->sessionManager
+            ->method('getSessionParameter')
+            ->with(SessionManager::KEY_UNTAGGED_ONLY)
+            ->willReturn(true)
+        ;
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_UNTAGGED_ONLY, false)
+        ;
+
+        $result = $this->controller->untaggedOnly($request, $response);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/controller/?searchtag=abc'], $result->getHeader('location'));
+    }
+}
diff --git a/tests/front/controller/visitor/ShaarliVisitorControllerTest.php b/tests/front/controller/visitor/ShaarliVisitorControllerTest.php
new file mode 100644 (file)
index 0000000..316ce49
--- /dev/null
@@ -0,0 +1,215 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use PHPUnit\Framework\TestCase;
+use Shaarli\Bookmark\BookmarkFilter;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Class ShaarliControllerTest
+ *
+ * This class is used to test default behavior of ShaarliVisitorController abstract class.
+ * It uses a dummy non abstract controller.
+ */
+class ShaarliVisitorControllerTest extends TestCase
+{
+    use FrontControllerMockHelper;
+
+    /** @var LoginController */
+    protected $controller;
+
+    /** @var mixed[] List of variable assigned to the template */
+    protected $assignedValues;
+
+    /** @var Request */
+    protected $request;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->controller = new class($this->container) extends ShaarliVisitorController
+        {
+            public function assignView(string $key, $value): ShaarliVisitorController
+            {
+                return parent::assignView($key, $value);
+            }
+
+            public function render(string $template): string
+            {
+                return parent::render($template);
+            }
+
+            public function redirectFromReferer(
+                Request $request,
+                Response $response,
+                array $loopTerms = [],
+                array $clearParams = [],
+                string $anchor = null
+            ): Response {
+                return parent::redirectFromReferer($request, $response, $loopTerms, $clearParams, $anchor);
+            }
+        };
+        $this->assignedValues = [];
+
+        $this->request = $this->createMock(Request::class);
+    }
+
+    public function testAssignView(): void
+    {
+        $this->assignTemplateVars($this->assignedValues);
+
+        $self = $this->controller->assignView('variableName', 'variableValue');
+
+        static::assertInstanceOf(ShaarliVisitorController::class, $self);
+        static::assertSame('variableValue', $this->assignedValues['variableName']);
+    }
+
+    public function testRender(): void
+    {
+        $this->assignTemplateVars($this->assignedValues);
+
+        $this->container->bookmarkService
+            ->method('count')
+            ->willReturnCallback(function (string $visibility): int {
+                return $visibility === BookmarkFilter::$PRIVATE ? 5 : 10;
+            })
+        ;
+
+        $this->container->pluginManager
+            ->method('executeHooks')
+            ->willReturnCallback(function (string $hook, array &$data, array $params): array {
+                return $data[$hook] = $params;
+            });
+        $this->container->pluginManager->method('getErrors')->willReturn(['error']);
+
+        $this->container->loginManager->method('isLoggedIn')->willReturn(true);
+
+        $render = $this->controller->render('templateName');
+
+        static::assertSame('templateName', $render);
+
+        static::assertSame(10, $this->assignedValues['linkcount']);
+        static::assertSame(5, $this->assignedValues['privateLinkcount']);
+        static::assertSame(['error'], $this->assignedValues['plugin_errors']);
+
+        static::assertSame('templateName', $this->assignedValues['plugins_includes']['render_includes']['target']);
+        static::assertTrue($this->assignedValues['plugins_includes']['render_includes']['loggedin']);
+        static::assertSame('templateName', $this->assignedValues['plugins_header']['render_header']['target']);
+        static::assertTrue($this->assignedValues['plugins_header']['render_header']['loggedin']);
+        static::assertSame('templateName', $this->assignedValues['plugins_footer']['render_footer']['target']);
+        static::assertTrue($this->assignedValues['plugins_footer']['render_footer']['loggedin']);
+    }
+
+    /**
+     * Test redirectFromReferer() - Default behaviour
+     */
+    public function testRedirectFromRefererDefault(): void
+    {
+        $this->container->environment['HTTP_REFERER'] = 'http://shaarli.tld/subfolder/controller?query=param&other=2';
+
+        $response = new Response();
+
+        $result = $this->controller->redirectFromReferer($this->request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/controller?query=param&other=2'], $result->getHeader('location'));
+    }
+
+    /**
+     * Test redirectFromReferer() - With a loop term not matched in the referer
+     */
+    public function testRedirectFromRefererWithUnmatchedLoopTerm(): void
+    {
+        $this->container->environment['HTTP_REFERER'] = 'http://shaarli.tld/subfolder/controller?query=param&other=2';
+
+        $response = new Response();
+
+        $result = $this->controller->redirectFromReferer($this->request, $response, ['nope']);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/controller?query=param&other=2'], $result->getHeader('location'));
+    }
+
+    /**
+     * Test redirectFromReferer() - With a loop term matching the referer in its path -> redirect to default
+     */
+    public function testRedirectFromRefererWithMatchingLoopTermInPath(): void
+    {
+        $this->container->environment['HTTP_REFERER'] = 'http://shaarli.tld/subfolder/controller?query=param&other=2';
+
+        $response = new Response();
+
+        $result = $this->controller->redirectFromReferer($this->request, $response, ['nope', 'controller']);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    /**
+     * Test redirectFromReferer() - With a loop term matching the referer in its query parameters -> redirect to default
+     */
+    public function testRedirectFromRefererWithMatchingLoopTermInQueryParam(): void
+    {
+        $this->container->environment['HTTP_REFERER'] = 'http://shaarli.tld/subfolder/controller?query=param&other=2';
+
+        $response = new Response();
+
+        $result = $this->controller->redirectFromReferer($this->request, $response, ['nope', 'other']);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    /**
+     * Test redirectFromReferer() - With a loop term matching the referer in its query value
+     *                              -> we do not block redirection for query parameter values.
+     */
+    public function testRedirectFromRefererWithMatchingLoopTermInQueryValue(): void
+    {
+        $this->container->environment['HTTP_REFERER'] = 'http://shaarli.tld/subfolder/controller?query=param&other=2';
+
+        $response = new Response();
+
+        $result = $this->controller->redirectFromReferer($this->request, $response, ['nope', 'param']);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/controller?query=param&other=2'], $result->getHeader('location'));
+    }
+
+    /**
+     * Test redirectFromReferer() - With a loop term matching the referer in its domain name
+     *                              -> we do not block redirection for shaarli's hosts
+     */
+    public function testRedirectFromRefererWithLoopTermInDomain(): void
+    {
+        $this->container->environment['HTTP_REFERER'] = 'http://shaarli.tld/subfolder/controller?query=param&other=2';
+
+        $response = new Response();
+
+        $result = $this->controller->redirectFromReferer($this->request, $response, ['shaarli']);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/controller?query=param&other=2'], $result->getHeader('location'));
+    }
+
+    /**
+     * Test redirectFromReferer() - With a loop term matching a query parameter AND clear this query param
+     *                              -> the param should be cleared before checking if it matches the redir loop terms
+     */
+    public function testRedirectFromRefererWithMatchingClearedParam(): void
+    {
+        $this->container->environment['HTTP_REFERER'] = 'http://shaarli.tld/subfolder/controller?query=param&other=2';
+
+        $response = new Response();
+
+        $result = $this->controller->redirectFromReferer($this->request, $response, ['query'], ['query']);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/controller?other=2'], $result->getHeader('location'));
+    }
+}
diff --git a/tests/front/controller/visitor/TagCloudControllerTest.php b/tests/front/controller/visitor/TagCloudControllerTest.php
new file mode 100644 (file)
index 0000000..9a6a4bc
--- /dev/null
@@ -0,0 +1,369 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use PHPUnit\Framework\TestCase;
+use Shaarli\Bookmark\BookmarkFilter;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class TagCloudControllerTest extends TestCase
+{
+    use FrontControllerMockHelper;
+
+    /** @var TagCloudController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->controller = new TagCloudController($this->container);
+    }
+
+    /**
+     * Tag Cloud - default parameters
+     */
+    public function testValidCloudControllerInvokeDefault(): void
+    {
+        $allTags = [
+            'ghi' => 1,
+            'abc' => 3,
+            'def' => 12,
+        ];
+        $expectedOrder = ['abc', 'def', 'ghi'];
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('bookmarksCountPerTag')
+            ->with([], null)
+            ->willReturnCallback(function () use ($allTags): array {
+                return $allTags;
+            })
+        ;
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::at(0))
+            ->method('executeHooks')
+            ->willReturnCallback(function (string $hook, array $data, array $param): array {
+                static::assertSame('render_tagcloud', $hook);
+                static::assertSame('', $data['search_tags']);
+                static::assertCount(3, $data['tags']);
+
+                static::assertArrayHasKey('loggedin', $param);
+
+                return $data;
+            })
+        ;
+
+        $result = $this->controller->cloud($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('tag.cloud', (string) $result->getBody());
+        static::assertSame('Tag cloud - Shaarli', $assignedVariables['pagetitle']);
+
+        static::assertSame('', $assignedVariables['search_tags']);
+        static::assertCount(3, $assignedVariables['tags']);
+        static::assertSame($expectedOrder, array_keys($assignedVariables['tags']));
+
+        foreach ($allTags as $tag => $count) {
+            static::assertArrayHasKey($tag, $assignedVariables['tags']);
+            static::assertSame($count, $assignedVariables['tags'][$tag]['count']);
+            static::assertGreaterThan(0, $assignedVariables['tags'][$tag]['size']);
+            static::assertLessThan(5, $assignedVariables['tags'][$tag]['size']);
+        }
+    }
+
+    /**
+     * Tag Cloud - Additional parameters:
+     *   - logged in
+     *   - visibility private
+     *   - search tags: `ghi` and `def` (note that filtered tags are not displayed anymore)
+     */
+    public function testValidCloudControllerInvokeWithParameters(): void
+    {
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getQueryParam')
+            ->with()
+            ->willReturnCallback(function (string $key): ?string {
+                if ('searchtags' === $key) {
+                    return 'ghi def';
+                }
+
+                return null;
+            })
+        ;
+        $response = new Response();
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $this->container->loginManager->method('isLoggedin')->willReturn(true);
+        $this->container->sessionManager->expects(static::once())->method('getSessionParameter')->willReturn('private');
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('bookmarksCountPerTag')
+            ->with(['ghi', 'def'], BookmarkFilter::$PRIVATE)
+            ->willReturnCallback(function (): array {
+                return ['abc' => 3];
+            })
+        ;
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::at(0))
+            ->method('executeHooks')
+            ->willReturnCallback(function (string $hook, array $data, array $param): array {
+                static::assertSame('render_tagcloud', $hook);
+                static::assertSame('ghi def', $data['search_tags']);
+                static::assertCount(1, $data['tags']);
+
+                static::assertArrayHasKey('loggedin', $param);
+
+                return $data;
+            })
+        ;
+
+        $result = $this->controller->cloud($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('tag.cloud', (string) $result->getBody());
+        static::assertSame('ghi def - Tag cloud - Shaarli', $assignedVariables['pagetitle']);
+
+        static::assertSame('ghi def', $assignedVariables['search_tags']);
+        static::assertCount(1, $assignedVariables['tags']);
+
+        static::assertArrayHasKey('abc', $assignedVariables['tags']);
+        static::assertSame(3, $assignedVariables['tags']['abc']['count']);
+        static::assertGreaterThan(0, $assignedVariables['tags']['abc']['size']);
+        static::assertLessThan(5, $assignedVariables['tags']['abc']['size']);
+    }
+
+    /**
+     * Tag Cloud - empty
+     */
+    public function testEmptyCloud(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('bookmarksCountPerTag')
+            ->with([], null)
+            ->willReturnCallback(function (array $parameters, ?string $visibility): array {
+                return [];
+            })
+        ;
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::at(0))
+            ->method('executeHooks')
+            ->willReturnCallback(function (string $hook, array $data, array $param): array {
+                static::assertSame('render_tagcloud', $hook);
+                static::assertSame('', $data['search_tags']);
+                static::assertCount(0, $data['tags']);
+
+                static::assertArrayHasKey('loggedin', $param);
+
+                return $data;
+            })
+        ;
+
+        $result = $this->controller->cloud($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('tag.cloud', (string) $result->getBody());
+        static::assertSame('Tag cloud - Shaarli', $assignedVariables['pagetitle']);
+
+        static::assertSame('', $assignedVariables['search_tags']);
+        static::assertCount(0, $assignedVariables['tags']);
+    }
+
+    /**
+     * Tag List - Default sort is by usage DESC
+     */
+    public function testValidListControllerInvokeDefault(): void
+    {
+        $allTags = [
+            'def' => 12,
+            'abc' => 3,
+            'ghi' => 1,
+        ];
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('bookmarksCountPerTag')
+            ->with([], null)
+            ->willReturnCallback(function () use ($allTags): array {
+                return $allTags;
+            })
+        ;
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::at(0))
+            ->method('executeHooks')
+            ->willReturnCallback(function (string $hook, array $data, array $param): array {
+                static::assertSame('render_taglist', $hook);
+                static::assertSame('', $data['search_tags']);
+                static::assertCount(3, $data['tags']);
+
+                static::assertArrayHasKey('loggedin', $param);
+
+                return $data;
+            })
+        ;
+
+        $result = $this->controller->list($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('tag.list', (string) $result->getBody());
+        static::assertSame('Tag list - Shaarli', $assignedVariables['pagetitle']);
+
+        static::assertSame('', $assignedVariables['search_tags']);
+        static::assertCount(3, $assignedVariables['tags']);
+
+        foreach ($allTags as $tag => $count) {
+            static::assertSame($count, $assignedVariables['tags'][$tag]);
+        }
+    }
+
+    /**
+     * Tag List - Additional parameters:
+     *   - logged in
+     *   - visibility private
+     *   - search tags: `ghi` and `def` (note that filtered tags are not displayed anymore)
+     *   - sort alphabetically
+     */
+    public function testValidListControllerInvokeWithParameters(): void
+    {
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getQueryParam')
+            ->with()
+            ->willReturnCallback(function (string $key): ?string {
+                if ('searchtags' === $key) {
+                    return 'ghi def';
+                } elseif ('sort' === $key) {
+                    return 'alpha';
+                }
+
+                return null;
+            })
+        ;
+        $response = new Response();
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $this->container->loginManager->method('isLoggedin')->willReturn(true);
+        $this->container->sessionManager->expects(static::once())->method('getSessionParameter')->willReturn('private');
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('bookmarksCountPerTag')
+            ->with(['ghi', 'def'], BookmarkFilter::$PRIVATE)
+            ->willReturnCallback(function (): array {
+                return ['abc' => 3];
+            })
+        ;
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::at(0))
+            ->method('executeHooks')
+            ->willReturnCallback(function (string $hook, array $data, array $param): array {
+                static::assertSame('render_taglist', $hook);
+                static::assertSame('ghi def', $data['search_tags']);
+                static::assertCount(1, $data['tags']);
+
+                static::assertArrayHasKey('loggedin', $param);
+
+                return $data;
+            })
+        ;
+
+        $result = $this->controller->list($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('tag.list', (string) $result->getBody());
+        static::assertSame('ghi def - Tag list - Shaarli', $assignedVariables['pagetitle']);
+
+        static::assertSame('ghi def', $assignedVariables['search_tags']);
+        static::assertCount(1, $assignedVariables['tags']);
+        static::assertSame(3, $assignedVariables['tags']['abc']);
+    }
+
+    /**
+     * Tag List - empty
+     */
+    public function testEmptyList(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('bookmarksCountPerTag')
+            ->with([], null)
+            ->willReturnCallback(function (array $parameters, ?string $visibility): array {
+                return [];
+            })
+        ;
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::at(0))
+            ->method('executeHooks')
+            ->willReturnCallback(function (string $hook, array $data, array $param): array {
+                static::assertSame('render_taglist', $hook);
+                static::assertSame('', $data['search_tags']);
+                static::assertCount(0, $data['tags']);
+
+                static::assertArrayHasKey('loggedin', $param);
+
+                return $data;
+            })
+        ;
+
+        $result = $this->controller->list($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('tag.list', (string) $result->getBody());
+        static::assertSame('Tag list - Shaarli', $assignedVariables['pagetitle']);
+
+        static::assertSame('', $assignedVariables['search_tags']);
+        static::assertCount(0, $assignedVariables['tags']);
+    }
+}
diff --git a/tests/front/controller/visitor/TagControllerTest.php b/tests/front/controller/visitor/TagControllerTest.php
new file mode 100644 (file)
index 0000000..4307608
--- /dev/null
@@ -0,0 +1,215 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use PHPUnit\Framework\TestCase;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class TagControllerTest extends TestCase
+{
+    use FrontControllerMockHelper;
+
+    /** @var TagController */    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->controller = new TagController($this->container);
+    }
+
+    public function testAddTagWithReferer(): void
+    {
+        $this->container->environment = ['HTTP_REFERER' => 'http://shaarli/controller/'];
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $tags = ['newTag' => 'abc'];
+
+        $result = $this->controller->addTag($request, $response, $tags);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/controller/?searchtags=abc'], $result->getHeader('location'));
+    }
+
+    public function testAddTagWithRefererAndExistingSearch(): void
+    {
+        $this->container->environment = ['HTTP_REFERER' => 'http://shaarli/controller/?searchtags=def'];
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $tags = ['newTag' => 'abc'];
+
+        $result = $this->controller->addTag($request, $response, $tags);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/controller/?searchtags=def+abc'], $result->getHeader('location'));
+    }
+
+    public function testAddTagWithoutRefererAndExistingSearch(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $tags = ['newTag' => 'abc'];
+
+        $result = $this->controller->addTag($request, $response, $tags);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/?searchtags=abc'], $result->getHeader('location'));
+    }
+
+    public function testAddTagRemoveLegacyQueryParam(): void
+    {
+        $this->container->environment = ['HTTP_REFERER' => 'http://shaarli/controller/?searchtags=def&addtag=abc'];
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $tags = ['newTag' => 'abc'];
+
+        $result = $this->controller->addTag($request, $response, $tags);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/controller/?searchtags=def+abc'], $result->getHeader('location'));
+    }
+
+    public function testAddTagResetPagination(): void
+    {
+        $this->container->environment = ['HTTP_REFERER' => 'http://shaarli/controller/?searchtags=def&page=12'];
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $tags = ['newTag' => 'abc'];
+
+        $result = $this->controller->addTag($request, $response, $tags);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/controller/?searchtags=def+abc'], $result->getHeader('location'));
+    }
+
+    public function testAddTagWithRefererAndEmptySearch(): void
+    {
+        $this->container->environment = ['HTTP_REFERER' => 'http://shaarli/controller/?searchtags='];
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $tags = ['newTag' => 'abc'];
+
+        $result = $this->controller->addTag($request, $response, $tags);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/controller/?searchtags=abc'], $result->getHeader('location'));
+    }
+
+    public function testAddTagWithoutNewTagWithReferer(): void
+    {
+        $this->container->environment = ['HTTP_REFERER' => 'http://shaarli/controller/?searchtags=def'];
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $result = $this->controller->addTag($request, $response, []);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/controller/?searchtags=def'], $result->getHeader('location'));
+    }
+
+    public function testAddTagWithoutNewTagWithoutReferer(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $result = $this->controller->addTag($request, $response, []);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    public function testRemoveTagWithoutMatchingTag(): void
+    {
+        $this->container->environment = ['HTTP_REFERER' => 'http://shaarli/controller/?searchtags=def'];
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $tags = ['tag' => 'abc'];
+
+        $result = $this->controller->removeTag($request, $response, $tags);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/controller/?searchtags=def'], $result->getHeader('location'));
+    }
+
+    public function testRemoveTagWithoutTagsearch(): void
+    {
+        $this->container->environment = ['HTTP_REFERER' => 'http://shaarli/controller/'];
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $tags = ['tag' => 'abc'];
+
+        $result = $this->controller->removeTag($request, $response, $tags);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/controller/'], $result->getHeader('location'));
+    }
+
+    public function testRemoveTagWithoutReferer(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $tags = ['tag' => 'abc'];
+
+        $result = $this->controller->removeTag($request, $response, $tags);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    public function testRemoveTagWithoutTag(): void
+    {
+        $this->container->environment = ['HTTP_REFERER' => 'http://shaarli/controller/?searchtag=abc'];
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $result = $this->controller->removeTag($request, $response, []);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/controller/?searchtag=abc'], $result->getHeader('location'));
+    }
+
+    public function testRemoveTagWithoutTagWithoutReferer(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $result = $this->controller->removeTag($request, $response, []);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+}
index bcbe59cbf6abe493a392f429fb96f1c54cb2d46c..73d33cd450d783237829c133641783b5715243b3 100644 (file)
@@ -71,4 +71,36 @@ class IndexUrlTest extends \PHPUnit\Framework\TestCase
             )
         );
     }
+
+    /**
+     * The route is stored in REQUEST_URI
+     */
+    public function testPageUrlWithRoute()
+    {
+        $this->assertEquals(
+            'http://host.tld/picture-wall',
+            page_url(
+                array(
+                    'HTTPS' => 'Off',
+                    'SERVER_NAME' => 'host.tld',
+                    'SERVER_PORT' => '80',
+                    'SCRIPT_NAME' => '/index.php',
+                    'REQUEST_URI' => '/picture-wall',
+                )
+            )
+        );
+
+        $this->assertEquals(
+            'http://host.tld/admin/picture-wall',
+            page_url(
+                array(
+                    'HTTPS' => 'Off',
+                    'SERVER_NAME' => 'host.tld',
+                    'SERVER_PORT' => '80',
+                    'SCRIPT_NAME' => '/admin/index.php',
+                    'REQUEST_URI' => '/admin/picture-wall',
+                )
+            )
+        );
+    }
 }
diff --git a/tests/legacy/LegacyControllerTest.php b/tests/legacy/LegacyControllerTest.php
new file mode 100644 (file)
index 0000000..759a5b2
--- /dev/null
@@ -0,0 +1,99 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Legacy;
+
+use PHPUnit\Framework\TestCase;
+use Shaarli\Front\Controller\Visitor\FrontControllerMockHelper;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class LegacyControllerTest extends TestCase
+{
+    use FrontControllerMockHelper;
+
+    /** @var LegacyController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->controller = new LegacyController($this->container);
+    }
+
+    /**
+     * @dataProvider getProcessProvider
+     */
+    public function testProcess(string $legacyRoute, array $queryParameters, string $slimRoute, bool $isLoggedIn): void
+    {
+        $request = $this->createMock(Request::class);
+        $request->method('getQueryParams')->willReturn($queryParameters);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($queryParameters): ?string {
+                return $queryParameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $this->container->loginManager->method('isLoggedIn')->willReturn($isLoggedIn);
+
+        $result = $this->controller->process($request, $response, $legacyRoute);
+
+        static::assertSame('/subfolder' . $slimRoute, $result->getHeader('location')[0]);
+    }
+
+    public function testProcessNotFound(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->expectException(UnknowLegacyRouteException::class);
+
+        $this->controller->process($request, $response, 'nope');
+    }
+
+    /**
+     * @return array[] Parameters:
+     *                   - string legacyRoute
+     *                   - array  queryParameters
+     *                   - string slimRoute
+     *                   - bool   isLoggedIn
+     */
+    public function getProcessProvider(): array
+    {
+        return [
+            ['post', [], '/admin/shaare', true],
+            ['post', [], '/login', false],
+            ['post', ['title' => 'test'], '/admin/shaare?title=test', true],
+            ['post', ['title' => 'test'], '/login?title=test', false],
+            ['addlink', [], '/admin/add-shaare', true],
+            ['addlink', [], '/login', false],
+            ['login', [], '/login', true],
+            ['login', [], '/login', false],
+            ['logout', [], '/admin/logout', true],
+            ['logout', [], '/admin/logout', false],
+            ['picwall', [], '/picture-wall', false],
+            ['picwall', [], '/picture-wall', true],
+            ['tagcloud', [], '/tags/cloud', false],
+            ['tagcloud', [], '/tags/cloud', true],
+            ['taglist', [], '/tags/list', false],
+            ['taglist', [], '/tags/list', true],
+            ['daily', [], '/daily', false],
+            ['daily', [], '/daily', true],
+            ['daily', ['day' => '123456789', 'discard' => '1'], '/daily?day=123456789', false],
+            ['rss', [], '/feed/rss', false],
+            ['rss', [], '/feed/rss', true],
+            ['rss', ['search' => 'filter123', 'other' => 'param'], '/feed/rss?search=filter123&other=param', false],
+            ['atom', [], '/feed/atom', false],
+            ['atom', [], '/feed/atom', true],
+            ['atom', ['search' => 'filter123', 'other' => 'param'], '/feed/atom?search=filter123&other=param', false],
+            ['opensearch', [], '/open-search', false],
+            ['opensearch', [], '/open-search', true],
+            ['dailyrss', [], '/daily-rss', false],
+            ['dailyrss', [], '/daily-rss', true],
+        ];
+    }
+}
index 17b2b0e6cc35d277c87ce9949bfa8a7ee525f27d..0884ad03ed2bd40837bc08ac4d1d60adb28820f6 100644 (file)
@@ -11,7 +11,6 @@ use ReflectionClass;
 use Shaarli;
 use Shaarli\Bookmark\Bookmark;
 
-require_once 'application/feed/Cache.php';
 require_once 'application/Utils.php';
 require_once 'tests/utils/ReferenceLinkDB.php';
 
similarity index 51%
rename from tests/RouterTest.php
rename to tests/legacy/LegacyRouterTest.php
index 0cd49bb8fb2ea6951018e818bc3b8a9907c242d7..c2019ca7f3fc8e0563a909e542360c9bc717966a 100644 (file)
@@ -1,10 +1,13 @@
 <?php
-namespace Shaarli;
+
+namespace Shaarli\Legacy;
+
+use PHPUnit\Framework\TestCase;
 
 /**
  * Unit tests for Router
  */
-class RouterTest extends \PHPUnit\Framework\TestCase
+class LegacyRouterTest extends TestCase
 {
     /**
      * Test findPage: login page output.
@@ -15,18 +18,18 @@ class RouterTest extends \PHPUnit\Framework\TestCase
     public function testFindPageLoginValid()
     {
         $this->assertEquals(
-            Router::$PAGE_LOGIN,
-            Router::findPage('do=login', array(), false)
+            LegacyRouter::$PAGE_LOGIN,
+            LegacyRouter::findPage('do=login', array(), false)
         );
 
         $this->assertEquals(
-            Router::$PAGE_LOGIN,
-            Router::findPage('do=login', array(), 1)
+            LegacyRouter::$PAGE_LOGIN,
+            LegacyRouter::findPage('do=login', array(), 1)
         );
 
         $this->assertEquals(
-            Router::$PAGE_LOGIN,
-            Router::findPage('do=login&stuff', array(), false)
+            LegacyRouter::$PAGE_LOGIN,
+            LegacyRouter::findPage('do=login&stuff', array(), false)
         );
     }
 
@@ -39,13 +42,13 @@ class RouterTest extends \PHPUnit\Framework\TestCase
     public function testFindPageLoginInvalid()
     {
         $this->assertNotEquals(
-            Router::$PAGE_LOGIN,
-            Router::findPage('do=login', array(), true)
+            LegacyRouter::$PAGE_LOGIN,
+            LegacyRouter::findPage('do=login', array(), true)
         );
 
         $this->assertNotEquals(
-            Router::$PAGE_LOGIN,
-            Router::findPage('do=other', array(), false)
+            LegacyRouter::$PAGE_LOGIN,
+            LegacyRouter::findPage('do=other', array(), false)
         );
     }
 
@@ -58,13 +61,13 @@ class RouterTest extends \PHPUnit\Framework\TestCase
     public function testFindPagePicwallValid()
     {
         $this->assertEquals(
-            Router::$PAGE_PICWALL,
-            Router::findPage('do=picwall', array(), false)
+            LegacyRouter::$PAGE_PICWALL,
+            LegacyRouter::findPage('do=picwall', array(), false)
         );
 
         $this->assertEquals(
-            Router::$PAGE_PICWALL,
-            Router::findPage('do=picwall', array(), true)
+            LegacyRouter::$PAGE_PICWALL,
+            LegacyRouter::findPage('do=picwall', array(), true)
         );
     }
 
@@ -77,13 +80,13 @@ class RouterTest extends \PHPUnit\Framework\TestCase
     public function testFindPagePicwallInvalid()
     {
         $this->assertEquals(
-            Router::$PAGE_PICWALL,
-            Router::findPage('do=picwall&stuff', array(), false)
+            LegacyRouter::$PAGE_PICWALL,
+            LegacyRouter::findPage('do=picwall&stuff', array(), false)
         );
 
         $this->assertNotEquals(
-            Router::$PAGE_PICWALL,
-            Router::findPage('do=other', array(), false)
+            LegacyRouter::$PAGE_PICWALL,
+            LegacyRouter::findPage('do=other', array(), false)
         );
     }
 
@@ -96,18 +99,18 @@ class RouterTest extends \PHPUnit\Framework\TestCase
     public function testFindPageTagcloudValid()
     {
         $this->assertEquals(
-            Router::$PAGE_TAGCLOUD,
-            Router::findPage('do=tagcloud', array(), false)
+            LegacyRouter::$PAGE_TAGCLOUD,
+            LegacyRouter::findPage('do=tagcloud', array(), false)
         );
 
         $this->assertEquals(
-            Router::$PAGE_TAGCLOUD,
-            Router::findPage('do=tagcloud', array(), true)
+            LegacyRouter::$PAGE_TAGCLOUD,
+            LegacyRouter::findPage('do=tagcloud', array(), true)
         );
 
         $this->assertEquals(
-            Router::$PAGE_TAGCLOUD,
-            Router::findPage('do=tagcloud&stuff', array(), false)
+            LegacyRouter::$PAGE_TAGCLOUD,
+            LegacyRouter::findPage('do=tagcloud&stuff', array(), false)
         );
     }
 
@@ -120,8 +123,8 @@ class RouterTest extends \PHPUnit\Framework\TestCase
     public function testFindPageTagcloudInvalid()
     {
         $this->assertNotEquals(
-            Router::$PAGE_TAGCLOUD,
-            Router::findPage('do=other', array(), false)
+            LegacyRouter::$PAGE_TAGCLOUD,
+            LegacyRouter::findPage('do=other', array(), false)
         );
     }
 
@@ -134,23 +137,23 @@ class RouterTest extends \PHPUnit\Framework\TestCase
     public function testFindPageLinklistValid()
     {
         $this->assertEquals(
-            Router::$PAGE_LINKLIST,
-            Router::findPage('', array(), true)
+            LegacyRouter::$PAGE_LINKLIST,
+            LegacyRouter::findPage('', array(), true)
         );
 
         $this->assertEquals(
-            Router::$PAGE_LINKLIST,
-            Router::findPage('whatever', array(), true)
+            LegacyRouter::$PAGE_LINKLIST,
+            LegacyRouter::findPage('whatever', array(), true)
         );
 
         $this->assertEquals(
-            Router::$PAGE_LINKLIST,
-            Router::findPage('whatever', array(), false)
+            LegacyRouter::$PAGE_LINKLIST,
+            LegacyRouter::findPage('whatever', array(), false)
         );
 
         $this->assertEquals(
-            Router::$PAGE_LINKLIST,
-            Router::findPage('do=tools', array(), false)
+            LegacyRouter::$PAGE_LINKLIST,
+            LegacyRouter::findPage('do=tools', array(), false)
         );
     }
 
@@ -163,13 +166,13 @@ class RouterTest extends \PHPUnit\Framework\TestCase
     public function testFindPageToolsValid()
     {
         $this->assertEquals(
-            Router::$PAGE_TOOLS,
-            Router::findPage('do=tools', array(), true)
+            LegacyRouter::$PAGE_TOOLS,
+            LegacyRouter::findPage('do=tools', array(), true)
         );
 
         $this->assertEquals(
-            Router::$PAGE_TOOLS,
-            Router::findPage('do=tools&stuff', array(), true)
+            LegacyRouter::$PAGE_TOOLS,
+            LegacyRouter::findPage('do=tools&stuff', array(), true)
         );
     }
 
@@ -182,18 +185,18 @@ class RouterTest extends \PHPUnit\Framework\TestCase
     public function testFindPageToolsInvalid()
     {
         $this->assertNotEquals(
-            Router::$PAGE_TOOLS,
-            Router::findPage('do=tools', array(), 1)
+            LegacyRouter::$PAGE_TOOLS,
+            LegacyRouter::findPage('do=tools', array(), 1)
         );
 
         $this->assertNotEquals(
-            Router::$PAGE_TOOLS,
-            Router::findPage('do=tools', array(), false)
+            LegacyRouter::$PAGE_TOOLS,
+            LegacyRouter::findPage('do=tools', array(), false)
         );
 
         $this->assertNotEquals(
-            Router::$PAGE_TOOLS,
-            Router::findPage('do=other', array(), true)
+            LegacyRouter::$PAGE_TOOLS,
+            LegacyRouter::findPage('do=other', array(), true)
         );
     }
 
@@ -206,12 +209,12 @@ class RouterTest extends \PHPUnit\Framework\TestCase
     public function testFindPageChangepasswdValid()
     {
         $this->assertEquals(
-            Router::$PAGE_CHANGEPASSWORD,
-            Router::findPage('do=changepasswd', array(), true)
+            LegacyRouter::$PAGE_CHANGEPASSWORD,
+            LegacyRouter::findPage('do=changepasswd', array(), true)
         );
         $this->assertEquals(
-            Router::$PAGE_CHANGEPASSWORD,
-            Router::findPage('do=changepasswd&stuff', array(), true)
+            LegacyRouter::$PAGE_CHANGEPASSWORD,
+            LegacyRouter::findPage('do=changepasswd&stuff', array(), true)
         );
     }
 
@@ -224,18 +227,18 @@ class RouterTest extends \PHPUnit\Framework\TestCase
     public function testFindPageChangepasswdInvalid()
     {
         $this->assertNotEquals(
-            Router::$PAGE_CHANGEPASSWORD,
-            Router::findPage('do=changepasswd', array(), 1)
+            LegacyRouter::$PAGE_CHANGEPASSWORD,
+            LegacyRouter::findPage('do=changepasswd', array(), 1)
         );
 
         $this->assertNotEquals(
-            Router::$PAGE_CHANGEPASSWORD,
-            Router::findPage('do=changepasswd', array(), false)
+            LegacyRouter::$PAGE_CHANGEPASSWORD,
+            LegacyRouter::findPage('do=changepasswd', array(), false)
         );
 
         $this->assertNotEquals(
-            Router::$PAGE_CHANGEPASSWORD,
-            Router::findPage('do=other', array(), true)
+            LegacyRouter::$PAGE_CHANGEPASSWORD,
+            LegacyRouter::findPage('do=other', array(), true)
         );
     }
     /**
@@ -247,13 +250,13 @@ class RouterTest extends \PHPUnit\Framework\TestCase
     public function testFindPageConfigureValid()
     {
         $this->assertEquals(
-            Router::$PAGE_CONFIGURE,
-            Router::findPage('do=configure', array(), true)
+            LegacyRouter::$PAGE_CONFIGURE,
+            LegacyRouter::findPage('do=configure', array(), true)
         );
 
         $this->assertEquals(
-            Router::$PAGE_CONFIGURE,
-            Router::findPage('do=configure&stuff', array(), true)
+            LegacyRouter::$PAGE_CONFIGURE,
+            LegacyRouter::findPage('do=configure&stuff', array(), true)
         );
     }
 
@@ -266,18 +269,18 @@ class RouterTest extends \PHPUnit\Framework\TestCase
     public function testFindPageConfigureInvalid()
     {
         $this->assertNotEquals(
-            Router::$PAGE_CONFIGURE,
-            Router::findPage('do=configure', array(), 1)
+            LegacyRouter::$PAGE_CONFIGURE,
+            LegacyRouter::findPage('do=configure', array(), 1)
         );
 
         $this->assertNotEquals(
-            Router::$PAGE_CONFIGURE,
-            Router::findPage('do=configure', array(), false)
+            LegacyRouter::$PAGE_CONFIGURE,
+            LegacyRouter::findPage('do=configure', array(), false)
         );
 
         $this->assertNotEquals(
-            Router::$PAGE_CONFIGURE,
-            Router::findPage('do=other', array(), true)
+            LegacyRouter::$PAGE_CONFIGURE,
+            LegacyRouter::findPage('do=other', array(), true)
         );
     }
 
@@ -290,13 +293,13 @@ class RouterTest extends \PHPUnit\Framework\TestCase
     public function testFindPageChangetagValid()
     {
         $this->assertEquals(
-            Router::$PAGE_CHANGETAG,
-            Router::findPage('do=changetag', array(), true)
+            LegacyRouter::$PAGE_CHANGETAG,
+            LegacyRouter::findPage('do=changetag', array(), true)
         );
 
         $this->assertEquals(
-            Router::$PAGE_CHANGETAG,
-            Router::findPage('do=changetag&stuff', array(), true)
+            LegacyRouter::$PAGE_CHANGETAG,
+            LegacyRouter::findPage('do=changetag&stuff', array(), true)
         );
     }
 
@@ -309,18 +312,18 @@ class RouterTest extends \PHPUnit\Framework\TestCase
     public function testFindPageChangetagInvalid()
     {
         $this->assertNotEquals(
-            Router::$PAGE_CHANGETAG,
-            Router::findPage('do=changetag', array(), 1)
+            LegacyRouter::$PAGE_CHANGETAG,
+            LegacyRouter::findPage('do=changetag', array(), 1)
         );
 
         $this->assertNotEquals(
-            Router::$PAGE_CHANGETAG,
-            Router::findPage('do=changetag', array(), false)
+            LegacyRouter::$PAGE_CHANGETAG,
+            LegacyRouter::findPage('do=changetag', array(), false)
         );
 
         $this->assertNotEquals(
-            Router::$PAGE_CHANGETAG,
-            Router::findPage('do=other', array(), true)
+            LegacyRouter::$PAGE_CHANGETAG,
+            LegacyRouter::findPage('do=other', array(), true)
         );
     }
 
@@ -333,13 +336,13 @@ class RouterTest extends \PHPUnit\Framework\TestCase
     public function testFindPageAddlinkValid()
     {
         $this->assertEquals(
-            Router::$PAGE_ADDLINK,
-            Router::findPage('do=addlink', array(), true)
+            LegacyRouter::$PAGE_ADDLINK,
+            LegacyRouter::findPage('do=addlink', array(), true)
         );
 
         $this->assertEquals(
-            Router::$PAGE_ADDLINK,
-            Router::findPage('do=addlink&stuff', array(), true)
+            LegacyRouter::$PAGE_ADDLINK,
+            LegacyRouter::findPage('do=addlink&stuff', array(), true)
         );
     }
 
@@ -352,18 +355,18 @@ class RouterTest extends \PHPUnit\Framework\TestCase
     public function testFindPageAddlinkInvalid()
     {
         $this->assertNotEquals(
-            Router::$PAGE_ADDLINK,
-            Router::findPage('do=addlink', array(), 1)
+            LegacyRouter::$PAGE_ADDLINK,
+            LegacyRouter::findPage('do=addlink', array(), 1)
         );
 
         $this->assertNotEquals(
-            Router::$PAGE_ADDLINK,
-            Router::findPage('do=addlink', array(), false)
+            LegacyRouter::$PAGE_ADDLINK,
+            LegacyRouter::findPage('do=addlink', array(), false)
         );
 
         $this->assertNotEquals(
-            Router::$PAGE_ADDLINK,
-            Router::findPage('do=other', array(), true)
+            LegacyRouter::$PAGE_ADDLINK,
+            LegacyRouter::findPage('do=other', array(), true)
         );
     }
 
@@ -376,13 +379,13 @@ class RouterTest extends \PHPUnit\Framework\TestCase
     public function testFindPageExportValid()
     {
         $this->assertEquals(
-            Router::$PAGE_EXPORT,
-            Router::findPage('do=export', array(), true)
+            LegacyRouter::$PAGE_EXPORT,
+            LegacyRouter::findPage('do=export', array(), true)
         );
 
         $this->assertEquals(
-            Router::$PAGE_EXPORT,
-            Router::findPage('do=export&stuff', array(), true)
+            LegacyRouter::$PAGE_EXPORT,
+            LegacyRouter::findPage('do=export&stuff', array(), true)
         );
     }
 
@@ -395,18 +398,18 @@ class RouterTest extends \PHPUnit\Framework\TestCase
     public function testFindPageExportInvalid()
     {
         $this->assertNotEquals(
-            Router::$PAGE_EXPORT,
-            Router::findPage('do=export', array(), 1)
+            LegacyRouter::$PAGE_EXPORT,
+            LegacyRouter::findPage('do=export', array(), 1)
         );
 
         $this->assertNotEquals(
-            Router::$PAGE_EXPORT,
-            Router::findPage('do=export', array(), false)
+            LegacyRouter::$PAGE_EXPORT,
+            LegacyRouter::findPage('do=export', array(), false)
         );
 
         $this->assertNotEquals(
-            Router::$PAGE_EXPORT,
-            Router::findPage('do=other', array(), true)
+            LegacyRouter::$PAGE_EXPORT,
+            LegacyRouter::findPage('do=other', array(), true)
         );
     }
 
@@ -419,13 +422,13 @@ class RouterTest extends \PHPUnit\Framework\TestCase
     public function testFindPageImportValid()
     {
         $this->assertEquals(
-            Router::$PAGE_IMPORT,
-            Router::findPage('do=import', array(), true)
+            LegacyRouter::$PAGE_IMPORT,
+            LegacyRouter::findPage('do=import', array(), true)
         );
 
         $this->assertEquals(
-            Router::$PAGE_IMPORT,
-            Router::findPage('do=import&stuff', array(), true)
+            LegacyRouter::$PAGE_IMPORT,
+            LegacyRouter::findPage('do=import&stuff', array(), true)
         );
     }
 
@@ -438,18 +441,18 @@ class RouterTest extends \PHPUnit\Framework\TestCase
     public function testFindPageImportInvalid()
     {
         $this->assertNotEquals(
-            Router::$PAGE_IMPORT,
-            Router::findPage('do=import', array(), 1)
+            LegacyRouter::$PAGE_IMPORT,
+            LegacyRouter::findPage('do=import', array(), 1)
         );
 
         $this->assertNotEquals(
-            Router::$PAGE_IMPORT,
-            Router::findPage('do=import', array(), false)
+            LegacyRouter::$PAGE_IMPORT,
+            LegacyRouter::findPage('do=import', array(), false)
         );
 
         $this->assertNotEquals(
-            Router::$PAGE_IMPORT,
-            Router::findPage('do=other', array(), true)
+            LegacyRouter::$PAGE_IMPORT,
+            LegacyRouter::findPage('do=other', array(), true)
         );
     }
 
@@ -462,24 +465,24 @@ class RouterTest extends \PHPUnit\Framework\TestCase
     public function testFindPageEditlinkValid()
     {
         $this->assertEquals(
-            Router::$PAGE_EDITLINK,
-            Router::findPage('whatever', array('edit_link' => 1), true)
+            LegacyRouter::$PAGE_EDITLINK,
+            LegacyRouter::findPage('whatever', array('edit_link' => 1), true)
         );
 
         $this->assertEquals(
-            Router::$PAGE_EDITLINK,
-            Router::findPage('', array('edit_link' => 1), true)
+            LegacyRouter::$PAGE_EDITLINK,
+            LegacyRouter::findPage('', array('edit_link' => 1), true)
         );
 
 
         $this->assertEquals(
-            Router::$PAGE_EDITLINK,
-            Router::findPage('whatever', array('post' => 1), true)
+            LegacyRouter::$PAGE_EDITLINK,
+            LegacyRouter::findPage('whatever', array('post' => 1), true)
         );
 
         $this->assertEquals(
-            Router::$PAGE_EDITLINK,
-            Router::findPage('whatever', array('post' => 1, 'edit_link' => 1), true)
+            LegacyRouter::$PAGE_EDITLINK,
+            LegacyRouter::findPage('whatever', array('post' => 1, 'edit_link' => 1), true)
         );
     }
 
@@ -492,18 +495,18 @@ class RouterTest extends \PHPUnit\Framework\TestCase
     public function testFindPageEditlinkInvalid()
     {
         $this->assertNotEquals(
-            Router::$PAGE_EDITLINK,
-            Router::findPage('whatever', array('edit_link' => 1), false)
+            LegacyRouter::$PAGE_EDITLINK,
+            LegacyRouter::findPage('whatever', array('edit_link' => 1), false)
         );
 
         $this->assertNotEquals(
-            Router::$PAGE_EDITLINK,
-            Router::findPage('whatever', array('edit_link' => 1), 1)
+            LegacyRouter::$PAGE_EDITLINK,
+            LegacyRouter::findPage('whatever', array('edit_link' => 1), 1)
         );
 
         $this->assertNotEquals(
-            Router::$PAGE_EDITLINK,
-            Router::findPage('whatever', array(), true)
+            LegacyRouter::$PAGE_EDITLINK,
+            LegacyRouter::findPage('whatever', array(), true)
         );
     }
 }
index 6c948bba4a0d805f09927af06e3e5d6d68482d57..509da51d8ad2e576546b018b14671034bd901af2 100644 (file)
@@ -1,11 +1,12 @@
 <?php
+
 namespace Shaarli\Netscape;
 
+use PHPUnit\Framework\TestCase;
 use Shaarli\Bookmark\BookmarkFileService;
-use Shaarli\Bookmark\LinkDB;
 use Shaarli\Config\ConfigManager;
-use Shaarli\Formatter\FormatterFactory;
 use Shaarli\Formatter\BookmarkFormatter;
+use Shaarli\Formatter\FormatterFactory;
 use Shaarli\History;
 
 require_once 'tests/utils/ReferenceLinkDB.php';
@@ -13,13 +14,18 @@ require_once 'tests/utils/ReferenceLinkDB.php';
 /**
  * Netscape bookmark export
  */
-class BookmarkExportTest extends \PHPUnit\Framework\TestCase
+class BookmarkExportTest extends TestCase
 {
     /**
      * @var string datastore to test write operations
      */
     protected static $testDatastore = 'sandbox/datastore.php';
 
+    /**
+     * @var ConfigManager instance.
+     */
+    protected static $conf;
+
     /**
      * @var \ReferenceLinkDB instance.
      */
@@ -35,19 +41,38 @@ class BookmarkExportTest extends \PHPUnit\Framework\TestCase
      */
     protected static $formatter;
 
+    /**
+     * @var History instance
+     */
+    protected static $history;
+
+    /**
+     * @var NetscapeBookmarkUtils
+     */
+    protected $netscapeBookmarkUtils;
+
     /**
      * Instantiate reference data
      */
     public static function setUpBeforeClass()
     {
-        $conf = new ConfigManager('tests/utils/config/configJson');
-        $conf->set('resource.datastore', self::$testDatastore);
-        self::$refDb = new \ReferenceLinkDB();
-        self::$refDb->write(self::$testDatastore);
-        $history = new History('sandbox/history.php');
-        self::$bookmarkService = new BookmarkFileService($conf, $history, true);
-        $factory = new FormatterFactory($conf, true);
-        self::$formatter = $factory->getFormatter('raw');
+        static::$conf = new ConfigManager('tests/utils/config/configJson');
+        static::$conf->set('resource.datastore', static::$testDatastore);
+        static::$refDb = new \ReferenceLinkDB();
+        static::$refDb->write(static::$testDatastore);
+        static::$history = new History('sandbox/history.php');
+        static::$bookmarkService = new BookmarkFileService(static::$conf, static::$history, true);
+        $factory = new FormatterFactory(static::$conf, true);
+        static::$formatter = $factory->getFormatter('raw');
+    }
+
+    public function setUp(): void
+    {
+        $this->netscapeBookmarkUtils = new NetscapeBookmarkUtils(
+            static::$bookmarkService,
+            static::$conf,
+            static::$history
+        );
     }
 
     /**
@@ -57,8 +82,7 @@ class BookmarkExportTest extends \PHPUnit\Framework\TestCase
      */
     public function testFilterAndFormatInvalid()
     {
-        NetscapeBookmarkUtils::filterAndFormat(
-            self::$bookmarkService,
+        $this->netscapeBookmarkUtils->filterAndFormat(
             self::$formatter,
             'derp',
             false,
@@ -71,8 +95,7 @@ class BookmarkExportTest extends \PHPUnit\Framework\TestCase
      */
     public function testFilterAndFormatAll()
     {
-        $links = NetscapeBookmarkUtils::filterAndFormat(
-            self::$bookmarkService,
+        $links = $this->netscapeBookmarkUtils->filterAndFormat(
             self::$formatter,
             'all',
             false,
@@ -97,8 +120,7 @@ class BookmarkExportTest extends \PHPUnit\Framework\TestCase
      */
     public function testFilterAndFormatPrivate()
     {
-        $links = NetscapeBookmarkUtils::filterAndFormat(
-            self::$bookmarkService,
+        $links = $this->netscapeBookmarkUtils->filterAndFormat(
             self::$formatter,
             'private',
             false,
@@ -123,8 +145,7 @@ class BookmarkExportTest extends \PHPUnit\Framework\TestCase
      */
     public function testFilterAndFormatPublic()
     {
-        $links = NetscapeBookmarkUtils::filterAndFormat(
-            self::$bookmarkService,
+        $links = $this->netscapeBookmarkUtils->filterAndFormat(
             self::$formatter,
             'public',
             false,
@@ -149,15 +170,14 @@ class BookmarkExportTest extends \PHPUnit\Framework\TestCase
      */
     public function testFilterAndFormatDoNotPrependNoteUrl()
     {
-        $links = NetscapeBookmarkUtils::filterAndFormat(
-            self::$bookmarkService,
+        $links = $this->netscapeBookmarkUtils->filterAndFormat(
             self::$formatter,
             'public',
             false,
             ''
         );
         $this->assertEquals(
-            '?WDWyig',
+            '/shaare/WDWyig',
             $links[2]['url']
         );
     }
@@ -168,15 +188,14 @@ class BookmarkExportTest extends \PHPUnit\Framework\TestCase
     public function testFilterAndFormatPrependNoteUrl()
     {
         $indexUrl = 'http://localhost:7469/shaarli/';
-        $links = NetscapeBookmarkUtils::filterAndFormat(
-            self::$bookmarkService,
+        $links = $this->netscapeBookmarkUtils->filterAndFormat(
             self::$formatter,
             'public',
             true,
             $indexUrl
         );
         $this->assertEquals(
-            $indexUrl . '?WDWyig',
+            $indexUrl . 'shaare/WDWyig',
             $links[2]['url']
         );
     }
index fef7f6d18450123cff395f0f5c8f510750721b59..f678e26bd5ca8d3199242d33f6613bb36f7afecc 100644 (file)
@@ -1,29 +1,31 @@
 <?php
+
 namespace Shaarli\Netscape;
 
 use DateTime;
+use PHPUnit\Framework\TestCase;
+use Psr\Http\Message\UploadedFileInterface;
 use Shaarli\Bookmark\Bookmark;
-use Shaarli\Bookmark\BookmarkFilter;
 use Shaarli\Bookmark\BookmarkFileService;
-use Shaarli\Bookmark\LinkDB;
+use Shaarli\Bookmark\BookmarkFilter;
 use Shaarli\Config\ConfigManager;
 use Shaarli\History;
+use Slim\Http\UploadedFile;
 
 /**
  * Utility function to load a file's metadata in a $_FILES-like array
  *
  * @param string $filename Basename of the file
  *
- * @return array A $_FILES-like array
+ * @return UploadedFileInterface Upload file in PSR-7 compatible object
  */
 function file2array($filename)
 {
-    return array(
-        'filetoupload' => array(
-            'name'     => $filename,
-            'tmp_name' => __DIR__ . '/input/' . $filename,
-            'size'     => filesize(__DIR__ . '/input/' . $filename)
-        )
+    return new UploadedFile(
+        __DIR__ . '/input/' . $filename,
+        $filename,
+        null,
+        filesize(__DIR__ . '/input/' . $filename)
     );
 }
 
@@ -31,7 +33,7 @@ function file2array($filename)
 /**
  * Netscape bookmark import
  */
-class BookmarkImportTest extends \PHPUnit\Framework\TestCase
+class BookmarkImportTest extends TestCase
 {
     /**
      * @var string datastore to test write operations
@@ -63,6 +65,11 @@ class BookmarkImportTest extends \PHPUnit\Framework\TestCase
      */
     protected $history;
 
+    /**
+     * @var NetscapeBookmarkUtils
+     */
+    protected $netscapeBookmarkUtils;
+
     /**
      * @var string Save the current timezone.
      */
@@ -91,6 +98,7 @@ class BookmarkImportTest extends \PHPUnit\Framework\TestCase
         $this->conf->set('resource.datastore', self::$testDatastore);
         $this->history = new History(self::$historyFilePath);
         $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, true);
+        $this->netscapeBookmarkUtils = new NetscapeBookmarkUtils($this->bookmarkService, $this->conf, $this->history);
     }
 
     /**
@@ -115,7 +123,7 @@ class BookmarkImportTest extends \PHPUnit\Framework\TestCase
         $this->assertEquals(
             'File empty.htm (0 bytes) has an unknown file format.'
             .' Nothing was imported.',
-            NetscapeBookmarkUtils::import(null, $files, null, $this->conf, $this->history)
+            $this->netscapeBookmarkUtils->import(null, $files)
         );
         $this->assertEquals(0, $this->bookmarkService->count());
     }
@@ -128,7 +136,7 @@ class BookmarkImportTest extends \PHPUnit\Framework\TestCase
         $files = file2array('no_doctype.htm');
         $this->assertEquals(
             'File no_doctype.htm (350 bytes) has an unknown file format. Nothing was imported.',
-            NetscapeBookmarkUtils::import(null, $files, null, $this->conf, $this->history)
+            $this->netscapeBookmarkUtils->import(null, $files)
         );
         $this->assertEquals(0, $this->bookmarkService->count());
     }
@@ -142,7 +150,7 @@ class BookmarkImportTest extends \PHPUnit\Framework\TestCase
         $this->assertStringMatchesFormat(
             'File lowercase_doctype.htm (386 bytes) was successfully processed in %d seconds:'
             .' 2 bookmarks imported, 0 bookmarks overwritten, 0 bookmarks skipped.',
-            NetscapeBookmarkUtils::import(null, $files, $this->bookmarkService, $this->conf, $this->history)
+            $this->netscapeBookmarkUtils->import(null, $files)
         );
         $this->assertEquals(2, $this->bookmarkService->count());
     }
@@ -157,7 +165,7 @@ class BookmarkImportTest extends \PHPUnit\Framework\TestCase
         $this->assertStringMatchesFormat(
             'File internet_explorer_encoding.htm (356 bytes) was successfully processed in %d seconds:'
             .' 1 bookmarks imported, 0 bookmarks overwritten, 0 bookmarks skipped.',
-            NetscapeBookmarkUtils::import([], $files, $this->bookmarkService, $this->conf, $this->history)
+            $this->netscapeBookmarkUtils->import([], $files)
         );
         $this->assertEquals(1, $this->bookmarkService->count());
         $this->assertEquals(0, $this->bookmarkService->count(BookmarkFilter::$PRIVATE));
@@ -185,7 +193,7 @@ class BookmarkImportTest extends \PHPUnit\Framework\TestCase
         $this->assertStringMatchesFormat(
             'File netscape_nested.htm (1337 bytes) was successfully processed in %d seconds:'
             .' 8 bookmarks imported, 0 bookmarks overwritten, 0 bookmarks skipped.',
-            NetscapeBookmarkUtils::import([], $files, $this->bookmarkService, $this->conf, $this->history)
+            $this->netscapeBookmarkUtils->import([], $files)
         );
         $this->assertEquals(8, $this->bookmarkService->count());
         $this->assertEquals(2, $this->bookmarkService->count(BookmarkFilter::$PRIVATE));
@@ -306,7 +314,7 @@ class BookmarkImportTest extends \PHPUnit\Framework\TestCase
         $this->assertStringMatchesFormat(
             'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:'
             .' 2 bookmarks imported, 0 bookmarks overwritten, 0 bookmarks skipped.',
-            NetscapeBookmarkUtils::import([], $files, $this->bookmarkService, $this->conf, $this->history)
+            $this->netscapeBookmarkUtils->import([], $files)
         );
 
         $this->assertEquals(2, $this->bookmarkService->count());
@@ -349,7 +357,7 @@ class BookmarkImportTest extends \PHPUnit\Framework\TestCase
         $this->assertStringMatchesFormat(
             'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:'
             .' 2 bookmarks imported, 0 bookmarks overwritten, 0 bookmarks skipped.',
-            NetscapeBookmarkUtils::import($post, $files, $this->bookmarkService, $this->conf, $this->history)
+            $this->netscapeBookmarkUtils->import($post, $files)
         );
 
         $this->assertEquals(2, $this->bookmarkService->count());
@@ -392,7 +400,7 @@ class BookmarkImportTest extends \PHPUnit\Framework\TestCase
         $this->assertStringMatchesFormat(
             'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:'
             .' 2 bookmarks imported, 0 bookmarks overwritten, 0 bookmarks skipped.',
-            NetscapeBookmarkUtils::import($post, $files, $this->bookmarkService, $this->conf, $this->history)
+            $this->netscapeBookmarkUtils->import($post, $files)
         );
         $this->assertEquals(2, $this->bookmarkService->count());
         $this->assertEquals(0, $this->bookmarkService->count(BookmarkFilter::$PRIVATE));
@@ -410,7 +418,7 @@ class BookmarkImportTest extends \PHPUnit\Framework\TestCase
         $this->assertStringMatchesFormat(
             'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:'
             .' 2 bookmarks imported, 0 bookmarks overwritten, 0 bookmarks skipped.',
-            NetscapeBookmarkUtils::import($post, $files, $this->bookmarkService, $this->conf, $this->history)
+            $this->netscapeBookmarkUtils->import($post, $files)
         );
         $this->assertEquals(2, $this->bookmarkService->count());
         $this->assertEquals(2, $this->bookmarkService->count(BookmarkFilter::$PRIVATE));
@@ -430,7 +438,7 @@ class BookmarkImportTest extends \PHPUnit\Framework\TestCase
         $this->assertStringMatchesFormat(
             'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:'
             .' 2 bookmarks imported, 0 bookmarks overwritten, 0 bookmarks skipped.',
-            NetscapeBookmarkUtils::import($post, $files, $this->bookmarkService, $this->conf, $this->history)
+            $this->netscapeBookmarkUtils->import($post, $files)
         );
         $this->assertEquals(2, $this->bookmarkService->count());
         $this->assertEquals(2, $this->bookmarkService->count(BookmarkFilter::$PRIVATE));
@@ -445,7 +453,7 @@ class BookmarkImportTest extends \PHPUnit\Framework\TestCase
         $this->assertStringMatchesFormat(
             'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:'
             .' 2 bookmarks imported, 2 bookmarks overwritten, 0 bookmarks skipped.',
-            NetscapeBookmarkUtils::import($post, $files, $this->bookmarkService, $this->conf, $this->history)
+            $this->netscapeBookmarkUtils->import($post, $files)
         );
         $this->assertEquals(2, $this->bookmarkService->count());
         $this->assertEquals(0, $this->bookmarkService->count(BookmarkFilter::$PRIVATE));
@@ -465,7 +473,7 @@ class BookmarkImportTest extends \PHPUnit\Framework\TestCase
         $this->assertStringMatchesFormat(
             'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:'
             .' 2 bookmarks imported, 0 bookmarks overwritten, 0 bookmarks skipped.',
-            NetscapeBookmarkUtils::import($post, $files, $this->bookmarkService, $this->conf, $this->history)
+            $this->netscapeBookmarkUtils->import($post, $files)
         );
         $this->assertEquals(2, $this->bookmarkService->count());
         $this->assertEquals(0, $this->bookmarkService->count(BookmarkFilter::$PRIVATE));
@@ -480,7 +488,7 @@ class BookmarkImportTest extends \PHPUnit\Framework\TestCase
         $this->assertStringMatchesFormat(
             'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:'
             .' 2 bookmarks imported, 2 bookmarks overwritten, 0 bookmarks skipped.',
-            NetscapeBookmarkUtils::import($post, $files, $this->bookmarkService, $this->conf, $this->history)
+            $this->netscapeBookmarkUtils->import($post, $files)
         );
         $this->assertEquals(2, $this->bookmarkService->count());
         $this->assertEquals(2, $this->bookmarkService->count(BookmarkFilter::$PRIVATE));
@@ -498,7 +506,7 @@ class BookmarkImportTest extends \PHPUnit\Framework\TestCase
         $this->assertStringMatchesFormat(
             'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:'
             .' 2 bookmarks imported, 0 bookmarks overwritten, 0 bookmarks skipped.',
-            NetscapeBookmarkUtils::import($post, $files, $this->bookmarkService, $this->conf, $this->history)
+            $this->netscapeBookmarkUtils->import($post, $files)
         );
         $this->assertEquals(2, $this->bookmarkService->count());
         $this->assertEquals(0, $this->bookmarkService->count(BookmarkFilter::$PRIVATE));
@@ -508,7 +516,7 @@ class BookmarkImportTest extends \PHPUnit\Framework\TestCase
         $this->assertStringMatchesFormat(
             'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:'
             .' 0 bookmarks imported, 0 bookmarks overwritten, 2 bookmarks skipped.',
-            NetscapeBookmarkUtils::import($post, $files, $this->bookmarkService, $this->conf, $this->history)
+            $this->netscapeBookmarkUtils->import($post, $files)
         );
         $this->assertEquals(2, $this->bookmarkService->count());
         $this->assertEquals(0, $this->bookmarkService->count(BookmarkFilter::$PRIVATE));
@@ -527,7 +535,7 @@ class BookmarkImportTest extends \PHPUnit\Framework\TestCase
         $this->assertStringMatchesFormat(
             'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:'
             .' 2 bookmarks imported, 0 bookmarks overwritten, 0 bookmarks skipped.',
-            NetscapeBookmarkUtils::import($post, $files, $this->bookmarkService, $this->conf, $this->history)
+            $this->netscapeBookmarkUtils->import($post, $files)
         );
         $this->assertEquals(2, $this->bookmarkService->count());
         $this->assertEquals(0, $this->bookmarkService->count(BookmarkFilter::$PRIVATE));
@@ -548,7 +556,7 @@ class BookmarkImportTest extends \PHPUnit\Framework\TestCase
         $this->assertStringMatchesFormat(
             'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:'
             .' 2 bookmarks imported, 0 bookmarks overwritten, 0 bookmarks skipped.',
-            NetscapeBookmarkUtils::import($post, $files, $this->bookmarkService, $this->conf, $this->history)
+            $this->netscapeBookmarkUtils->import($post, $files)
         );
         $this->assertEquals(2, $this->bookmarkService->count());
         $this->assertEquals(0, $this->bookmarkService->count(BookmarkFilter::$PRIVATE));
@@ -573,7 +581,7 @@ class BookmarkImportTest extends \PHPUnit\Framework\TestCase
         $this->assertStringMatchesFormat(
             'File same_date.htm (453 bytes) was successfully processed in %d seconds:'
             .' 3 bookmarks imported, 0 bookmarks overwritten, 0 bookmarks skipped.',
-            NetscapeBookmarkUtils::import(array(), $files, $this->bookmarkService, $this->conf, $this->history)
+            $this->netscapeBookmarkUtils->import(array(), $files)
         );
         $this->assertEquals(3, $this->bookmarkService->count());
         $this->assertEquals(0, $this->bookmarkService->count(BookmarkFilter::$PRIVATE));
@@ -589,14 +597,14 @@ class BookmarkImportTest extends \PHPUnit\Framework\TestCase
             'overwrite' => 'true',
         ];
         $files = file2array('netscape_basic.htm');
-        NetscapeBookmarkUtils::import($post, $files, $this->bookmarkService, $this->conf, $this->history);
+        $this->netscapeBookmarkUtils->import($post, $files);
         $history = $this->history->getHistory();
         $this->assertEquals(1, count($history));
         $this->assertEquals(History::IMPORT, $history[0]['event']);
         $this->assertTrue(new DateTime('-5 seconds') < $history[0]['datetime']);
 
         // re-import as private, enable overwriting
-        NetscapeBookmarkUtils::import($post, $files, $this->bookmarkService, $this->conf, $this->history);
+        $this->netscapeBookmarkUtils->import($post, $files);
         $history = $this->history->getHistory();
         $this->assertEquals(2, count($history));
         $this->assertEquals(History::IMPORT, $history[0]['event']);
index d052f8b9f24766911d4c295a4015b24456959f07..aa5c6988f639da5c9e8097897bdf151fa0b1b960 100644 (file)
@@ -2,7 +2,7 @@
 namespace Shaarli\Plugin\Addlink;
 
 use Shaarli\Plugin\PluginManager;
-use Shaarli\Router;
+use Shaarli\Render\TemplatePage;
 
 require_once 'plugins/addlink_toolbar/addlink_toolbar.php';
 
@@ -26,8 +26,9 @@ class PluginAddlinkTest extends \PHPUnit\Framework\TestCase
     {
         $str = 'stuff';
         $data = array($str => $str);
-        $data['_PAGE_'] = Router::$PAGE_LINKLIST;
+        $data['_PAGE_'] = TemplatePage::LINKLIST;
         $data['_LOGGEDIN_'] = true;
+        $data['_BASE_PATH_'] = '/subfolder';
 
         $data = hook_addlink_toolbar_render_header($data);
         $this->assertEquals($str, $data[$str]);
@@ -36,6 +37,8 @@ class PluginAddlinkTest extends \PHPUnit\Framework\TestCase
         $data = array($str => $str);
         $data['_PAGE_'] = $str;
         $data['_LOGGEDIN_'] = true;
+        $data['_BASE_PATH_'] = '/subfolder';
+
         $data = hook_addlink_toolbar_render_header($data);
         $this->assertEquals($str, $data[$str]);
         $this->assertArrayNotHasKey('fields_toolbar', $data);
@@ -48,8 +51,9 @@ class PluginAddlinkTest extends \PHPUnit\Framework\TestCase
     {
         $str = 'stuff';
         $data = array($str => $str);
-        $data['_PAGE_'] = Router::$PAGE_LINKLIST;
+        $data['_PAGE_'] = TemplatePage::LINKLIST;
         $data['_LOGGEDIN_'] = false;
+        $data['_BASE_PATH_'] = '/subfolder';
 
         $data = hook_addlink_toolbar_render_header($data);
         $this->assertEquals($str, $data[$str]);
index 51472617ad47cf21cd5fbc418ca2fd5d0982652c..b7b6ce53fee211132aeee26030b3c1ffadd3249a 100644 (file)
@@ -6,7 +6,7 @@ namespace Shaarli\Plugin\Playvideos;
  */
 
 use Shaarli\Plugin\PluginManager;
-use Shaarli\Router;
+use Shaarli\Render\TemplatePage;
 
 require_once 'plugins/playvideos/playvideos.php';
 
@@ -31,7 +31,7 @@ class PluginPlayvideosTest extends \PHPUnit\Framework\TestCase
     {
         $str = 'stuff';
         $data = array($str => $str);
-        $data['_PAGE_'] = Router::$PAGE_LINKLIST;
+        $data['_PAGE_'] = TemplatePage::LINKLIST;
 
         $data = hook_playvideos_render_header($data);
         $this->assertEquals($str, $data[$str]);
@@ -50,7 +50,7 @@ class PluginPlayvideosTest extends \PHPUnit\Framework\TestCase
     {
         $str = 'stuff';
         $data = array($str => $str);
-        $data['_PAGE_'] = Router::$PAGE_LINKLIST;
+        $data['_PAGE_'] = TemplatePage::LINKLIST;
 
         $data = hook_playvideos_render_footer($data);
         $this->assertEquals($str, $data[$str]);
index a7bd8fc93d79980633fd7edc71a66eb248df0b3e..e66f484edcdb6928ec4213ae918c2ebff2b36323 100644 (file)
@@ -3,7 +3,7 @@ namespace Shaarli\Plugin\Pubsubhubbub;
 
 use Shaarli\Config\ConfigManager;
 use Shaarli\Plugin\PluginManager;
-use Shaarli\Router;
+use Shaarli\Render\TemplatePage;
 
 require_once 'plugins/pubsubhubbub/pubsubhubbub.php';
 
@@ -34,7 +34,7 @@ class PluginPubsubhubbubTest extends \PHPUnit\Framework\TestCase
         $hub = 'http://domain.hub';
         $conf = new ConfigManager(self::$configFile);
         $conf->set('plugins.PUBSUBHUB_URL', $hub);
-        $data['_PAGE_'] = Router::$PAGE_FEED_RSS;
+        $data['_PAGE_'] = TemplatePage::FEED_RSS;
 
         $data = hook_pubsubhubbub_render_feed($data, $conf);
         $expected = '<atom:link rel="hub" href="'. $hub .'" />';
@@ -49,7 +49,7 @@ class PluginPubsubhubbubTest extends \PHPUnit\Framework\TestCase
         $hub = 'http://domain.hub';
         $conf = new ConfigManager(self::$configFile);
         $conf->set('plugins.PUBSUBHUB_URL', $hub);
-        $data['_PAGE_'] = Router::$PAGE_FEED_ATOM;
+        $data['_PAGE_'] = TemplatePage::FEED_ATOM;
 
         $data = hook_pubsubhubbub_render_feed($data, $conf);
         $expected = '<link rel="hub" href="'. $hub .'" />';
index 0c61e14a75bc841f1be4d5362bcb4b55c8b4662d..c9f8c733d40adcac140fc2bdb5fe8ab817123f6c 100644 (file)
@@ -6,7 +6,7 @@ namespace Shaarli\Plugin\Qrcode;
  */
 
 use Shaarli\Plugin\PluginManager;
-use Shaarli\Router;
+use Shaarli\Render\TemplatePage;
 
 require_once 'plugins/qrcode/qrcode.php';
 
@@ -57,7 +57,7 @@ class PluginQrcodeTest extends \PHPUnit\Framework\TestCase
     {
         $str = 'stuff';
         $data = array($str => $str);
-        $data['_PAGE_'] = Router::$PAGE_LINKLIST;
+        $data['_PAGE_'] = TemplatePage::LINKLIST;
 
         $data = hook_qrcode_render_footer($data);
         $this->assertEquals($str, $data[$str]);
diff --git a/tests/plugins/resources/hashtags.md b/tests/plugins/resources/hashtags.md
deleted file mode 100644 (file)
index 46326de..0000000
+++ /dev/null
@@ -1,10 +0,0 @@
-[#lol](?addtag=lol)
-
-    #test
-
-`#test2`
-
-```
-bla #bli blo
-#bla
-```
diff --git a/tests/plugins/resources/hashtags.raw b/tests/plugins/resources/hashtags.raw
deleted file mode 100644 (file)
index 9d2dc98..0000000
+++ /dev/null
@@ -1,10 +0,0 @@
-#lol
-
-    #test
-
-`#test2`
-
-```
-bla #bli blo
-#bla
-```
diff --git a/tests/plugins/resources/markdown.html b/tests/plugins/resources/markdown.html
deleted file mode 100644 (file)
index c3460bf..0000000
+++ /dev/null
@@ -1,33 +0,0 @@
-<div class="markdown"><ul>
-<li>test:
-<ul>
-<li><a href="http://link.tld">zero</a></li>
-<li><a href="http://link.tld">two</a></li>
-<li><a href="http://link.tld">three</a></li>
-</ul></li>
-</ul>
-<ol>
-<li><a href="http://link.tld">zero</a>
-<ol start="2">
-<li><a href="http://link.tld">two</a></li>
-<li><a href="http://link.tld">three</a></li>
-<li><a href="http://link.tld">four</a></li>
-<li>foo <a href="?addtag=foobar">#foobar</a></li>
-</ol></li>
-</ol>
-<p><a href="?addtag=foobar">#foobar</a> foo <code>lol #foo</code> <a href="?addtag=bar">#bar</a></p>
-<p>fsdfs <a href="http://link.tld">http://link.tld</a> <a href="?addtag=foobar">#foobar</a> <code>http://link.tld</code></p>
-<pre><code>http://link.tld #foobar
-next #foo</code></pre>
-<p>Block:</p>
-<pre><code>lorem ipsum #foobar http://link.tld
-#foobar http://link.tld</code></pre>
-<p><a href="?123456">link</a><br />
-<img src="/img/train.png" alt="link" /><br />
-<a href="http://test.tld/path/?query=value#hash">link</a><br />
-<a href="http://test.tld/path/?query=value#hash">link</a><br />
-<a href="https://test.tld/path/?query=value#hash">link</a><br />
-<a href="ftp://test.tld/path/?query=value#hash">link</a><br />
-<a href="magnet:test.tld/path/?query=value#hash">link</a><br />
-<a href="http://alert(&#039;xss&#039;)">link</a><br />
-<a href="http://test.tld/path/?query=value#hash">link</a></p></div>
diff --git a/tests/plugins/resources/markdown.md b/tests/plugins/resources/markdown.md
deleted file mode 100644 (file)
index 9350a8c..0000000
+++ /dev/null
@@ -1,34 +0,0 @@
-* test:
-    * [zero](http://link.tld)
-    + [two](http://link.tld)
-    - [three](http://link.tld)
-
-1. [zero](http://link.tld)
-  2. [two](http://link.tld)
-   3. [three](http://link.tld)
-    4. [four](http://link.tld)
-    5. foo #foobar
-
-#foobar foo `lol #foo` #bar
-
-fsdfs http://link.tld #foobar `http://link.tld`
-
-    http://link.tld #foobar
-    next #foo
-    
-Block:
-
-```
-lorem ipsum #foobar http://link.tld
-#foobar http://link.tld
-```
-
-[link](?123456)
-![link](/img/train.png)
-[link](test.tld/path/?query=value#hash)
-[link](http://test.tld/path/?query=value#hash)
-[link](https://test.tld/path/?query=value#hash)
-[link](ftp://test.tld/path/?query=value#hash)
-[link](magnet:test.tld/path/?query=value#hash)
-[link](javascript:alert('xss'))
-[link](other://test.tld/path/?query=value#hash)
index 2aaf51223e326e108451b0ba77b026db7828b6d0..ae5032dd36a5b19fc999c387c008ff781481c70c 100644 (file)
@@ -19,3 +19,8 @@ function hook_test_random($data)
 
     return $data;
 }
+
+function hook_test_error()
+{
+    new Unknown();
+}
similarity index 68%
rename from tests/feed/CacheTest.php
rename to tests/render/PageCacheManagerTest.php
index c0a9f26f2dc933d7adf8644a8b580d797efe01de..c258f45f566cb12a321730fa6a9f71e112648181 100644 (file)
@@ -1,18 +1,18 @@
 <?php
+
 /**
  * Cache tests
  */
-namespace Shaarli\Feed;
 
-// required to access $_SESSION array
-session_start();
+namespace Shaarli\Render;
 
-require_once 'application/feed/Cache.php';
+use PHPUnit\Framework\TestCase;
+use Shaarli\Security\SessionManager;
 
 /**
  * Unitary tests for cached pages
  */
-class CacheTest extends \PHPUnit\Framework\TestCase
+class PageCacheManagerTest extends TestCase
 {
     // test cache directory
     protected static $testCacheDir = 'sandbox/dummycache';
@@ -20,12 +20,19 @@ class CacheTest extends \PHPUnit\Framework\TestCase
     // dummy cached file names / content
     protected static $pages = array('a', 'toto', 'd7b59c');
 
+    /** @var PageCacheManager */
+    protected $cacheManager;
+
+    /** @var SessionManager */
+    protected $sessionManager;
 
     /**
      * Populate the cache with dummy files
      */
     public function setUp()
     {
+        $this->cacheManager = new PageCacheManager(static::$testCacheDir, true);
+
         if (!is_dir(self::$testCacheDir)) {
             mkdir(self::$testCacheDir);
         } else {
@@ -52,7 +59,7 @@ class CacheTest extends \PHPUnit\Framework\TestCase
      */
     public function testPurgeCachedPages()
     {
-        purgeCachedPages(self::$testCacheDir);
+        $this->cacheManager->purgeCachedPages();
         foreach (self::$pages as $page) {
             $this->assertFileNotExists(self::$testCacheDir . '/' . $page . '.cache');
         }
@@ -65,28 +72,14 @@ class CacheTest extends \PHPUnit\Framework\TestCase
      */
     public function testPurgeCachedPagesMissingDir()
     {
+        $this->cacheManager = new PageCacheManager(self::$testCacheDir . '_missing', true);
+
         $oldlog = ini_get('error_log');
         ini_set('error_log', '/dev/null');
         $this->assertEquals(
             'Cannot purge sandbox/dummycache_missing: no directory',
-            purgeCachedPages(self::$testCacheDir . '_missing')
+            $this->cacheManager->purgeCachedPages()
         );
         ini_set('error_log', $oldlog);
     }
-
-    /**
-     * Purge cached pages and session cache
-     */
-    public function testInvalidateCaches()
-    {
-        $this->assertArrayNotHasKey('tags', $_SESSION);
-        $_SESSION['tags'] = array('goodbye', 'cruel', 'world');
-
-        invalidateCaches(self::$testCacheDir);
-        foreach (self::$pages as $page) {
-            $this->assertFileNotExists(self::$testCacheDir . '/' . $page . '.cache');
-        }
-
-        $this->assertArrayNotHasKey('tags', $_SESSION);
-    }
 }
index 8fd1698c1bf751043afa4ec437990242437bd2a6..f242be0919685d9d5231e1ae20b96309471a136f 100644 (file)
@@ -1,7 +1,6 @@
 <?php
-namespace Shaarli\Security;
 
-require_once 'tests/utils/FakeConfigManager.php';
+namespace Shaarli\Security;
 
 use PHPUnit\Framework\TestCase;
 
@@ -58,6 +57,9 @@ class LoginManagerTest extends TestCase
     /** @var string Salt used by hash functions */
     protected $salt = '669e24fa9c5a59a613f98e8e38327384504a4af2';
 
+    /** @var CookieManager */
+    protected $cookieManager;
+
     /**
      * Prepare or reset test resources
      */
@@ -84,8 +86,12 @@ class LoginManagerTest extends TestCase
         $this->cookie = [];
         $this->session = [];
 
-        $this->sessionManager = new SessionManager($this->session, $this->configManager);
-        $this->loginManager = new LoginManager($this->configManager, $this->sessionManager);
+        $this->cookieManager = $this->createMock(CookieManager::class);
+        $this->cookieManager->method('getCookieParameter')->willReturnCallback(function (string $key) {
+            return $this->cookie[$key] ?? null;
+        });
+        $this->sessionManager = new SessionManager($this->session, $this->configManager, 'session_path');
+        $this->loginManager = new LoginManager($this->configManager, $this->sessionManager, $this->cookieManager);
         $this->server['REMOTE_ADDR'] = $this->ipAddr;
     }
 
@@ -193,8 +199,8 @@ class LoginManagerTest extends TestCase
         $configManager = new \FakeConfigManager([
             'resource.ban_file' => $this->banFile,
         ]);
-        $loginManager = new LoginManager($configManager, null);
-        $loginManager->checkLoginState([], '');
+        $loginManager = new LoginManager($configManager, null, $this->cookieManager);
+        $loginManager->checkLoginState('');
 
         $this->assertFalse($loginManager->isLoggedIn());
     }
@@ -210,9 +216,9 @@ class LoginManagerTest extends TestCase
             'expires_on' => time() + 100,
         ];
         $this->loginManager->generateStaySignedInToken($this->clientIpAddress);
-        $this->cookie[LoginManager::$STAY_SIGNED_IN_COOKIE] = 'nope';
+        $this->cookie[CookieManager::STAY_SIGNED_IN] = 'nope';
 
-        $this->loginManager->checkLoginState($this->cookie, $this->clientIpAddress);
+        $this->loginManager->checkLoginState($this->clientIpAddress);
 
         $this->assertTrue($this->loginManager->isLoggedIn());
         $this->assertTrue(empty($this->session['username']));
@@ -224,9 +230,9 @@ class LoginManagerTest extends TestCase
     public function testCheckLoginStateStaySignedInWithValidToken()
     {
         $this->loginManager->generateStaySignedInToken($this->clientIpAddress);
-        $this->cookie[LoginManager::$STAY_SIGNED_IN_COOKIE] = $this->loginManager->getStaySignedInToken();
+        $this->cookie[CookieManager::STAY_SIGNED_IN] = $this->loginManager->getStaySignedInToken();
 
-        $this->loginManager->checkLoginState($this->cookie, $this->clientIpAddress);
+        $this->loginManager->checkLoginState($this->clientIpAddress);
 
         $this->assertTrue($this->loginManager->isLoggedIn());
         $this->assertEquals($this->login, $this->session['username']);
@@ -241,7 +247,7 @@ class LoginManagerTest extends TestCase
         $this->loginManager->generateStaySignedInToken($this->clientIpAddress);
         $this->session['expires_on'] = time() - 100;
 
-        $this->loginManager->checkLoginState($this->cookie, $this->clientIpAddress);
+        $this->loginManager->checkLoginState($this->clientIpAddress);
 
         $this->assertFalse($this->loginManager->isLoggedIn());
     }
@@ -253,7 +259,7 @@ class LoginManagerTest extends TestCase
     {
         $this->loginManager->generateStaySignedInToken($this->clientIpAddress);
 
-        $this->loginManager->checkLoginState($this->cookie, '10.7.157.98');
+        $this->loginManager->checkLoginState('10.7.157.98');
 
         $this->assertFalse($this->loginManager->isLoggedIn());
     }
index f264505eaefa81159e7cf1af14fb01b22b60f027..60695dcf944de720ac8e16163ef7df346f434513 100644 (file)
@@ -1,12 +1,8 @@
 <?php
-require_once 'tests/utils/FakeConfigManager.php';
 
-// Initialize reference data _before_ PHPUnit starts a session
-require_once 'tests/utils/ReferenceSessionIdHashes.php';
-ReferenceSessionIdHashes::genAllHashes();
+namespace Shaarli\Security;
 
 use PHPUnit\Framework\TestCase;
-use Shaarli\Security\SessionManager;
 
 /**
  * Test coverage for SessionManager
@@ -30,7 +26,7 @@ class SessionManagerTest extends TestCase
      */
     public static function setUpBeforeClass()
     {
-        self::$sidHashes = ReferenceSessionIdHashes::getHashes();
+        self::$sidHashes = \ReferenceSessionIdHashes::getHashes();
     }
 
     /**
@@ -38,13 +34,13 @@ class SessionManagerTest extends TestCase
      */
     public function setUp()
     {
-        $this->conf = new FakeConfigManager([
+        $this->conf = new \FakeConfigManager([
             'credentials.login' => 'johndoe',
             'credentials.salt' => 'salt',
             'security.session_protection_disabled' => false,
         ]);
         $this->session = [];
-        $this->sessionManager = new SessionManager($this->session, $this->conf);
+        $this->sessionManager = new SessionManager($this->session, $this->conf, 'session_path');
     }
 
     /**
@@ -69,7 +65,7 @@ class SessionManagerTest extends TestCase
                 $token => 1,
             ],
         ];
-        $sessionManager = new SessionManager($session, $this->conf);
+        $sessionManager = new SessionManager($session, $this->conf, 'session_path');
 
         // check and destroy the token
         $this->assertTrue($sessionManager->checkToken($token));
@@ -269,4 +265,61 @@ class SessionManagerTest extends TestCase
         $this->session['ip'] = 'ip_id_one';
         $this->assertTrue($this->sessionManager->hasClientIpChanged('ip_id_two'));
     }
+
+    /**
+     * Test creating an entry in the session array
+     */
+    public function testSetSessionParameterCreate(): void
+    {
+        $this->sessionManager->setSessionParameter('abc', 'def');
+
+        static::assertSame('def', $this->session['abc']);
+    }
+
+    /**
+     * Test updating an entry in the session array
+     */
+    public function testSetSessionParameterUpdate(): void
+    {
+        $this->session['abc'] = 'ghi';
+
+        $this->sessionManager->setSessionParameter('abc', 'def');
+
+        static::assertSame('def', $this->session['abc']);
+    }
+
+    /**
+     * Test updating an entry in the session array with null value
+     */
+    public function testSetSessionParameterUpdateNull(): void
+    {
+        $this->session['abc'] = 'ghi';
+
+        $this->sessionManager->setSessionParameter('abc', null);
+
+        static::assertArrayHasKey('abc', $this->session);
+        static::assertNull($this->session['abc']);
+    }
+
+    /**
+     * Test deleting an existing entry in the session array
+     */
+    public function testDeleteSessionParameter(): void
+    {
+        $this->session['abc'] = 'def';
+
+        $this->sessionManager->deleteSessionParameter('abc');
+
+        static::assertArrayNotHasKey('abc', $this->session);
+    }
+
+    /**
+     * Test deleting a non existent entry in the session array
+     */
+    public function testDeleteSessionParameterNotExisting(): void
+    {
+        $this->sessionManager->deleteSessionParameter('abc');
+
+        static::assertArrayNotHasKey('abc', $this->session);
+    }
 }
index c689982b49ea15551b230290b76dbf50caa9f203..a7dd70bfc4b1a386bf27b7d8964984a458e23607 100644 (file)
@@ -2,17 +2,18 @@
 namespace Shaarli\Updater;
 
 use Exception;
+use PHPUnit\Framework\TestCase;
+use Shaarli\Bookmark\BookmarkFileService;
+use Shaarli\Bookmark\BookmarkServiceInterface;
 use Shaarli\Config\ConfigManager;
+use Shaarli\History;
 
-require_once 'tests/updater/DummyUpdater.php';
-require_once 'tests/utils/ReferenceLinkDB.php';
-require_once 'inc/rain.tpl.class.php';
 
 /**
  * Class UpdaterTest.
  * Runs unit tests against the updater class.
  */
-class UpdaterTest extends \PHPUnit\Framework\TestCase
+class UpdaterTest extends TestCase
 {
     /**
      * @var string Path to test datastore.
@@ -29,13 +30,27 @@ class UpdaterTest extends \PHPUnit\Framework\TestCase
      */
     protected $conf;
 
+    /** @var BookmarkServiceInterface */
+    protected $bookmarkService;
+
+    /** @var \ReferenceLinkDB */
+    protected $refDB;
+
+    /** @var Updater */
+    protected $updater;
+
     /**
      * Executed before each test.
      */
     public function setUp()
     {
+        $this->refDB = new \ReferenceLinkDB();
+        $this->refDB->write(self::$testDatastore);
+
         copy('tests/utils/config/configJson.json.php', self::$configFile .'.json.php');
         $this->conf = new ConfigManager(self::$configFile);
+        $this->bookmarkService = new BookmarkFileService($this->conf, $this->createMock(History::class), true);
+        $this->updater = new Updater([], $this->bookmarkService, $this->conf, true);
     }
 
     /**
@@ -167,4 +182,40 @@ class UpdaterTest extends \PHPUnit\Framework\TestCase
         $updater = new DummyUpdater($updates, array(), $this->conf, true);
         $updater->update();
     }
+
+    public function testUpdateMethodRelativeHomeLinkRename(): void
+    {
+        $this->updater->setBasePath('/subfolder');
+        $this->conf->set('general.header_link', '?');
+
+        $this->updater->updateMethodRelativeHomeLink();
+
+        static::assertSame('/subfolder/', $this->conf->get('general.header_link'));
+    }
+
+    public function testUpdateMethodRelativeHomeLinkDoNotRename(): void
+    {
+        $this->conf->set('general.header_link', '~/my-blog');
+
+        $this->updater->updateMethodRelativeHomeLink();
+
+        static::assertSame('~/my-blog', $this->conf->get('general.header_link'));
+    }
+
+    public function testUpdateMethodMigrateExistingNotesUrl(): void
+    {
+        $this->updater->updateMethodMigrateExistingNotesUrl();
+
+        static::assertSame($this->refDB->getLinks()[0]->getUrl(), $this->bookmarkService->get(0)->getUrl());
+        static::assertSame($this->refDB->getLinks()[1]->getUrl(), $this->bookmarkService->get(1)->getUrl());
+        static::assertSame($this->refDB->getLinks()[4]->getUrl(), $this->bookmarkService->get(4)->getUrl());
+        static::assertSame($this->refDB->getLinks()[6]->getUrl(), $this->bookmarkService->get(6)->getUrl());
+        static::assertSame($this->refDB->getLinks()[7]->getUrl(), $this->bookmarkService->get(7)->getUrl());
+        static::assertSame($this->refDB->getLinks()[8]->getUrl(), $this->bookmarkService->get(8)->getUrl());
+        static::assertSame($this->refDB->getLinks()[9]->getUrl(), $this->bookmarkService->get(9)->getUrl());
+        static::assertSame('/shaare/WDWyig', $this->bookmarkService->get(42)->getUrl());
+        static::assertSame('/shaare/WDWyig', $this->bookmarkService->get(41)->getUrl());
+        static::assertSame('/shaare/0gCTjQ', $this->bookmarkService->get(10)->getUrl());
+        static::assertSame('/shaare/PCRizQ', $this->bookmarkService->get(11)->getUrl());
+    }
 }
index 0095f5a15ba5f5e98614af2970ff87197cc33103..fc3cb1094930eb28f8c0aefa3f6b17e75654b3c3 100644 (file)
@@ -30,7 +30,7 @@ class ReferenceLinkDB
         $this->addLink(
             11,
             'Pined older',
-            '?PCRizQ',
+            '/shaare/PCRizQ',
             'This is an older pinned link',
             0,
             DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, '20100309_101010'),
@@ -43,7 +43,7 @@ class ReferenceLinkDB
         $this->addLink(
             10,
             'Pined',
-            '?0gCTjQ',
+            '/shaare/0gCTjQ',
             'This is a pinned link',
             0,
             DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, '20121207_152312'),
@@ -56,7 +56,7 @@ class ReferenceLinkDB
         $this->addLink(
             41,
             'Link title: @website',
-            '?WDWyig',
+            '/shaare/WDWyig',
             'Stallman has a beard and is part of the Free Software Foundation (or not). Seriously, read this. #hashtag',
             0,
             DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, '20150310_114651'),
@@ -68,7 +68,7 @@ class ReferenceLinkDB
         $this->addLink(
             42,
             'Note: I have a big ID but an old date',
-            '?WDWyig',
+            '/shaare/WDWyig',
             'Used to test bookmarks reordering.',
             0,
             DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, '20100310_101010'),
index 09737b4b0a7850686e72dacbb726d0d7757cd9de..7b696e4c480f77e2831093e91cd7a63e56f208c2 100644 (file)
@@ -8,7 +8,7 @@
   {include="page.header"}
 <div id="pageError" class="page-error-container center">
   <h2>{'Sorry, nothing to see here.'|t}</h2>
-  <img src="img/sad_star.png" alt="">
+  <img src="{$asset_path}/img/sad_star.png#" alt="">
   <p>{$error_message}</p>
 </div>
 {include="page.footer"}
index b4b4a0ec1e3a5443d6923dc72fca9535806ba132..67d3ebd1c3f14e5d2dae92da82a2caf93d0178bb 100644 (file)
@@ -9,7 +9,7 @@
   <div class="pure-u-lg-1-3 pure-u-1-24"></div>
   <div id="addlink-form" class="page-form  page-form-light pure-u-lg-1-3 pure-u-22-24">
     <h2 class="window-title">{"Shaare a new link"|t}</h2>
-    <form method="GET" action="#" name="addform" class="addform">
+    <form method="GET" action="{$base_path}/admin/shaare" name="addform" class="addform">
       <div>
         <label for="shaare">{'URL or leave empty to post a note'|t}</label>
         <input type="text" name="post" id="shaare" class="autofocus">
index ab57943334c600d63f35871c84ea0e3cad7516b7..736774f37948a60617dbfb7a94304093b2d1a8d5 100644 (file)
@@ -9,7 +9,7 @@
   <div class="pure-u-lg-1-3 pure-u-1-24"></div>
   <div id="addlink-form" class="page-form page-form-light pure-u-lg-1-3 pure-u-22-24">
     <h2 class="window-title">{"Change password"|t}</h2>
-    <form method="POST" action="#" name="changepasswordform" id="changepasswordform">
+    <form method="POST" action="{$base_path}/admin/password" name="changepasswordform" id="changepasswordform">
       <div>
         <input type="password" name="oldpassword" aria-label="{'Current password'|t}" placeholder="{'Current password'|t}" class="autofocus">
       </div>
index ec6e0b464d66cc91e0ce02a12cf048c2249d23f4..16c558969a085dd5549cf6a64a53da60177a7f33 100644 (file)
@@ -9,7 +9,7 @@
   <div class="pure-u-lg-1-3 pure-u-1-24"></div>
   <div id="addlink-form" class="page-form page-form-light pure-u-lg-1-3 pure-u-22-24">
     <h2 class="window-title">{"Manage tags"|t}</h2>
-    <form method="POST" action="#" name="changetag" id="changetag">
+    <form method="POST" action="{$base_path}/admin/tags" name="changetag" id="changetag">
       <div>
         <input type="text" name="fromtag" aria-label="{'Tag'|t}" placeholder="{'Tag'|t}" value="{$fromtag}"
                list="tagsList" autocomplete="off" class="awesomplete autofocus" data-minChars="1">
@@ -32,7 +32,7 @@
       </div>
     </form>
 
-    <p>{'You can also edit tags in the'|t} <a href="?do=taglist&sort=usage">{'tag list'|t}</a>.</p>
+    <p>{'You can also edit tags in the'|t} <a href="{$base_path}/tags/list?sort=usage">{'tag list'|t}</a>.</p>
   </div>
 </div>
 {include="page.footer"}
index 8b75900de2e73a1c12c6039dbd2be4d56a1423bb..bb2564afd3712dc1dbb989043c035711dc8ccc93 100644 (file)
@@ -11,7 +11,7 @@
 {$ratioInput='7-12'}
 {$ratioInputMobile='1-8'}
 
-<form method="POST" action="#" name="configform" id="configform">
+<form method="POST" action="{$base_path}/admin/configure" name="configform" id="configform">
   <div class="pure-g">
     <div class="pure-u-lg-1-8 pure-u-1-24"></div>
     <div class="pure-u-lg-3-4 pure-u-22-24 page-form page-form-complete">
@@ -35,7 +35,7 @@
           <div class="form-label">
             <label for="titleLink">
               <span class="label-name">{'Home link'|t}</span><br>
-              <span class="label-desc">{'Default value'|t}: ?</span>
+              <span class="label-desc">{'Default value'|t}: {$base_path}/</span>
             </label>
           </div>
         </div>
                 {if="! $gd_enabled"}
                   {'You need to enable the extension <code>php-gd</code> to use thumbnails.'|t}
                 {elseif="$thumbnails_enabled"}
-                  <a href="?do=thumbs_update">{'Synchronize thumbnails'|t}</a>
+                  <a href="{$base_path}/admin/thumbnails">{'Synchronize thumbnails'|t}</a>
                 {/if}
               </span>
             </label>
index 6b5103a479c8400e65ec0465be03c56a78bac890..3ab8053f753280d348a00b31da3cd5c00e4d2f44 100644 (file)
@@ -11,7 +11,7 @@
   <div class="pure-u-lg-2-3 pure-u-22-24 page-form page-visitor" id="daily">
     <h2 class="window-title">
       {'The Daily Shaarli'|t}
-      <a href="?do=dailyrss" title="{'1 RSS entry per day'|t}"><i class="fa fa-rss"></i></a>
+      <a href="{$base_path}/daily-rss" title="{'1 RSS entry per day'|t}"><i class="fa fa-rss"></i></a>
     </h2>
 
     <div id="plugin_zone_start_daily" class="plugin_zone">
@@ -25,7 +25,7 @@
       <div class="pure-g">
         <div class="pure-u-lg-1-3 pure-u-1 center">
           {if="$previousday"}
-            <a href="?do=daily&amp;day={$previousday}">
+            <a href="{$base_path}/daily?day={$previousday}">
               <i class="fa fa-arrow-left"></i>
               {'Previous day'|t}
             </a>
@@ -36,7 +36,7 @@
         </div>
         <div class="pure-u-lg-1-3 pure-u-1 center">
           {if="$nextday"}
-            <a href="?do=daily&amp;day={$nextday}">
+            <a href="{$base_path}/daily?day={$nextday}">
               {'Next day'|t}
               <i class="fa fa-arrow-right"></i>
             </a>
@@ -69,7 +69,7 @@
                 {$link=$value}
                 <div class="daily-entry">
                   <div class="daily-entry-title center">
-                    <a href="?{$link.shorturl}" title="{'Permalink'|t}">
+                    <a href="{$base_path}/?{$link.shorturl}" title="{'Permalink'|t}">
                       <i class="fa fa-link"></i>
                     </a>
                     <a href="{$link.real_url}">{$link.title}</a>
@@ -85,7 +85,7 @@
                   {if="$link.tags"}
                     <div class="daily-entry-tags center">
                       {loop="link.taglist"}
-                        <span class="label label-tag" title="Add tag">
+                        <span class="label label-tag">
                           {$value}
                         </span>
                       {/loop}
   </div>
 </div>
 {include="page.footer"}
-<script src="js/thumbnails.min.js?v={$version_hash}"></script>
+<script src="{$asset_path}/js/thumbnails.min.js?v={$version_hash}#"></script>
 </body>
 </html>
 
index f589b06ead8b0f675c67f347c6b9360437d1bdb9..d40d94968ad6d75b2145b4116af5f449de7788f5 100644 (file)
@@ -1,16 +1,32 @@
-<item>
-    <title>{$title} - {function="strftime('%A %e %B %Y', $daydate)"}</title>
-    <guid>{$absurl}</guid>
-    <link>{$absurl}</link>
-    <pubDate>{$rssdate}</pubDate>
-    <description><![CDATA[
-        {loop="links"}
-               <h3><a href="{$value.url}">{$value.title}</a></h3>
-               <small>{if="!$hide_timestamps"}{function="strftime('%c', $value.timestamp)"} - {/if}{if="$value.tags"}{$value.tags}{/if}<br>
-               {$value.url}</small><br>
-               {if="$value.thumbnail"}<img src="{$index_url}{$value.thumbnail}#" alt="thumbnail" />{/if}<br>
-               {if="$value.description"}{$value.formatedDescription}{/if}
-               <br><br><hr>
-        {/loop}
-    ]]></description>
-</item>
+<?xml version="1.0" encoding="UTF-8"?>
+<rss version="2.0">
+  <channel>
+    <title>Daily - {$title}</title>
+    <link>{$index_url}</link>
+    <description>Daily shaared bookmarks</description>
+    <language>{$language}</language>
+    <copyright>{$index_url}</copyright>
+    <generator>Shaarli</generator>
+
+    {loop="$days"}
+      <item>
+        <title>{$value.date_human} - {$title}</title>
+        <guid>{$value.absolute_url}</guid>
+        <link>{$value.absolute_url}</link>
+        <pubDate>{$value.date_rss}</pubDate>
+        <description><![CDATA[
+          {loop="$value.links"}
+            <h3><a href="{$value.url}">{$value.title}</a></h3>
+            <small>
+              {if="!$hide_timestamps"}{$value.created|format_date} - {/if}{if="$value.tags"}{$value.tags}{/if}<br>
+              {$value.url}
+            </small><br>
+            {if="$value.thumbnail"}<img src="{$index_url}{$value.thumbnail}#" alt="thumbnail" />{/if}<br>
+            {if="$value.description"}{$value.description}{/if}
+            <br><br><hr>
+          {/loop}
+        ]]></description>
+      </item>
+    {/loop}
+  </channel>
+</rss><!-- Cached version of {$page_url} -->
index d16059a39b2dcfa204ddbc0ddff742eb812c7710..568545bd5cee0fb100ef838fb9d08cc813079b3c 100644 (file)
@@ -7,7 +7,11 @@
   {include="page.header"}
   <div id="editlinkform" class="edit-link-container" class="pure-g">
     <div class="pure-u-lg-1-5 pure-u-1-24"></div>
-    <form method="post" name="linkform" class="page-form pure-u-lg-3-5 pure-u-22-24 page-form page-form-light">
+    <form method="post"
+          name="linkform"
+          action="{$base_path}/admin/shaare"
+          class="page-form pure-u-lg-3-5 pure-u-22-24 page-form page-form-light"
+    >
       <h2 class="window-title">
         {if="!$link_is_new"}{'Edit Shaare'|t}{else}{'New Shaare'|t}{/if}
       </h2>
@@ -69,7 +73,7 @@
         <input type="submit" name="save_edit" class="" id="button-save-edit"
                value="{if="$link_is_new"}{'Save'|t}{else}{'Apply Changes'|t}{/if}">
         {if="!$link_is_new"}
-        <a href="?delete_link&amp;lf_linkdate={$link.id}&amp;token={$token}"
+        <a href="{$base_path}/admin/shaare/delete?id={$link.id}&amp;token={$token}"
            title="" name="delete_link" class="button button-red confirm-delete">
           {'Delete'|t}
         </a>
@@ -77,6 +81,7 @@
       </div>
 
       <input type="hidden" name="token" value="{$token}">
+      <input type="hidden" name="source" value="{$source}">
       {if="$http_referer"}
         <input type="hidden" name="returnurl" value="{$http_referer}">
       {/if}
index ef1dfd73e1759ea7f60f3dc3ec503133bdd415a4..c3e0c3c1db3f914475fbf0d59fa6a3e349f684c1 100644 (file)
@@ -15,7 +15,7 @@
       </pre>
   {/if}
 
-  <img src="img/sad_star.png" alt="">
+  <img src="{$asset_path}/img/sad_star.png#" alt="">
 </div>
 {include="page.footer"}
 </body>
index 99c01b11523a39fa72bc7da02e3b4582d01faa7f..c9c92943e8f804910da93ed5154ce34bea5c8032 100644 (file)
@@ -6,14 +6,13 @@
 <body>
 {include="page.header"}
 
-<form method="GET" action="#" name="exportform" id="exportform">
+<form method="POST" action="{$base_path}/admin/export" name="exportform" id="exportform">
   <div class="pure-g">
     <div class="pure-u-lg-1-4 pure-u-1-24"></div>
     <div class="pure-u-lg-1-2 pure-u-22-24 page-form page-form-complete">
       <div>
         <h2 class="window-title">{"Export Database"|t}</h2>
       </div>
-      <input type="hidden" name="do" value="export">
       <input type="hidden" name="token" value="{$token}">
 
       <div class="pure-g">
index bcfa7012dbe2ca93924e03bcaeab8ba070a0d149..dd58bd1e5196e5e09514a2787cb78647dd6ef699 100644 (file)
@@ -6,6 +6,8 @@
     <updated>{$last_update}</updated>
   {/if}
   <link rel="self" href="{$self_link}#" />
+  <link rel="search" type="application/opensearchdescription+xml" href="{$index_url}open-search#"
+        title="Shaarli search - {$shaarlititle}" />
   {loop="$plugins_feed_header"}
     {$value}
   {/loop}
index 66d9a8697b1ca9647305559c2da34f4a64d5ca14..85cec7f382ecf24e7a6ea23834ed448cff250605 100644 (file)
@@ -7,7 +7,9 @@
     <language>{$language}</language>
     <copyright>{$index_url}</copyright>
     <generator>Shaarli</generator>
-    <atom:link rel="self" href="{$self_link}"  />
+    <atom:link rel="self" href="{$self_link}" />
+    <atom:link rel="search" type="application/opensearchdescription+xml" href="{$index_url}open-search#"
+               title="Shaarli search - {$shaarlititle}" />
     {loop="$plugins_feed_header"}
       {$value}
     {/loop}
index c41afcdbda76b69c43d7900ed08b092dc8649226..156de71fc34d790c8400902a3c7944464a639a21 100644 (file)
@@ -6,7 +6,7 @@
 <body>
 {include="page.header"}
 
-<form method="POST" action="?do=import" enctype="multipart/form-data" name="uploadform" id="uploadform">
+<form method="POST" action="{$base_path}/admin/import" enctype="multipart/form-data" name="uploadform" id="uploadform">
   <div class="pure-g">
     <div class="pure-u-lg-1-4 pure-u-1-24"></div>
     <div class="pure-u-lg-1-2 pure-u-22-24 page-form page-form-complete">
index 3820a4f7ea14c6799820ede1c878b1be6b8810b5..227f9b52ae805080ec089ff59b1d2e16d0ee0dfd 100644 (file)
@@ -3,21 +3,22 @@
 <meta name="format-detection" content="telephone=no" />
 <meta name="viewport" content="width=device-width, initial-scale=1">
 <meta name="referrer" content="same-origin">
-<link rel="alternate" type="application/atom+xml" href="{$feedurl}?do=atom{$searchcrits}#" title="ATOM Feed" />
-<link rel="alternate" type="application/rss+xml" href="{$feedurl}?do=rss{$searchcrits}#" title="RSS Feed" />
-<link href="img/favicon.png" rel="shortcut icon" type="image/png" />
-<link href="img/apple-touch-icon.png" rel="apple-touch-icon" sizes="180x180" />
-<link type="text/css" rel="stylesheet" href="css/shaarli.min.css?v={$version_hash}" />
+<link rel="alternate" type="application/atom+xml" href="{$feedurl}feed/atom?{$searchcrits}#" title="ATOM Feed" />
+<link rel="alternate" type="application/rss+xml" href="{$feedurl}feed/rss?{$searchcrits}#" title="RSS Feed" />
+<link href="{$asset_path}/img/favicon.png#" rel="shortcut icon" type="image/png" />
+<link href="{$asset_path}/img/apple-touch-icon.png#" rel="apple-touch-icon" sizes="180x180" />
+<link type="text/css" rel="stylesheet" href="{$asset_path}/css/shaarli.min.css?v={$version_hash}#" />
 {if="$formatter==='markdown'"}
-  <link type="text/css" rel="stylesheet" href="css/markdown.min.css?v={$version_hash}" />
+  <link type="text/css" rel="stylesheet" href="{$asset_path}/css/markdown.min.css?v={$version_hash}#" />
 {/if}
 {loop="$plugins_includes.css_files"}
-  <link type="text/css" rel="stylesheet" href="{$value}?v={$version_hash}#"/>
+  <link type="text/css" rel="stylesheet" href="{$base_path}/{$value}?v={$version_hash}#"/>
 {/loop}
 {if="is_file('data/user.css')"}
-  <link type="text/css" rel="stylesheet" href="data/user.css#" />
+  <link type="text/css" rel="stylesheet" href="{$base_path}/data/user.css#" />
 {/if}
-<link rel="search" type="application/opensearchdescription+xml" href="?do=opensearch#" title="Shaarli search - {$shaarlititle}"/>
+<link rel="search" type="application/opensearchdescription+xml" href="{$base_path}/open-search#"
+      title="Shaarli search - {$shaarlititle}" />
 {if="! empty($links) && count($links) === 1"}
   {$link=reset($links)}
   <meta property="og:title" content="{$link.title}" />
index c6f501f0aa25c30c1dae2df8bab256027d89b838..a506a2eb2543b76f7dbe5ee760de737938a0bce5 100644 (file)
@@ -10,7 +10,7 @@
 {$ratioLabelMobile='7-8'}
 {$ratioInputMobile='1-8'}
 
-<form method="POST" action="#" name="installform" id="installform">
+<form method="POST" action="{$base_path}/install" name="installform" id="installform">
 <div class="pure-g">
   <div class="pure-u-lg-1-6 pure-u-1-24"></div>
   <div class="pure-u-lg-2-3 pure-u-22-24 page-form page-form-complete">
index ffc236c71554035bc61b4dc00a812d905ec57d56..c7617b228b5c13be9a140865a9c8e0dd5c7be784 100644 (file)
@@ -94,7 +94,9 @@
           {'tagged'|t}
           {loop="$exploded_tags"}
               <span class="label label-tag" title="{'Remove tag'|t}">
-                <a href="?removetag={function="urlencode($value)"}" aria-label="{'Remove tag'|t}">{$value}<span class="remove"><i class="fa fa-times" aria-hidden="true"></i></span></a>
+                <a href="{$base_path}/remove-tag/{function="urlencode($value)"}" aria-label="{'Remove tag'|t}">
+                  {$value}<span class="remove"><i class="fa fa-times" aria-hidden="true"></i></span>
+                </a>
               </span>
           {/loop}
         {/if}
                 <div class="thumbnail">
                   {ignore}RainTPL hack: put the 2 src on two different line to avoid path replace bug{/ignore}
                   <a href="{$value.real_url}" aria-hidden="true" tabindex="-1">
-                  <img data-src="{$value.thumbnail}#" class="b-lazy"
+                  <img data-src="{$base_path}/{$value.thumbnail}#" class="b-lazy"
                     src=""
                     alt="" width="{$thumbnails_width}" height="{$thumbnails_height}" />
                   </a>
                 {$tag_counter=count($value.taglist)}
                 {loop="value.taglist"}
                   <span class="label label-tag" title="{$strAddTag}">
-                    <a href="?addtag={$value|urlencode}">{$value}</a>
+                    <a href="{$base_path}/add-tag/{$value|urlencode}">{$value}</a>
                   </span>
                   {if="$tag_counter - 1 != $counter"}&middot;{/if}
                 {/loop}
                       <input type="checkbox" class="link-checkbox" value="{$value.id}">
                     </span>
                     <span class="linklist-item-infos-controls-item ctrl-edit">
-                      <a href="?edit_link={$value.id}" aria-label="{$strEdit}" title="{$strEdit}"><i class="fa fa-pencil-square-o edit-link" aria-hidden="true"></i></a>
+                      <a href="{$base_path}/admin/shaare/{$value.id}" aria-label="{$strEdit}" title="{$strEdit}"><i class="fa fa-pencil-square-o edit-link" aria-hidden="true"></i></a>
                     </span>
                     <span class="linklist-item-infos-controls-item ctrl-delete">
-                      <a href="?delete_link&amp;lf_linkdate={$value.id}&amp;token={$token}" aria-label="{$strDelete}"
+                      <a href="{$base_path}/admin/shaare/delete?id={$value.id}&amp;token={$token}" aria-label="{$strDelete}"
                          title="{$strDelete}" class="delete-link pure-u-0 pure-u-lg-visible confirm-delete">
                         <i class="fa fa-trash" aria-hidden="true"></i>
                       </a>
                     </span>
                     <span class="linklist-item-infos-controls-item ctrl-pin">
-                      <a href="?do=pin&amp;id={$value.id}&amp;token={$token}"
+                      <a href="{$base_path}/admin/shaare/{$value.id}/pin?token={$token}"
                          title="{$strToggleSticky}" aria-label="{$strToggleSticky}" class="pin-link {if="$value.sticky"}pinned-link{/if} pure-u-0 pure-u-lg-visible">
                         <i class="fa fa-thumb-tack" aria-hidden="true"></i>
                       </a>
                     </div>
                   {/if}
                 {/if}
-                <a href="?{$value.shorturl}" title="{$strPermalink}">
+                <a href="{$base_path}/shaare/{$value.shorturl}" title="{$strPermalink}">
                   {if="!$hide_timestamps || $is_logged_in"}
                     {$updated=$value.updated_timestamp ? $strEdited. format_date($value.updated) : $strPermalink}
                     <span class="linkdate" title="{$updated}">
                 {/if}
                 {if="$is_logged_in"}
                   &middot;
-                  <a href="?delete_link&amp;lf_linkdate={$value.id}&amp;token={$token}" aria-label="{$strDelete}"
+                  <a href="{$base_path}/admin/shaare/delete?id={$value.id}&amp;token={$token}" aria-label="{$strDelete}"
                      title="{$strDelete}" class="delete-link confirm-delete">
                     <i class="fa fa-trash" aria-hidden="true"></i>
                   </a>
                   &middot;
-                  <a href="?edit_link={$value.id}" aria-label="{$strEdit}" title="{$strEdit}"><i class="fa fa-pencil-square-o edit-link" aria-hidden="true"></i></a>
+                  <a href="{$base_path}/admin/shaare/{$value.id}" aria-label="{$strEdit}" title="{$strEdit}"><i class="fa fa-pencil-square-o edit-link" aria-hidden="true"></i></a>
                 {/if}
               </div>
             </div>
 </div>
 
 {include="page.footer"}
-<script src="js/thumbnails.min.js?v={$version_hash}"></script>
+<script src="{$asset_path}/js/thumbnails.min.js?v={$version_hash}#"></script>
 </body>
 </html>
index 68947f923a37afaa47e149bb2bd7cdbfd47a6901..7b320eaff226a99b523209dc90dc6096d32174b3 100644 (file)
@@ -6,14 +6,14 @@
           {'Filters'|t}
         </span>
         {if="$is_logged_in"}
-        <a href="?visibility=private" aria-label="{'Only display private links'|t}" title="{'Only display private links'|t}"
+        <a href="{$base_path}/admin/visibility/private" aria-label="{'Only display private links'|t}" title="{'Only display private links'|t}"
            class="{if="$visibility==='private'"}filter-on{else}filter-off{/if}"
         ><i class="fa fa-user-secret" aria-hidden="true"></i></a>
-        <a href="?visibility=public" aria-label="{'Only display public links'|t}" title="{'Only display public links'|t}"
+        <a href="{$base_path}/admin/visibility/public" aria-label="{'Only display public links'|t}" title="{'Only display public links'|t}"
            class="{if="$visibility==='public'"}filter-on{else}filter-off{/if}"
         ><i class="fa fa-globe" aria-hidden="true"></i></a>
         {/if}
-        <a href="?untaggedonly" aria-label="{'Filter untagged links'|t}" title="{'Filter untagged links'|t}"
+        <a href="{$base_path}/untagged-only" aria-label="{'Filter untagged links'|t}" title="{'Filter untagged links'|t}"
            class={if="$untaggedonly"}"filter-on"{else}"filter-off"{/if}
         ><i class="fa fa-tag" aria-hidden="true"></i></a>
         <a href="#" aria-label="{'Select all'|t}" title="{'Select all'|t}"
 
     <div class="linksperpage pure-u-1-3">
       <div class="pure-u-0 pure-u-lg-visible">{'Links per page'|t}</div>
-      <a href="?linksperpage=20">20</a>
-      <a href="?linksperpage=50">50</a>
-      <a href="?linksperpage=100">100</a>
-      <form method="GET" class="pure-u-0 pure-u-lg-visible">
-        <input type="text" name="linksperpage" placeholder="133">
+      <a href="{$base_path}/links-per-page?nb=20">20</a>
+      <a href="{$base_path}/links-per-page?nb=50">50</a>
+      <a href="{$base_path}/links-per-page?nb=100">100</a>
+      <form method="GET" class="pure-u-0 pure-u-lg-visible" action="{$base_path}/links-per-page">
+        <input type="text" name="nb" placeholder="133">
       </form>
       <a href="#" class="filter-off fold-all pure-u-0 pure-u-lg-visible" aria-label="{'Fold all'|t}" title="{'Fold all'|t}">
         <i class="fa fa-chevron-up" aria-hidden="true"></i>
index 3fcc30b74fdc2bc150687dcabc12f89b62222c54..1c7f279b0d061ff74b310cf79986ebfa57ca0464 100644 (file)
@@ -3,8 +3,8 @@
     <ShortName>Shaarli search - {$pagetitle}</ShortName>
     <Description>Shaarli search - {$pagetitle}</Description>
     <Url type="text/html" template="{$serverurl}?searchterm={searchTerms}" />
-    <Url type="application/atom+xml" template="{$serverurl}?do=atom&amp;searchterm={searchTerms}"/>
-    <Url type="application/rss+xml" template="{$serverurl}?do=rss&amp;searchterm={searchTerms}"/>
+    <Url type="application/atom+xml" template="{$serverurl}feed/atom?searchterm={searchTerms}"/>
+    <Url type="application/rss+xml" template="{$serverurl}feed/rss?searchterm={searchTerms}"/>
     <InputEncoding>UTF-8</InputEncoding>
     <Developer>Shaarli Community - https://github.com/shaarli/Shaarli/</Developer>
     <Image width="16" height="16">data:image/x-icon;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAHRklE
index 0899826b66f7b2f73d1437f2e4c49a27e47f8cbd..51bdb2f0b0eb0569f0b42e8d2255ccafb3a334d8 100644 (file)
@@ -10,7 +10,7 @@
     {/if}
     &middot;
     {'The personal, minimalist, super-fast, database free, bookmarking service'|t} {'by the Shaarli community'|t} &middot;
-    <a href="doc/html/index.html" rel="nofollow">{'Documentation'|t}</a>
+    <a href="{$base_path}/doc/html/index.html" rel="nofollow">{'Documentation'|t}</a>
       {loop="$plugins_footer.text"}
           {$value}
       {/loop}
@@ -25,7 +25,7 @@
 {/loop}
 
 {loop="$plugins_footer.js_files"}
-       <script src="{$value}#"></script>
+       <script src="{$base_path}/{$value}#"></script>
 {/loop}
 
 <div id="js-translations" class="hidden">
@@ -39,4 +39,5 @@
   </span>
 </div>
 
-<script src="js/shaarli.min.js?v={$version_hash}"></script>
+<input type="hidden" name="js_base_path" value="{$base_path}" />
+<script src="{$asset_path}/js/shaarli.min.js?v={$version_hash}#"></script>
index 82f8ebf1fa164f947221ae36bdcd994c8443adc8..a71464c71c3faf000c5f46d0807d9a0106ce3bca 100644 (file)
         </li>
         {if="$is_logged_in || $openshaarli"}
           <li class="pure-menu-item">
-            <a href="?do=addlink" class="pure-menu-link" id="shaarli-menu-shaare">
+            <a href="{$base_path}/admin/add-shaare" class="pure-menu-link" id="shaarli-menu-shaare">
               <i class="fa fa-plus" aria-hidden="true"></i> {'Shaare'|t}
             </a>
           </li>
           <li class="pure-menu-item" id="shaarli-menu-tools">
-            <a href="?do=tools" class="pure-menu-link">{'Tools'|t}</a>
+            <a href="{$base_path}/admin/tools" class="pure-menu-link">{'Tools'|t}</a>
           </li>
         {/if}
         <li class="pure-menu-item" id="shaarli-menu-tags">
-          <a href="?do=tagcloud" class="pure-menu-link">{'Tag cloud'|t}</a>
+          <a href="{$base_path}/tags/cloud" class="pure-menu-link">{'Tag cloud'|t}</a>
         </li>
         {if="$thumbnails_enabled"}
           <li class="pure-menu-item" id="shaarli-menu-picwall">
-            <a href="?do=picwall{$searchcrits}" class="pure-menu-link">{'Picture wall'|t}</a>
+            <a href="{$base_path}/picture-wall?{function="ltrim($searchcrits, '&')"}" class="pure-menu-link">{'Picture wall'|t}</a>
           </li>
         {/if}
         <li class="pure-menu-item" id="shaarli-menu-daily">
-          <a href="?do=daily" class="pure-menu-link">{'Daily'|t}</a>
+          <a href="{$base_path}/daily" class="pure-menu-link">{'Daily'|t}</a>
         </li>
         {loop="$plugins_header.buttons_toolbar"}
           <li class="pure-menu-item shaarli-menu-plugin">
           </li>
         {/loop}
         <li class="pure-menu-item pure-u-lg-0 shaarli-menu-mobile" id="shaarli-menu-mobile-rss">
-            <a href="?do={$feed_type}{$searchcrits}" class="pure-menu-link">{'RSS Feed'|t}</a>
+            <a href="{$base_path}/feed/{$feed_type}?{$searchcrits}" class="pure-menu-link">{'RSS Feed'|t}</a>
         </li>
         {if="$is_logged_in"}
           <li class="pure-menu-item pure-u-lg-0 shaarli-menu-mobile" id="shaarli-menu-mobile-logout">
-            <a href="?do=logout" class="pure-menu-link">{'Logout'|t}</a>
+            <a href="{$base_path}/admin/logout" class="pure-menu-link">{'Logout'|t}</a>
           </li>
         {else}
           <li class="pure-menu-item pure-u-lg-0 shaarli-menu-mobile" id="shaarli-menu-mobile-login">
-            <a href="/login" class="pure-menu-link">{'Login'|t}</a>
+            <a href="{$base_path}/login" class="pure-menu-link">{'Login'|t}</a>
           </li>
         {/if}
       </ul>
             </a>
           </li>
           <li class="pure-menu-item" id="shaarli-menu-desktop-rss">
-            <a href="?do={$feed_type}{$searchcrits}" class="pure-menu-link" title="{'RSS Feed'|t}" aria-label="{'RSS Feed'|t}">
+            <a href="{$base_path}/feed/{$feed_type}?{$searchcrits}" class="pure-menu-link" title="{'RSS Feed'|t}" aria-label="{'RSS Feed'|t}">
               <i class="fa fa-rss" aria-hidden="true"></i>
             </a>
           </li>
           {if="!$is_logged_in"}
             <li class="pure-menu-item" id="shaarli-menu-desktop-login">
-              <a href="/login" class="pure-menu-link"
+              <a href="{$base_path}/login" class="pure-menu-link"
                  data-open-id="header-login-form"
                  id="login-button" aria-label="{'Login'|t}" title="{'Login'|t}">
                 <i class="fa fa-user" aria-hidden="true"></i>
@@ -88,7 +88,7 @@
             </li>
           {else}
             <li class="pure-menu-item" id="shaarli-menu-desktop-logout">
-              <a href="?do=logout" class="pure-menu-link" aria-label="{'Logout'|t}" title="{'Logout'|t}">
+              <a href="{$base_path}/admin/logout" class="pure-menu-link" aria-label="{'Logout'|t}" title="{'Logout'|t}">
                 <i class="fa fa-sign-out" aria-hidden="true"></i>
               </a>
             </li>
 
 <main id="content" class="container" role="main">
   <div id="search" class="subheader-form searchform-block header-search">
-    <form method="GET" class="pure-form searchform" name="searchform">
+    <form method="GET" class="pure-form searchform" name="searchform" action="{$base_path}/">
       <input type="text" id="searchform_value" name="searchterm" aria-label="{'Search text'|t}" placeholder="{'Search text'|t}"
              {if="!empty($search_term)"}
              value="{$search_term}"
   </div>
 {/if}
 
-{if="!empty($global_warnings) && $is_logged_in"}
-  <div class="pure-g pure-alert pure-alert-warning pure-alert-closable" id="shaarli-warnings-alert">
+{if="!empty($global_errors)"}
+  <div class="pure-g header-alert-message pure-alert pure-alert-error pure-alert-closable" id="shaarli-errors-alert">
+  <div class="pure-u-2-24"></div>
+    <div class="pure-u-20-24">
+      {loop="$global_errors"}
+        <p>{$value}</p>
+      {/loop}
+    </div>
+    <div class="pure-u-2-24">
+      <i class="fa fa-times pure-alert-close"></i>
+    </div>
+  </div>
+{/if}
+
+{if="!empty($global_warnings)"}
+  <div class="pure-g header-alert-message pure-alert pure-alert-warning pure-alert-closable" id="shaarli-warnings-alert">
     <div class="pure-u-2-24"></div>
     <div class="pure-u-20-24">
       {loop="global_warnings"}
   </div>
 {/if}
 
+{if="!empty($global_successes)"}
+  <div class="pure-g header-alert-message new-version-message pure-alert pure-alert-success pure-alert-closable" id="shaarli-success-alert">
+    <div class="pure-u-2-24"></div>
+    <div class="pure-u-20-24">
+      {loop="$global_successes"}
+      <p>{$value}</p>
+      {/loop}
+    </div>
+    <div class="pure-u-2-24">
+      <i class="fa fa-times pure-alert-close"></i>
+    </div>
+  </div>
+{/if}
+
   <div class="clear"></div>
index 73359949ce433cb96dc0e6510c56e6e22933602c..b7a56c89b17fb56540e1127acff994122286d035 100644 (file)
@@ -5,61 +5,55 @@
 </head>
 <body>
 {include="page.header"}
-{if="!$thumbnails_enabled"}
-<div class="pure-g pure-alert pure-alert-warning page-single-alert">
-  <div class="pure-u-1 center">
-    {'Picture wall unavailable (thumbnails are disabled).'|t}
-  </div>
-</div>
-{else}
-  {if="count($linksToDisplay)===0 && $is_logged_in"}
-    <div class="pure-g pure-alert pure-alert-warning page-single-alert">
-      <div class="pure-u-1 center">
-        {'There is no cached thumbnail. Try to <a href="?do=thumbs_update">synchronize them</a>.'|t}
-      </div>
+
+{if="count($linksToDisplay)===0 && $is_logged_in"}
+  <div class="pure-g pure-alert pure-alert-warning page-single-alert">
+    <div class="pure-u-1 center">
+      {'There is no cached thumbnail.'|t}
+      <a href="{$base_path}/admin/thumbnails">{'Try to synchronize them.'|t}</a>
     </div>
-  {/if}
+  </div>
+{/if}
 
-  <div class="pure-g">
-    <div class="pure-u-lg-1-6 pure-u-1-24"></div>
-    <div class="pure-u-lg-2-3 pure-u-22-24 page-form page-visitor">
-      {$countPics=count($linksToDisplay)}
-      <h2 class="window-title">{'Picture Wall'|t} - {$countPics} {'pics'|t}</h2>
+<div class="pure-g">
+  <div class="pure-u-lg-1-6 pure-u-1-24"></div>
+  <div class="pure-u-lg-2-3 pure-u-22-24 page-form page-visitor">
+    {$countPics=count($linksToDisplay)}
+    <h2 class="window-title">{'Picture Wall'|t} - {$countPics} {'pics'|t}</h2>
 
-      <div id="plugin_zone_start_picwall" class="plugin_zone">
-        {loop="$plugin_start_zone"}
-          {$value}
-        {/loop}
-      </div>
+    <div id="plugin_zone_start_picwall" class="plugin_zone">
+      {loop="$plugin_start_zone"}
+        {$value}
+      {/loop}
+    </div>
 
-      <div id="picwall-container" class="picwall-container" role="list">
-        {loop="$linksToDisplay"}
-          <div class="picwall-pictureframe" role="listitem">
-            {ignore}RainTPL hack: put the 2 src on two different line to avoid path replace bug{/ignore}
-            <img data-src="{$value.thumbnail}#" class="b-lazy"
-                 src=""
-                 alt="" width="{$thumbnails_width}" height="{$thumbnails_height}" />
-            <a href="{$value.real_url}"><span class="info">{$value.title}</span></a>
-            {loop="$value.picwall_plugin"}
-              {$value}
-            {/loop}
-          </div>
-        {/loop}
-        <div class="clear"></div>
-      </div>
+    <div id="picwall-container" class="picwall-container" role="list">
+      {loop="$linksToDisplay"}
+        <div class="picwall-pictureframe" role="listitem">
+          {ignore}RainTPL hack: put the 2 src on two different line to avoid path replace bug{/ignore}
+          <img data-src="{$value.thumbnail}#" class="b-lazy"
+               src=""
+               alt="" width="{$thumbnails_width}" height="{$thumbnails_height}" />
+          <a href="{$value.real_url}"><span class="info">{$value.title}</span></a>
+          {loop="$value.picwall_plugin"}
+            {$value}
+          {/loop}
+        </div>
+      {/loop}
+      <div class="clear"></div>
+    </div>
 
-      <div id="plugin_zone_end_picwall" class="plugin_zone">
-        {loop="$plugin_end_zone"}
-          {$value}
-        {/loop}
-      </div>
+    <div id="plugin_zone_end_picwall" class="plugin_zone">
+      {loop="$plugin_end_zone"}
+        {$value}
+      {/loop}
     </div>
-    <div class="pure-u-lg-1-6 pure-u-1-24"></div>
   </div>
-{/if}
+  <div class="pure-u-lg-1-6 pure-u-1-24"></div>
+</div>
 
 {include="page.footer"}
-<script src="js/thumbnails.min.js?v={$version_hash}"></script>
+<script src="{$asset_path}/js/thumbnails.min.js?v={$version_hash}#"></script>
 </body>
 </html>
 
index 4bfaa934381893d09dd46388b34cd4ac3d4448e3..05d13556231418de5528e8053a7413f14f239d9d 100644 (file)
@@ -16,7 +16,7 @@
   <div class="clear"></div>
 </noscript>
 
-<form method="POST" action="?do=save_pluginadmin" name="pluginform" id="pluginform" class="pluginform-container">
+<form method="POST" action="{$base_path}/admin/plugins" name="pluginform" id="pluginform" class="pluginform-container">
   <div class="pure-g">
     <div class="pure-u-lg-1-8 pure-u-1-24"></div>
     <div class="pure-u-lg-3-4 pure-u-22-24 page-form page-form-complete">
   <input type="hidden" name="token" value="{$token}">
 </form>
 
-<form action="?do=save_pluginadmin" method="POST">
+<form action="{$base_path}/admin/plugins" method="POST">
   <div class="pure-g">
     <div class="pure-u-lg-1-8 pure-u-1-24"></div>
     <div class="pure-u-lg-3-4 pure-u-22-24 page-form page-form-light">
       </section>
     </div>
   </div>
+  <input type="hidden" name="token" value="{$token}">
 </form>
 
 {include="page.footer"}
-<script src="js/pluginsadmin.min.js?v={$version_hash}"></script>
+<script src="{$asset_path}/js/pluginsadmin.min.js?v={$version_hash}#"></script>
 
 </body>
 </html>
index 7839fcca78e4b1b5f6c408ae190a576a083ac4da..024882ec723db3310b145466f750a1eb1269a4d4 100644 (file)
@@ -15,7 +15,7 @@
     <h2 class="window-title">{'Tag cloud'|t} - {$countTags} {'tags'|t}</h2>
     {if="!empty($search_tags)"}
     <p class="center">
-      <a href="?searchtags={$search_tags|urlencode}" class="pure-button pure-button-shaarli">
+      <a href="{$base_path}/?searchtags={$search_tags|urlencode}" class="pure-button pure-button-shaarli">
         {'List all links with those tags'|t}
       </a>
     </p>
@@ -48,8 +48,8 @@
 
     <div id="cloudtag" class="cloudtag-container">
       {loop="tags"}
-        <a href="?searchtags={$key|urlencode} {$search_tags|urlencode}" style="font-size:{$value.size}em;">{$key}</a
-        ><a href="?addtag={$key|urlencode}" title="{'Filter by tag'|t}" class="count">{$value.count}</a>
+        <a href="{$base_path}/?searchtags={$key|urlencode} {$search_tags|urlencode}" style="font-size:{$value.size}em;">{$key}</a
+        ><a href="{$base_path}/add-tag/{$key|urlencode}" title="{'Filter by tag'|t}" class="count">{$value.count}</a>
         {loop="$value.tag_plugin"}
           {$value}
         {/loop}
index d5777465ce3177e62fc59f52c0cd70506e32306b..99ae44d2487f22a4a339a018968ce883c79b8ed2 100644 (file)
@@ -15,7 +15,7 @@
     <h2 class="window-title">{'Tag list'|t} - {$countTags} {'tags'|t}</h2>
     {if="!empty($search_tags)"}
       <p class="center">
-        <a href="?searchtags={$search_tags|urlencode}" class="pure-button pure-button-shaarli">
+        <a href="{$base_path}/?searchtags={$search_tags|urlencode}" class="pure-button pure-button-shaarli">
           {'List all links with those tags'|t}
         </a>
       </p>
           <div class="pure-u-1">
             {if="$is_logged_in===true"}
               <a href="#" class="delete-tag" aria-label="{'Delete'|t}"><i class="fa fa-trash" aria-hidden="true"></i></a>&nbsp;&nbsp;
-              <a href="?do=changetag&fromtag={$key|urlencode}" class="rename-tag" aria-label="{'Rename tag'|t}">
+              <a href="{$base_path}/admin/tags?fromtag={$key|urlencode}" class="rename-tag" aria-label="{'Rename tag'|t}">
                 <i class="fa fa-pencil-square-o {$key}" aria-hidden="true"></i>
               </a>
             {/if}
 
-            <a href="?addtag={$key|urlencode}" title="{'Filter by tag'|t}" class="count">{$value}</a>
-            <a href="?searchtags={$key|urlencode} {$search_tags|urlencode}" class="tag-link">{$key}</a>
+            <a href="{$base_path}/add-tag/{$key|urlencode}" title="{'Filter by tag'|t}" class="count">{$value}</a>
+            <a href="{$base_path}/?searchtags={$key|urlencode} {$search_tags|urlencode}" class="tag-link">{$key}</a>
 
             {loop="$value.tag_plugin"}
               {$value}
index d24c9f645ae3f9feecc541bb8ff22eeb2c258cbe..8718b188370c6008f5edd77334378242a4b92b3b 100644 (file)
@@ -1,8 +1,8 @@
 <div class="pure-g">
   <div class="pure-u-1 pure-alert pure-alert-success tag-sort">
     {'Sort by:'|t}
-    <a href="?do=tagcloud">{'Cloud'|t}</a> &middot;
-    <a href="?do=taglist&sort=usage">{'Most used'|t}</a> &middot;
-    <a href="?do=taglist&sort=alpha">{'Alphabetical'|t}</a>
+    <a href="{$base_path}/tags/cloud">{'Cloud'|t}</a> &middot;
+    <a href="{$base_path}/tags/list?sort=usage">{'Most used'|t}</a> &middot;
+    <a href="{$base_path}/tags/list?sort=alpha">{'Alphabetical'|t}</a>
   </div>
-</div>
\ No newline at end of file
+</div>
index 5f9bef08e48b91b806c03894bcb19dc7d7bf1253..504644ca84fa00d60ccca5fe2f5be98bc3b3ebcf 100644 (file)
@@ -43,6 +43,6 @@
 </div>
 
 {include="page.footer"}
-<script src="js/thumbnails_update.min.js?v={$version_hash}"></script>
+<script src="{$asset_path}/js/thumbnails_update.min.js?v={$version_hash}#"></script>
 </body>
 </html>
index 20d0c893cb8d80d7dce271d8937189daa7775ac1..2cb08e387b468e8f2b39942a46ae0699abc98088 100644 (file)
   <div class="pure-u-lg-1-3 pure-u-22-24 page-form page-form-light">
     <h2 class="window-title">{'Settings'|t}</h2>
     <div class="tools-item">
-      <a href="?do=configure" title="{'Change Shaarli settings: title, timezone, etc.'|t}">
+      <a href="{$base_path}/admin/configure" title="{'Change Shaarli settings: title, timezone, etc.'|t}">
         <span class="pure-button pure-u-lg-2-3 pure-u-3-4">{'Configure your Shaarli'|t}</span>
       </a>
     </div>
     <div class="tools-item">
-      <a href="?do=pluginadmin" title="{'Enable, disable and configure plugins'|t}">
+      <a href="{$base_path}/admin/plugins" title="{'Enable, disable and configure plugins'|t}">
         <span class="pure-button pure-u-lg-2-3 pure-u-3-4">{'Plugin administration'|t}</span>
       </a>
     </div>
     {if="!$openshaarli"}
       <div class="tools-item">
-        <a href="?do=changepasswd" title="{'Change your password'|t}">
+        <a href="{$base_path}/admin/password" title="{'Change your password'|t}">
           <span class="pure-button pure-u-lg-2-3 pure-u-3-4">{'Change password'|t}</span>
         </a>
       </div>
     {/if}
     <div class="tools-item">
-      <a href="?do=changetag" title="{'Rename or delete a tag in all links'|t}">
+      <a href="{$base_path}/admin/tags" title="{'Rename or delete a tag in all links'|t}">
         <span class="pure-button pure-u-lg-2-3 pure-u-3-4">{'Manage tags'|t}</span>
       </a>
     </div>
     <div class="tools-item">
-      <a href="?do=import"
+      <a href="{$base_path}/admin/import"
          title="{'Import Netscape HTML bookmarks (as exported from Firefox, Chrome, Opera, delicious...)'|t}">
         <span class="pure-button pure-u-lg-2-3 pure-u-3-4">{'Import links'|t}</span>
       </a>
     </div>
     <div class="tools-item">
-      <a href="?do=export"
+      <a href="{$base_path}/admin/export"
          title="{'Export Netscape HTML bookmarks (which can be imported in Firefox, Chrome, Opera, delicious...)'|t}">
         <span class="pure-button pure-u-lg-2-3 pure-u-3-4">{'Export database'|t}</span>
       </a>
@@ -47,7 +47,7 @@
 
     {if="$thumbnails_enabled"}
       <div class="tools-item">
-        <a href="?do=thumbs_update" title="{'Synchronize all link thumbnails'|t}">
+        <a href="{$base_path}/admin/thumbnails" title="{'Synchronize all link thumbnails'|t}">
           <span class="pure-button pure-u-lg-2-3 pure-u-3-4">{'Synchronize thumbnails'|t}</span>
         </a>
       </div>
@@ -86,7 +86,7 @@
               alert('{function="str_replace(' ', '%20', t('The selected text is too long, it will be truncated.'))"}');
             }
             window.open(
-              '{$pageabsaddr}?post='%20+%20encodeURIComponent(url)+
+              '{$pageabsaddr}admin/shaare?post='%20+%20encodeURIComponent(url)+
               '&amp;title='%20+%20encodeURIComponent(title)+
               '&amp;description='%20+%20encodeURIComponent(desc)+
               '&amp;source=bookmarklet','_blank','menubar=no,height=800,width=600,toolbar=no,scrollbars=yes,status=no,dialog=1'
index 53e98e2e0f36ffb61135122fea42f9461930b9b0..0fef0f08be9a3b3e1ca7035a5b564a8247647c51 100644 (file)
@@ -10,7 +10,7 @@
 <div class="error-container">
     <h1>404 Not found <small>Oh crap!</small></h1>
     <p>{$error_message}</p>
-    <p>Would you mind <a href="?">clicking here</a>?</p>
+    <p>Would you mind <a href="{$base_path}/">clicking here</a>?</p>
 </div>
 {include="page.footer"}
 </body>
index da50f45e208029a704e24dd0bf44a31c4a73ddd1..ade08c7c69cfc108e1944b96d036dfc2e451edc3 100644 (file)
@@ -5,7 +5,7 @@
 <div id="pageheader">
        {include="page.header"}
        <div id="headerform">
-               <form method="GET" action="" name="addform" class="addform">
+               <form method="GET" action="{$base_path}/admin/shaare" name="addform" class="addform">
                        <input type="text" name="post" class="linkurl">
                        <input type="submit" value="Add link" class="bigbutton">
                </form>
index c40daf9d385b781fe3c2c1bbfa713b00a2164ba9..7e37b9a3459b7e4f8b6e862cfbe1491be88dcb36 100644 (file)
@@ -4,7 +4,7 @@
 <body onload="document.changepasswordform.oldpassword.focus();">
 <div id="pageheader">
        {include="page.header"}
-       <form method="POST" action="#" name="changepasswordform" id="changepasswordform">
+       <form method="POST" action="{$base_path}/admin/password" name="changepasswordform" id="changepasswordform">
        Old password: <input type="password" name="oldpassword">&nbsp; &nbsp;
        New password: <input type="password" name="setpassword">
        <input type="hidden" name="token" value="{$token}">
@@ -12,4 +12,4 @@
 </div>
 {include="page.footer"}
 </body>
-</html>
\ No newline at end of file
+</html>
index 670a8dd7309c2d73b92927aae72569b434dec351..6ef60252d32394b1b5a5760c9abbacb829e90ec0 100644 (file)
@@ -5,7 +5,7 @@
 <body onload="document.changetag.fromtag.focus();">
 <div id="pageheader">
        {include="page.header"}
-       <form method="POST" action="" name="changetag" id="changetag">
+       <form method="POST" action="{$base_path}/admin/tags" name="changetag" id="changetag">
         <input type="hidden" name="token" value="{$token}">
         <div>
             <label for="fromtag">Tag:</label>
index 53b0cad20ef6f3b74b424f271cf854a151280a47..ba4f3f71c02fd38e359498a95f09a7d9ae2e3198 100644 (file)
@@ -4,7 +4,7 @@
 <body onload="document.configform.title.focus();">
 <div id="pageheader">
   {include="page.header"}
-  <form method="POST" action="#" name="configform" id="configform">
+  <form method="POST" action="{$base_path}/admin/configure" name="configform" id="configform">
     <input type="hidden" name="token" value="{$token}">
     <table id="configuration_table">
 
@@ -16,7 +16,7 @@
       <tr>
         <td><b>Home link:</b></td>
         <td><input type="text" name="titleLink" id="titleLink" size="50" value="{$titleLink}"><br/><label
-            for="titleLink">(default value is: ?)</label></td>
+            for="titleLink">(default value is: {$base_path}/)</label></td>
       </tr>
 
       <tr>
             {if="! $gd_enabled"}
               {'You need to enable the extension <code>php-gd</code> to use thumbnails.'|t}
             {elseif="$thumbnails_enabled"}
-              <a href="?do=thumbs_update">{'Synchonize thumbnails'|t}</a>
+              <a href="{$base_path}/admin/thumbnails">{'Synchonize thumbnails'|t}</a>
             {/if}
           </label>
         </td>
index 00f18e26c97951330d9439a1cbb318ab67ed6a3c..74f6cdc74417194f9931a1ceabdd51b701575b4f 100644 (file)
@@ -14,9 +14,9 @@
 
     <div class="dailyAbout">
         All links of one day<br>in a single page.<br>
-        {if="$previousday"} <a href="?do=daily&amp;day={$previousday}"><b>&lt;</b>Previous day</a>{else}<b>&lt;</b>Previous day{/if}
+        {if="$previousday"} <a href="{$base_path}/daily&amp;day={$previousday}"><b>&lt;</b>Previous day</a>{else}<b>&lt;</b>Previous day{/if}
         -
-        {if="$nextday"}<a href="?do=daily&amp;day={$nextday}">Next day<b>&gt;</b></a>{else}Next day<b>&gt;</b>{/if}
+        {if="$nextday"}<a href="{$base_path}/daily&amp;day={$nextday}">Next day<b>&gt;</b></a>{else}Next day<b>&gt;</b>{/if}
         <br>
 
         {loop="$daily_about_plugin"}
         {/loop}
 
         <br>
-        <a href="?do=dailyrss" title="1 RSS entry per day"><img src="img/feed-icon-14x14.png" alt="rss_feed">Daily RSS Feed</a>
+        <a href="{$base_path}/daily-rss" title="1 RSS entry per day"><img src="{$asset_path}/img/feed-icon-14x14.png#" alt="rss_feed">Daily RSS Feed</a>
     </div>
 
     <div class="dailyTitle">
-        <img src="img/floral_left.png" width="51" height="50" class="nomobile" alt="floral_left">
+        <img src="{$asset_path}/img/floral_left.png#" width="51" height="50" class="nomobile" alt="floral_left">
         The Daily Shaarli
-        <img src="img/floral_right.png" width="51" height="50" class="nomobile" alt="floral_right">
+        <img src="{$asset_path}/img/floral_right.png#" width="51" height="50" class="nomobile" alt="floral_right">
     </div>
 
     <div class="dailyDate">
                     {$link=$value}
                     <div class="dailyEntry">
                         <div class="dailyEntryPermalink">
-                            <a href="?{$value.shorturl}">
-                                <img src="img/squiggle.png" width="25" height="26" title="permalink" alt="permalink">
+                            <a href="{$base_path}/?{$value.shorturl}">
+                                <img src="{$asset_path}/img/squiggle.png#" width="25" height="26" title="permalink" alt="permalink">
                             </a>
                         </div>
                         {if="!$hide_timestamps || $is_logged_in"}
                             <div class="dailyEntryLinkdate">
-                                <a href="?{$value.shorturl}">{function="strftime('%c', $link.timestamp)"}</a>
+                                <a href="{$base_path}/?{$value.shorturl}">{function="strftime('%c', $link.timestamp)"}</a>
                             </div>
                         {/if}
                         {if="$link.tags"}
             {$value}
         {/loop}
     </div>
-    <div id="closing"><img src="img/squiggle_closing.png" width="66" height="61" alt="-"></div>
+    <div id="closing"><img src="{$asset_path}/img/squiggle_closing.png#" width="66" height="61" alt="-"></div>
 </div>
 {include="page.footer"}
-<script src="js/thumbnails.min.js?v={$version_hash}"></script>
+<script src="{$asset_path}/js/thumbnails.min.js?v={$version_hash}#"></script>
 </body>
 </html>
index f589b06ead8b0f675c67f347c6b9360437d1bdb9..ff19bbfbae7f8c1ed9903705e849e53f455fe5cd 100644 (file)
@@ -1,16 +1,32 @@
-<item>
-    <title>{$title} - {function="strftime('%A %e %B %Y', $daydate)"}</title>
-    <guid>{$absurl}</guid>
-    <link>{$absurl}</link>
-    <pubDate>{$rssdate}</pubDate>
-    <description><![CDATA[
-        {loop="links"}
-               <h3><a href="{$value.url}">{$value.title}</a></h3>
-               <small>{if="!$hide_timestamps"}{function="strftime('%c', $value.timestamp)"} - {/if}{if="$value.tags"}{$value.tags}{/if}<br>
-               {$value.url}</small><br>
-               {if="$value.thumbnail"}<img src="{$index_url}{$value.thumbnail}#" alt="thumbnail" />{/if}<br>
-               {if="$value.description"}{$value.formatedDescription}{/if}
-               <br><br><hr>
+<?xml version="1.0" encoding="UTF-8"?>
+<rss version="2.0">
+  <channel>
+    <title>Daily - {$title}</title>
+    <link>{$index_url}</link>
+    <description>Daily shaared bookmarks</description>
+    <language>{$language}</language>
+    <copyright>{$index_url}</copyright>
+    <generator>Shaarli</generator>
+
+    {loop="$days"}
+    <item>
+      <title>{$value.date_human} - {$title}</title>
+      <guid>{$value.absolute_url}</guid>
+      <link>{$value.absolute_url}</link>
+      <pubDate>{$value.date_rss}</pubDate>
+      <description><![CDATA[
+        {loop="$value.links"}
+        <h3><a href="{$value.url}">{$value.title}</a></h3>
+        <small>
+          {if="!$hide_timestamps"}{$value.created|format_date} - {/if}{if="$value.tags"}{$value.tags}{/if}<br>
+          {$value.url}
+        </small><br>
+        {if="$value.thumbnail"}<img src="{$index_url}{$value.thumbnail}#" alt="thumbnail" />{/if}<br>
+        {if="$value.description"}{$value.description}{/if}
+        <br><br><hr>
         {/loop}
-    ]]></description>
-</item>
+        ]]></description>
+    </item>
+    {/loop}
+  </channel>
+</rss><!-- Cached version of {$page_url} -->
index 6f7a330f4f979ab1f044f482f3a56dc26f24adae..c3671b1f960bf8b2432f13920bc941c63af828a4 100644 (file)
@@ -1,20 +1,16 @@
 <!DOCTYPE html>
 <html>
 <head>{include="includes"}
-    <link type="text/css" rel="stylesheet" href="inc/awesomplete.css#" />
 </head>
 <body
 {if="$link.title==''"}onload="document.linkform.lf_title.focus();"
 {elseif="$link.description==''"}onload="document.linkform.lf_description.focus();"
 {else}onload="document.linkform.lf_tags.focus();"{/if} >
 <div id="pageheader">
-    {if="$source !== 'firefoxsocialapi'"}
     {include="page.header"}
-    {else}
     <div id="shaarli_title"><a href="{$titleLink}">{$shaarlititle}</a></div>
-    {/if}
     <div id="editlinkform">
-        <form method="post" name="linkform">
+        <form method="post" name="linkform" action="{$base_path}/admin/shaare">
             <input type="hidden" name="lf_linkdate" value="{$link.linkdate}">
           {if="isset($link.id)"}
                  <input type="hidden" name="lf_id" value="{$link.id}">
             {/if}
             <input type="submit" value="Save" name="save_edit" class="bigbutton">
             {if="!$link_is_new && isset($link.id)"}
-              <a href="?delete_link&amp;lf_linkdate={$link.id}&amp;token={$token}"
+              <a href="{$base_path}/admin/shaare/delete?id={$link.id}&amp;token={$token}"
                  name="delete_link" class="bigbutton"
                  onClick="return confirmDeleteLink();">
                 {'Delete'|t}
               </a>
             {/if}
             <input type="hidden" name="token" value="{$token}">
+            <input type="hidden" name="source" value="{$source}">
             {if="$http_referer"}<input type="hidden" name="returnurl" value="{$http_referer}">{/if}
         </form>
     </div>
 </div>
-{if="$source !== 'firefoxsocialapi'"}
 {include="page.footer"}
-{/if}
 </body>
 </html>
index b6e62be09e9ff6f404c388c82c8c15e08413b496..64f54cd287be2d44f956eaa234d19bda0c13a419 100644 (file)
@@ -18,7 +18,7 @@
         </pre>
     {/if}
 
-    <p>Would you mind <a href="?">clicking here</a>?</p>
+    <p>Would you mind <a href="{$base_path}/">clicking here</a>?</p>
 </div>
 {include="page.footer"}
 </body>
index 67c3d05fb8a4926260d32f4a5ca09b8f2e9bca4c..c30e3b0ad41f3a3c3dc6944c85fa55a2c8fd64b1 100644 (file)
@@ -5,12 +5,13 @@
   <div id="pageheader">
     {include="page.header"}
     <div id="toolsdiv">
-      <form method="GET">
-        <input type="hidden" name="do" value="export">
+      <form method="POST" action="{$base_path}/admin/export">
         Selection:<br>
         <input type="radio" name="selection" value="all" checked="true"> All<br>
         <input type="radio" name="selection" value="private"> Private<br>
         <input type="radio" name="selection" value="public"> Public<br>
+        <input type="hidden" name="token" value="{$token}">
+
         <br>
         <input type="checkbox" name="prepend_note_url" id="prepend_note_url">
         <label for="prepend_note_url">
index 0621cb9e456d105e8769275c7fd5fb7a10454259..5919bb4956210ddf152b2df4d8bc0dad067db26e 100644 (file)
@@ -6,8 +6,8 @@
     <updated>{$last_update}</updated>
   {/if}
   <link rel="self" href="{$self_link}#" />
-  <link rel="search" type="application/opensearchdescription+xml" href="{$index_url}?do=opensearch#"
-             title="Shaarli search - {$shaarlititle}" />
+  <link rel="search" type="application/opensearchdescription+xml" href="{$index_url}open-search#"
+        title="Shaarli search - {$shaarlititle}" />
   {loop="$feed_plugins_header"}
     {$value}
   {/loop}
index ee3fef880de3b7e931d4f04968d4cb3ca0403706..4be8202f57ced7cb19fbbd470b38fa70f857f843 100644 (file)
@@ -8,7 +8,7 @@
     <copyright>{$index_url}</copyright>
     <generator>Shaarli</generator>
     <atom:link rel="self" href="{$self_link}"  />
-    <atom:link rel="search" type="application/opensearchdescription+xml" href="{$index_url}?do=opensearch#"
+    <atom:link rel="search" type="application/opensearchdescription+xml" href="{$index_url}open-search#"
                title="Shaarli search - {$shaarlititle}" />
     {loop="$feed_plugins_header"}
       {$value}
index bb9e4a562040b410b00e90bbc6a6a63b5520d9ab..7d6eac766d47677044608434e82d06fdcb9b456c 100644 (file)
@@ -6,7 +6,7 @@
   {include="page.header"}
   <div id="uploaddiv">
     Import Netscape HTML bookmarks (as exported from Firefox/Chrome/Opera/Delicious/Diigo...) (Max: {$maxfilesize}).
-    <form method="POST" action="?do=import" enctype="multipart/form-data"
+    <form method="POST" action="{$base_path}/admin/import" enctype="multipart/form-data"
           name="uploadform" id="uploadform">
       <input type="hidden" name="token" value="{$token}">
       <input type="hidden" name="MAX_FILE_SIZE" value="{$maxfilesize}">
index 8d273c441c95bb7f5123bfeae86114d1a75f25b7..eac05701c70abf1ed49479faa6dbbd2bf0cfcdcf 100644 (file)
@@ -3,18 +3,19 @@
 <meta name="format-detection" content="telephone=no" />
 <meta name="viewport" content="width=device-width,initial-scale=1.0" />
 <meta name="referrer" content="same-origin">
-<link rel="alternate" type="application/rss+xml" href="{$feedurl}?do=rss{$searchcrits}#" title="RSS Feed" />
-<link rel="alternate" type="application/atom+xml" href="{$feedurl}?do=atom{$searchcrits}#" title="ATOM Feed" />
+<link rel="alternate" type="application/rss+xml" href="{$feedurl}feed/rss?{$searchcrits}#" title="RSS Feed" />
+<link rel="alternate" type="application/atom+xml" href="{$feedurl}feed/atom?{$searchcrits}#" title="ATOM Feed" />
 <link href="img/favicon.ico" rel="shortcut icon" type="image/x-icon" />
-<link type="text/css" rel="stylesheet" href="css/shaarli.min.css" />
+<link type="text/css" rel="stylesheet" href="{$asset_path}/css/shaarli.min.css#" />
 {if="$formatter==='markdown'"}
-  <link type="text/css" rel="stylesheet" href="css/markdown.min.css?v={$version_hash}" />
+  <link type="text/css" rel="stylesheet" href="{$asset_path}/css/markdown.min.css?v={$version_hash}#" />
 {/if}
 {loop="$plugins_includes.css_files"}
-<link type="text/css" rel="stylesheet" href="{$value}#"/>
+<link type="text/css" rel="stylesheet" href="{$base_path}/{$value}#"/>
 {/loop}
-{if="is_file('data/user.css')"}<link type="text/css" rel="stylesheet" href="data/user.css#" />{/if}
-<link rel="search" type="application/opensearchdescription+xml" href="?do=opensearch#" title="Shaarli search - {$shaarlititle|htmlspecialchars}"/>
+{if="is_file('data/user.css')"}<link type="text/css" rel="stylesheet" href="{$base_path}/data/user.css#" />{/if}
+<link rel="search" type="application/opensearchdescription+xml" href="{$base_path}/open-search#"
+      title="Shaarli search - {$shaarlititle|htmlspecialchars}" />
 {if="! empty($links) && count($links) === 1"}
   {$link=reset($links)}
   <meta property="og:title" content="{$link.title}" />
index aca890d6c3a549fca924fa02a58a5fa384a7dae6..8c10b2cba7df49f27d53e0cc1d1df353365ee64e 100644 (file)
@@ -5,7 +5,7 @@
 <div id="install">
     <h1>Shaarli</h1>
     It looks like it's the first time you run Shaarli. Please configure it:<br>
-    <form method="POST" action="#" name="installform" id="installform">
+    <form method="POST" action="{$base_path}/install" name="installform" id="installform">
         <table>
             <tr><td><b>Login:</b></td><td><input type="text" name="setlogin" size="30"></td></tr>
             <tr><td><b>Password:</b></td><td><input type="password" name="setpassword" size="30"></td></tr>
index dcb14e908042f6a6088ca118f820f19551cac47a..00896eb5ec65153cab29823bd738ece6b0ff2cb6 100644 (file)
@@ -1,7 +1,6 @@
 <!DOCTYPE html>
 <html>
 <head>
-    <link type="text/css" rel="stylesheet" href="inc/awesomplete.css#" />
     {include="includes"}
 </head>
 <body>
                 tagged
                 {loop="$exploded_tags"}
                     <span class="linktag" title="Remove tag">
-                        <a href="?removetag={function="urlencode($value)"}">{$value} <span class="remove">x</span></a>
+                        <a href="{$base_path}/remove-tag/{function="urlencode($value)"}">{$value} <span class="remove">x</span></a>
                     </span>
                 {/loop}
             {elseif="$search_tags === false"}
                 <span class="linktag" title="Remove tag">
-                    <a href="?">untagged <span class="remove">x</span></a>
+                    <a href="{$base_path}/">untagged <span class="remove">x</span></a>
                 </span>
             {/if}
         </div>
@@ -84,7 +83,7 @@
                 <div class="thumbnail">
                     <a href="{$value.real_url}">
                         {ignore}RainTPL hack: put the 2 src on two different line to avoid path replace bug{/ignore}
-                        <img data-src="{$value.thumbnail}#" class="b-lazy"
+                        <img data-src="{$base_path}/{$value.thumbnail}#" class="b-lazy"
                              src=""
                              alt="thumbnail" width="{$thumbnails_width}" height="{$thumbnails_height}" />
                     </a>
             <div class="linkcontainer">
                 {if="$is_logged_in"}
                     <div class="linkeditbuttons">
-                        <form method="GET" class="buttoneditform">
-                            <input type="hidden" name="edit_link" value="{$value.id}">
-                            <input type="image" alt="Edit" src="img/edit_icon.png" title="Edit" class="button_edit">
-                        </form><br>
-                        <form method="GET" class="buttoneditform">
-                            <input type="hidden" name="lf_linkdate" value="{$value.id}">
-                            <input type="hidden" name="token" value="{$token}">
-                            <input type="hidden" name="delete_link">
-                            <input type="image" alt="Delete" src="img/delete_icon.png" title="Delete"
-                                   class="button_delete" onClick="return confirmDeleteLink();">
-                        </form>
+                        <a href="{$base_path}/admin/shaare/{$value.id}" title="Edit" class="button_edit">
+                            <img src="{$asset_path}/img/edit_icon.png#">
+                        </a>
+                        <br>
+                        <a href="{$base_path}/admin/shaare/delete?id={$value.id}&amp;token={$token}" label="Delete"
+                           onClick="return confirmDeleteLink();"
+                           class="button_delete"
+                        >
+                            <img src="{$asset_path}/img/delete_icon.png#">
+                        </a>
                     </div>
                 {/if}
                 <span class="linktitle">
                 {if="!$hide_timestamps || $is_logged_in"}
                     {$updated=$value.updated_timestamp ? 'Edited: '. format_date($value.updated) : 'Permalink'}
                     <span class="linkdate" title="Permalink">
-                        <a href="?{$value.shorturl}">
+                        <a href="{$base_path}/shaare/{$value.shorturl}">
                             <span title="{$updated}">
                                 {$value.created|format_date}
                                 {if="$value.updated_timestamp"}*{/if}
                         </a> -
                     </span>
                 {else}
-                    <span class="linkdate" title="Short link here"><a href="?{$value.shorturl}">permalink</a> - </span>
+                    <span class="linkdate" title="Short link here"><a href="{$base_path}/shaare/{$value.shorturl}">permalink</a> - </span>
                 {/if}
 
                 {loop="$value.link_plugin"}
                 <a href="{$value.real_url}"><span class="linkurl" title="Short link">{$value.url}</span></a><br>
                 {if="$value.tags"}
                     <div class="linktaglist">
-                    {loop="$value.taglist"}<span class="linktag" title="Add tag"><a href="?addtag={$value|urlencode}">{$value}</a></span> {/loop}
+                    {loop="$value.taglist"}<span class="linktag" title="Add tag"><a href="{$base_path}/add-tag/{$value|urlencode}">{$value}</a></span> {/loop}
                     </div>
                 {/if}
 
 </div>
 
     {include="page.footer"}
-<script src="js/thumbnails.min.js"></script>
+<script src="{$asset_path}/js/thumbnails.min.js#"></script>
 
 </body>
 </html>
index 35149a6bfddda4c5b3633da3ca5ada947f855b9e..b9396df6de74d61f8f19a5e6d67a01bb6e169fb1 100644 (file)
@@ -1,11 +1,11 @@
 <div class="paging">
 {if="$is_logged_in"}
     <div class="paging_privatelinks">
-      <a href="?visibility=private">
+      <a href="{$base_path}/admin/isibility/private">
                {if="$visibility=='private'"}
-      <img src="img/private_16x16_active.png" width="16" height="16" title="Click to see all links" alt="Click to see all links">
+      <img src="{$asset_path}/img/private_16x16_active.png#" width="16" height="16" title="Click to see all links" alt="Click to see all links">
     {else}
-      <img src="img/private_16x16.png" width="16" height="16" title="Click to see only private links" alt="Click to see only private links">
+      <img src="{$asset_path}/img/private_16x16.png#" width="16" height="16" title="Click to see only private links" alt="Click to see only private links">
                {/if}
                </a>
 
       </div>
     {/loop}
     <div class="paging_linksperpage">
-        Links per page: <a href="?linksperpage=20">20</a> <a href="?linksperpage=50">50</a> <a href="?linksperpage=100">100</a>
-        <form method="GET" class="linksperpage"><input type="text" name="linksperpage" size="2"></form>
+        Links per page:
+        <a href="{$base_path}/links-per-page?nb=20">20</a>
+        <a href="{$base_path}/links-per-page?nb=50">50</a>
+        <a href="{$base_path}/links-per-page?nb=100">100</a>
+        <form method="GET" class="linksperpage" action="{$base_path}/links-per-page">
+          <input type="text" name="nb" size="2">
+        </form>
     </div>
     {if="$previous_page_url"} <a href="{$previous_page_url}" class="paging_older">&#x25C4;Older</a> {/if}
     <div class="paging_current">page {$page_current} / {$page_max} </div>
index a37920667883f5182a083be7b8387bcbf09a7791..6aa20ab193453df7baaa9128c0106bfb0c3f50dd 100644 (file)
@@ -11,7 +11,7 @@
   {include="page.header"}
 
   <div id="headerform">
-    <form method="post" name="loginform">
+    <form method="post" name="loginform" action="{$base_path}/login">
       <label for="login">Login: <input type="text" id="login" name="login" tabindex="1"
          {if="!empty($username)"}value="{$username}"{/if}>
       </label>
index 3fcc30b74fdc2bc150687dcabc12f89b62222c54..1c7f279b0d061ff74b310cf79986ebfa57ca0464 100644 (file)
@@ -3,8 +3,8 @@
     <ShortName>Shaarli search - {$pagetitle}</ShortName>
     <Description>Shaarli search - {$pagetitle}</Description>
     <Url type="text/html" template="{$serverurl}?searchterm={searchTerms}" />
-    <Url type="application/atom+xml" template="{$serverurl}?do=atom&amp;searchterm={searchTerms}"/>
-    <Url type="application/rss+xml" template="{$serverurl}?do=rss&amp;searchterm={searchTerms}"/>
+    <Url type="application/atom+xml" template="{$serverurl}feed/atom?searchterm={searchTerms}"/>
+    <Url type="application/rss+xml" template="{$serverurl}feed/rss?searchterm={searchTerms}"/>
     <InputEncoding>UTF-8</InputEncoding>
     <Developer>Shaarli Community - https://github.com/shaarli/Shaarli/</Developer>
     <Image width="16" height="16">data:image/x-icon;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAHRklE
index a3380841b5d35e00dccbafa2e7949c99ece0793a..0fe4c7368f10ac071b76565fd2a2cdcc1ddb822c 100644 (file)
 </div>
 {/if}
 
-<script src="js/shaarli.min.js"></script>
+<script src="{$asset_path}/js/shaarli.min.js#"></script>
 
 {if="$is_logged_in"}
 <script>function confirmDeleteLink() { var agree=confirm("Are you sure you want to delete this link ?"); if (agree) return true ; else return false ; }</script>
 {/if}
 
 {loop="$plugins_footer.js_files"}
-       <script src="{$value}#"></script>
+       <script src="{$base_path}/{$value}#"></script>
 {/loop}
+
+<input type="hidden" name="js_base_path" value="{$base_path}" />
index a37926d2a183521398b5060b52abf67390af60b4..0a33523b375c3f650242a155a68386ee6f4cb5b5 100644 (file)
 {else}
 <li><a href="{$titleLink}" class="nomobile">Home</a></li>
     {if="$is_logged_in"}
-    <li><a href="?do=logout">Logout</a></li>
-    <li><a href="?do=tools">Tools</a></li>
-    <li><a href="?do=addlink">Add link</a></li>
+    <li><a href="{$base_path}/admin/logout">Logout</a></li>
+    <li><a href="{$base_path}/admin/tools">Tools</a></li>
+    <li><a href="{$base_path}/admin/add-shaare">Add link</a></li>
     {elseif="$openshaarli"}
-    <li><a href="?do=tools">Tools</a></li>
-    <li><a href="?do=addlink">Add link</a></li>
+    <li><a href="{$base_path}/admin/tools">Tools</a></li>
+    <li><a href="{$base_path}/admin/add-shaare">Add link</a></li>
     {else}
-    <li><a href="/login">Login</a></li>
+    <li><a href="{$base_path}/login">Login</a></li>
     {/if}
-    <li><a href="{$feedurl}?do=rss{$searchcrits}" class="nomobile">RSS Feed</a></li>
+    <li><a href="{$feedurl}/feed/rss?{$searchcrits}" class="nomobile">RSS Feed</a></li>
     {if="$showatom"}
-    <li><a href="{$feedurl}?do=atom{$searchcrits}" class="nomobile">ATOM Feed</a></li>
+    <li><a href="{$feedurl}/feed/atom?{$searchcrits}" class="nomobile">ATOM Feed</a></li>
     {/if}
-    <li><a href="?do=tagcloud">Tag cloud</a></li>
-    <li><a href="?do=picwall{$searchcrits}">Picture wall</a></li>
-    <li><a href="?do=daily">Daily</a></li>
+    <li><a href="{$base_path}/tags/cloud">Tag cloud</a></li>
+    <li><a href="{$base_path}/picture-wall{function="ltrim($searchcrits, '&')"}">Picture wall</a></li>
+    <li><a href="{$base_path}/daily">Daily</a></li>
     {loop="$plugins_header.buttons_toolbar"}
         <li><a
             {loop="$value.attr"}
index b3a16791b679a06dc8cadea773d299f3cf0e701f..da3aa36c4096d2b601931c9ad88c96766690a5ac 100644 (file)
@@ -38,6 +38,6 @@
 
 {include="page.footer"}
 
-<script src="js/thumbnails.min.js"></script>
+<script src="{$asset_path}/js/thumbnails.min.js#"></script>
 </body>
 </html>
index 63b45cac5d13b7719fe71a4684ca229192be68a0..d0972cd15befc4b4d0900e75e532c884b4563ccf 100644 (file)
@@ -16,7 +16,7 @@
 </noscript>
 
 <div id="pluginsadmin">
-  <form action="?do=save_pluginadmin" method="POST">
+  <form action="{$base_path}/admin/plugins" method="POST">
     <section id="enabled_plugins">
       <h1>Enabled Plugins</h1>
 
         <input type="submit" value="Save"/>
       </div>
     </section>
+    <input type="hidden" name="token" value="{$token}">
   </form>
 
-  <form action="?do=save_pluginadmin" method="POST">
+  <form action="{$base_path}/admin/plugins" method="POST">
     <section id="plugin_parameters">
       <h1>Enabled Plugin Parameters</h1>
 
         </div>
       </div>
     </section>
+    <input type="hidden" name="token" value="{$token}">
   </form>
 
 </div>
index d93bf4f9db94b8cdbbd4b97f3358025652320a72..5d21f2395549a27de7de4d1ebd28395589b41592 100644 (file)
@@ -12,8 +12,8 @@
 
     <div id="cloudtag">
         {loop="$tags"}
-            <a href="?addtag={$key|urlencode}" class="count">{$value.count}</a><a
-                href="?searchtags={$key|urlencode}" style="font-size:{$value.size}em;">{$key}</a>
+            <a href="{$base_path}/add-tag/{$key|urlencode}" class="count">{$value.count}</a><a
+                href="{$base_path}/?searchtags={$key|urlencode}" style="font-size:{$value.size}em;">{$key}</a>
             {loop="$value.tag_plugin"}
                 {$value}
             {/loop}
index 5cad845b62d91b45dc4dd3109cfda1ee0593bea3..18f296f7ad73fff10d1f5c77c216962dc8ea3dca 100644 (file)
@@ -23,6 +23,6 @@
 <input type="hidden" name="ids" value="{function="implode(',', $ids)"}" />
 
 {include="page.footer"}
-<script src="js/thumbnails_update.min.js?v={$version_hash}"></script>
+<script src="{$asset_path}/js/thumbnails_update.min.js?v={$version_hash}#"></script>
 </body>
 </html>
index 1cef726eef357aedd4a27779dcb85990b2effe55..1125bba92082ba8ef3622c208ffb22df35387998 100644 (file)
@@ -5,17 +5,17 @@
 <div id="pageheader">
        {include="page.header"}
        <div id="toolsdiv">
-               <a href="?do=configure"><b>Configure your Shaarli</b><span>: Change Title, timezone...</span></a>
+               <a href="{$base_path}/admin/configure"><b>Configure your Shaarli</b><span>: Change Title, timezone...</span></a>
                <br><br>
-               <a href="?do=pluginadmin"><b>Plugin administration</b><span>: Enable, disable and configure plugins.</span></a>
+               <a href="{$base_path}/admin/plugins"><b>Plugin administration</b><span>: Enable, disable and configure plugins.</span></a>
     <br><br>
-               {if="!$openshaarli"}<a href="?do=changepasswd"><b>Change password</b><span>: Change your password.</span></a>
+               {if="!$openshaarli"}<a href="{$base_path}/admin/password"><b>Change password</b><span>: Change your password.</span></a>
     <br><br>{/if}
-               <a href="?do=changetag"><b>Rename/delete tags</b><span>: Rename or delete a tag in all links</span></a>
+               <a href="{$base_path}/admin/tags"><b>Rename/delete tags</b><span>: Rename or delete a tag in all links</span></a>
     <br><br>
-               <a href="?do=import"><b>Import</b><span>: Import Netscape html bookmarks (as exported from Firefox, Chrome, Opera, delicious...)</span></a>
+               <a href="{$base_path}/admin/import"><b>Import</b><span>: Import Netscape html bookmarks (as exported from Firefox, Chrome, Opera, delicious...)</span></a>
     <br><br>
-               <a href="?do=export"><b>Export</b><span>: Export Netscape html bookmarks (which can be imported in Firefox, Chrome, Opera, delicious...)</span></a>
+               <a href="{$base_path}/admin/export"><b>Export</b><span>: Export Netscape html bookmarks (which can be imported in Firefox, Chrome, Opera, delicious...)</span></a>
     <br><br>
                <a class="smallbutton"
                   onclick="return alertBookmarklet();"
@@ -24,7 +24,7 @@
                                var%20url%20=%20location.href;
                                var%20title%20=%20document.title%20||%20url;
                                window.open(
-                                       '{$pageabsaddr}?post='%20+%20encodeURIComponent(url)+
+                                       '{$pageabsaddr}admin/shaare?post='%20+%20encodeURIComponent(url)+
                                        '&amp;title='%20+%20encodeURIComponent(title)+
                                        '&amp;description='%20+%20encodeURIComponent(document.getSelection())+
                                        '&amp;source=bookmarklet','_blank','menubar=no,height=390,width=600,toolbar=no,scrollbars=no,status=no,dialog=1'
index cb547f3eab32ff0a3b3d51f138094c8dc5afbbbd..df6479505770634460f8b5c5ddc6e12eba09f3af 100644 (file)
--- a/yarn.lock
+++ b/yarn.lock
@@ -3433,9 +3433,9 @@ lodash.uniq@^4.5.0:
   integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=
 
 lodash@^4.0.0, lodash@^4.17.11, lodash@^4.17.4, lodash@^4.3.0, lodash@~4.17.10:
-  version "4.17.15"
-  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548"
-  integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==
+  version "4.17.19"
+  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.19.tgz#e48ddedbe30b3321783c5b4301fbd353bc1e4a4b"
+  integrity sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ==
 
 longest@^1.0.1:
   version "1.0.1"