aboutsummaryrefslogtreecommitdiffhomepage
path: root/application
diff options
context:
space:
mode:
Diffstat (limited to 'application')
-rw-r--r--application/History.php1
-rw-r--r--application/Languages.php3
-rw-r--r--application/Router.php184
-rw-r--r--application/Thumbnailer.php3
-rw-r--r--application/Utils.php84
-rw-r--r--application/api/ApiMiddleware.php21
-rw-r--r--application/api/ApiUtils.php17
-rw-r--r--application/api/controllers/ApiController.php3
-rw-r--r--application/api/controllers/Links.php23
-rw-r--r--application/bookmark/Bookmark.php202
-rw-r--r--application/bookmark/BookmarkArray.php23
-rw-r--r--application/bookmark/BookmarkFileService.php157
-rw-r--r--application/bookmark/BookmarkFilter.php175
-rw-r--r--application/bookmark/BookmarkIO.php49
-rw-r--r--application/bookmark/BookmarkInitializer.php87
-rw-r--r--application/bookmark/BookmarkServiceInterface.php109
-rw-r--r--application/bookmark/LinkUtils.php116
-rw-r--r--application/bookmark/exception/DatastoreNotInitializedException.php10
-rw-r--r--application/config/ConfigJson.php8
-rw-r--r--application/config/ConfigManager.php7
-rw-r--r--application/config/ConfigPlugin.php17
-rw-r--r--application/container/ContainerBuilder.php105
-rw-r--r--application/container/ShaarliContainer.php31
-rw-r--r--application/feed/Cache.php38
-rw-r--r--application/feed/FeedBuilder.php147
-rw-r--r--application/formatter/BookmarkDefaultFormatter.php154
-rw-r--r--application/formatter/BookmarkFormatter.php101
-rw-r--r--application/formatter/BookmarkMarkdownExtraFormatter.php24
-rw-r--r--application/formatter/BookmarkMarkdownFormatter.php10
-rw-r--r--application/formatter/FormatterFactory.php2
-rw-r--r--application/front/ShaarliAdminMiddleware.php27
-rw-r--r--application/front/ShaarliMiddleware.php83
-rw-r--r--application/front/controller/admin/ConfigureController.php126
-rw-r--r--application/front/controller/admin/ExportController.php80
-rw-r--r--application/front/controller/admin/ImportController.php82
-rw-r--r--application/front/controller/admin/LogoutController.php33
-rw-r--r--application/front/controller/admin/ManageTagController.php88
-rw-r--r--application/front/controller/admin/MetadataController.php29
-rw-r--r--application/front/controller/admin/PasswordController.php101
-rw-r--r--application/front/controller/admin/PluginsController.php85
-rw-r--r--application/front/controller/admin/ServerController.php87
-rw-r--r--application/front/controller/admin/SessionFilterController.php50
-rw-r--r--application/front/controller/admin/ShaareAddController.php34
-rw-r--r--application/front/controller/admin/ShaareManageController.php202
-rw-r--r--application/front/controller/admin/ShaarePublishController.php263
-rw-r--r--application/front/controller/admin/ShaarliAdminController.php71
-rw-r--r--application/front/controller/admin/ThumbnailsController.php65
-rw-r--r--application/front/controller/admin/TokenController.php26
-rw-r--r--application/front/controller/admin/ToolsController.php35
-rw-r--r--application/front/controller/visitor/BookmarkListController.php249
-rw-r--r--application/front/controller/visitor/DailyController.php205
-rw-r--r--application/front/controller/visitor/ErrorController.php42
-rw-r--r--application/front/controller/visitor/ErrorNotFoundController.php29
-rw-r--r--application/front/controller/visitor/FeedController.php58
-rw-r--r--application/front/controller/visitor/InstallController.php175
-rw-r--r--application/front/controller/visitor/LoginController.php153
-rw-r--r--application/front/controller/visitor/OpenSearchController.php27
-rw-r--r--application/front/controller/visitor/PictureWallController.php54
-rw-r--r--application/front/controller/visitor/PublicSessionFilterController.php46
-rw-r--r--application/front/controller/visitor/ShaarliVisitorController.php181
-rw-r--r--application/front/controller/visitor/TagCloudController.php121
-rw-r--r--application/front/controller/visitor/TagController.php118
-rw-r--r--application/front/controllers/LoginController.php48
-rw-r--r--application/front/controllers/ShaarliController.php69
-rw-r--r--application/front/exceptions/AlreadyInstalledException.php15
-rw-r--r--application/front/exceptions/CantLoginException.php10
-rw-r--r--application/front/exceptions/LoginBannedException.php2
-rw-r--r--application/front/exceptions/OpenShaarliPasswordException.php18
-rw-r--r--application/front/exceptions/ResourcePermissionException.php13
-rw-r--r--application/front/exceptions/ShaarliFrontException.php (renamed from application/front/exceptions/ShaarliException.php)4
-rw-r--r--application/front/exceptions/ThumbnailsDisabledException.php15
-rw-r--r--application/front/exceptions/UnauthorizedException.php15
-rw-r--r--application/front/exceptions/WrongTokenException.php18
-rw-r--r--application/helper/ApplicationUtils.php (renamed from application/ApplicationUtils.php)95
-rw-r--r--application/helper/DailyPageHelper.php208
-rw-r--r--application/helper/FileUtils.php (renamed from application/FileUtils.php)58
-rw-r--r--application/http/HttpAccess.php47
-rw-r--r--application/http/HttpUtils.php196
-rw-r--r--application/http/MetadataRetriever.php69
-rw-r--r--application/legacy/LegacyController.php162
-rw-r--r--application/legacy/LegacyLinkDB.php6
-rw-r--r--application/legacy/LegacyRouter.php63
-rw-r--r--application/legacy/LegacyUpdater.php7
-rw-r--r--application/legacy/UnknowLegacyRouteException.php9
-rw-r--r--application/netscape/NetscapeBookmarkUtils.php133
-rw-r--r--application/plugin/PluginManager.php31
-rw-r--r--application/render/PageBuilder.php114
-rw-r--r--application/render/PageCacheManager.php60
-rw-r--r--application/render/TemplatePage.php34
-rw-r--r--application/security/BanManager.php30
-rw-r--r--application/security/CookieManager.php33
-rw-r--r--application/security/LoginManager.php83
-rw-r--r--application/security/SessionManager.php111
-rw-r--r--application/updater/Updater.php75
94 files changed, 5669 insertions, 1118 deletions
diff --git a/application/History.php b/application/History.php
index 4fd2f294..bd5c1bf7 100644
--- a/application/History.php
+++ b/application/History.php
@@ -4,6 +4,7 @@ namespace Shaarli;
4use DateTime; 4use DateTime;
5use Exception; 5use Exception;
6use Shaarli\Bookmark\Bookmark; 6use Shaarli\Bookmark\Bookmark;
7use Shaarli\Helper\FileUtils;
7 8
8/** 9/**
9 * Class History 10 * Class History
diff --git a/application/Languages.php b/application/Languages.php
index 5cda802e..d83e0765 100644
--- a/application/Languages.php
+++ b/application/Languages.php
@@ -179,9 +179,10 @@ class Languages
179 { 179 {
180 return [ 180 return [
181 'auto' => t('Automatic'), 181 'auto' => t('Automatic'),
182 'de' => t('German'),
182 'en' => t('English'), 183 'en' => t('English'),
183 'fr' => t('French'), 184 'fr' => t('French'),
184 'de' => t('German'), 185 'jp' => t('Japanese'),
185 ]; 186 ];
186 } 187 }
187} 188}
diff --git a/application/Router.php b/application/Router.php
deleted file mode 100644
index d7187487..00000000
--- a/application/Router.php
+++ /dev/null
@@ -1,184 +0,0 @@
1<?php
2namespace Shaarli;
3
4/**
5 * Class Router
6 *
7 * (only displayable pages here)
8 */
9class Router
10{
11 public static $AJAX_THUMB_UPDATE = 'ajax_thumb_update';
12
13 public static $PAGE_LOGIN = 'login';
14
15 public static $PAGE_PICWALL = 'picwall';
16
17 public static $PAGE_TAGCLOUD = 'tagcloud';
18
19 public static $PAGE_TAGLIST = 'taglist';
20
21 public static $PAGE_DAILY = 'daily';
22
23 public static $PAGE_FEED_ATOM = 'atom';
24
25 public static $PAGE_FEED_RSS = 'rss';
26
27 public static $PAGE_TOOLS = 'tools';
28
29 public static $PAGE_CHANGEPASSWORD = 'changepasswd';
30
31 public static $PAGE_CONFIGURE = 'configure';
32
33 public static $PAGE_CHANGETAG = 'changetag';
34
35 public static $PAGE_ADDLINK = 'addlink';
36
37 public static $PAGE_EDITLINK = 'edit_link';
38
39 public static $PAGE_DELETELINK = 'delete_link';
40
41 public static $PAGE_CHANGE_VISIBILITY = 'change_visibility';
42
43 public static $PAGE_PINLINK = 'pin';
44
45 public static $PAGE_EXPORT = 'export';
46
47 public static $PAGE_IMPORT = 'import';
48
49 public static $PAGE_OPENSEARCH = 'opensearch';
50
51 public static $PAGE_LINKLIST = 'linklist';
52
53 public static $PAGE_PLUGINSADMIN = 'pluginadmin';
54
55 public static $PAGE_SAVE_PLUGINSADMIN = 'save_pluginadmin';
56
57 public static $PAGE_THUMBS_UPDATE = 'thumbs_update';
58
59 public static $GET_TOKEN = 'token';
60
61 /**
62 * Reproducing renderPage() if hell, to avoid regression.
63 *
64 * This highlights how bad this needs to be rewrite,
65 * but let's focus on plugins for now.
66 *
67 * @param string $query $_SERVER['QUERY_STRING'].
68 * @param array $get $_SERVER['GET'].
69 * @param bool $loggedIn true if authenticated user.
70 *
71 * @return string page found.
72 */
73 public static function findPage($query, $get, $loggedIn)
74 {
75 $loggedIn = ($loggedIn === true) ? true : false;
76
77 if (empty($query) && !isset($get['edit_link']) && !isset($get['post'])) {
78 return self::$PAGE_LINKLIST;
79 }
80
81 if (startsWith($query, 'do=' . self::$PAGE_LOGIN) && $loggedIn === false) {
82 return self::$PAGE_LOGIN;
83 }
84
85 if (startsWith($query, 'do=' . self::$PAGE_PICWALL)) {
86 return self::$PAGE_PICWALL;
87 }
88
89 if (startsWith($query, 'do=' . self::$PAGE_TAGCLOUD)) {
90 return self::$PAGE_TAGCLOUD;
91 }
92
93 if (startsWith($query, 'do=' . self::$PAGE_TAGLIST)) {
94 return self::$PAGE_TAGLIST;
95 }
96
97 if (startsWith($query, 'do=' . self::$PAGE_OPENSEARCH)) {
98 return self::$PAGE_OPENSEARCH;
99 }
100
101 if (startsWith($query, 'do=' . self::$PAGE_DAILY)) {
102 return self::$PAGE_DAILY;
103 }
104
105 if (startsWith($query, 'do=' . self::$PAGE_FEED_ATOM)) {
106 return self::$PAGE_FEED_ATOM;
107 }
108
109 if (startsWith($query, 'do=' . self::$PAGE_FEED_RSS)) {
110 return self::$PAGE_FEED_RSS;
111 }
112
113 if (startsWith($query, 'do=' . self::$PAGE_THUMBS_UPDATE)) {
114 return self::$PAGE_THUMBS_UPDATE;
115 }
116
117 if (startsWith($query, 'do=' . self::$AJAX_THUMB_UPDATE)) {
118 return self::$AJAX_THUMB_UPDATE;
119 }
120
121 // At this point, only loggedin pages.
122 if (!$loggedIn) {
123 return self::$PAGE_LINKLIST;
124 }
125
126 if (startsWith($query, 'do=' . self::$PAGE_TOOLS)) {
127 return self::$PAGE_TOOLS;
128 }
129
130 if (startsWith($query, 'do=' . self::$PAGE_CHANGEPASSWORD)) {
131 return self::$PAGE_CHANGEPASSWORD;
132 }
133
134 if (startsWith($query, 'do=' . self::$PAGE_CONFIGURE)) {
135 return self::$PAGE_CONFIGURE;
136 }
137
138 if (startsWith($query, 'do=' . self::$PAGE_CHANGETAG)) {
139 return self::$PAGE_CHANGETAG;
140 }
141
142 if (startsWith($query, 'do=' . self::$PAGE_ADDLINK)) {
143 return self::$PAGE_ADDLINK;
144 }
145
146 if (isset($get['edit_link']) || isset($get['post'])) {
147 return self::$PAGE_EDITLINK;
148 }
149
150 if (isset($get['delete_link'])) {
151 return self::$PAGE_DELETELINK;
152 }
153
154 if (isset($get[self::$PAGE_CHANGE_VISIBILITY])) {
155 return self::$PAGE_CHANGE_VISIBILITY;
156 }
157
158 if (startsWith($query, 'do=' . self::$PAGE_PINLINK)) {
159 return self::$PAGE_PINLINK;
160 }
161
162 if (startsWith($query, 'do=' . self::$PAGE_EXPORT)) {
163 return self::$PAGE_EXPORT;
164 }
165
166 if (startsWith($query, 'do=' . self::$PAGE_IMPORT)) {
167 return self::$PAGE_IMPORT;
168 }
169
170 if (startsWith($query, 'do=' . self::$PAGE_PLUGINSADMIN)) {
171 return self::$PAGE_PLUGINSADMIN;
172 }
173
174 if (startsWith($query, 'do=' . self::$PAGE_SAVE_PLUGINSADMIN)) {
175 return self::$PAGE_SAVE_PLUGINSADMIN;
176 }
177
178 if (startsWith($query, 'do=' . self::$GET_TOKEN)) {
179 return self::$GET_TOKEN;
180 }
181
182 return self::$PAGE_LINKLIST;
183 }
184}
diff --git a/application/Thumbnailer.php b/application/Thumbnailer.php
index 314baf0d..5aec23c8 100644
--- a/application/Thumbnailer.php
+++ b/application/Thumbnailer.php
@@ -4,7 +4,6 @@ namespace Shaarli;
4 4
5use Shaarli\Config\ConfigManager; 5use Shaarli\Config\ConfigManager;
6use WebThumbnailer\Application\ConfigManager as WTConfigManager; 6use WebThumbnailer\Application\ConfigManager as WTConfigManager;
7use WebThumbnailer\Exception\WebThumbnailerException;
8use WebThumbnailer\WebThumbnailer; 7use WebThumbnailer\WebThumbnailer;
9 8
10/** 9/**
@@ -90,7 +89,7 @@ class Thumbnailer
90 89
91 try { 90 try {
92 return $this->wt->thumbnail($url); 91 return $this->wt->thumbnail($url);
93 } catch (WebThumbnailerException $e) { 92 } catch (\Throwable $e) {
94 // Exceptions are only thrown in debug mode. 93 // Exceptions are only thrown in debug mode.
95 error_log(get_class($e) . ': ' . $e->getMessage()); 94 error_log(get_class($e) . ': ' . $e->getMessage());
96 } 95 }
diff --git a/application/Utils.php b/application/Utils.php
index 4b7fc546..db046893 100644
--- a/application/Utils.php
+++ b/application/Utils.php
@@ -4,21 +4,23 @@
4 */ 4 */
5 5
6/** 6/**
7 * Logs a message to a text file 7 * Format log using provided data.
8 * 8 *
9 * The log format is compatible with fail2ban. 9 * @param string $message the message to log
10 * @param string|null $clientIp the client's remote IPv4/IPv6 address
10 * 11 *
11 * @param string $logFile where to write the logs 12 * @return string Formatted message to log
12 * @param string $clientIp the client's remote IPv4/IPv6 address
13 * @param string $message the message to log
14 */ 13 */
15function logm($logFile, $clientIp, $message) 14function format_log(string $message, string $clientIp = null): string
16{ 15{
17 file_put_contents( 16 $out = $message;
18 $logFile, 17
19 date('Y/m/d H:i:s').' - '.$clientIp.' - '.strval($message).PHP_EOL, 18 if (!empty($clientIp)) {
20 FILE_APPEND 19 // Note: we keep the first dash to avoid breaking fail2ban configs
21 ); 20 $out = '- ' . $clientIp . ' - ' . $out;
21 }
22
23 return $out;
22} 24}
23 25
24/** 26/**
@@ -87,18 +89,22 @@ function endsWith($haystack, $needle, $case = true)
87 * 89 *
88 * @param mixed $input Data to escape: a single string or an array of strings. 90 * @param mixed $input Data to escape: a single string or an array of strings.
89 * 91 *
90 * @return string escaped. 92 * @return string|array escaped.
91 */ 93 */
92function escape($input) 94function escape($input)
93{ 95{
94 if (is_bool($input)) { 96 if (null === $input) {
97 return null;
98 }
99
100 if (is_bool($input) || is_int($input) || is_float($input) || $input instanceof DateTimeInterface) {
95 return $input; 101 return $input;
96 } 102 }
97 103
98 if (is_array($input)) { 104 if (is_array($input)) {
99 $out = array(); 105 $out = array();
100 foreach ($input as $key => $value) { 106 foreach ($input as $key => $value) {
101 $out[$key] = escape($value); 107 $out[escape($key)] = escape($value);
102 } 108 }
103 return $out; 109 return $out;
104 } 110 }
@@ -294,15 +300,15 @@ function normalize_spaces($string)
294 * Requires php-intl to display international datetimes, 300 * Requires php-intl to display international datetimes,
295 * otherwise default format '%c' will be returned. 301 * otherwise default format '%c' will be returned.
296 * 302 *
297 * @param DateTime $date to format. 303 * @param DateTimeInterface $date to format.
298 * @param bool $time Displays time if true. 304 * @param bool $time Displays time if true.
299 * @param bool $intl Use international format if true. 305 * @param bool $intl Use international format if true.
300 * 306 *
301 * @return bool|string Formatted date, or false if the input is invalid. 307 * @return bool|string Formatted date, or false if the input is invalid.
302 */ 308 */
303function format_date($date, $time = true, $intl = true) 309function format_date($date, $time = true, $intl = true)
304{ 310{
305 if (! $date instanceof DateTime) { 311 if (! $date instanceof DateTimeInterface) {
306 return false; 312 return false;
307 } 313 }
308 314
@@ -321,6 +327,23 @@ function format_date($date, $time = true, $intl = true)
321} 327}
322 328
323/** 329/**
330 * Format the date month according to the locale.
331 *
332 * @param DateTimeInterface $date to format.
333 *
334 * @return bool|string Formatted date, or false if the input is invalid.
335 */
336function format_month(DateTimeInterface $date)
337{
338 if (! $date instanceof DateTimeInterface) {
339 return false;
340 }
341
342 return strftime('%B', $date->getTimestamp());
343}
344
345
346/**
324 * Check if the input is an integer, no matter its real type. 347 * Check if the input is an integer, no matter its real type.
325 * 348 *
326 * PHP is a bit messy regarding this: 349 * PHP is a bit messy regarding this:
@@ -448,14 +471,27 @@ function alphabetical_sort(&$data, $reverse = false, $byKeys = false)
448 * Wrapper function for translation which match the API 471 * Wrapper function for translation which match the API
449 * of gettext()/_() and ngettext(). 472 * of gettext()/_() and ngettext().
450 * 473 *
451 * @param string $text Text to translate. 474 * @param string $text Text to translate.
452 * @param string $nText The plural message ID. 475 * @param string $nText The plural message ID.
453 * @param int $nb The number of items for plural forms. 476 * @param int $nb The number of items for plural forms.
454 * @param string $domain The domain where the translation is stored (default: shaarli). 477 * @param string $domain The domain where the translation is stored (default: shaarli).
478 * @param array $variables Associative array of variables to replace in translated text.
479 * @param bool $fixCase Apply `ucfirst` on the translated string, might be useful for strings with variables.
455 * 480 *
456 * @return string Text translated. 481 * @return string Text translated.
457 */ 482 */
458function t($text, $nText = '', $nb = 1, $domain = 'shaarli') 483function t($text, $nText = '', $nb = 1, $domain = 'shaarli', $variables = [], $fixCase = false)
484{
485 $postFunction = $fixCase ? 'ucfirst' : function ($input) { return $input; };
486
487 return $postFunction(dn__($domain, $text, $nText, $nb, $variables));
488}
489
490/**
491 * Converts an exception into a printable stack trace string.
492 */
493function exception2text(Throwable $e): string
459{ 494{
460 return dn__($domain, $text, $nText, $nb); 495 return $e->getMessage() . PHP_EOL . $e->getFile() . $e->getLine() . PHP_EOL . $e->getTraceAsString();
461} 496}
497
diff --git a/application/api/ApiMiddleware.php b/application/api/ApiMiddleware.php
index 4745ac94..adc8b266 100644
--- a/application/api/ApiMiddleware.php
+++ b/application/api/ApiMiddleware.php
@@ -1,6 +1,7 @@
1<?php 1<?php
2namespace Shaarli\Api; 2namespace Shaarli\Api;
3 3
4use malkusch\lock\mutex\FlockMutex;
4use Shaarli\Api\Exceptions\ApiAuthorizationException; 5use Shaarli\Api\Exceptions\ApiAuthorizationException;
5use Shaarli\Api\Exceptions\ApiException; 6use Shaarli\Api\Exceptions\ApiException;
6use Shaarli\Bookmark\BookmarkFileService; 7use Shaarli\Bookmark\BookmarkFileService;
@@ -71,7 +72,14 @@ class ApiMiddleware
71 $response = $e->getApiResponse(); 72 $response = $e->getApiResponse();
72 } 73 }
73 74
74 return $response; 75 return $response
76 ->withHeader('Access-Control-Allow-Origin', '*')
77 ->withHeader(
78 'Access-Control-Allow-Headers',
79 'X-Requested-With, Content-Type, Accept, Origin, Authorization'
80 )
81 ->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
82 ;
75 } 83 }
76 84
77 /** 85 /**
@@ -100,7 +108,9 @@ class ApiMiddleware
100 */ 108 */
101 protected function checkToken($request) 109 protected function checkToken($request)
102 { 110 {
103 if (! $request->hasHeader('Authorization')) { 111 if (!$request->hasHeader('Authorization')
112 && !isset($this->container->environment['REDIRECT_HTTP_AUTHORIZATION'])
113 ) {
104 throw new ApiAuthorizationException('JWT token not provided'); 114 throw new ApiAuthorizationException('JWT token not provided');
105 } 115 }
106 116
@@ -108,7 +118,11 @@ class ApiMiddleware
108 throw new ApiAuthorizationException('Token secret must be set in Shaarli\'s administration'); 118 throw new ApiAuthorizationException('Token secret must be set in Shaarli\'s administration');
109 } 119 }
110 120
111 $authorization = $request->getHeaderLine('Authorization'); 121 if (isset($this->container->environment['REDIRECT_HTTP_AUTHORIZATION'])) {
122 $authorization = $this->container->environment['REDIRECT_HTTP_AUTHORIZATION'];
123 } else {
124 $authorization = $request->getHeaderLine('Authorization');
125 }
112 126
113 if (! preg_match('/^Bearer (.*)/i', $authorization, $matches)) { 127 if (! preg_match('/^Bearer (.*)/i', $authorization, $matches)) {
114 throw new ApiAuthorizationException('Invalid JWT header'); 128 throw new ApiAuthorizationException('Invalid JWT header');
@@ -130,6 +144,7 @@ class ApiMiddleware
130 $linkDb = new BookmarkFileService( 144 $linkDb = new BookmarkFileService(
131 $conf, 145 $conf,
132 $this->container->get('history'), 146 $this->container->get('history'),
147 new FlockMutex(fopen(SHAARLI_MUTEX_FILE, 'r'), 2),
133 true 148 true
134 ); 149 );
135 $this->container['db'] = $linkDb; 150 $this->container['db'] = $linkDb;
diff --git a/application/api/ApiUtils.php b/application/api/ApiUtils.php
index 5156a5f7..eb1ca9bc 100644
--- a/application/api/ApiUtils.php
+++ b/application/api/ApiUtils.php
@@ -67,7 +67,7 @@ class ApiUtils
67 if (! $bookmark->isNote()) { 67 if (! $bookmark->isNote()) {
68 $out['url'] = $bookmark->getUrl(); 68 $out['url'] = $bookmark->getUrl();
69 } else { 69 } else {
70 $out['url'] = $indexUrl . $bookmark->getUrl(); 70 $out['url'] = rtrim($indexUrl, '/') . '/' . ltrim($bookmark->getUrl(), '/');
71 } 71 }
72 $out['shorturl'] = $bookmark->getShortUrl(); 72 $out['shorturl'] = $bookmark->getShortUrl();
73 $out['title'] = $bookmark->getTitle(); 73 $out['title'] = $bookmark->getTitle();
@@ -89,12 +89,12 @@ class ApiUtils
89 * If no URL is provided, it will generate a local note URL. 89 * If no URL is provided, it will generate a local note URL.
90 * If no title is provided, it will use the URL as title. 90 * If no title is provided, it will use the URL as title.
91 * 91 *
92 * @param array $input Request Link. 92 * @param array|null $input Request Link.
93 * @param bool $defaultPrivate Request Link. 93 * @param bool $defaultPrivate Setting defined if a bookmark is private by default.
94 * 94 *
95 * @return Bookmark instance. 95 * @return Bookmark instance.
96 */ 96 */
97 public static function buildLinkFromRequest($input, $defaultPrivate) 97 public static function buildBookmarkFromRequest(?array $input, bool $defaultPrivate): Bookmark
98 { 98 {
99 $bookmark = new Bookmark(); 99 $bookmark = new Bookmark();
100 $url = ! empty($input['url']) ? cleanup_url($input['url']) : ''; 100 $url = ! empty($input['url']) ? cleanup_url($input['url']) : '';
@@ -110,6 +110,15 @@ class ApiUtils
110 $bookmark->setTags(! empty($input['tags']) ? $input['tags'] : []); 110 $bookmark->setTags(! empty($input['tags']) ? $input['tags'] : []);
111 $bookmark->setPrivate($private); 111 $bookmark->setPrivate($private);
112 112
113 $created = \DateTime::createFromFormat(\DateTime::ATOM, $input['created'] ?? '');
114 if ($created instanceof \DateTimeInterface) {
115 $bookmark->setCreated($created);
116 }
117 $updated = \DateTime::createFromFormat(\DateTime::ATOM, $input['updated'] ?? '');
118 if ($updated instanceof \DateTimeInterface) {
119 $bookmark->setUpdated($updated);
120 }
121
113 return $bookmark; 122 return $bookmark;
114 } 123 }
115 124
diff --git a/application/api/controllers/ApiController.php b/application/api/controllers/ApiController.php
index c4b3d0c3..88a845eb 100644
--- a/application/api/controllers/ApiController.php
+++ b/application/api/controllers/ApiController.php
@@ -4,6 +4,7 @@ namespace Shaarli\Api\Controllers;
4 4
5use Shaarli\Bookmark\BookmarkServiceInterface; 5use Shaarli\Bookmark\BookmarkServiceInterface;
6use Shaarli\Config\ConfigManager; 6use Shaarli\Config\ConfigManager;
7use Shaarli\History;
7use Slim\Container; 8use Slim\Container;
8 9
9/** 10/**
@@ -31,7 +32,7 @@ abstract class ApiController
31 protected $bookmarkService; 32 protected $bookmarkService;
32 33
33 /** 34 /**
34 * @var HistoryController 35 * @var History
35 */ 36 */
36 protected $history; 37 protected $history;
37 38
diff --git a/application/api/controllers/Links.php b/application/api/controllers/Links.php
index 16fc8688..6bf529e4 100644
--- a/application/api/controllers/Links.php
+++ b/application/api/controllers/Links.php
@@ -96,11 +96,12 @@ class Links extends ApiController
96 */ 96 */
97 public function getLink($request, $response, $args) 97 public function getLink($request, $response, $args)
98 { 98 {
99 if (!$this->bookmarkService->exists($args['id'])) { 99 $id = is_integer_mixed($args['id']) ? (int) $args['id'] : null;
100 if ($id === null || ! $this->bookmarkService->exists($id)) {
100 throw new ApiLinkNotFoundException(); 101 throw new ApiLinkNotFoundException();
101 } 102 }
102 $index = index_url($this->ci['environment']); 103 $index = index_url($this->ci['environment']);
103 $out = ApiUtils::formatLink($this->bookmarkService->get($args['id']), $index); 104 $out = ApiUtils::formatLink($this->bookmarkService->get($id), $index);
104 105
105 return $response->withJson($out, 200, $this->jsonStyle); 106 return $response->withJson($out, 200, $this->jsonStyle);
106 } 107 }
@@ -115,8 +116,8 @@ class Links extends ApiController
115 */ 116 */
116 public function postLink($request, $response) 117 public function postLink($request, $response)
117 { 118 {
118 $data = $request->getParsedBody(); 119 $data = (array) ($request->getParsedBody() ?? []);
119 $bookmark = ApiUtils::buildLinkFromRequest($data, $this->conf->get('privacy.default_private_links')); 120 $bookmark = ApiUtils::buildBookmarkFromRequest($data, $this->conf->get('privacy.default_private_links'));
120 // duplicate by URL, return 409 Conflict 121 // duplicate by URL, return 409 Conflict
121 if (! empty($bookmark->getUrl()) 122 if (! empty($bookmark->getUrl())
122 && ! empty($dup = $this->bookmarkService->findByUrl($bookmark->getUrl())) 123 && ! empty($dup = $this->bookmarkService->findByUrl($bookmark->getUrl()))
@@ -148,18 +149,19 @@ class Links extends ApiController
148 */ 149 */
149 public function putLink($request, $response, $args) 150 public function putLink($request, $response, $args)
150 { 151 {
151 if (! $this->bookmarkService->exists($args['id'])) { 152 $id = is_integer_mixed($args['id']) ? (int) $args['id'] : null;
153 if ($id === null || !$this->bookmarkService->exists($id)) {
152 throw new ApiLinkNotFoundException(); 154 throw new ApiLinkNotFoundException();
153 } 155 }
154 156
155 $index = index_url($this->ci['environment']); 157 $index = index_url($this->ci['environment']);
156 $data = $request->getParsedBody(); 158 $data = $request->getParsedBody();
157 159
158 $requestBookmark = ApiUtils::buildLinkFromRequest($data, $this->conf->get('privacy.default_private_links')); 160 $requestBookmark = ApiUtils::buildBookmarkFromRequest($data, $this->conf->get('privacy.default_private_links'));
159 // duplicate URL on a different link, return 409 Conflict 161 // duplicate URL on a different link, return 409 Conflict
160 if (! empty($requestBookmark->getUrl()) 162 if (! empty($requestBookmark->getUrl())
161 && ! empty($dup = $this->bookmarkService->findByUrl($requestBookmark->getUrl())) 163 && ! empty($dup = $this->bookmarkService->findByUrl($requestBookmark->getUrl()))
162 && $dup->getId() != $args['id'] 164 && $dup->getId() != $id
163 ) { 165 ) {
164 return $response->withJson( 166 return $response->withJson(
165 ApiUtils::formatLink($dup, $index), 167 ApiUtils::formatLink($dup, $index),
@@ -168,7 +170,7 @@ class Links extends ApiController
168 ); 170 );
169 } 171 }
170 172
171 $responseBookmark = $this->bookmarkService->get($args['id']); 173 $responseBookmark = $this->bookmarkService->get($id);
172 $responseBookmark = ApiUtils::updateLink($responseBookmark, $requestBookmark); 174 $responseBookmark = ApiUtils::updateLink($responseBookmark, $requestBookmark);
173 $this->bookmarkService->set($responseBookmark); 175 $this->bookmarkService->set($responseBookmark);
174 176
@@ -189,10 +191,11 @@ class Links extends ApiController
189 */ 191 */
190 public function deleteLink($request, $response, $args) 192 public function deleteLink($request, $response, $args)
191 { 193 {
192 if (! $this->bookmarkService->exists($args['id'])) { 194 $id = is_integer_mixed($args['id']) ? (int) $args['id'] : null;
195 if ($id === null || !$this->bookmarkService->exists($id)) {
193 throw new ApiLinkNotFoundException(); 196 throw new ApiLinkNotFoundException();
194 } 197 }
195 $bookmark = $this->bookmarkService->get($args['id']); 198 $bookmark = $this->bookmarkService->get($id);
196 $this->bookmarkService->remove($bookmark); 199 $this->bookmarkService->remove($bookmark);
197 200
198 return $response->withStatus(204); 201 return $response->withStatus(204);
diff --git a/application/bookmark/Bookmark.php b/application/bookmark/Bookmark.php
index f9b21d3d..4810c5e6 100644
--- a/application/bookmark/Bookmark.php
+++ b/application/bookmark/Bookmark.php
@@ -1,8 +1,11 @@
1<?php 1<?php
2 2
3declare(strict_types=1);
4
3namespace Shaarli\Bookmark; 5namespace Shaarli\Bookmark;
4 6
5use DateTime; 7use DateTime;
8use DateTimeInterface;
6use Shaarli\Bookmark\Exception\InvalidBookmarkException; 9use Shaarli\Bookmark\Exception\InvalidBookmarkException;
7 10
8/** 11/**
@@ -36,21 +39,24 @@ class Bookmark
36 /** @var array List of bookmark's tags */ 39 /** @var array List of bookmark's tags */
37 protected $tags; 40 protected $tags;
38 41
39 /** @var string Thumbnail's URL - false if no thumbnail could be found */ 42 /** @var string|bool|null Thumbnail's URL - initialized at null, false if no thumbnail could be found */
40 protected $thumbnail; 43 protected $thumbnail;
41 44
42 /** @var bool Set to true if the bookmark is set as sticky */ 45 /** @var bool Set to true if the bookmark is set as sticky */
43 protected $sticky; 46 protected $sticky;
44 47
45 /** @var DateTime Creation datetime */ 48 /** @var DateTimeInterface Creation datetime */
46 protected $created; 49 protected $created;
47 50
48 /** @var DateTime Update datetime */ 51 /** @var DateTimeInterface datetime */
49 protected $updated; 52 protected $updated;
50 53
51 /** @var bool True if the bookmark can only be seen while logged in */ 54 /** @var bool True if the bookmark can only be seen while logged in */
52 protected $private; 55 protected $private;
53 56
57 /** @var mixed[] Available to store any additional content for a bookmark. Currently used for search highlight. */
58 protected $additionalContent = [];
59
54 /** 60 /**
55 * Initialize a link from array data. Especially useful to create a Bookmark from former link storage format. 61 * Initialize a link from array data. Especially useful to create a Bookmark from former link storage format.
56 * 62 *
@@ -58,25 +64,25 @@ class Bookmark
58 * 64 *
59 * @return $this 65 * @return $this
60 */ 66 */
61 public function fromArray($data) 67 public function fromArray(array $data): Bookmark
62 { 68 {
63 $this->id = $data['id']; 69 $this->id = $data['id'] ?? null;
64 $this->shortUrl = $data['shorturl']; 70 $this->shortUrl = $data['shorturl'] ?? null;
65 $this->url = $data['url']; 71 $this->url = $data['url'] ?? null;
66 $this->title = $data['title']; 72 $this->title = $data['title'] ?? null;
67 $this->description = $data['description']; 73 $this->description = $data['description'] ?? null;
68 $this->thumbnail = isset($data['thumbnail']) ? $data['thumbnail'] : null; 74 $this->thumbnail = $data['thumbnail'] ?? null;
69 $this->sticky = isset($data['sticky']) ? $data['sticky'] : false; 75 $this->sticky = $data['sticky'] ?? false;
70 $this->created = $data['created']; 76 $this->created = $data['created'] ?? null;
71 if (is_array($data['tags'])) { 77 if (is_array($data['tags'])) {
72 $this->tags = $data['tags']; 78 $this->tags = $data['tags'];
73 } else { 79 } else {
74 $this->tags = preg_split('/\s+/', $data['tags'], -1, PREG_SPLIT_NO_EMPTY); 80 $this->tags = preg_split('/\s+/', $data['tags'] ?? '', -1, PREG_SPLIT_NO_EMPTY);
75 } 81 }
76 if (! empty($data['updated'])) { 82 if (! empty($data['updated'])) {
77 $this->updated = $data['updated']; 83 $this->updated = $data['updated'];
78 } 84 }
79 $this->private = $data['private'] ? true : false; 85 $this->private = ($data['private'] ?? false) ? true : false;
80 86
81 return $this; 87 return $this;
82 } 88 }
@@ -92,24 +98,28 @@ class Bookmark
92 * - the URL with the permalink 98 * - the URL with the permalink
93 * - the title with the URL 99 * - the title with the URL
94 * 100 *
101 * Also make sure that we do not save search highlights in the datastore.
102 *
95 * @throws InvalidBookmarkException 103 * @throws InvalidBookmarkException
96 */ 104 */
97 public function validate() 105 public function validate(): void
98 { 106 {
99 if ($this->id === null 107 if ($this->id === null
100 || ! is_int($this->id) 108 || ! is_int($this->id)
101 || empty($this->shortUrl) 109 || empty($this->shortUrl)
102 || empty($this->created) 110 || empty($this->created)
103 || ! $this->created instanceof DateTime
104 ) { 111 ) {
105 throw new InvalidBookmarkException($this); 112 throw new InvalidBookmarkException($this);
106 } 113 }
107 if (empty($this->url)) { 114 if (empty($this->url)) {
108 $this->url = '?'. $this->shortUrl; 115 $this->url = '/shaare/'. $this->shortUrl;
109 } 116 }
110 if (empty($this->title)) { 117 if (empty($this->title)) {
111 $this->title = $this->url; 118 $this->title = $this->url;
112 } 119 }
120 if (array_key_exists('search_highlight', $this->additionalContent)) {
121 unset($this->additionalContent['search_highlight']);
122 }
113 } 123 }
114 124
115 /** 125 /**
@@ -118,11 +128,11 @@ class Bookmark
118 * - created: with the current datetime 128 * - created: with the current datetime
119 * - shortUrl: with a generated small hash from the date and the given ID 129 * - shortUrl: with a generated small hash from the date and the given ID
120 * 130 *
121 * @param int $id 131 * @param int|null $id
122 * 132 *
123 * @return Bookmark 133 * @return Bookmark
124 */ 134 */
125 public function setId($id) 135 public function setId(?int $id): Bookmark
126 { 136 {
127 $this->id = $id; 137 $this->id = $id;
128 if (empty($this->created)) { 138 if (empty($this->created)) {
@@ -138,9 +148,9 @@ class Bookmark
138 /** 148 /**
139 * Get the Id. 149 * Get the Id.
140 * 150 *
141 * @return int 151 * @return int|null
142 */ 152 */
143 public function getId() 153 public function getId(): ?int
144 { 154 {
145 return $this->id; 155 return $this->id;
146 } 156 }
@@ -148,9 +158,9 @@ class Bookmark
148 /** 158 /**
149 * Get the ShortUrl. 159 * Get the ShortUrl.
150 * 160 *
151 * @return string 161 * @return string|null
152 */ 162 */
153 public function getShortUrl() 163 public function getShortUrl(): ?string
154 { 164 {
155 return $this->shortUrl; 165 return $this->shortUrl;
156 } 166 }
@@ -158,9 +168,9 @@ class Bookmark
158 /** 168 /**
159 * Get the Url. 169 * Get the Url.
160 * 170 *
161 * @return string 171 * @return string|null
162 */ 172 */
163 public function getUrl() 173 public function getUrl(): ?string
164 { 174 {
165 return $this->url; 175 return $this->url;
166 } 176 }
@@ -170,7 +180,7 @@ class Bookmark
170 * 180 *
171 * @return string 181 * @return string
172 */ 182 */
173 public function getTitle() 183 public function getTitle(): ?string
174 { 184 {
175 return $this->title; 185 return $this->title;
176 } 186 }
@@ -180,7 +190,7 @@ class Bookmark
180 * 190 *
181 * @return string 191 * @return string
182 */ 192 */
183 public function getDescription() 193 public function getDescription(): string
184 { 194 {
185 return ! empty($this->description) ? $this->description : ''; 195 return ! empty($this->description) ? $this->description : '';
186 } 196 }
@@ -188,9 +198,9 @@ class Bookmark
188 /** 198 /**
189 * Get the Created. 199 * Get the Created.
190 * 200 *
191 * @return DateTime 201 * @return DateTimeInterface
192 */ 202 */
193 public function getCreated() 203 public function getCreated(): ?DateTimeInterface
194 { 204 {
195 return $this->created; 205 return $this->created;
196 } 206 }
@@ -198,9 +208,9 @@ class Bookmark
198 /** 208 /**
199 * Get the Updated. 209 * Get the Updated.
200 * 210 *
201 * @return DateTime 211 * @return DateTimeInterface
202 */ 212 */
203 public function getUpdated() 213 public function getUpdated(): ?DateTimeInterface
204 { 214 {
205 return $this->updated; 215 return $this->updated;
206 } 216 }
@@ -208,11 +218,11 @@ class Bookmark
208 /** 218 /**
209 * Set the ShortUrl. 219 * Set the ShortUrl.
210 * 220 *
211 * @param string $shortUrl 221 * @param string|null $shortUrl
212 * 222 *
213 * @return Bookmark 223 * @return Bookmark
214 */ 224 */
215 public function setShortUrl($shortUrl) 225 public function setShortUrl(?string $shortUrl): Bookmark
216 { 226 {
217 $this->shortUrl = $shortUrl; 227 $this->shortUrl = $shortUrl;
218 228
@@ -222,14 +232,14 @@ class Bookmark
222 /** 232 /**
223 * Set the Url. 233 * Set the Url.
224 * 234 *
225 * @param string $url 235 * @param string|null $url
226 * @param array $allowedProtocols 236 * @param string[] $allowedProtocols
227 * 237 *
228 * @return Bookmark 238 * @return Bookmark
229 */ 239 */
230 public function setUrl($url, $allowedProtocols = []) 240 public function setUrl(?string $url, array $allowedProtocols = []): Bookmark
231 { 241 {
232 $url = trim($url); 242 $url = $url !== null ? trim($url) : '';
233 if (! empty($url)) { 243 if (! empty($url)) {
234 $url = whitelist_protocols($url, $allowedProtocols); 244 $url = whitelist_protocols($url, $allowedProtocols);
235 } 245 }
@@ -241,13 +251,13 @@ class Bookmark
241 /** 251 /**
242 * Set the Title. 252 * Set the Title.
243 * 253 *
244 * @param string $title 254 * @param string|null $title
245 * 255 *
246 * @return Bookmark 256 * @return Bookmark
247 */ 257 */
248 public function setTitle($title) 258 public function setTitle(?string $title): Bookmark
249 { 259 {
250 $this->title = trim($title); 260 $this->title = $title !== null ? trim($title) : '';
251 261
252 return $this; 262 return $this;
253 } 263 }
@@ -255,11 +265,11 @@ class Bookmark
255 /** 265 /**
256 * Set the Description. 266 * Set the Description.
257 * 267 *
258 * @param string $description 268 * @param string|null $description
259 * 269 *
260 * @return Bookmark 270 * @return Bookmark
261 */ 271 */
262 public function setDescription($description) 272 public function setDescription(?string $description): Bookmark
263 { 273 {
264 $this->description = $description; 274 $this->description = $description;
265 275
@@ -270,11 +280,11 @@ class Bookmark
270 * Set the Created. 280 * Set the Created.
271 * Note: you shouldn't set this manually except for special cases (like bookmark import) 281 * Note: you shouldn't set this manually except for special cases (like bookmark import)
272 * 282 *
273 * @param DateTime $created 283 * @param DateTimeInterface|null $created
274 * 284 *
275 * @return Bookmark 285 * @return Bookmark
276 */ 286 */
277 public function setCreated($created) 287 public function setCreated(?DateTimeInterface $created): Bookmark
278 { 288 {
279 $this->created = $created; 289 $this->created = $created;
280 290
@@ -284,11 +294,11 @@ class Bookmark
284 /** 294 /**
285 * Set the Updated. 295 * Set the Updated.
286 * 296 *
287 * @param DateTime $updated 297 * @param DateTimeInterface|null $updated
288 * 298 *
289 * @return Bookmark 299 * @return Bookmark
290 */ 300 */
291 public function setUpdated($updated) 301 public function setUpdated(?DateTimeInterface $updated): Bookmark
292 { 302 {
293 $this->updated = $updated; 303 $this->updated = $updated;
294 304
@@ -300,7 +310,7 @@ class Bookmark
300 * 310 *
301 * @return bool 311 * @return bool
302 */ 312 */
303 public function isPrivate() 313 public function isPrivate(): bool
304 { 314 {
305 return $this->private ? true : false; 315 return $this->private ? true : false;
306 } 316 }
@@ -308,11 +318,11 @@ class Bookmark
308 /** 318 /**
309 * Set the Private. 319 * Set the Private.
310 * 320 *
311 * @param bool $private 321 * @param bool|null $private
312 * 322 *
313 * @return Bookmark 323 * @return Bookmark
314 */ 324 */
315 public function setPrivate($private) 325 public function setPrivate(?bool $private): Bookmark
316 { 326 {
317 $this->private = $private ? true : false; 327 $this->private = $private ? true : false;
318 328
@@ -322,9 +332,9 @@ class Bookmark
322 /** 332 /**
323 * Get the Tags. 333 * Get the Tags.
324 * 334 *
325 * @return array 335 * @return string[]
326 */ 336 */
327 public function getTags() 337 public function getTags(): array
328 { 338 {
329 return is_array($this->tags) ? $this->tags : []; 339 return is_array($this->tags) ? $this->tags : [];
330 } 340 }
@@ -332,13 +342,13 @@ class Bookmark
332 /** 342 /**
333 * Set the Tags. 343 * Set the Tags.
334 * 344 *
335 * @param array $tags 345 * @param string[]|null $tags
336 * 346 *
337 * @return Bookmark 347 * @return Bookmark
338 */ 348 */
339 public function setTags($tags) 349 public function setTags(?array $tags): Bookmark
340 { 350 {
341 $this->setTagsString(implode(' ', $tags)); 351 $this->setTagsString(implode(' ', $tags ?? []));
342 352
343 return $this; 353 return $this;
344 } 354 }
@@ -346,7 +356,7 @@ class Bookmark
346 /** 356 /**
347 * Get the Thumbnail. 357 * Get the Thumbnail.
348 * 358 *
349 * @return string|bool 359 * @return string|bool|null Thumbnail's URL - initialized at null, false if no thumbnail could be found
350 */ 360 */
351 public function getThumbnail() 361 public function getThumbnail()
352 { 362 {
@@ -356,11 +366,11 @@ class Bookmark
356 /** 366 /**
357 * Set the Thumbnail. 367 * Set the Thumbnail.
358 * 368 *
359 * @param string|bool $thumbnail 369 * @param string|bool|null $thumbnail Thumbnail's URL - false if no thumbnail could be found
360 * 370 *
361 * @return Bookmark 371 * @return Bookmark
362 */ 372 */
363 public function setThumbnail($thumbnail) 373 public function setThumbnail($thumbnail): Bookmark
364 { 374 {
365 $this->thumbnail = $thumbnail; 375 $this->thumbnail = $thumbnail;
366 376
@@ -368,11 +378,29 @@ class Bookmark
368 } 378 }
369 379
370 /** 380 /**
381 * Return true if:
382 * - the bookmark's thumbnail is not already set to false (= not found)
383 * - it's not a note
384 * - it's an HTTP(S) link
385 * - the thumbnail has not yet be retrieved (null) or its associated cache file doesn't exist anymore
386 *
387 * @return bool True if the bookmark's thumbnail needs to be retrieved.
388 */
389 public function shouldUpdateThumbnail(): bool
390 {
391 return $this->thumbnail !== false
392 && !$this->isNote()
393 && startsWith(strtolower($this->url), 'http')
394 && (null === $this->thumbnail || !is_file($this->thumbnail))
395 ;
396 }
397
398 /**
371 * Get the Sticky. 399 * Get the Sticky.
372 * 400 *
373 * @return bool 401 * @return bool
374 */ 402 */
375 public function isSticky() 403 public function isSticky(): bool
376 { 404 {
377 return $this->sticky ? true : false; 405 return $this->sticky ? true : false;
378 } 406 }
@@ -380,11 +408,11 @@ class Bookmark
380 /** 408 /**
381 * Set the Sticky. 409 * Set the Sticky.
382 * 410 *
383 * @param bool $sticky 411 * @param bool|null $sticky
384 * 412 *
385 * @return Bookmark 413 * @return Bookmark
386 */ 414 */
387 public function setSticky($sticky) 415 public function setSticky(?bool $sticky): Bookmark
388 { 416 {
389 $this->sticky = $sticky ? true : false; 417 $this->sticky = $sticky ? true : false;
390 418
@@ -394,7 +422,7 @@ class Bookmark
394 /** 422 /**
395 * @return string Bookmark's tags as a string, separated by a space 423 * @return string Bookmark's tags as a string, separated by a space
396 */ 424 */
397 public function getTagsString() 425 public function getTagsString(): string
398 { 426 {
399 return implode(' ', $this->getTags()); 427 return implode(' ', $this->getTags());
400 } 428 }
@@ -402,10 +430,10 @@ class Bookmark
402 /** 430 /**
403 * @return bool 431 * @return bool
404 */ 432 */
405 public function isNote() 433 public function isNote(): bool
406 { 434 {
407 // We check empty value to get a valid result if the link has not been saved yet 435 // We check empty value to get a valid result if the link has not been saved yet
408 return empty($this->url) || $this->url[0] === '?'; 436 return empty($this->url) || startsWith($this->url, '/shaare/') || $this->url[0] === '?';
409 } 437 }
410 438
411 /** 439 /**
@@ -415,14 +443,14 @@ class Bookmark
415 * - multiple spaces will be removed 443 * - multiple spaces will be removed
416 * - trailing dash in tags will be removed 444 * - trailing dash in tags will be removed
417 * 445 *
418 * @param string $tags 446 * @param string|null $tags
419 * 447 *
420 * @return $this 448 * @return $this
421 */ 449 */
422 public function setTagsString($tags) 450 public function setTagsString(?string $tags): Bookmark
423 { 451 {
424 // Remove first '-' char in tags. 452 // Remove first '-' char in tags.
425 $tags = preg_replace('/(^| )\-/', '$1', $tags); 453 $tags = preg_replace('/(^| )\-/', '$1', $tags ?? '');
426 // Explode all tags separted by spaces or commas 454 // Explode all tags separted by spaces or commas
427 $tags = preg_split('/[\s,]+/', $tags); 455 $tags = preg_split('/[\s,]+/', $tags);
428 // Remove eventual empty values 456 // Remove eventual empty values
@@ -434,12 +462,50 @@ class Bookmark
434 } 462 }
435 463
436 /** 464 /**
465 * Get entire additionalContent array.
466 *
467 * @return mixed[]
468 */
469 public function getAdditionalContent(): array
470 {
471 return $this->additionalContent;
472 }
473
474 /**
475 * Set a single entry in additionalContent, by key.
476 *
477 * @param string $key
478 * @param mixed|null $value Any type of value can be set.
479 *
480 * @return $this
481 */
482 public function addAdditionalContentEntry(string $key, $value): self
483 {
484 $this->additionalContent[$key] = $value;
485
486 return $this;
487 }
488
489 /**
490 * Get a single entry in additionalContent, by key.
491 *
492 * @param string $key
493 * @param mixed|null $default
494 *
495 * @return mixed|null can be any type or even null.
496 */
497 public function getAdditionalContentEntry(string $key, $default = null)
498 {
499 return array_key_exists($key, $this->additionalContent) ? $this->additionalContent[$key] : $default;
500 }
501
502 /**
437 * Rename a tag in tags list. 503 * Rename a tag in tags list.
438 * 504 *
439 * @param string $fromTag 505 * @param string $fromTag
440 * @param string $toTag 506 * @param string $toTag
441 */ 507 */
442 public function renameTag($fromTag, $toTag) 508 public function renameTag(string $fromTag, string $toTag): void
443 { 509 {
444 if (($pos = array_search($fromTag, $this->tags)) !== false) { 510 if (($pos = array_search($fromTag, $this->tags)) !== false) {
445 $this->tags[$pos] = trim($toTag); 511 $this->tags[$pos] = trim($toTag);
@@ -451,7 +517,7 @@ class Bookmark
451 * 517 *
452 * @param string $tag 518 * @param string $tag
453 */ 519 */
454 public function deleteTag($tag) 520 public function deleteTag(string $tag): void
455 { 521 {
456 if (($pos = array_search($tag, $this->tags)) !== false) { 522 if (($pos = array_search($tag, $this->tags)) !== false) {
457 unset($this->tags[$pos]); 523 unset($this->tags[$pos]);
diff --git a/application/bookmark/BookmarkArray.php b/application/bookmark/BookmarkArray.php
index d87d43b4..67bb3b73 100644
--- a/application/bookmark/BookmarkArray.php
+++ b/application/bookmark/BookmarkArray.php
@@ -1,5 +1,7 @@
1<?php 1<?php
2 2
3declare(strict_types=1);
4
3namespace Shaarli\Bookmark; 5namespace Shaarli\Bookmark;
4 6
5use Shaarli\Bookmark\Exception\InvalidBookmarkException; 7use Shaarli\Bookmark\Exception\InvalidBookmarkException;
@@ -187,13 +189,13 @@ class BookmarkArray implements \Iterator, \Countable, \ArrayAccess
187 /** 189 /**
188 * Returns a bookmark offset in bookmarks array from its unique ID. 190 * Returns a bookmark offset in bookmarks array from its unique ID.
189 * 191 *
190 * @param int $id Persistent ID of a bookmark. 192 * @param int|null $id Persistent ID of a bookmark.
191 * 193 *
192 * @return int Real offset in local array, or null if doesn't exist. 194 * @return int Real offset in local array, or null if doesn't exist.
193 */ 195 */
194 protected function getBookmarkOffset($id) 196 protected function getBookmarkOffset(?int $id): ?int
195 { 197 {
196 if (isset($this->ids[$id])) { 198 if ($id !== null && isset($this->ids[$id])) {
197 return $this->ids[$id]; 199 return $this->ids[$id];
198 } 200 }
199 return null; 201 return null;
@@ -205,7 +207,7 @@ class BookmarkArray implements \Iterator, \Countable, \ArrayAccess
205 * 207 *
206 * @return int next ID. 208 * @return int next ID.
207 */ 209 */
208 public function getNextId() 210 public function getNextId(): int
209 { 211 {
210 if (!empty($this->ids)) { 212 if (!empty($this->ids)) {
211 return max(array_keys($this->ids)) + 1; 213 return max(array_keys($this->ids)) + 1;
@@ -214,11 +216,11 @@ class BookmarkArray implements \Iterator, \Countable, \ArrayAccess
214 } 216 }
215 217
216 /** 218 /**
217 * @param $url 219 * @param string $url
218 * 220 *
219 * @return Bookmark|null 221 * @return Bookmark|null
220 */ 222 */
221 public function getByUrl($url) 223 public function getByUrl(string $url): ?Bookmark
222 { 224 {
223 if (! empty($url) 225 if (! empty($url)
224 && isset($this->urls[$url]) 226 && isset($this->urls[$url])
@@ -234,16 +236,17 @@ class BookmarkArray implements \Iterator, \Countable, \ArrayAccess
234 * 236 *
235 * Also update the urls and ids mapping arrays. 237 * Also update the urls and ids mapping arrays.
236 * 238 *
237 * @param string $order ASC|DESC 239 * @param string $order ASC|DESC
240 * @param bool $ignoreSticky If set to true, sticky bookmarks won't be first
238 */ 241 */
239 public function reorder($order = 'DESC') 242 public function reorder(string $order = 'DESC', bool $ignoreSticky = false): void
240 { 243 {
241 $order = $order === 'ASC' ? -1 : 1; 244 $order = $order === 'ASC' ? -1 : 1;
242 // Reorder array by dates. 245 // Reorder array by dates.
243 usort($this->bookmarks, function ($a, $b) use ($order) { 246 usort($this->bookmarks, function ($a, $b) use ($order, $ignoreSticky) {
244 /** @var $a Bookmark */ 247 /** @var $a Bookmark */
245 /** @var $b Bookmark */ 248 /** @var $b Bookmark */
246 if ($a->isSticky() !== $b->isSticky()) { 249 if (false === $ignoreSticky && $a->isSticky() !== $b->isSticky()) {
247 return $a->isSticky() ? -1 : 1; 250 return $a->isSticky() ? -1 : 1;
248 } 251 }
249 return $a->getCreated() < $b->getCreated() ? 1 * $order : -1 * $order; 252 return $a->getCreated() < $b->getCreated() ? 1 * $order : -1 * $order;
diff --git a/application/bookmark/BookmarkFileService.php b/application/bookmark/BookmarkFileService.php
index 9c59e139..3ea98a45 100644
--- a/application/bookmark/BookmarkFileService.php
+++ b/application/bookmark/BookmarkFileService.php
@@ -1,17 +1,21 @@
1<?php 1<?php
2 2
3declare(strict_types=1);
3 4
4namespace Shaarli\Bookmark; 5namespace Shaarli\Bookmark;
5 6
6 7use DateTime;
7use Exception; 8use Exception;
9use malkusch\lock\mutex\Mutex;
8use Shaarli\Bookmark\Exception\BookmarkNotFoundException; 10use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
11use Shaarli\Bookmark\Exception\DatastoreNotInitializedException;
9use Shaarli\Bookmark\Exception\EmptyDataStoreException; 12use Shaarli\Bookmark\Exception\EmptyDataStoreException;
10use Shaarli\Config\ConfigManager; 13use Shaarli\Config\ConfigManager;
11use Shaarli\Formatter\BookmarkMarkdownFormatter; 14use Shaarli\Formatter\BookmarkMarkdownFormatter;
12use Shaarli\History; 15use Shaarli\History;
13use Shaarli\Legacy\LegacyLinkDB; 16use Shaarli\Legacy\LegacyLinkDB;
14use Shaarli\Legacy\LegacyUpdater; 17use Shaarli\Legacy\LegacyUpdater;
18use Shaarli\Render\PageCacheManager;
15use Shaarli\Updater\UpdaterUtils; 19use Shaarli\Updater\UpdaterUtils;
16 20
17/** 21/**
@@ -39,17 +43,25 @@ class BookmarkFileService implements BookmarkServiceInterface
39 /** @var History instance */ 43 /** @var History instance */
40 protected $history; 44 protected $history;
41 45
46 /** @var PageCacheManager instance */
47 protected $pageCacheManager;
48
42 /** @var bool true for logged in users. Default value to retrieve private bookmarks. */ 49 /** @var bool true for logged in users. Default value to retrieve private bookmarks. */
43 protected $isLoggedIn; 50 protected $isLoggedIn;
44 51
52 /** @var Mutex */
53 protected $mutex;
54
45 /** 55 /**
46 * @inheritDoc 56 * @inheritDoc
47 */ 57 */
48 public function __construct(ConfigManager $conf, History $history, $isLoggedIn) 58 public function __construct(ConfigManager $conf, History $history, Mutex $mutex, bool $isLoggedIn)
49 { 59 {
50 $this->conf = $conf; 60 $this->conf = $conf;
51 $this->history = $history; 61 $this->history = $history;
52 $this->bookmarksIO = new BookmarkIO($this->conf); 62 $this->mutex = $mutex;
63 $this->pageCacheManager = new PageCacheManager($this->conf->get('resource.page_cache'), $isLoggedIn);
64 $this->bookmarksIO = new BookmarkIO($this->conf, $this->mutex);
53 $this->isLoggedIn = $isLoggedIn; 65 $this->isLoggedIn = $isLoggedIn;
54 66
55 if (!$this->isLoggedIn && $this->conf->get('privacy.hide_public_links', false)) { 67 if (!$this->isLoggedIn && $this->conf->get('privacy.hide_public_links', false)) {
@@ -57,10 +69,16 @@ class BookmarkFileService implements BookmarkServiceInterface
57 } else { 69 } else {
58 try { 70 try {
59 $this->bookmarks = $this->bookmarksIO->read(); 71 $this->bookmarks = $this->bookmarksIO->read();
60 } catch (EmptyDataStoreException $e) { 72 } catch (EmptyDataStoreException|DatastoreNotInitializedException $e) {
61 $this->bookmarks = new BookmarkArray(); 73 $this->bookmarks = new BookmarkArray();
62 if ($isLoggedIn) { 74
63 $this->save(); 75 if ($this->isLoggedIn) {
76 // Datastore file does not exists, we initialize it with default bookmarks.
77 if ($e instanceof DatastoreNotInitializedException) {
78 $this->initialize();
79 } else {
80 $this->save();
81 }
64 } 82 }
65 } 83 }
66 84
@@ -79,22 +97,25 @@ class BookmarkFileService implements BookmarkServiceInterface
79 /** 97 /**
80 * @inheritDoc 98 * @inheritDoc
81 */ 99 */
82 public function findByHash($hash) 100 public function findByHash(string $hash, string $privateKey = null): Bookmark
83 { 101 {
84 $bookmark = $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_HASH, $hash); 102 $bookmark = $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_HASH, $hash);
85 // PHP 7.3 introduced array_key_first() to avoid this hack 103 // PHP 7.3 introduced array_key_first() to avoid this hack
86 $first = reset($bookmark); 104 $first = reset($bookmark);
87 if (! $this->isLoggedIn && $first->isPrivate()) { 105 if (!$this->isLoggedIn
88 throw new Exception('Not authorized'); 106 && $first->isPrivate()
107 && (empty($privateKey) || $privateKey !== $first->getAdditionalContentEntry('private_key'))
108 ) {
109 throw new BookmarkNotFoundException();
89 } 110 }
90 111
91 return $bookmark; 112 return $first;
92 } 113 }
93 114
94 /** 115 /**
95 * @inheritDoc 116 * @inheritDoc
96 */ 117 */
97 public function findByUrl($url) 118 public function findByUrl(string $url): ?Bookmark
98 { 119 {
99 return $this->bookmarks->getByUrl($url); 120 return $this->bookmarks->getByUrl($url);
100 } 121 }
@@ -102,19 +123,28 @@ class BookmarkFileService implements BookmarkServiceInterface
102 /** 123 /**
103 * @inheritDoc 124 * @inheritDoc
104 */ 125 */
105 public function search($request = [], $visibility = null, $caseSensitive = false, $untaggedOnly = false) 126 public function search(
106 { 127 array $request = [],
128 string $visibility = null,
129 bool $caseSensitive = false,
130 bool $untaggedOnly = false,
131 bool $ignoreSticky = false
132 ) {
107 if ($visibility === null) { 133 if ($visibility === null) {
108 $visibility = $this->isLoggedIn ? BookmarkFilter::$ALL : BookmarkFilter::$PUBLIC; 134 $visibility = $this->isLoggedIn ? BookmarkFilter::$ALL : BookmarkFilter::$PUBLIC;
109 } 135 }
110 136
111 // Filter bookmark database according to parameters. 137 // Filter bookmark database according to parameters.
112 $searchtags = isset($request['searchtags']) ? $request['searchtags'] : ''; 138 $searchTags = isset($request['searchtags']) ? $request['searchtags'] : '';
113 $searchterm = isset($request['searchterm']) ? $request['searchterm'] : ''; 139 $searchTerm = isset($request['searchterm']) ? $request['searchterm'] : '';
140
141 if ($ignoreSticky) {
142 $this->bookmarks->reorder('DESC', true);
143 }
114 144
115 return $this->bookmarkFilter->filter( 145 return $this->bookmarkFilter->filter(
116 BookmarkFilter::$FILTER_TAG | BookmarkFilter::$FILTER_TEXT, 146 BookmarkFilter::$FILTER_TAG | BookmarkFilter::$FILTER_TEXT,
117 [$searchtags, $searchterm], 147 [$searchTags, $searchTerm],
118 $caseSensitive, 148 $caseSensitive,
119 $visibility, 149 $visibility,
120 $untaggedOnly 150 $untaggedOnly
@@ -124,7 +154,7 @@ class BookmarkFileService implements BookmarkServiceInterface
124 /** 154 /**
125 * @inheritDoc 155 * @inheritDoc
126 */ 156 */
127 public function get($id, $visibility = null) 157 public function get(int $id, string $visibility = null): Bookmark
128 { 158 {
129 if (! isset($this->bookmarks[$id])) { 159 if (! isset($this->bookmarks[$id])) {
130 throw new BookmarkNotFoundException(); 160 throw new BookmarkNotFoundException();
@@ -147,20 +177,17 @@ class BookmarkFileService implements BookmarkServiceInterface
147 /** 177 /**
148 * @inheritDoc 178 * @inheritDoc
149 */ 179 */
150 public function set($bookmark, $save = true) 180 public function set(Bookmark $bookmark, bool $save = true): Bookmark
151 { 181 {
152 if ($this->isLoggedIn !== true) { 182 if (true !== $this->isLoggedIn) {
153 throw new Exception(t('You\'re not authorized to alter the datastore')); 183 throw new Exception(t('You\'re not authorized to alter the datastore'));
154 } 184 }
155 if (! $bookmark instanceof Bookmark) {
156 throw new Exception(t('Provided data is invalid'));
157 }
158 if (! isset($this->bookmarks[$bookmark->getId()])) { 185 if (! isset($this->bookmarks[$bookmark->getId()])) {
159 throw new BookmarkNotFoundException(); 186 throw new BookmarkNotFoundException();
160 } 187 }
161 $bookmark->validate(); 188 $bookmark->validate();
162 189
163 $bookmark->setUpdated(new \DateTime()); 190 $bookmark->setUpdated(new DateTime());
164 $this->bookmarks[$bookmark->getId()] = $bookmark; 191 $this->bookmarks[$bookmark->getId()] = $bookmark;
165 if ($save === true) { 192 if ($save === true) {
166 $this->save(); 193 $this->save();
@@ -172,15 +199,12 @@ class BookmarkFileService implements BookmarkServiceInterface
172 /** 199 /**
173 * @inheritDoc 200 * @inheritDoc
174 */ 201 */
175 public function add($bookmark, $save = true) 202 public function add(Bookmark $bookmark, bool $save = true): Bookmark
176 { 203 {
177 if ($this->isLoggedIn !== true) { 204 if (true !== $this->isLoggedIn) {
178 throw new Exception(t('You\'re not authorized to alter the datastore')); 205 throw new Exception(t('You\'re not authorized to alter the datastore'));
179 } 206 }
180 if (! $bookmark instanceof Bookmark) { 207 if (!empty($bookmark->getId())) {
181 throw new Exception(t('Provided data is invalid'));
182 }
183 if (! empty($bookmark->getId())) {
184 throw new Exception(t('This bookmarks already exists')); 208 throw new Exception(t('This bookmarks already exists'));
185 } 209 }
186 $bookmark->setId($this->bookmarks->getNextId()); 210 $bookmark->setId($this->bookmarks->getNextId());
@@ -197,14 +221,11 @@ class BookmarkFileService implements BookmarkServiceInterface
197 /** 221 /**
198 * @inheritDoc 222 * @inheritDoc
199 */ 223 */
200 public function addOrSet($bookmark, $save = true) 224 public function addOrSet(Bookmark $bookmark, bool $save = true): Bookmark
201 { 225 {
202 if ($this->isLoggedIn !== true) { 226 if (true !== $this->isLoggedIn) {
203 throw new Exception(t('You\'re not authorized to alter the datastore')); 227 throw new Exception(t('You\'re not authorized to alter the datastore'));
204 } 228 }
205 if (! $bookmark instanceof Bookmark) {
206 throw new Exception('Provided data is invalid');
207 }
208 if ($bookmark->getId() === null) { 229 if ($bookmark->getId() === null) {
209 return $this->add($bookmark, $save); 230 return $this->add($bookmark, $save);
210 } 231 }
@@ -214,14 +235,11 @@ class BookmarkFileService implements BookmarkServiceInterface
214 /** 235 /**
215 * @inheritDoc 236 * @inheritDoc
216 */ 237 */
217 public function remove($bookmark, $save = true) 238 public function remove(Bookmark $bookmark, bool $save = true): void
218 { 239 {
219 if ($this->isLoggedIn !== true) { 240 if (true !== $this->isLoggedIn) {
220 throw new Exception(t('You\'re not authorized to alter the datastore')); 241 throw new Exception(t('You\'re not authorized to alter the datastore'));
221 } 242 }
222 if (! $bookmark instanceof Bookmark) {
223 throw new Exception(t('Provided data is invalid'));
224 }
225 if (! isset($this->bookmarks[$bookmark->getId()])) { 243 if (! isset($this->bookmarks[$bookmark->getId()])) {
226 throw new BookmarkNotFoundException(); 244 throw new BookmarkNotFoundException();
227 } 245 }
@@ -236,7 +254,7 @@ class BookmarkFileService implements BookmarkServiceInterface
236 /** 254 /**
237 * @inheritDoc 255 * @inheritDoc
238 */ 256 */
239 public function exists($id, $visibility = null) 257 public function exists(int $id, string $visibility = null): bool
240 { 258 {
241 if (! isset($this->bookmarks[$id])) { 259 if (! isset($this->bookmarks[$id])) {
242 return false; 260 return false;
@@ -259,7 +277,7 @@ class BookmarkFileService implements BookmarkServiceInterface
259 /** 277 /**
260 * @inheritDoc 278 * @inheritDoc
261 */ 279 */
262 public function count($visibility = null) 280 public function count(string $visibility = null): int
263 { 281 {
264 return count($this->search([], $visibility)); 282 return count($this->search([], $visibility));
265 } 283 }
@@ -267,21 +285,22 @@ class BookmarkFileService implements BookmarkServiceInterface
267 /** 285 /**
268 * @inheritDoc 286 * @inheritDoc
269 */ 287 */
270 public function save() 288 public function save(): void
271 { 289 {
272 if (!$this->isLoggedIn) { 290 if (true !== $this->isLoggedIn) {
273 // TODO: raise an Exception instead 291 // TODO: raise an Exception instead
274 die('You are not authorized to change the database.'); 292 die('You are not authorized to change the database.');
275 } 293 }
294
276 $this->bookmarks->reorder(); 295 $this->bookmarks->reorder();
277 $this->bookmarksIO->write($this->bookmarks); 296 $this->bookmarksIO->write($this->bookmarks);
278 invalidateCaches($this->conf->get('resource.page_cache')); 297 $this->pageCacheManager->invalidateCaches();
279 } 298 }
280 299
281 /** 300 /**
282 * @inheritDoc 301 * @inheritDoc
283 */ 302 */
284 public function bookmarksCountPerTag($filteringTags = [], $visibility = null) 303 public function bookmarksCountPerTag(array $filteringTags = [], string $visibility = null): array
285 { 304 {
286 $bookmarks = $this->search(['searchtags' => $filteringTags], $visibility); 305 $bookmarks = $this->search(['searchtags' => $filteringTags], $visibility);
287 $tags = []; 306 $tags = [];
@@ -291,6 +310,7 @@ class BookmarkFileService implements BookmarkServiceInterface
291 if (empty($tag) 310 if (empty($tag)
292 || (! $this->isLoggedIn && startsWith($tag, '.')) 311 || (! $this->isLoggedIn && startsWith($tag, '.'))
293 || $tag === BookmarkMarkdownFormatter::NO_MD_TAG 312 || $tag === BookmarkMarkdownFormatter::NO_MD_TAG
313 || in_array($tag, $filteringTags, true)
294 ) { 314 ) {
295 continue; 315 continue;
296 } 316 }
@@ -316,45 +336,68 @@ class BookmarkFileService implements BookmarkServiceInterface
316 $keys = array_keys($tags); 336 $keys = array_keys($tags);
317 $tmpTags = array_combine($keys, $keys); 337 $tmpTags = array_combine($keys, $keys);
318 array_multisort($tags, SORT_DESC, $tmpTags, SORT_ASC, $tags); 338 array_multisort($tags, SORT_DESC, $tmpTags, SORT_ASC, $tags);
339
319 return $tags; 340 return $tags;
320 } 341 }
321 342
322 /** 343 /**
323 * @inheritDoc 344 * @inheritDoc
324 */ 345 */
325 public function days() 346 public function findByDate(
326 { 347 \DateTimeInterface $from,
327 $bookmarkDays = []; 348 \DateTimeInterface $to,
328 foreach ($this->search() as $bookmark) { 349 ?\DateTimeInterface &$previous,
329 $bookmarkDays[$bookmark->getCreated()->format('Ymd')] = 0; 350 ?\DateTimeInterface &$next
351 ): array {
352 $out = [];
353 $previous = null;
354 $next = null;
355
356 foreach ($this->search([], null, false, false, true) as $bookmark) {
357 if ($to < $bookmark->getCreated()) {
358 $next = $bookmark->getCreated();
359 } else if ($from < $bookmark->getCreated() && $to > $bookmark->getCreated()) {
360 $out[] = $bookmark;
361 } else {
362 if ($previous !== null) {
363 break;
364 }
365 $previous = $bookmark->getCreated();
366 }
330 } 367 }
331 $bookmarkDays = array_keys($bookmarkDays);
332 sort($bookmarkDays);
333 368
334 return $bookmarkDays; 369 return $out;
335 } 370 }
336 371
337 /** 372 /**
338 * @inheritDoc 373 * @inheritDoc
339 */ 374 */
340 public function filterDay($request) 375 public function getLatest(): ?Bookmark
341 { 376 {
342 return $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_DAY, $request); 377 foreach ($this->search([], null, false, false, true) as $bookmark) {
378 return $bookmark;
379 }
380
381 return null;
343 } 382 }
344 383
345 /** 384 /**
346 * @inheritDoc 385 * @inheritDoc
347 */ 386 */
348 public function initialize() 387 public function initialize(): void
349 { 388 {
350 $initializer = new BookmarkInitializer($this); 389 $initializer = new BookmarkInitializer($this);
351 $initializer->initialize(); 390 $initializer->initialize();
391
392 if (true === $this->isLoggedIn) {
393 $this->save();
394 }
352 } 395 }
353 396
354 /** 397 /**
355 * Handles migration to the new database format (BookmarksArray). 398 * Handles migration to the new database format (BookmarksArray).
356 */ 399 */
357 protected function migrate() 400 protected function migrate(): void
358 { 401 {
359 $bookmarkDb = new LegacyLinkDB( 402 $bookmarkDb = new LegacyLinkDB(
360 $this->conf->get('resource.datastore'), 403 $this->conf->get('resource.datastore'),
diff --git a/application/bookmark/BookmarkFilter.php b/application/bookmark/BookmarkFilter.php
index fd556679..c79386ea 100644
--- a/application/bookmark/BookmarkFilter.php
+++ b/application/bookmark/BookmarkFilter.php
@@ -1,5 +1,7 @@
1<?php 1<?php
2 2
3declare(strict_types=1);
4
3namespace Shaarli\Bookmark; 5namespace Shaarli\Bookmark;
4 6
5use Exception; 7use Exception;
@@ -77,8 +79,13 @@ class BookmarkFilter
77 * 79 *
78 * @throws BookmarkNotFoundException 80 * @throws BookmarkNotFoundException
79 */ 81 */
80 public function filter($type, $request, $casesensitive = false, $visibility = 'all', $untaggedonly = false) 82 public function filter(
81 { 83 string $type,
84 $request,
85 bool $casesensitive = false,
86 string $visibility = 'all',
87 bool $untaggedonly = false
88 ) {
82 if (!in_array($visibility, ['all', 'public', 'private'])) { 89 if (!in_array($visibility, ['all', 'public', 'private'])) {
83 $visibility = 'all'; 90 $visibility = 'all';
84 } 91 }
@@ -115,7 +122,7 @@ class BookmarkFilter
115 return $this->filterTags($request, $casesensitive, $visibility); 122 return $this->filterTags($request, $casesensitive, $visibility);
116 } 123 }
117 case self::$FILTER_DAY: 124 case self::$FILTER_DAY:
118 return $this->filterDay($request); 125 return $this->filterDay($request, $visibility);
119 default: 126 default:
120 return $this->noFilter($visibility); 127 return $this->noFilter($visibility);
121 } 128 }
@@ -128,7 +135,7 @@ class BookmarkFilter
128 * 135 *
129 * @return Bookmark[] filtered bookmarks. 136 * @return Bookmark[] filtered bookmarks.
130 */ 137 */
131 private function noFilter($visibility = 'all') 138 private function noFilter(string $visibility = 'all')
132 { 139 {
133 if ($visibility === 'all') { 140 if ($visibility === 'all') {
134 return $this->bookmarks; 141 return $this->bookmarks;
@@ -151,11 +158,11 @@ class BookmarkFilter
151 * 158 *
152 * @param string $smallHash permalink hash. 159 * @param string $smallHash permalink hash.
153 * 160 *
154 * @return array $filtered array containing permalink data. 161 * @return Bookmark[] $filtered array containing permalink data.
155 * 162 *
156 * @throws \Shaarli\Bookmark\Exception\BookmarkNotFoundException if the smallhash doesn't match any link. 163 * @throws BookmarkNotFoundException if the smallhash doesn't match any link.
157 */ 164 */
158 private function filterSmallHash($smallHash) 165 private function filterSmallHash(string $smallHash)
159 { 166 {
160 foreach ($this->bookmarks as $key => $l) { 167 foreach ($this->bookmarks as $key => $l) {
161 if ($smallHash == $l->getShortUrl()) { 168 if ($smallHash == $l->getShortUrl()) {
@@ -186,15 +193,15 @@ class BookmarkFilter
186 * @param string $searchterms search query. 193 * @param string $searchterms search query.
187 * @param string $visibility Optional: return only all/private/public bookmarks. 194 * @param string $visibility Optional: return only all/private/public bookmarks.
188 * 195 *
189 * @return array search results. 196 * @return Bookmark[] search results.
190 */ 197 */
191 private function filterFulltext($searchterms, $visibility = 'all') 198 private function filterFulltext(string $searchterms, string $visibility = 'all')
192 { 199 {
193 if (empty($searchterms)) { 200 if (empty($searchterms)) {
194 return $this->noFilter($visibility); 201 return $this->noFilter($visibility);
195 } 202 }
196 203
197 $filtered = array(); 204 $filtered = [];
198 $search = mb_convert_case(html_entity_decode($searchterms), MB_CASE_LOWER, 'UTF-8'); 205 $search = mb_convert_case(html_entity_decode($searchterms), MB_CASE_LOWER, 'UTF-8');
199 $exactRegex = '/"([^"]+)"/'; 206 $exactRegex = '/"([^"]+)"/';
200 // Retrieve exact search terms. 207 // Retrieve exact search terms.
@@ -206,8 +213,8 @@ class BookmarkFilter
206 $explodedSearchAnd = array_values(array_filter($explodedSearchAnd)); 213 $explodedSearchAnd = array_values(array_filter($explodedSearchAnd));
207 214
208 // Filter excluding terms and update andSearch. 215 // Filter excluding terms and update andSearch.
209 $excludeSearch = array(); 216 $excludeSearch = [];
210 $andSearch = array(); 217 $andSearch = [];
211 foreach ($explodedSearchAnd as $needle) { 218 foreach ($explodedSearchAnd as $needle) {
212 if ($needle[0] == '-' && strlen($needle) > 1) { 219 if ($needle[0] == '-' && strlen($needle) > 1) {
213 $excludeSearch[] = substr($needle, 1); 220 $excludeSearch[] = substr($needle, 1);
@@ -227,33 +234,38 @@ class BookmarkFilter
227 } 234 }
228 } 235 }
229 236
230 // Concatenate link fields to search across fields. 237 $lengths = [];
231 // Adds a '\' separator for exact search terms. 238 $content = $this->buildFullTextSearchableLink($link, $lengths);
232 $content = mb_convert_case($link->getTitle(), MB_CASE_LOWER, 'UTF-8') .'\\';
233 $content .= mb_convert_case($link->getDescription(), MB_CASE_LOWER, 'UTF-8') .'\\';
234 $content .= mb_convert_case($link->getUrl(), MB_CASE_LOWER, 'UTF-8') .'\\';
235 $content .= mb_convert_case($link->getTagsString(), MB_CASE_LOWER, 'UTF-8') .'\\';
236 239
237 // Be optimistic 240 // Be optimistic
238 $found = true; 241 $found = true;
242 $foundPositions = [];
239 243
240 // First, we look for exact term search 244 // First, we look for exact term search
241 for ($i = 0; $i < count($exactSearch) && $found; $i++) { 245 // Then iterate over keywords, if keyword is not found,
242 $found = strpos($content, $exactSearch[$i]) !== false;
243 }
244
245 // Iterate over keywords, if keyword is not found,
246 // no need to check for the others. We want all or nothing. 246 // no need to check for the others. We want all or nothing.
247 for ($i = 0; $i < count($andSearch) && $found; $i++) { 247 foreach ([$exactSearch, $andSearch] as $search) {
248 $found = strpos($content, $andSearch[$i]) !== false; 248 for ($i = 0; $i < count($search) && $found !== false; $i++) {
249 $found = mb_strpos($content, $search[$i]);
250 if ($found === false) {
251 break;
252 }
253
254 $foundPositions[] = ['start' => $found, 'end' => $found + mb_strlen($search[$i])];
255 }
249 } 256 }
250 257
251 // Exclude terms. 258 // Exclude terms.
252 for ($i = 0; $i < count($excludeSearch) && $found; $i++) { 259 for ($i = 0; $i < count($excludeSearch) && $found !== false; $i++) {
253 $found = strpos($content, $excludeSearch[$i]) === false; 260 $found = strpos($content, $excludeSearch[$i]) === false;
254 } 261 }
255 262
256 if ($found) { 263 if ($found !== false) {
264 $link->addAdditionalContentEntry(
265 'search_highlight',
266 $this->postProcessFoundPositions($lengths, $foundPositions)
267 );
268
257 $filtered[$id] = $link; 269 $filtered[$id] = $link;
258 } 270 }
259 } 271 }
@@ -268,7 +280,7 @@ class BookmarkFilter
268 * 280 *
269 * @return string generated regex fragment 281 * @return string generated regex fragment
270 */ 282 */
271 private static function tag2regex($tag) 283 private static function tag2regex(string $tag): string
272 { 284 {
273 $len = strlen($tag); 285 $len = strlen($tag);
274 if (!$len || $tag === "-" || $tag === "*") { 286 if (!$len || $tag === "-" || $tag === "*") {
@@ -314,13 +326,13 @@ class BookmarkFilter
314 * You can specify one or more tags, separated by space or a comma, e.g. 326 * You can specify one or more tags, separated by space or a comma, e.g.
315 * print_r($mydb->filterTags('linux programming')); 327 * print_r($mydb->filterTags('linux programming'));
316 * 328 *
317 * @param string $tags list of tags separated by commas or blank spaces. 329 * @param string|array $tags list of tags, separated by commas or blank spaces if passed as string.
318 * @param bool $casesensitive ignore case if false. 330 * @param bool $casesensitive ignore case if false.
319 * @param string $visibility Optional: return only all/private/public bookmarks. 331 * @param string $visibility Optional: return only all/private/public bookmarks.
320 * 332 *
321 * @return array filtered bookmarks. 333 * @return Bookmark[] filtered bookmarks.
322 */ 334 */
323 public function filterTags($tags, $casesensitive = false, $visibility = 'all') 335 public function filterTags($tags, bool $casesensitive = false, string $visibility = 'all')
324 { 336 {
325 // get single tags (we may get passed an array, even though the docs say different) 337 // get single tags (we may get passed an array, even though the docs say different)
326 $inputTags = $tags; 338 $inputTags = $tags;
@@ -396,9 +408,9 @@ class BookmarkFilter
396 * 408 *
397 * @param string $visibility return only all/private/public bookmarks. 409 * @param string $visibility return only all/private/public bookmarks.
398 * 410 *
399 * @return array filtered bookmarks. 411 * @return Bookmark[] filtered bookmarks.
400 */ 412 */
401 public function filterUntagged($visibility) 413 public function filterUntagged(string $visibility)
402 { 414 {
403 $filtered = []; 415 $filtered = [];
404 foreach ($this->bookmarks as $key => $link) { 416 foreach ($this->bookmarks as $key => $link) {
@@ -425,21 +437,26 @@ class BookmarkFilter
425 * print_r($mydb->filterDay('20120125')); 437 * print_r($mydb->filterDay('20120125'));
426 * 438 *
427 * @param string $day day to filter. 439 * @param string $day day to filter.
428 * 440 * @param string $visibility return only all/private/public bookmarks.
429 * @return array all link matching given day. 441
442 * @return Bookmark[] all link matching given day.
430 * 443 *
431 * @throws Exception if date format is invalid. 444 * @throws Exception if date format is invalid.
432 */ 445 */
433 public function filterDay($day) 446 public function filterDay(string $day, string $visibility)
434 { 447 {
435 if (!checkDateFormat('Ymd', $day)) { 448 if (!checkDateFormat('Ymd', $day)) {
436 throw new Exception('Invalid date format'); 449 throw new Exception('Invalid date format');
437 } 450 }
438 451
439 $filtered = array(); 452 $filtered = [];
440 foreach ($this->bookmarks as $key => $l) { 453 foreach ($this->bookmarks as $key => $bookmark) {
441 if ($l->getCreated()->format('Ymd') == $day) { 454 if ($visibility === static::$PUBLIC && $bookmark->isPrivate()) {
442 $filtered[$key] = $l; 455 continue;
456 }
457
458 if ($bookmark->getCreated()->format('Ymd') == $day) {
459 $filtered[$key] = $bookmark;
443 } 460 }
444 } 461 }
445 462
@@ -455,9 +472,9 @@ class BookmarkFilter
455 * @param string $tags string containing a list of tags. 472 * @param string $tags string containing a list of tags.
456 * @param bool $casesensitive will convert everything to lowercase if false. 473 * @param bool $casesensitive will convert everything to lowercase if false.
457 * 474 *
458 * @return array filtered tags string. 475 * @return string[] filtered tags string.
459 */ 476 */
460 public static function tagsStrToArray($tags, $casesensitive) 477 public static function tagsStrToArray(string $tags, bool $casesensitive): array
461 { 478 {
462 // We use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek) 479 // We use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek)
463 $tagsOut = $casesensitive ? $tags : mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8'); 480 $tagsOut = $casesensitive ? $tags : mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8');
@@ -465,4 +482,74 @@ class BookmarkFilter
465 482
466 return preg_split('/\s+/', $tagsOut, -1, PREG_SPLIT_NO_EMPTY); 483 return preg_split('/\s+/', $tagsOut, -1, PREG_SPLIT_NO_EMPTY);
467 } 484 }
485
486 /**
487 * This method finalize the content of the foundPositions array,
488 * by associated all search results to their associated bookmark field,
489 * making sure that there is no overlapping results, etc.
490 *
491 * @param array $fieldLengths Start and end positions of every bookmark fields in the aggregated bookmark content.
492 * @param array $foundPositions Positions where the search results were found in the aggregated content.
493 *
494 * @return array Updated $foundPositions, by bookmark field.
495 */
496 protected function postProcessFoundPositions(array $fieldLengths, array $foundPositions): array
497 {
498 // Sort results by starting position ASC.
499 usort($foundPositions, function (array $entryA, array $entryB): int {
500 return $entryA['start'] > $entryB['start'] ? 1 : -1;
501 });
502
503 $out = [];
504 $currentMax = -1;
505 foreach ($foundPositions as $foundPosition) {
506 // we do not allow overlapping highlights
507 if ($foundPosition['start'] < $currentMax) {
508 continue;
509 }
510
511 $currentMax = $foundPosition['end'];
512 foreach ($fieldLengths as $part => $length) {
513 if ($foundPosition['start'] < $length['start'] || $foundPosition['start'] > $length['end']) {
514 continue;
515 }
516
517 $out[$part][] = [
518 'start' => $foundPosition['start'] - $length['start'],
519 'end' => $foundPosition['end'] - $length['start'],
520 ];
521 break;
522 }
523 }
524
525 return $out;
526 }
527
528 /**
529 * Concatenate link fields to search across fields. Adds a '\' separator for exact search terms.
530 * Also populate $length array with starting and ending positions of every bookmark field
531 * inside concatenated content.
532 *
533 * @param Bookmark $link
534 * @param array $lengths (by reference)
535 *
536 * @return string Lowercase concatenated fields content.
537 */
538 protected function buildFullTextSearchableLink(Bookmark $link, array &$lengths): string
539 {
540 $content = mb_convert_case($link->getTitle(), MB_CASE_LOWER, 'UTF-8') .'\\';
541 $content .= mb_convert_case($link->getDescription(), MB_CASE_LOWER, 'UTF-8') .'\\';
542 $content .= mb_convert_case($link->getUrl(), MB_CASE_LOWER, 'UTF-8') .'\\';
543 $content .= mb_convert_case($link->getTagsString(), MB_CASE_LOWER, 'UTF-8') .'\\';
544
545 $lengths['title'] = ['start' => 0, 'end' => mb_strlen($link->getTitle())];
546 $nextField = $lengths['title']['end'] + 1;
547 $lengths['description'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($link->getDescription())];
548 $nextField = $lengths['description']['end'] + 1;
549 $lengths['url'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($link->getUrl())];
550 $nextField = $lengths['url']['end'] + 1;
551 $lengths['tags'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($link->getTagsString())];
552
553 return $content;
554 }
468} 555}
diff --git a/application/bookmark/BookmarkIO.php b/application/bookmark/BookmarkIO.php
index ae9ffcb4..f40fa476 100644
--- a/application/bookmark/BookmarkIO.php
+++ b/application/bookmark/BookmarkIO.php
@@ -1,7 +1,12 @@
1<?php 1<?php
2 2
3declare(strict_types=1);
4
3namespace Shaarli\Bookmark; 5namespace Shaarli\Bookmark;
4 6
7use malkusch\lock\mutex\Mutex;
8use malkusch\lock\mutex\NoMutex;
9use Shaarli\Bookmark\Exception\DatastoreNotInitializedException;
5use Shaarli\Bookmark\Exception\EmptyDataStoreException; 10use Shaarli\Bookmark\Exception\EmptyDataStoreException;
6use Shaarli\Bookmark\Exception\NotWritableDataStoreException; 11use Shaarli\Bookmark\Exception\NotWritableDataStoreException;
7use Shaarli\Config\ConfigManager; 12use Shaarli\Config\ConfigManager;
@@ -26,11 +31,14 @@ class BookmarkIO
26 */ 31 */
27 protected $conf; 32 protected $conf;
28 33
34
35 /** @var Mutex */
36 protected $mutex;
37
29 /** 38 /**
30 * string Datastore PHP prefix 39 * string Datastore PHP prefix
31 */ 40 */
32 protected static $phpPrefix = '<?php /* '; 41 protected static $phpPrefix = '<?php /* ';
33
34 /** 42 /**
35 * string Datastore PHP suffix 43 * string Datastore PHP suffix
36 */ 44 */
@@ -41,35 +49,46 @@ class BookmarkIO
41 * 49 *
42 * @param ConfigManager $conf instance 50 * @param ConfigManager $conf instance
43 */ 51 */
44 public function __construct($conf) 52 public function __construct(ConfigManager $conf, Mutex $mutex = null)
45 { 53 {
54 if ($mutex === null) {
55 // This should only happen with legacy classes
56 $mutex = new NoMutex();
57 }
46 $this->conf = $conf; 58 $this->conf = $conf;
47 $this->datastore = $conf->get('resource.datastore'); 59 $this->datastore = $conf->get('resource.datastore');
60 $this->mutex = $mutex;
48 } 61 }
49 62
50 /** 63 /**
51 * Reads database from disk to memory 64 * Reads database from disk to memory
52 * 65 *
53 * @return BookmarkArray instance 66 * @return Bookmark[]
54 * 67 *
55 * @throws NotWritableDataStoreException Data couldn't be loaded 68 * @throws NotWritableDataStoreException Data couldn't be loaded
56 * @throws EmptyDataStoreException Datastore doesn't exist 69 * @throws EmptyDataStoreException Datastore file exists but does not contain any bookmark
70 * @throws DatastoreNotInitializedException File does not exists
57 */ 71 */
58 public function read() 72 public function read()
59 { 73 {
60 if (! file_exists($this->datastore)) { 74 if (! file_exists($this->datastore)) {
61 throw new EmptyDataStoreException(); 75 throw new DatastoreNotInitializedException();
62 } 76 }
63 77
64 if (!is_writable($this->datastore)) { 78 if (!is_writable($this->datastore)) {
65 throw new NotWritableDataStoreException($this->datastore); 79 throw new NotWritableDataStoreException($this->datastore);
66 } 80 }
67 81
82 $content = null;
83 $this->mutex->synchronized(function () use (&$content) {
84 $content = file_get_contents($this->datastore);
85 });
86
68 // Note that gzinflate is faster than gzuncompress. 87 // Note that gzinflate is faster than gzuncompress.
69 // See: http://www.php.net/manual/en/function.gzdeflate.php#96439 88 // See: http://www.php.net/manual/en/function.gzdeflate.php#96439
70 $links = unserialize(gzinflate(base64_decode( 89 $links = unserialize(gzinflate(base64_decode(
71 substr(file_get_contents($this->datastore), 90 substr($content, strlen(self::$phpPrefix), -strlen(self::$phpSuffix))
72 strlen(self::$phpPrefix), -strlen(self::$phpSuffix))))); 91 )));
73 92
74 if (empty($links)) { 93 if (empty($links)) {
75 if (filesize($this->datastore) > 100) { 94 if (filesize($this->datastore) > 100) {
@@ -84,7 +103,7 @@ class BookmarkIO
84 /** 103 /**
85 * Saves the database from memory to disk 104 * Saves the database from memory to disk
86 * 105 *
87 * @param BookmarkArray $links instance. 106 * @param Bookmark[] $links
88 * 107 *
89 * @throws NotWritableDataStoreException the datastore is not writable 108 * @throws NotWritableDataStoreException the datastore is not writable
90 */ 109 */
@@ -98,11 +117,13 @@ class BookmarkIO
98 throw new NotWritableDataStoreException(dirname($this->datastore)); 117 throw new NotWritableDataStoreException(dirname($this->datastore));
99 } 118 }
100 119
101 file_put_contents( 120 $data = self::$phpPrefix.base64_encode(gzdeflate(serialize($links))).self::$phpSuffix;
102 $this->datastore,
103 self::$phpPrefix.base64_encode(gzdeflate(serialize($links))).self::$phpSuffix
104 );
105 121
106 invalidateCaches($this->conf->get('resource.page_cache')); 122 $this->mutex->synchronized(function () use ($data) {
123 file_put_contents(
124 $this->datastore,
125 $data
126 );
127 });
107 } 128 }
108} 129}
diff --git a/application/bookmark/BookmarkInitializer.php b/application/bookmark/BookmarkInitializer.php
index 9eee9a35..04b996f3 100644
--- a/application/bookmark/BookmarkInitializer.php
+++ b/application/bookmark/BookmarkInitializer.php
@@ -1,13 +1,14 @@
1<?php 1<?php
2 2
3declare(strict_types=1);
4
3namespace Shaarli\Bookmark; 5namespace Shaarli\Bookmark;
4 6
5/** 7/**
6 * Class BookmarkInitializer 8 * Class BookmarkInitializer
7 * 9 *
8 * This class is used to initialized default bookmarks after a fresh install of Shaarli. 10 * This class is used to initialized default bookmarks after a fresh install of Shaarli.
9 * It is no longer call when the data store is empty, 11 * It should be only called if the datastore file does not exist(users might want to delete the default bookmarks).
10 * because user might want to delete default bookmarks after the install.
11 * 12 *
12 * To prevent data corruption, it does not overwrite existing bookmarks, 13 * To prevent data corruption, it does not overwrite existing bookmarks,
13 * even though there should not be any. 14 * even though there should not be any.
@@ -24,7 +25,7 @@ class BookmarkInitializer
24 * 25 *
25 * @param BookmarkServiceInterface $bookmarkService 26 * @param BookmarkServiceInterface $bookmarkService
26 */ 27 */
27 public function __construct($bookmarkService) 28 public function __construct(BookmarkServiceInterface $bookmarkService)
28 { 29 {
29 $this->bookmarkService = $bookmarkService; 30 $this->bookmarkService = $bookmarkService;
30 } 31 }
@@ -32,28 +33,80 @@ class BookmarkInitializer
32 /** 33 /**
33 * Initialize the data store with default bookmarks 34 * Initialize the data store with default bookmarks
34 */ 35 */
35 public function initialize() 36 public function initialize(): void
36 { 37 {
37 $bookmark = new Bookmark(); 38 $bookmark = new Bookmark();
38 $bookmark->setTitle(t('My secret stuff... - Pastebin.com')); 39 $bookmark->setTitle('quicksilver (loop) on Vimeo ' . t('(private bookmark with thumbnail demo)'));
39 $bookmark->setUrl('http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=', []); 40 $bookmark->setUrl('https://vimeo.com/153493904');
40 $bookmark->setDescription(t('Shhhh! I\'m a private link only YOU can see. You can delete me too.')); 41 $bookmark->setDescription(t(
41 $bookmark->setTagsString('secretstuff'); 42'Shaarli will automatically pick up the thumbnail for links to a variety of websites.
43
44Explore your new Shaarli instance by trying out controls and menus.
45Visit the project on [Github](https://github.com/shaarli/Shaarli) or [the documentation](https://shaarli.readthedocs.io/en/master/) to learn more about Shaarli.
46
47Now you can edit or delete the default shaares.
48'
49 ));
50 $bookmark->setTagsString('shaarli help thumbnail');
51 $bookmark->setPrivate(true);
52 $this->bookmarkService->add($bookmark, false);
53
54 $bookmark = new Bookmark();
55 $bookmark->setTitle(t('Note: Shaare descriptions'));
56 $bookmark->setDescription(t(
57'Adding a shaare without entering a URL creates a text-only "note" post such as this one.
58This note is private, so you are the only one able to see it while logged in.
59
60You can use this to keep notes, post articles, code snippets, and much more.
61
62The Markdown formatting setting allows you to format your notes and bookmark description:
63
64### Title headings
65
66#### Multiple headings levels
67 * bullet lists
68 * _italic_ text
69 * **bold** text
70 * ~~strike through~~ text
71 * `code` blocks
72 * images
73 * [links](https://en.wikipedia.org/wiki/Markdown)
74
75Markdown also supports tables:
76
77| Name | Type | Color | Qty |
78| ------- | --------- | ------ | ----- |
79| Orange | Fruit | Orange | 126 |
80| Apple | Fruit | Any | 62 |
81| Lemon | Fruit | Yellow | 30 |
82| Carrot | Vegetable | Red | 14 |
83'
84 ));
85 $bookmark->setTagsString('shaarli help');
42 $bookmark->setPrivate(true); 86 $bookmark->setPrivate(true);
43 $this->bookmarkService->add($bookmark); 87 $this->bookmarkService->add($bookmark, false);
44 88
45 $bookmark = new Bookmark(); 89 $bookmark = new Bookmark();
46 $bookmark->setTitle(t('The personal, minimalist, super-fast, database free, bookmarking service')); 90 $bookmark->setTitle(
47 $bookmark->setUrl('https://shaarli.readthedocs.io', []); 91 'Shaarli - ' . t('The personal, minimalist, super-fast, database free, bookmarking service')
92 );
48 $bookmark->setDescription(t( 93 $bookmark->setDescription(t(
49 'Welcome to Shaarli! This is your first public bookmark. ' 94'Welcome to Shaarli!
50 . 'To edit or delete me, you must first login. 95
96Shaarli allows you to bookmark your favorite pages, and share them with others or store them privately.
97You can add a description to your bookmarks, such as this one, and tag them.
98
99Create a new shaare by clicking the `+Shaare` button, or using any of the recommended tools (browser extension, mobile app, bookmarklet, REST API, etc.).
51 100
52To learn how to use Shaarli, consult the link "Documentation" at the bottom of this page. 101You can easily retrieve your links, even with thousands of them, using the internal search engine, or search through tags (e.g. this Shaare is tagged with `shaarli` and `help`).
102Hashtags such as #shaarli #help are also supported.
103You can also filter the available [RSS feed](/feed/atom) and picture wall by tag or plaintext search.
53 104
54You use the community supported version of the original Shaarli project, by Sebastien Sauvage.' 105We hope that you will enjoy using Shaarli, maintained with ❤️ by the community!
106Feel free to open [an issue](https://github.com/shaarli/Shaarli/issues) if you have a suggestion or encounter an issue.
107'
55 )); 108 ));
56 $bookmark->setTagsString('opensource software'); 109 $bookmark->setTagsString('shaarli help');
57 $this->bookmarkService->add($bookmark); 110 $this->bookmarkService->add($bookmark, false);
58 } 111 }
59} 112}
diff --git a/application/bookmark/BookmarkServiceInterface.php b/application/bookmark/BookmarkServiceInterface.php
index 7b7a4f09..08cdbb4e 100644
--- a/application/bookmark/BookmarkServiceInterface.php
+++ b/application/bookmark/BookmarkServiceInterface.php
@@ -1,73 +1,73 @@
1<?php 1<?php
2 2
3namespace Shaarli\Bookmark; 3declare(strict_types=1);
4 4
5namespace Shaarli\Bookmark;
5 6
6use Shaarli\Bookmark\Exception\BookmarkNotFoundException; 7use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
7use Shaarli\Bookmark\Exception\NotWritableDataStoreException; 8use Shaarli\Bookmark\Exception\NotWritableDataStoreException;
8use Shaarli\Config\ConfigManager;
9use Shaarli\Exceptions\IOException;
10use Shaarli\History;
11 9
12/** 10/**
13 * Class BookmarksService 11 * Class BookmarksService
14 * 12 *
15 * This is the entry point to manipulate the bookmark DB. 13 * This is the entry point to manipulate the bookmark DB.
14 *
15 * Regarding return types of a list of bookmarks, it can either be an array or an ArrayAccess implementation,
16 * so until PHP 8.0 is the minimal supported version with union return types it cannot be explicitly added.
16 */ 17 */
17interface BookmarkServiceInterface 18interface BookmarkServiceInterface
18{ 19{
19 /** 20 /**
20 * BookmarksService constructor.
21 *
22 * @param ConfigManager $conf instance
23 * @param History $history instance
24 * @param bool $isLoggedIn true if the current user is logged in
25 */
26 public function __construct(ConfigManager $conf, History $history, $isLoggedIn);
27
28 /**
29 * Find a bookmark by hash 21 * Find a bookmark by hash
30 * 22 *
31 * @param string $hash 23 * @param string $hash Bookmark's hash
24 * @param string|null $privateKey Optional key used to access private links while logged out
32 * 25 *
33 * @return mixed 26 * @return Bookmark
34 * 27 *
35 * @throws \Exception 28 * @throws \Exception
36 */ 29 */
37 public function findByHash($hash); 30 public function findByHash(string $hash, string $privateKey = null);
38 31
39 /** 32 /**
40 * @param $url 33 * @param $url
41 * 34 *
42 * @return Bookmark|null 35 * @return Bookmark|null
43 */ 36 */
44 public function findByUrl($url); 37 public function findByUrl(string $url): ?Bookmark;
45 38
46 /** 39 /**
47 * Search bookmarks 40 * Search bookmarks
48 * 41 *
49 * @param mixed $request 42 * @param array $request
50 * @param string $visibility 43 * @param ?string $visibility
51 * @param bool $caseSensitive 44 * @param bool $caseSensitive
52 * @param bool $untaggedOnly 45 * @param bool $untaggedOnly
46 * @param bool $ignoreSticky
53 * 47 *
54 * @return Bookmark[] 48 * @return Bookmark[]
55 */ 49 */
56 public function search($request = [], $visibility = null, $caseSensitive = false, $untaggedOnly = false); 50 public function search(
51 array $request = [],
52 string $visibility = null,
53 bool $caseSensitive = false,
54 bool $untaggedOnly = false,
55 bool $ignoreSticky = false
56 );
57 57
58 /** 58 /**
59 * Get a single bookmark by its ID. 59 * Get a single bookmark by its ID.
60 * 60 *
61 * @param int $id Bookmark ID 61 * @param int $id Bookmark ID
62 * @param string $visibility all|public|private e.g. with public, accessing a private bookmark will throw an 62 * @param ?string $visibility all|public|private e.g. with public, accessing a private bookmark will throw an
63 * exception 63 * exception
64 * 64 *
65 * @return Bookmark 65 * @return Bookmark
66 * 66 *
67 * @throws BookmarkNotFoundException 67 * @throws BookmarkNotFoundException
68 * @throws \Exception 68 * @throws \Exception
69 */ 69 */
70 public function get($id, $visibility = null); 70 public function get(int $id, string $visibility = null);
71 71
72 /** 72 /**
73 * Updates an existing bookmark (depending on its ID). 73 * Updates an existing bookmark (depending on its ID).
@@ -80,7 +80,7 @@ interface BookmarkServiceInterface
80 * @throws BookmarkNotFoundException 80 * @throws BookmarkNotFoundException
81 * @throws \Exception 81 * @throws \Exception
82 */ 82 */
83 public function set($bookmark, $save = true); 83 public function set(Bookmark $bookmark, bool $save = true): Bookmark;
84 84
85 /** 85 /**
86 * Adds a new bookmark (the ID must be empty). 86 * Adds a new bookmark (the ID must be empty).
@@ -92,7 +92,7 @@ interface BookmarkServiceInterface
92 * 92 *
93 * @throws \Exception 93 * @throws \Exception
94 */ 94 */
95 public function add($bookmark, $save = true); 95 public function add(Bookmark $bookmark, bool $save = true): Bookmark;
96 96
97 /** 97 /**
98 * Adds or updates a bookmark depending on its ID: 98 * Adds or updates a bookmark depending on its ID:
@@ -106,7 +106,7 @@ interface BookmarkServiceInterface
106 * 106 *
107 * @throws \Exception 107 * @throws \Exception
108 */ 108 */
109 public function addOrSet($bookmark, $save = true); 109 public function addOrSet(Bookmark $bookmark, bool $save = true): Bookmark;
110 110
111 /** 111 /**
112 * Deletes a bookmark. 112 * Deletes a bookmark.
@@ -116,65 +116,72 @@ interface BookmarkServiceInterface
116 * 116 *
117 * @throws \Exception 117 * @throws \Exception
118 */ 118 */
119 public function remove($bookmark, $save = true); 119 public function remove(Bookmark $bookmark, bool $save = true): void;
120 120
121 /** 121 /**
122 * Get a single bookmark by its ID. 122 * Get a single bookmark by its ID.
123 * 123 *
124 * @param int $id Bookmark ID 124 * @param int $id Bookmark ID
125 * @param string $visibility all|public|private e.g. with public, accessing a private bookmark will throw an 125 * @param ?string $visibility all|public|private e.g. with public, accessing a private bookmark will throw an
126 * exception 126 * exception
127 * 127 *
128 * @return bool 128 * @return bool
129 */ 129 */
130 public function exists($id, $visibility = null); 130 public function exists(int $id, string $visibility = null): bool;
131 131
132 /** 132 /**
133 * Return the number of available bookmarks for given visibility. 133 * Return the number of available bookmarks for given visibility.
134 * 134 *
135 * @param string $visibility public|private|all 135 * @param ?string $visibility public|private|all
136 * 136 *
137 * @return int Number of bookmarks 137 * @return int Number of bookmarks
138 */ 138 */
139 public function count($visibility = null); 139 public function count(string $visibility = null): int;
140 140
141 /** 141 /**
142 * Write the datastore. 142 * Write the datastore.
143 * 143 *
144 * @throws NotWritableDataStoreException 144 * @throws NotWritableDataStoreException
145 */ 145 */
146 public function save(); 146 public function save(): void;
147 147
148 /** 148 /**
149 * Returns the list tags appearing in the bookmarks with the given tags 149 * Returns the list tags appearing in the bookmarks with the given tags
150 * 150 *
151 * @param array $filteringTags tags selecting the bookmarks to consider 151 * @param array|null $filteringTags tags selecting the bookmarks to consider
152 * @param string $visibility process only all/private/public bookmarks 152 * @param string|null $visibility process only all/private/public bookmarks
153 * 153 *
154 * @return array tag => bookmarksCount 154 * @return array tag => bookmarksCount
155 */ 155 */
156 public function bookmarksCountPerTag($filteringTags = [], $visibility = 'all'); 156 public function bookmarksCountPerTag(array $filteringTags = [], ?string $visibility = null): array;
157 157
158 /** 158 /**
159 * Returns the list of days containing articles (oldest first) 159 * Return a list of bookmark matching provided period of time.
160 * It also update directly previous and next date outside of given period found in the datastore.
161 *
162 * @param \DateTimeInterface $from Starting date.
163 * @param \DateTimeInterface $to Ending date.
164 * @param \DateTimeInterface|null $previous (by reference) updated with first created date found before $from.
165 * @param \DateTimeInterface|null $next (by reference) updated with first created date found after $to.
160 * 166 *
161 * @return array containing days (in format YYYYMMDD). 167 * @return array List of bookmarks matching provided period of time.
162 */ 168 */
163 public function days(); 169 public function findByDate(
170 \DateTimeInterface $from,
171 \DateTimeInterface $to,
172 ?\DateTimeInterface &$previous,
173 ?\DateTimeInterface &$next
174 ): array;
164 175
165 /** 176 /**
166 * Returns the list of articles for a given day. 177 * Returns the latest bookmark by creation date.
167 * 178 *
168 * @param string $request day to filter. Format: YYYYMMDD. 179 * @return Bookmark|null Found Bookmark or null if the datastore is empty.
169 *
170 * @return Bookmark[] list of shaare found.
171 *
172 * @throws BookmarkNotFoundException
173 */ 180 */
174 public function filterDay($request); 181 public function getLatest(): ?Bookmark;
175 182
176 /** 183 /**
177 * Creates the default database after a fresh install. 184 * Creates the default database after a fresh install.
178 */ 185 */
179 public function initialize(); 186 public function initialize(): void;
180} 187}
diff --git a/application/bookmark/LinkUtils.php b/application/bookmark/LinkUtils.php
index 88379430..faf5dbfd 100644
--- a/application/bookmark/LinkUtils.php
+++ b/application/bookmark/LinkUtils.php
@@ -3,112 +3,6 @@
3use Shaarli\Bookmark\Bookmark; 3use Shaarli\Bookmark\Bookmark;
4 4
5/** 5/**
6 * Get cURL callback function for CURLOPT_WRITEFUNCTION
7 *
8 * @param string $charset to extract from the downloaded page (reference)
9 * @param string $title to extract from the downloaded page (reference)
10 * @param string $description to extract from the downloaded page (reference)
11 * @param string $keywords to extract from the downloaded page (reference)
12 * @param bool $retrieveDescription Automatically tries to retrieve description and keywords from HTML content
13 * @param string $curlGetInfo Optionally overrides curl_getinfo function
14 *
15 * @return Closure
16 */
17function get_curl_download_callback(
18 &$charset,
19 &$title,
20 &$description,
21 &$keywords,
22 $retrieveDescription,
23 $curlGetInfo = 'curl_getinfo'
24) {
25 $isRedirected = false;
26 $currentChunk = 0;
27 $foundChunk = null;
28
29 /**
30 * cURL callback function for CURLOPT_WRITEFUNCTION (called during the download).
31 *
32 * While downloading the remote page, we check that the HTTP code is 200 and content type is 'html/text'
33 * Then we extract the title and the charset and stop the download when it's done.
34 *
35 * @param resource $ch cURL resource
36 * @param string $data chunk of data being downloaded
37 *
38 * @return int|bool length of $data or false if we need to stop the download
39 */
40 return function (&$ch, $data) use (
41 $retrieveDescription,
42 $curlGetInfo,
43 &$charset,
44 &$title,
45 &$description,
46 &$keywords,
47 &$isRedirected,
48 &$currentChunk,
49 &$foundChunk
50 ) {
51 $currentChunk++;
52 $responseCode = $curlGetInfo($ch, CURLINFO_RESPONSE_CODE);
53 if (!empty($responseCode) && in_array($responseCode, [301, 302])) {
54 $isRedirected = true;
55 return strlen($data);
56 }
57 if (!empty($responseCode) && $responseCode !== 200) {
58 return false;
59 }
60 // After a redirection, the content type will keep the previous request value
61 // until it finds the next content-type header.
62 if (! $isRedirected || strpos(strtolower($data), 'content-type') !== false) {
63 $contentType = $curlGetInfo($ch, CURLINFO_CONTENT_TYPE);
64 }
65 if (!empty($contentType) && strpos($contentType, 'text/html') === false) {
66 return false;
67 }
68 if (!empty($contentType) && empty($charset)) {
69 $charset = header_extract_charset($contentType);
70 }
71 if (empty($charset)) {
72 $charset = html_extract_charset($data);
73 }
74 if (empty($title)) {
75 $title = html_extract_title($data);
76 $foundChunk = ! empty($title) ? $currentChunk : $foundChunk;
77 }
78 if ($retrieveDescription && empty($description)) {
79 $description = html_extract_tag('description', $data);
80 $foundChunk = ! empty($description) ? $currentChunk : $foundChunk;
81 }
82 if ($retrieveDescription && empty($keywords)) {
83 $keywords = html_extract_tag('keywords', $data);
84 if (! empty($keywords)) {
85 $foundChunk = $currentChunk;
86 // Keywords use the format tag1, tag2 multiple words, tag
87 // So we format them to match Shaarli's separator and glue multiple words with '-'
88 $keywords = implode(' ', array_map(function($keyword) {
89 return implode('-', preg_split('/\s+/', trim($keyword)));
90 }, explode(',', $keywords)));
91 }
92 }
93
94 // We got everything we want, stop the download.
95 // If we already found either the title, description or keywords,
96 // it's highly unlikely that we'll found the other metas further than
97 // in the same chunk of data or the next one. So we also stop the download after that.
98 if ((!empty($responseCode) && !empty($contentType) && !empty($charset)) && $foundChunk !== null
99 && (! $retrieveDescription
100 || $foundChunk < $currentChunk
101 || (!empty($title) && !empty($description) && !empty($keywords))
102 )
103 ) {
104 return false;
105 }
106
107 return strlen($data);
108 };
109}
110
111/**
112 * Extract title from an HTML document. 6 * Extract title from an HTML document.
113 * 7 *
114 * @param string $html HTML content where to look for a title. 8 * @param string $html HTML content where to look for a title.
@@ -132,7 +26,7 @@ function html_extract_title($html)
132 */ 26 */
133function header_extract_charset($header) 27function header_extract_charset($header)
134{ 28{
135 preg_match('/charset="?([^; ]+)/i', $header, $match); 29 preg_match('/charset=["\']?([^; "\']+)/i', $header, $match);
136 if (! empty($match[1])) { 30 if (! empty($match[1])) {
137 return strtolower(trim($match[1])); 31 return strtolower(trim($match[1]));
138 } 32 }
@@ -172,11 +66,13 @@ function html_extract_tag($tag, $html)
172{ 66{
173 $propertiesKey = ['property', 'name', 'itemprop']; 67 $propertiesKey = ['property', 'name', 'itemprop'];
174 $properties = implode('|', $propertiesKey); 68 $properties = implode('|', $propertiesKey);
69 // We need a OR here to accept either 'property=og:noquote' or 'property="og:unrelated og:my-tag"'
70 $orCondition = '["\']?(?:og:)?'. $tag .'["\']?|["\'][^\'"]*?(?:og:)?' . $tag . '[^\'"]*?[\'"]';
175 // Try to retrieve OpenGraph image. 71 // Try to retrieve OpenGraph image.
176 $ogRegex = '#<meta[^>]+(?:'. $properties .')=["\']?(?:og:)?'. $tag .'["\'\s][^>]*content=["\']?(.*?)["\'/>]#'; 72 $ogRegex = '#<meta[^>]+(?:'. $properties .')=(?:'. $orCondition .')[^>]*content=["\'](.*?)["\'].*?>#';
177 // If the attributes are not in the order property => content (e.g. Github) 73 // If the attributes are not in the order property => content (e.g. Github)
178 // New regex to keep this readable... more or less. 74 // New regex to keep this readable... more or less.
179 $ogRegexReverse = '#<meta[^>]+content=["\']([^"\']+)[^>]+(?:'. $properties .')=["\']?(?:og)?:'. $tag .'["\'\s/>]#'; 75 $ogRegexReverse = '#<meta[^>]+content=["\'](.*?)["\'][^>]+(?:'. $properties .')=(?:'. $orCondition .').*?>#';
180 76
181 if (preg_match($ogRegex, $html, $matches) > 0 77 if (preg_match($ogRegex, $html, $matches) > 0
182 || preg_match($ogRegexReverse, $html, $matches) > 0 78 || preg_match($ogRegexReverse, $html, $matches) > 0
@@ -220,7 +116,7 @@ function hashtag_autolink($description, $indexUrl = '')
220 * \p{Mn} - any non marking space (accents, umlauts, etc) 116 * \p{Mn} - any non marking space (accents, umlauts, etc)
221 */ 117 */
222 $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui'; 118 $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui';
223 $replacement = '$1<a href="'. $indexUrl .'?addtag=$2" title="Hashtag $2">#$2</a>'; 119 $replacement = '$1<a href="'. $indexUrl .'./add-tag/$2" title="Hashtag $2">#$2</a>';
224 return preg_replace($regex, $replacement, $description); 120 return preg_replace($regex, $replacement, $description);
225} 121}
226 122
diff --git a/application/bookmark/exception/DatastoreNotInitializedException.php b/application/bookmark/exception/DatastoreNotInitializedException.php
new file mode 100644
index 00000000..f495049d
--- /dev/null
+++ b/application/bookmark/exception/DatastoreNotInitializedException.php
@@ -0,0 +1,10 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Bookmark\Exception;
6
7class DatastoreNotInitializedException extends \Exception
8{
9
10}
diff --git a/application/config/ConfigJson.php b/application/config/ConfigJson.php
index 4509357c..23b22269 100644
--- a/application/config/ConfigJson.php
+++ b/application/config/ConfigJson.php
@@ -19,7 +19,7 @@ class ConfigJson implements ConfigIO
19 $data = file_get_contents($filepath); 19 $data = file_get_contents($filepath);
20 $data = str_replace(self::getPhpHeaders(), '', $data); 20 $data = str_replace(self::getPhpHeaders(), '', $data);
21 $data = str_replace(self::getPhpSuffix(), '', $data); 21 $data = str_replace(self::getPhpSuffix(), '', $data);
22 $data = json_decode($data, true); 22 $data = json_decode(trim($data), true);
23 if ($data === null) { 23 if ($data === null) {
24 $errorCode = json_last_error(); 24 $errorCode = json_last_error();
25 $error = sprintf( 25 $error = sprintf(
@@ -46,7 +46,7 @@ class ConfigJson implements ConfigIO
46 // JSON_PRETTY_PRINT is available from PHP 5.4. 46 // JSON_PRETTY_PRINT is available from PHP 5.4.
47 $print = defined('JSON_PRETTY_PRINT') ? JSON_PRETTY_PRINT : 0; 47 $print = defined('JSON_PRETTY_PRINT') ? JSON_PRETTY_PRINT : 0;
48 $data = self::getPhpHeaders() . json_encode($conf, $print) . self::getPhpSuffix(); 48 $data = self::getPhpHeaders() . json_encode($conf, $print) . self::getPhpSuffix();
49 if (!file_put_contents($filepath, $data)) { 49 if (empty($filepath) || !file_put_contents($filepath, $data)) {
50 throw new \Shaarli\Exceptions\IOException( 50 throw new \Shaarli\Exceptions\IOException(
51 $filepath, 51 $filepath,
52 t('Shaarli could not create the config file. '. 52 t('Shaarli could not create the config file. '.
@@ -73,7 +73,7 @@ class ConfigJson implements ConfigIO
73 */ 73 */
74 public static function getPhpHeaders() 74 public static function getPhpHeaders()
75 { 75 {
76 return '<?php /*'. PHP_EOL; 76 return '<?php /*';
77 } 77 }
78 78
79 /** 79 /**
@@ -85,6 +85,6 @@ class ConfigJson implements ConfigIO
85 */ 85 */
86 public static function getPhpSuffix() 86 public static function getPhpSuffix()
87 { 87 {
88 return PHP_EOL . '*/ ?>'; 88 return '*/ ?>';
89 } 89 }
90} 90}
diff --git a/application/config/ConfigManager.php b/application/config/ConfigManager.php
index e45bb4c3..fb085023 100644
--- a/application/config/ConfigManager.php
+++ b/application/config/ConfigManager.php
@@ -3,6 +3,7 @@ namespace Shaarli\Config;
3 3
4use Shaarli\Config\Exception\MissingFieldConfigException; 4use Shaarli\Config\Exception\MissingFieldConfigException;
5use Shaarli\Config\Exception\UnauthorizedConfigException; 5use Shaarli\Config\Exception\UnauthorizedConfigException;
6use Shaarli\Thumbnailer;
6 7
7/** 8/**
8 * Class ConfigManager 9 * Class ConfigManager
@@ -361,11 +362,12 @@ class ConfigManager
361 $this->setEmpty('security.open_shaarli', false); 362 $this->setEmpty('security.open_shaarli', false);
362 $this->setEmpty('security.allowed_protocols', ['ftp', 'ftps', 'magnet']); 363 $this->setEmpty('security.allowed_protocols', ['ftp', 'ftps', 'magnet']);
363 364
364 $this->setEmpty('general.header_link', '?'); 365 $this->setEmpty('general.header_link', '/');
365 $this->setEmpty('general.links_per_page', 20); 366 $this->setEmpty('general.links_per_page', 20);
366 $this->setEmpty('general.enabled_plugins', self::$DEFAULT_PLUGINS); 367 $this->setEmpty('general.enabled_plugins', self::$DEFAULT_PLUGINS);
367 $this->setEmpty('general.default_note_title', 'Note: '); 368 $this->setEmpty('general.default_note_title', 'Note: ');
368 $this->setEmpty('general.retrieve_description', false); 369 $this->setEmpty('general.retrieve_description', true);
370 $this->setEmpty('general.enable_async_metadata', true);
369 371
370 $this->setEmpty('updates.check_updates', false); 372 $this->setEmpty('updates.check_updates', false);
371 $this->setEmpty('updates.check_updates_branch', 'stable'); 373 $this->setEmpty('updates.check_updates_branch', 'stable');
@@ -381,6 +383,7 @@ class ConfigManager
381 // default state of the 'remember me' checkbox of the login form 383 // default state of the 'remember me' checkbox of the login form
382 $this->setEmpty('privacy.remember_user_default', true); 384 $this->setEmpty('privacy.remember_user_default', true);
383 385
386 $this->setEmpty('thumbnails.mode', Thumbnailer::MODE_ALL);
384 $this->setEmpty('thumbnails.width', '125'); 387 $this->setEmpty('thumbnails.width', '125');
385 $this->setEmpty('thumbnails.height', '90'); 388 $this->setEmpty('thumbnails.height', '90');
386 389
diff --git a/application/config/ConfigPlugin.php b/application/config/ConfigPlugin.php
index dbb24937..ea8dfbda 100644
--- a/application/config/ConfigPlugin.php
+++ b/application/config/ConfigPlugin.php
@@ -1,6 +1,7 @@
1<?php 1<?php
2 2
3use Shaarli\Config\Exception\PluginConfigOrderException; 3use Shaarli\Config\Exception\PluginConfigOrderException;
4use Shaarli\Plugin\PluginManager;
4 5
5/** 6/**
6 * Plugin configuration helper functions. 7 * Plugin configuration helper functions.
@@ -19,6 +20,20 @@ use Shaarli\Config\Exception\PluginConfigOrderException;
19 */ 20 */
20function save_plugin_config($formData) 21function save_plugin_config($formData)
21{ 22{
23 // We can only save existing plugins
24 $directories = str_replace(
25 PluginManager::$PLUGINS_PATH . '/',
26 '',
27 glob(PluginManager::$PLUGINS_PATH . '/*')
28 );
29 $formData = array_filter(
30 $formData,
31 function ($value, string $key) use ($directories) {
32 return startsWith($key, 'order') || in_array($key, $directories);
33 },
34 ARRAY_FILTER_USE_BOTH
35 );
36
22 // Make sure there are no duplicates in orders. 37 // Make sure there are no duplicates in orders.
23 if (!validate_plugin_order($formData)) { 38 if (!validate_plugin_order($formData)) {
24 throw new PluginConfigOrderException(); 39 throw new PluginConfigOrderException();
@@ -69,7 +84,7 @@ function validate_plugin_order($formData)
69 $orders = array(); 84 $orders = array();
70 foreach ($formData as $key => $value) { 85 foreach ($formData as $key => $value) {
71 // No duplicate order allowed. 86 // No duplicate order allowed.
72 if (in_array($value, $orders)) { 87 if (in_array($value, $orders, true)) {
73 return false; 88 return false;
74 } 89 }
75 90
diff --git a/application/container/ContainerBuilder.php b/application/container/ContainerBuilder.php
index e2c78ccc..d84418ad 100644
--- a/application/container/ContainerBuilder.php
+++ b/application/container/ContainerBuilder.php
@@ -4,14 +4,28 @@ declare(strict_types=1);
4 4
5namespace Shaarli\Container; 5namespace Shaarli\Container;
6 6
7use malkusch\lock\mutex\FlockMutex;
8use Psr\Log\LoggerInterface;
7use Shaarli\Bookmark\BookmarkFileService; 9use Shaarli\Bookmark\BookmarkFileService;
8use Shaarli\Bookmark\BookmarkServiceInterface; 10use Shaarli\Bookmark\BookmarkServiceInterface;
9use Shaarli\Config\ConfigManager; 11use Shaarli\Config\ConfigManager;
12use Shaarli\Feed\FeedBuilder;
13use Shaarli\Formatter\FormatterFactory;
14use Shaarli\Front\Controller\Visitor\ErrorController;
15use Shaarli\Front\Controller\Visitor\ErrorNotFoundController;
10use Shaarli\History; 16use Shaarli\History;
17use Shaarli\Http\HttpAccess;
18use Shaarli\Http\MetadataRetriever;
19use Shaarli\Netscape\NetscapeBookmarkUtils;
11use Shaarli\Plugin\PluginManager; 20use Shaarli\Plugin\PluginManager;
12use Shaarli\Render\PageBuilder; 21use Shaarli\Render\PageBuilder;
22use Shaarli\Render\PageCacheManager;
23use Shaarli\Security\CookieManager;
13use Shaarli\Security\LoginManager; 24use Shaarli\Security\LoginManager;
14use Shaarli\Security\SessionManager; 25use Shaarli\Security\SessionManager;
26use Shaarli\Thumbnailer;
27use Shaarli\Updater\Updater;
28use Shaarli\Updater\UpdaterUtils;
15 29
16/** 30/**
17 * Class ContainerBuilder 31 * Class ContainerBuilder
@@ -30,22 +44,43 @@ class ContainerBuilder
30 /** @var SessionManager */ 44 /** @var SessionManager */
31 protected $session; 45 protected $session;
32 46
47 /** @var CookieManager */
48 protected $cookieManager;
49
33 /** @var LoginManager */ 50 /** @var LoginManager */
34 protected $login; 51 protected $login;
35 52
36 public function __construct(ConfigManager $conf, SessionManager $session, LoginManager $login) 53 /** @var LoggerInterface */
37 { 54 protected $logger;
55
56 /** @var string|null */
57 protected $basePath = null;
58
59 public function __construct(
60 ConfigManager $conf,
61 SessionManager $session,
62 CookieManager $cookieManager,
63 LoginManager $login,
64 LoggerInterface $logger
65 ) {
38 $this->conf = $conf; 66 $this->conf = $conf;
39 $this->session = $session; 67 $this->session = $session;
40 $this->login = $login; 68 $this->login = $login;
69 $this->cookieManager = $cookieManager;
70 $this->logger = $logger;
41 } 71 }
42 72
43 public function build(): ShaarliContainer 73 public function build(): ShaarliContainer
44 { 74 {
45 $container = new ShaarliContainer(); 75 $container = new ShaarliContainer();
76
46 $container['conf'] = $this->conf; 77 $container['conf'] = $this->conf;
47 $container['sessionManager'] = $this->session; 78 $container['sessionManager'] = $this->session;
79 $container['cookieManager'] = $this->cookieManager;
48 $container['loginManager'] = $this->login; 80 $container['loginManager'] = $this->login;
81 $container['logger'] = $this->logger;
82 $container['basePath'] = $this->basePath;
83
49 $container['plugins'] = function (ShaarliContainer $container): PluginManager { 84 $container['plugins'] = function (ShaarliContainer $container): PluginManager {
50 return new PluginManager($container->conf); 85 return new PluginManager($container->conf);
51 }; 86 };
@@ -58,14 +93,20 @@ class ContainerBuilder
58 return new BookmarkFileService( 93 return new BookmarkFileService(
59 $container->conf, 94 $container->conf,
60 $container->history, 95 $container->history,
96 new FlockMutex(fopen(SHAARLI_MUTEX_FILE, 'r'), 2),
61 $container->loginManager->isLoggedIn() 97 $container->loginManager->isLoggedIn()
62 ); 98 );
63 }; 99 };
64 100
101 $container['metadataRetriever'] = function (ShaarliContainer $container): MetadataRetriever {
102 return new MetadataRetriever($container->conf, $container->httpAccess);
103 };
104
65 $container['pageBuilder'] = function (ShaarliContainer $container): PageBuilder { 105 $container['pageBuilder'] = function (ShaarliContainer $container): PageBuilder {
66 return new PageBuilder( 106 return new PageBuilder(
67 $container->conf, 107 $container->conf,
68 $container->sessionManager->getSession(), 108 $container->sessionManager->getSession(),
109 $container->logger,
69 $container->bookmarkService, 110 $container->bookmarkService,
70 $container->sessionManager->generateToken(), 111 $container->sessionManager->generateToken(),
71 $container->loginManager->isLoggedIn() 112 $container->loginManager->isLoggedIn()
@@ -73,7 +114,65 @@ class ContainerBuilder
73 }; 114 };
74 115
75 $container['pluginManager'] = function (ShaarliContainer $container): PluginManager { 116 $container['pluginManager'] = function (ShaarliContainer $container): PluginManager {
76 return new PluginManager($container->conf); 117 $pluginManager = new PluginManager($container->conf);
118
119 $pluginManager->load($container->conf->get('general.enabled_plugins'));
120
121 return $pluginManager;
122 };
123
124 $container['formatterFactory'] = function (ShaarliContainer $container): FormatterFactory {
125 return new FormatterFactory(
126 $container->conf,
127 $container->loginManager->isLoggedIn()
128 );
129 };
130
131 $container['pageCacheManager'] = function (ShaarliContainer $container): PageCacheManager {
132 return new PageCacheManager(
133 $container->conf->get('resource.page_cache'),
134 $container->loginManager->isLoggedIn()
135 );
136 };
137
138 $container['feedBuilder'] = function (ShaarliContainer $container): FeedBuilder {
139 return new FeedBuilder(
140 $container->bookmarkService,
141 $container->formatterFactory->getFormatter(),
142 $container->environment,
143 $container->loginManager->isLoggedIn()
144 );
145 };
146
147 $container['thumbnailer'] = function (ShaarliContainer $container): Thumbnailer {
148 return new Thumbnailer($container->conf);
149 };
150
151 $container['httpAccess'] = function (): HttpAccess {
152 return new HttpAccess();
153 };
154
155 $container['netscapeBookmarkUtils'] = function (ShaarliContainer $container): NetscapeBookmarkUtils {
156 return new NetscapeBookmarkUtils($container->bookmarkService, $container->conf, $container->history);
157 };
158
159 $container['updater'] = function (ShaarliContainer $container): Updater {
160 return new Updater(
161 UpdaterUtils::read_updates_file($container->conf->get('resource.updates')),
162 $container->bookmarkService,
163 $container->conf,
164 $container->loginManager->isLoggedIn()
165 );
166 };
167
168 $container['notFoundHandler'] = function (ShaarliContainer $container): ErrorNotFoundController {
169 return new ErrorNotFoundController($container);
170 };
171 $container['errorHandler'] = function (ShaarliContainer $container): ErrorController {
172 return new ErrorController($container);
173 };
174 $container['phpErrorHandler'] = function (ShaarliContainer $container): ErrorController {
175 return new ErrorController($container);
77 }; 176 };
78 177
79 return $container; 178 return $container;
diff --git a/application/container/ShaarliContainer.php b/application/container/ShaarliContainer.php
index 3fa9116e..3e5bd252 100644
--- a/application/container/ShaarliContainer.php
+++ b/application/container/ShaarliContainer.php
@@ -4,25 +4,50 @@ declare(strict_types=1);
4 4
5namespace Shaarli\Container; 5namespace Shaarli\Container;
6 6
7use Psr\Log\LoggerInterface;
7use Shaarli\Bookmark\BookmarkServiceInterface; 8use Shaarli\Bookmark\BookmarkServiceInterface;
8use Shaarli\Config\ConfigManager; 9use Shaarli\Config\ConfigManager;
10use Shaarli\Feed\FeedBuilder;
11use Shaarli\Formatter\FormatterFactory;
9use Shaarli\History; 12use Shaarli\History;
13use Shaarli\Http\HttpAccess;
14use Shaarli\Http\MetadataRetriever;
15use Shaarli\Netscape\NetscapeBookmarkUtils;
10use Shaarli\Plugin\PluginManager; 16use Shaarli\Plugin\PluginManager;
11use Shaarli\Render\PageBuilder; 17use Shaarli\Render\PageBuilder;
18use Shaarli\Render\PageCacheManager;
19use Shaarli\Security\CookieManager;
12use Shaarli\Security\LoginManager; 20use Shaarli\Security\LoginManager;
13use Shaarli\Security\SessionManager; 21use Shaarli\Security\SessionManager;
22use Shaarli\Thumbnailer;
23use Shaarli\Updater\Updater;
14use Slim\Container; 24use Slim\Container;
15 25
16/** 26/**
17 * Extension of Slim container to document the injected objects. 27 * Extension of Slim container to document the injected objects.
18 * 28 *
29 * @property string $basePath Shaarli's instance base path (e.g. `/shaarli/`)
30 * @property BookmarkServiceInterface $bookmarkService
31 * @property CookieManager $cookieManager
19 * @property ConfigManager $conf 32 * @property ConfigManager $conf
20 * @property SessionManager $sessionManager 33 * @property mixed[] $environment $_SERVER automatically injected by Slim
21 * @property LoginManager $loginManager 34 * @property callable $errorHandler Overrides default Slim exception display
35 * @property FeedBuilder $feedBuilder
36 * @property FormatterFactory $formatterFactory
22 * @property History $history 37 * @property History $history
23 * @property BookmarkServiceInterface $bookmarkService 38 * @property HttpAccess $httpAccess
39 * @property LoginManager $loginManager
40 * @property LoggerInterface $logger
41 * @property MetadataRetriever $metadataRetriever
42 * @property NetscapeBookmarkUtils $netscapeBookmarkUtils
43 * @property callable $notFoundHandler Overrides default Slim exception display
24 * @property PageBuilder $pageBuilder 44 * @property PageBuilder $pageBuilder
45 * @property PageCacheManager $pageCacheManager
46 * @property callable $phpErrorHandler Overrides default Slim PHP error display
25 * @property PluginManager $pluginManager 47 * @property PluginManager $pluginManager
48 * @property SessionManager $sessionManager
49 * @property Thumbnailer $thumbnailer
50 * @property Updater $updater
26 */ 51 */
27class ShaarliContainer extends Container 52class ShaarliContainer extends Container
28{ 53{
diff --git a/application/feed/Cache.php b/application/feed/Cache.php
deleted file mode 100644
index e5d43e61..00000000
--- a/application/feed/Cache.php
+++ /dev/null
@@ -1,38 +0,0 @@
1<?php
2/**
3 * Cache utilities
4 */
5
6/**
7 * Purges all cached pages
8 *
9 * @param string $pageCacheDir page cache directory
10 *
11 * @return mixed an error string if the directory is missing
12 */
13function purgeCachedPages($pageCacheDir)
14{
15 if (! is_dir($pageCacheDir)) {
16 $error = sprintf(t('Cannot purge %s: no directory'), $pageCacheDir);
17 error_log($error);
18 return $error;
19 }
20
21 array_map('unlink', glob($pageCacheDir.'/*.cache'));
22}
23
24/**
25 * Invalidates caches when the database is changed or the user logs out.
26 *
27 * @param string $pageCacheDir page cache directory
28 */
29function invalidateCaches($pageCacheDir)
30{
31 // Purge cache attached to session.
32 if (isset($_SESSION['tags'])) {
33 unset($_SESSION['tags']);
34 }
35
36 // Purge page cache shared by sessions.
37 purgeCachedPages($pageCacheDir);
38}
diff --git a/application/feed/FeedBuilder.php b/application/feed/FeedBuilder.php
index 40bd4f15..f70fce4f 100644
--- a/application/feed/FeedBuilder.php
+++ b/application/feed/FeedBuilder.php
@@ -43,22 +43,10 @@ class FeedBuilder
43 */ 43 */
44 protected $formatter; 44 protected $formatter;
45 45
46 /** 46 /** @var mixed[] $_SERVER */
47 * @var string RSS or ATOM feed.
48 */
49 protected $feedType;
50
51 /**
52 * @var array $_SERVER
53 */
54 protected $serverInfo; 47 protected $serverInfo;
55 48
56 /** 49 /**
57 * @var array $_GET
58 */
59 protected $userInput;
60
61 /**
62 * @var boolean True if the user is currently logged in, false otherwise. 50 * @var boolean True if the user is currently logged in, false otherwise.
63 */ 51 */
64 protected $isLoggedIn; 52 protected $isLoggedIn;
@@ -77,7 +65,6 @@ class FeedBuilder
77 * @var string server locale. 65 * @var string server locale.
78 */ 66 */
79 protected $locale; 67 protected $locale;
80
81 /** 68 /**
82 * @var DateTime Latest item date. 69 * @var DateTime Latest item date.
83 */ 70 */
@@ -88,37 +75,36 @@ class FeedBuilder
88 * 75 *
89 * @param BookmarkServiceInterface $linkDB LinkDB instance. 76 * @param BookmarkServiceInterface $linkDB LinkDB instance.
90 * @param BookmarkFormatter $formatter instance. 77 * @param BookmarkFormatter $formatter instance.
91 * @param string $feedType Type of feed.
92 * @param array $serverInfo $_SERVER. 78 * @param array $serverInfo $_SERVER.
93 * @param array $userInput $_GET.
94 * @param boolean $isLoggedIn True if the user is currently logged in, false otherwise. 79 * @param boolean $isLoggedIn True if the user is currently logged in, false otherwise.
95 */ 80 */
96 public function __construct($linkDB, $formatter, $feedType, $serverInfo, $userInput, $isLoggedIn) 81 public function __construct($linkDB, $formatter, $serverInfo, $isLoggedIn)
97 { 82 {
98 $this->linkDB = $linkDB; 83 $this->linkDB = $linkDB;
99 $this->formatter = $formatter; 84 $this->formatter = $formatter;
100 $this->feedType = $feedType;
101 $this->serverInfo = $serverInfo; 85 $this->serverInfo = $serverInfo;
102 $this->userInput = $userInput;
103 $this->isLoggedIn = $isLoggedIn; 86 $this->isLoggedIn = $isLoggedIn;
104 } 87 }
105 88
106 /** 89 /**
107 * Build data for feed templates. 90 * Build data for feed templates.
108 * 91 *
92 * @param string $feedType Type of feed (RSS/ATOM).
93 * @param array $userInput $_GET.
94 *
109 * @return array Formatted data for feeds templates. 95 * @return array Formatted data for feeds templates.
110 */ 96 */
111 public function buildData() 97 public function buildData(string $feedType, ?array $userInput)
112 { 98 {
113 // Search for untagged bookmarks 99 // Search for untagged bookmarks
114 if (isset($this->userInput['searchtags']) && empty($this->userInput['searchtags'])) { 100 if (isset($this->userInput['searchtags']) && empty($userInput['searchtags'])) {
115 $this->userInput['searchtags'] = false; 101 $userInput['searchtags'] = false;
116 } 102 }
117 103
118 // Optionally filter the results: 104 // Optionally filter the results:
119 $linksToDisplay = $this->linkDB->search($this->userInput); 105 $linksToDisplay = $this->linkDB->search($userInput ?? [], null, false, false, true);
120 106
121 $nblinksToDisplay = $this->getNbLinks(count($linksToDisplay)); 107 $nblinksToDisplay = $this->getNbLinks(count($linksToDisplay), $userInput);
122 108
123 // Can't use array_keys() because $link is a LinkDB instance and not a real array. 109 // Can't use array_keys() because $link is a LinkDB instance and not a real array.
124 $keys = array(); 110 $keys = array();
@@ -130,15 +116,15 @@ class FeedBuilder
130 $this->formatter->addContextData('index_url', $pageaddr); 116 $this->formatter->addContextData('index_url', $pageaddr);
131 $linkDisplayed = array(); 117 $linkDisplayed = array();
132 for ($i = 0; $i < $nblinksToDisplay && $i < count($keys); $i++) { 118 for ($i = 0; $i < $nblinksToDisplay && $i < count($keys); $i++) {
133 $linkDisplayed[$keys[$i]] = $this->buildItem($linksToDisplay[$keys[$i]], $pageaddr); 119 $linkDisplayed[$keys[$i]] = $this->buildItem($feedType, $linksToDisplay[$keys[$i]], $pageaddr);
134 } 120 }
135 121
136 $data['language'] = $this->getTypeLanguage(); 122 $data['language'] = $this->getTypeLanguage($feedType);
137 $data['last_update'] = $this->getLatestDateFormatted(); 123 $data['last_update'] = $this->getLatestDateFormatted($feedType);
138 $data['show_dates'] = !$this->hideDates || $this->isLoggedIn; 124 $data['show_dates'] = !$this->hideDates || $this->isLoggedIn;
139 // Remove leading slash from REQUEST_URI. 125 // Remove leading path from REQUEST_URI (already contained in $pageaddr).
140 $data['self_link'] = escape(server_url($this->serverInfo)) 126 $requestUri = preg_replace('#(.*?/)(feed.*)#', '$2', escape($this->serverInfo['REQUEST_URI']));
141 . escape($this->serverInfo['REQUEST_URI']); 127 $data['self_link'] = $pageaddr . $requestUri;
142 $data['index_url'] = $pageaddr; 128 $data['index_url'] = $pageaddr;
143 $data['usepermalinks'] = $this->usePermalinks === true; 129 $data['usepermalinks'] = $this->usePermalinks === true;
144 $data['links'] = $linkDisplayed; 130 $data['links'] = $linkDisplayed;
@@ -147,17 +133,48 @@ class FeedBuilder
147 } 133 }
148 134
149 /** 135 /**
136 * Set this to true to use permalinks instead of direct bookmarks.
137 *
138 * @param boolean $usePermalinks true to force permalinks.
139 */
140 public function setUsePermalinks($usePermalinks)
141 {
142 $this->usePermalinks = $usePermalinks;
143 }
144
145 /**
146 * Set this to true to hide timestamps in feeds.
147 *
148 * @param boolean $hideDates true to enable.
149 */
150 public function setHideDates($hideDates)
151 {
152 $this->hideDates = $hideDates;
153 }
154
155 /**
156 * Set the locale. Used to show feed language.
157 *
158 * @param string $locale The locale (eg. 'fr_FR.UTF8').
159 */
160 public function setLocale($locale)
161 {
162 $this->locale = strtolower($locale);
163 }
164
165 /**
150 * Build a feed item (one per shaare). 166 * Build a feed item (one per shaare).
151 * 167 *
168 * @param string $feedType Type of feed (RSS/ATOM).
152 * @param Bookmark $link Single link array extracted from LinkDB. 169 * @param Bookmark $link Single link array extracted from LinkDB.
153 * @param string $pageaddr Index URL. 170 * @param string $pageaddr Index URL.
154 * 171 *
155 * @return array Link array with feed attributes. 172 * @return array Link array with feed attributes.
156 */ 173 */
157 protected function buildItem($link, $pageaddr) 174 protected function buildItem(string $feedType, $link, $pageaddr)
158 { 175 {
159 $data = $this->formatter->format($link); 176 $data = $this->formatter->format($link);
160 $data['guid'] = $pageaddr . '?' . $data['shorturl']; 177 $data['guid'] = rtrim($pageaddr, '/') . '/shaare/' . $data['shorturl'];
161 if ($this->usePermalinks === true) { 178 if ($this->usePermalinks === true) {
162 $permalink = '<a href="'. $data['url'] .'" title="'. t('Direct link') .'">'. t('Direct link') .'</a>'; 179 $permalink = '<a href="'. $data['url'] .'" title="'. t('Direct link') .'">'. t('Direct link') .'</a>';
163 } else { 180 } else {
@@ -165,13 +182,13 @@ class FeedBuilder
165 } 182 }
166 $data['description'] .= PHP_EOL . PHP_EOL . '<br>&#8212; ' . $permalink; 183 $data['description'] .= PHP_EOL . PHP_EOL . '<br>&#8212; ' . $permalink;
167 184
168 $data['pub_iso_date'] = $this->getIsoDate($data['created']); 185 $data['pub_iso_date'] = $this->getIsoDate($feedType, $data['created']);
169 186
170 // atom:entry elements MUST contain exactly one atom:updated element. 187 // atom:entry elements MUST contain exactly one atom:updated element.
171 if (!empty($link->getUpdated())) { 188 if (!empty($link->getUpdated())) {
172 $data['up_iso_date'] = $this->getIsoDate($data['updated'], DateTime::ATOM); 189 $data['up_iso_date'] = $this->getIsoDate($feedType, $data['updated'], DateTime::ATOM);
173 } else { 190 } else {
174 $data['up_iso_date'] = $this->getIsoDate($data['created'], DateTime::ATOM); 191 $data['up_iso_date'] = $this->getIsoDate($feedType, $data['created'], DateTime::ATOM);
175 } 192 }
176 193
177 // Save the more recent item. 194 // Save the more recent item.
@@ -186,51 +203,23 @@ class FeedBuilder
186 } 203 }
187 204
188 /** 205 /**
189 * Set this to true to use permalinks instead of direct bookmarks.
190 *
191 * @param boolean $usePermalinks true to force permalinks.
192 */
193 public function setUsePermalinks($usePermalinks)
194 {
195 $this->usePermalinks = $usePermalinks;
196 }
197
198 /**
199 * Set this to true to hide timestamps in feeds.
200 *
201 * @param boolean $hideDates true to enable.
202 */
203 public function setHideDates($hideDates)
204 {
205 $this->hideDates = $hideDates;
206 }
207
208 /**
209 * Set the locale. Used to show feed language.
210 *
211 * @param string $locale The locale (eg. 'fr_FR.UTF8').
212 */
213 public function setLocale($locale)
214 {
215 $this->locale = strtolower($locale);
216 }
217
218 /**
219 * Get the language according to the feed type, based on the locale: 206 * Get the language according to the feed type, based on the locale:
220 * 207 *
221 * - RSS format: en-us (default: 'en-en'). 208 * - RSS format: en-us (default: 'en-en').
222 * - ATOM format: fr (default: 'en'). 209 * - ATOM format: fr (default: 'en').
223 * 210 *
211 * @param string $feedType Type of feed (RSS/ATOM).
212 *
224 * @return string The language. 213 * @return string The language.
225 */ 214 */
226 public function getTypeLanguage() 215 protected function getTypeLanguage(string $feedType)
227 { 216 {
228 // Use the locale do define the language, if available. 217 // Use the locale do define the language, if available.
229 if (!empty($this->locale) && preg_match('/^\w{2}[_\-]\w{2}/', $this->locale)) { 218 if (!empty($this->locale) && preg_match('/^\w{2}[_\-]\w{2}/', $this->locale)) {
230 $length = ($this->feedType === self::$FEED_RSS) ? 5 : 2; 219 $length = ($feedType === self::$FEED_RSS) ? 5 : 2;
231 return str_replace('_', '-', substr($this->locale, 0, $length)); 220 return str_replace('_', '-', substr($this->locale, 0, $length));
232 } 221 }
233 return ($this->feedType === self::$FEED_RSS) ? 'en-en' : 'en'; 222 return ($feedType === self::$FEED_RSS) ? 'en-en' : 'en';
234 } 223 }
235 224
236 /** 225 /**
@@ -238,32 +227,35 @@ class FeedBuilder
238 * 227 *
239 * Return an empty string if invalid DateTime is passed. 228 * Return an empty string if invalid DateTime is passed.
240 * 229 *
230 * @param string $feedType Type of feed (RSS/ATOM).
231 *
241 * @return string Formatted date. 232 * @return string Formatted date.
242 */ 233 */
243 protected function getLatestDateFormatted() 234 protected function getLatestDateFormatted(string $feedType)
244 { 235 {
245 if (empty($this->latestDate) || !$this->latestDate instanceof DateTime) { 236 if (empty($this->latestDate) || !$this->latestDate instanceof DateTime) {
246 return ''; 237 return '';
247 } 238 }
248 239
249 $type = ($this->feedType == self::$FEED_RSS) ? DateTime::RSS : DateTime::ATOM; 240 $type = ($feedType == self::$FEED_RSS) ? DateTime::RSS : DateTime::ATOM;
250 return $this->latestDate->format($type); 241 return $this->latestDate->format($type);
251 } 242 }
252 243
253 /** 244 /**
254 * Get ISO date from DateTime according to feed type. 245 * Get ISO date from DateTime according to feed type.
255 * 246 *
247 * @param string $feedType Type of feed (RSS/ATOM).
256 * @param DateTime $date Date to format. 248 * @param DateTime $date Date to format.
257 * @param string|bool $format Force format. 249 * @param string|bool $format Force format.
258 * 250 *
259 * @return string Formatted date. 251 * @return string Formatted date.
260 */ 252 */
261 protected function getIsoDate(DateTime $date, $format = false) 253 protected function getIsoDate(string $feedType, DateTime $date, $format = false)
262 { 254 {
263 if ($format !== false) { 255 if ($format !== false) {
264 return $date->format($format); 256 return $date->format($format);
265 } 257 }
266 if ($this->feedType == self::$FEED_RSS) { 258 if ($feedType == self::$FEED_RSS) {
267 return $date->format(DateTime::RSS); 259 return $date->format(DateTime::RSS);
268 } 260 }
269 return $date->format(DateTime::ATOM); 261 return $date->format(DateTime::ATOM);
@@ -275,21 +267,22 @@ class FeedBuilder
275 * If 'nb' not set or invalid, default value: $DEFAULT_NB_LINKS. 267 * If 'nb' not set or invalid, default value: $DEFAULT_NB_LINKS.
276 * If 'nb' is set to 'all', display all filtered bookmarks (max parameter). 268 * If 'nb' is set to 'all', display all filtered bookmarks (max parameter).
277 * 269 *
278 * @param int $max maximum number of bookmarks to display. 270 * @param int $max maximum number of bookmarks to display.
271 * @param array $userInput $_GET.
279 * 272 *
280 * @return int number of bookmarks to display. 273 * @return int number of bookmarks to display.
281 */ 274 */
282 public function getNbLinks($max) 275 protected function getNbLinks($max, ?array $userInput)
283 { 276 {
284 if (empty($this->userInput['nb'])) { 277 if (empty($userInput['nb'])) {
285 return self::$DEFAULT_NB_LINKS; 278 return self::$DEFAULT_NB_LINKS;
286 } 279 }
287 280
288 if ($this->userInput['nb'] == 'all') { 281 if ($userInput['nb'] == 'all') {
289 return $max; 282 return $max;
290 } 283 }
291 284
292 $intNb = intval($this->userInput['nb']); 285 $intNb = intval($userInput['nb']);
293 if (!is_int($intNb) || $intNb == 0) { 286 if (!is_int($intNb) || $intNb == 0) {
294 return self::$DEFAULT_NB_LINKS; 287 return self::$DEFAULT_NB_LINKS;
295 } 288 }
diff --git a/application/formatter/BookmarkDefaultFormatter.php b/application/formatter/BookmarkDefaultFormatter.php
index c6c59064..d58a5e39 100644
--- a/application/formatter/BookmarkDefaultFormatter.php
+++ b/application/formatter/BookmarkDefaultFormatter.php
@@ -12,10 +12,13 @@ namespace Shaarli\Formatter;
12 */ 12 */
13class BookmarkDefaultFormatter extends BookmarkFormatter 13class BookmarkDefaultFormatter extends BookmarkFormatter
14{ 14{
15 const SEARCH_HIGHLIGHT_OPEN = '|@@HIGHLIGHT';
16 const SEARCH_HIGHLIGHT_CLOSE = 'HIGHLIGHT@@|';
17
15 /** 18 /**
16 * @inheritdoc 19 * @inheritdoc
17 */ 20 */
18 public function formatTitle($bookmark) 21 protected function formatTitle($bookmark)
19 { 22 {
20 return escape($bookmark->getTitle()); 23 return escape($bookmark->getTitle());
21 } 24 }
@@ -23,10 +26,28 @@ class BookmarkDefaultFormatter extends BookmarkFormatter
23 /** 26 /**
24 * @inheritdoc 27 * @inheritdoc
25 */ 28 */
26 public function formatDescription($bookmark) 29 protected function formatTitleHtml($bookmark)
30 {
31 $title = $this->tokenizeSearchHighlightField(
32 $bookmark->getTitle() ?? '',
33 $bookmark->getAdditionalContentEntry('search_highlight')['title'] ?? []
34 );
35
36 return $this->replaceTokens(escape($title));
37 }
38
39 /**
40 * @inheritdoc
41 */
42 protected function formatDescription($bookmark)
27 { 43 {
28 $indexUrl = ! empty($this->contextData['index_url']) ? $this->contextData['index_url'] : ''; 44 $indexUrl = ! empty($this->contextData['index_url']) ? $this->contextData['index_url'] : '';
29 return format_description(escape($bookmark->getDescription()), $indexUrl); 45 $description = $this->tokenizeSearchHighlightField(
46 $bookmark->getDescription() ?? '',
47 $bookmark->getAdditionalContentEntry('search_highlight')['description'] ?? []
48 );
49
50 return $this->replaceTokens(format_description(escape($description), $indexUrl));
30 } 51 }
31 52
32 /** 53 /**
@@ -40,7 +61,27 @@ class BookmarkDefaultFormatter extends BookmarkFormatter
40 /** 61 /**
41 * @inheritdoc 62 * @inheritdoc
42 */ 63 */
43 public function formatTagString($bookmark) 64 protected function formatTagListHtml($bookmark)
65 {
66 if (empty($bookmark->getAdditionalContentEntry('search_highlight')['tags'])) {
67 return $this->formatTagList($bookmark);
68 }
69
70 $tags = $this->tokenizeSearchHighlightField(
71 $bookmark->getTagsString(),
72 $bookmark->getAdditionalContentEntry('search_highlight')['tags']
73 );
74 $tags = $this->filterTagList(explode(' ', $tags));
75 $tags = escape($tags);
76 $tags = $this->replaceTokensArray($tags);
77
78 return $tags;
79 }
80
81 /**
82 * @inheritdoc
83 */
84 protected function formatTagString($bookmark)
44 { 85 {
45 return implode(' ', $this->formatTagList($bookmark)); 86 return implode(' ', $this->formatTagList($bookmark));
46 } 87 }
@@ -48,13 +89,12 @@ class BookmarkDefaultFormatter extends BookmarkFormatter
48 /** 89 /**
49 * @inheritdoc 90 * @inheritdoc
50 */ 91 */
51 public function formatUrl($bookmark) 92 protected function formatUrl($bookmark)
52 { 93 {
53 if (! empty($this->contextData['index_url']) && ( 94 if ($bookmark->isNote() && isset($this->contextData['index_url'])) {
54 startsWith($bookmark->getUrl(), '?') || startsWith($bookmark->getUrl(), '/') 95 return rtrim($this->contextData['index_url'], '/') . '/' . escape(ltrim($bookmark->getUrl(), '/'));
55 )) {
56 return $this->contextData['index_url'] . escape($bookmark->getUrl());
57 } 96 }
97
58 return escape($bookmark->getUrl()); 98 return escape($bookmark->getUrl());
59 } 99 }
60 100
@@ -63,19 +103,107 @@ class BookmarkDefaultFormatter extends BookmarkFormatter
63 */ 103 */
64 protected function formatRealUrl($bookmark) 104 protected function formatRealUrl($bookmark)
65 { 105 {
66 if (! empty($this->contextData['index_url']) && ( 106 if ($bookmark->isNote()) {
67 startsWith($bookmark->getUrl(), '?') || startsWith($bookmark->getUrl(), '/') 107 if (isset($this->contextData['index_url'])) {
68 )) { 108 $prefix = rtrim($this->contextData['index_url'], '/') . '/';
69 return $this->contextData['index_url'] . escape($bookmark->getUrl()); 109 }
110
111 if (isset($this->contextData['base_path'])) {
112 $prefix = rtrim($this->contextData['base_path'], '/') . '/';
113 }
114
115 return escape($prefix ?? '') . escape(ltrim($bookmark->getUrl(), '/'));
70 } 116 }
117
71 return escape($bookmark->getUrl()); 118 return escape($bookmark->getUrl());
72 } 119 }
73 120
74 /** 121 /**
75 * @inheritdoc 122 * @inheritdoc
76 */ 123 */
124 protected function formatUrlHtml($bookmark)
125 {
126 $url = $this->tokenizeSearchHighlightField(
127 $bookmark->getUrl() ?? '',
128 $bookmark->getAdditionalContentEntry('search_highlight')['url'] ?? []
129 );
130
131 return $this->replaceTokens(escape($url));
132 }
133
134 /**
135 * @inheritdoc
136 */
77 protected function formatThumbnail($bookmark) 137 protected function formatThumbnail($bookmark)
78 { 138 {
79 return escape($bookmark->getThumbnail()); 139 return escape($bookmark->getThumbnail());
80 } 140 }
141
142 /**
143 * Insert search highlight token in provided field content based on a list of search result positions
144 *
145 * @param string $fieldContent
146 * @param array|null $positions List of of search results with 'start' and 'end' positions.
147 *
148 * @return string Updated $fieldContent.
149 */
150 protected function tokenizeSearchHighlightField(string $fieldContent, ?array $positions): string
151 {
152 if (empty($positions)) {
153 return $fieldContent;
154 }
155
156 $insertedTokens = 0;
157 $tokenLength = strlen(static::SEARCH_HIGHLIGHT_OPEN);
158 foreach ($positions as $position) {
159 $position = [
160 'start' => $position['start'] + ($insertedTokens * $tokenLength),
161 'end' => $position['end'] + ($insertedTokens * $tokenLength),
162 ];
163
164 $content = mb_substr($fieldContent, 0, $position['start']);
165 $content .= static::SEARCH_HIGHLIGHT_OPEN;
166 $content .= mb_substr($fieldContent, $position['start'], $position['end'] - $position['start']);
167 $content .= static::SEARCH_HIGHLIGHT_CLOSE;
168 $content .= mb_substr($fieldContent, $position['end']);
169
170 $fieldContent = $content;
171
172 $insertedTokens += 2;
173 }
174
175 return $fieldContent;
176 }
177
178 /**
179 * Replace search highlight tokens with HTML highlighted span.
180 *
181 * @param string $fieldContent
182 *
183 * @return string updated content.
184 */
185 protected function replaceTokens(string $fieldContent): string
186 {
187 return str_replace(
188 [static::SEARCH_HIGHLIGHT_OPEN, static::SEARCH_HIGHLIGHT_CLOSE],
189 ['<span class="search-highlight">', '</span>'],
190 $fieldContent
191 );
192 }
193
194 /**
195 * Apply replaceTokens to an array of content strings.
196 *
197 * @param string[] $fieldContents
198 *
199 * @return array
200 */
201 protected function replaceTokensArray(array $fieldContents): array
202 {
203 foreach ($fieldContents as &$entry) {
204 $entry = $this->replaceTokens($entry);
205 }
206
207 return $fieldContents;
208 }
81} 209}
diff --git a/application/formatter/BookmarkFormatter.php b/application/formatter/BookmarkFormatter.php
index a80d83fc..e1b7f705 100644
--- a/application/formatter/BookmarkFormatter.php
+++ b/application/formatter/BookmarkFormatter.php
@@ -2,15 +2,38 @@
2 2
3namespace Shaarli\Formatter; 3namespace Shaarli\Formatter;
4 4
5use DateTime; 5use DateTimeInterface;
6use Shaarli\Config\ConfigManager;
7use Shaarli\Bookmark\Bookmark; 6use Shaarli\Bookmark\Bookmark;
7use Shaarli\Config\ConfigManager;
8 8
9/** 9/**
10 * Class BookmarkFormatter 10 * Class BookmarkFormatter
11 * 11 *
12 * Abstract class processing all bookmark attributes through methods designed to be overridden. 12 * Abstract class processing all bookmark attributes through methods designed to be overridden.
13 * 13 *
14 * List of available formatted fields:
15 * - id ID
16 * - shorturl Unique identifier, used in permalinks
17 * - url URL, can be altered in some way, e.g. passing through an HTTP reverse proxy
18 * - real_url (legacy) same as `url`
19 * - url_html URL to be displayed in HTML content (it can contain HTML tags)
20 * - title Title
21 * - title_html Title to be displayed in HTML content (it can contain HTML tags)
22 * - description Description content. It most likely contains HTML tags
23 * - thumbnail Thumbnail: path to local cache file, false if there is none, null if hasn't been retrieved
24 * - taglist List of tags (array)
25 * - taglist_urlencoded List of tags (array) URL encoded: it must be used to create a link to a URL containing a tag
26 * - taglist_html List of tags (array) to be displayed in HTML content (it can contain HTML tags)
27 * - tags Tags separated by a single whitespace
28 * - tags_urlencoded Tags separated by a single whitespace, URL encoded: must be used to create a link
29 * - sticky Is sticky (bool)
30 * - private Is private (bool)
31 * - class Additional CSS class
32 * - created Creation DateTime
33 * - updated Last edit DateTime
34 * - timestamp Creation timestamp
35 * - updated_timestamp Last edit timestamp
36 *
14 * @package Shaarli\Formatter 37 * @package Shaarli\Formatter
15 */ 38 */
16abstract class BookmarkFormatter 39abstract class BookmarkFormatter
@@ -55,11 +78,16 @@ abstract class BookmarkFormatter
55 $out['shorturl'] = $this->formatShortUrl($bookmark); 78 $out['shorturl'] = $this->formatShortUrl($bookmark);
56 $out['url'] = $this->formatUrl($bookmark); 79 $out['url'] = $this->formatUrl($bookmark);
57 $out['real_url'] = $this->formatRealUrl($bookmark); 80 $out['real_url'] = $this->formatRealUrl($bookmark);
81 $out['url_html'] = $this->formatUrlHtml($bookmark);
58 $out['title'] = $this->formatTitle($bookmark); 82 $out['title'] = $this->formatTitle($bookmark);
83 $out['title_html'] = $this->formatTitleHtml($bookmark);
59 $out['description'] = $this->formatDescription($bookmark); 84 $out['description'] = $this->formatDescription($bookmark);
60 $out['thumbnail'] = $this->formatThumbnail($bookmark); 85 $out['thumbnail'] = $this->formatThumbnail($bookmark);
61 $out['taglist'] = $this->formatTagList($bookmark); 86 $out['taglist'] = $this->formatTagList($bookmark);
87 $out['taglist_urlencoded'] = $this->formatTagListUrlEncoded($bookmark);
88 $out['taglist_html'] = $this->formatTagListHtml($bookmark);
62 $out['tags'] = $this->formatTagString($bookmark); 89 $out['tags'] = $this->formatTagString($bookmark);
90 $out['tags_urlencoded'] = $this->formatTagStringUrlEncoded($bookmark);
63 $out['sticky'] = $bookmark->isSticky(); 91 $out['sticky'] = $bookmark->isSticky();
64 $out['private'] = $bookmark->isPrivate(); 92 $out['private'] = $bookmark->isPrivate();
65 $out['class'] = $this->formatClass($bookmark); 93 $out['class'] = $this->formatClass($bookmark);
@@ -67,6 +95,7 @@ abstract class BookmarkFormatter
67 $out['updated'] = $this->formatUpdated($bookmark); 95 $out['updated'] = $this->formatUpdated($bookmark);
68 $out['timestamp'] = $this->formatCreatedTimestamp($bookmark); 96 $out['timestamp'] = $this->formatCreatedTimestamp($bookmark);
69 $out['updated_timestamp'] = $this->formatUpdatedTimestamp($bookmark); 97 $out['updated_timestamp'] = $this->formatUpdatedTimestamp($bookmark);
98
70 return $out; 99 return $out;
71 } 100 }
72 101
@@ -80,6 +109,8 @@ abstract class BookmarkFormatter
80 public function addContextData($key, $value) 109 public function addContextData($key, $value)
81 { 110 {
82 $this->contextData[$key] = $value; 111 $this->contextData[$key] = $value;
112
113 return $this;
83 } 114 }
84 115
85 /** 116 /**
@@ -128,7 +159,19 @@ abstract class BookmarkFormatter
128 */ 159 */
129 protected function formatRealUrl($bookmark) 160 protected function formatRealUrl($bookmark)
130 { 161 {
131 return $bookmark->getUrl(); 162 return $this->formatUrl($bookmark);
163 }
164
165 /**
166 * Format Url Html: to be displayed in HTML content, it can contains HTML tags.
167 *
168 * @param Bookmark $bookmark instance
169 *
170 * @return string formatted Url HTML
171 */
172 protected function formatUrlHtml($bookmark)
173 {
174 return $this->formatUrl($bookmark);
132 } 175 }
133 176
134 /** 177 /**
@@ -144,6 +187,18 @@ abstract class BookmarkFormatter
144 } 187 }
145 188
146 /** 189 /**
190 * Format Title HTML: to be displayed in HTML content, it can contains HTML tags.
191 *
192 * @param Bookmark $bookmark instance
193 *
194 * @return string formatted Title
195 */
196 protected function formatTitleHtml($bookmark)
197 {
198 return $bookmark->getTitle();
199 }
200
201 /**
147 * Format Description 202 * Format Description
148 * 203 *
149 * @param Bookmark $bookmark instance 204 * @param Bookmark $bookmark instance
@@ -180,6 +235,30 @@ abstract class BookmarkFormatter
180 } 235 }
181 236
182 /** 237 /**
238 * Format Url Encoded Tags
239 *
240 * @param Bookmark $bookmark instance
241 *
242 * @return array formatted Tags
243 */
244 protected function formatTagListUrlEncoded($bookmark)
245 {
246 return array_map('urlencode', $this->filterTagList($bookmark->getTags()));
247 }
248
249 /**
250 * Format Tags HTML: to be displayed in HTML content, it can contains HTML tags.
251 *
252 * @param Bookmark $bookmark instance
253 *
254 * @return array formatted Tags
255 */
256 protected function formatTagListHtml($bookmark)
257 {
258 return $this->formatTagList($bookmark);
259 }
260
261 /**
183 * Format TagString 262 * Format TagString
184 * 263 *
185 * @param Bookmark $bookmark instance 264 * @param Bookmark $bookmark instance
@@ -192,6 +271,18 @@ abstract class BookmarkFormatter
192 } 271 }
193 272
194 /** 273 /**
274 * Format TagString
275 *
276 * @param Bookmark $bookmark instance
277 *
278 * @return string formatted TagString
279 */
280 protected function formatTagStringUrlEncoded($bookmark)
281 {
282 return implode(' ', $this->formatTagListUrlEncoded($bookmark));
283 }
284
285 /**
195 * Format Class 286 * Format Class
196 * Used to add specific CSS class for a link 287 * Used to add specific CSS class for a link
197 * 288 *
@@ -209,7 +300,7 @@ abstract class BookmarkFormatter
209 * 300 *
210 * @param Bookmark $bookmark instance 301 * @param Bookmark $bookmark instance
211 * 302 *
212 * @return DateTime instance 303 * @return DateTimeInterface instance
213 */ 304 */
214 protected function formatCreated(Bookmark $bookmark) 305 protected function formatCreated(Bookmark $bookmark)
215 { 306 {
@@ -221,7 +312,7 @@ abstract class BookmarkFormatter
221 * 312 *
222 * @param Bookmark $bookmark instance 313 * @param Bookmark $bookmark instance
223 * 314 *
224 * @return DateTime instance 315 * @return DateTimeInterface instance
225 */ 316 */
226 protected function formatUpdated(Bookmark $bookmark) 317 protected function formatUpdated(Bookmark $bookmark)
227 { 318 {
diff --git a/application/formatter/BookmarkMarkdownExtraFormatter.php b/application/formatter/BookmarkMarkdownExtraFormatter.php
new file mode 100644
index 00000000..0694b23f
--- /dev/null
+++ b/application/formatter/BookmarkMarkdownExtraFormatter.php
@@ -0,0 +1,24 @@
1<?php
2
3namespace Shaarli\Formatter;
4
5use Shaarli\Config\ConfigManager;
6
7/**
8 * Class BookmarkMarkdownExtraFormatter
9 *
10 * Format bookmark description into MarkdownExtra format.
11 *
12 * @see https://michelf.ca/projects/php-markdown/extra/
13 *
14 * @package Shaarli\Formatter
15 */
16class BookmarkMarkdownExtraFormatter extends BookmarkMarkdownFormatter
17{
18 public function __construct(ConfigManager $conf, bool $isLoggedIn)
19 {
20 parent::__construct($conf, $isLoggedIn);
21
22 $this->parsedown = new \ParsedownExtra();
23 }
24}
diff --git a/application/formatter/BookmarkMarkdownFormatter.php b/application/formatter/BookmarkMarkdownFormatter.php
index 077e5312..f7714be9 100644
--- a/application/formatter/BookmarkMarkdownFormatter.php
+++ b/application/formatter/BookmarkMarkdownFormatter.php
@@ -56,7 +56,10 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter
56 return parent::formatDescription($bookmark); 56 return parent::formatDescription($bookmark);
57 } 57 }
58 58
59 $processedDescription = $bookmark->getDescription(); 59 $processedDescription = $this->tokenizeSearchHighlightField(
60 $bookmark->getDescription() ?? '',
61 $bookmark->getAdditionalContentEntry('search_highlight')['description'] ?? []
62 );
60 $processedDescription = $this->filterProtocols($processedDescription); 63 $processedDescription = $this->filterProtocols($processedDescription);
61 $processedDescription = $this->formatHashTags($processedDescription); 64 $processedDescription = $this->formatHashTags($processedDescription);
62 $processedDescription = $this->reverseEscapedHtml($processedDescription); 65 $processedDescription = $this->reverseEscapedHtml($processedDescription);
@@ -65,6 +68,7 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter
65 ->setBreaksEnabled(true) 68 ->setBreaksEnabled(true)
66 ->text($processedDescription); 69 ->text($processedDescription);
67 $processedDescription = $this->sanitizeHtml($processedDescription); 70 $processedDescription = $this->sanitizeHtml($processedDescription);
71 $processedDescription = $this->replaceTokens($processedDescription);
68 72
69 if (!empty($processedDescription)) { 73 if (!empty($processedDescription)) {
70 $processedDescription = '<div class="markdown">'. $processedDescription . '</div>'; 74 $processedDescription = '<div class="markdown">'. $processedDescription . '</div>';
@@ -114,7 +118,7 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter
114 118
115 /** 119 /**
116 * Replace hashtag in Markdown links format 120 * Replace hashtag in Markdown links format
117 * E.g. `#hashtag` becomes `[#hashtag](?addtag=hashtag)` 121 * E.g. `#hashtag` becomes `[#hashtag](./add-tag/hashtag)`
118 * It includes the index URL if specified. 122 * It includes the index URL if specified.
119 * 123 *
120 * @param string $description 124 * @param string $description
@@ -133,7 +137,7 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter
133 * \p{Mn} - any non marking space (accents, umlauts, etc) 137 * \p{Mn} - any non marking space (accents, umlauts, etc)
134 */ 138 */
135 $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui'; 139 $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui';
136 $replacement = '$1[#$2]('. $indexUrl .'?addtag=$2)'; 140 $replacement = '$1[#$2]('. $indexUrl .'./add-tag/$2)';
137 141
138 $descriptionLines = explode(PHP_EOL, $description); 142 $descriptionLines = explode(PHP_EOL, $description);
139 $descriptionOut = ''; 143 $descriptionOut = '';
diff --git a/application/formatter/FormatterFactory.php b/application/formatter/FormatterFactory.php
index 5f282f68..a029579f 100644
--- a/application/formatter/FormatterFactory.php
+++ b/application/formatter/FormatterFactory.php
@@ -38,7 +38,7 @@ class FormatterFactory
38 * 38 *
39 * @return BookmarkFormatter instance. 39 * @return BookmarkFormatter instance.
40 */ 40 */
41 public function getFormatter(string $type = null) 41 public function getFormatter(string $type = null): BookmarkFormatter
42 { 42 {
43 $type = $type ? $type : $this->conf->get('formatter', 'default'); 43 $type = $type ? $type : $this->conf->get('formatter', 'default');
44 $className = '\\Shaarli\\Formatter\\Bookmark'. ucfirst($type) .'Formatter'; 44 $className = '\\Shaarli\\Formatter\\Bookmark'. ucfirst($type) .'Formatter';
diff --git a/application/front/ShaarliAdminMiddleware.php b/application/front/ShaarliAdminMiddleware.php
new file mode 100644
index 00000000..35ce4a3b
--- /dev/null
+++ b/application/front/ShaarliAdminMiddleware.php
@@ -0,0 +1,27 @@
1<?php
2
3namespace Shaarli\Front;
4
5use Slim\Http\Request;
6use Slim\Http\Response;
7
8/**
9 * Middleware used for controller requiring to be authenticated.
10 * It extends ShaarliMiddleware, and just make sure that the user is authenticated.
11 * Otherwise, it redirects to the login page.
12 */
13class ShaarliAdminMiddleware extends ShaarliMiddleware
14{
15 public function __invoke(Request $request, Response $response, callable $next): Response
16 {
17 $this->initBasePath($request);
18
19 if (true !== $this->container->loginManager->isLoggedIn()) {
20 $returnUrl = urlencode($this->container->environment['REQUEST_URI']);
21
22 return $response->withRedirect($this->container->basePath . '/login?returnurl=' . $returnUrl);
23 }
24
25 return parent::__invoke($request, $response, $next);
26 }
27}
diff --git a/application/front/ShaarliMiddleware.php b/application/front/ShaarliMiddleware.php
index fa6c6467..d1aa1399 100644
--- a/application/front/ShaarliMiddleware.php
+++ b/application/front/ShaarliMiddleware.php
@@ -3,7 +3,7 @@
3namespace Shaarli\Front; 3namespace Shaarli\Front;
4 4
5use Shaarli\Container\ShaarliContainer; 5use Shaarli\Container\ShaarliContainer;
6use Shaarli\Front\Exception\ShaarliException; 6use Shaarli\Front\Exception\UnauthorizedException;
7use Slim\Http\Request; 7use Slim\Http\Request;
8use Slim\Http\Response; 8use Slim\Http\Response;
9 9
@@ -24,6 +24,8 @@ class ShaarliMiddleware
24 24
25 /** 25 /**
26 * Middleware execution: 26 * Middleware execution:
27 * - run updates
28 * - if not logged in open shaarli, redirect to login
27 * - execute the controller 29 * - execute the controller
28 * - return the response 30 * - return the response
29 * 31 *
@@ -35,23 +37,78 @@ class ShaarliMiddleware
35 * 37 *
36 * @return Response response. 38 * @return Response response.
37 */ 39 */
38 public function __invoke(Request $request, Response $response, callable $next) 40 public function __invoke(Request $request, Response $response, callable $next): Response
39 { 41 {
42 $this->initBasePath($request);
43
40 try { 44 try {
41 $response = $next($request, $response); 45 if (!is_file($this->container->conf->getConfigFileExt())
42 } catch (ShaarliException $e) { 46 && !in_array($next->getName(), ['displayInstall', 'saveInstall'], true)
43 $this->container->pageBuilder->assign('message', $e->getMessage()); 47 ) {
44 if ($this->container->conf->get('dev.debug', false)) { 48 return $response->withRedirect($this->container->basePath . '/install');
45 $this->container->pageBuilder->assign(
46 'stacktrace',
47 nl2br(get_class($this) .': '. $e->getTraceAsString())
48 );
49 } 49 }
50 50
51 $response = $response->withStatus($e->getCode()); 51 $this->runUpdates();
52 $response = $response->write($this->container->pageBuilder->render('error')); 52 $this->checkOpenShaarli($request, $response, $next);
53
54 return $next($request, $response);
55 } catch (UnauthorizedException $e) {
56 $returnUrl = urlencode($this->container->environment['REQUEST_URI']);
57
58 return $response->withRedirect($this->container->basePath . '/login?returnurl=' . $returnUrl);
59 }
60 // Other exceptions are handled by ErrorController
61 }
62
63 /**
64 * Run the updater for every requests processed while logged in.
65 */
66 protected function runUpdates(): void
67 {
68 if ($this->container->loginManager->isLoggedIn() !== true) {
69 return;
70 }
71
72 $this->container->updater->setBasePath($this->container->basePath);
73 $newUpdates = $this->container->updater->update();
74 if (!empty($newUpdates)) {
75 $this->container->updater->writeUpdates(
76 $this->container->conf->get('resource.updates'),
77 $this->container->updater->getDoneUpdates()
78 );
79
80 $this->container->pageCacheManager->invalidateCaches();
81 }
82 }
83
84 /**
85 * Access is denied to most pages with `hide_public_links` + `force_login` settings.
86 */
87 protected function checkOpenShaarli(Request $request, Response $response, callable $next): bool
88 {
89 if (// if the user isn't logged in
90 !$this->container->loginManager->isLoggedIn()
91 // and Shaarli doesn't have public content...
92 && $this->container->conf->get('privacy.hide_public_links')
93 // and is configured to enforce the login
94 && $this->container->conf->get('privacy.force_login')
95 // and the current page isn't already the login page
96 // and the user is not requesting a feed (which would lead to a different content-type as expected)
97 && !in_array($next->getName(), ['login', 'processLogin', 'atom', 'rss'], true)
98 ) {
99 throw new UnauthorizedException();
53 } 100 }
54 101
55 return $response; 102 return true;
103 }
104
105 /**
106 * Initialize the URL base path if it hasn't been defined yet.
107 */
108 protected function initBasePath(Request $request): void
109 {
110 if (null === $this->container->basePath) {
111 $this->container->basePath = rtrim($request->getUri()->getBasePath(), '/');
112 }
56 } 113 }
57} 114}
diff --git a/application/front/controller/admin/ConfigureController.php b/application/front/controller/admin/ConfigureController.php
new file mode 100644
index 00000000..0ed7ad81
--- /dev/null
+++ b/application/front/controller/admin/ConfigureController.php
@@ -0,0 +1,126 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use Shaarli\Languages;
8use Shaarli\Render\TemplatePage;
9use Shaarli\Render\ThemeUtils;
10use Shaarli\Thumbnailer;
11use Slim\Http\Request;
12use Slim\Http\Response;
13use Throwable;
14
15/**
16 * Class ConfigureController
17 *
18 * Slim controller used to handle Shaarli configuration page (display + save new config).
19 */
20class ConfigureController extends ShaarliAdminController
21{
22 /**
23 * GET /admin/configure - Displays the configuration page
24 */
25 public function index(Request $request, Response $response): Response
26 {
27 $this->assignView('title', $this->container->conf->get('general.title', 'Shaarli'));
28 $this->assignView('theme', $this->container->conf->get('resource.theme'));
29 $this->assignView(
30 'theme_available',
31 ThemeUtils::getThemes($this->container->conf->get('resource.raintpl_tpl'))
32 );
33 $this->assignView('formatter_available', ['default', 'markdown', 'markdownExtra']);
34 list($continents, $cities) = generateTimeZoneData(
35 timezone_identifiers_list(),
36 $this->container->conf->get('general.timezone')
37 );
38 $this->assignView('continents', $continents);
39 $this->assignView('cities', $cities);
40 $this->assignView('retrieve_description', $this->container->conf->get('general.retrieve_description', false));
41 $this->assignView('private_links_default', $this->container->conf->get('privacy.default_private_links', false));
42 $this->assignView(
43 'session_protection_disabled',
44 $this->container->conf->get('security.session_protection_disabled', false)
45 );
46 $this->assignView('enable_rss_permalinks', $this->container->conf->get('feed.rss_permalinks', false));
47 $this->assignView('enable_update_check', $this->container->conf->get('updates.check_updates', true));
48 $this->assignView('hide_public_links', $this->container->conf->get('privacy.hide_public_links', false));
49 $this->assignView('api_enabled', $this->container->conf->get('api.enabled', true));
50 $this->assignView('api_secret', $this->container->conf->get('api.secret'));
51 $this->assignView('languages', Languages::getAvailableLanguages());
52 $this->assignView('gd_enabled', extension_loaded('gd'));
53 $this->assignView('thumbnails_mode', $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE));
54 $this->assignView('pagetitle', t('Configure') .' - '. $this->container->conf->get('general.title', 'Shaarli'));
55
56 return $response->write($this->render(TemplatePage::CONFIGURE));
57 }
58
59 /**
60 * POST /admin/configure - Update Shaarli's configuration
61 */
62 public function save(Request $request, Response $response): Response
63 {
64 $this->checkToken($request);
65
66 $continent = $request->getParam('continent');
67 $city = $request->getParam('city');
68 $tz = 'UTC';
69 if (null !== $continent && null !== $city && isTimeZoneValid($continent, $city)) {
70 $tz = $continent . '/' . $city;
71 }
72
73 $this->container->conf->set('general.timezone', $tz);
74 $this->container->conf->set('general.title', escape($request->getParam('title')));
75 $this->container->conf->set('general.header_link', escape($request->getParam('titleLink')));
76 $this->container->conf->set('general.retrieve_description', !empty($request->getParam('retrieveDescription')));
77 $this->container->conf->set('resource.theme', escape($request->getParam('theme')));
78 $this->container->conf->set(
79 'security.session_protection_disabled',
80 !empty($request->getParam('disablesessionprotection'))
81 );
82 $this->container->conf->set(
83 'privacy.default_private_links',
84 !empty($request->getParam('privateLinkByDefault'))
85 );
86 $this->container->conf->set('feed.rss_permalinks', !empty($request->getParam('enableRssPermalinks')));
87 $this->container->conf->set('updates.check_updates', !empty($request->getParam('updateCheck')));
88 $this->container->conf->set('privacy.hide_public_links', !empty($request->getParam('hidePublicLinks')));
89 $this->container->conf->set('api.enabled', !empty($request->getParam('enableApi')));
90 $this->container->conf->set('api.secret', escape($request->getParam('apiSecret')));
91 $this->container->conf->set('formatter', escape($request->getParam('formatter')));
92
93 if (!empty($request->getParam('language'))) {
94 $this->container->conf->set('translation.language', escape($request->getParam('language')));
95 }
96
97 $thumbnailsMode = extension_loaded('gd') ? $request->getParam('enableThumbnails') : Thumbnailer::MODE_NONE;
98 if ($thumbnailsMode !== Thumbnailer::MODE_NONE
99 && $thumbnailsMode !== $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE)
100 ) {
101 $this->saveWarningMessage(
102 t('You have enabled or changed thumbnails mode.') .
103 '<a href="'. $this->container->basePath .'/admin/thumbnails">' . t('Please synchronize them.') .'</a>'
104 );
105 }
106 $this->container->conf->set('thumbnails.mode', $thumbnailsMode);
107
108 try {
109 $this->container->conf->write($this->container->loginManager->isLoggedIn());
110 $this->container->history->updateSettings();
111 $this->container->pageCacheManager->invalidateCaches();
112 } catch (Throwable $e) {
113 $this->assignView('message', t('Error while writing config file after configuration update.'));
114
115 if ($this->container->conf->get('dev.debug', false)) {
116 $this->assignView('stacktrace', $e->getMessage() . PHP_EOL . $e->getTraceAsString());
117 }
118
119 return $response->write($this->render('error'));
120 }
121
122 $this->saveSuccessMessage(t('Configuration was saved.'));
123
124 return $this->redirect($response, '/admin/configure');
125 }
126}
diff --git a/application/front/controller/admin/ExportController.php b/application/front/controller/admin/ExportController.php
new file mode 100644
index 00000000..2be957fa
--- /dev/null
+++ b/application/front/controller/admin/ExportController.php
@@ -0,0 +1,80 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use DateTime;
8use Shaarli\Bookmark\Bookmark;
9use Shaarli\Render\TemplatePage;
10use Slim\Http\Request;
11use Slim\Http\Response;
12
13/**
14 * Class ExportController
15 *
16 * Slim controller used to display Shaarli data export page,
17 * and process the bookmarks export as a Netscape Bookmarks file.
18 */
19class ExportController extends ShaarliAdminController
20{
21 /**
22 * GET /admin/export - Display export page
23 */
24 public function index(Request $request, Response $response): Response
25 {
26 $this->assignView('pagetitle', t('Export') .' - '. $this->container->conf->get('general.title', 'Shaarli'));
27
28 return $response->write($this->render(TemplatePage::EXPORT));
29 }
30
31 /**
32 * POST /admin/export - Process export, and serve download file named
33 * bookmarks_(all|private|public)_datetime.html
34 */
35 public function export(Request $request, Response $response): Response
36 {
37 $this->checkToken($request);
38
39 $selection = $request->getParam('selection');
40
41 if (empty($selection)) {
42 $this->saveErrorMessage(t('Please select an export mode.'));
43
44 return $this->redirect($response, '/admin/export');
45 }
46
47 $prependNoteUrl = filter_var($request->getParam('prepend_note_url') ?? false, FILTER_VALIDATE_BOOLEAN);
48
49 try {
50 $formatter = $this->container->formatterFactory->getFormatter('raw');
51
52 $this->assignView(
53 'links',
54 $this->container->netscapeBookmarkUtils->filterAndFormat(
55 $formatter,
56 $selection,
57 $prependNoteUrl,
58 index_url($this->container->environment)
59 )
60 );
61 } catch (\Exception $exc) {
62 $this->saveErrorMessage($exc->getMessage());
63
64 return $this->redirect($response, '/admin/export');
65 }
66
67 $now = new DateTime();
68 $response = $response->withHeader('Content-Type', 'text/html; charset=utf-8');
69 $response = $response->withHeader(
70 'Content-disposition',
71 'attachment; filename=bookmarks_'.$selection.'_'.$now->format(Bookmark::LINK_DATE_FORMAT).'.html'
72 );
73
74 $this->assignView('date', $now->format(DateTime::RFC822));
75 $this->assignView('eol', PHP_EOL);
76 $this->assignView('selection', $selection);
77
78 return $response->write($this->render(TemplatePage::NETSCAPE_EXPORT_BOOKMARKS));
79 }
80}
diff --git a/application/front/controller/admin/ImportController.php b/application/front/controller/admin/ImportController.php
new file mode 100644
index 00000000..758d5ef9
--- /dev/null
+++ b/application/front/controller/admin/ImportController.php
@@ -0,0 +1,82 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use Psr\Http\Message\UploadedFileInterface;
8use Shaarli\Render\TemplatePage;
9use Slim\Http\Request;
10use Slim\Http\Response;
11
12/**
13 * Class ImportController
14 *
15 * Slim controller used to display Shaarli data import page,
16 * and import bookmarks from Netscape Bookmarks file.
17 */
18class ImportController extends ShaarliAdminController
19{
20 /**
21 * GET /admin/import - Display import page
22 */
23 public function index(Request $request, Response $response): Response
24 {
25 $this->assignView(
26 'maxfilesize',
27 get_max_upload_size(
28 ini_get('post_max_size'),
29 ini_get('upload_max_filesize'),
30 false
31 )
32 );
33 $this->assignView(
34 'maxfilesizeHuman',
35 get_max_upload_size(
36 ini_get('post_max_size'),
37 ini_get('upload_max_filesize'),
38 true
39 )
40 );
41 $this->assignView('pagetitle', t('Import') .' - '. $this->container->conf->get('general.title', 'Shaarli'));
42
43 return $response->write($this->render(TemplatePage::IMPORT));
44 }
45
46 /**
47 * POST /admin/import - Process import file provided and create bookmarks
48 */
49 public function import(Request $request, Response $response): Response
50 {
51 $this->checkToken($request);
52
53 $file = ($request->getUploadedFiles() ?? [])['filetoupload'] ?? null;
54 if (!$file instanceof UploadedFileInterface) {
55 $this->saveErrorMessage(t('No import file provided.'));
56
57 return $this->redirect($response, '/admin/import');
58 }
59
60
61 // Import bookmarks from an uploaded file
62 if (0 === $file->getSize()) {
63 // The file is too big or some form field may be missing.
64 $msg = sprintf(
65 t(
66 'The file you are trying to upload is probably bigger than what this webserver can accept'
67 .' (%s). Please upload in smaller chunks.'
68 ),
69 get_max_upload_size(ini_get('post_max_size'), ini_get('upload_max_filesize'))
70 );
71 $this->saveErrorMessage($msg);
72
73 return $this->redirect($response, '/admin/import');
74 }
75
76 $status = $this->container->netscapeBookmarkUtils->import($request->getParams(), $file);
77
78 $this->saveSuccessMessage($status);
79
80 return $this->redirect($response, '/admin/import');
81 }
82}
diff --git a/application/front/controller/admin/LogoutController.php b/application/front/controller/admin/LogoutController.php
new file mode 100644
index 00000000..28165129
--- /dev/null
+++ b/application/front/controller/admin/LogoutController.php
@@ -0,0 +1,33 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use Shaarli\Security\CookieManager;
8use Shaarli\Security\LoginManager;
9use Slim\Http\Request;
10use Slim\Http\Response;
11
12/**
13 * Class LogoutController
14 *
15 * Slim controller used to logout the user.
16 * It invalidates page cache and terminate the user session. Then it redirects to the homepage.
17 */
18class LogoutController extends ShaarliAdminController
19{
20 public function index(Request $request, Response $response): Response
21 {
22 $this->container->pageCacheManager->invalidateCaches();
23 $this->container->sessionManager->logout();
24 $this->container->cookieManager->setCookieParameter(
25 CookieManager::STAY_SIGNED_IN,
26 'false',
27 0,
28 $this->container->basePath . '/'
29 );
30
31 return $this->redirect($response, '/');
32 }
33}
diff --git a/application/front/controller/admin/ManageTagController.php b/application/front/controller/admin/ManageTagController.php
new file mode 100644
index 00000000..2065c3e2
--- /dev/null
+++ b/application/front/controller/admin/ManageTagController.php
@@ -0,0 +1,88 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use Shaarli\Bookmark\BookmarkFilter;
8use Shaarli\Render\TemplatePage;
9use Slim\Http\Request;
10use Slim\Http\Response;
11
12/**
13 * Class ManageTagController
14 *
15 * Slim controller used to handle Shaarli manage tags page (rename and delete tags).
16 */
17class ManageTagController extends ShaarliAdminController
18{
19 /**
20 * GET /admin/tags - Displays the manage tags page
21 */
22 public function index(Request $request, Response $response): Response
23 {
24 $fromTag = $request->getParam('fromtag') ?? '';
25
26 $this->assignView('fromtag', escape($fromTag));
27 $this->assignView(
28 'pagetitle',
29 t('Manage tags') .' - '. $this->container->conf->get('general.title', 'Shaarli')
30 );
31
32 return $response->write($this->render(TemplatePage::CHANGE_TAG));
33 }
34
35 /**
36 * POST /admin/tags - Update or delete provided tag
37 */
38 public function save(Request $request, Response $response): Response
39 {
40 $this->checkToken($request);
41
42 $isDelete = null !== $request->getParam('deletetag') && null === $request->getParam('renametag');
43
44 $fromTag = trim($request->getParam('fromtag') ?? '');
45 $toTag = trim($request->getParam('totag') ?? '');
46
47 if (0 === strlen($fromTag) || false === $isDelete && 0 === strlen($toTag)) {
48 $this->saveWarningMessage(t('Invalid tags provided.'));
49
50 return $this->redirect($response, '/admin/tags');
51 }
52
53 // TODO: move this to bookmark service
54 $count = 0;
55 $bookmarks = $this->container->bookmarkService->search(['searchtags' => $fromTag], BookmarkFilter::$ALL, true);
56 foreach ($bookmarks as $bookmark) {
57 if (false === $isDelete) {
58 $bookmark->renameTag($fromTag, $toTag);
59 } else {
60 $bookmark->deleteTag($fromTag);
61 }
62
63 $this->container->bookmarkService->set($bookmark, false);
64 $this->container->history->updateLink($bookmark);
65 $count++;
66 }
67
68 $this->container->bookmarkService->save();
69
70 if (true === $isDelete) {
71 $alert = sprintf(
72 t('The tag was removed from %d bookmark.', 'The tag was removed from %d bookmarks.', $count),
73 $count
74 );
75 } else {
76 $alert = sprintf(
77 t('The tag was renamed in %d bookmark.', 'The tag was renamed in %d bookmarks.', $count),
78 $count
79 );
80 }
81
82 $this->saveSuccessMessage($alert);
83
84 $redirect = true === $isDelete ? '/admin/tags' : '/?searchtags='. urlencode($toTag);
85
86 return $this->redirect($response, $redirect);
87 }
88}
diff --git a/application/front/controller/admin/MetadataController.php b/application/front/controller/admin/MetadataController.php
new file mode 100644
index 00000000..ff845944
--- /dev/null
+++ b/application/front/controller/admin/MetadataController.php
@@ -0,0 +1,29 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use Slim\Http\Request;
8use Slim\Http\Response;
9
10/**
11 * Controller used to retrieve/update bookmark's metadata.
12 */
13class MetadataController extends ShaarliAdminController
14{
15 /**
16 * GET /admin/metadata/{url} - Attempt to retrieve the bookmark title from provided URL.
17 */
18 public function ajaxRetrieveTitle(Request $request, Response $response): Response
19 {
20 $url = $request->getParam('url');
21
22 // Only try to extract metadata from URL with HTTP(s) scheme
23 if (!empty($url) && strpos(get_url_scheme($url) ?: '', 'http') !== false) {
24 return $response->withJson($this->container->metadataRetriever->retrieve($url));
25 }
26
27 return $response->withJson([]);
28 }
29}
diff --git a/application/front/controller/admin/PasswordController.php b/application/front/controller/admin/PasswordController.php
new file mode 100644
index 00000000..5ec0d24b
--- /dev/null
+++ b/application/front/controller/admin/PasswordController.php
@@ -0,0 +1,101 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use Shaarli\Container\ShaarliContainer;
8use Shaarli\Front\Exception\OpenShaarliPasswordException;
9use Shaarli\Front\Exception\ShaarliFrontException;
10use Shaarli\Render\TemplatePage;
11use Slim\Http\Request;
12use Slim\Http\Response;
13use Throwable;
14
15/**
16 * Class PasswordController
17 *
18 * Slim controller used to handle passwords update.
19 */
20class PasswordController extends ShaarliAdminController
21{
22 public function __construct(ShaarliContainer $container)
23 {
24 parent::__construct($container);
25
26 $this->assignView(
27 'pagetitle',
28 t('Change password') .' - '. $this->container->conf->get('general.title', 'Shaarli')
29 );
30 }
31
32 /**
33 * GET /admin/password - Displays the change password template
34 */
35 public function index(Request $request, Response $response): Response
36 {
37 return $response->write($this->render(TemplatePage::CHANGE_PASSWORD));
38 }
39
40 /**
41 * POST /admin/password - Change admin password - existing and new passwords need to be provided.
42 */
43 public function change(Request $request, Response $response): Response
44 {
45 $this->checkToken($request);
46
47 if ($this->container->conf->get('security.open_shaarli', false)) {
48 throw new OpenShaarliPasswordException();
49 }
50
51 $oldPassword = $request->getParam('oldpassword');
52 $newPassword = $request->getParam('setpassword');
53
54 if (empty($newPassword) || empty($oldPassword)) {
55 $this->saveErrorMessage(t('You must provide the current and new password to change it.'));
56
57 return $response
58 ->withStatus(400)
59 ->write($this->render(TemplatePage::CHANGE_PASSWORD))
60 ;
61 }
62
63 // Make sure old password is correct.
64 $oldHash = sha1(
65 $oldPassword .
66 $this->container->conf->get('credentials.login') .
67 $this->container->conf->get('credentials.salt')
68 );
69
70 if ($oldHash !== $this->container->conf->get('credentials.hash')) {
71 $this->saveErrorMessage(t('The old password is not correct.'));
72
73 return $response
74 ->withStatus(400)
75 ->write($this->render(TemplatePage::CHANGE_PASSWORD))
76 ;
77 }
78
79 // Save new password
80 // Salt renders rainbow-tables attacks useless.
81 $this->container->conf->set('credentials.salt', sha1(uniqid('', true) .'_'. mt_rand()));
82 $this->container->conf->set(
83 'credentials.hash',
84 sha1(
85 $newPassword
86 . $this->container->conf->get('credentials.login')
87 . $this->container->conf->get('credentials.salt')
88 )
89 );
90
91 try {
92 $this->container->conf->write($this->container->loginManager->isLoggedIn());
93 } catch (Throwable $e) {
94 throw new ShaarliFrontException($e->getMessage(), 500, $e);
95 }
96
97 $this->saveSuccessMessage(t('Your password has been changed'));
98
99 return $response->write($this->render(TemplatePage::CHANGE_PASSWORD));
100 }
101}
diff --git a/application/front/controller/admin/PluginsController.php b/application/front/controller/admin/PluginsController.php
new file mode 100644
index 00000000..8e059681
--- /dev/null
+++ b/application/front/controller/admin/PluginsController.php
@@ -0,0 +1,85 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use Exception;
8use Shaarli\Render\TemplatePage;
9use Slim\Http\Request;
10use Slim\Http\Response;
11
12/**
13 * Class PluginsController
14 *
15 * Slim controller used to handle Shaarli plugins configuration page (display + save new config).
16 */
17class PluginsController extends ShaarliAdminController
18{
19 /**
20 * GET /admin/plugins - Displays the configuration page
21 */
22 public function index(Request $request, Response $response): Response
23 {
24 $pluginMeta = $this->container->pluginManager->getPluginsMeta();
25
26 // Split plugins into 2 arrays: ordered enabled plugins and disabled.
27 $enabledPlugins = array_filter($pluginMeta, function ($v) {
28 return ($v['order'] ?? false) !== false;
29 });
30 $enabledPlugins = load_plugin_parameter_values($enabledPlugins, $this->container->conf->get('plugins', []));
31 uasort(
32 $enabledPlugins,
33 function ($a, $b) {
34 return $a['order'] - $b['order'];
35 }
36 );
37 $disabledPlugins = array_filter($pluginMeta, function ($v) {
38 return ($v['order'] ?? false) === false;
39 });
40
41 $this->assignView('enabledPlugins', $enabledPlugins);
42 $this->assignView('disabledPlugins', $disabledPlugins);
43 $this->assignView(
44 'pagetitle',
45 t('Plugin Administration') .' - '. $this->container->conf->get('general.title', 'Shaarli')
46 );
47
48 return $response->write($this->render(TemplatePage::PLUGINS_ADMIN));
49 }
50
51 /**
52 * POST /admin/plugins - Update Shaarli's configuration
53 */
54 public function save(Request $request, Response $response): Response
55 {
56 $this->checkToken($request);
57
58 try {
59 $parameters = $request->getParams() ?? [];
60
61 $this->executePageHooks('save_plugin_parameters', $parameters);
62
63 if (isset($parameters['parameters_form'])) {
64 unset($parameters['parameters_form']);
65 unset($parameters['token']);
66 foreach ($parameters as $param => $value) {
67 $this->container->conf->set('plugins.'. $param, escape($value));
68 }
69 } else {
70 $this->container->conf->set('general.enabled_plugins', save_plugin_config($parameters));
71 }
72
73 $this->container->conf->write($this->container->loginManager->isLoggedIn());
74 $this->container->history->updateSettings();
75
76 $this->saveSuccessMessage(t('Setting successfully saved.'));
77 } catch (Exception $e) {
78 $this->saveErrorMessage(
79 t('Error while saving plugin configuration: ') . PHP_EOL . $e->getMessage()
80 );
81 }
82
83 return $this->redirect($response, '/admin/plugins');
84 }
85}
diff --git a/application/front/controller/admin/ServerController.php b/application/front/controller/admin/ServerController.php
new file mode 100644
index 00000000..bfc99422
--- /dev/null
+++ b/application/front/controller/admin/ServerController.php
@@ -0,0 +1,87 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use Shaarli\Helper\ApplicationUtils;
8use Shaarli\Helper\FileUtils;
9use Slim\Http\Request;
10use Slim\Http\Response;
11
12/**
13 * Slim controller used to handle Server administration page, and actions.
14 */
15class ServerController extends ShaarliAdminController
16{
17 /** @var string Cache type - main - by default pagecache/ and tmp/ */
18 protected const CACHE_MAIN = 'main';
19
20 /** @var string Cache type - thumbnails - by default cache/ */
21 protected const CACHE_THUMB = 'thumbnails';
22
23 /**
24 * GET /admin/server - Display page Server administration
25 */
26 public function index(Request $request, Response $response): Response
27 {
28 $latestVersion = 'v' . ApplicationUtils::getVersion(
29 ApplicationUtils::$GIT_RAW_URL . '/latest/' . ApplicationUtils::$VERSION_FILE
30 );
31 $currentVersion = ApplicationUtils::getVersion('./shaarli_version.php');
32 $currentVersion = $currentVersion === 'dev' ? $currentVersion : 'v' . $currentVersion;
33 $phpEol = new \DateTimeImmutable(ApplicationUtils::getPhpEol(PHP_VERSION));
34
35 $this->assignView('php_version', PHP_VERSION);
36 $this->assignView('php_eol', format_date($phpEol, false));
37 $this->assignView('php_has_reached_eol', $phpEol < new \DateTimeImmutable());
38 $this->assignView('php_extensions', ApplicationUtils::getPhpExtensionsRequirement());
39 $this->assignView('permissions', ApplicationUtils::checkResourcePermissions($this->container->conf));
40 $this->assignView('release_url', ApplicationUtils::$GITHUB_URL . '/releases/tag/' . $latestVersion);
41 $this->assignView('latest_version', $latestVersion);
42 $this->assignView('current_version', $currentVersion);
43 $this->assignView('thumbnails_mode', $this->container->conf->get('thumbnails.mode'));
44 $this->assignView('index_url', index_url($this->container->environment));
45 $this->assignView('client_ip', client_ip_id($this->container->environment));
46 $this->assignView('trusted_proxies', $this->container->conf->get('security.trusted_proxies', []));
47
48 $this->assignView(
49 'pagetitle',
50 t('Server administration') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
51 );
52
53 return $response->write($this->render('server'));
54 }
55
56 /**
57 * GET /admin/clear-cache?type={$type} - Action to trigger cache folder clearing (either main or thumbnails).
58 */
59 public function clearCache(Request $request, Response $response): Response
60 {
61 $exclude = ['.htaccess'];
62
63 if ($request->getQueryParam('type') === static::CACHE_THUMB) {
64 $folders = [$this->container->conf->get('resource.thumbnails_cache')];
65
66 $this->saveWarningMessage(
67 t('Thumbnails cache has been cleared.') . ' ' .
68 '<a href="'. $this->container->basePath .'/admin/thumbnails">' . t('Please synchronize them.') .'</a>'
69 );
70 } else {
71 $folders = [
72 $this->container->conf->get('resource.page_cache'),
73 $this->container->conf->get('resource.raintpl_tmp'),
74 ];
75
76 $this->saveSuccessMessage(t('Shaarli\'s cache folder has been cleared!'));
77 }
78
79 // Make sure that we don't delete root cache folder
80 $folders = array_map('realpath', array_values(array_filter(array_map('trim', $folders))));
81 foreach ($folders as $folder) {
82 FileUtils::clearFolder($folder, false, $exclude);
83 }
84
85 return $this->redirect($response, '/admin/server');
86 }
87}
diff --git a/application/front/controller/admin/SessionFilterController.php b/application/front/controller/admin/SessionFilterController.php
new file mode 100644
index 00000000..d9a7a2e0
--- /dev/null
+++ b/application/front/controller/admin/SessionFilterController.php
@@ -0,0 +1,50 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use Shaarli\Bookmark\BookmarkFilter;
8use Shaarli\Security\SessionManager;
9use Slim\Http\Request;
10use Slim\Http\Response;
11
12/**
13 * Class SessionFilterController
14 *
15 * Slim controller used to handle filters stored in the user session, such as visibility, etc.
16 */
17class SessionFilterController extends ShaarliAdminController
18{
19 /**
20 * GET /admin/visibility: allows to display only public or only private bookmarks in linklist
21 */
22 public function visibility(Request $request, Response $response, array $args): Response
23 {
24 if (false === $this->container->loginManager->isLoggedIn()) {
25 return $this->redirectFromReferer($request, $response, ['visibility']);
26 }
27
28 $newVisibility = $args['visibility'] ?? null;
29 if (false === in_array($newVisibility, [BookmarkFilter::$PRIVATE, BookmarkFilter::$PUBLIC], true)) {
30 $newVisibility = null;
31 }
32
33 $currentVisibility = $this->container->sessionManager->getSessionParameter(SessionManager::KEY_VISIBILITY);
34
35 // Visibility not set or not already expected value, set expected value, otherwise reset it
36 if ($newVisibility !== null && (null === $currentVisibility || $currentVisibility !== $newVisibility)) {
37 // See only public bookmarks
38 $this->container->sessionManager->setSessionParameter(
39 SessionManager::KEY_VISIBILITY,
40 $newVisibility
41 );
42 } else {
43 $this->container->sessionManager->deleteSessionParameter(SessionManager::KEY_VISIBILITY);
44 }
45
46 return $this->redirectFromReferer($request, $response, ['visibility']);
47 }
48
49
50}
diff --git a/application/front/controller/admin/ShaareAddController.php b/application/front/controller/admin/ShaareAddController.php
new file mode 100644
index 00000000..8dc386b2
--- /dev/null
+++ b/application/front/controller/admin/ShaareAddController.php
@@ -0,0 +1,34 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use Shaarli\Formatter\BookmarkMarkdownFormatter;
8use Shaarli\Render\TemplatePage;
9use Slim\Http\Request;
10use Slim\Http\Response;
11
12class ShaareAddController extends ShaarliAdminController
13{
14 /**
15 * GET /admin/add-shaare - Displays the form used to create a new bookmark from an URL
16 */
17 public function addShaare(Request $request, Response $response): Response
18 {
19 $tags = $this->container->bookmarkService->bookmarksCountPerTag();
20 if ($this->container->conf->get('formatter') === 'markdown') {
21 $tags[BookmarkMarkdownFormatter::NO_MD_TAG] = 1;
22 }
23
24 $this->assignView(
25 'pagetitle',
26 t('Shaare a new link') .' - '. $this->container->conf->get('general.title', 'Shaarli')
27 );
28 $this->assignView('tags', $tags);
29 $this->assignView('default_private_links', $this->container->conf->get('privacy.default_private_links', false));
30 $this->assignView('async_metadata', $this->container->conf->get('general.enable_async_metadata', true));
31
32 return $response->write($this->render(TemplatePage::ADDLINK));
33 }
34}
diff --git a/application/front/controller/admin/ShaareManageController.php b/application/front/controller/admin/ShaareManageController.php
new file mode 100644
index 00000000..7ceb8d8a
--- /dev/null
+++ b/application/front/controller/admin/ShaareManageController.php
@@ -0,0 +1,202 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
8use Slim\Http\Request;
9use Slim\Http\Response;
10
11/**
12 * Class PostBookmarkController
13 *
14 * Slim controller used to handle Shaarli create or edit bookmarks.
15 */
16class ShaareManageController extends ShaarliAdminController
17{
18 /**
19 * GET /admin/shaare/delete - Delete one or multiple bookmarks (depending on `id` query parameter).
20 */
21 public function deleteBookmark(Request $request, Response $response): Response
22 {
23 $this->checkToken($request);
24
25 $ids = escape(trim($request->getParam('id') ?? ''));
26 if (empty($ids) || strpos($ids, ' ') !== false) {
27 // multiple, space-separated ids provided
28 $ids = array_values(array_filter(preg_split('/\s+/', $ids), 'ctype_digit'));
29 } else {
30 $ids = [$ids];
31 }
32
33 // assert at least one id is given
34 if (0 === count($ids)) {
35 $this->saveErrorMessage(t('Invalid bookmark ID provided.'));
36
37 return $this->redirectFromReferer($request, $response, [], ['delete-shaare']);
38 }
39
40 $formatter = $this->container->formatterFactory->getFormatter('raw');
41 $count = 0;
42 foreach ($ids as $id) {
43 try {
44 $bookmark = $this->container->bookmarkService->get((int) $id);
45 } catch (BookmarkNotFoundException $e) {
46 $this->saveErrorMessage(sprintf(
47 t('Bookmark with identifier %s could not be found.'),
48 $id
49 ));
50
51 continue;
52 }
53
54 $data = $formatter->format($bookmark);
55 $this->executePageHooks('delete_link', $data);
56 $this->container->bookmarkService->remove($bookmark, false);
57 ++ $count;
58 }
59
60 if ($count > 0) {
61 $this->container->bookmarkService->save();
62 }
63
64 // If we are called from the bookmarklet, we must close the popup:
65 if ($request->getParam('source') === 'bookmarklet') {
66 return $response->write('<script>self.close();</script>');
67 }
68
69 // Don't redirect to where we were previously because the datastore has changed.
70 return $this->redirect($response, '/');
71 }
72
73 /**
74 * GET /admin/shaare/visibility
75 *
76 * Change visibility (public/private) of one or multiple bookmarks (depending on `id` query parameter).
77 */
78 public function changeVisibility(Request $request, Response $response): Response
79 {
80 $this->checkToken($request);
81
82 $ids = trim(escape($request->getParam('id') ?? ''));
83 if (empty($ids) || strpos($ids, ' ') !== false) {
84 // multiple, space-separated ids provided
85 $ids = array_values(array_filter(preg_split('/\s+/', $ids), 'ctype_digit'));
86 } else {
87 // only a single id provided
88 $ids = [$ids];
89 }
90
91 // assert at least one id is given
92 if (0 === count($ids)) {
93 $this->saveErrorMessage(t('Invalid bookmark ID provided.'));
94
95 return $this->redirectFromReferer($request, $response, [], ['change_visibility']);
96 }
97
98 // assert that the visibility is valid
99 $visibility = $request->getParam('newVisibility');
100 if (null === $visibility || false === in_array($visibility, ['public', 'private'], true)) {
101 $this->saveErrorMessage(t('Invalid visibility provided.'));
102
103 return $this->redirectFromReferer($request, $response, [], ['change_visibility']);
104 } else {
105 $isPrivate = $visibility === 'private';
106 }
107
108 $formatter = $this->container->formatterFactory->getFormatter('raw');
109 $count = 0;
110
111 foreach ($ids as $id) {
112 try {
113 $bookmark = $this->container->bookmarkService->get((int) $id);
114 } catch (BookmarkNotFoundException $e) {
115 $this->saveErrorMessage(sprintf(
116 t('Bookmark with identifier %s could not be found.'),
117 $id
118 ));
119
120 continue;
121 }
122
123 $bookmark->setPrivate($isPrivate);
124
125 // To preserve backward compatibility with 3rd parties, plugins still use arrays
126 $data = $formatter->format($bookmark);
127 $this->executePageHooks('save_link', $data);
128 $bookmark->fromArray($data);
129
130 $this->container->bookmarkService->set($bookmark, false);
131 ++$count;
132 }
133
134 if ($count > 0) {
135 $this->container->bookmarkService->save();
136 }
137
138 return $this->redirectFromReferer($request, $response, ['/visibility'], ['change_visibility']);
139 }
140
141 /**
142 * GET /admin/shaare/{id}/pin - Pin or unpin a bookmark.
143 */
144 public function pinBookmark(Request $request, Response $response, array $args): Response
145 {
146 $this->checkToken($request);
147
148 $id = $args['id'] ?? '';
149 try {
150 if (false === ctype_digit($id)) {
151 throw new BookmarkNotFoundException();
152 }
153 $bookmark = $this->container->bookmarkService->get((int) $id); // Read database
154 } catch (BookmarkNotFoundException $e) {
155 $this->saveErrorMessage(sprintf(
156 t('Bookmark with identifier %s could not be found.'),
157 $id
158 ));
159
160 return $this->redirectFromReferer($request, $response, ['/pin'], ['pin']);
161 }
162
163 $formatter = $this->container->formatterFactory->getFormatter('raw');
164
165 $bookmark->setSticky(!$bookmark->isSticky());
166
167 // To preserve backward compatibility with 3rd parties, plugins still use arrays
168 $data = $formatter->format($bookmark);
169 $this->executePageHooks('save_link', $data);
170 $bookmark->fromArray($data);
171
172 $this->container->bookmarkService->set($bookmark);
173
174 return $this->redirectFromReferer($request, $response, ['/pin'], ['pin']);
175 }
176
177 /**
178 * GET /admin/shaare/private/{hash} - Attach a private key to given bookmark, then redirect to the sharing URL.
179 */
180 public function sharePrivate(Request $request, Response $response, array $args): Response
181 {
182 $this->checkToken($request);
183
184 $hash = $args['hash'] ?? '';
185 $bookmark = $this->container->bookmarkService->findByHash($hash);
186
187 if ($bookmark->isPrivate() !== true) {
188 return $this->redirect($response, '/shaare/' . $hash);
189 }
190
191 if (empty($bookmark->getAdditionalContentEntry('private_key'))) {
192 $privateKey = bin2hex(random_bytes(16));
193 $bookmark->addAdditionalContentEntry('private_key', $privateKey);
194 $this->container->bookmarkService->set($bookmark);
195 }
196
197 return $this->redirect(
198 $response,
199 '/shaare/' . $hash . '?key=' . $bookmark->getAdditionalContentEntry('private_key')
200 );
201 }
202}
diff --git a/application/front/controller/admin/ShaarePublishController.php b/application/front/controller/admin/ShaarePublishController.php
new file mode 100644
index 00000000..18afc2d1
--- /dev/null
+++ b/application/front/controller/admin/ShaarePublishController.php
@@ -0,0 +1,263 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use Shaarli\Bookmark\Bookmark;
8use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
9use Shaarli\Formatter\BookmarkFormatter;
10use Shaarli\Formatter\BookmarkMarkdownFormatter;
11use Shaarli\Render\TemplatePage;
12use Shaarli\Thumbnailer;
13use Slim\Http\Request;
14use Slim\Http\Response;
15
16class ShaarePublishController extends ShaarliAdminController
17{
18 /**
19 * @var BookmarkFormatter[] Statically cached instances of formatters
20 */
21 protected $formatters = [];
22
23 /**
24 * @var array Statically cached bookmark's tags counts
25 */
26 protected $tags;
27
28 /**
29 * GET /admin/shaare - Displays the bookmark form for creation.
30 * Note that if the URL is found in existing bookmarks, then it will be in edit mode.
31 */
32 public function displayCreateForm(Request $request, Response $response): Response
33 {
34 $url = cleanup_url($request->getParam('post'));
35 $link = $this->buildLinkDataFromUrl($request, $url);
36
37 return $this->displayForm($link, $link['linkIsNew'], $request, $response);
38 }
39
40 /**
41 * POST /admin/shaare-batch - Displays multiple creation/edit forms from bulk add in add-link page.
42 */
43 public function displayCreateBatchForms(Request $request, Response $response): Response
44 {
45 $urls = array_map('cleanup_url', explode(PHP_EOL, $request->getParam('urls')));
46
47 $links = [];
48 foreach ($urls as $url) {
49 if (empty($url)) {
50 continue;
51 }
52 $link = $this->buildLinkDataFromUrl($request, $url);
53 $data = $this->buildFormData($link, $link['linkIsNew'], $request);
54 $data['token'] = $this->container->sessionManager->generateToken();
55 $data['source'] = 'batch';
56
57 $this->executePageHooks('render_editlink', $data, TemplatePage::EDIT_LINK);
58
59 $links[] = $data;
60 }
61
62 $this->assignView('links', $links);
63 $this->assignView('batch_mode', true);
64 $this->assignView('async_metadata', $this->container->conf->get('general.enable_async_metadata', true));
65
66 return $response->write($this->render(TemplatePage::EDIT_LINK_BATCH));
67 }
68
69 /**
70 * GET /admin/shaare/{id} - Displays the bookmark form in edition mode.
71 */
72 public function displayEditForm(Request $request, Response $response, array $args): Response
73 {
74 $id = $args['id'] ?? '';
75 try {
76 if (false === ctype_digit($id)) {
77 throw new BookmarkNotFoundException();
78 }
79 $bookmark = $this->container->bookmarkService->get((int) $id); // Read database
80 } catch (BookmarkNotFoundException $e) {
81 $this->saveErrorMessage(sprintf(
82 t('Bookmark with identifier %s could not be found.'),
83 $id
84 ));
85
86 return $this->redirect($response, '/');
87 }
88
89 $formatter = $this->getFormatter('raw');
90 $link = $formatter->format($bookmark);
91
92 return $this->displayForm($link, false, $request, $response);
93 }
94
95 /**
96 * POST /admin/shaare
97 */
98 public function save(Request $request, Response $response): Response
99 {
100 $this->checkToken($request);
101
102 // lf_id should only be present if the link exists.
103 $id = $request->getParam('lf_id') !== null ? intval(escape($request->getParam('lf_id'))) : null;
104 if (null !== $id && true === $this->container->bookmarkService->exists($id)) {
105 // Edit
106 $bookmark = $this->container->bookmarkService->get($id);
107 } else {
108 // New link
109 $bookmark = new Bookmark();
110 }
111
112 $bookmark->setTitle($request->getParam('lf_title'));
113 $bookmark->setDescription($request->getParam('lf_description'));
114 $bookmark->setUrl($request->getParam('lf_url'), $this->container->conf->get('security.allowed_protocols', []));
115 $bookmark->setPrivate(filter_var($request->getParam('lf_private'), FILTER_VALIDATE_BOOLEAN));
116 $bookmark->setTagsString($request->getParam('lf_tags'));
117
118 if ($this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE
119 && true !== $this->container->conf->get('general.enable_async_metadata', true)
120 && $bookmark->shouldUpdateThumbnail()
121 ) {
122 $bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl()));
123 }
124 $this->container->bookmarkService->addOrSet($bookmark, false);
125
126 // To preserve backward compatibility with 3rd parties, plugins still use arrays
127 $formatter = $this->getFormatter('raw');
128 $data = $formatter->format($bookmark);
129 $this->executePageHooks('save_link', $data);
130
131 $bookmark->fromArray($data);
132 $this->container->bookmarkService->set($bookmark);
133
134 // If we are called from the bookmarklet, we must close the popup:
135 if ($request->getParam('source') === 'bookmarklet') {
136 return $response->write('<script>self.close();</script>');
137 } elseif ($request->getParam('source') === 'batch') {
138 return $response;
139 }
140
141 if (!empty($request->getParam('returnurl'))) {
142 $this->container->environment['HTTP_REFERER'] = $request->getParam('returnurl');
143 }
144
145 return $this->redirectFromReferer(
146 $request,
147 $response,
148 ['/admin/add-shaare', '/admin/shaare'], ['addlink', 'post', 'edit_link'],
149 $bookmark->getShortUrl()
150 );
151 }
152
153 /**
154 * Helper function used to display the shaare form whether it's a new or existing bookmark.
155 *
156 * @param array $link data used in template, either from parameters or from the data store
157 */
158 protected function displayForm(array $link, bool $isNew, Request $request, Response $response): Response
159 {
160 $data = $this->buildFormData($link, $isNew, $request);
161
162 $this->executePageHooks('render_editlink', $data, TemplatePage::EDIT_LINK);
163
164 foreach ($data as $key => $value) {
165 $this->assignView($key, $value);
166 }
167
168 $editLabel = false === $isNew ? t('Edit') .' ' : '';
169 $this->assignView(
170 'pagetitle',
171 $editLabel . t('Shaare') .' - '. $this->container->conf->get('general.title', 'Shaarli')
172 );
173
174 return $response->write($this->render(TemplatePage::EDIT_LINK));
175 }
176
177 protected function buildLinkDataFromUrl(Request $request, string $url): array
178 {
179 // Check if URL is not already in database (in this case, we will edit the existing link)
180 $bookmark = $this->container->bookmarkService->findByUrl($url);
181 if (null === $bookmark) {
182 // Get shaare data if it was provided in URL (e.g.: by the bookmarklet).
183 $title = $request->getParam('title');
184 $description = $request->getParam('description');
185 $tags = $request->getParam('tags');
186 if ($request->getParam('private') !== null) {
187 $private = filter_var($request->getParam('private'), FILTER_VALIDATE_BOOLEAN);
188 } else {
189 $private = $this->container->conf->get('privacy.default_private_links', false);
190 }
191
192 // If this is an HTTP(S) link, we try go get the page to extract
193 // the title (otherwise we will to straight to the edit form.)
194 if (true !== $this->container->conf->get('general.enable_async_metadata', true)
195 && empty($title)
196 && strpos(get_url_scheme($url) ?: '', 'http') !== false
197 ) {
198 $metadata = $this->container->metadataRetriever->retrieve($url);
199 }
200
201 if (empty($url)) {
202 $metadata['title'] = $this->container->conf->get('general.default_note_title', t('Note: '));
203 }
204
205 return [
206 'title' => $title ?? $metadata['title'] ?? '',
207 'url' => $url ?? '',
208 'description' => $description ?? $metadata['description'] ?? '',
209 'tags' => $tags ?? $metadata['tags'] ?? '',
210 'private' => $private,
211 'linkIsNew' => true,
212 ];
213 }
214
215 $formatter = $this->getFormatter('raw');
216 $link = $formatter->format($bookmark);
217 $link['linkIsNew'] = false;
218
219 return $link;
220 }
221
222 protected function buildFormData(array $link, bool $isNew, Request $request): array
223 {
224 return escape([
225 'link' => $link,
226 'link_is_new' => $isNew,
227 'http_referer' => $this->container->environment['HTTP_REFERER'] ?? '',
228 'source' => $request->getParam('source') ?? '',
229 'tags' => $this->getTags(),
230 'default_private_links' => $this->container->conf->get('privacy.default_private_links', false),
231 'async_metadata' => $this->container->conf->get('general.enable_async_metadata', true),
232 'retrieve_description' => $this->container->conf->get('general.retrieve_description', false),
233 ]);
234 }
235
236 /**
237 * Memoize formatterFactory->getFormatter() calls.
238 */
239 protected function getFormatter(string $type): BookmarkFormatter
240 {
241 if (!array_key_exists($type, $this->formatters) || $this->formatters[$type] === null) {
242 $this->formatters[$type] = $this->container->formatterFactory->getFormatter($type);
243 }
244
245 return $this->formatters[$type];
246 }
247
248 /**
249 * Memoize bookmarkService->bookmarksCountPerTag() calls.
250 */
251 protected function getTags(): array
252 {
253 if ($this->tags === null) {
254 $this->tags = $this->container->bookmarkService->bookmarksCountPerTag();
255
256 if ($this->container->conf->get('formatter') === 'markdown') {
257 $this->tags[BookmarkMarkdownFormatter::NO_MD_TAG] = 1;
258 }
259 }
260
261 return $this->tags;
262 }
263}
diff --git a/application/front/controller/admin/ShaarliAdminController.php b/application/front/controller/admin/ShaarliAdminController.php
new file mode 100644
index 00000000..c26c9cbe
--- /dev/null
+++ b/application/front/controller/admin/ShaarliAdminController.php
@@ -0,0 +1,71 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use Shaarli\Front\Controller\Visitor\ShaarliVisitorController;
8use Shaarli\Front\Exception\WrongTokenException;
9use Shaarli\Security\SessionManager;
10use Slim\Http\Request;
11
12/**
13 * Class ShaarliAdminController
14 *
15 * All admin controllers (for logged in users) MUST extend this abstract class.
16 * It makes sure that the user is properly logged in, and otherwise throw an exception
17 * which will redirect to the login page.
18 *
19 * @package Shaarli\Front\Controller\Admin
20 */
21abstract class ShaarliAdminController extends ShaarliVisitorController
22{
23 /**
24 * Any persistent action to the config or data store must check the XSRF token validity.
25 */
26 protected function checkToken(Request $request): bool
27 {
28 if (!$this->container->sessionManager->checkToken($request->getParam('token'))) {
29 throw new WrongTokenException();
30 }
31
32 return true;
33 }
34
35 /**
36 * Save a SUCCESS message in user session, which will be displayed on any template page.
37 */
38 protected function saveSuccessMessage(string $message): void
39 {
40 $this->saveMessage(SessionManager::KEY_SUCCESS_MESSAGES, $message);
41 }
42
43 /**
44 * Save a WARNING message in user session, which will be displayed on any template page.
45 */
46 protected function saveWarningMessage(string $message): void
47 {
48 $this->saveMessage(SessionManager::KEY_WARNING_MESSAGES, $message);
49 }
50
51 /**
52 * Save an ERROR message in user session, which will be displayed on any template page.
53 */
54 protected function saveErrorMessage(string $message): void
55 {
56 $this->saveMessage(SessionManager::KEY_ERROR_MESSAGES, $message);
57 }
58
59 /**
60 * Use the sessionManager to save the provided message using the proper type.
61 *
62 * @param string $type successed/warnings/errors
63 */
64 protected function saveMessage(string $type, string $message): void
65 {
66 $messages = $this->container->sessionManager->getSessionParameter($type) ?? [];
67 $messages[] = $message;
68
69 $this->container->sessionManager->setSessionParameter($type, $messages);
70 }
71}
diff --git a/application/front/controller/admin/ThumbnailsController.php b/application/front/controller/admin/ThumbnailsController.php
new file mode 100644
index 00000000..4dc09d38
--- /dev/null
+++ b/application/front/controller/admin/ThumbnailsController.php
@@ -0,0 +1,65 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
8use Shaarli\Render\TemplatePage;
9use Slim\Http\Request;
10use Slim\Http\Response;
11
12/**
13 * Class ToolsController
14 *
15 * Slim controller used to handle thumbnails update.
16 */
17class ThumbnailsController extends ShaarliAdminController
18{
19 /**
20 * GET /admin/thumbnails - Display thumbnails update page
21 */
22 public function index(Request $request, Response $response): Response
23 {
24 $ids = [];
25 foreach ($this->container->bookmarkService->search() as $bookmark) {
26 // A note or not HTTP(S)
27 if ($bookmark->isNote() || !startsWith(strtolower($bookmark->getUrl()), 'http')) {
28 continue;
29 }
30
31 $ids[] = $bookmark->getId();
32 }
33
34 $this->assignView('ids', $ids);
35 $this->assignView(
36 'pagetitle',
37 t('Thumbnails update') .' - '. $this->container->conf->get('general.title', 'Shaarli')
38 );
39
40 return $response->write($this->render(TemplatePage::THUMBNAILS));
41 }
42
43 /**
44 * PATCH /admin/shaare/{id}/thumbnail-update - Route for AJAX calls
45 */
46 public function ajaxUpdate(Request $request, Response $response, array $args): Response
47 {
48 $id = $args['id'] ?? null;
49
50 if (false === ctype_digit($id)) {
51 return $response->withStatus(400);
52 }
53
54 try {
55 $bookmark = $this->container->bookmarkService->get((int) $id);
56 } catch (BookmarkNotFoundException $e) {
57 return $response->withStatus(404);
58 }
59
60 $bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl()));
61 $this->container->bookmarkService->set($bookmark);
62
63 return $response->withJson($this->container->formatterFactory->getFormatter('raw')->format($bookmark));
64 }
65}
diff --git a/application/front/controller/admin/TokenController.php b/application/front/controller/admin/TokenController.php
new file mode 100644
index 00000000..08d68d0a
--- /dev/null
+++ b/application/front/controller/admin/TokenController.php
@@ -0,0 +1,26 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use Slim\Http\Request;
8use Slim\Http\Response;
9
10/**
11 * Class TokenController
12 *
13 * Endpoint used to retrieve a XSRF token. Useful for AJAX requests.
14 */
15class TokenController extends ShaarliAdminController
16{
17 /**
18 * GET /admin/token
19 */
20 public function getToken(Request $request, Response $response): Response
21 {
22 $response = $response->withHeader('Content-Type', 'text/plain');
23
24 return $response->write($this->container->sessionManager->generateToken());
25 }
26}
diff --git a/application/front/controller/admin/ToolsController.php b/application/front/controller/admin/ToolsController.php
new file mode 100644
index 00000000..a87f20d2
--- /dev/null
+++ b/application/front/controller/admin/ToolsController.php
@@ -0,0 +1,35 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use Shaarli\Render\TemplatePage;
8use Slim\Http\Request;
9use Slim\Http\Response;
10
11/**
12 * Class ToolsController
13 *
14 * Slim controller used to display the tools page.
15 */
16class ToolsController extends ShaarliAdminController
17{
18 public function index(Request $request, Response $response): Response
19 {
20 $data = [
21 'pageabsaddr' => index_url($this->container->environment),
22 'sslenabled' => is_https($this->container->environment),
23 ];
24
25 $this->executePageHooks('render_tools', $data, TemplatePage::TOOLS);
26
27 foreach ($data as $key => $value) {
28 $this->assignView($key, $value);
29 }
30
31 $this->assignView('pagetitle', t('Tools') .' - '. $this->container->conf->get('general.title', 'Shaarli'));
32
33 return $response->write($this->render(TemplatePage::TOOLS));
34 }
35}
diff --git a/application/front/controller/visitor/BookmarkListController.php b/application/front/controller/visitor/BookmarkListController.php
new file mode 100644
index 00000000..78c474c9
--- /dev/null
+++ b/application/front/controller/visitor/BookmarkListController.php
@@ -0,0 +1,249 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Visitor;
6
7use Shaarli\Bookmark\Bookmark;
8use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
9use Shaarli\Legacy\LegacyController;
10use Shaarli\Legacy\UnknowLegacyRouteException;
11use Shaarli\Render\TemplatePage;
12use Shaarli\Thumbnailer;
13use Slim\Http\Request;
14use Slim\Http\Response;
15
16/**
17 * Class BookmarkListController
18 *
19 * Slim controller used to render the bookmark list, the home page of Shaarli.
20 * It also displays permalinks, and process legacy routes based on GET parameters.
21 */
22class BookmarkListController extends ShaarliVisitorController
23{
24 /**
25 * GET / - Displays the bookmark list, with optional filter parameters.
26 */
27 public function index(Request $request, Response $response): Response
28 {
29 $legacyResponse = $this->processLegacyController($request, $response);
30 if (null !== $legacyResponse) {
31 return $legacyResponse;
32 }
33
34 $formatter = $this->container->formatterFactory->getFormatter();
35 $formatter->addContextData('base_path', $this->container->basePath);
36
37 $searchTags = normalize_spaces($request->getParam('searchtags') ?? '');
38 $searchTerm = escape(normalize_spaces($request->getParam('searchterm') ?? ''));;
39
40 // Filter bookmarks according search parameters.
41 $visibility = $this->container->sessionManager->getSessionParameter('visibility');
42 $search = [
43 'searchtags' => $searchTags,
44 'searchterm' => $searchTerm,
45 ];
46 $linksToDisplay = $this->container->bookmarkService->search(
47 $search,
48 $visibility,
49 false,
50 !!$this->container->sessionManager->getSessionParameter('untaggedonly')
51 ) ?? [];
52
53 // ---- Handle paging.
54 $keys = [];
55 foreach ($linksToDisplay as $key => $value) {
56 $keys[] = $key;
57 }
58
59 $linksPerPage = $this->container->sessionManager->getSessionParameter('LINKS_PER_PAGE', 20) ?: 20;
60
61 // Select articles according to paging.
62 $pageCount = (int) ceil(count($keys) / $linksPerPage) ?: 1;
63 $page = (int) $request->getParam('page') ?? 1;
64 $page = $page < 1 ? 1 : $page;
65 $page = $page > $pageCount ? $pageCount : $page;
66
67 // Start index.
68 $i = ($page - 1) * $linksPerPage;
69 $end = $i + $linksPerPage;
70
71 $linkDisp = [];
72 $save = false;
73 while ($i < $end && $i < count($keys)) {
74 $save = $this->updateThumbnail($linksToDisplay[$keys[$i]], false) || $save;
75 $link = $formatter->format($linksToDisplay[$keys[$i]]);
76
77 $linkDisp[$keys[$i]] = $link;
78 $i++;
79 }
80
81 if ($save) {
82 $this->container->bookmarkService->save();
83 }
84
85 // Compute paging navigation
86 $searchtagsUrl = $searchTags === '' ? '' : '&searchtags=' . urlencode($searchTags);
87 $searchtermUrl = $searchTerm === '' ? '' : '&searchterm=' . urlencode($searchTerm);
88
89 $previous_page_url = '';
90 if ($i !== count($keys)) {
91 $previous_page_url = '?page=' . ($page + 1) . $searchtermUrl . $searchtagsUrl;
92 }
93 $next_page_url = '';
94 if ($page > 1) {
95 $next_page_url = '?page=' . ($page - 1) . $searchtermUrl . $searchtagsUrl;
96 }
97
98 // Fill all template fields.
99 $data = array_merge(
100 $this->initializeTemplateVars(),
101 [
102 'previous_page_url' => $previous_page_url,
103 'next_page_url' => $next_page_url,
104 'page_current' => $page,
105 'page_max' => $pageCount,
106 'result_count' => count($linksToDisplay),
107 'search_term' => escape($searchTerm),
108 'search_tags' => escape($searchTags),
109 'search_tags_url' => array_map('urlencode', explode(' ', $searchTags)),
110 'visibility' => $visibility,
111 'links' => $linkDisp,
112 ]
113 );
114
115 if (!empty($searchTerm) || !empty($searchTags)) {
116 $data['pagetitle'] = t('Search: ');
117 $data['pagetitle'] .= ! empty($searchTerm) ? $searchTerm . ' ' : '';
118 $bracketWrap = function ($tag) {
119 return '[' . $tag . ']';
120 };
121 $data['pagetitle'] .= ! empty($searchTags)
122 ? implode(' ', array_map($bracketWrap, preg_split('/\s+/', $searchTags))) . ' '
123 : '';
124 $data['pagetitle'] .= '- ';
125 }
126
127 $data['pagetitle'] = ($data['pagetitle'] ?? '') . $this->container->conf->get('general.title', 'Shaarli');
128
129 $this->executePageHooks('render_linklist', $data, TemplatePage::LINKLIST);
130 $this->assignAllView($data);
131
132 return $response->write($this->render(TemplatePage::LINKLIST));
133 }
134
135 /**
136 * GET /shaare/{hash} - Display a single shaare
137 */
138 public function permalink(Request $request, Response $response, array $args): Response
139 {
140 $privateKey = $request->getParam('key');
141
142 try {
143 $bookmark = $this->container->bookmarkService->findByHash($args['hash'], $privateKey);
144 } catch (BookmarkNotFoundException $e) {
145 $this->assignView('error_message', $e->getMessage());
146
147 return $response->write($this->render(TemplatePage::ERROR_404));
148 }
149
150 $this->updateThumbnail($bookmark);
151
152 $formatter = $this->container->formatterFactory->getFormatter();
153 $formatter->addContextData('base_path', $this->container->basePath);
154
155 $data = array_merge(
156 $this->initializeTemplateVars(),
157 [
158 'pagetitle' => $bookmark->getTitle() .' - '. $this->container->conf->get('general.title', 'Shaarli'),
159 'links' => [$formatter->format($bookmark)],
160 ]
161 );
162
163 $this->executePageHooks('render_linklist', $data, TemplatePage::LINKLIST);
164 $this->assignAllView($data);
165
166 return $response->write($this->render(TemplatePage::LINKLIST));
167 }
168
169 /**
170 * Update the thumbnail of a single bookmark if necessary.
171 */
172 protected function updateThumbnail(Bookmark $bookmark, bool $writeDatastore = true): bool
173 {
174 if (false === $this->container->loginManager->isLoggedIn()) {
175 return false;
176 }
177
178 // If thumbnail should be updated, we reset it to null
179 if ($bookmark->shouldUpdateThumbnail()) {
180 $bookmark->setThumbnail(null);
181
182 // Requires an update, not async retrieval, thumbnails enabled
183 if ($bookmark->shouldUpdateThumbnail()
184 && true !== $this->container->conf->get('general.enable_async_metadata', true)
185 && $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE
186 ) {
187 $bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl()));
188 $this->container->bookmarkService->set($bookmark, $writeDatastore);
189
190 return true;
191 }
192 }
193
194 return false;
195 }
196
197 /**
198 * @return string[] Default template variables without values.
199 */
200 protected function initializeTemplateVars(): array
201 {
202 return [
203 'previous_page_url' => '',
204 'next_page_url' => '',
205 'page_max' => '',
206 'search_tags' => '',
207 'result_count' => '',
208 'async_metadata' => $this->container->conf->get('general.enable_async_metadata', true)
209 ];
210 }
211
212 /**
213 * Process legacy routes if necessary. They used query parameters.
214 * If no legacy routes is passed, return null.
215 */
216 protected function processLegacyController(Request $request, Response $response): ?Response
217 {
218 // Legacy smallhash filter
219 $queryString = $this->container->environment['QUERY_STRING'] ?? null;
220 if (null !== $queryString && 1 === preg_match('/^([a-zA-Z0-9-_@]{6})($|&|#)/', $queryString, $match)) {
221 return $this->redirect($response, '/shaare/' . $match[1]);
222 }
223
224 // Legacy controllers (mostly used for redirections)
225 if (null !== $request->getQueryParam('do')) {
226 $legacyController = new LegacyController($this->container);
227
228 try {
229 return $legacyController->process($request, $response, $request->getQueryParam('do'));
230 } catch (UnknowLegacyRouteException $e) {
231 // We ignore legacy 404
232 return null;
233 }
234 }
235
236 // Legacy GET admin routes
237 $legacyGetRoutes = array_intersect(
238 LegacyController::LEGACY_GET_ROUTES,
239 array_keys($request->getQueryParams() ?? [])
240 );
241 if (1 === count($legacyGetRoutes)) {
242 $legacyController = new LegacyController($this->container);
243
244 return $legacyController->process($request, $response, $legacyGetRoutes[0]);
245 }
246
247 return null;
248 }
249}
diff --git a/application/front/controller/visitor/DailyController.php b/application/front/controller/visitor/DailyController.php
new file mode 100644
index 00000000..728bc2d8
--- /dev/null
+++ b/application/front/controller/visitor/DailyController.php
@@ -0,0 +1,205 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Visitor;
6
7use DateTime;
8use Shaarli\Bookmark\Bookmark;
9use Shaarli\Helper\DailyPageHelper;
10use Shaarli\Render\TemplatePage;
11use Slim\Http\Request;
12use Slim\Http\Response;
13
14/**
15 * Class DailyController
16 *
17 * Slim controller used to render the daily page.
18 */
19class DailyController extends ShaarliVisitorController
20{
21 public static $DAILY_RSS_NB_DAYS = 8;
22
23 /**
24 * Controller displaying all bookmarks published in a single day.
25 * It take a `day` date query parameter (format YYYYMMDD).
26 */
27 public function index(Request $request, Response $response): Response
28 {
29 $type = DailyPageHelper::extractRequestedType($request);
30 $format = DailyPageHelper::getFormatByType($type);
31 $latestBookmark = $this->container->bookmarkService->getLatest();
32 $dateTime = DailyPageHelper::extractRequestedDateTime($type, $request->getQueryParam($type), $latestBookmark);
33 $start = DailyPageHelper::getStartDateTimeByType($type, $dateTime);
34 $end = DailyPageHelper::getEndDateTimeByType($type, $dateTime);
35 $dailyDesc = DailyPageHelper::getDescriptionByType($type, $dateTime);
36
37 $linksToDisplay = $this->container->bookmarkService->findByDate(
38 $start,
39 $end,
40 $previousDay,
41 $nextDay
42 );
43
44 $formatter = $this->container->formatterFactory->getFormatter();
45 $formatter->addContextData('base_path', $this->container->basePath);
46 // We pre-format some fields for proper output.
47 foreach ($linksToDisplay as $key => $bookmark) {
48 $linksToDisplay[$key] = $formatter->format($bookmark);
49 // This page is a bit specific, we need raw description to calculate the length
50 $linksToDisplay[$key]['formatedDescription'] = $linksToDisplay[$key]['description'];
51 $linksToDisplay[$key]['description'] = $bookmark->getDescription();
52 }
53
54 $data = [
55 'linksToDisplay' => $linksToDisplay,
56 'dayDate' => $start,
57 'day' => $start->getTimestamp(),
58 'previousday' => $previousDay ? $previousDay->format($format) : '',
59 'nextday' => $nextDay ? $nextDay->format($format) : '',
60 'dayDesc' => $dailyDesc,
61 'type' => $type,
62 'localizedType' => $this->translateType($type),
63 ];
64
65 // Hooks are called before column construction so that plugins don't have to deal with columns.
66 $this->executePageHooks('render_daily', $data, TemplatePage::DAILY);
67
68 $data['cols'] = $this->calculateColumns($data['linksToDisplay']);
69
70 $this->assignAllView($data);
71
72 $mainTitle = $this->container->conf->get('general.title', 'Shaarli');
73 $this->assignView(
74 'pagetitle',
75 $data['localizedType'] . ' - ' . $data['dayDesc'] . ' - ' . $mainTitle
76 );
77
78 return $response->write($this->render(TemplatePage::DAILY));
79 }
80
81 /**
82 * Daily RSS feed: 1 RSS entry per day giving all the bookmarks on that day.
83 * Gives the last 7 days (which have bookmarks).
84 * This RSS feed cannot be filtered and does not trigger plugins yet.
85 */
86 public function rss(Request $request, Response $response): Response
87 {
88 $response = $response->withHeader('Content-Type', 'application/rss+xml; charset=utf-8');
89
90 $pageUrl = page_url($this->container->environment);
91 $cache = $this->container->pageCacheManager->getCachePage($pageUrl);
92
93 $cached = $cache->cachedVersion();
94 if (!empty($cached)) {
95 return $response->write($cached);
96 }
97
98 $days = [];
99 $type = DailyPageHelper::extractRequestedType($request);
100 $format = DailyPageHelper::getFormatByType($type);
101 $length = DailyPageHelper::getRssLengthByType($type);
102 foreach ($this->container->bookmarkService->search() as $bookmark) {
103 $day = $bookmark->getCreated()->format($format);
104
105 // Stop iterating after DAILY_RSS_NB_DAYS entries
106 if (count($days) === $length && !isset($days[$day])) {
107 break;
108 }
109
110 $days[$day][] = $bookmark;
111 }
112
113 // Build the RSS feed.
114 $indexUrl = escape(index_url($this->container->environment));
115
116 $formatter = $this->container->formatterFactory->getFormatter();
117 $formatter->addContextData('index_url', $indexUrl);
118
119 $dataPerDay = [];
120
121 /** @var Bookmark[] $bookmarks */
122 foreach ($days as $day => $bookmarks) {
123 $dayDateTime = DailyPageHelper::extractRequestedDateTime($type, (string) $day);
124 $endDateTime = DailyPageHelper::getEndDateTimeByType($type, $dayDateTime);
125
126 // We only want the RSS entry to be published when the period is over.
127 if (new DateTime() < $endDateTime) {
128 continue;
129 }
130
131 $dataPerDay[$day] = [
132 'date' => $endDateTime,
133 'date_rss' => $endDateTime->format(DateTime::RSS),
134 'date_human' => DailyPageHelper::getDescriptionByType($type, $dayDateTime),
135 'absolute_url' => $indexUrl . 'daily?'. $type .'=' . $day,
136 'links' => [],
137 ];
138
139 foreach ($bookmarks as $key => $bookmark) {
140 $dataPerDay[$day]['links'][$key] = $formatter->format($bookmark);
141
142 // Make permalink URL absolute
143 if ($bookmark->isNote()) {
144 $dataPerDay[$day]['links'][$key]['url'] = rtrim($indexUrl, '/') . $bookmark->getUrl();
145 }
146 }
147 }
148
149 $this->assignAllView([
150 'title' => $this->container->conf->get('general.title', 'Shaarli'),
151 'index_url' => $indexUrl,
152 'page_url' => $pageUrl,
153 'hide_timestamps' => $this->container->conf->get('privacy.hide_timestamps', false),
154 'days' => $dataPerDay,
155 'type' => $type,
156 'localizedType' => $this->translateType($type),
157 ]);
158
159 $rssContent = $this->render(TemplatePage::DAILY_RSS);
160
161 $cache->cache($rssContent);
162
163 return $response->write($rssContent);
164 }
165
166 /**
167 * We need to spread the articles on 3 columns.
168 * did not want to use a JavaScript lib like http://masonry.desandro.com/
169 * so I manually spread entries with a simple method: I roughly evaluate the
170 * height of a div according to title and description length.
171 */
172 protected function calculateColumns(array $links): array
173 {
174 // Entries to display, for each column.
175 $columns = [[], [], []];
176 // Rough estimate of columns fill.
177 $fill = [0, 0, 0];
178 foreach ($links as $link) {
179 // Roughly estimate length of entry (by counting characters)
180 // Title: 30 chars = 1 line. 1 line is 30 pixels height.
181 // Description: 836 characters gives roughly 342 pixel height.
182 // This is not perfect, but it's usually OK.
183 $length = strlen($link['title'] ?? '') + (342 * strlen($link['description'] ?? '')) / 836;
184 if (! empty($link['thumbnail'])) {
185 $length += 100; // 1 thumbnails roughly takes 100 pixels height.
186 }
187 // Then put in column which is the less filled:
188 $smallest = min($fill); // find smallest value in array.
189 $index = array_search($smallest, $fill); // find index of this smallest value.
190 array_push($columns[$index], $link); // Put entry in this column.
191 $fill[$index] += $length;
192 }
193
194 return $columns;
195 }
196
197 protected function translateType($type): string
198 {
199 return [
200 t('day') => t('Daily'),
201 t('week') => t('Weekly'),
202 t('month') => t('Monthly'),
203 ][t($type)] ?? t('Daily');
204 }
205}
diff --git a/application/front/controller/visitor/ErrorController.php b/application/front/controller/visitor/ErrorController.php
new file mode 100644
index 00000000..8da11172
--- /dev/null
+++ b/application/front/controller/visitor/ErrorController.php
@@ -0,0 +1,42 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Visitor;
6
7use Shaarli\Front\Exception\ShaarliFrontException;
8use Slim\Http\Request;
9use Slim\Http\Response;
10
11/**
12 * Controller used to render the error page, with a provided exception.
13 * It is actually used as a Slim error handler.
14 */
15class ErrorController extends ShaarliVisitorController
16{
17 public function __invoke(Request $request, Response $response, \Throwable $throwable): Response
18 {
19 // Unknown error encountered
20 $this->container->pageBuilder->reset();
21
22 if ($throwable instanceof ShaarliFrontException) {
23 // Functional error
24 $this->assignView('message', nl2br($throwable->getMessage()));
25
26 $response = $response->withStatus($throwable->getCode());
27 } else {
28 // Internal error (any other Throwable)
29 if ($this->container->conf->get('dev.debug', false)) {
30 $this->assignView('message', $throwable->getMessage());
31 $this->assignView('stacktrace', exception2text($throwable));
32 } else {
33 $this->assignView('message', t('An unexpected error occurred.'));
34 }
35
36 $response = $response->withStatus(500);
37 }
38
39
40 return $response->write($this->render('error'));
41 }
42}
diff --git a/application/front/controller/visitor/ErrorNotFoundController.php b/application/front/controller/visitor/ErrorNotFoundController.php
new file mode 100644
index 00000000..758dd83b
--- /dev/null
+++ b/application/front/controller/visitor/ErrorNotFoundController.php
@@ -0,0 +1,29 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Visitor;
6
7use Slim\Http\Request;
8use Slim\Http\Response;
9
10/**
11 * Controller used to render the 404 error page.
12 */
13class ErrorNotFoundController extends ShaarliVisitorController
14{
15 public function __invoke(Request $request, Response $response): Response
16 {
17 // Request from the API
18 if (false !== strpos($request->getRequestTarget(), '/api/v1')) {
19 return $response->withStatus(404);
20 }
21
22 // This is required because the middleware is ignored if the route is not found.
23 $this->container->basePath = rtrim($request->getUri()->getBasePath(), '/');
24
25 $this->assignView('error_message', t('Requested page could not be found.'));
26
27 return $response->withStatus(404)->write($this->render('404'));
28 }
29}
diff --git a/application/front/controller/visitor/FeedController.php b/application/front/controller/visitor/FeedController.php
new file mode 100644
index 00000000..8d8b546a
--- /dev/null
+++ b/application/front/controller/visitor/FeedController.php
@@ -0,0 +1,58 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Visitor;
6
7use Shaarli\Feed\FeedBuilder;
8use Slim\Http\Request;
9use Slim\Http\Response;
10
11/**
12 * Class FeedController
13 *
14 * Slim controller handling ATOM and RSS feed.
15 */
16class FeedController extends ShaarliVisitorController
17{
18 public function atom(Request $request, Response $response): Response
19 {
20 return $this->processRequest(FeedBuilder::$FEED_ATOM, $request, $response);
21 }
22
23 public function rss(Request $request, Response $response): Response
24 {
25 return $this->processRequest(FeedBuilder::$FEED_RSS, $request, $response);
26 }
27
28 protected function processRequest(string $feedType, Request $request, Response $response): Response
29 {
30 $response = $response->withHeader('Content-Type', 'application/'. $feedType .'+xml; charset=utf-8');
31
32 $pageUrl = page_url($this->container->environment);
33 $cache = $this->container->pageCacheManager->getCachePage($pageUrl);
34
35 $cached = $cache->cachedVersion();
36 if (!empty($cached)) {
37 return $response->write($cached);
38 }
39
40 // Generate data.
41 $this->container->feedBuilder->setLocale(strtolower(setlocale(LC_COLLATE, 0)));
42 $this->container->feedBuilder->setHideDates($this->container->conf->get('privacy.hide_timestamps', false));
43 $this->container->feedBuilder->setUsePermalinks(
44 null !== $request->getParam('permalinks') || !$this->container->conf->get('feed.rss_permalinks')
45 );
46
47 $data = $this->container->feedBuilder->buildData($feedType, $request->getParams());
48
49 $this->executePageHooks('render_feed', $data, 'feed.' . $feedType);
50 $this->assignAllView($data);
51
52 $content = $this->render('feed.' . $feedType);
53
54 $cache->cache($content);
55
56 return $response->write($content);
57 }
58}
diff --git a/application/front/controller/visitor/InstallController.php b/application/front/controller/visitor/InstallController.php
new file mode 100644
index 00000000..22329294
--- /dev/null
+++ b/application/front/controller/visitor/InstallController.php
@@ -0,0 +1,175 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Visitor;
6
7use Shaarli\Container\ShaarliContainer;
8use Shaarli\Front\Exception\AlreadyInstalledException;
9use Shaarli\Front\Exception\ResourcePermissionException;
10use Shaarli\Helper\ApplicationUtils;
11use Shaarli\Languages;
12use Shaarli\Security\SessionManager;
13use Slim\Http\Request;
14use Slim\Http\Response;
15
16/**
17 * Slim controller used to render install page, and create initial configuration file.
18 */
19class InstallController extends ShaarliVisitorController
20{
21 public const SESSION_TEST_KEY = 'session_tested';
22 public const SESSION_TEST_VALUE = 'Working';
23
24 public function __construct(ShaarliContainer $container)
25 {
26 parent::__construct($container);
27
28 if (is_file($this->container->conf->getConfigFileExt())) {
29 throw new AlreadyInstalledException();
30 }
31 }
32
33 /**
34 * Display the install template page.
35 * Also test file permissions and sessions beforehand.
36 */
37 public function index(Request $request, Response $response): Response
38 {
39 // Before installation, we'll make sure that permissions are set properly, and sessions are working.
40 $this->checkPermissions();
41
42 if (static::SESSION_TEST_VALUE
43 !== $this->container->sessionManager->getSessionParameter(static::SESSION_TEST_KEY)
44 ) {
45 $this->container->sessionManager->setSessionParameter(static::SESSION_TEST_KEY, static::SESSION_TEST_VALUE);
46
47 return $this->redirect($response, '/install/session-test');
48 }
49
50 [$continents, $cities] = generateTimeZoneData(timezone_identifiers_list(), date_default_timezone_get());
51
52 $this->assignView('continents', $continents);
53 $this->assignView('cities', $cities);
54 $this->assignView('languages', Languages::getAvailableLanguages());
55
56 $phpEol = new \DateTimeImmutable(ApplicationUtils::getPhpEol(PHP_VERSION));
57
58 $this->assignView('php_version', PHP_VERSION);
59 $this->assignView('php_eol', format_date($phpEol, false));
60 $this->assignView('php_has_reached_eol', $phpEol < new \DateTimeImmutable());
61 $this->assignView('php_extensions', ApplicationUtils::getPhpExtensionsRequirement());
62 $this->assignView('permissions', ApplicationUtils::checkResourcePermissions($this->container->conf));
63
64 $this->assignView('pagetitle', t('Install Shaarli'));
65
66 return $response->write($this->render('install'));
67 }
68
69 /**
70 * Route checking that the session parameter has been properly saved between two distinct requests.
71 * If the session parameter is preserved, redirect to install template page, otherwise displays error.
72 */
73 public function sessionTest(Request $request, Response $response): Response
74 {
75 // This part makes sure sessions works correctly.
76 // (Because on some hosts, session.save_path may not be set correctly,
77 // or we may not have write access to it.)
78 if (static::SESSION_TEST_VALUE
79 !== $this->container->sessionManager->getSessionParameter(static::SESSION_TEST_KEY)
80 ) {
81 // Step 2: Check if data in session is correct.
82 $msg = t(
83 '<pre>Sessions do not seem to work correctly on your server.<br>'.
84 'Make sure the variable "session.save_path" is set correctly in your PHP config, '.
85 'and that you have write access to it.<br>'.
86 'It currently points to %s.<br>'.
87 'On some browsers, accessing your server via a hostname like \'localhost\' '.
88 'or any custom hostname without a dot causes cookie storage to fail. '.
89 'We recommend accessing your server via it\'s IP address or Fully Qualified Domain Name.<br>'
90 );
91 $msg = sprintf($msg, $this->container->sessionManager->getSavePath());
92
93 $this->assignView('message', $msg);
94
95 return $response->write($this->render('error'));
96 }
97
98 return $this->redirect($response, '/install');
99 }
100
101 /**
102 * Save installation form and initialize config file and datastore if necessary.
103 */
104 public function save(Request $request, Response $response): Response
105 {
106 $timezone = 'UTC';
107 if (!empty($request->getParam('continent'))
108 && !empty($request->getParam('city'))
109 && isTimeZoneValid($request->getParam('continent'), $request->getParam('city'))
110 ) {
111 $timezone = $request->getParam('continent') . '/' . $request->getParam('city');
112 }
113 $this->container->conf->set('general.timezone', $timezone);
114
115 $login = $request->getParam('setlogin');
116 $this->container->conf->set('credentials.login', $login);
117 $salt = sha1(uniqid('', true) .'_'. mt_rand());
118 $this->container->conf->set('credentials.salt', $salt);
119 $this->container->conf->set('credentials.hash', sha1($request->getParam('setpassword') . $login . $salt));
120
121 if (!empty($request->getParam('title'))) {
122 $this->container->conf->set('general.title', escape($request->getParam('title')));
123 } else {
124 $this->container->conf->set(
125 'general.title',
126 'Shared bookmarks on '.escape(index_url($this->container->environment))
127 );
128 }
129
130 $this->container->conf->set('translation.language', escape($request->getParam('language')));
131 $this->container->conf->set('updates.check_updates', !empty($request->getParam('updateCheck')));
132 $this->container->conf->set('api.enabled', !empty($request->getParam('enableApi')));
133 $this->container->conf->set(
134 'api.secret',
135 generate_api_secret(
136 $this->container->conf->get('credentials.login'),
137 $this->container->conf->get('credentials.salt')
138 )
139 );
140 $this->container->conf->set('general.header_link', $this->container->basePath . '/');
141
142 try {
143 // Everything is ok, let's create config file.
144 $this->container->conf->write($this->container->loginManager->isLoggedIn());
145 } catch (\Exception $e) {
146 $this->assignView('message', t('Error while writing config file after configuration update.'));
147 $this->assignView('stacktrace', $e->getMessage() . PHP_EOL . $e->getTraceAsString());
148
149 return $response->write($this->render('error'));
150 }
151
152 $this->container->sessionManager->setSessionParameter(
153 SessionManager::KEY_SUCCESS_MESSAGES,
154 [t('Shaarli is now configured. Please login and start shaaring your bookmarks!')]
155 );
156
157 return $this->redirect($response, '/login');
158 }
159
160 protected function checkPermissions(): bool
161 {
162 // Ensure Shaarli has proper access to its resources
163 $errors = ApplicationUtils::checkResourcePermissions($this->container->conf, true);
164 if (empty($errors)) {
165 return true;
166 }
167
168 $message = t('Insufficient permissions:') . PHP_EOL;
169 foreach ($errors as $error) {
170 $message .= PHP_EOL . $error;
171 }
172
173 throw new ResourcePermissionException($message);
174 }
175}
diff --git a/application/front/controller/visitor/LoginController.php b/application/front/controller/visitor/LoginController.php
new file mode 100644
index 00000000..f5038fe3
--- /dev/null
+++ b/application/front/controller/visitor/LoginController.php
@@ -0,0 +1,153 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Visitor;
6
7use Shaarli\Front\Exception\CantLoginException;
8use Shaarli\Front\Exception\LoginBannedException;
9use Shaarli\Front\Exception\WrongTokenException;
10use Shaarli\Render\TemplatePage;
11use Shaarli\Security\CookieManager;
12use Shaarli\Security\SessionManager;
13use Slim\Http\Request;
14use Slim\Http\Response;
15
16/**
17 * Class LoginController
18 *
19 * Slim controller used to render the login page.
20 *
21 * The login page is not available if the user is banned
22 * or if open shaarli setting is enabled.
23 */
24class LoginController extends ShaarliVisitorController
25{
26 /**
27 * GET /login - Display the login page.
28 */
29 public function index(Request $request, Response $response): Response
30 {
31 try {
32 $this->checkLoginState();
33 } catch (CantLoginException $e) {
34 return $this->redirect($response, '/');
35 }
36
37 if ($request->getParam('login') !== null) {
38 $this->assignView('username', escape($request->getParam('login')));
39 }
40
41 $returnUrl = $request->getParam('returnurl') ?? $this->container->environment['HTTP_REFERER'] ?? null;
42
43 $this
44 ->assignView('returnurl', escape($returnUrl))
45 ->assignView('remember_user_default', $this->container->conf->get('privacy.remember_user_default', true))
46 ->assignView('pagetitle', t('Login') .' - '. $this->container->conf->get('general.title', 'Shaarli'))
47 ;
48
49 return $response->write($this->render(TemplatePage::LOGIN));
50 }
51
52 /**
53 * POST /login - Process login
54 */
55 public function login(Request $request, Response $response): Response
56 {
57 if (!$this->container->sessionManager->checkToken($request->getParam('token'))) {
58 throw new WrongTokenException();
59 }
60
61 try {
62 $this->checkLoginState();
63 } catch (CantLoginException $e) {
64 return $this->redirect($response, '/');
65 }
66
67 if (!$this->container->loginManager->checkCredentials(
68 client_ip_id($this->container->environment),
69 $request->getParam('login'),
70 $request->getParam('password')
71 )
72 ) {
73 $this->container->loginManager->handleFailedLogin($this->container->environment);
74
75 $this->container->sessionManager->setSessionParameter(
76 SessionManager::KEY_ERROR_MESSAGES,
77 [t('Wrong login/password.')]
78 );
79
80 // Call controller directly instead of unnecessary redirection
81 return $this->index($request, $response);
82 }
83
84 $this->container->loginManager->handleSuccessfulLogin($this->container->environment);
85
86 $cookiePath = $this->container->basePath . '/';
87 $expirationTime = $this->saveLongLastingSession($request, $cookiePath);
88 $this->renewUserSession($cookiePath, $expirationTime);
89
90 // Force referer from given return URL
91 $this->container->environment['HTTP_REFERER'] = $request->getParam('returnurl');
92
93 return $this->redirectFromReferer($request, $response, ['login', 'install']);
94 }
95
96 /**
97 * Make sure that the user is allowed to login and/or displaying the login page:
98 * - not already logged in
99 * - not open shaarli
100 * - not banned
101 */
102 protected function checkLoginState(): bool
103 {
104 if ($this->container->loginManager->isLoggedIn()
105 || $this->container->conf->get('security.open_shaarli', false)
106 ) {
107 throw new CantLoginException();
108 }
109
110 if (true !== $this->container->loginManager->canLogin($this->container->environment)) {
111 throw new LoginBannedException();
112 }
113
114 return true;
115 }
116
117 /**
118 * @return int Session duration in seconds
119 */
120 protected function saveLongLastingSession(Request $request, string $cookiePath): int
121 {
122 if (empty($request->getParam('longlastingsession'))) {
123 // Standard session expiration (=when browser closes)
124 $expirationTime = 0;
125 } else {
126 // Keep the session cookie even after the browser closes
127 $this->container->sessionManager->setStaySignedIn(true);
128 $expirationTime = $this->container->sessionManager->extendSession();
129 }
130
131 $this->container->cookieManager->setCookieParameter(
132 CookieManager::STAY_SIGNED_IN,
133 $this->container->loginManager->getStaySignedInToken(),
134 $expirationTime,
135 $cookiePath
136 );
137
138 return $expirationTime;
139 }
140
141 protected function renewUserSession(string $cookiePath, int $expirationTime): void
142 {
143 // Send cookie with the new expiration date to the browser
144 $this->container->sessionManager->destroy();
145 $this->container->sessionManager->cookieParameters(
146 $expirationTime,
147 $cookiePath,
148 $this->container->environment['SERVER_NAME']
149 );
150 $this->container->sessionManager->start();
151 $this->container->sessionManager->regenerateId(true);
152 }
153}
diff --git a/application/front/controller/visitor/OpenSearchController.php b/application/front/controller/visitor/OpenSearchController.php
new file mode 100644
index 00000000..36d60acf
--- /dev/null
+++ b/application/front/controller/visitor/OpenSearchController.php
@@ -0,0 +1,27 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Visitor;
6
7use Shaarli\Render\TemplatePage;
8use Slim\Http\Request;
9use Slim\Http\Response;
10
11/**
12 * Class OpenSearchController
13 *
14 * Slim controller used to render open search template.
15 * This allows to add Shaarli as a search engine within the browser.
16 */
17class OpenSearchController extends ShaarliVisitorController
18{
19 public function index(Request $request, Response $response): Response
20 {
21 $response = $response->withHeader('Content-Type', 'application/opensearchdescription+xml; charset=utf-8');
22
23 $this->assignView('serverurl', index_url($this->container->environment));
24
25 return $response->write($this->render(TemplatePage::OPEN_SEARCH));
26 }
27}
diff --git a/application/front/controller/visitor/PictureWallController.php b/application/front/controller/visitor/PictureWallController.php
new file mode 100644
index 00000000..3c57f8dd
--- /dev/null
+++ b/application/front/controller/visitor/PictureWallController.php
@@ -0,0 +1,54 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Visitor;
6
7use Shaarli\Front\Exception\ThumbnailsDisabledException;
8use Shaarli\Render\TemplatePage;
9use Shaarli\Thumbnailer;
10use Slim\Http\Request;
11use Slim\Http\Response;
12
13/**
14 * Class PicturesWallController
15 *
16 * Slim controller used to render the pictures wall page.
17 * If thumbnails mode is set to NONE, we just render the template without any image.
18 */
19class PictureWallController extends ShaarliVisitorController
20{
21 public function index(Request $request, Response $response): Response
22 {
23 if ($this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) === Thumbnailer::MODE_NONE) {
24 throw new ThumbnailsDisabledException();
25 }
26
27 $this->assignView(
28 'pagetitle',
29 t('Picture wall') .' - '. $this->container->conf->get('general.title', 'Shaarli')
30 );
31
32 // Optionally filter the results:
33 $links = $this->container->bookmarkService->search($request->getQueryParams());
34 $linksToDisplay = [];
35
36 // Get only bookmarks which have a thumbnail.
37 // Note: we do not retrieve thumbnails here, the request is too heavy.
38 $formatter = $this->container->formatterFactory->getFormatter('raw');
39 foreach ($links as $key => $link) {
40 if (!empty($link->getThumbnail())) {
41 $linksToDisplay[] = $formatter->format($link);
42 }
43 }
44
45 $data = ['linksToDisplay' => $linksToDisplay];
46 $this->executePageHooks('render_picwall', $data, TemplatePage::PICTURE_WALL);
47
48 foreach ($data as $key => $value) {
49 $this->assignView($key, $value);
50 }
51
52 return $response->write($this->render(TemplatePage::PICTURE_WALL));
53 }
54}
diff --git a/application/front/controller/visitor/PublicSessionFilterController.php b/application/front/controller/visitor/PublicSessionFilterController.php
new file mode 100644
index 00000000..1a66362d
--- /dev/null
+++ b/application/front/controller/visitor/PublicSessionFilterController.php
@@ -0,0 +1,46 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Visitor;
6
7use Shaarli\Security\SessionManager;
8use Slim\Http\Request;
9use Slim\Http\Response;
10
11/**
12 * Slim controller used to handle filters stored in the visitor session, links per page, etc.
13 */
14class PublicSessionFilterController extends ShaarliVisitorController
15{
16 /**
17 * GET /links-per-page: set the number of bookmarks to display per page in homepage
18 */
19 public function linksPerPage(Request $request, Response $response): Response
20 {
21 $linksPerPage = $request->getParam('nb') ?? null;
22 if (null === $linksPerPage || false === is_numeric($linksPerPage)) {
23 $linksPerPage = $this->container->conf->get('general.links_per_page', 20);
24 }
25
26 $this->container->sessionManager->setSessionParameter(
27 SessionManager::KEY_LINKS_PER_PAGE,
28 abs(intval($linksPerPage))
29 );
30
31 return $this->redirectFromReferer($request, $response, ['linksperpage'], ['nb']);
32 }
33
34 /**
35 * GET /untagged-only: allows to display only bookmarks without any tag
36 */
37 public function untaggedOnly(Request $request, Response $response): Response
38 {
39 $this->container->sessionManager->setSessionParameter(
40 SessionManager::KEY_UNTAGGED_ONLY,
41 empty($this->container->sessionManager->getSessionParameter(SessionManager::KEY_UNTAGGED_ONLY))
42 );
43
44 return $this->redirectFromReferer($request, $response, ['untaggedonly', 'untagged-only']);
45 }
46}
diff --git a/application/front/controller/visitor/ShaarliVisitorController.php b/application/front/controller/visitor/ShaarliVisitorController.php
new file mode 100644
index 00000000..54f9fe03
--- /dev/null
+++ b/application/front/controller/visitor/ShaarliVisitorController.php
@@ -0,0 +1,181 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Visitor;
6
7use Shaarli\Bookmark\BookmarkFilter;
8use Shaarli\Container\ShaarliContainer;
9use Slim\Http\Request;
10use Slim\Http\Response;
11
12/**
13 * Class ShaarliVisitorController
14 *
15 * All controllers accessible by visitors (non logged in users) should extend this abstract class.
16 * Contains a few helper function for template rendering, plugins, etc.
17 *
18 * @package Shaarli\Front\Controller\Visitor
19 */
20abstract class ShaarliVisitorController
21{
22 /** @var ShaarliContainer */
23 protected $container;
24
25 /** @param ShaarliContainer $container Slim container (extended for attribute completion). */
26 public function __construct(ShaarliContainer $container)
27 {
28 $this->container = $container;
29 }
30
31 /**
32 * Assign variables to RainTPL template through the PageBuilder.
33 *
34 * @param mixed $value Value to assign to the template
35 */
36 protected function assignView(string $name, $value): self
37 {
38 $this->container->pageBuilder->assign($name, $value);
39
40 return $this;
41 }
42
43 /**
44 * Assign variables to RainTPL template through the PageBuilder.
45 *
46 * @param mixed $data Values to assign to the template and their keys
47 */
48 protected function assignAllView(array $data): self
49 {
50 foreach ($data as $key => $value) {
51 $this->assignView($key, $value);
52 }
53
54 return $this;
55 }
56
57 protected function render(string $template): string
58 {
59 $this->assignView('linkcount', $this->container->bookmarkService->count(BookmarkFilter::$ALL));
60 $this->assignView('privateLinkcount', $this->container->bookmarkService->count(BookmarkFilter::$PRIVATE));
61
62 $this->executeDefaultHooks($template);
63
64 $this->assignView('plugin_errors', $this->container->pluginManager->getErrors());
65
66 return $this->container->pageBuilder->render($template, $this->container->basePath);
67 }
68
69 /**
70 * Call plugin hooks for header, footer and includes, specifying which page will be rendered.
71 * Then assign generated data to RainTPL.
72 */
73 protected function executeDefaultHooks(string $template): void
74 {
75 $common_hooks = [
76 'includes',
77 'header',
78 'footer',
79 ];
80
81 $parameters = $this->buildPluginParameters($template);
82
83 foreach ($common_hooks as $name) {
84 $pluginData = [];
85 $this->container->pluginManager->executeHooks(
86 'render_' . $name,
87 $pluginData,
88 $parameters
89 );
90 $this->assignView('plugins_' . $name, $pluginData);
91 }
92 }
93
94 protected function executePageHooks(string $hook, array &$data, string $template = null): void
95 {
96 $this->container->pluginManager->executeHooks(
97 $hook,
98 $data,
99 $this->buildPluginParameters($template)
100 );
101 }
102
103 protected function buildPluginParameters(?string $template): array
104 {
105 return [
106 'target' => $template,
107 'loggedin' => $this->container->loginManager->isLoggedIn(),
108 'basePath' => $this->container->basePath,
109 'rootPath' => preg_replace('#/index\.php$#', '', $this->container->basePath),
110 'bookmarkService' => $this->container->bookmarkService
111 ];
112 }
113
114 /**
115 * Simple helper which prepend the base path to redirect path.
116 *
117 * @param Response $response
118 * @param string $path Absolute path, e.g.: `/`, or `/admin/shaare/123` regardless of install directory
119 *
120 * @return Response updated
121 */
122 protected function redirect(Response $response, string $path): Response
123 {
124 return $response->withRedirect($this->container->basePath . $path);
125 }
126
127 /**
128 * Generates a redirection to the previous page, based on the HTTP_REFERER.
129 * It fails back to the home page.
130 *
131 * @param array $loopTerms Terms to remove from path and query string to prevent direction loop.
132 * @param array $clearParams List of parameter to remove from the query string of the referrer.
133 */
134 protected function redirectFromReferer(
135 Request $request,
136 Response $response,
137 array $loopTerms = [],
138 array $clearParams = [],
139 string $anchor = null
140 ): Response {
141 $defaultPath = $this->container->basePath . '/';
142 $referer = $this->container->environment['HTTP_REFERER'] ?? null;
143
144 if (null !== $referer) {
145 $currentUrl = parse_url($referer);
146 // If the referer is not related to Shaarli instance, redirect to default
147 if (isset($currentUrl['host'])
148 && strpos(index_url($this->container->environment), $currentUrl['host']) === false
149 ) {
150 return $response->withRedirect($defaultPath);
151 }
152
153 parse_str($currentUrl['query'] ?? '', $params);
154 $path = $currentUrl['path'] ?? $defaultPath;
155 } else {
156 $params = [];
157 $path = $defaultPath;
158 }
159
160 // Prevent redirection loop
161 if (isset($currentUrl)) {
162 foreach ($clearParams as $value) {
163 unset($params[$value]);
164 }
165
166 $checkQuery = implode('', array_keys($params));
167 foreach ($loopTerms as $value) {
168 if (strpos($path . $checkQuery, $value) !== false) {
169 $params = [];
170 $path = $defaultPath;
171 break;
172 }
173 }
174 }
175
176 $queryString = count($params) > 0 ? '?'. http_build_query($params) : '';
177 $anchor = $anchor ? '#' . $anchor : '';
178
179 return $response->withRedirect($path . $queryString . $anchor);
180 }
181}
diff --git a/application/front/controller/visitor/TagCloudController.php b/application/front/controller/visitor/TagCloudController.php
new file mode 100644
index 00000000..76ed7690
--- /dev/null
+++ b/application/front/controller/visitor/TagCloudController.php
@@ -0,0 +1,121 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Visitor;
6
7use Slim\Http\Request;
8use Slim\Http\Response;
9
10/**
11 * Class TagCloud
12 *
13 * Slim controller used to render the tag cloud and tag list pages.
14 */
15class TagCloudController extends ShaarliVisitorController
16{
17 protected const TYPE_CLOUD = 'cloud';
18 protected const TYPE_LIST = 'list';
19
20 /**
21 * Display the tag cloud through the template engine.
22 * This controller a few filters:
23 * - Visibility stored in the session for logged in users
24 * - `searchtags` query parameter: will return tags associated with filter in at least one bookmark
25 */
26 public function cloud(Request $request, Response $response): Response
27 {
28 return $this->processRequest(static::TYPE_CLOUD, $request, $response);
29 }
30
31 /**
32 * Display the tag list through the template engine.
33 * This controller a few filters:
34 * - Visibility stored in the session for logged in users
35 * - `searchtags` query parameter: will return tags associated with filter in at least one bookmark
36 * - `sort` query parameters:
37 * + `usage` (default): most used tags first
38 * + `alpha`: alphabetical order
39 */
40 public function list(Request $request, Response $response): Response
41 {
42 return $this->processRequest(static::TYPE_LIST, $request, $response);
43 }
44
45 /**
46 * Process the request for both tag cloud and tag list endpoints.
47 */
48 protected function processRequest(string $type, Request $request, Response $response): Response
49 {
50 if ($this->container->loginManager->isLoggedIn() === true) {
51 $visibility = $this->container->sessionManager->getSessionParameter('visibility');
52 }
53
54 $sort = $request->getQueryParam('sort');
55 $searchTags = $request->getQueryParam('searchtags');
56 $filteringTags = $searchTags !== null ? explode(' ', $searchTags) : [];
57
58 $tags = $this->container->bookmarkService->bookmarksCountPerTag($filteringTags, $visibility ?? null);
59
60 if (static::TYPE_CLOUD === $type || 'alpha' === $sort) {
61 // TODO: the sorting should be handled by bookmarkService instead of the controller
62 alphabetical_sort($tags, false, true);
63 }
64
65 if (static::TYPE_CLOUD === $type) {
66 $tags = $this->formatTagsForCloud($tags);
67 }
68
69 $tagsUrl = [];
70 foreach ($tags as $tag => $value) {
71 $tagsUrl[escape($tag)] = urlencode((string) $tag);
72 }
73
74 $searchTags = implode(' ', escape($filteringTags));
75 $searchTagsUrl = urlencode(implode(' ', $filteringTags));
76 $data = [
77 'search_tags' => escape($searchTags),
78 'search_tags_url' => $searchTagsUrl,
79 'tags' => escape($tags),
80 'tags_url' => $tagsUrl,
81 ];
82 $this->executePageHooks('render_tag' . $type, $data, 'tag.' . $type);
83 $this->assignAllView($data);
84
85 $searchTags = !empty($searchTags) ? $searchTags .' - ' : '';
86 $this->assignView(
87 'pagetitle',
88 $searchTags . t('Tag '. $type) .' - '. $this->container->conf->get('general.title', 'Shaarli')
89 );
90
91 return $response->write($this->render('tag.' . $type));
92 }
93
94 /**
95 * Format the tags array for the tag cloud template.
96 *
97 * @param array<string, int> $tags List of tags as key with count as value
98 *
99 * @return mixed[] List of tags as key, with count and expected font size in a subarray
100 */
101 protected function formatTagsForCloud(array $tags): array
102 {
103 // We sort tags alphabetically, then choose a font size according to count.
104 // First, find max value.
105 $maxCount = count($tags) > 0 ? max($tags) : 0;
106 $logMaxCount = $maxCount > 1 ? log($maxCount, 30) : 1;
107 $tagList = [];
108 foreach ($tags as $key => $value) {
109 // Tag font size scaling:
110 // default 15 and 30 logarithm bases affect scaling,
111 // 2.2 and 0.8 are arbitrary font sizes in em.
112 $size = log($value, 15) / $logMaxCount * 2.2 + 0.8;
113 $tagList[$key] = [
114 'count' => $value,
115 'size' => number_format($size, 2, '.', ''),
116 ];
117 }
118
119 return $tagList;
120 }
121}
diff --git a/application/front/controller/visitor/TagController.php b/application/front/controller/visitor/TagController.php
new file mode 100644
index 00000000..de4e7ea2
--- /dev/null
+++ b/application/front/controller/visitor/TagController.php
@@ -0,0 +1,118 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Visitor;
6
7use Slim\Http\Request;
8use Slim\Http\Response;
9
10/**
11 * Class TagController
12 *
13 * Slim controller handle tags.
14 */
15class TagController extends ShaarliVisitorController
16{
17 /**
18 * Add another tag in the current search through an HTTP redirection.
19 *
20 * @param array $args Should contain `newTag` key as tag to add to current search
21 */
22 public function addTag(Request $request, Response $response, array $args): Response
23 {
24 $newTag = $args['newTag'] ?? null;
25 $referer = $this->container->environment['HTTP_REFERER'] ?? null;
26
27 // In case browser does not send HTTP_REFERER, we search a single tag
28 if (null === $referer) {
29 if (null !== $newTag) {
30 return $this->redirect($response, '/?searchtags='. urlencode($newTag));
31 }
32
33 return $this->redirect($response, '/');
34 }
35
36 $currentUrl = parse_url($referer);
37 parse_str($currentUrl['query'] ?? '', $params);
38
39 if (null === $newTag) {
40 return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params));
41 }
42
43 // Prevent redirection loop
44 if (isset($params['addtag'])) {
45 unset($params['addtag']);
46 }
47
48 // Check if this tag is already in the search query and ignore it if it is.
49 // Each tag is always separated by a space
50 $currentTags = isset($params['searchtags']) ? explode(' ', $params['searchtags']) : [];
51
52 $addtag = true;
53 foreach ($currentTags as $value) {
54 if ($value === $newTag) {
55 $addtag = false;
56 break;
57 }
58 }
59
60 // Append the tag if necessary
61 if (true === $addtag) {
62 $currentTags[] = trim($newTag);
63 }
64
65 $params['searchtags'] = trim(implode(' ', $currentTags));
66
67 // We also remove page (keeping the same page has no sense, since the results are different)
68 unset($params['page']);
69
70 return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params));
71 }
72
73 /**
74 * Remove a tag from the current search through an HTTP redirection.
75 *
76 * @param array $args Should contain `tag` key as tag to remove from current search
77 */
78 public function removeTag(Request $request, Response $response, array $args): Response
79 {
80 $referer = $this->container->environment['HTTP_REFERER'] ?? null;
81
82 // If the referrer is not provided, we can update the search, so we failback on the bookmark list
83 if (empty($referer)) {
84 return $this->redirect($response, '/');
85 }
86
87 $tagToRemove = $args['tag'] ?? null;
88 $currentUrl = parse_url($referer);
89 parse_str($currentUrl['query'] ?? '', $params);
90
91 if (null === $tagToRemove) {
92 return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params));
93 }
94
95 // Prevent redirection loop
96 if (isset($params['removetag'])) {
97 unset($params['removetag']);
98 }
99
100 if (isset($params['searchtags'])) {
101 $tags = explode(' ', $params['searchtags']);
102 // Remove value from array $tags.
103 $tags = array_diff($tags, [$tagToRemove]);
104 $params['searchtags'] = implode(' ', $tags);
105
106 if (empty($params['searchtags'])) {
107 unset($params['searchtags']);
108 }
109
110 // We also remove page (keeping the same page has no sense, since the results are different)
111 unset($params['page']);
112 }
113
114 $queryParams = count($params) > 0 ? '?' . http_build_query($params) : '';
115
116 return $response->withRedirect(($currentUrl['path'] ?? './') . $queryParams);
117 }
118}
diff --git a/application/front/controllers/LoginController.php b/application/front/controllers/LoginController.php
deleted file mode 100644
index ae3599e0..00000000
--- a/application/front/controllers/LoginController.php
+++ /dev/null
@@ -1,48 +0,0 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller;
6
7use Shaarli\Front\Exception\LoginBannedException;
8use Slim\Http\Request;
9use Slim\Http\Response;
10
11/**
12 * Class LoginController
13 *
14 * Slim controller used to render the login page.
15 *
16 * The login page is not available if the user is banned
17 * or if open shaarli setting is enabled.
18 *
19 * @package Front\Controller
20 */
21class LoginController extends ShaarliController
22{
23 public function index(Request $request, Response $response): Response
24 {
25 if ($this->container->loginManager->isLoggedIn()
26 || $this->container->conf->get('security.open_shaarli', false)
27 ) {
28 return $response->withRedirect('./');
29 }
30
31 $userCanLogin = $this->container->loginManager->canLogin($request->getServerParams());
32 if ($userCanLogin !== true) {
33 throw new LoginBannedException();
34 }
35
36 if ($request->getParam('username') !== null) {
37 $this->assignView('username', escape($request->getParam('username')));
38 }
39
40 $this
41 ->assignView('returnurl', escape($request->getServerParam('HTTP_REFERER')))
42 ->assignView('remember_user_default', $this->container->conf->get('privacy.remember_user_default', true))
43 ->assignView('pagetitle', t('Login') .' - '. $this->container->conf->get('general.title', 'Shaarli'))
44 ;
45
46 return $response->write($this->render('loginform'));
47 }
48}
diff --git a/application/front/controllers/ShaarliController.php b/application/front/controllers/ShaarliController.php
deleted file mode 100644
index 2b828588..00000000
--- a/application/front/controllers/ShaarliController.php
+++ /dev/null
@@ -1,69 +0,0 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller;
6
7use Shaarli\Bookmark\BookmarkFilter;
8use Shaarli\Container\ShaarliContainer;
9
10abstract class ShaarliController
11{
12 /** @var ShaarliContainer */
13 protected $container;
14
15 /** @param ShaarliContainer $container Slim container (extended for attribute completion). */
16 public function __construct(ShaarliContainer $container)
17 {
18 $this->container = $container;
19 }
20
21 /**
22 * Assign variables to RainTPL template through the PageBuilder.
23 *
24 * @param mixed $value Value to assign to the template
25 */
26 protected function assignView(string $name, $value): self
27 {
28 $this->container->pageBuilder->assign($name, $value);
29
30 return $this;
31 }
32
33 protected function render(string $template): string
34 {
35 $this->assignView('linkcount', $this->container->bookmarkService->count(BookmarkFilter::$ALL));
36 $this->assignView('privateLinkcount', $this->container->bookmarkService->count(BookmarkFilter::$PRIVATE));
37 $this->assignView('plugin_errors', $this->container->pluginManager->getErrors());
38
39 $this->executeDefaultHooks($template);
40
41 return $this->container->pageBuilder->render($template);
42 }
43
44 /**
45 * Call plugin hooks for header, footer and includes, specifying which page will be rendered.
46 * Then assign generated data to RainTPL.
47 */
48 protected function executeDefaultHooks(string $template): void
49 {
50 $common_hooks = [
51 'includes',
52 'header',
53 'footer',
54 ];
55
56 foreach ($common_hooks as $name) {
57 $plugin_data = [];
58 $this->container->pluginManager->executeHooks(
59 'render_' . $name,
60 $plugin_data,
61 [
62 'target' => $template,
63 'loggedin' => $this->container->loginManager->isLoggedIn()
64 ]
65 );
66 $this->assignView('plugins_' . $name, $plugin_data);
67 }
68 }
69}
diff --git a/application/front/exceptions/AlreadyInstalledException.php b/application/front/exceptions/AlreadyInstalledException.php
new file mode 100644
index 00000000..4add86cf
--- /dev/null
+++ b/application/front/exceptions/AlreadyInstalledException.php
@@ -0,0 +1,15 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Exception;
6
7class AlreadyInstalledException extends ShaarliFrontException
8{
9 public function __construct()
10 {
11 $message = t('Shaarli has already been installed. Login to edit the configuration.');
12
13 parent::__construct($message, 401);
14 }
15}
diff --git a/application/front/exceptions/CantLoginException.php b/application/front/exceptions/CantLoginException.php
new file mode 100644
index 00000000..cd16635d
--- /dev/null
+++ b/application/front/exceptions/CantLoginException.php
@@ -0,0 +1,10 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Exception;
6
7class CantLoginException extends \Exception
8{
9
10}
diff --git a/application/front/exceptions/LoginBannedException.php b/application/front/exceptions/LoginBannedException.php
index b31a4a14..79d0ea15 100644
--- a/application/front/exceptions/LoginBannedException.php
+++ b/application/front/exceptions/LoginBannedException.php
@@ -4,7 +4,7 @@ declare(strict_types=1);
4 4
5namespace Shaarli\Front\Exception; 5namespace Shaarli\Front\Exception;
6 6
7class LoginBannedException extends ShaarliException 7class LoginBannedException extends ShaarliFrontException
8{ 8{
9 public function __construct() 9 public function __construct()
10 { 10 {
diff --git a/application/front/exceptions/OpenShaarliPasswordException.php b/application/front/exceptions/OpenShaarliPasswordException.php
new file mode 100644
index 00000000..a6f0b3ae
--- /dev/null
+++ b/application/front/exceptions/OpenShaarliPasswordException.php
@@ -0,0 +1,18 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Exception;
6
7/**
8 * Class OpenShaarliPasswordException
9 *
10 * Raised if the user tries to change the admin password on an open shaarli instance.
11 */
12class OpenShaarliPasswordException extends ShaarliFrontException
13{
14 public function __construct()
15 {
16 parent::__construct(t('You are not supposed to change a password on an Open Shaarli.'), 403);
17 }
18}
diff --git a/application/front/exceptions/ResourcePermissionException.php b/application/front/exceptions/ResourcePermissionException.php
new file mode 100644
index 00000000..8fbf03b9
--- /dev/null
+++ b/application/front/exceptions/ResourcePermissionException.php
@@ -0,0 +1,13 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Exception;
6
7class ResourcePermissionException extends ShaarliFrontException
8{
9 public function __construct(string $message)
10 {
11 parent::__construct($message, 500);
12 }
13}
diff --git a/application/front/exceptions/ShaarliException.php b/application/front/exceptions/ShaarliFrontException.php
index 800bfbec..73847e6d 100644
--- a/application/front/exceptions/ShaarliException.php
+++ b/application/front/exceptions/ShaarliFrontException.php
@@ -9,11 +9,11 @@ use Throwable;
9/** 9/**
10 * Class ShaarliException 10 * Class ShaarliException
11 * 11 *
12 * Abstract exception class used to defined any custom exception thrown during front rendering. 12 * Exception class used to defined any custom exception thrown during front rendering.
13 * 13 *
14 * @package Front\Exception 14 * @package Front\Exception
15 */ 15 */
16abstract class ShaarliException extends \Exception 16class ShaarliFrontException extends \Exception
17{ 17{
18 /** Override parent constructor to force $message and $httpCode parameters to be set. */ 18 /** Override parent constructor to force $message and $httpCode parameters to be set. */
19 public function __construct(string $message, int $httpCode, Throwable $previous = null) 19 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
index 00000000..0ed337f5
--- /dev/null
+++ b/application/front/exceptions/ThumbnailsDisabledException.php
@@ -0,0 +1,15 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Exception;
6
7class ThumbnailsDisabledException extends ShaarliFrontException
8{
9 public function __construct()
10 {
11 $message = t('Picture wall unavailable (thumbnails are disabled).');
12
13 parent::__construct($message, 400);
14 }
15}
diff --git a/application/front/exceptions/UnauthorizedException.php b/application/front/exceptions/UnauthorizedException.php
new file mode 100644
index 00000000..4231094a
--- /dev/null
+++ b/application/front/exceptions/UnauthorizedException.php
@@ -0,0 +1,15 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Exception;
6
7/**
8 * Class UnauthorizedException
9 *
10 * Exception raised if the user tries to access a ShaarliAdminController while logged out.
11 */
12class UnauthorizedException extends \Exception
13{
14
15}
diff --git a/application/front/exceptions/WrongTokenException.php b/application/front/exceptions/WrongTokenException.php
new file mode 100644
index 00000000..42002720
--- /dev/null
+++ b/application/front/exceptions/WrongTokenException.php
@@ -0,0 +1,18 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Exception;
6
7/**
8 * Class OpenShaarliPasswordException
9 *
10 * Raised if the user tries to perform an action with an invalid XSRF token.
11 */
12class WrongTokenException extends ShaarliFrontException
13{
14 public function __construct()
15 {
16 parent::__construct(t('Wrong token.'), 403);
17 }
18}
diff --git a/application/ApplicationUtils.php b/application/helper/ApplicationUtils.php
index 3aa21829..4b34e114 100644
--- a/application/ApplicationUtils.php
+++ b/application/helper/ApplicationUtils.php
@@ -1,5 +1,5 @@
1<?php 1<?php
2namespace Shaarli; 2namespace Shaarli\Helper;
3 3
4use Exception; 4use Exception;
5use Shaarli\Config\ConfigManager; 5use Shaarli\Config\ConfigManager;
@@ -14,8 +14,9 @@ class ApplicationUtils
14 */ 14 */
15 public static $VERSION_FILE = 'shaarli_version.php'; 15 public static $VERSION_FILE = 'shaarli_version.php';
16 16
17 private static $GIT_URL = 'https://raw.githubusercontent.com/shaarli/Shaarli'; 17 public static $GITHUB_URL = 'https://github.com/shaarli/Shaarli';
18 private static $GIT_BRANCHES = array('latest', 'stable'); 18 public static $GIT_RAW_URL = 'https://raw.githubusercontent.com/shaarli/Shaarli';
19 public static $GIT_BRANCHES = array('latest', 'stable');
19 private static $VERSION_START_TAG = '<?php /* '; 20 private static $VERSION_START_TAG = '<?php /* ';
20 private static $VERSION_END_TAG = ' */ ?>'; 21 private static $VERSION_END_TAG = ' */ ?>';
21 22
@@ -125,7 +126,7 @@ class ApplicationUtils
125 // Late Static Binding allows overriding within tests 126 // Late Static Binding allows overriding within tests
126 // See http://php.net/manual/en/language.oop5.late-static-bindings.php 127 // See http://php.net/manual/en/language.oop5.late-static-bindings.php
127 $latestVersion = static::getVersion( 128 $latestVersion = static::getVersion(
128 self::$GIT_URL . '/' . $branch . '/' . self::$VERSION_FILE 129 self::$GIT_RAW_URL . '/' . $branch . '/' . self::$VERSION_FILE
129 ); 130 );
130 131
131 if (!$latestVersion) { 132 if (!$latestVersion) {
@@ -171,35 +172,45 @@ class ApplicationUtils
171 /** 172 /**
172 * Checks Shaarli has the proper access permissions to its resources 173 * Checks Shaarli has the proper access permissions to its resources
173 * 174 *
174 * @param ConfigManager $conf Configuration Manager instance. 175 * @param ConfigManager $conf Configuration Manager instance.
176 * @param bool $minimalMode In minimal mode we only check permissions to be able to display a template.
177 * Currently we only need to be able to read the theme and write in raintpl cache.
175 * 178 *
176 * @return array A list of the detected configuration issues 179 * @return array A list of the detected configuration issues
177 */ 180 */
178 public static function checkResourcePermissions($conf) 181 public static function checkResourcePermissions(ConfigManager $conf, bool $minimalMode = false): array
179 { 182 {
180 $errors = array(); 183 $errors = [];
181 $rainTplDir = rtrim($conf->get('resource.raintpl_tpl'), '/'); 184 $rainTplDir = rtrim($conf->get('resource.raintpl_tpl'), '/');
182 185
183 // Check script and template directories are readable 186 // Check script and template directories are readable
184 foreach (array( 187 foreach ([
185 'application', 188 'application',
186 'inc', 189 'inc',
187 'plugins', 190 'plugins',
188 $rainTplDir, 191 $rainTplDir,
189 $rainTplDir . '/' . $conf->get('resource.theme'), 192 $rainTplDir . '/' . $conf->get('resource.theme'),
190 ) as $path) { 193 ] as $path) {
191 if (!is_readable(realpath($path))) { 194 if (!is_readable(realpath($path))) {
192 $errors[] = '"' . $path . '" ' . t('directory is not readable'); 195 $errors[] = '"' . $path . '" ' . t('directory is not readable');
193 } 196 }
194 } 197 }
195 198
196 // Check cache and data directories are readable and writable 199 // Check cache and data directories are readable and writable
197 foreach (array( 200 if ($minimalMode) {
198 $conf->get('resource.thumbnails_cache'), 201 $folders = [
199 $conf->get('resource.data_dir'), 202 $conf->get('resource.raintpl_tmp'),
200 $conf->get('resource.page_cache'), 203 ];
201 $conf->get('resource.raintpl_tmp'), 204 } else {
202 ) as $path) { 205 $folders = [
206 $conf->get('resource.thumbnails_cache'),
207 $conf->get('resource.data_dir'),
208 $conf->get('resource.page_cache'),
209 $conf->get('resource.raintpl_tmp'),
210 ];
211 }
212
213 foreach ($folders as $path) {
203 if (!is_readable(realpath($path))) { 214 if (!is_readable(realpath($path))) {
204 $errors[] = '"' . $path . '" ' . t('directory is not readable'); 215 $errors[] = '"' . $path . '" ' . t('directory is not readable');
205 } 216 }
@@ -208,6 +219,10 @@ class ApplicationUtils
208 } 219 }
209 } 220 }
210 221
222 if ($minimalMode) {
223 return $errors;
224 }
225
211 // Check configuration files are readable and writable 226 // Check configuration files are readable and writable
212 foreach (array( 227 foreach (array(
213 $conf->getConfigFileExt(), 228 $conf->getConfigFileExt(),
@@ -246,4 +261,54 @@ class ApplicationUtils
246 { 261 {
247 return hash_hmac('sha256', $currentVersion, $salt); 262 return hash_hmac('sha256', $currentVersion, $salt);
248 } 263 }
264
265 /**
266 * Get a list of PHP extensions used by Shaarli.
267 *
268 * @return array[] List of extension with following keys:
269 * - name: extension name
270 * - required: whether the extension is required to use Shaarli
271 * - desc: short description of extension usage in Shaarli
272 * - loaded: whether the extension is properly loaded or not
273 */
274 public static function getPhpExtensionsRequirement(): array
275 {
276 $extensions = [
277 ['name' => 'json', 'required' => true, 'desc' => t('Configuration parsing')],
278 ['name' => 'simplexml', 'required' => true, 'desc' => t('Slim Framework (routing, etc.)')],
279 ['name' => 'mbstring', 'required' => true, 'desc' => t('Multibyte (Unicode) string support')],
280 ['name' => 'gd', 'required' => false, 'desc' => t('Required to use thumbnails')],
281 ['name' => 'intl', 'required' => false, 'desc' => t('Localized text sorting (e.g. e->è->f)')],
282 ['name' => 'curl', 'required' => false, 'desc' => t('Better retrieval of bookmark metadata and thumbnail')],
283 ['name' => 'gettext', 'required' => false, 'desc' => t('Use the translation system in gettext mode')],
284 ['name' => 'ldap', 'required' => false, 'desc' => t('Login using LDAP server')],
285 ];
286
287 foreach ($extensions as &$extension) {
288 $extension['loaded'] = extension_loaded($extension['name']);
289 }
290
291 return $extensions;
292 }
293
294 /**
295 * Return the EOL date of given PHP version. If the version is unknown,
296 * we return today + 2 years.
297 *
298 * @param string $fullVersion PHP version, e.g. 7.4.7
299 *
300 * @return string Date format: YYYY-MM-DD
301 */
302 public static function getPhpEol(string $fullVersion): string
303 {
304 preg_match('/(\d+\.\d+)\.\d+/', $fullVersion, $matches);
305
306 return [
307 '7.1' => '2019-12-01',
308 '7.2' => '2020-11-30',
309 '7.3' => '2021-12-06',
310 '7.4' => '2022-11-28',
311 '8.0' => '2023-12-01',
312 ][$matches[1]] ?? (new \DateTime('+2 year'))->format('Y-m-d');
313 }
249} 314}
diff --git a/application/helper/DailyPageHelper.php b/application/helper/DailyPageHelper.php
new file mode 100644
index 00000000..5fabc907
--- /dev/null
+++ b/application/helper/DailyPageHelper.php
@@ -0,0 +1,208 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Helper;
6
7use Shaarli\Bookmark\Bookmark;
8use Slim\Http\Request;
9
10class DailyPageHelper
11{
12 public const MONTH = 'month';
13 public const WEEK = 'week';
14 public const DAY = 'day';
15
16 /**
17 * Extracts the type of the daily to display from the HTTP request parameters
18 *
19 * @param Request $request HTTP request
20 *
21 * @return string month/week/day
22 */
23 public static function extractRequestedType(Request $request): string
24 {
25 if ($request->getQueryParam(static::MONTH) !== null) {
26 return static::MONTH;
27 } elseif ($request->getQueryParam(static::WEEK) !== null) {
28 return static::WEEK;
29 }
30
31 return static::DAY;
32 }
33
34 /**
35 * Extracts a DateTimeImmutable from provided HTTP request.
36 * If no parameter is provided, we rely on the creation date of the latest provided created bookmark.
37 * If the datastore is empty or no bookmark is provided, we use the current date.
38 *
39 * @param string $type month/week/day
40 * @param string|null $requestedDate Input string extracted from the request
41 * @param Bookmark|null $latestBookmark Latest bookmark found in the datastore (by date)
42 *
43 * @return \DateTimeImmutable from input or latest bookmark.
44 *
45 * @throws \Exception Type not supported.
46 */
47 public static function extractRequestedDateTime(
48 string $type,
49 ?string $requestedDate,
50 Bookmark $latestBookmark = null
51 ): \DateTimeImmutable {
52 $format = static::getFormatByType($type);
53 if (empty($requestedDate)) {
54 return $latestBookmark instanceof Bookmark
55 ? new \DateTimeImmutable($latestBookmark->getCreated()->format(\DateTime::ATOM))
56 : new \DateTimeImmutable()
57 ;
58 }
59
60 // W is not supported by createFromFormat...
61 if ($type === static::WEEK) {
62 return (new \DateTimeImmutable())
63 ->setISODate((int) substr($requestedDate, 0, 4), (int) substr($requestedDate, 4, 2))
64 ;
65 }
66
67 return \DateTimeImmutable::createFromFormat($format, $requestedDate);
68 }
69
70 /**
71 * Get the DateTime format used by provided type
72 * Examples:
73 * - day: 20201016 (<year><month><day>)
74 * - week: 202041 (<year><week number>)
75 * - month: 202010 (<year><month>)
76 *
77 * @param string $type month/week/day
78 *
79 * @return string DateTime compatible format
80 *
81 * @see https://www.php.net/manual/en/datetime.format.php
82 *
83 * @throws \Exception Type not supported.
84 */
85 public static function getFormatByType(string $type): string
86 {
87 switch ($type) {
88 case static::MONTH:
89 return 'Ym';
90 case static::WEEK:
91 return 'YW';
92 case static::DAY:
93 return 'Ymd';
94 default:
95 throw new \Exception('Unsupported daily format type');
96 }
97 }
98
99 /**
100 * Get the first DateTime of the time period depending on given datetime and type.
101 * Note: DateTimeImmutable is required because we rely heavily on DateTime->modify() syntax
102 * and we don't want to alter original datetime.
103 *
104 * @param string $type month/week/day
105 * @param \DateTimeImmutable $requested DateTime extracted from request input
106 * (should come from extractRequestedDateTime)
107 *
108 * @return \DateTimeInterface First DateTime of the time period
109 *
110 * @throws \Exception Type not supported.
111 */
112 public static function getStartDateTimeByType(string $type, \DateTimeImmutable $requested): \DateTimeInterface
113 {
114 switch ($type) {
115 case static::MONTH:
116 return $requested->modify('first day of this month midnight');
117 case static::WEEK:
118 return $requested->modify('Monday this week midnight');
119 case static::DAY:
120 return $requested->modify('Today midnight');
121 default:
122 throw new \Exception('Unsupported daily format type');
123 }
124 }
125
126 /**
127 * Get the last DateTime of the time period depending on given datetime and type.
128 * Note: DateTimeImmutable is required because we rely heavily on DateTime->modify() syntax
129 * and we don't want to alter original datetime.
130 *
131 * @param string $type month/week/day
132 * @param \DateTimeImmutable $requested DateTime extracted from request input
133 * (should come from extractRequestedDateTime)
134 *
135 * @return \DateTimeInterface Last DateTime of the time period
136 *
137 * @throws \Exception Type not supported.
138 */
139 public static function getEndDateTimeByType(string $type, \DateTimeImmutable $requested): \DateTimeInterface
140 {
141 switch ($type) {
142 case static::MONTH:
143 return $requested->modify('last day of this month 23:59:59');
144 case static::WEEK:
145 return $requested->modify('Sunday this week 23:59:59');
146 case static::DAY:
147 return $requested->modify('Today 23:59:59');
148 default:
149 throw new \Exception('Unsupported daily format type');
150 }
151 }
152
153 /**
154 * Get localized description of the time period depending on given datetime and type.
155 * Example: for a month period, it returns `October, 2020`.
156 *
157 * @param string $type month/week/day
158 * @param \DateTimeImmutable $requested DateTime extracted from request input
159 * (should come from extractRequestedDateTime)
160 *
161 * @return string Localized time period description
162 *
163 * @throws \Exception Type not supported.
164 */
165 public static function getDescriptionByType(string $type, \DateTimeImmutable $requested): string
166 {
167 switch ($type) {
168 case static::MONTH:
169 return $requested->format('F') . ', ' . $requested->format('Y');
170 case static::WEEK:
171 $requested = $requested->modify('Monday this week');
172 return t('Week') . ' ' . $requested->format('W') . ' (' . format_date($requested, false) . ')';
173 case static::DAY:
174 $out = '';
175 if ($requested->format('Ymd') === date('Ymd')) {
176 $out = t('Today') . ' - ';
177 } elseif ($requested->format('Ymd') === date('Ymd', strtotime('-1 days'))) {
178 $out = t('Yesterday') . ' - ';
179 }
180 return $out . format_date($requested, false);
181 default:
182 throw new \Exception('Unsupported daily format type');
183 }
184 }
185
186 /**
187 * Get the number of items to display in the RSS feed depending on the given type.
188 *
189 * @param string $type month/week/day
190 *
191 * @return int number of elements
192 *
193 * @throws \Exception Type not supported.
194 */
195 public static function getRssLengthByType(string $type): int
196 {
197 switch ($type) {
198 case static::MONTH:
199 return 12; // 1 year
200 case static::WEEK:
201 return 26; // ~6 months
202 case static::DAY:
203 return 30; // ~1 month
204 default:
205 throw new \Exception('Unsupported daily format type');
206 }
207 }
208}
diff --git a/application/FileUtils.php b/application/helper/FileUtils.php
index 30560bfc..2eac0793 100644
--- a/application/FileUtils.php
+++ b/application/helper/FileUtils.php
@@ -1,6 +1,6 @@
1<?php 1<?php
2 2
3namespace Shaarli; 3namespace Shaarli\Helper;
4 4
5use Shaarli\Exceptions\IOException; 5use Shaarli\Exceptions\IOException;
6 6
@@ -81,4 +81,60 @@ class FileUtils
81 ) 81 )
82 ); 82 );
83 } 83 }
84
85 /**
86 * Recursively deletes a folder content, and deletes itself optionally.
87 * If an excluded file is found, folders won't be deleted.
88 *
89 * Additional security: raise an exception if it tries to delete a folder outside of Shaarli directory.
90 *
91 * @param string $path
92 * @param bool $selfDelete Delete the provided folder if true, only its content if false.
93 * @param array $exclude
94 */
95 public static function clearFolder(string $path, bool $selfDelete, array $exclude = []): bool
96 {
97 $skipped = false;
98
99 if (!is_dir($path)) {
100 throw new IOException(t('Provided path is not a directory.'));
101 }
102
103 if (!static::isPathInShaarliFolder($path)) {
104 throw new IOException(t('Trying to delete a folder outside of Shaarli path.'));
105 }
106
107 foreach (new \DirectoryIterator($path) as $file) {
108 if($file->isDot()) {
109 continue;
110 }
111
112 if (in_array($file->getBasename(), $exclude, true)) {
113 $skipped = true;
114 continue;
115 }
116
117 if ($file->isFile()) {
118 unlink($file->getPathname());
119 } elseif($file->isDir()) {
120 $skipped = static::clearFolder($file->getRealPath(), true, $exclude) || $skipped;
121 }
122 }
123
124 if ($selfDelete && !$skipped) {
125 rmdir($path);
126 }
127
128 return $skipped;
129 }
130
131 /**
132 * Checks that the given path is inside Shaarli directory.
133 */
134 public static function isPathInShaarliFolder(string $path): bool
135 {
136 $rootDirectory = dirname(dirname(dirname(__FILE__)));
137
138 return strpos(realpath($path), $rootDirectory) !== false;
139 }
84} 140}
diff --git a/application/http/HttpAccess.php b/application/http/HttpAccess.php
new file mode 100644
index 00000000..646a5264
--- /dev/null
+++ b/application/http/HttpAccess.php
@@ -0,0 +1,47 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Http;
6
7/**
8 * Class HttpAccess
9 *
10 * This is mostly an OOP wrapper for HTTP functions defined in `HttpUtils`.
11 * It is used as dependency injection in Shaarli's container.
12 *
13 * @package Shaarli\Http
14 */
15class HttpAccess
16{
17 public function getHttpResponse(
18 $url,
19 $timeout = 30,
20 $maxBytes = 4194304,
21 $curlHeaderFunction = null,
22 $curlWriteFunction = null
23 ) {
24 return get_http_response($url, $timeout, $maxBytes, $curlHeaderFunction, $curlWriteFunction);
25 }
26
27 public function getCurlDownloadCallback(
28 &$charset,
29 &$title,
30 &$description,
31 &$keywords,
32 $retrieveDescription
33 ) {
34 return get_curl_download_callback(
35 $charset,
36 $title,
37 $description,
38 $keywords,
39 $retrieveDescription
40 );
41 }
42
43 public function getCurlHeaderCallback(&$charset, $curlGetInfo = 'curl_getinfo')
44 {
45 return get_curl_header_callback($charset, $curlGetInfo);
46 }
47}
diff --git a/application/http/HttpUtils.php b/application/http/HttpUtils.php
index 2ea9195d..28c12969 100644
--- a/application/http/HttpUtils.php
+++ b/application/http/HttpUtils.php
@@ -6,12 +6,14 @@ use Shaarli\Http\Url;
6 * GET an HTTP URL to retrieve its content 6 * GET an HTTP URL to retrieve its content
7 * Uses the cURL library or a fallback method 7 * Uses the cURL library or a fallback method
8 * 8 *
9 * @param string $url URL to get (http://...) 9 * @param string $url URL to get (http://...)
10 * @param int $timeout network timeout (in seconds) 10 * @param int $timeout network timeout (in seconds)
11 * @param int $maxBytes maximum downloaded bytes (default: 4 MiB) 11 * @param int $maxBytes maximum downloaded bytes (default: 4 MiB)
12 * @param callable|string $curlWriteFunction Optional callback called during the download (cURL CURLOPT_WRITEFUNCTION). 12 * @param callable|string $curlHeaderFunction Optional callback called during the download of headers
13 * Can be used to add download conditions on the 13 * (CURLOPT_HEADERFUNCTION)
14 * headers (response code, content type, etc.). 14 * @param callable|string $curlWriteFunction Optional callback called during the download (cURL CURLOPT_WRITEFUNCTION).
15 * Can be used to add download conditions on the
16 * headers (response code, content type, etc.).
15 * 17 *
16 * @return array HTTP response headers, downloaded content 18 * @return array HTTP response headers, downloaded content
17 * 19 *
@@ -35,8 +37,13 @@ use Shaarli\Http\Url;
35 * @see http://stackoverflow.com/q/9183178 37 * @see http://stackoverflow.com/q/9183178
36 * @see http://stackoverflow.com/q/1462720 38 * @see http://stackoverflow.com/q/1462720
37 */ 39 */
38function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteFunction = null) 40function get_http_response(
39{ 41 $url,
42 $timeout = 30,
43 $maxBytes = 4194304,
44 $curlHeaderFunction = null,
45 $curlWriteFunction = null
46) {
40 $urlObj = new Url($url); 47 $urlObj = new Url($url);
41 $cleanUrl = $urlObj->idnToAscii(); 48 $cleanUrl = $urlObj->idnToAscii();
42 49
@@ -70,7 +77,8 @@ function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteF
70 // General cURL settings 77 // General cURL settings
71 curl_setopt($ch, CURLOPT_AUTOREFERER, true); 78 curl_setopt($ch, CURLOPT_AUTOREFERER, true);
72 curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); 79 curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
73 curl_setopt($ch, CURLOPT_HEADER, true); 80 // Default header download if the $curlHeaderFunction is not defined
81 curl_setopt($ch, CURLOPT_HEADER, !is_callable($curlHeaderFunction));
74 curl_setopt( 82 curl_setopt(
75 $ch, 83 $ch,
76 CURLOPT_HTTPHEADER, 84 CURLOPT_HTTPHEADER,
@@ -81,25 +89,21 @@ function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteF
81 curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); 89 curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
82 curl_setopt($ch, CURLOPT_USERAGENT, $userAgent); 90 curl_setopt($ch, CURLOPT_USERAGENT, $userAgent);
83 91
84 if (is_callable($curlWriteFunction)) {
85 curl_setopt($ch, CURLOPT_WRITEFUNCTION, $curlWriteFunction);
86 }
87
88 // Max download size management 92 // Max download size management
89 curl_setopt($ch, CURLOPT_BUFFERSIZE, 1024*16); 93 curl_setopt($ch, CURLOPT_BUFFERSIZE, 1024*16);
90 curl_setopt($ch, CURLOPT_NOPROGRESS, false); 94 curl_setopt($ch, CURLOPT_NOPROGRESS, false);
95 if (is_callable($curlHeaderFunction)) {
96 curl_setopt($ch, CURLOPT_HEADERFUNCTION, $curlHeaderFunction);
97 }
98 if (is_callable($curlWriteFunction)) {
99 curl_setopt($ch, CURLOPT_WRITEFUNCTION, $curlWriteFunction);
100 }
91 curl_setopt( 101 curl_setopt(
92 $ch, 102 $ch,
93 CURLOPT_PROGRESSFUNCTION, 103 CURLOPT_PROGRESSFUNCTION,
94 function ($arg0, $arg1, $arg2, $arg3, $arg4 = 0) use ($maxBytes) { 104 function ($arg0, $arg1, $arg2, $arg3, $arg4) use ($maxBytes) {
95 if (version_compare(phpversion(), '5.5', '<')) { 105 $downloaded = $arg2;
96 // PHP version lower than 5.5 106
97 // Callback has 4 arguments
98 $downloaded = $arg1;
99 } else {
100 // Callback has 5 arguments
101 $downloaded = $arg2;
102 }
103 // Non-zero return stops downloading 107 // Non-zero return stops downloading
104 return ($downloaded > $maxBytes) ? 1 : 0; 108 return ($downloaded > $maxBytes) ? 1 : 0;
105 } 109 }
@@ -369,7 +373,11 @@ function server_url($server)
369 */ 373 */
370function index_url($server) 374function index_url($server)
371{ 375{
372 $scriptname = $server['SCRIPT_NAME']; 376 if (defined('SHAARLI_ROOT_URL') && null !== SHAARLI_ROOT_URL) {
377 return rtrim(SHAARLI_ROOT_URL, '/') . '/';
378 }
379
380 $scriptname = !empty($server['SCRIPT_NAME']) ? $server['SCRIPT_NAME'] : '/';
373 if (endsWith($scriptname, 'index.php')) { 381 if (endsWith($scriptname, 'index.php')) {
374 $scriptname = substr($scriptname, 0, -9); 382 $scriptname = substr($scriptname, 0, -9);
375 } 383 }
@@ -377,7 +385,7 @@ function index_url($server)
377} 385}
378 386
379/** 387/**
380 * Returns the absolute URL of the current script, with the query 388 * Returns the absolute URL of the current script, with current route and query
381 * 389 *
382 * If the resource is "index.php", then it is removed (for better-looking URLs) 390 * If the resource is "index.php", then it is removed (for better-looking URLs)
383 * 391 *
@@ -387,10 +395,17 @@ function index_url($server)
387 */ 395 */
388function page_url($server) 396function page_url($server)
389{ 397{
398 $scriptname = $server['SCRIPT_NAME'] ?? '';
399 if (endsWith($scriptname, 'index.php')) {
400 $scriptname = substr($scriptname, 0, -9);
401 }
402
403 $route = preg_replace('@^' . $scriptname . '@', '', $server['REQUEST_URI'] ?? '');
390 if (! empty($server['QUERY_STRING'])) { 404 if (! empty($server['QUERY_STRING'])) {
391 return index_url($server).'?'.$server['QUERY_STRING']; 405 return index_url($server) . $route . '?' . $server['QUERY_STRING'];
392 } 406 }
393 return index_url($server); 407
408 return index_url($server) . $route;
394} 409}
395 410
396/** 411/**
@@ -477,3 +492,132 @@ function is_https($server)
477 492
478 return ! empty($server['HTTPS']); 493 return ! empty($server['HTTPS']);
479} 494}
495
496/**
497 * Get cURL callback function for CURLOPT_WRITEFUNCTION
498 *
499 * @param string $charset to extract from the downloaded page (reference)
500 * @param string $curlGetInfo Optionally overrides curl_getinfo function
501 *
502 * @return Closure
503 */
504function get_curl_header_callback(
505 &$charset,
506 $curlGetInfo = 'curl_getinfo'
507) {
508 $isRedirected = false;
509
510 return function ($ch, $data) use ($curlGetInfo, &$charset, &$isRedirected) {
511 $responseCode = $curlGetInfo($ch, CURLINFO_RESPONSE_CODE);
512 $chunkLength = strlen($data);
513 if (!empty($responseCode) && in_array($responseCode, [301, 302])) {
514 $isRedirected = true;
515 return $chunkLength;
516 }
517 if (!empty($responseCode) && $responseCode !== 200) {
518 return false;
519 }
520 // After a redirection, the content type will keep the previous request value
521 // until it finds the next content-type header.
522 if (! $isRedirected || strpos(strtolower($data), 'content-type') !== false) {
523 $contentType = $curlGetInfo($ch, CURLINFO_CONTENT_TYPE);
524 }
525 if (!empty($contentType) && strpos($contentType, 'text/html') === false) {
526 return false;
527 }
528 if (!empty($contentType) && empty($charset)) {
529 $charset = header_extract_charset($contentType);
530 }
531
532 return $chunkLength;
533 };
534}
535
536/**
537 * Get cURL callback function for CURLOPT_WRITEFUNCTION
538 *
539 * @param string $charset to extract from the downloaded page (reference)
540 * @param string $title to extract from the downloaded page (reference)
541 * @param string $description to extract from the downloaded page (reference)
542 * @param string $keywords to extract from the downloaded page (reference)
543 * @param bool $retrieveDescription Automatically tries to retrieve description and keywords from HTML content
544 * @param string $curlGetInfo Optionally overrides curl_getinfo function
545 *
546 * @return Closure
547 */
548function get_curl_download_callback(
549 &$charset,
550 &$title,
551 &$description,
552 &$keywords,
553 $retrieveDescription
554) {
555 $currentChunk = 0;
556 $foundChunk = null;
557
558 /**
559 * cURL callback function for CURLOPT_WRITEFUNCTION (called during the download).
560 *
561 * While downloading the remote page, we check that the HTTP code is 200 and content type is 'html/text'
562 * Then we extract the title and the charset and stop the download when it's done.
563 *
564 * @param resource $ch cURL resource
565 * @param string $data chunk of data being downloaded
566 *
567 * @return int|bool length of $data or false if we need to stop the download
568 */
569 return function ($ch, $data) use (
570 $retrieveDescription,
571 &$charset,
572 &$title,
573 &$description,
574 &$keywords,
575 &$currentChunk,
576 &$foundChunk
577 ) {
578 $chunkLength = strlen($data);
579 $currentChunk++;
580
581 if (empty($charset)) {
582 $charset = html_extract_charset($data);
583 }
584 if (empty($title)) {
585 $title = html_extract_title($data);
586 $foundChunk = ! empty($title) ? $currentChunk : $foundChunk;
587 }
588 if (empty($title)) {
589 $title = html_extract_tag('title', $data);
590 $foundChunk = ! empty($title) ? $currentChunk : $foundChunk;
591 }
592 if ($retrieveDescription && empty($description)) {
593 $description = html_extract_tag('description', $data);
594 $foundChunk = ! empty($description) ? $currentChunk : $foundChunk;
595 }
596 if ($retrieveDescription && empty($keywords)) {
597 $keywords = html_extract_tag('keywords', $data);
598 if (! empty($keywords)) {
599 $foundChunk = $currentChunk;
600 // Keywords use the format tag1, tag2 multiple words, tag
601 // So we format them to match Shaarli's separator and glue multiple words with '-'
602 $keywords = implode(' ', array_map(function($keyword) {
603 return implode('-', preg_split('/\s+/', trim($keyword)));
604 }, explode(',', $keywords)));
605 }
606 }
607
608 // We got everything we want, stop the download.
609 // If we already found either the title, description or keywords,
610 // it's highly unlikely that we'll found the other metas further than
611 // in the same chunk of data or the next one. So we also stop the download after that.
612 if ((!empty($responseCode) && !empty($contentType) && !empty($charset)) && $foundChunk !== null
613 && (! $retrieveDescription
614 || $foundChunk < $currentChunk
615 || (!empty($title) && !empty($description) && !empty($keywords))
616 )
617 ) {
618 return false;
619 }
620
621 return $chunkLength;
622 };
623}
diff --git a/application/http/MetadataRetriever.php b/application/http/MetadataRetriever.php
new file mode 100644
index 00000000..ba9bd40c
--- /dev/null
+++ b/application/http/MetadataRetriever.php
@@ -0,0 +1,69 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Http;
6
7use Shaarli\Config\ConfigManager;
8
9/**
10 * HTTP Tool used to extract metadata from external URL (title, description, etc.).
11 */
12class MetadataRetriever
13{
14 /** @var ConfigManager */
15 protected $conf;
16
17 /** @var HttpAccess */
18 protected $httpAccess;
19
20 public function __construct(ConfigManager $conf, HttpAccess $httpAccess)
21 {
22 $this->conf = $conf;
23 $this->httpAccess = $httpAccess;
24 }
25
26 /**
27 * Retrieve metadata for given URL.
28 *
29 * @return array [
30 * 'title' => <remote title>,
31 * 'description' => <remote description>,
32 * 'tags' => <remote keywords>,
33 * ]
34 */
35 public function retrieve(string $url): array
36 {
37 $charset = null;
38 $title = null;
39 $description = null;
40 $tags = null;
41 $retrieveDescription = $this->conf->get('general.retrieve_description');
42
43 // Short timeout to keep the application responsive
44 // The callback will fill $charset and $title with data from the downloaded page.
45 $this->httpAccess->getHttpResponse(
46 $url,
47 $this->conf->get('general.download_timeout', 30),
48 $this->conf->get('general.download_max_size', 4194304),
49 $this->httpAccess->getCurlHeaderCallback($charset),
50 $this->httpAccess->getCurlDownloadCallback(
51 $charset,
52 $title,
53 $description,
54 $tags,
55 $retrieveDescription
56 )
57 );
58
59 if (!empty($title) && strtolower($charset) !== 'utf-8') {
60 $title = mb_convert_encoding($title, 'utf-8', $charset);
61 }
62
63 return [
64 'title' => $title,
65 'description' => $description,
66 'tags' => $tags,
67 ];
68 }
69}
diff --git a/application/legacy/LegacyController.php b/application/legacy/LegacyController.php
new file mode 100644
index 00000000..826604e7
--- /dev/null
+++ b/application/legacy/LegacyController.php
@@ -0,0 +1,162 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Legacy;
6
7use Shaarli\Feed\FeedBuilder;
8use Shaarli\Front\Controller\Visitor\ShaarliVisitorController;
9use Slim\Http\Request;
10use Slim\Http\Response;
11
12/**
13 * We use this to maintain legacy routes, and redirect requests to the corresponding Slim route.
14 * Only public routes, and both `?addlink` and `?post` were kept here.
15 * Other routes will just display the linklist.
16 *
17 * @deprecated
18 */
19class LegacyController extends ShaarliVisitorController
20{
21 /** @var string[] Both `?post` and `?addlink` do not use `?do=` format. */
22 public const LEGACY_GET_ROUTES = [
23 'post',
24 'addlink',
25 ];
26
27 /**
28 * This method will call `$action` method, which will redirect to corresponding Slim route.
29 */
30 public function process(Request $request, Response $response, string $action): Response
31 {
32 if (!method_exists($this, $action)) {
33 throw new UnknowLegacyRouteException();
34 }
35
36 return $this->{$action}($request, $response);
37 }
38
39 /** Legacy route: ?post= */
40 public function post(Request $request, Response $response): Response
41 {
42 $route = '/admin/shaare';
43 $buildParameters = function (?array $parameters, bool $encode) {
44 if ($encode) {
45 $parameters = array_map('urlencode', $parameters);
46 }
47
48 return count($parameters) > 0 ? '?' . http_build_query($parameters) : '';
49 };
50
51
52 if (!$this->container->loginManager->isLoggedIn()) {
53 $parameters = $buildParameters($request->getQueryParams(), true);
54 return $this->redirect($response, '/login?returnurl='. $this->getBasePath() . $route . $parameters);
55 }
56
57 $parameters = $buildParameters($request->getQueryParams(), false);
58
59 return $this->redirect($response, $route . $parameters);
60 }
61
62 /** Legacy route: ?addlink= */
63 protected function addlink(Request $request, Response $response): Response
64 {
65 $route = '/admin/add-shaare';
66
67 if (!$this->container->loginManager->isLoggedIn()) {
68 return $this->redirect($response, '/login?returnurl=' . $this->getBasePath() . $route);
69 }
70
71 return $this->redirect($response, $route);
72 }
73
74 /** Legacy route: ?do=login */
75 protected function login(Request $request, Response $response): Response
76 {
77 $returnUrl = $request->getQueryParam('returnurl');
78
79 return $this->redirect($response, '/login' . ($returnUrl ? '?returnurl=' . $returnUrl : ''));
80 }
81
82 /** Legacy route: ?do=logout */
83 protected function logout(Request $request, Response $response): Response
84 {
85 return $this->redirect($response, '/admin/logout');
86 }
87
88 /** Legacy route: ?do=picwall */
89 protected function picwall(Request $request, Response $response): Response
90 {
91 return $this->redirect($response, '/picture-wall');
92 }
93
94 /** Legacy route: ?do=tagcloud */
95 protected function tagcloud(Request $request, Response $response): Response
96 {
97 return $this->redirect($response, '/tags/cloud');
98 }
99
100 /** Legacy route: ?do=taglist */
101 protected function taglist(Request $request, Response $response): Response
102 {
103 return $this->redirect($response, '/tags/list');
104 }
105
106 /** Legacy route: ?do=daily */
107 protected function daily(Request $request, Response $response): Response
108 {
109 $dayParam = !empty($request->getParam('day')) ? '?day=' . escape($request->getParam('day')) : '';
110
111 return $this->redirect($response, '/daily' . $dayParam);
112 }
113
114 /** Legacy route: ?do=rss */
115 protected function rss(Request $request, Response $response): Response
116 {
117 return $this->feed($request, $response, FeedBuilder::$FEED_RSS);
118 }
119
120 /** Legacy route: ?do=atom */
121 protected function atom(Request $request, Response $response): Response
122 {
123 return $this->feed($request, $response, FeedBuilder::$FEED_ATOM);
124 }
125
126 /** Legacy route: ?do=opensearch */
127 protected function opensearch(Request $request, Response $response): Response
128 {
129 return $this->redirect($response, '/open-search');
130 }
131
132 /** Legacy route: ?do=dailyrss */
133 protected function dailyrss(Request $request, Response $response): Response
134 {
135 return $this->redirect($response, '/daily-rss');
136 }
137
138 /** Legacy route: ?do=feed */
139 protected function feed(Request $request, Response $response, string $feedType): Response
140 {
141 $parameters = count($request->getQueryParams()) > 0 ? '?' . http_build_query($request->getQueryParams()) : '';
142
143 return $this->redirect($response, '/feed/' . $feedType . $parameters);
144 }
145
146 /** Legacy route: ?do=configure */
147 protected function configure(Request $request, Response $response): Response
148 {
149 $route = '/admin/configure';
150
151 if (!$this->container->loginManager->isLoggedIn()) {
152 return $this->redirect($response, '/login?returnurl=' . $this->getBasePath() . $route);
153 }
154
155 return $this->redirect($response, $route);
156 }
157
158 protected function getBasePath(): string
159 {
160 return $this->container->basePath ?: '';
161 }
162}
diff --git a/application/legacy/LegacyLinkDB.php b/application/legacy/LegacyLinkDB.php
index 7ccf5e54..5c02a21b 100644
--- a/application/legacy/LegacyLinkDB.php
+++ b/application/legacy/LegacyLinkDB.php
@@ -8,7 +8,8 @@ use DateTime;
8use Iterator; 8use Iterator;
9use Shaarli\Bookmark\Exception\BookmarkNotFoundException; 9use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
10use Shaarli\Exceptions\IOException; 10use Shaarli\Exceptions\IOException;
11use Shaarli\FileUtils; 11use Shaarli\Helper\FileUtils;
12use Shaarli\Render\PageCacheManager;
12 13
13/** 14/**
14 * Data storage for bookmarks. 15 * Data storage for bookmarks.
@@ -352,7 +353,8 @@ You use the community supported version of the original Shaarli project, by Seba
352 353
353 $this->write(); 354 $this->write();
354 355
355 invalidateCaches($pageCacheDir); 356 $pageCacheManager = new PageCacheManager($pageCacheDir, $this->loggedIn);
357 $pageCacheManager->invalidateCaches();
356 } 358 }
357 359
358 /** 360 /**
diff --git a/application/legacy/LegacyRouter.php b/application/legacy/LegacyRouter.php
new file mode 100644
index 00000000..0449c7e1
--- /dev/null
+++ b/application/legacy/LegacyRouter.php
@@ -0,0 +1,63 @@
1<?php
2
3namespace Shaarli\Legacy;
4
5/**
6 * Class Router
7 *
8 * (only displayable pages here)
9 *
10 * @deprecated
11 */
12class LegacyRouter
13{
14 public static $AJAX_THUMB_UPDATE = 'ajax_thumb_update';
15
16 public static $PAGE_LOGIN = 'login';
17
18 public static $PAGE_PICWALL = 'picwall';
19
20 public static $PAGE_TAGCLOUD = 'tag.cloud';
21
22 public static $PAGE_TAGLIST = 'tag.list';
23
24 public static $PAGE_DAILY = 'daily';
25
26 public static $PAGE_FEED_ATOM = 'feed.atom';
27
28 public static $PAGE_FEED_RSS = 'feed.rss';
29
30 public static $PAGE_TOOLS = 'tools';
31
32 public static $PAGE_CHANGEPASSWORD = 'changepasswd';
33
34 public static $PAGE_CONFIGURE = 'configure';
35
36 public static $PAGE_CHANGETAG = 'changetag';
37
38 public static $PAGE_ADDLINK = 'addlink';
39
40 public static $PAGE_EDITLINK = 'editlink';
41
42 public static $PAGE_DELETELINK = 'delete_link';
43
44 public static $PAGE_CHANGE_VISIBILITY = 'change_visibility';
45
46 public static $PAGE_PINLINK = 'pin';
47
48 public static $PAGE_EXPORT = 'export';
49
50 public static $PAGE_IMPORT = 'import';
51
52 public static $PAGE_OPENSEARCH = 'opensearch';
53
54 public static $PAGE_LINKLIST = 'linklist';
55
56 public static $PAGE_PLUGINSADMIN = 'pluginadmin';
57
58 public static $PAGE_SAVE_PLUGINSADMIN = 'save_pluginadmin';
59
60 public static $PAGE_THUMBS_UPDATE = 'thumbs_update';
61
62 public static $GET_TOKEN = 'token';
63}
diff --git a/application/legacy/LegacyUpdater.php b/application/legacy/LegacyUpdater.php
index 3a5de79f..fe1a286f 100644
--- a/application/legacy/LegacyUpdater.php
+++ b/application/legacy/LegacyUpdater.php
@@ -7,16 +7,16 @@ use RainTPL;
7use ReflectionClass; 7use ReflectionClass;
8use ReflectionException; 8use ReflectionException;
9use ReflectionMethod; 9use ReflectionMethod;
10use Shaarli\ApplicationUtils;
11use Shaarli\Bookmark\Bookmark; 10use Shaarli\Bookmark\Bookmark;
12use Shaarli\Bookmark\BookmarkArray; 11use Shaarli\Bookmark\BookmarkArray;
13use Shaarli\Bookmark\LinkDB;
14use Shaarli\Bookmark\BookmarkFilter; 12use Shaarli\Bookmark\BookmarkFilter;
15use Shaarli\Bookmark\BookmarkIO; 13use Shaarli\Bookmark\BookmarkIO;
14use Shaarli\Bookmark\LinkDB;
16use Shaarli\Config\ConfigJson; 15use Shaarli\Config\ConfigJson;
17use Shaarli\Config\ConfigManager; 16use Shaarli\Config\ConfigManager;
18use Shaarli\Config\ConfigPhp; 17use Shaarli\Config\ConfigPhp;
19use Shaarli\Exceptions\IOException; 18use Shaarli\Exceptions\IOException;
19use Shaarli\Helper\ApplicationUtils;
20use Shaarli\Thumbnailer; 20use Shaarli\Thumbnailer;
21use Shaarli\Updater\Exception\UpdaterException; 21use Shaarli\Updater\Exception\UpdaterException;
22 22
@@ -534,7 +534,8 @@ class LegacyUpdater
534 534
535 if ($thumbnailsEnabled) { 535 if ($thumbnailsEnabled) {
536 $this->session['warnings'][] = t( 536 $this->session['warnings'][] = t(
537 'You have enabled or changed thumbnails mode. <a href="?do=thumbs_update">Please synchronize them</a>.' 537 t('You have enabled or changed thumbnails mode.') .
538 '<a href="./admin/thumbnails">' . t('Please synchronize them.') . '</a>'
538 ); 539 );
539 } 540 }
540 541
diff --git a/application/legacy/UnknowLegacyRouteException.php b/application/legacy/UnknowLegacyRouteException.php
new file mode 100644
index 00000000..ae1518ad
--- /dev/null
+++ b/application/legacy/UnknowLegacyRouteException.php
@@ -0,0 +1,9 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Legacy;
6
7class UnknowLegacyRouteException extends \Exception
8{
9}
diff --git a/application/netscape/NetscapeBookmarkUtils.php b/application/netscape/NetscapeBookmarkUtils.php
index d64eef7f..b83f16f8 100644
--- a/application/netscape/NetscapeBookmarkUtils.php
+++ b/application/netscape/NetscapeBookmarkUtils.php
@@ -6,6 +6,7 @@ use DateTime;
6use DateTimeZone; 6use DateTimeZone;
7use Exception; 7use Exception;
8use Katzgrau\KLogger\Logger; 8use Katzgrau\KLogger\Logger;
9use Psr\Http\Message\UploadedFileInterface;
9use Psr\Log\LogLevel; 10use Psr\Log\LogLevel;
10use Shaarli\Bookmark\Bookmark; 11use Shaarli\Bookmark\Bookmark;
11use Shaarli\Bookmark\BookmarkServiceInterface; 12use Shaarli\Bookmark\BookmarkServiceInterface;
@@ -16,10 +17,24 @@ use Shaarli\NetscapeBookmarkParser\NetscapeBookmarkParser;
16 17
17/** 18/**
18 * Utilities to import and export bookmarks using the Netscape format 19 * Utilities to import and export bookmarks using the Netscape format
19 * TODO: Not static, use a container.
20 */ 20 */
21class NetscapeBookmarkUtils 21class NetscapeBookmarkUtils
22{ 22{
23 /** @var BookmarkServiceInterface */
24 protected $bookmarkService;
25
26 /** @var ConfigManager */
27 protected $conf;
28
29 /** @var History */
30 protected $history;
31
32 public function __construct(BookmarkServiceInterface $bookmarkService, ConfigManager $conf, History $history)
33 {
34 $this->bookmarkService = $bookmarkService;
35 $this->conf = $conf;
36 $this->history = $history;
37 }
23 38
24 /** 39 /**
25 * Filters bookmarks and adds Netscape-formatted fields 40 * Filters bookmarks and adds Netscape-formatted fields
@@ -28,18 +43,16 @@ class NetscapeBookmarkUtils
28 * - timestamp link addition date, using the Unix epoch format 43 * - timestamp link addition date, using the Unix epoch format
29 * - taglist comma-separated tag list 44 * - taglist comma-separated tag list
30 * 45 *
31 * @param BookmarkServiceInterface $bookmarkService Link datastore
32 * @param BookmarkFormatter $formatter instance 46 * @param BookmarkFormatter $formatter instance
33 * @param string $selection Which bookmarks to export: (all|private|public) 47 * @param string $selection Which bookmarks to export: (all|private|public)
34 * @param bool $prependNoteUrl Prepend note permalinks with the server's URL 48 * @param bool $prependNoteUrl Prepend note permalinks with the server's URL
35 * @param string $indexUrl Absolute URL of the Shaarli index page 49 * @param string $indexUrl Absolute URL of the Shaarli index page
36 * 50 *
37 * @return array The bookmarks to be exported, with additional fields 51 * @return array The bookmarks to be exported, with additional fields
38 *@throws Exception Invalid export selection
39 * 52 *
53 * @throws Exception Invalid export selection
40 */ 54 */
41 public static function filterAndFormat( 55 public function filterAndFormat(
42 $bookmarkService,
43 $formatter, 56 $formatter,
44 $selection, 57 $selection,
45 $prependNoteUrl, 58 $prependNoteUrl,
@@ -51,11 +64,11 @@ class NetscapeBookmarkUtils
51 } 64 }
52 65
53 $bookmarkLinks = array(); 66 $bookmarkLinks = array();
54 foreach ($bookmarkService->search([], $selection) as $bookmark) { 67 foreach ($this->bookmarkService->search([], $selection) as $bookmark) {
55 $link = $formatter->format($bookmark); 68 $link = $formatter->format($bookmark);
56 $link['taglist'] = implode(',', $bookmark->getTags()); 69 $link['taglist'] = implode(',', $bookmark->getTags());
57 if ($bookmark->isNote() && $prependNoteUrl) { 70 if ($bookmark->isNote() && $prependNoteUrl) {
58 $link['url'] = $indexUrl . $link['url']; 71 $link['url'] = rtrim($indexUrl, '/') . '/' . ltrim($link['url'], '/');
59 } 72 }
60 73
61 $bookmarkLinks[] = $link; 74 $bookmarkLinks[] = $link;
@@ -65,60 +78,22 @@ class NetscapeBookmarkUtils
65 } 78 }
66 79
67 /** 80 /**
68 * Generates an import status summary
69 *
70 * @param string $filename name of the file to import
71 * @param int $filesize size of the file to import
72 * @param int $importCount how many bookmarks were imported
73 * @param int $overwriteCount how many bookmarks were overwritten
74 * @param int $skipCount how many bookmarks were skipped
75 * @param int $duration how many seconds did the import take
76 *
77 * @return string Summary of the bookmark import status
78 */
79 private static function importStatus(
80 $filename,
81 $filesize,
82 $importCount = 0,
83 $overwriteCount = 0,
84 $skipCount = 0,
85 $duration = 0
86 ) {
87 $status = sprintf(t('File %s (%d bytes) '), $filename, $filesize);
88 if ($importCount == 0 && $overwriteCount == 0 && $skipCount == 0) {
89 $status .= t('has an unknown file format. Nothing was imported.');
90 } else {
91 $status .= vsprintf(
92 t(
93 'was successfully processed in %d seconds: '
94 . '%d bookmarks imported, %d bookmarks overwritten, %d bookmarks skipped.'
95 ),
96 [$duration, $importCount, $overwriteCount, $skipCount]
97 );
98 }
99 return $status;
100 }
101
102 /**
103 * Imports Web bookmarks from an uploaded Netscape bookmark dump 81 * Imports Web bookmarks from an uploaded Netscape bookmark dump
104 * 82 *
105 * @param array $post Server $_POST parameters 83 * @param array $post Server $_POST parameters
106 * @param array $files Server $_FILES parameters 84 * @param UploadedFileInterface $file File in PSR-7 object format
107 * @param BookmarkServiceInterface $bookmarkService Loaded LinkDB instance
108 * @param ConfigManager $conf instance
109 * @param History $history History instance
110 * 85 *
111 * @return string Summary of the bookmark import status 86 * @return string Summary of the bookmark import status
112 */ 87 */
113 public static function import($post, $files, $bookmarkService, $conf, $history) 88 public function import($post, UploadedFileInterface $file)
114 { 89 {
115 $start = time(); 90 $start = time();
116 $filename = $files['filetoupload']['name']; 91 $filename = $file->getClientFilename();
117 $filesize = $files['filetoupload']['size']; 92 $filesize = $file->getSize();
118 $data = file_get_contents($files['filetoupload']['tmp_name']); 93 $data = (string) $file->getStream();
119 94
120 if (preg_match('/<!DOCTYPE NETSCAPE-Bookmark-file-1>/i', $data) === 0) { 95 if (preg_match('/<!DOCTYPE NETSCAPE-Bookmark-file-1>/i', $data) === 0) {
121 return self::importStatus($filename, $filesize); 96 return $this->importStatus($filename, $filesize);
122 } 97 }
123 98
124 // Overwrite existing bookmarks? 99 // Overwrite existing bookmarks?
@@ -141,11 +116,11 @@ class NetscapeBookmarkUtils
141 true, // nested tag support 116 true, // nested tag support
142 $defaultTags, // additional user-specified tags 117 $defaultTags, // additional user-specified tags
143 strval(1 - $defaultPrivacy), // defaultPub = 1 - defaultPrivacy 118 strval(1 - $defaultPrivacy), // defaultPub = 1 - defaultPrivacy
144 $conf->get('resource.data_dir') // log path, will be overridden 119 $this->conf->get('resource.data_dir') // log path, will be overridden
145 ); 120 );
146 $logger = new Logger( 121 $logger = new Logger(
147 $conf->get('resource.data_dir'), 122 $this->conf->get('resource.data_dir'),
148 !$conf->get('dev.debug') ? LogLevel::INFO : LogLevel::DEBUG, 123 !$this->conf->get('dev.debug') ? LogLevel::INFO : LogLevel::DEBUG,
149 [ 124 [
150 'prefix' => 'import.', 125 'prefix' => 'import.',
151 'extension' => 'log', 126 'extension' => 'log',
@@ -171,7 +146,7 @@ class NetscapeBookmarkUtils
171 $private = 0; 146 $private = 0;
172 } 147 }
173 148
174 $link = $bookmarkService->findByUrl($bkm['uri']); 149 $link = $this->bookmarkService->findByUrl($bkm['uri']);
175 $existingLink = $link !== null; 150 $existingLink = $link !== null;
176 if (! $existingLink) { 151 if (! $existingLink) {
177 $link = new Bookmark(); 152 $link = new Bookmark();
@@ -193,20 +168,21 @@ class NetscapeBookmarkUtils
193 } 168 }
194 169
195 $link->setTitle($bkm['title']); 170 $link->setTitle($bkm['title']);
196 $link->setUrl($bkm['uri'], $conf->get('security.allowed_protocols')); 171 $link->setUrl($bkm['uri'], $this->conf->get('security.allowed_protocols'));
197 $link->setDescription($bkm['note']); 172 $link->setDescription($bkm['note']);
198 $link->setPrivate($private); 173 $link->setPrivate($private);
199 $link->setTagsString($bkm['tags']); 174 $link->setTagsString($bkm['tags']);
200 175
201 $bookmarkService->addOrSet($link, false); 176 $this->bookmarkService->addOrSet($link, false);
202 $importCount++; 177 $importCount++;
203 } 178 }
204 179
205 $bookmarkService->save(); 180 $this->bookmarkService->save();
206 $history->importLinks(); 181 $this->history->importLinks();
207 182
208 $duration = time() - $start; 183 $duration = time() - $start;
209 return self::importStatus( 184
185 return $this->importStatus(
210 $filename, 186 $filename,
211 $filesize, 187 $filesize,
212 $importCount, 188 $importCount,
@@ -215,4 +191,39 @@ class NetscapeBookmarkUtils
215 $duration 191 $duration
216 ); 192 );
217 } 193 }
194
195 /**
196 * Generates an import status summary
197 *
198 * @param string $filename name of the file to import
199 * @param int $filesize size of the file to import
200 * @param int $importCount how many bookmarks were imported
201 * @param int $overwriteCount how many bookmarks were overwritten
202 * @param int $skipCount how many bookmarks were skipped
203 * @param int $duration how many seconds did the import take
204 *
205 * @return string Summary of the bookmark import status
206 */
207 protected function importStatus(
208 $filename,
209 $filesize,
210 $importCount = 0,
211 $overwriteCount = 0,
212 $skipCount = 0,
213 $duration = 0
214 ) {
215 $status = sprintf(t('File %s (%d bytes) '), $filename, $filesize);
216 if ($importCount == 0 && $overwriteCount == 0 && $skipCount == 0) {
217 $status .= t('has an unknown file format. Nothing was imported.');
218 } else {
219 $status .= vsprintf(
220 t(
221 'was successfully processed in %d seconds: '
222 . '%d bookmarks imported, %d bookmarks overwritten, %d bookmarks skipped.'
223 ),
224 [$duration, $importCount, $overwriteCount, $skipCount]
225 );
226 }
227 return $status;
228 }
218} 229}
diff --git a/application/plugin/PluginManager.php b/application/plugin/PluginManager.php
index f7b24a8e..da66dea3 100644
--- a/application/plugin/PluginManager.php
+++ b/application/plugin/PluginManager.php
@@ -16,7 +16,7 @@ class PluginManager
16 * 16 *
17 * @var array $authorizedPlugins 17 * @var array $authorizedPlugins
18 */ 18 */
19 private $authorizedPlugins; 19 private $authorizedPlugins = [];
20 20
21 /** 21 /**
22 * List of loaded plugins. 22 * List of loaded plugins.
@@ -100,21 +100,36 @@ class PluginManager
100 */ 100 */
101 public function executeHooks($hook, &$data, $params = array()) 101 public function executeHooks($hook, &$data, $params = array())
102 { 102 {
103 if (!empty($params['target'])) { 103 $metadataParameters = [
104 $data['_PAGE_'] = $params['target']; 104 'target' => '_PAGE_',
105 } 105 'loggedin' => '_LOGGEDIN_',
106 106 'basePath' => '_BASE_PATH_',
107 if (isset($params['loggedin'])) { 107 'rootPath' => '_ROOT_PATH_',
108 $data['_LOGGEDIN_'] = $params['loggedin']; 108 'bookmarkService' => '_BOOKMARK_SERVICE_',
109 ];
110
111 foreach ($metadataParameters as $parameter => $metaKey) {
112 if (array_key_exists($parameter, $params)) {
113 $data[$metaKey] = $params[$parameter];
114 }
109 } 115 }
110 116
111 foreach ($this->loadedPlugins as $plugin) { 117 foreach ($this->loadedPlugins as $plugin) {
112 $hookFunction = $this->buildHookName($hook, $plugin); 118 $hookFunction = $this->buildHookName($hook, $plugin);
113 119
114 if (function_exists($hookFunction)) { 120 if (function_exists($hookFunction)) {
115 $data = call_user_func($hookFunction, $data, $this->conf); 121 try {
122 $data = call_user_func($hookFunction, $data, $this->conf);
123 } catch (\Throwable $e) {
124 $error = $plugin . t(' [plugin incompatibility]: ') . $e->getMessage();
125 $this->errors = array_unique(array_merge($this->errors, [$error]));
126 }
116 } 127 }
117 } 128 }
129
130 foreach ($metadataParameters as $metaKey) {
131 unset($data[$metaKey]);
132 }
118 } 133 }
119 134
120 /** 135 /**
diff --git a/application/render/PageBuilder.php b/application/render/PageBuilder.php
index f4fefda8..c2fae705 100644
--- a/application/render/PageBuilder.php
+++ b/application/render/PageBuilder.php
@@ -3,10 +3,12 @@
3namespace Shaarli\Render; 3namespace Shaarli\Render;
4 4
5use Exception; 5use Exception;
6use Psr\Log\LoggerInterface;
6use RainTPL; 7use RainTPL;
7use Shaarli\ApplicationUtils;
8use Shaarli\Bookmark\BookmarkServiceInterface; 8use Shaarli\Bookmark\BookmarkServiceInterface;
9use Shaarli\Config\ConfigManager; 9use Shaarli\Config\ConfigManager;
10use Shaarli\Helper\ApplicationUtils;
11use Shaarli\Security\SessionManager;
10use Shaarli\Thumbnailer; 12use Shaarli\Thumbnailer;
11 13
12/** 14/**
@@ -33,6 +35,9 @@ class PageBuilder
33 */ 35 */
34 protected $session; 36 protected $session;
35 37
38 /** @var LoggerInterface */
39 protected $logger;
40
36 /** 41 /**
37 * @var BookmarkServiceInterface $bookmarkService instance. 42 * @var BookmarkServiceInterface $bookmarkService instance.
38 */ 43 */
@@ -52,23 +57,40 @@ class PageBuilder
52 * PageBuilder constructor. 57 * PageBuilder constructor.
53 * $tpl is initialized at false for lazy loading. 58 * $tpl is initialized at false for lazy loading.
54 * 59 *
55 * @param ConfigManager $conf Configuration Manager instance (reference). 60 * @param ConfigManager $conf Configuration Manager instance (reference).
56 * @param array $session $_SESSION array 61 * @param array $session $_SESSION array
57 * @param BookmarkServiceInterface $linkDB instance. 62 * @param LoggerInterface $logger
58 * @param string $token Session token 63 * @param null $linkDB instance.
59 * @param bool $isLoggedIn 64 * @param null $token Session token
65 * @param bool $isLoggedIn
60 */ 66 */
61 public function __construct(&$conf, $session, $linkDB = null, $token = null, $isLoggedIn = false) 67 public function __construct(
62 { 68 ConfigManager &$conf,
69 array $session,
70 LoggerInterface $logger,
71 $linkDB = null,
72 $token = null,
73 $isLoggedIn = false
74 ) {
63 $this->tpl = false; 75 $this->tpl = false;
64 $this->conf = $conf; 76 $this->conf = $conf;
65 $this->session = $session; 77 $this->session = $session;
78 $this->logger = $logger;
66 $this->bookmarkService = $linkDB; 79 $this->bookmarkService = $linkDB;
67 $this->token = $token; 80 $this->token = $token;
68 $this->isLoggedIn = $isLoggedIn; 81 $this->isLoggedIn = $isLoggedIn;
69 } 82 }
70 83
71 /** 84 /**
85 * Reset current state of template rendering.
86 * Mostly useful for error handling. We remove everything, and display the error template.
87 */
88 public function reset(): void
89 {
90 $this->tpl = false;
91 }
92
93 /**
72 * Initialize all default tpl tags. 94 * Initialize all default tpl tags.
73 */ 95 */
74 private function initialize() 96 private function initialize()
@@ -87,7 +109,7 @@ class PageBuilder
87 $this->tpl->assign('newVersion', escape($version)); 109 $this->tpl->assign('newVersion', escape($version));
88 $this->tpl->assign('versionError', ''); 110 $this->tpl->assign('versionError', '');
89 } catch (Exception $exc) { 111 } catch (Exception $exc) {
90 logm($this->conf->get('resource.log'), $_SERVER['REMOTE_ADDR'], $exc->getMessage()); 112 $this->logger->error(format_log('Error: ' . $exc->getMessage(), client_ip_id($_SERVER)));
91 $this->tpl->assign('newVersion', ''); 113 $this->tpl->assign('newVersion', '');
92 $this->tpl->assign('versionError', escape($exc->getMessage())); 114 $this->tpl->assign('versionError', escape($exc->getMessage()));
93 } 115 }
@@ -126,7 +148,7 @@ class PageBuilder
126 $this->tpl->assign('language', $this->conf->get('translation.language')); 148 $this->tpl->assign('language', $this->conf->get('translation.language'));
127 149
128 if ($this->bookmarkService !== null) { 150 if ($this->bookmarkService !== null) {
129 $this->tpl->assign('tags', $this->bookmarkService->bookmarksCountPerTag()); 151 $this->tpl->assign('tags', escape($this->bookmarkService->bookmarksCountPerTag()));
130 } 152 }
131 153
132 $this->tpl->assign( 154 $this->tpl->assign(
@@ -136,18 +158,45 @@ class PageBuilder
136 $this->tpl->assign('thumbnails_width', $this->conf->get('thumbnails.width')); 158 $this->tpl->assign('thumbnails_width', $this->conf->get('thumbnails.width'));
137 $this->tpl->assign('thumbnails_height', $this->conf->get('thumbnails.height')); 159 $this->tpl->assign('thumbnails_height', $this->conf->get('thumbnails.height'));
138 160
139 if (!empty($_SESSION['warnings'])) {
140 $this->tpl->assign('global_warnings', $_SESSION['warnings']);
141 unset($_SESSION['warnings']);
142 }
143
144 $this->tpl->assign('formatter', $this->conf->get('formatter', 'default')); 161 $this->tpl->assign('formatter', $this->conf->get('formatter', 'default'));
145 162
163 $this->tpl->assign('links_per_page', $this->session['LINKS_PER_PAGE'] ?? 20);
164
146 // To be removed with a proper theme configuration. 165 // To be removed with a proper theme configuration.
147 $this->tpl->assign('conf', $this->conf); 166 $this->tpl->assign('conf', $this->conf);
148 } 167 }
149 168
150 /** 169 /**
170 * Affect variable after controller processing.
171 * Used for alert messages.
172 */
173 protected function finalize(string $basePath): void
174 {
175 // TODO: use the SessionManager
176 $messageKeys = [
177 SessionManager::KEY_SUCCESS_MESSAGES,
178 SessionManager::KEY_WARNING_MESSAGES,
179 SessionManager::KEY_ERROR_MESSAGES
180 ];
181 foreach ($messageKeys as $messageKey) {
182 if (!empty($_SESSION[$messageKey])) {
183 $this->tpl->assign('global_' . $messageKey, $_SESSION[$messageKey]);
184 unset($_SESSION[$messageKey]);
185 }
186 }
187
188 $rootPath = preg_replace('#/index\.php$#', '', $basePath);
189 $this->assign('base_path', $basePath);
190 $this->assign('root_path', $rootPath);
191 $this->assign(
192 'asset_path',
193 $rootPath . '/' .
194 rtrim($this->conf->get('resource.raintpl_tpl', 'tpl'), '/') . '/' .
195 $this->conf->get('resource.theme', 'default')
196 );
197 }
198
199 /**
151 * The following assign() method is basically the same as RainTPL (except lazy loading) 200 * The following assign() method is basically the same as RainTPL (except lazy loading)
152 * 201 *
153 * @param string $placeholder Template placeholder. 202 * @param string $placeholder Template placeholder.
@@ -185,21 +234,6 @@ class PageBuilder
185 } 234 }
186 235
187 /** 236 /**
188 * Render a specific page (using a template file).
189 * e.g. $pb->renderPage('picwall');
190 *
191 * @param string $page Template filename (without extension).
192 */
193 public function renderPage($page)
194 {
195 if ($this->tpl === false) {
196 $this->initialize();
197 }
198
199 $this->tpl->draw($page);
200 }
201
202 /**
203 * Render a specific page as string (using a template file). 237 * Render a specific page as string (using a template file).
204 * e.g. $pb->render('picwall'); 238 * e.g. $pb->render('picwall');
205 * 239 *
@@ -207,28 +241,14 @@ class PageBuilder
207 * 241 *
208 * @return string Processed template content 242 * @return string Processed template content
209 */ 243 */
210 public function render(string $page): string 244 public function render(string $page, string $basePath): string
211 { 245 {
212 if ($this->tpl === false) { 246 if ($this->tpl === false) {
213 $this->initialize(); 247 $this->initialize();
214 } 248 }
215 249
216 return $this->tpl->draw($page, true); 250 $this->finalize($basePath);
217 }
218 251
219 /** 252 return $this->tpl->draw($page, true);
220 * Render a 404 page (uses the template : tpl/404.tpl)
221 * usage: $PAGE->render404('The link was deleted')
222 *
223 * @param string $message A message to display what is not found
224 */
225 public function render404($message = '')
226 {
227 if (empty($message)) {
228 $message = t('The page you are trying to reach does not exist or has been deleted.');
229 }
230 header($_SERVER['SERVER_PROTOCOL'] . ' ' . t('404 Not Found'));
231 $this->tpl->assign('error_message', $message);
232 $this->renderPage('404');
233 } 253 }
234} 254}
diff --git a/application/render/PageCacheManager.php b/application/render/PageCacheManager.php
new file mode 100644
index 00000000..97805c35
--- /dev/null
+++ b/application/render/PageCacheManager.php
@@ -0,0 +1,60 @@
1<?php
2
3namespace Shaarli\Render;
4
5use Shaarli\Feed\CachedPage;
6
7/**
8 * Cache utilities
9 */
10class PageCacheManager
11{
12 /** @var string Cache directory */
13 protected $pageCacheDir;
14
15 /** @var bool */
16 protected $isLoggedIn;
17
18 public function __construct(string $pageCacheDir, bool $isLoggedIn)
19 {
20 $this->pageCacheDir = $pageCacheDir;
21 $this->isLoggedIn = $isLoggedIn;
22 }
23
24 /**
25 * Purges all cached pages
26 *
27 * @return string|null an error string if the directory is missing
28 */
29 public function purgeCachedPages(): ?string
30 {
31 if (!is_dir($this->pageCacheDir)) {
32 $error = sprintf(t('Cannot purge %s: no directory'), $this->pageCacheDir);
33 error_log($error);
34
35 return $error;
36 }
37
38 array_map('unlink', glob($this->pageCacheDir . '/*.cache'));
39
40 return null;
41 }
42
43 /**
44 * Invalidates caches when the database is changed or the user logs out.
45 */
46 public function invalidateCaches(): void
47 {
48 // Purge page cache shared by sessions.
49 $this->purgeCachedPages();
50 }
51
52 public function getCachePage(string $pageUrl): CachedPage
53 {
54 return new CachedPage(
55 $this->pageCacheDir,
56 $pageUrl,
57 false === $this->isLoggedIn
58 );
59 }
60}
diff --git a/application/render/TemplatePage.php b/application/render/TemplatePage.php
new file mode 100644
index 00000000..03b424f3
--- /dev/null
+++ b/application/render/TemplatePage.php
@@ -0,0 +1,34 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Render;
6
7interface TemplatePage
8{
9 public const ERROR_404 = '404';
10 public const ADDLINK = 'addlink';
11 public const CHANGE_PASSWORD = 'changepassword';
12 public const CHANGE_TAG = 'changetag';
13 public const CONFIGURE = 'configure';
14 public const DAILY = 'daily';
15 public const DAILY_RSS = 'dailyrss';
16 public const EDIT_LINK = 'editlink';
17 public const EDIT_LINK_BATCH = 'editlink.batch';
18 public const ERROR = 'error';
19 public const EXPORT = 'export';
20 public const NETSCAPE_EXPORT_BOOKMARKS = 'export.bookmarks';
21 public const FEED_ATOM = 'feed.atom';
22 public const FEED_RSS = 'feed.rss';
23 public const IMPORT = 'import';
24 public const INSTALL = 'install';
25 public const LINKLIST = 'linklist';
26 public const LOGIN = 'loginform';
27 public const OPEN_SEARCH = 'opensearch';
28 public const PICTURE_WALL = 'picwall';
29 public const PLUGINS_ADMIN = 'pluginsadmin';
30 public const TAG_CLOUD = 'tag.cloud';
31 public const TAG_LIST = 'tag.list';
32 public const THUMBNAILS = 'thumbnails';
33 public const TOOLS = 'tools';
34}
diff --git a/application/security/BanManager.php b/application/security/BanManager.php
index 68190c54..288cbde0 100644
--- a/application/security/BanManager.php
+++ b/application/security/BanManager.php
@@ -3,7 +3,8 @@
3 3
4namespace Shaarli\Security; 4namespace Shaarli\Security;
5 5
6use Shaarli\FileUtils; 6use Psr\Log\LoggerInterface;
7use Shaarli\Helper\FileUtils;
7 8
8/** 9/**
9 * Class BanManager 10 * Class BanManager
@@ -28,8 +29,8 @@ class BanManager
28 /** @var string Path to the file containing IP bans and failures */ 29 /** @var string Path to the file containing IP bans and failures */
29 protected $banFile; 30 protected $banFile;
30 31
31 /** @var string Path to the log file, used to log bans */ 32 /** @var LoggerInterface Path to the log file, used to log bans */
32 protected $logFile; 33 protected $logger;
33 34
34 /** @var array List of IP with their associated number of failed attempts */ 35 /** @var array List of IP with their associated number of failed attempts */
35 protected $failures = []; 36 protected $failures = [];
@@ -40,18 +41,19 @@ class BanManager
40 /** 41 /**
41 * BanManager constructor. 42 * BanManager constructor.
42 * 43 *
43 * @param array $trustedProxies List of allowed proxies IP 44 * @param array $trustedProxies List of allowed proxies IP
44 * @param int $nbAttempts Number of allowed failed attempt before the ban 45 * @param int $nbAttempts Number of allowed failed attempt before the ban
45 * @param int $banDuration Ban duration in seconds 46 * @param int $banDuration Ban duration in seconds
46 * @param string $banFile Path to the file containing IP bans and failures 47 * @param string $banFile Path to the file containing IP bans and failures
47 * @param string $logFile Path to the log file, used to log bans 48 * @param LoggerInterface $logger PSR-3 logger to save login attempts in log directory
48 */ 49 */
49 public function __construct($trustedProxies, $nbAttempts, $banDuration, $banFile, $logFile) { 50 public function __construct($trustedProxies, $nbAttempts, $banDuration, $banFile, LoggerInterface $logger) {
50 $this->trustedProxies = $trustedProxies; 51 $this->trustedProxies = $trustedProxies;
51 $this->nbAttempts = $nbAttempts; 52 $this->nbAttempts = $nbAttempts;
52 $this->banDuration = $banDuration; 53 $this->banDuration = $banDuration;
53 $this->banFile = $banFile; 54 $this->banFile = $banFile;
54 $this->logFile = $logFile; 55 $this->logger = $logger;
56
55 $this->readBanFile(); 57 $this->readBanFile();
56 } 58 }
57 59
@@ -78,11 +80,7 @@ class BanManager
78 80
79 if ($this->failures[$ip] >= $this->nbAttempts) { 81 if ($this->failures[$ip] >= $this->nbAttempts) {
80 $this->bans[$ip] = time() + $this->banDuration; 82 $this->bans[$ip] = time() + $this->banDuration;
81 logm( 83 $this->logger->info(format_log('IP address banned from login: '. $ip, $ip));
82 $this->logFile,
83 $server['REMOTE_ADDR'],
84 'IP address banned from login: '. $ip
85 );
86 } 84 }
87 $this->writeBanFile(); 85 $this->writeBanFile();
88 } 86 }
@@ -138,7 +136,7 @@ class BanManager
138 unset($this->failures[$ip]); 136 unset($this->failures[$ip]);
139 } 137 }
140 unset($this->bans[$ip]); 138 unset($this->bans[$ip]);
141 logm($this->logFile, $server['REMOTE_ADDR'], 'Ban lifted for: '. $ip); 139 $this->logger->info(format_log('Ban lifted for: '. $ip, $ip));
142 140
143 $this->writeBanFile(); 141 $this->writeBanFile();
144 return false; 142 return false;
diff --git a/application/security/CookieManager.php b/application/security/CookieManager.php
new file mode 100644
index 00000000..cde4746e
--- /dev/null
+++ b/application/security/CookieManager.php
@@ -0,0 +1,33 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Security;
6
7class CookieManager
8{
9 /** @var string Name of the cookie set after logging in **/
10 public const STAY_SIGNED_IN = 'shaarli_staySignedIn';
11
12 /** @var mixed $_COOKIE set by reference */
13 protected $cookies;
14
15 public function __construct(array &$cookies)
16 {
17 $this->cookies = $cookies;
18 }
19
20 public function setCookieParameter(string $key, string $value, int $expires, string $path): self
21 {
22 $this->cookies[$key] = $value;
23
24 setcookie($key, $value, $expires, $path);
25
26 return $this;
27 }
28
29 public function getCookieParameter(string $key, string $default = null): ?string
30 {
31 return $this->cookies[$key] ?? $default;
32 }
33}
diff --git a/application/security/LoginManager.php b/application/security/LoginManager.php
index 39ec9b2e..426e785e 100644
--- a/application/security/LoginManager.php
+++ b/application/security/LoginManager.php
@@ -2,6 +2,7 @@
2namespace Shaarli\Security; 2namespace Shaarli\Security;
3 3
4use Exception; 4use Exception;
5use Psr\Log\LoggerInterface;
5use Shaarli\Config\ConfigManager; 6use Shaarli\Config\ConfigManager;
6 7
7/** 8/**
@@ -9,9 +10,6 @@ use Shaarli\Config\ConfigManager;
9 */ 10 */
10class LoginManager 11class LoginManager
11{ 12{
12 /** @var string Name of the cookie set after logging in **/
13 public static $STAY_SIGNED_IN_COOKIE = 'shaarli_staySignedIn';
14
15 /** @var array A reference to the $_GLOBALS array */ 13 /** @var array A reference to the $_GLOBALS array */
16 protected $globals = []; 14 protected $globals = [];
17 15
@@ -32,24 +30,32 @@ class LoginManager
32 30
33 /** @var string User sign-in token depending on remote IP and credentials */ 31 /** @var string User sign-in token depending on remote IP and credentials */
34 protected $staySignedInToken = ''; 32 protected $staySignedInToken = '';
33 /** @var CookieManager */
34 protected $cookieManager;
35 /** @var LoggerInterface */
36 protected $logger;
35 37
36 /** 38 /**
37 * Constructor 39 * Constructor
38 * 40 *
39 * @param ConfigManager $configManager Configuration Manager instance 41 * @param ConfigManager $configManager Configuration Manager instance
40 * @param SessionManager $sessionManager SessionManager instance 42 * @param SessionManager $sessionManager SessionManager instance
43 * @param CookieManager $cookieManager CookieManager instance
44 * @param BanManager $banManager
45 * @param LoggerInterface $logger Used to log login attempts
41 */ 46 */
42 public function __construct($configManager, $sessionManager) 47 public function __construct(
43 { 48 ConfigManager $configManager,
49 SessionManager $sessionManager,
50 CookieManager $cookieManager,
51 BanManager $banManager,
52 LoggerInterface $logger
53 ) {
44 $this->configManager = $configManager; 54 $this->configManager = $configManager;
45 $this->sessionManager = $sessionManager; 55 $this->sessionManager = $sessionManager;
46 $this->banManager = new BanManager( 56 $this->cookieManager = $cookieManager;
47 $this->configManager->get('security.trusted_proxies', []), 57 $this->banManager = $banManager;
48 $this->configManager->get('security.ban_after'), 58 $this->logger = $logger;
49 $this->configManager->get('security.ban_duration'),
50 $this->configManager->get('resource.ban_file', 'data/ipbans.php'),
51 $this->configManager->get('resource.log')
52 );
53 59
54 if ($this->configManager->get('security.open_shaarli') === true) { 60 if ($this->configManager->get('security.open_shaarli') === true) {
55 $this->openShaarli = true; 61 $this->openShaarli = true;
@@ -86,10 +92,9 @@ class LoginManager
86 /** 92 /**
87 * Check user session state and validity (expiration) 93 * Check user session state and validity (expiration)
88 * 94 *
89 * @param array $cookie The $_COOKIE array
90 * @param string $clientIpId Client IP address identifier 95 * @param string $clientIpId Client IP address identifier
91 */ 96 */
92 public function checkLoginState($cookie, $clientIpId) 97 public function checkLoginState($clientIpId)
93 { 98 {
94 if (! $this->configManager->exists('credentials.login')) { 99 if (! $this->configManager->exists('credentials.login')) {
95 // Shaarli is not configured yet 100 // Shaarli is not configured yet
@@ -97,9 +102,7 @@ class LoginManager
97 return; 102 return;
98 } 103 }
99 104
100 if (isset($cookie[self::$STAY_SIGNED_IN_COOKIE]) 105 if ($this->staySignedInToken === $this->cookieManager->getCookieParameter(CookieManager::STAY_SIGNED_IN)) {
101 && $cookie[self::$STAY_SIGNED_IN_COOKIE] === $this->staySignedInToken
102 ) {
103 // The user client has a valid stay-signed-in cookie 106 // The user client has a valid stay-signed-in cookie
104 // Session information is updated with the current client information 107 // Session information is updated with the current client information
105 $this->sessionManager->storeLoginInfo($clientIpId); 108 $this->sessionManager->storeLoginInfo($clientIpId);
@@ -120,7 +123,7 @@ class LoginManager
120 * 123 *
121 * @return true when the user is logged in, false otherwise 124 * @return true when the user is logged in, false otherwise
122 */ 125 */
123 public function isLoggedIn() 126 public function isLoggedIn(): bool
124 { 127 {
125 if ($this->openShaarli) { 128 if ($this->openShaarli) {
126 return true; 129 return true;
@@ -131,48 +134,34 @@ class LoginManager
131 /** 134 /**
132 * Check user credentials are valid 135 * Check user credentials are valid
133 * 136 *
134 * @param string $remoteIp Remote client IP address
135 * @param string $clientIpId Client IP address identifier 137 * @param string $clientIpId Client IP address identifier
136 * @param string $login Username 138 * @param string $login Username
137 * @param string $password Password 139 * @param string $password Password
138 * 140 *
139 * @return bool true if the provided credentials are valid, false otherwise 141 * @return bool true if the provided credentials are valid, false otherwise
140 */ 142 */
141 public function checkCredentials($remoteIp, $clientIpId, $login, $password) 143 public function checkCredentials($clientIpId, $login, $password)
142 { 144 {
143 // Check login matches config
144 if ($login !== $this->configManager->get('credentials.login')) {
145 return false;
146 }
147
148 // Check credentials 145 // Check credentials
149 try { 146 try {
150 $useLdapLogin = !empty($this->configManager->get('ldap.host')); 147 $useLdapLogin = !empty($this->configManager->get('ldap.host'));
151 if ((false === $useLdapLogin && $this->checkCredentialsFromLocalConfig($login, $password)) 148 if ($login === $this->configManager->get('credentials.login')
152 || (true === $useLdapLogin && $this->checkCredentialsFromLdap($login, $password)) 149 && (
150 (false === $useLdapLogin && $this->checkCredentialsFromLocalConfig($login, $password))
151 || (true === $useLdapLogin && $this->checkCredentialsFromLdap($login, $password))
152 )
153 ) { 153 ) {
154 $this->sessionManager->storeLoginInfo($clientIpId); 154 $this->sessionManager->storeLoginInfo($clientIpId);
155 logm( 155 $this->logger->info(format_log('Login successful', $clientIpId));
156 $this->configManager->get('resource.log'), 156
157 $remoteIp, 157 return true;
158 'Login successful'
159 );
160 return true;
161 } 158 }
162 } 159 } catch(Exception $exception) {
163 catch(Exception $exception) { 160 $this->logger->info(format_log('Exception while checking credentials: ' . $exception, $clientIpId));
164 logm(
165 $this->configManager->get('resource.log'),
166 $remoteIp,
167 'Exception while checking credentials: ' . $exception
168 );
169 } 161 }
170 162
171 logm( 163 $this->logger->info(format_log('Login failed for user ' . $login, $clientIpId));
172 $this->configManager->get('resource.log'), 164
173 $remoteIp,
174 'Login failed for user ' . $login
175 );
176 return false; 165 return false;
177 } 166 }
178 167
diff --git a/application/security/SessionManager.php b/application/security/SessionManager.php
index 994fcbe5..96bf193c 100644
--- a/application/security/SessionManager.php
+++ b/application/security/SessionManager.php
@@ -8,6 +8,14 @@ use Shaarli\Config\ConfigManager;
8 */ 8 */
9class SessionManager 9class SessionManager
10{ 10{
11 public const KEY_LINKS_PER_PAGE = 'LINKS_PER_PAGE';
12 public const KEY_VISIBILITY = 'visibility';
13 public const KEY_UNTAGGED_ONLY = 'untaggedonly';
14
15 public const KEY_SUCCESS_MESSAGES = 'successes';
16 public const KEY_WARNING_MESSAGES = 'warnings';
17 public const KEY_ERROR_MESSAGES = 'errors';
18
11 /** @var int Session expiration timeout, in seconds */ 19 /** @var int Session expiration timeout, in seconds */
12 public static $SHORT_TIMEOUT = 3600; // 1 hour 20 public static $SHORT_TIMEOUT = 3600; // 1 hour
13 21
@@ -23,16 +31,35 @@ class SessionManager
23 /** @var bool Whether the user should stay signed in (LONG_TIMEOUT) */ 31 /** @var bool Whether the user should stay signed in (LONG_TIMEOUT) */
24 protected $staySignedIn = false; 32 protected $staySignedIn = false;
25 33
34 /** @var string */
35 protected $savePath;
36
26 /** 37 /**
27 * Constructor 38 * Constructor
28 * 39 *
29 * @param array $session The $_SESSION array (reference) 40 * @param array $session The $_SESSION array (reference)
30 * @param ConfigManager $conf ConfigManager instance 41 * @param ConfigManager $conf ConfigManager instance
42 * @param string $savePath Session save path returned by builtin function session_save_path()
31 */ 43 */
32 public function __construct(& $session, $conf) 44 public function __construct(&$session, $conf, string $savePath)
33 { 45 {
34 $this->session = &$session; 46 $this->session = &$session;
35 $this->conf = $conf; 47 $this->conf = $conf;
48 $this->savePath = $savePath;
49 }
50
51 /**
52 * Initialize XSRF token and links per page session variables.
53 */
54 public function initialize(): void
55 {
56 if (!isset($this->session['tokens'])) {
57 $this->session['tokens'] = [];
58 }
59
60 if (!isset($this->session['LINKS_PER_PAGE'])) {
61 $this->session['LINKS_PER_PAGE'] = $this->conf->get('general.links_per_page', 20);
62 }
36 } 63 }
37 64
38 /** 65 /**
@@ -156,7 +183,6 @@ class SessionManager
156 unset($this->session['expires_on']); 183 unset($this->session['expires_on']);
157 unset($this->session['username']); 184 unset($this->session['username']);
158 unset($this->session['visibility']); 185 unset($this->session['visibility']);
159 unset($this->session['untaggedonly']);
160 } 186 }
161 } 187 }
162 188
@@ -202,4 +228,81 @@ class SessionManager
202 { 228 {
203 return $this->session; 229 return $this->session;
204 } 230 }
231
232 /**
233 * @param mixed $default value which will be returned if the $key is undefined
234 *
235 * @return mixed Content stored in session
236 */
237 public function getSessionParameter(string $key, $default = null)
238 {
239 return $this->session[$key] ?? $default;
240 }
241
242 /**
243 * Store a variable in user session.
244 *
245 * @param string $key Session key
246 * @param mixed $value Session value to store
247 *
248 * @return $this
249 */
250 public function setSessionParameter(string $key, $value): self
251 {
252 $this->session[$key] = $value;
253
254 return $this;
255 }
256
257 /**
258 * Store a variable in user session.
259 *
260 * @param string $key Session key
261 *
262 * @return $this
263 */
264 public function deleteSessionParameter(string $key): self
265 {
266 unset($this->session[$key]);
267
268 return $this;
269 }
270
271 public function getSavePath(): string
272 {
273 return $this->savePath;
274 }
275
276 /*
277 * Next public functions wrapping native PHP session API.
278 */
279
280 public function destroy(): bool
281 {
282 $this->session = [];
283
284 return session_destroy();
285 }
286
287 public function start(): bool
288 {
289 if (session_status() === PHP_SESSION_ACTIVE) {
290 $this->destroy();
291 }
292
293 return session_start();
294 }
295
296 /**
297 * Be careful, return type of session_set_cookie_params() changed between PHP 7.1 and 7.2.
298 */
299 public function cookieParameters(int $lifeTime, string $path, string $domain): void
300 {
301 session_set_cookie_params($lifeTime, $path, $domain);
302 }
303
304 public function regenerateId(bool $deleteOldSession = false): bool
305 {
306 return session_regenerate_id($deleteOldSession);
307 }
205} 308}
diff --git a/application/updater/Updater.php b/application/updater/Updater.php
index 95654d81..88a7bc7b 100644
--- a/application/updater/Updater.php
+++ b/application/updater/Updater.php
@@ -2,8 +2,8 @@
2 2
3namespace Shaarli\Updater; 3namespace Shaarli\Updater;
4 4
5use Shaarli\Config\ConfigManager;
6use Shaarli\Bookmark\BookmarkServiceInterface; 5use Shaarli\Bookmark\BookmarkServiceInterface;
6use Shaarli\Config\ConfigManager;
7use Shaarli\Updater\Exception\UpdaterException; 7use Shaarli\Updater\Exception\UpdaterException;
8 8
9/** 9/**
@@ -21,7 +21,7 @@ class Updater
21 /** 21 /**
22 * @var BookmarkServiceInterface instance. 22 * @var BookmarkServiceInterface instance.
23 */ 23 */
24 protected $linkServices; 24 protected $bookmarkService;
25 25
26 /** 26 /**
27 * @var ConfigManager $conf Configuration Manager instance. 27 * @var ConfigManager $conf Configuration Manager instance.
@@ -39,6 +39,11 @@ class Updater
39 protected $methods; 39 protected $methods;
40 40
41 /** 41 /**
42 * @var string $basePath Shaarli root directory (from HTTP Request)
43 */
44 protected $basePath = null;
45
46 /**
42 * Object constructor. 47 * Object constructor.
43 * 48 *
44 * @param array $doneUpdates Updates which are already done. 49 * @param array $doneUpdates Updates which are already done.
@@ -49,7 +54,7 @@ class Updater
49 public function __construct($doneUpdates, $linkDB, $conf, $isLoggedIn) 54 public function __construct($doneUpdates, $linkDB, $conf, $isLoggedIn)
50 { 55 {
51 $this->doneUpdates = $doneUpdates; 56 $this->doneUpdates = $doneUpdates;
52 $this->linkServices = $linkDB; 57 $this->bookmarkService = $linkDB;
53 $this->conf = $conf; 58 $this->conf = $conf;
54 $this->isLoggedIn = $isLoggedIn; 59 $this->isLoggedIn = $isLoggedIn;
55 60
@@ -62,13 +67,15 @@ class Updater
62 * Run all new updates. 67 * Run all new updates.
63 * Update methods have to start with 'updateMethod' and return true (on success). 68 * Update methods have to start with 'updateMethod' and return true (on success).
64 * 69 *
70 * @param string $basePath Shaarli root directory (from HTTP Request)
71 *
65 * @return array An array containing ran updates. 72 * @return array An array containing ran updates.
66 * 73 *
67 * @throws UpdaterException If something went wrong. 74 * @throws UpdaterException If something went wrong.
68 */ 75 */
69 public function update() 76 public function update(string $basePath = null)
70 { 77 {
71 $updatesRan = array(); 78 $updatesRan = [];
72 79
73 // If the user isn't logged in, exit without updating. 80 // If the user isn't logged in, exit without updating.
74 if ($this->isLoggedIn !== true) { 81 if ($this->isLoggedIn !== true) {
@@ -111,4 +118,62 @@ class Updater
111 { 118 {
112 return $this->doneUpdates; 119 return $this->doneUpdates;
113 } 120 }
121
122 public function readUpdates(string $updatesFilepath): array
123 {
124 return UpdaterUtils::read_updates_file($updatesFilepath);
125 }
126
127 public function writeUpdates(string $updatesFilepath, array $updates): void
128 {
129 UpdaterUtils::write_updates_file($updatesFilepath, $updates);
130 }
131
132 /**
133 * With the Slim routing system, default header link should be `/subfolder/` instead of `?`.
134 * Otherwise you can not go back to the home page.
135 * Example: `/subfolder/picture-wall` -> `/subfolder/picture-wall?` instead of `/subfolder/`.
136 */
137 public function updateMethodRelativeHomeLink(): bool
138 {
139 if ('?' === trim($this->conf->get('general.header_link'))) {
140 $this->conf->set('general.header_link', $this->basePath . '/', true, true);
141 }
142
143 return true;
144 }
145
146 /**
147 * With the Slim routing system, note bookmarks URL formatted `?abcdef`
148 * should be replaced with `/shaare/abcdef`
149 */
150 public function updateMethodMigrateExistingNotesUrl(): bool
151 {
152 $updated = false;
153
154 foreach ($this->bookmarkService->search() as $bookmark) {
155 if ($bookmark->isNote()
156 && startsWith($bookmark->getUrl(), '?')
157 && 1 === preg_match('/^\?([a-zA-Z0-9-_@]{6})($|&|#)/', $bookmark->getUrl(), $match)
158 ) {
159 $updated = true;
160 $bookmark = $bookmark->setUrl('/shaare/' . $match[1]);
161
162 $this->bookmarkService->set($bookmark, false);
163 }
164 }
165
166 if ($updated) {
167 $this->bookmarkService->save();
168 }
169
170 return true;
171 }
172
173 public function setBasePath(string $basePath): self
174 {
175 $this->basePath = $basePath;
176
177 return $this;
178 }
114} 179}