aboutsummaryrefslogtreecommitdiffhomepage
path: root/application
diff options
context:
space:
mode:
Diffstat (limited to 'application')
-rw-r--r--application/ApplicationUtils.php3
-rw-r--r--application/History.php17
-rw-r--r--application/Languages.php3
-rw-r--r--application/Router.php184
-rw-r--r--application/Thumbnailer.php4
-rw-r--r--application/Utils.php22
-rw-r--r--application/api/ApiMiddleware.php30
-rw-r--r--application/api/ApiUtils.php83
-rw-r--r--application/api/controllers/ApiController.php8
-rw-r--r--application/api/controllers/HistoryController.php2
-rw-r--r--application/api/controllers/Info.php5
-rw-r--r--application/api/controllers/Links.php79
-rw-r--r--application/api/controllers/Tags.php44
-rw-r--r--application/bookmark/Bookmark.php462
-rw-r--r--application/bookmark/BookmarkArray.php260
-rw-r--r--application/bookmark/BookmarkFileService.php407
-rw-r--r--application/bookmark/BookmarkFilter.php473
-rw-r--r--application/bookmark/BookmarkIO.php108
-rw-r--r--application/bookmark/BookmarkInitializer.php110
-rw-r--r--application/bookmark/BookmarkServiceInterface.php186
-rw-r--r--application/bookmark/LinkUtils.php137
-rw-r--r--application/bookmark/exception/BookmarkNotFoundException.php (renamed from application/bookmark/exception/LinkNotFoundException.php)2
-rw-r--r--application/bookmark/exception/DatastoreNotInitializedException.php10
-rw-r--r--application/bookmark/exception/EmptyDataStoreException.php7
-rw-r--r--application/bookmark/exception/InvalidBookmarkException.php30
-rw-r--r--application/bookmark/exception/NotWritableDataStoreException.php19
-rw-r--r--application/config/ConfigJson.php2
-rw-r--r--application/config/ConfigManager.php6
-rw-r--r--application/config/ConfigPlugin.php17
-rw-r--r--application/container/ContainerBuilder.php165
-rw-r--r--application/container/ShaarliContainer.php51
-rw-r--r--application/feed/Cache.php38
-rw-r--r--application/feed/FeedBuilder.php189
-rw-r--r--application/formatter/BookmarkDefaultFormatter.php87
-rw-r--r--application/formatter/BookmarkFormatter.php313
-rw-r--r--application/formatter/BookmarkMarkdownFormatter.php206
-rw-r--r--application/formatter/BookmarkRawFormatter.php13
-rw-r--r--application/formatter/FormatterFactory.php51
-rw-r--r--application/front/ShaarliAdminMiddleware.php27
-rw-r--r--application/front/ShaarliMiddleware.php114
-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/ManageShaareController.php371
-rw-r--r--application/front/controller/admin/ManageTagController.php88
-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/SessionFilterController.php50
-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.php241
-rw-r--r--application/front/controller/visitor/DailyController.php192
-rw-r--r--application/front/controller/visitor/ErrorController.php45
-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.php165
-rw-r--r--application/front/controller/visitor/LoginController.php154
-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.php180
-rw-r--r--application/front/controller/visitor/TagCloudController.php121
-rw-r--r--application/front/controller/visitor/TagController.php118
-rw-r--r--application/front/exceptions/AlreadyInstalledException.php15
-rw-r--r--application/front/exceptions/CantLoginException.php10
-rw-r--r--application/front/exceptions/LoginBannedException.php15
-rw-r--r--application/front/exceptions/OpenShaarliPasswordException.php18
-rw-r--r--application/front/exceptions/ResourcePermissionException.php13
-rw-r--r--application/front/exceptions/ShaarliFrontException.php23
-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/http/HttpAccess.php39
-rw-r--r--application/http/HttpUtils.php125
-rw-r--r--application/http/UrlUtils.php2
-rw-r--r--application/legacy/LegacyController.php162
-rw-r--r--application/legacy/LegacyLinkDB.php (renamed from application/bookmark/LinkDB.php)75
-rw-r--r--application/legacy/LegacyLinkFilter.php (renamed from application/bookmark/LinkFilter.php)20
-rw-r--r--application/legacy/LegacyRouter.php63
-rw-r--r--application/legacy/LegacyUpdater.php618
-rw-r--r--application/legacy/UnknowLegacyRouteException.php9
-rw-r--r--application/netscape/NetscapeBookmarkUtils.php212
-rw-r--r--application/plugin/PluginManager.php30
-rw-r--r--application/render/PageBuilder.php94
-rw-r--r--application/render/PageCacheManager.php60
-rw-r--r--application/render/TemplatePage.php33
-rw-r--r--application/security/CookieManager.php33
-rw-r--r--application/security/LoginManager.php95
-rw-r--r--application/security/SessionManager.php114
-rw-r--r--application/updater/Updater.php485
-rw-r--r--application/updater/UpdaterUtils.php65
94 files changed, 7817 insertions, 1276 deletions
diff --git a/application/ApplicationUtils.php b/application/ApplicationUtils.php
index 7fe3cb32..3aa21829 100644
--- a/application/ApplicationUtils.php
+++ b/application/ApplicationUtils.php
@@ -150,6 +150,8 @@ class ApplicationUtils
150 * @param string $minVersion minimum PHP required version 150 * @param string $minVersion minimum PHP required version
151 * @param string $curVersion current PHP version (use PHP_VERSION) 151 * @param string $curVersion current PHP version (use PHP_VERSION)
152 * 152 *
153 * @return bool true on success
154 *
153 * @throws Exception the PHP version is not supported 155 * @throws Exception the PHP version is not supported
154 */ 156 */
155 public static function checkPHPVersion($minVersion, $curVersion) 157 public static function checkPHPVersion($minVersion, $curVersion)
@@ -163,6 +165,7 @@ class ApplicationUtils
163 ); 165 );
164 throw new Exception(sprintf($msg, $minVersion)); 166 throw new Exception(sprintf($msg, $minVersion));
165 } 167 }
168 return true;
166 } 169 }
167 170
168 /** 171 /**
diff --git a/application/History.php b/application/History.php
index a5846652..4fd2f294 100644
--- a/application/History.php
+++ b/application/History.php
@@ -3,6 +3,7 @@ namespace Shaarli;
3 3
4use DateTime; 4use DateTime;
5use Exception; 5use Exception;
6use Shaarli\Bookmark\Bookmark;
6 7
7/** 8/**
8 * Class History 9 * Class History
@@ -20,7 +21,7 @@ use Exception;
20 * - UPDATED: link updated 21 * - UPDATED: link updated
21 * - DELETED: link deleted 22 * - DELETED: link deleted
22 * - SETTINGS: the settings have been updated through the UI. 23 * - SETTINGS: the settings have been updated through the UI.
23 * - IMPORT: bulk links import 24 * - IMPORT: bulk bookmarks import
24 * 25 *
25 * Note: new events are put at the beginning of the file and history array. 26 * Note: new events are put at the beginning of the file and history array.
26 */ 27 */
@@ -96,31 +97,31 @@ class History
96 /** 97 /**
97 * Add Event: new link. 98 * Add Event: new link.
98 * 99 *
99 * @param array $link Link data. 100 * @param Bookmark $link Link data.
100 */ 101 */
101 public function addLink($link) 102 public function addLink($link)
102 { 103 {
103 $this->addEvent(self::CREATED, $link['id']); 104 $this->addEvent(self::CREATED, $link->getId());
104 } 105 }
105 106
106 /** 107 /**
107 * Add Event: update existing link. 108 * Add Event: update existing link.
108 * 109 *
109 * @param array $link Link data. 110 * @param Bookmark $link Link data.
110 */ 111 */
111 public function updateLink($link) 112 public function updateLink($link)
112 { 113 {
113 $this->addEvent(self::UPDATED, $link['id']); 114 $this->addEvent(self::UPDATED, $link->getId());
114 } 115 }
115 116
116 /** 117 /**
117 * Add Event: delete existing link. 118 * Add Event: delete existing link.
118 * 119 *
119 * @param array $link Link data. 120 * @param Bookmark $link Link data.
120 */ 121 */
121 public function deleteLink($link) 122 public function deleteLink($link)
122 { 123 {
123 $this->addEvent(self::DELETED, $link['id']); 124 $this->addEvent(self::DELETED, $link->getId());
124 } 125 }
125 126
126 /** 127 /**
@@ -134,7 +135,7 @@ class History
134 /** 135 /**
135 * Add Event: bulk import. 136 * Add Event: bulk import.
136 * 137 *
137 * Note: we don't store links add/update one by one since it can have a huge impact on performances. 138 * Note: we don't store bookmarks add/update one by one since it can have a huge impact on performances.
138 */ 139 */
139 public function importLinks() 140 public function importLinks()
140 { 141 {
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 d5f5ac28..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/**
@@ -27,6 +26,7 @@ class Thumbnailer
27 'instagram.com', 26 'instagram.com',
28 'pinterest.com', 27 'pinterest.com',
29 'pinterest.fr', 28 'pinterest.fr',
29 'soundcloud.com',
30 'tumblr.com', 30 'tumblr.com',
31 'deviantart.com', 31 'deviantart.com',
32 ]; 32 ];
@@ -89,7 +89,7 @@ class Thumbnailer
89 89
90 try { 90 try {
91 return $this->wt->thumbnail($url); 91 return $this->wt->thumbnail($url);
92 } catch (WebThumbnailerException $e) { 92 } catch (\Throwable $e) {
93 // Exceptions are only thrown in debug mode. 93 // Exceptions are only thrown in debug mode.
94 error_log(get_class($e) . ': ' . $e->getMessage()); 94 error_log(get_class($e) . ': ' . $e->getMessage());
95 } 95 }
diff --git a/application/Utils.php b/application/Utils.php
index 925e1a22..bcfda65c 100644
--- a/application/Utils.php
+++ b/application/Utils.php
@@ -87,18 +87,22 @@ function endsWith($haystack, $needle, $case = true)
87 * 87 *
88 * @param mixed $input Data to escape: a single string or an array of strings. 88 * @param mixed $input Data to escape: a single string or an array of strings.
89 * 89 *
90 * @return string escaped. 90 * @return string|array escaped.
91 */ 91 */
92function escape($input) 92function escape($input)
93{ 93{
94 if (is_bool($input)) { 94 if (null === $input) {
95 return null;
96 }
97
98 if (is_bool($input) || is_int($input) || is_float($input) || $input instanceof DateTimeInterface) {
95 return $input; 99 return $input;
96 } 100 }
97 101
98 if (is_array($input)) { 102 if (is_array($input)) {
99 $out = array(); 103 $out = array();
100 foreach ($input as $key => $value) { 104 foreach ($input as $key => $value) {
101 $out[$key] = escape($value); 105 $out[escape($key)] = escape($value);
102 } 106 }
103 return $out; 107 return $out;
104 } 108 }
@@ -159,10 +163,10 @@ function checkDateFormat($format, $string)
159 */ 163 */
160function generateLocation($referer, $host, $loopTerms = array()) 164function generateLocation($referer, $host, $loopTerms = array())
161{ 165{
162 $finalReferer = '?'; 166 $finalReferer = './?';
163 167
164 // No referer if it contains any value in $loopCriteria. 168 // No referer if it contains any value in $loopCriteria.
165 foreach ($loopTerms as $value) { 169 foreach (array_filter($loopTerms) as $value) {
166 if (strpos($referer, $value) !== false) { 170 if (strpos($referer, $value) !== false) {
167 return $finalReferer; 171 return $finalReferer;
168 } 172 }
@@ -294,15 +298,15 @@ function normalize_spaces($string)
294 * Requires php-intl to display international datetimes, 298 * Requires php-intl to display international datetimes,
295 * otherwise default format '%c' will be returned. 299 * otherwise default format '%c' will be returned.
296 * 300 *
297 * @param DateTime $date to format. 301 * @param DateTimeInterface $date to format.
298 * @param bool $time Displays time if true. 302 * @param bool $time Displays time if true.
299 * @param bool $intl Use international format if true. 303 * @param bool $intl Use international format if true.
300 * 304 *
301 * @return bool|string Formatted date, or false if the input is invalid. 305 * @return bool|string Formatted date, or false if the input is invalid.
302 */ 306 */
303function format_date($date, $time = true, $intl = true) 307function format_date($date, $time = true, $intl = true)
304{ 308{
305 if (! $date instanceof DateTime) { 309 if (! $date instanceof DateTimeInterface) {
306 return false; 310 return false;
307 } 311 }
308 312
diff --git a/application/api/ApiMiddleware.php b/application/api/ApiMiddleware.php
index 2d55bda6..f5b53b01 100644
--- a/application/api/ApiMiddleware.php
+++ b/application/api/ApiMiddleware.php
@@ -3,6 +3,7 @@ namespace Shaarli\Api;
3 3
4use Shaarli\Api\Exceptions\ApiAuthorizationException; 4use Shaarli\Api\Exceptions\ApiAuthorizationException;
5use Shaarli\Api\Exceptions\ApiException; 5use Shaarli\Api\Exceptions\ApiException;
6use Shaarli\Bookmark\BookmarkFileService;
6use Shaarli\Config\ConfigManager; 7use Shaarli\Config\ConfigManager;
7use Slim\Container; 8use Slim\Container;
8use Slim\Http\Request; 9use Slim\Http\Request;
@@ -70,7 +71,14 @@ class ApiMiddleware
70 $response = $e->getApiResponse(); 71 $response = $e->getApiResponse();
71 } 72 }
72 73
73 return $response; 74 return $response
75 ->withHeader('Access-Control-Allow-Origin', '*')
76 ->withHeader(
77 'Access-Control-Allow-Headers',
78 'X-Requested-With, Content-Type, Accept, Origin, Authorization'
79 )
80 ->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
81 ;
74 } 82 }
75 83
76 /** 84 /**
@@ -99,7 +107,9 @@ class ApiMiddleware
99 */ 107 */
100 protected function checkToken($request) 108 protected function checkToken($request)
101 { 109 {
102 if (! $request->hasHeader('Authorization')) { 110 if (!$request->hasHeader('Authorization')
111 && !isset($this->container->environment['REDIRECT_HTTP_AUTHORIZATION'])
112 ) {
103 throw new ApiAuthorizationException('JWT token not provided'); 113 throw new ApiAuthorizationException('JWT token not provided');
104 } 114 }
105 115
@@ -107,7 +117,11 @@ class ApiMiddleware
107 throw new ApiAuthorizationException('Token secret must be set in Shaarli\'s administration'); 117 throw new ApiAuthorizationException('Token secret must be set in Shaarli\'s administration');
108 } 118 }
109 119
110 $authorization = $request->getHeaderLine('Authorization'); 120 if (isset($this->container->environment['REDIRECT_HTTP_AUTHORIZATION'])) {
121 $authorization = $this->container->environment['REDIRECT_HTTP_AUTHORIZATION'];
122 } else {
123 $authorization = $request->getHeaderLine('Authorization');
124 }
111 125
112 if (! preg_match('/^Bearer (.*)/i', $authorization, $matches)) { 126 if (! preg_match('/^Bearer (.*)/i', $authorization, $matches)) {
113 throw new ApiAuthorizationException('Invalid JWT header'); 127 throw new ApiAuthorizationException('Invalid JWT header');
@@ -117,7 +131,7 @@ class ApiMiddleware
117 } 131 }
118 132
119 /** 133 /**
120 * Instantiate a new LinkDB including private links, 134 * Instantiate a new LinkDB including private bookmarks,
121 * and load in the Slim container. 135 * and load in the Slim container.
122 * 136 *
123 * FIXME! LinkDB could use a refactoring to avoid this trick. 137 * FIXME! LinkDB could use a refactoring to avoid this trick.
@@ -126,10 +140,10 @@ class ApiMiddleware
126 */ 140 */
127 protected function setLinkDb($conf) 141 protected function setLinkDb($conf)
128 { 142 {
129 $linkDb = new \Shaarli\Bookmark\LinkDB( 143 $linkDb = new BookmarkFileService(
130 $conf->get('resource.datastore'), 144 $conf,
131 true, 145 $this->container->get('history'),
132 $conf->get('privacy.hide_public_links') 146 true
133 ); 147 );
134 $this->container['db'] = $linkDb; 148 $this->container['db'] = $linkDb;
135 } 149 }
diff --git a/application/api/ApiUtils.php b/application/api/ApiUtils.php
index 1e3ac02e..faebb8f5 100644
--- a/application/api/ApiUtils.php
+++ b/application/api/ApiUtils.php
@@ -2,6 +2,7 @@
2namespace Shaarli\Api; 2namespace Shaarli\Api;
3 3
4use Shaarli\Api\Exceptions\ApiAuthorizationException; 4use Shaarli\Api\Exceptions\ApiAuthorizationException;
5use Shaarli\Bookmark\Bookmark;
5use Shaarli\Http\Base64Url; 6use Shaarli\Http\Base64Url;
6 7
7/** 8/**
@@ -15,6 +16,8 @@ class ApiUtils
15 * @param string $token JWT token extracted from the headers. 16 * @param string $token JWT token extracted from the headers.
16 * @param string $secret API secret set in the settings. 17 * @param string $secret API secret set in the settings.
17 * 18 *
19 * @return bool true on success
20 *
18 * @throws ApiAuthorizationException the token is not valid. 21 * @throws ApiAuthorizationException the token is not valid.
19 */ 22 */
20 public static function validateJwtToken($token, $secret) 23 public static function validateJwtToken($token, $secret)
@@ -45,33 +48,35 @@ class ApiUtils
45 ) { 48 ) {
46 throw new ApiAuthorizationException('Invalid JWT issued time'); 49 throw new ApiAuthorizationException('Invalid JWT issued time');
47 } 50 }
51
52 return true;
48 } 53 }
49 54
50 /** 55 /**
51 * Format a Link for the REST API. 56 * Format a Link for the REST API.
52 * 57 *
53 * @param array $link Link data read from the datastore. 58 * @param Bookmark $bookmark Bookmark data read from the datastore.
54 * @param string $indexUrl Shaarli's index URL (used for relative URL). 59 * @param string $indexUrl Shaarli's index URL (used for relative URL).
55 * 60 *
56 * @return array Link data formatted for the REST API. 61 * @return array Link data formatted for the REST API.
57 */ 62 */
58 public static function formatLink($link, $indexUrl) 63 public static function formatLink($bookmark, $indexUrl)
59 { 64 {
60 $out['id'] = $link['id']; 65 $out['id'] = $bookmark->getId();
61 // Not an internal link 66 // Not an internal link
62 if (! is_note($link['url'])) { 67 if (! $bookmark->isNote()) {
63 $out['url'] = $link['url']; 68 $out['url'] = $bookmark->getUrl();
64 } else { 69 } else {
65 $out['url'] = $indexUrl . $link['url']; 70 $out['url'] = rtrim($indexUrl, '/') . '/' . ltrim($bookmark->getUrl(), '/');
66 } 71 }
67 $out['shorturl'] = $link['shorturl']; 72 $out['shorturl'] = $bookmark->getShortUrl();
68 $out['title'] = $link['title']; 73 $out['title'] = $bookmark->getTitle();
69 $out['description'] = $link['description']; 74 $out['description'] = $bookmark->getDescription();
70 $out['tags'] = preg_split('/\s+/', $link['tags'], -1, PREG_SPLIT_NO_EMPTY); 75 $out['tags'] = $bookmark->getTags();
71 $out['private'] = $link['private'] == true; 76 $out['private'] = $bookmark->isPrivate();
72 $out['created'] = $link['created']->format(\DateTime::ATOM); 77 $out['created'] = $bookmark->getCreated()->format(\DateTime::ATOM);
73 if (! empty($link['updated'])) { 78 if (! empty($bookmark->getUpdated())) {
74 $out['updated'] = $link['updated']->format(\DateTime::ATOM); 79 $out['updated'] = $bookmark->getUpdated()->format(\DateTime::ATOM);
75 } else { 80 } else {
76 $out['updated'] = ''; 81 $out['updated'] = '';
77 } 82 }
@@ -79,7 +84,7 @@ class ApiUtils
79 } 84 }
80 85
81 /** 86 /**
82 * Convert a link given through a request, to a valid link for LinkDB. 87 * Convert a link given through a request, to a valid Bookmark for the datastore.
83 * 88 *
84 * 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.
85 * 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.
@@ -87,50 +92,42 @@ class ApiUtils
87 * @param array $input Request Link. 92 * @param array $input Request Link.
88 * @param bool $defaultPrivate Request Link. 93 * @param bool $defaultPrivate Request Link.
89 * 94 *
90 * @return array Formatted link. 95 * @return Bookmark instance.
91 */ 96 */
92 public static function buildLinkFromRequest($input, $defaultPrivate) 97 public static function buildLinkFromRequest($input, $defaultPrivate)
93 { 98 {
94 $input['url'] = ! empty($input['url']) ? cleanup_url($input['url']) : ''; 99 $bookmark = new Bookmark();
100 $url = ! empty($input['url']) ? cleanup_url($input['url']) : '';
95 if (isset($input['private'])) { 101 if (isset($input['private'])) {
96 $private = filter_var($input['private'], FILTER_VALIDATE_BOOLEAN); 102 $private = filter_var($input['private'], FILTER_VALIDATE_BOOLEAN);
97 } else { 103 } else {
98 $private = $defaultPrivate; 104 $private = $defaultPrivate;
99 } 105 }
100 106
101 $link = [ 107 $bookmark->setTitle(! empty($input['title']) ? $input['title'] : '');
102 'title' => ! empty($input['title']) ? $input['title'] : $input['url'], 108 $bookmark->setUrl($url);
103 'url' => $input['url'], 109 $bookmark->setDescription(! empty($input['description']) ? $input['description'] : '');
104 'description' => ! empty($input['description']) ? $input['description'] : '', 110 $bookmark->setTags(! empty($input['tags']) ? $input['tags'] : []);
105 'tags' => ! empty($input['tags']) ? implode(' ', $input['tags']) : '', 111 $bookmark->setPrivate($private);
106 'private' => $private, 112
107 'created' => new \DateTime(), 113 return $bookmark;
108 ];
109 return $link;
110 } 114 }
111 115
112 /** 116 /**
113 * Update link fields using an updated link object. 117 * Update link fields using an updated link object.
114 * 118 *
115 * @param array $oldLink data 119 * @param Bookmark $oldLink data
116 * @param array $newLink data 120 * @param Bookmark $newLink data
117 * 121 *
118 * @return array $oldLink updated with $newLink values 122 * @return Bookmark $oldLink updated with $newLink values
119 */ 123 */
120 public static function updateLink($oldLink, $newLink) 124 public static function updateLink($oldLink, $newLink)
121 { 125 {
122 foreach (['title', 'url', 'description', 'tags', 'private'] as $field) { 126 $oldLink->setTitle($newLink->getTitle());
123 $oldLink[$field] = $newLink[$field]; 127 $oldLink->setUrl($newLink->getUrl());
124 } 128 $oldLink->setDescription($newLink->getDescription());
125 $oldLink['updated'] = new \DateTime(); 129 $oldLink->setTags($newLink->getTags());
126 130 $oldLink->setPrivate($newLink->isPrivate());
127 if (empty($oldLink['url'])) {
128 $oldLink['url'] = '?' . $oldLink['shorturl'];
129 }
130
131 if (empty($oldLink['title'])) {
132 $oldLink['title'] = $oldLink['url'];
133 }
134 131
135 return $oldLink; 132 return $oldLink;
136 } 133 }
@@ -139,7 +136,7 @@ class ApiUtils
139 * Format a Tag for the REST API. 136 * Format a Tag for the REST API.
140 * 137 *
141 * @param string $tag Tag name 138 * @param string $tag Tag name
142 * @param int $occurrences Number of links using this tag 139 * @param int $occurrences Number of bookmarks using this tag
143 * 140 *
144 * @return array Link data formatted for the REST API. 141 * @return array Link data formatted for the REST API.
145 */ 142 */
diff --git a/application/api/controllers/ApiController.php b/application/api/controllers/ApiController.php
index a6e7cbab..c4b3d0c3 100644
--- a/application/api/controllers/ApiController.php
+++ b/application/api/controllers/ApiController.php
@@ -2,7 +2,7 @@
2 2
3namespace Shaarli\Api\Controllers; 3namespace Shaarli\Api\Controllers;
4 4
5use Shaarli\Bookmark\LinkDB; 5use Shaarli\Bookmark\BookmarkServiceInterface;
6use Shaarli\Config\ConfigManager; 6use Shaarli\Config\ConfigManager;
7use Slim\Container; 7use Slim\Container;
8 8
@@ -26,9 +26,9 @@ abstract class ApiController
26 protected $conf; 26 protected $conf;
27 27
28 /** 28 /**
29 * @var LinkDB 29 * @var BookmarkServiceInterface
30 */ 30 */
31 protected $linkDb; 31 protected $bookmarkService;
32 32
33 /** 33 /**
34 * @var HistoryController 34 * @var HistoryController
@@ -51,7 +51,7 @@ abstract class ApiController
51 { 51 {
52 $this->ci = $ci; 52 $this->ci = $ci;
53 $this->conf = $ci->get('conf'); 53 $this->conf = $ci->get('conf');
54 $this->linkDb = $ci->get('db'); 54 $this->bookmarkService = $ci->get('db');
55 $this->history = $ci->get('history'); 55 $this->history = $ci->get('history');
56 if ($this->conf->get('dev.debug', false)) { 56 if ($this->conf->get('dev.debug', false)) {
57 $this->jsonStyle = JSON_PRETTY_PRINT; 57 $this->jsonStyle = JSON_PRETTY_PRINT;
diff --git a/application/api/controllers/HistoryController.php b/application/api/controllers/HistoryController.php
index 9afcfa26..505647a9 100644
--- a/application/api/controllers/HistoryController.php
+++ b/application/api/controllers/HistoryController.php
@@ -41,7 +41,7 @@ class HistoryController extends ApiController
41 throw new ApiBadParametersException('Invalid offset'); 41 throw new ApiBadParametersException('Invalid offset');
42 } 42 }
43 43
44 // limit parameter is either a number of links or 'all' for everything. 44 // limit parameter is either a number of bookmarks or 'all' for everything.
45 $limit = $request->getParam('limit'); 45 $limit = $request->getParam('limit');
46 if (empty($limit)) { 46 if (empty($limit)) {
47 $limit = count($history); 47 $limit = count($history);
diff --git a/application/api/controllers/Info.php b/application/api/controllers/Info.php
index f37dcae5..12f6b2f0 100644
--- a/application/api/controllers/Info.php
+++ b/application/api/controllers/Info.php
@@ -2,6 +2,7 @@
2 2
3namespace Shaarli\Api\Controllers; 3namespace Shaarli\Api\Controllers;
4 4
5use Shaarli\Bookmark\BookmarkFilter;
5use Slim\Http\Request; 6use Slim\Http\Request;
6use Slim\Http\Response; 7use Slim\Http\Response;
7 8
@@ -26,8 +27,8 @@ class Info extends ApiController
26 public function getInfo($request, $response) 27 public function getInfo($request, $response)
27 { 28 {
28 $info = [ 29 $info = [
29 'global_counter' => count($this->linkDb), 30 'global_counter' => $this->bookmarkService->count(),
30 'private_counter' => count_private($this->linkDb), 31 'private_counter' => $this->bookmarkService->count(BookmarkFilter::$PRIVATE),
31 'settings' => array( 32 'settings' => array(
32 'title' => $this->conf->get('general.title', 'Shaarli'), 33 'title' => $this->conf->get('general.title', 'Shaarli'),
33 'header_link' => $this->conf->get('general.header_link', '?'), 34 'header_link' => $this->conf->get('general.header_link', '?'),
diff --git a/application/api/controllers/Links.php b/application/api/controllers/Links.php
index ffcfd4c7..29247950 100644
--- a/application/api/controllers/Links.php
+++ b/application/api/controllers/Links.php
@@ -11,7 +11,7 @@ use Slim\Http\Response;
11/** 11/**
12 * Class Links 12 * Class Links
13 * 13 *
14 * REST API Controller: all services related to links collection. 14 * REST API Controller: all services related to bookmarks collection.
15 * 15 *
16 * @package Api\Controllers 16 * @package Api\Controllers
17 * @see http://shaarli.github.io/api-documentation/#links-links-collection 17 * @see http://shaarli.github.io/api-documentation/#links-links-collection
@@ -19,12 +19,12 @@ use Slim\Http\Response;
19class Links extends ApiController 19class Links extends ApiController
20{ 20{
21 /** 21 /**
22 * @var int Number of links returned if no limit is provided. 22 * @var int Number of bookmarks returned if no limit is provided.
23 */ 23 */
24 public static $DEFAULT_LIMIT = 20; 24 public static $DEFAULT_LIMIT = 20;
25 25
26 /** 26 /**
27 * Retrieve a list of links, allowing different filters. 27 * Retrieve a list of bookmarks, allowing different filters.
28 * 28 *
29 * @param Request $request Slim request. 29 * @param Request $request Slim request.
30 * @param Response $response Slim response. 30 * @param Response $response Slim response.
@@ -36,33 +36,32 @@ class Links extends ApiController
36 public function getLinks($request, $response) 36 public function getLinks($request, $response)
37 { 37 {
38 $private = $request->getParam('visibility'); 38 $private = $request->getParam('visibility');
39 $links = $this->linkDb->filterSearch( 39 $bookmarks = $this->bookmarkService->search(
40 [ 40 [
41 'searchtags' => $request->getParam('searchtags', ''), 41 'searchtags' => $request->getParam('searchtags', ''),
42 'searchterm' => $request->getParam('searchterm', ''), 42 'searchterm' => $request->getParam('searchterm', ''),
43 ], 43 ],
44 false,
45 $private 44 $private
46 ); 45 );
47 46
48 // Return links from the {offset}th link, starting from 0. 47 // Return bookmarks from the {offset}th link, starting from 0.
49 $offset = $request->getParam('offset'); 48 $offset = $request->getParam('offset');
50 if (! empty($offset) && ! ctype_digit($offset)) { 49 if (! empty($offset) && ! ctype_digit($offset)) {
51 throw new ApiBadParametersException('Invalid offset'); 50 throw new ApiBadParametersException('Invalid offset');
52 } 51 }
53 $offset = ! empty($offset) ? intval($offset) : 0; 52 $offset = ! empty($offset) ? intval($offset) : 0;
54 if ($offset > count($links)) { 53 if ($offset > count($bookmarks)) {
55 return $response->withJson([], 200, $this->jsonStyle); 54 return $response->withJson([], 200, $this->jsonStyle);
56 } 55 }
57 56
58 // limit parameter is either a number of links or 'all' for everything. 57 // limit parameter is either a number of bookmarks or 'all' for everything.
59 $limit = $request->getParam('limit'); 58 $limit = $request->getParam('limit');
60 if (empty($limit)) { 59 if (empty($limit)) {
61 $limit = self::$DEFAULT_LIMIT; 60 $limit = self::$DEFAULT_LIMIT;
62 } elseif (ctype_digit($limit)) { 61 } elseif (ctype_digit($limit)) {
63 $limit = intval($limit); 62 $limit = intval($limit);
64 } elseif ($limit === 'all') { 63 } elseif ($limit === 'all') {
65 $limit = count($links); 64 $limit = count($bookmarks);
66 } else { 65 } else {
67 throw new ApiBadParametersException('Invalid limit'); 66 throw new ApiBadParametersException('Invalid limit');
68 } 67 }
@@ -72,12 +71,12 @@ class Links extends ApiController
72 71
73 $out = []; 72 $out = [];
74 $index = 0; 73 $index = 0;
75 foreach ($links as $link) { 74 foreach ($bookmarks as $bookmark) {
76 if (count($out) >= $limit) { 75 if (count($out) >= $limit) {
77 break; 76 break;
78 } 77 }
79 if ($index++ >= $offset) { 78 if ($index++ >= $offset) {
80 $out[] = ApiUtils::formatLink($link, $indexUrl); 79 $out[] = ApiUtils::formatLink($bookmark, $indexUrl);
81 } 80 }
82 } 81 }
83 82
@@ -97,11 +96,11 @@ class Links extends ApiController
97 */ 96 */
98 public function getLink($request, $response, $args) 97 public function getLink($request, $response, $args)
99 { 98 {
100 if (!isset($this->linkDb[$args['id']])) { 99 if (!$this->bookmarkService->exists($args['id'])) {
101 throw new ApiLinkNotFoundException(); 100 throw new ApiLinkNotFoundException();
102 } 101 }
103 $index = index_url($this->ci['environment']); 102 $index = index_url($this->ci['environment']);
104 $out = ApiUtils::formatLink($this->linkDb[$args['id']], $index); 103 $out = ApiUtils::formatLink($this->bookmarkService->get($args['id']), $index);
105 104
106 return $response->withJson($out, 200, $this->jsonStyle); 105 return $response->withJson($out, 200, $this->jsonStyle);
107 } 106 }
@@ -117,9 +116,11 @@ class Links extends ApiController
117 public function postLink($request, $response) 116 public function postLink($request, $response)
118 { 117 {
119 $data = $request->getParsedBody(); 118 $data = $request->getParsedBody();
120 $link = ApiUtils::buildLinkFromRequest($data, $this->conf->get('privacy.default_private_links')); 119 $bookmark = ApiUtils::buildLinkFromRequest($data, $this->conf->get('privacy.default_private_links'));
121 // duplicate by URL, return 409 Conflict 120 // duplicate by URL, return 409 Conflict
122 if (! empty($link['url']) && ! empty($dup = $this->linkDb->getLinkFromUrl($link['url']))) { 121 if (! empty($bookmark->getUrl())
122 && ! empty($dup = $this->bookmarkService->findByUrl($bookmark->getUrl()))
123 ) {
123 return $response->withJson( 124 return $response->withJson(
124 ApiUtils::formatLink($dup, index_url($this->ci['environment'])), 125 ApiUtils::formatLink($dup, index_url($this->ci['environment'])),
125 409, 126 409,
@@ -127,23 +128,9 @@ class Links extends ApiController
127 ); 128 );
128 } 129 }
129 130
130 $link['id'] = $this->linkDb->getNextId(); 131 $this->bookmarkService->add($bookmark);
131 $link['shorturl'] = link_small_hash($link['created'], $link['id']); 132 $out = ApiUtils::formatLink($bookmark, index_url($this->ci['environment']));
132 133 $redirect = $this->ci->router->relativePathFor('getLink', ['id' => $bookmark->getId()]);
133 // note: general relative URL
134 if (empty($link['url'])) {
135 $link['url'] = '?' . $link['shorturl'];
136 }
137
138 if (empty($link['title'])) {
139 $link['title'] = $link['url'];
140 }
141
142 $this->linkDb[$link['id']] = $link;
143 $this->linkDb->save($this->conf->get('resource.page_cache'));
144 $this->history->addLink($link);
145 $out = ApiUtils::formatLink($link, index_url($this->ci['environment']));
146 $redirect = $this->ci->router->relativePathFor('getLink', ['id' => $link['id']]);
147 return $response->withAddedHeader('Location', $redirect) 134 return $response->withAddedHeader('Location', $redirect)
148 ->withJson($out, 201, $this->jsonStyle); 135 ->withJson($out, 201, $this->jsonStyle);
149 } 136 }
@@ -161,18 +148,18 @@ class Links extends ApiController
161 */ 148 */
162 public function putLink($request, $response, $args) 149 public function putLink($request, $response, $args)
163 { 150 {
164 if (! isset($this->linkDb[$args['id']])) { 151 if (! $this->bookmarkService->exists($args['id'])) {
165 throw new ApiLinkNotFoundException(); 152 throw new ApiLinkNotFoundException();
166 } 153 }
167 154
168 $index = index_url($this->ci['environment']); 155 $index = index_url($this->ci['environment']);
169 $data = $request->getParsedBody(); 156 $data = $request->getParsedBody();
170 157
171 $requestLink = ApiUtils::buildLinkFromRequest($data, $this->conf->get('privacy.default_private_links')); 158 $requestBookmark = ApiUtils::buildLinkFromRequest($data, $this->conf->get('privacy.default_private_links'));
172 // duplicate URL on a different link, return 409 Conflict 159 // duplicate URL on a different link, return 409 Conflict
173 if (! empty($requestLink['url']) 160 if (! empty($requestBookmark->getUrl())
174 && ! empty($dup = $this->linkDb->getLinkFromUrl($requestLink['url'])) 161 && ! empty($dup = $this->bookmarkService->findByUrl($requestBookmark->getUrl()))
175 && $dup['id'] != $args['id'] 162 && $dup->getId() != $args['id']
176 ) { 163 ) {
177 return $response->withJson( 164 return $response->withJson(
178 ApiUtils::formatLink($dup, $index), 165 ApiUtils::formatLink($dup, $index),
@@ -181,13 +168,11 @@ class Links extends ApiController
181 ); 168 );
182 } 169 }
183 170
184 $responseLink = $this->linkDb[$args['id']]; 171 $responseBookmark = $this->bookmarkService->get($args['id']);
185 $responseLink = ApiUtils::updateLink($responseLink, $requestLink); 172 $responseBookmark = ApiUtils::updateLink($responseBookmark, $requestBookmark);
186 $this->linkDb[$responseLink['id']] = $responseLink; 173 $this->bookmarkService->set($responseBookmark);
187 $this->linkDb->save($this->conf->get('resource.page_cache'));
188 $this->history->updateLink($responseLink);
189 174
190 $out = ApiUtils::formatLink($responseLink, $index); 175 $out = ApiUtils::formatLink($responseBookmark, $index);
191 return $response->withJson($out, 200, $this->jsonStyle); 176 return $response->withJson($out, 200, $this->jsonStyle);
192 } 177 }
193 178
@@ -204,13 +189,11 @@ class Links extends ApiController
204 */ 189 */
205 public function deleteLink($request, $response, $args) 190 public function deleteLink($request, $response, $args)
206 { 191 {
207 if (! isset($this->linkDb[$args['id']])) { 192 if (! $this->bookmarkService->exists($args['id'])) {
208 throw new ApiLinkNotFoundException(); 193 throw new ApiLinkNotFoundException();
209 } 194 }
210 $link = $this->linkDb[$args['id']]; 195 $bookmark = $this->bookmarkService->get($args['id']);
211 unset($this->linkDb[(int) $args['id']]); 196 $this->bookmarkService->remove($bookmark);
212 $this->linkDb->save($this->conf->get('resource.page_cache'));
213 $this->history->deleteLink($link);
214 197
215 return $response->withStatus(204); 198 return $response->withStatus(204);
216 } 199 }
diff --git a/application/api/controllers/Tags.php b/application/api/controllers/Tags.php
index 82f3ef74..e60e00a7 100644
--- a/application/api/controllers/Tags.php
+++ b/application/api/controllers/Tags.php
@@ -5,6 +5,7 @@ namespace Shaarli\Api\Controllers;
5use Shaarli\Api\ApiUtils; 5use Shaarli\Api\ApiUtils;
6use Shaarli\Api\Exceptions\ApiBadParametersException; 6use Shaarli\Api\Exceptions\ApiBadParametersException;
7use Shaarli\Api\Exceptions\ApiTagNotFoundException; 7use Shaarli\Api\Exceptions\ApiTagNotFoundException;
8use Shaarli\Bookmark\BookmarkFilter;
8use Slim\Http\Request; 9use Slim\Http\Request;
9use Slim\Http\Response; 10use Slim\Http\Response;
10 11
@@ -18,7 +19,7 @@ use Slim\Http\Response;
18class Tags extends ApiController 19class Tags extends ApiController
19{ 20{
20 /** 21 /**
21 * @var int Number of links returned if no limit is provided. 22 * @var int Number of bookmarks returned if no limit is provided.
22 */ 23 */
23 public static $DEFAULT_LIMIT = 'all'; 24 public static $DEFAULT_LIMIT = 'all';
24 25
@@ -35,7 +36,7 @@ class Tags extends ApiController
35 public function getTags($request, $response) 36 public function getTags($request, $response)
36 { 37 {
37 $visibility = $request->getParam('visibility'); 38 $visibility = $request->getParam('visibility');
38 $tags = $this->linkDb->linksCountPerTag([], $visibility); 39 $tags = $this->bookmarkService->bookmarksCountPerTag([], $visibility);
39 40
40 // Return tags from the {offset}th tag, starting from 0. 41 // Return tags from the {offset}th tag, starting from 0.
41 $offset = $request->getParam('offset'); 42 $offset = $request->getParam('offset');
@@ -47,7 +48,7 @@ class Tags extends ApiController
47 return $response->withJson([], 200, $this->jsonStyle); 48 return $response->withJson([], 200, $this->jsonStyle);
48 } 49 }
49 50
50 // limit parameter is either a number of links or 'all' for everything. 51 // limit parameter is either a number of bookmarks or 'all' for everything.
51 $limit = $request->getParam('limit'); 52 $limit = $request->getParam('limit');
52 if (empty($limit)) { 53 if (empty($limit)) {
53 $limit = self::$DEFAULT_LIMIT; 54 $limit = self::$DEFAULT_LIMIT;
@@ -87,7 +88,7 @@ class Tags extends ApiController
87 */ 88 */
88 public function getTag($request, $response, $args) 89 public function getTag($request, $response, $args)
89 { 90 {
90 $tags = $this->linkDb->linksCountPerTag(); 91 $tags = $this->bookmarkService->bookmarksCountPerTag();
91 if (!isset($tags[$args['tagName']])) { 92 if (!isset($tags[$args['tagName']])) {
92 throw new ApiTagNotFoundException(); 93 throw new ApiTagNotFoundException();
93 } 94 }
@@ -111,7 +112,7 @@ class Tags extends ApiController
111 */ 112 */
112 public function putTag($request, $response, $args) 113 public function putTag($request, $response, $args)
113 { 114 {
114 $tags = $this->linkDb->linksCountPerTag(); 115 $tags = $this->bookmarkService->bookmarksCountPerTag();
115 if (! isset($tags[$args['tagName']])) { 116 if (! isset($tags[$args['tagName']])) {
116 throw new ApiTagNotFoundException(); 117 throw new ApiTagNotFoundException();
117 } 118 }
@@ -121,13 +122,19 @@ class Tags extends ApiController
121 throw new ApiBadParametersException('New tag name is required in the request body'); 122 throw new ApiBadParametersException('New tag name is required in the request body');
122 } 123 }
123 124
124 $updated = $this->linkDb->renameTag($args['tagName'], $data['name']); 125 $bookmarks = $this->bookmarkService->search(
125 $this->linkDb->save($this->conf->get('resource.page_cache')); 126 ['searchtags' => $args['tagName']],
126 foreach ($updated as $link) { 127 BookmarkFilter::$ALL,
127 $this->history->updateLink($link); 128 true
129 );
130 foreach ($bookmarks as $bookmark) {
131 $bookmark->renameTag($args['tagName'], $data['name']);
132 $this->bookmarkService->set($bookmark, false);
133 $this->history->updateLink($bookmark);
128 } 134 }
135 $this->bookmarkService->save();
129 136
130 $tags = $this->linkDb->linksCountPerTag(); 137 $tags = $this->bookmarkService->bookmarksCountPerTag();
131 $out = ApiUtils::formatTag($data['name'], $tags[$data['name']]); 138 $out = ApiUtils::formatTag($data['name'], $tags[$data['name']]);
132 return $response->withJson($out, 200, $this->jsonStyle); 139 return $response->withJson($out, 200, $this->jsonStyle);
133 } 140 }
@@ -145,15 +152,22 @@ class Tags extends ApiController
145 */ 152 */
146 public function deleteTag($request, $response, $args) 153 public function deleteTag($request, $response, $args)
147 { 154 {
148 $tags = $this->linkDb->linksCountPerTag(); 155 $tags = $this->bookmarkService->bookmarksCountPerTag();
149 if (! isset($tags[$args['tagName']])) { 156 if (! isset($tags[$args['tagName']])) {
150 throw new ApiTagNotFoundException(); 157 throw new ApiTagNotFoundException();
151 } 158 }
152 $updated = $this->linkDb->renameTag($args['tagName'], null); 159
153 $this->linkDb->save($this->conf->get('resource.page_cache')); 160 $bookmarks = $this->bookmarkService->search(
154 foreach ($updated as $link) { 161 ['searchtags' => $args['tagName']],
155 $this->history->updateLink($link); 162 BookmarkFilter::$ALL,
163 true
164 );
165 foreach ($bookmarks as $bookmark) {
166 $bookmark->deleteTag($args['tagName']);
167 $this->bookmarkService->set($bookmark, false);
168 $this->history->updateLink($bookmark);
156 } 169 }
170 $this->bookmarkService->save();
157 171
158 return $response->withStatus(204); 172 return $response->withStatus(204);
159 } 173 }
diff --git a/application/bookmark/Bookmark.php b/application/bookmark/Bookmark.php
new file mode 100644
index 00000000..1beb8be2
--- /dev/null
+++ b/application/bookmark/Bookmark.php
@@ -0,0 +1,462 @@
1<?php
2
3namespace Shaarli\Bookmark;
4
5use DateTime;
6use DateTimeInterface;
7use Shaarli\Bookmark\Exception\InvalidBookmarkException;
8
9/**
10 * Class Bookmark
11 *
12 * This class represent a single Bookmark with all its attributes.
13 * Every bookmark should manipulated using this, before being formatted.
14 *
15 * @package Shaarli\Bookmark
16 */
17class Bookmark
18{
19 /** @var string Date format used in string (former ID format) */
20 const LINK_DATE_FORMAT = 'Ymd_His';
21
22 /** @var int Bookmark ID */
23 protected $id;
24
25 /** @var string Permalink identifier */
26 protected $shortUrl;
27
28 /** @var string Bookmark's URL - $shortUrl prefixed with `?` for notes */
29 protected $url;
30
31 /** @var string Bookmark's title */
32 protected $title;
33
34 /** @var string Raw bookmark's description */
35 protected $description;
36
37 /** @var array List of bookmark's tags */
38 protected $tags;
39
40 /** @var string|bool|null Thumbnail's URL - initialized at null, false if no thumbnail could be found */
41 protected $thumbnail;
42
43 /** @var bool Set to true if the bookmark is set as sticky */
44 protected $sticky;
45
46 /** @var DateTimeInterface Creation datetime */
47 protected $created;
48
49 /** @var DateTimeInterface datetime */
50 protected $updated;
51
52 /** @var bool True if the bookmark can only be seen while logged in */
53 protected $private;
54
55 /**
56 * Initialize a link from array data. Especially useful to create a Bookmark from former link storage format.
57 *
58 * @param array $data
59 *
60 * @return $this
61 */
62 public function fromArray($data)
63 {
64 $this->id = $data['id'];
65 $this->shortUrl = $data['shorturl'];
66 $this->url = $data['url'];
67 $this->title = $data['title'];
68 $this->description = $data['description'];
69 $this->thumbnail = isset($data['thumbnail']) ? $data['thumbnail'] : null;
70 $this->sticky = isset($data['sticky']) ? $data['sticky'] : false;
71 $this->created = $data['created'];
72 if (is_array($data['tags'])) {
73 $this->tags = $data['tags'];
74 } else {
75 $this->tags = preg_split('/\s+/', $data['tags'], -1, PREG_SPLIT_NO_EMPTY);
76 }
77 if (! empty($data['updated'])) {
78 $this->updated = $data['updated'];
79 }
80 $this->private = $data['private'] ? true : false;
81
82 return $this;
83 }
84
85 /**
86 * Make sure that the current instance of Bookmark is valid and can be saved into the data store.
87 * A valid link requires:
88 * - an integer ID
89 * - a short URL (for permalinks)
90 * - a creation date
91 *
92 * This function also initialize optional empty fields:
93 * - the URL with the permalink
94 * - the title with the URL
95 *
96 * @throws InvalidBookmarkException
97 */
98 public function validate()
99 {
100 if ($this->id === null
101 || ! is_int($this->id)
102 || empty($this->shortUrl)
103 || empty($this->created)
104 || ! $this->created instanceof DateTimeInterface
105 ) {
106 throw new InvalidBookmarkException($this);
107 }
108 if (empty($this->url)) {
109 $this->url = '/shaare/'. $this->shortUrl;
110 }
111 if (empty($this->title)) {
112 $this->title = $this->url;
113 }
114 }
115
116 /**
117 * Set the Id.
118 * If they're not already initialized, this function also set:
119 * - created: with the current datetime
120 * - shortUrl: with a generated small hash from the date and the given ID
121 *
122 * @param int $id
123 *
124 * @return Bookmark
125 */
126 public function setId($id)
127 {
128 $this->id = $id;
129 if (empty($this->created)) {
130 $this->created = new DateTime();
131 }
132 if (empty($this->shortUrl)) {
133 $this->shortUrl = link_small_hash($this->created, $this->id);
134 }
135
136 return $this;
137 }
138
139 /**
140 * Get the Id.
141 *
142 * @return int
143 */
144 public function getId()
145 {
146 return $this->id;
147 }
148
149 /**
150 * Get the ShortUrl.
151 *
152 * @return string
153 */
154 public function getShortUrl()
155 {
156 return $this->shortUrl;
157 }
158
159 /**
160 * Get the Url.
161 *
162 * @return string
163 */
164 public function getUrl()
165 {
166 return $this->url;
167 }
168
169 /**
170 * Get the Title.
171 *
172 * @return string
173 */
174 public function getTitle()
175 {
176 return $this->title;
177 }
178
179 /**
180 * Get the Description.
181 *
182 * @return string
183 */
184 public function getDescription()
185 {
186 return ! empty($this->description) ? $this->description : '';
187 }
188
189 /**
190 * Get the Created.
191 *
192 * @return DateTimeInterface
193 */
194 public function getCreated()
195 {
196 return $this->created;
197 }
198
199 /**
200 * Get the Updated.
201 *
202 * @return DateTimeInterface
203 */
204 public function getUpdated()
205 {
206 return $this->updated;
207 }
208
209 /**
210 * Set the ShortUrl.
211 *
212 * @param string $shortUrl
213 *
214 * @return Bookmark
215 */
216 public function setShortUrl($shortUrl)
217 {
218 $this->shortUrl = $shortUrl;
219
220 return $this;
221 }
222
223 /**
224 * Set the Url.
225 *
226 * @param string $url
227 * @param array $allowedProtocols
228 *
229 * @return Bookmark
230 */
231 public function setUrl($url, $allowedProtocols = [])
232 {
233 $url = trim($url);
234 if (! empty($url)) {
235 $url = whitelist_protocols($url, $allowedProtocols);
236 }
237 $this->url = $url;
238
239 return $this;
240 }
241
242 /**
243 * Set the Title.
244 *
245 * @param string $title
246 *
247 * @return Bookmark
248 */
249 public function setTitle($title)
250 {
251 $this->title = trim($title);
252
253 return $this;
254 }
255
256 /**
257 * Set the Description.
258 *
259 * @param string $description
260 *
261 * @return Bookmark
262 */
263 public function setDescription($description)
264 {
265 $this->description = $description;
266
267 return $this;
268 }
269
270 /**
271 * Set the Created.
272 * Note: you shouldn't set this manually except for special cases (like bookmark import)
273 *
274 * @param DateTimeInterface $created
275 *
276 * @return Bookmark
277 */
278 public function setCreated($created)
279 {
280 $this->created = $created;
281
282 return $this;
283 }
284
285 /**
286 * Set the Updated.
287 *
288 * @param DateTimeInterface $updated
289 *
290 * @return Bookmark
291 */
292 public function setUpdated($updated)
293 {
294 $this->updated = $updated;
295
296 return $this;
297 }
298
299 /**
300 * Get the Private.
301 *
302 * @return bool
303 */
304 public function isPrivate()
305 {
306 return $this->private ? true : false;
307 }
308
309 /**
310 * Set the Private.
311 *
312 * @param bool $private
313 *
314 * @return Bookmark
315 */
316 public function setPrivate($private)
317 {
318 $this->private = $private ? true : false;
319
320 return $this;
321 }
322
323 /**
324 * Get the Tags.
325 *
326 * @return array
327 */
328 public function getTags()
329 {
330 return is_array($this->tags) ? $this->tags : [];
331 }
332
333 /**
334 * Set the Tags.
335 *
336 * @param array $tags
337 *
338 * @return Bookmark
339 */
340 public function setTags($tags)
341 {
342 $this->setTagsString(implode(' ', $tags));
343
344 return $this;
345 }
346
347 /**
348 * Get the Thumbnail.
349 *
350 * @return string|bool|null Thumbnail's URL - initialized at null, false if no thumbnail could be found
351 */
352 public function getThumbnail()
353 {
354 return !$this->isNote() ? $this->thumbnail : false;
355 }
356
357 /**
358 * Set the Thumbnail.
359 *
360 * @param string|bool $thumbnail Thumbnail's URL - false if no thumbnail could be found
361 *
362 * @return Bookmark
363 */
364 public function setThumbnail($thumbnail)
365 {
366 $this->thumbnail = $thumbnail;
367
368 return $this;
369 }
370
371 /**
372 * Get the Sticky.
373 *
374 * @return bool
375 */
376 public function isSticky()
377 {
378 return $this->sticky ? true : false;
379 }
380
381 /**
382 * Set the Sticky.
383 *
384 * @param bool $sticky
385 *
386 * @return Bookmark
387 */
388 public function setSticky($sticky)
389 {
390 $this->sticky = $sticky ? true : false;
391
392 return $this;
393 }
394
395 /**
396 * @return string Bookmark's tags as a string, separated by a space
397 */
398 public function getTagsString()
399 {
400 return implode(' ', $this->getTags());
401 }
402
403 /**
404 * @return bool
405 */
406 public function isNote()
407 {
408 // We check empty value to get a valid result if the link has not been saved yet
409 return empty($this->url) || startsWith($this->url, '/shaare/') || $this->url[0] === '?';
410 }
411
412 /**
413 * Set tags from a string.
414 * Note:
415 * - tags must be separated whether by a space or a comma
416 * - multiple spaces will be removed
417 * - trailing dash in tags will be removed
418 *
419 * @param string $tags
420 *
421 * @return $this
422 */
423 public function setTagsString($tags)
424 {
425 // Remove first '-' char in tags.
426 $tags = preg_replace('/(^| )\-/', '$1', $tags);
427 // Explode all tags separted by spaces or commas
428 $tags = preg_split('/[\s,]+/', $tags);
429 // Remove eventual empty values
430 $tags = array_values(array_filter($tags));
431
432 $this->tags = $tags;
433
434 return $this;
435 }
436
437 /**
438 * Rename a tag in tags list.
439 *
440 * @param string $fromTag
441 * @param string $toTag
442 */
443 public function renameTag($fromTag, $toTag)
444 {
445 if (($pos = array_search($fromTag, $this->tags)) !== false) {
446 $this->tags[$pos] = trim($toTag);
447 }
448 }
449
450 /**
451 * Delete a tag from tags list.
452 *
453 * @param string $tag
454 */
455 public function deleteTag($tag)
456 {
457 if (($pos = array_search($tag, $this->tags)) !== false) {
458 unset($this->tags[$pos]);
459 $this->tags = array_values($this->tags);
460 }
461 }
462}
diff --git a/application/bookmark/BookmarkArray.php b/application/bookmark/BookmarkArray.php
new file mode 100644
index 00000000..3bd5eb20
--- /dev/null
+++ b/application/bookmark/BookmarkArray.php
@@ -0,0 +1,260 @@
1<?php
2
3namespace Shaarli\Bookmark;
4
5use Shaarli\Bookmark\Exception\InvalidBookmarkException;
6
7/**
8 * Class BookmarkArray
9 *
10 * Implementing ArrayAccess, this allows us to use the bookmark list
11 * as an array and iterate over it.
12 *
13 * @package Shaarli\Bookmark
14 */
15class BookmarkArray implements \Iterator, \Countable, \ArrayAccess
16{
17 /**
18 * @var Bookmark[]
19 */
20 protected $bookmarks;
21
22 /**
23 * @var array List of all bookmarks IDS mapped with their array offset.
24 * Map: id->offset.
25 */
26 protected $ids;
27
28 /**
29 * @var int Position in the $this->keys array (for the Iterator interface)
30 */
31 protected $position;
32
33 /**
34 * @var array List of offset keys (for the Iterator interface implementation)
35 */
36 protected $keys;
37
38 /**
39 * @var array List of all recorded URLs (key=url, value=bookmark offset)
40 * for fast reserve search (url-->bookmark offset)
41 */
42 protected $urls;
43
44 public function __construct()
45 {
46 $this->ids = [];
47 $this->bookmarks = [];
48 $this->keys = [];
49 $this->urls = [];
50 $this->position = 0;
51 }
52
53 /**
54 * Countable - Counts elements of an object
55 *
56 * @return int Number of bookmarks
57 */
58 public function count()
59 {
60 return count($this->bookmarks);
61 }
62
63 /**
64 * ArrayAccess - Assigns a value to the specified offset
65 *
66 * @param int $offset Bookmark ID
67 * @param Bookmark $value instance
68 *
69 * @throws InvalidBookmarkException
70 */
71 public function offsetSet($offset, $value)
72 {
73 if (! $value instanceof Bookmark
74 || $value->getId() === null || empty($value->getUrl())
75 || ($offset !== null && ! is_int($offset)) || ! is_int($value->getId())
76 || $offset !== null && $offset !== $value->getId()
77 ) {
78 throw new InvalidBookmarkException($value);
79 }
80
81 // If the bookmark exists, we reuse the real offset, otherwise new entry
82 if ($offset !== null) {
83 $existing = $this->getBookmarkOffset($offset);
84 } else {
85 $existing = $this->getBookmarkOffset($value->getId());
86 }
87
88 if ($existing !== null) {
89 $offset = $existing;
90 } else {
91 $offset = count($this->bookmarks);
92 }
93
94 $this->bookmarks[$offset] = $value;
95 $this->urls[$value->getUrl()] = $offset;
96 $this->ids[$value->getId()] = $offset;
97 }
98
99 /**
100 * ArrayAccess - Whether or not an offset exists
101 *
102 * @param int $offset Bookmark ID
103 *
104 * @return bool true if it exists, false otherwise
105 */
106 public function offsetExists($offset)
107 {
108 return array_key_exists($this->getBookmarkOffset($offset), $this->bookmarks);
109 }
110
111 /**
112 * ArrayAccess - Unsets an offset
113 *
114 * @param int $offset Bookmark ID
115 */
116 public function offsetUnset($offset)
117 {
118 $realOffset = $this->getBookmarkOffset($offset);
119 $url = $this->bookmarks[$realOffset]->getUrl();
120 unset($this->urls[$url]);
121 unset($this->ids[$offset]);
122 unset($this->bookmarks[$realOffset]);
123 }
124
125 /**
126 * ArrayAccess - Returns the value at specified offset
127 *
128 * @param int $offset Bookmark ID
129 *
130 * @return Bookmark|null The Bookmark if found, null otherwise
131 */
132 public function offsetGet($offset)
133 {
134 $realOffset = $this->getBookmarkOffset($offset);
135 return isset($this->bookmarks[$realOffset]) ? $this->bookmarks[$realOffset] : null;
136 }
137
138 /**
139 * Iterator - Returns the current element
140 *
141 * @return Bookmark corresponding to the current position
142 */
143 public function current()
144 {
145 return $this[$this->keys[$this->position]];
146 }
147
148 /**
149 * Iterator - Returns the key of the current element
150 *
151 * @return int Bookmark ID corresponding to the current position
152 */
153 public function key()
154 {
155 return $this->keys[$this->position];
156 }
157
158 /**
159 * Iterator - Moves forward to next element
160 */
161 public function next()
162 {
163 ++$this->position;
164 }
165
166 /**
167 * Iterator - Rewinds the Iterator to the first element
168 *
169 * Entries are sorted by date (latest first)
170 */
171 public function rewind()
172 {
173 $this->keys = array_keys($this->ids);
174 $this->position = 0;
175 }
176
177 /**
178 * Iterator - Checks if current position is valid
179 *
180 * @return bool true if the current Bookmark ID exists, false otherwise
181 */
182 public function valid()
183 {
184 return isset($this->keys[$this->position]);
185 }
186
187 /**
188 * Returns a bookmark offset in bookmarks array from its unique ID.
189 *
190 * @param int $id Persistent ID of a bookmark.
191 *
192 * @return int Real offset in local array, or null if doesn't exist.
193 */
194 protected function getBookmarkOffset($id)
195 {
196 if (isset($this->ids[$id])) {
197 return $this->ids[$id];
198 }
199 return null;
200 }
201
202 /**
203 * Return the next key for bookmark creation.
204 * E.g. If the last ID is 597, the next will be 598.
205 *
206 * @return int next ID.
207 */
208 public function getNextId()
209 {
210 if (!empty($this->ids)) {
211 return max(array_keys($this->ids)) + 1;
212 }
213 return 0;
214 }
215
216 /**
217 * @param $url
218 *
219 * @return Bookmark|null
220 */
221 public function getByUrl($url)
222 {
223 if (! empty($url)
224 && isset($this->urls[$url])
225 && isset($this->bookmarks[$this->urls[$url]])
226 ) {
227 return $this->bookmarks[$this->urls[$url]];
228 }
229 return null;
230 }
231
232 /**
233 * Reorder links by creation date (newest first).
234 *
235 * Also update the urls and ids mapping arrays.
236 *
237 * @param string $order ASC|DESC
238 * @param bool $ignoreSticky If set to true, sticky bookmarks won't be first
239 */
240 public function reorder(string $order = 'DESC', bool $ignoreSticky = false): void
241 {
242 $order = $order === 'ASC' ? -1 : 1;
243 // Reorder array by dates.
244 usort($this->bookmarks, function ($a, $b) use ($order, $ignoreSticky) {
245 /** @var $a Bookmark */
246 /** @var $b Bookmark */
247 if (false === $ignoreSticky && $a->isSticky() !== $b->isSticky()) {
248 return $a->isSticky() ? -1 : 1;
249 }
250 return $a->getCreated() < $b->getCreated() ? 1 * $order : -1 * $order;
251 });
252
253 $this->urls = [];
254 $this->ids = [];
255 foreach ($this->bookmarks as $key => $bookmark) {
256 $this->urls[$bookmark->getUrl()] = $key;
257 $this->ids[$bookmark->getId()] = $key;
258 }
259 }
260}
diff --git a/application/bookmark/BookmarkFileService.php b/application/bookmark/BookmarkFileService.php
new file mode 100644
index 00000000..c9ec2609
--- /dev/null
+++ b/application/bookmark/BookmarkFileService.php
@@ -0,0 +1,407 @@
1<?php
2
3
4namespace Shaarli\Bookmark;
5
6
7use Exception;
8use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
9use Shaarli\Bookmark\Exception\DatastoreNotInitializedException;
10use Shaarli\Bookmark\Exception\EmptyDataStoreException;
11use Shaarli\Config\ConfigManager;
12use Shaarli\Formatter\BookmarkMarkdownFormatter;
13use Shaarli\History;
14use Shaarli\Legacy\LegacyLinkDB;
15use Shaarli\Legacy\LegacyUpdater;
16use Shaarli\Render\PageCacheManager;
17use Shaarli\Updater\UpdaterUtils;
18
19/**
20 * Class BookmarksService
21 *
22 * This is the entry point to manipulate the bookmark DB.
23 * It manipulates loads links from a file data store containing all bookmarks.
24 *
25 * It also triggers the legacy format (bookmarks as arrays) migration.
26 */
27class BookmarkFileService implements BookmarkServiceInterface
28{
29 /** @var Bookmark[] instance */
30 protected $bookmarks;
31
32 /** @var BookmarkIO instance */
33 protected $bookmarksIO;
34
35 /** @var BookmarkFilter */
36 protected $bookmarkFilter;
37
38 /** @var ConfigManager instance */
39 protected $conf;
40
41 /** @var History instance */
42 protected $history;
43
44 /** @var PageCacheManager instance */
45 protected $pageCacheManager;
46
47 /** @var bool true for logged in users. Default value to retrieve private bookmarks. */
48 protected $isLoggedIn;
49
50 /**
51 * @inheritDoc
52 */
53 public function __construct(ConfigManager $conf, History $history, $isLoggedIn)
54 {
55 $this->conf = $conf;
56 $this->history = $history;
57 $this->pageCacheManager = new PageCacheManager($this->conf->get('resource.page_cache'), $isLoggedIn);
58 $this->bookmarksIO = new BookmarkIO($this->conf);
59 $this->isLoggedIn = $isLoggedIn;
60
61 if (!$this->isLoggedIn && $this->conf->get('privacy.hide_public_links', false)) {
62 $this->bookmarks = [];
63 } else {
64 try {
65 $this->bookmarks = $this->bookmarksIO->read();
66 } catch (EmptyDataStoreException|DatastoreNotInitializedException $e) {
67 $this->bookmarks = new BookmarkArray();
68
69 if ($this->isLoggedIn) {
70 // Datastore file does not exists, we initialize it with default bookmarks.
71 if ($e instanceof DatastoreNotInitializedException) {
72 $this->initialize();
73 } else {
74 $this->save();
75 }
76 }
77 }
78
79 if (! $this->bookmarks instanceof BookmarkArray) {
80 $this->migrate();
81 exit(
82 'Your data store has been migrated, please reload the page.'. PHP_EOL .
83 'If this message keeps showing up, please delete data/updates.txt file.'
84 );
85 }
86 }
87
88 $this->bookmarkFilter = new BookmarkFilter($this->bookmarks);
89 }
90
91 /**
92 * @inheritDoc
93 */
94 public function findByHash($hash)
95 {
96 $bookmark = $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_HASH, $hash);
97 // PHP 7.3 introduced array_key_first() to avoid this hack
98 $first = reset($bookmark);
99 if (! $this->isLoggedIn && $first->isPrivate()) {
100 throw new Exception('Not authorized');
101 }
102
103 return $first;
104 }
105
106 /**
107 * @inheritDoc
108 */
109 public function findByUrl($url)
110 {
111 return $this->bookmarks->getByUrl($url);
112 }
113
114 /**
115 * @inheritDoc
116 */
117 public function search(
118 $request = [],
119 $visibility = null,
120 $caseSensitive = false,
121 $untaggedOnly = false,
122 bool $ignoreSticky = false
123 ) {
124 if ($visibility === null) {
125 $visibility = $this->isLoggedIn ? BookmarkFilter::$ALL : BookmarkFilter::$PUBLIC;
126 }
127
128 // Filter bookmark database according to parameters.
129 $searchtags = isset($request['searchtags']) ? $request['searchtags'] : '';
130 $searchterm = isset($request['searchterm']) ? $request['searchterm'] : '';
131
132 if ($ignoreSticky) {
133 $this->bookmarks->reorder('DESC', true);
134 }
135
136 return $this->bookmarkFilter->filter(
137 BookmarkFilter::$FILTER_TAG | BookmarkFilter::$FILTER_TEXT,
138 [$searchtags, $searchterm],
139 $caseSensitive,
140 $visibility,
141 $untaggedOnly
142 );
143 }
144
145 /**
146 * @inheritDoc
147 */
148 public function get($id, $visibility = null)
149 {
150 if (! isset($this->bookmarks[$id])) {
151 throw new BookmarkNotFoundException();
152 }
153
154 if ($visibility === null) {
155 $visibility = $this->isLoggedIn ? BookmarkFilter::$ALL : BookmarkFilter::$PUBLIC;
156 }
157
158 $bookmark = $this->bookmarks[$id];
159 if (($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private')
160 || (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public')
161 ) {
162 throw new Exception('Unauthorized');
163 }
164
165 return $bookmark;
166 }
167
168 /**
169 * @inheritDoc
170 */
171 public function set($bookmark, $save = true)
172 {
173 if (true !== $this->isLoggedIn) {
174 throw new Exception(t('You\'re not authorized to alter the datastore'));
175 }
176 if (! $bookmark instanceof Bookmark) {
177 throw new Exception(t('Provided data is invalid'));
178 }
179 if (! isset($this->bookmarks[$bookmark->getId()])) {
180 throw new BookmarkNotFoundException();
181 }
182 $bookmark->validate();
183
184 $bookmark->setUpdated(new \DateTime());
185 $this->bookmarks[$bookmark->getId()] = $bookmark;
186 if ($save === true) {
187 $this->save();
188 $this->history->updateLink($bookmark);
189 }
190 return $this->bookmarks[$bookmark->getId()];
191 }
192
193 /**
194 * @inheritDoc
195 */
196 public function add($bookmark, $save = true)
197 {
198 if (true !== $this->isLoggedIn) {
199 throw new Exception(t('You\'re not authorized to alter the datastore'));
200 }
201 if (! $bookmark instanceof Bookmark) {
202 throw new Exception(t('Provided data is invalid'));
203 }
204 if (! empty($bookmark->getId())) {
205 throw new Exception(t('This bookmarks already exists'));
206 }
207 $bookmark->setId($this->bookmarks->getNextId());
208 $bookmark->validate();
209
210 $this->bookmarks[$bookmark->getId()] = $bookmark;
211 if ($save === true) {
212 $this->save();
213 $this->history->addLink($bookmark);
214 }
215 return $this->bookmarks[$bookmark->getId()];
216 }
217
218 /**
219 * @inheritDoc
220 */
221 public function addOrSet($bookmark, $save = true)
222 {
223 if (true !== $this->isLoggedIn) {
224 throw new Exception(t('You\'re not authorized to alter the datastore'));
225 }
226 if (! $bookmark instanceof Bookmark) {
227 throw new Exception('Provided data is invalid');
228 }
229 if ($bookmark->getId() === null) {
230 return $this->add($bookmark, $save);
231 }
232 return $this->set($bookmark, $save);
233 }
234
235 /**
236 * @inheritDoc
237 */
238 public function remove($bookmark, $save = true)
239 {
240 if (true !== $this->isLoggedIn) {
241 throw new Exception(t('You\'re not authorized to alter the datastore'));
242 }
243 if (! $bookmark instanceof Bookmark) {
244 throw new Exception(t('Provided data is invalid'));
245 }
246 if (! isset($this->bookmarks[$bookmark->getId()])) {
247 throw new BookmarkNotFoundException();
248 }
249
250 unset($this->bookmarks[$bookmark->getId()]);
251 if ($save === true) {
252 $this->save();
253 $this->history->deleteLink($bookmark);
254 }
255 }
256
257 /**
258 * @inheritDoc
259 */
260 public function exists($id, $visibility = null)
261 {
262 if (! isset($this->bookmarks[$id])) {
263 return false;
264 }
265
266 if ($visibility === null) {
267 $visibility = $this->isLoggedIn ? 'all' : 'public';
268 }
269
270 $bookmark = $this->bookmarks[$id];
271 if (($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private')
272 || (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public')
273 ) {
274 return false;
275 }
276
277 return true;
278 }
279
280 /**
281 * @inheritDoc
282 */
283 public function count($visibility = null)
284 {
285 return count($this->search([], $visibility));
286 }
287
288 /**
289 * @inheritDoc
290 */
291 public function save()
292 {
293 if (true !== $this->isLoggedIn) {
294 // TODO: raise an Exception instead
295 die('You are not authorized to change the database.');
296 }
297
298 $this->bookmarks->reorder();
299 $this->bookmarksIO->write($this->bookmarks);
300 $this->pageCacheManager->invalidateCaches();
301 }
302
303 /**
304 * @inheritDoc
305 */
306 public function bookmarksCountPerTag($filteringTags = [], $visibility = null)
307 {
308 $bookmarks = $this->search(['searchtags' => $filteringTags], $visibility);
309 $tags = [];
310 $caseMapping = [];
311 foreach ($bookmarks as $bookmark) {
312 foreach ($bookmark->getTags() as $tag) {
313 if (empty($tag)
314 || (! $this->isLoggedIn && startsWith($tag, '.'))
315 || $tag === BookmarkMarkdownFormatter::NO_MD_TAG
316 || in_array($tag, $filteringTags, true)
317 ) {
318 continue;
319 }
320
321 // The first case found will be displayed.
322 if (!isset($caseMapping[strtolower($tag)])) {
323 $caseMapping[strtolower($tag)] = $tag;
324 $tags[$caseMapping[strtolower($tag)]] = 0;
325 }
326 $tags[$caseMapping[strtolower($tag)]]++;
327 }
328 }
329
330 /*
331 * Formerly used arsort(), which doesn't define the sort behaviour for equal values.
332 * Also, this function doesn't produce the same result between PHP 5.6 and 7.
333 *
334 * So we now use array_multisort() to sort tags by DESC occurrences,
335 * then ASC alphabetically for equal values.
336 *
337 * @see https://github.com/shaarli/Shaarli/issues/1142
338 */
339 $keys = array_keys($tags);
340 $tmpTags = array_combine($keys, $keys);
341 array_multisort($tags, SORT_DESC, $tmpTags, SORT_ASC, $tags);
342 return $tags;
343 }
344
345 /**
346 * @inheritDoc
347 */
348 public function days()
349 {
350 $bookmarkDays = [];
351 foreach ($this->search() as $bookmark) {
352 $bookmarkDays[$bookmark->getCreated()->format('Ymd')] = 0;
353 }
354 $bookmarkDays = array_keys($bookmarkDays);
355 sort($bookmarkDays);
356
357 return $bookmarkDays;
358 }
359
360 /**
361 * @inheritDoc
362 */
363 public function filterDay($request)
364 {
365 $visibility = $this->isLoggedIn ? BookmarkFilter::$ALL : BookmarkFilter::$PUBLIC;
366
367 return $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_DAY, $request, false, $visibility);
368 }
369
370 /**
371 * @inheritDoc
372 */
373 public function initialize()
374 {
375 $initializer = new BookmarkInitializer($this);
376 $initializer->initialize();
377
378 if (true === $this->isLoggedIn) {
379 $this->save();
380 }
381 }
382
383 /**
384 * Handles migration to the new database format (BookmarksArray).
385 */
386 protected function migrate()
387 {
388 $bookmarkDb = new LegacyLinkDB(
389 $this->conf->get('resource.datastore'),
390 true,
391 false
392 );
393 $updater = new LegacyUpdater(
394 UpdaterUtils::read_updates_file($this->conf->get('resource.updates')),
395 $bookmarkDb,
396 $this->conf,
397 true
398 );
399 $newUpdates = $updater->update();
400 if (! empty($newUpdates)) {
401 UpdaterUtils::write_updates_file(
402 $this->conf->get('resource.updates'),
403 $updater->getDoneUpdates()
404 );
405 }
406 }
407}
diff --git a/application/bookmark/BookmarkFilter.php b/application/bookmark/BookmarkFilter.php
new file mode 100644
index 00000000..6636bbfe
--- /dev/null
+++ b/application/bookmark/BookmarkFilter.php
@@ -0,0 +1,473 @@
1<?php
2
3namespace Shaarli\Bookmark;
4
5use Exception;
6use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
7
8/**
9 * Class LinkFilter.
10 *
11 * Perform search and filter operation on link data list.
12 */
13class BookmarkFilter
14{
15 /**
16 * @var string permalinks.
17 */
18 public static $FILTER_HASH = 'permalink';
19
20 /**
21 * @var string text search.
22 */
23 public static $FILTER_TEXT = 'fulltext';
24
25 /**
26 * @var string tag filter.
27 */
28 public static $FILTER_TAG = 'tags';
29
30 /**
31 * @var string filter by day.
32 */
33 public static $FILTER_DAY = 'FILTER_DAY';
34
35 /**
36 * @var string filter by day.
37 */
38 public static $DEFAULT = 'NO_FILTER';
39
40 /** @var string Visibility: all */
41 public static $ALL = 'all';
42
43 /** @var string Visibility: public */
44 public static $PUBLIC = 'public';
45
46 /** @var string Visibility: private */
47 public static $PRIVATE = 'private';
48
49 /**
50 * @var string Allowed characters for hashtags (regex syntax).
51 */
52 public static $HASHTAG_CHARS = '\p{Pc}\p{N}\p{L}\p{Mn}';
53
54 /**
55 * @var Bookmark[] all available bookmarks.
56 */
57 private $bookmarks;
58
59 /**
60 * @param Bookmark[] $bookmarks initialization.
61 */
62 public function __construct($bookmarks)
63 {
64 $this->bookmarks = $bookmarks;
65 }
66
67 /**
68 * Filter bookmarks according to parameters.
69 *
70 * @param string $type Type of filter (eg. tags, permalink, etc.).
71 * @param mixed $request Filter content.
72 * @param bool $casesensitive Optional: Perform case sensitive filter if true.
73 * @param string $visibility Optional: return only all/private/public bookmarks
74 * @param bool $untaggedonly Optional: return only untagged bookmarks. Applies only if $type includes FILTER_TAG
75 *
76 * @return Bookmark[] filtered bookmark list.
77 *
78 * @throws BookmarkNotFoundException
79 */
80 public function filter($type, $request, $casesensitive = false, $visibility = 'all', $untaggedonly = false)
81 {
82 if (!in_array($visibility, ['all', 'public', 'private'])) {
83 $visibility = 'all';
84 }
85
86 switch ($type) {
87 case self::$FILTER_HASH:
88 return $this->filterSmallHash($request);
89 case self::$FILTER_TAG | self::$FILTER_TEXT: // == "vuotext"
90 $noRequest = empty($request) || (empty($request[0]) && empty($request[1]));
91 if ($noRequest) {
92 if ($untaggedonly) {
93 return $this->filterUntagged($visibility);
94 }
95 return $this->noFilter($visibility);
96 }
97 if ($untaggedonly) {
98 $filtered = $this->filterUntagged($visibility);
99 } else {
100 $filtered = $this->bookmarks;
101 }
102 if (!empty($request[0])) {
103 $filtered = (new BookmarkFilter($filtered))->filterTags($request[0], $casesensitive, $visibility);
104 }
105 if (!empty($request[1])) {
106 $filtered = (new BookmarkFilter($filtered))->filterFulltext($request[1], $visibility);
107 }
108 return $filtered;
109 case self::$FILTER_TEXT:
110 return $this->filterFulltext($request, $visibility);
111 case self::$FILTER_TAG:
112 if ($untaggedonly) {
113 return $this->filterUntagged($visibility);
114 } else {
115 return $this->filterTags($request, $casesensitive, $visibility);
116 }
117 case self::$FILTER_DAY:
118 return $this->filterDay($request, $visibility);
119 default:
120 return $this->noFilter($visibility);
121 }
122 }
123
124 /**
125 * Unknown filter, but handle private only.
126 *
127 * @param string $visibility Optional: return only all/private/public bookmarks
128 *
129 * @return Bookmark[] filtered bookmarks.
130 */
131 private function noFilter($visibility = 'all')
132 {
133 if ($visibility === 'all') {
134 return $this->bookmarks;
135 }
136
137 $out = array();
138 foreach ($this->bookmarks as $key => $value) {
139 if ($value->isPrivate() && $visibility === 'private') {
140 $out[$key] = $value;
141 } elseif (!$value->isPrivate() && $visibility === 'public') {
142 $out[$key] = $value;
143 }
144 }
145
146 return $out;
147 }
148
149 /**
150 * Returns the shaare corresponding to a smallHash.
151 *
152 * @param string $smallHash permalink hash.
153 *
154 * @return array $filtered array containing permalink data.
155 *
156 * @throws \Shaarli\Bookmark\Exception\BookmarkNotFoundException if the smallhash doesn't match any link.
157 */
158 private function filterSmallHash($smallHash)
159 {
160 foreach ($this->bookmarks as $key => $l) {
161 if ($smallHash == $l->getShortUrl()) {
162 // Yes, this is ugly and slow
163 return [$key => $l];
164 }
165 }
166
167 throw new BookmarkNotFoundException();
168 }
169
170 /**
171 * Returns the list of bookmarks corresponding to a full-text search
172 *
173 * Searches:
174 * - in the URLs, title and description;
175 * - are case-insensitive;
176 * - terms surrounded by quotes " are exact terms search.
177 * - terms starting with a dash - are excluded (except exact terms).
178 *
179 * Example:
180 * print_r($mydb->filterFulltext('hollandais'));
181 *
182 * mb_convert_case($val, MB_CASE_LOWER, 'UTF-8')
183 * - allows to perform searches on Unicode text
184 * - see https://github.com/shaarli/Shaarli/issues/75 for examples
185 *
186 * @param string $searchterms search query.
187 * @param string $visibility Optional: return only all/private/public bookmarks.
188 *
189 * @return array search results.
190 */
191 private function filterFulltext($searchterms, $visibility = 'all')
192 {
193 if (empty($searchterms)) {
194 return $this->noFilter($visibility);
195 }
196
197 $filtered = array();
198 $search = mb_convert_case(html_entity_decode($searchterms), MB_CASE_LOWER, 'UTF-8');
199 $exactRegex = '/"([^"]+)"/';
200 // Retrieve exact search terms.
201 preg_match_all($exactRegex, $search, $exactSearch);
202 $exactSearch = array_values(array_filter($exactSearch[1]));
203
204 // Remove exact search terms to get AND terms search.
205 $explodedSearchAnd = explode(' ', trim(preg_replace($exactRegex, '', $search)));
206 $explodedSearchAnd = array_values(array_filter($explodedSearchAnd));
207
208 // Filter excluding terms and update andSearch.
209 $excludeSearch = array();
210 $andSearch = array();
211 foreach ($explodedSearchAnd as $needle) {
212 if ($needle[0] == '-' && strlen($needle) > 1) {
213 $excludeSearch[] = substr($needle, 1);
214 } else {
215 $andSearch[] = $needle;
216 }
217 }
218
219 // Iterate over every stored link.
220 foreach ($this->bookmarks as $id => $link) {
221 // ignore non private bookmarks when 'privatonly' is on.
222 if ($visibility !== 'all') {
223 if (!$link->isPrivate() && $visibility === 'private') {
224 continue;
225 } elseif ($link->isPrivate() && $visibility === 'public') {
226 continue;
227 }
228 }
229
230 // Concatenate link fields to search across fields.
231 // Adds a '\' separator for exact search terms.
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
237 // Be optimistic
238 $found = true;
239
240 // First, we look for exact term search
241 for ($i = 0; $i < count($exactSearch) && $found; $i++) {
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.
247 for ($i = 0; $i < count($andSearch) && $found; $i++) {
248 $found = strpos($content, $andSearch[$i]) !== false;
249 }
250
251 // Exclude terms.
252 for ($i = 0; $i < count($excludeSearch) && $found; $i++) {
253 $found = strpos($content, $excludeSearch[$i]) === false;
254 }
255
256 if ($found) {
257 $filtered[$id] = $link;
258 }
259 }
260
261 return $filtered;
262 }
263
264 /**
265 * generate a regex fragment out of a tag
266 *
267 * @param string $tag to to generate regexs from. may start with '-' to negate, contain '*' as wildcard
268 *
269 * @return string generated regex fragment
270 */
271 private static function tag2regex($tag)
272 {
273 $len = strlen($tag);
274 if (!$len || $tag === "-" || $tag === "*") {
275 // nothing to search, return empty regex
276 return '';
277 }
278 if ($tag[0] === "-") {
279 // query is negated
280 $i = 1; // use offset to start after '-' character
281 $regex = '(?!'; // create negative lookahead
282 } else {
283 $i = 0; // start at first character
284 $regex = '(?='; // use positive lookahead
285 }
286 $regex .= '.*(?:^| )'; // before tag may only be a space or the beginning
287 // iterate over string, separating it into placeholder and content
288 for (; $i < $len; $i++) {
289 if ($tag[$i] === '*') {
290 // placeholder found
291 $regex .= '[^ ]*?';
292 } else {
293 // regular characters
294 $offset = strpos($tag, '*', $i);
295 if ($offset === false) {
296 // no placeholder found, set offset to end of string
297 $offset = $len;
298 }
299 // subtract one, as we want to get before the placeholder or end of string
300 $offset -= 1;
301 // we got a tag name that we want to search for. escape any regex characters to prevent conflicts.
302 $regex .= preg_quote(substr($tag, $i, $offset - $i + 1), '/');
303 // move $i on
304 $i = $offset;
305 }
306 }
307 $regex .= '(?:$| ))'; // after the tag may only be a space or the end
308 return $regex;
309 }
310
311 /**
312 * Returns the list of bookmarks associated with a given list of tags
313 *
314 * You can specify one or more tags, separated by space or a comma, e.g.
315 * print_r($mydb->filterTags('linux programming'));
316 *
317 * @param string $tags list of tags separated by commas or blank spaces.
318 * @param bool $casesensitive ignore case if false.
319 * @param string $visibility Optional: return only all/private/public bookmarks.
320 *
321 * @return array filtered bookmarks.
322 */
323 public function filterTags($tags, $casesensitive = false, $visibility = 'all')
324 {
325 // get single tags (we may get passed an array, even though the docs say different)
326 $inputTags = $tags;
327 if (!is_array($tags)) {
328 // we got an input string, split tags
329 $inputTags = preg_split('/(?:\s+)|,/', $inputTags, -1, PREG_SPLIT_NO_EMPTY);
330 }
331
332 if (!count($inputTags)) {
333 // no input tags
334 return $this->noFilter($visibility);
335 }
336
337 // If we only have public visibility, we can't look for hidden tags
338 if ($visibility === self::$PUBLIC) {
339 $inputTags = array_values(array_filter($inputTags, function ($tag) {
340 return ! startsWith($tag, '.');
341 }));
342
343 if (empty($inputTags)) {
344 return [];
345 }
346 }
347
348 // build regex from all tags
349 $re = '/^' . implode(array_map("self::tag2regex", $inputTags)) . '.*$/';
350 if (!$casesensitive) {
351 // make regex case insensitive
352 $re .= 'i';
353 }
354
355 // create resulting array
356 $filtered = [];
357
358 // iterate over each link
359 foreach ($this->bookmarks as $key => $link) {
360 // check level of visibility
361 // ignore non private bookmarks when 'privateonly' is on.
362 if ($visibility !== 'all') {
363 if (!$link->isPrivate() && $visibility === 'private') {
364 continue;
365 } elseif ($link->isPrivate() && $visibility === 'public') {
366 continue;
367 }
368 }
369 $search = $link->getTagsString(); // build search string, start with tags of current link
370 if (strlen(trim($link->getDescription())) && strpos($link->getDescription(), '#') !== false) {
371 // description given and at least one possible tag found
372 $descTags = array();
373 // find all tags in the form of #tag in the description
374 preg_match_all(
375 '/(?<![' . self::$HASHTAG_CHARS . '])#([' . self::$HASHTAG_CHARS . ']+?)\b/sm',
376 $link->getDescription(),
377 $descTags
378 );
379 if (count($descTags[1])) {
380 // there were some tags in the description, add them to the search string
381 $search .= ' ' . implode(' ', $descTags[1]);
382 }
383 };
384 // match regular expression with search string
385 if (!preg_match($re, $search)) {
386 // this entry does _not_ match our regex
387 continue;
388 }
389 $filtered[$key] = $link;
390 }
391 return $filtered;
392 }
393
394 /**
395 * Return only bookmarks without any tag.
396 *
397 * @param string $visibility return only all/private/public bookmarks.
398 *
399 * @return array filtered bookmarks.
400 */
401 public function filterUntagged($visibility)
402 {
403 $filtered = [];
404 foreach ($this->bookmarks as $key => $link) {
405 if ($visibility !== 'all') {
406 if (!$link->isPrivate() && $visibility === 'private') {
407 continue;
408 } elseif ($link->isPrivate() && $visibility === 'public') {
409 continue;
410 }
411 }
412
413 if (empty(trim($link->getTagsString()))) {
414 $filtered[$key] = $link;
415 }
416 }
417
418 return $filtered;
419 }
420
421 /**
422 * Returns the list of articles for a given day, chronologically sorted
423 *
424 * Day must be in the form 'YYYYMMDD' (e.g. '20120125'), e.g.
425 * print_r($mydb->filterDay('20120125'));
426 *
427 * @param string $day day to filter.
428 * @param string $visibility return only all/private/public bookmarks.
429
430 * @return array all link matching given day.
431 *
432 * @throws Exception if date format is invalid.
433 */
434 public function filterDay($day, $visibility)
435 {
436 if (!checkDateFormat('Ymd', $day)) {
437 throw new Exception('Invalid date format');
438 }
439
440 $filtered = [];
441 foreach ($this->bookmarks as $key => $bookmark) {
442 if ($visibility === static::$PUBLIC && $bookmark->isPrivate()) {
443 continue;
444 }
445
446 if ($bookmark->getCreated()->format('Ymd') == $day) {
447 $filtered[$key] = $bookmark;
448 }
449 }
450
451 // sort by date ASC
452 return array_reverse($filtered, true);
453 }
454
455 /**
456 * Convert a list of tags (str) to an array. Also
457 * - handle case sensitivity.
458 * - accepts spaces commas as separator.
459 *
460 * @param string $tags string containing a list of tags.
461 * @param bool $casesensitive will convert everything to lowercase if false.
462 *
463 * @return array filtered tags string.
464 */
465 public static function tagsStrToArray($tags, $casesensitive)
466 {
467 // We use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek)
468 $tagsOut = $casesensitive ? $tags : mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8');
469 $tagsOut = str_replace(',', ' ', $tagsOut);
470
471 return preg_split('/\s+/', $tagsOut, -1, PREG_SPLIT_NO_EMPTY);
472 }
473}
diff --git a/application/bookmark/BookmarkIO.php b/application/bookmark/BookmarkIO.php
new file mode 100644
index 00000000..6bf7f365
--- /dev/null
+++ b/application/bookmark/BookmarkIO.php
@@ -0,0 +1,108 @@
1<?php
2
3namespace Shaarli\Bookmark;
4
5use Shaarli\Bookmark\Exception\DatastoreNotInitializedException;
6use Shaarli\Bookmark\Exception\EmptyDataStoreException;
7use Shaarli\Bookmark\Exception\NotWritableDataStoreException;
8use Shaarli\Config\ConfigManager;
9
10/**
11 * Class BookmarkIO
12 *
13 * This class performs read/write operation to the file data store.
14 * Used by BookmarkFileService.
15 *
16 * @package Shaarli\Bookmark
17 */
18class BookmarkIO
19{
20 /**
21 * @var string Datastore file path
22 */
23 protected $datastore;
24
25 /**
26 * @var ConfigManager instance
27 */
28 protected $conf;
29
30 /**
31 * string Datastore PHP prefix
32 */
33 protected static $phpPrefix = '<?php /* ';
34
35 /**
36 * string Datastore PHP suffix
37 */
38 protected static $phpSuffix = ' */ ?>';
39
40 /**
41 * LinksIO constructor.
42 *
43 * @param ConfigManager $conf instance
44 */
45 public function __construct($conf)
46 {
47 $this->conf = $conf;
48 $this->datastore = $conf->get('resource.datastore');
49 }
50
51 /**
52 * Reads database from disk to memory
53 *
54 * @return BookmarkArray instance
55 *
56 * @throws NotWritableDataStoreException Data couldn't be loaded
57 * @throws EmptyDataStoreException Datastore file exists but does not contain any bookmark
58 * @throws DatastoreNotInitializedException File does not exists
59 */
60 public function read()
61 {
62 if (! file_exists($this->datastore)) {
63 throw new DatastoreNotInitializedException();
64 }
65
66 if (!is_writable($this->datastore)) {
67 throw new NotWritableDataStoreException($this->datastore);
68 }
69
70 // Note that gzinflate is faster than gzuncompress.
71 // See: http://www.php.net/manual/en/function.gzdeflate.php#96439
72 $links = unserialize(gzinflate(base64_decode(
73 substr(file_get_contents($this->datastore),
74 strlen(self::$phpPrefix), -strlen(self::$phpSuffix)))));
75
76 if (empty($links)) {
77 if (filesize($this->datastore) > 100) {
78 throw new NotWritableDataStoreException($this->datastore);
79 }
80 throw new EmptyDataStoreException();
81 }
82
83 return $links;
84 }
85
86 /**
87 * Saves the database from memory to disk
88 *
89 * @param BookmarkArray $links instance.
90 *
91 * @throws NotWritableDataStoreException the datastore is not writable
92 */
93 public function write($links)
94 {
95 if (is_file($this->datastore) && !is_writeable($this->datastore)) {
96 // The datastore exists but is not writeable
97 throw new NotWritableDataStoreException($this->datastore);
98 } else if (!is_file($this->datastore) && !is_writeable(dirname($this->datastore))) {
99 // The datastore does not exist and its parent directory is not writeable
100 throw new NotWritableDataStoreException(dirname($this->datastore));
101 }
102
103 file_put_contents(
104 $this->datastore,
105 self::$phpPrefix.base64_encode(gzdeflate(serialize($links))).self::$phpSuffix
106 );
107 }
108}
diff --git a/application/bookmark/BookmarkInitializer.php b/application/bookmark/BookmarkInitializer.php
new file mode 100644
index 00000000..815047e3
--- /dev/null
+++ b/application/bookmark/BookmarkInitializer.php
@@ -0,0 +1,110 @@
1<?php
2
3namespace Shaarli\Bookmark;
4
5/**
6 * Class BookmarkInitializer
7 *
8 * This class is used to initialized default bookmarks after a fresh install of Shaarli.
9 * It should be only called if the datastore file does not exist(users might want to delete the default bookmarks).
10 *
11 * To prevent data corruption, it does not overwrite existing bookmarks,
12 * even though there should not be any.
13 *
14 * @package Shaarli\Bookmark
15 */
16class BookmarkInitializer
17{
18 /** @var BookmarkServiceInterface */
19 protected $bookmarkService;
20
21 /**
22 * BookmarkInitializer constructor.
23 *
24 * @param BookmarkServiceInterface $bookmarkService
25 */
26 public function __construct($bookmarkService)
27 {
28 $this->bookmarkService = $bookmarkService;
29 }
30
31 /**
32 * Initialize the data store with default bookmarks
33 */
34 public function initialize()
35 {
36 $bookmark = new Bookmark();
37 $bookmark->setTitle('quicksilver (loop) on Vimeo ' . t('(private bookmark with thumbnail demo)'));
38 $bookmark->setUrl('https://vimeo.com/153493904');
39 $bookmark->setDescription(t(
40'Shaarli will automatically pick up the thumbnail for links to a variety of websites.
41
42Explore your new Shaarli instance by trying out controls and menus.
43Visit the project on [Github](https://github.com/shaarli/Shaarli) or [the documentation](https://shaarli.readthedocs.io/en/master/) to learn more about Shaarli.
44
45Now you can edit or delete the default shaares.
46'
47 ));
48 $bookmark->setTagsString('shaarli help thumbnail');
49 $bookmark->setPrivate(true);
50 $this->bookmarkService->add($bookmark, false);
51
52 $bookmark = new Bookmark();
53 $bookmark->setTitle(t('Note: Shaare descriptions'));
54 $bookmark->setDescription(t(
55'Adding a shaare without entering a URL creates a text-only "note" post such as this one.
56This note is private, so you are the only one able to see it while logged in.
57
58You can use this to keep notes, post articles, code snippets, and much more.
59
60The Markdown formatting setting allows you to format your notes and bookmark description:
61
62### Title headings
63
64#### Multiple headings levels
65 * bullet lists
66 * _italic_ text
67 * **bold** text
68 * ~~strike through~~ text
69 * `code` blocks
70 * images
71 * [links](https://en.wikipedia.org/wiki/Markdown)
72
73Markdown also supports tables:
74
75| Name | Type | Color | Qty |
76| ------- | --------- | ------ | ----- |
77| Orange | Fruit | Orange | 126 |
78| Apple | Fruit | Any | 62 |
79| Lemon | Fruit | Yellow | 30 |
80| Carrot | Vegetable | Red | 14 |
81'
82 ));
83 $bookmark->setTagsString('shaarli help');
84 $bookmark->setPrivate(true);
85 $this->bookmarkService->add($bookmark, false);
86
87 $bookmark = new Bookmark();
88 $bookmark->setTitle(
89 'Shaarli - ' . t('The personal, minimalist, super-fast, database free, bookmarking service')
90 );
91 $bookmark->setDescription(t(
92'Welcome to Shaarli!
93
94Shaarli allows you to bookmark your favorite pages, and share them with others or store them privately.
95You can add a description to your bookmarks, such as this one, and tag them.
96
97Create a new shaare by clicking the `+Shaare` button, or using any of the recommended tools (browser extension, mobile app, bookmarklet, REST API, etc.).
98
99You 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`).
100Hashtags such as #shaarli #help are also supported.
101You can also filter the available [RSS feed](/feed/atom) and picture wall by tag or plaintext search.
102
103We hope that you will enjoy using Shaarli, maintained with ❤️ by the community!
104Feel free to open [an issue](https://github.com/shaarli/Shaarli/issues) if you have a suggestion or encounter an issue.
105'
106 ));
107 $bookmark->setTagsString('shaarli help');
108 $this->bookmarkService->add($bookmark, false);
109 }
110}
diff --git a/application/bookmark/BookmarkServiceInterface.php b/application/bookmark/BookmarkServiceInterface.php
new file mode 100644
index 00000000..b9b483eb
--- /dev/null
+++ b/application/bookmark/BookmarkServiceInterface.php
@@ -0,0 +1,186 @@
1<?php
2
3namespace Shaarli\Bookmark;
4
5
6use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
7use Shaarli\Bookmark\Exception\NotWritableDataStoreException;
8use Shaarli\Config\ConfigManager;
9use Shaarli\History;
10
11/**
12 * Class BookmarksService
13 *
14 * This is the entry point to manipulate the bookmark DB.
15 */
16interface BookmarkServiceInterface
17{
18 /**
19 * BookmarksService constructor.
20 *
21 * @param ConfigManager $conf instance
22 * @param History $history instance
23 * @param bool $isLoggedIn true if the current user is logged in
24 */
25 public function __construct(ConfigManager $conf, History $history, $isLoggedIn);
26
27 /**
28 * Find a bookmark by hash
29 *
30 * @param string $hash
31 *
32 * @return mixed
33 *
34 * @throws \Exception
35 */
36 public function findByHash($hash);
37
38 /**
39 * @param $url
40 *
41 * @return Bookmark|null
42 */
43 public function findByUrl($url);
44
45 /**
46 * Search bookmarks
47 *
48 * @param mixed $request
49 * @param string $visibility
50 * @param bool $caseSensitive
51 * @param bool $untaggedOnly
52 * @param bool $ignoreSticky
53 *
54 * @return Bookmark[]
55 */
56 public function search(
57 $request = [],
58 $visibility = null,
59 $caseSensitive = false,
60 $untaggedOnly = false,
61 bool $ignoreSticky = false
62 );
63
64 /**
65 * Get a single bookmark by its ID.
66 *
67 * @param int $id Bookmark ID
68 * @param string $visibility all|public|private e.g. with public, accessing a private bookmark will throw an
69 * exception
70 *
71 * @return Bookmark
72 *
73 * @throws BookmarkNotFoundException
74 * @throws \Exception
75 */
76 public function get($id, $visibility = null);
77
78 /**
79 * Updates an existing bookmark (depending on its ID).
80 *
81 * @param Bookmark $bookmark
82 * @param bool $save Writes to the datastore if set to true
83 *
84 * @return Bookmark Updated bookmark
85 *
86 * @throws BookmarkNotFoundException
87 * @throws \Exception
88 */
89 public function set($bookmark, $save = true);
90
91 /**
92 * Adds a new bookmark (the ID must be empty).
93 *
94 * @param Bookmark $bookmark
95 * @param bool $save Writes to the datastore if set to true
96 *
97 * @return Bookmark new bookmark
98 *
99 * @throws \Exception
100 */
101 public function add($bookmark, $save = true);
102
103 /**
104 * Adds or updates a bookmark depending on its ID:
105 * - a Bookmark without ID will be added
106 * - a Bookmark with an existing ID will be updated
107 *
108 * @param Bookmark $bookmark
109 * @param bool $save
110 *
111 * @return Bookmark
112 *
113 * @throws \Exception
114 */
115 public function addOrSet($bookmark, $save = true);
116
117 /**
118 * Deletes a bookmark.
119 *
120 * @param Bookmark $bookmark
121 * @param bool $save
122 *
123 * @throws \Exception
124 */
125 public function remove($bookmark, $save = true);
126
127 /**
128 * Get a single bookmark by its ID.
129 *
130 * @param int $id Bookmark ID
131 * @param string $visibility all|public|private e.g. with public, accessing a private bookmark will throw an
132 * exception
133 *
134 * @return bool
135 */
136 public function exists($id, $visibility = null);
137
138 /**
139 * Return the number of available bookmarks for given visibility.
140 *
141 * @param string $visibility public|private|all
142 *
143 * @return int Number of bookmarks
144 */
145 public function count($visibility = null);
146
147 /**
148 * Write the datastore.
149 *
150 * @throws NotWritableDataStoreException
151 */
152 public function save();
153
154 /**
155 * Returns the list tags appearing in the bookmarks with the given tags
156 *
157 * @param array $filteringTags tags selecting the bookmarks to consider
158 * @param string $visibility process only all/private/public bookmarks
159 *
160 * @return array tag => bookmarksCount
161 */
162 public function bookmarksCountPerTag($filteringTags = [], $visibility = 'all');
163
164 /**
165 * Returns the list of days containing articles (oldest first)
166 *
167 * @return array containing days (in format YYYYMMDD).
168 */
169 public function days();
170
171 /**
172 * Returns the list of articles for a given day.
173 *
174 * @param string $request day to filter. Format: YYYYMMDD.
175 *
176 * @return Bookmark[] list of shaare found.
177 *
178 * @throws BookmarkNotFoundException
179 */
180 public function filterDay($request);
181
182 /**
183 * Creates the default database after a fresh install.
184 */
185 public function initialize();
186}
diff --git a/application/bookmark/LinkUtils.php b/application/bookmark/LinkUtils.php
index 77eb2d95..e7af4d55 100644
--- a/application/bookmark/LinkUtils.php
+++ b/application/bookmark/LinkUtils.php
@@ -1,112 +1,6 @@
1<?php 1<?php
2 2
3use Shaarli\Bookmark\LinkDB; 3use Shaarli\Bookmark\Bookmark;
4
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 4
111/** 5/**
112 * Extract title from an HTML document. 6 * Extract title from an HTML document.
@@ -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 }
@@ -188,30 +82,11 @@ function html_extract_tag($tag, $html)
188} 82}
189 83
190/** 84/**
191 * Count private links in given linklist. 85 * In a string, converts URLs to clickable bookmarks.
192 *
193 * @param array|Countable $links Linklist.
194 *
195 * @return int Number of private links.
196 */
197function count_private($links)
198{
199 $cpt = 0;
200 foreach ($links as $link) {
201 if ($link['private']) {
202 $cpt += 1;
203 }
204 }
205
206 return $cpt;
207}
208
209/**
210 * In a string, converts URLs to clickable links.
211 * 86 *
212 * @param string $text input string. 87 * @param string $text input string.
213 * 88 *
214 * @return string returns $text with all links converted to HTML links. 89 * @return string returns $text with all bookmarks converted to HTML bookmarks.
215 * 90 *
216 * @see Function inspired from http://www.php.net/manual/en/function.preg-replace.php#85722 91 * @see Function inspired from http://www.php.net/manual/en/function.preg-replace.php#85722
217 */ 92 */
@@ -239,7 +114,7 @@ function hashtag_autolink($description, $indexUrl = '')
239 * \p{Mn} - any non marking space (accents, umlauts, etc) 114 * \p{Mn} - any non marking space (accents, umlauts, etc)
240 */ 115 */
241 $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui'; 116 $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui';
242 $replacement = '$1<a href="'. $indexUrl .'?addtag=$2" title="Hashtag $2">#$2</a>'; 117 $replacement = '$1<a href="'. $indexUrl .'./add-tag/$2" title="Hashtag $2">#$2</a>';
243 return preg_replace($regex, $replacement, $description); 118 return preg_replace($regex, $replacement, $description);
244} 119}
245 120
@@ -279,7 +154,7 @@ function format_description($description, $indexUrl = '')
279 */ 154 */
280function link_small_hash($date, $id) 155function link_small_hash($date, $id)
281{ 156{
282 return smallHash($date->format(LinkDB::LINK_DATE_FORMAT) . $id); 157 return smallHash($date->format(Bookmark::LINK_DATE_FORMAT) . $id);
283} 158}
284 159
285/** 160/**
diff --git a/application/bookmark/exception/LinkNotFoundException.php b/application/bookmark/exception/BookmarkNotFoundException.php
index f9414428..827a3d35 100644
--- a/application/bookmark/exception/LinkNotFoundException.php
+++ b/application/bookmark/exception/BookmarkNotFoundException.php
@@ -3,7 +3,7 @@ namespace Shaarli\Bookmark\Exception;
3 3
4use Exception; 4use Exception;
5 5
6class LinkNotFoundException extends Exception 6class BookmarkNotFoundException extends Exception
7{ 7{
8 /** 8 /**
9 * LinkNotFoundException constructor. 9 * LinkNotFoundException constructor.
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/bookmark/exception/EmptyDataStoreException.php b/application/bookmark/exception/EmptyDataStoreException.php
new file mode 100644
index 00000000..cd48c1e6
--- /dev/null
+++ b/application/bookmark/exception/EmptyDataStoreException.php
@@ -0,0 +1,7 @@
1<?php
2
3
4namespace Shaarli\Bookmark\Exception;
5
6
7class EmptyDataStoreException extends \Exception {}
diff --git a/application/bookmark/exception/InvalidBookmarkException.php b/application/bookmark/exception/InvalidBookmarkException.php
new file mode 100644
index 00000000..10c84a6d
--- /dev/null
+++ b/application/bookmark/exception/InvalidBookmarkException.php
@@ -0,0 +1,30 @@
1<?php
2
3namespace Shaarli\Bookmark\Exception;
4
5use Shaarli\Bookmark\Bookmark;
6
7class InvalidBookmarkException extends \Exception
8{
9 public function __construct($bookmark)
10 {
11 if ($bookmark instanceof Bookmark) {
12 if ($bookmark->getCreated() instanceof \DateTime) {
13 $created = $bookmark->getCreated()->format(\DateTime::ATOM);
14 } elseif (empty($bookmark->getCreated())) {
15 $created = '';
16 } else {
17 $created = 'Not a DateTime object';
18 }
19 $this->message = 'This bookmark is not valid'. PHP_EOL;
20 $this->message .= ' - ID: '. $bookmark->getId() . PHP_EOL;
21 $this->message .= ' - Title: '. $bookmark->getTitle() . PHP_EOL;
22 $this->message .= ' - Url: '. $bookmark->getUrl() . PHP_EOL;
23 $this->message .= ' - ShortUrl: '. $bookmark->getShortUrl() . PHP_EOL;
24 $this->message .= ' - Created: '. $created . PHP_EOL;
25 } else {
26 $this->message = 'The provided data is not a bookmark'. PHP_EOL;
27 $this->message .= var_export($bookmark, true);
28 }
29 }
30}
diff --git a/application/bookmark/exception/NotWritableDataStoreException.php b/application/bookmark/exception/NotWritableDataStoreException.php
new file mode 100644
index 00000000..95f34b50
--- /dev/null
+++ b/application/bookmark/exception/NotWritableDataStoreException.php
@@ -0,0 +1,19 @@
1<?php
2
3
4namespace Shaarli\Bookmark\Exception;
5
6
7class NotWritableDataStoreException extends \Exception
8{
9 /**
10 * NotReadableDataStore constructor.
11 *
12 * @param string $dataStore file path
13 */
14 public function __construct($dataStore)
15 {
16 $this->message = 'Couldn\'t load data from the data store file "'. $dataStore .'". '.
17 'Your data might be corrupted, or your file isn\'t readable.';
18 }
19}
diff --git a/application/config/ConfigJson.php b/application/config/ConfigJson.php
index 4509357c..c0c0dab9 100644
--- a/application/config/ConfigJson.php
+++ b/application/config/ConfigJson.php
@@ -46,7 +46,7 @@ class ConfigJson implements ConfigIO
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. '.
diff --git a/application/config/ConfigManager.php b/application/config/ConfigManager.php
index c95e6800..4c98be30 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,7 +362,7 @@ 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: ');
@@ -381,6 +382,7 @@ class ConfigManager
381 // default state of the 'remember me' checkbox of the login form 382 // default state of the 'remember me' checkbox of the login form
382 $this->setEmpty('privacy.remember_user_default', true); 383 $this->setEmpty('privacy.remember_user_default', true);
383 384
385 $this->setEmpty('thumbnails.mode', Thumbnailer::MODE_ALL);
384 $this->setEmpty('thumbnails.width', '125'); 386 $this->setEmpty('thumbnails.width', '125');
385 $this->setEmpty('thumbnails.height', '90'); 387 $this->setEmpty('thumbnails.height', '90');
386 388
@@ -389,6 +391,8 @@ class ConfigManager
389 $this->setEmpty('translation.extensions', []); 391 $this->setEmpty('translation.extensions', []);
390 392
391 $this->setEmpty('plugins', array()); 393 $this->setEmpty('plugins', array());
394
395 $this->setEmpty('formatter', 'markdown');
392 } 396 }
393 397
394 /** 398 /**
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
new file mode 100644
index 00000000..55bb51b5
--- /dev/null
+++ b/application/container/ContainerBuilder.php
@@ -0,0 +1,165 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Container;
6
7use Shaarli\Bookmark\BookmarkFileService;
8use Shaarli\Bookmark\BookmarkServiceInterface;
9use Shaarli\Config\ConfigManager;
10use Shaarli\Feed\FeedBuilder;
11use Shaarli\Formatter\FormatterFactory;
12use Shaarli\Front\Controller\Visitor\ErrorController;
13use Shaarli\Front\Controller\Visitor\ErrorNotFoundController;
14use Shaarli\History;
15use Shaarli\Http\HttpAccess;
16use Shaarli\Netscape\NetscapeBookmarkUtils;
17use Shaarli\Plugin\PluginManager;
18use Shaarli\Render\PageBuilder;
19use Shaarli\Render\PageCacheManager;
20use Shaarli\Security\CookieManager;
21use Shaarli\Security\LoginManager;
22use Shaarli\Security\SessionManager;
23use Shaarli\Thumbnailer;
24use Shaarli\Updater\Updater;
25use Shaarli\Updater\UpdaterUtils;
26
27/**
28 * Class ContainerBuilder
29 *
30 * Helper used to build a Slim container instance with Shaarli's object dependencies.
31 * Note that most injected objects MUST be added as closures, to let the container instantiate
32 * only the objects it requires during the execution.
33 *
34 * @package Container
35 */
36class ContainerBuilder
37{
38 /** @var ConfigManager */
39 protected $conf;
40
41 /** @var SessionManager */
42 protected $session;
43
44 /** @var CookieManager */
45 protected $cookieManager;
46
47 /** @var LoginManager */
48 protected $login;
49
50 /** @var string|null */
51 protected $basePath = null;
52
53 public function __construct(
54 ConfigManager $conf,
55 SessionManager $session,
56 CookieManager $cookieManager,
57 LoginManager $login
58 ) {
59 $this->conf = $conf;
60 $this->session = $session;
61 $this->login = $login;
62 $this->cookieManager = $cookieManager;
63 }
64
65 public function build(): ShaarliContainer
66 {
67 $container = new ShaarliContainer();
68
69 $container['conf'] = $this->conf;
70 $container['sessionManager'] = $this->session;
71 $container['cookieManager'] = $this->cookieManager;
72 $container['loginManager'] = $this->login;
73 $container['basePath'] = $this->basePath;
74
75 $container['plugins'] = function (ShaarliContainer $container): PluginManager {
76 return new PluginManager($container->conf);
77 };
78
79 $container['history'] = function (ShaarliContainer $container): History {
80 return new History($container->conf->get('resource.history'));
81 };
82
83 $container['bookmarkService'] = function (ShaarliContainer $container): BookmarkServiceInterface {
84 return new BookmarkFileService(
85 $container->conf,
86 $container->history,
87 $container->loginManager->isLoggedIn()
88 );
89 };
90
91 $container['pageBuilder'] = function (ShaarliContainer $container): PageBuilder {
92 return new PageBuilder(
93 $container->conf,
94 $container->sessionManager->getSession(),
95 $container->bookmarkService,
96 $container->sessionManager->generateToken(),
97 $container->loginManager->isLoggedIn()
98 );
99 };
100
101 $container['pluginManager'] = function (ShaarliContainer $container): PluginManager {
102 $pluginManager = new PluginManager($container->conf);
103
104 $pluginManager->load($container->conf->get('general.enabled_plugins'));
105
106 return $pluginManager;
107 };
108
109 $container['formatterFactory'] = function (ShaarliContainer $container): FormatterFactory {
110 return new FormatterFactory(
111 $container->conf,
112 $container->loginManager->isLoggedIn()
113 );
114 };
115
116 $container['pageCacheManager'] = function (ShaarliContainer $container): PageCacheManager {
117 return new PageCacheManager(
118 $container->conf->get('resource.page_cache'),
119 $container->loginManager->isLoggedIn()
120 );
121 };
122
123 $container['feedBuilder'] = function (ShaarliContainer $container): FeedBuilder {
124 return new FeedBuilder(
125 $container->bookmarkService,
126 $container->formatterFactory->getFormatter(),
127 $container->environment,
128 $container->loginManager->isLoggedIn()
129 );
130 };
131
132 $container['thumbnailer'] = function (ShaarliContainer $container): Thumbnailer {
133 return new Thumbnailer($container->conf);
134 };
135
136 $container['httpAccess'] = function (): HttpAccess {
137 return new HttpAccess();
138 };
139
140 $container['netscapeBookmarkUtils'] = function (ShaarliContainer $container): NetscapeBookmarkUtils {
141 return new NetscapeBookmarkUtils($container->bookmarkService, $container->conf, $container->history);
142 };
143
144 $container['updater'] = function (ShaarliContainer $container): Updater {
145 return new Updater(
146 UpdaterUtils::read_updates_file($container->conf->get('resource.updates')),
147 $container->bookmarkService,
148 $container->conf,
149 $container->loginManager->isLoggedIn()
150 );
151 };
152
153 $container['notFoundHandler'] = function (ShaarliContainer $container): ErrorNotFoundController {
154 return new ErrorNotFoundController($container);
155 };
156 $container['errorHandler'] = function (ShaarliContainer $container): ErrorController {
157 return new ErrorController($container);
158 };
159 $container['phpErrorHandler'] = function (ShaarliContainer $container): ErrorController {
160 return new ErrorController($container);
161 };
162
163 return $container;
164 }
165}
diff --git a/application/container/ShaarliContainer.php b/application/container/ShaarliContainer.php
new file mode 100644
index 00000000..66e669aa
--- /dev/null
+++ b/application/container/ShaarliContainer.php
@@ -0,0 +1,51 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Container;
6
7use Shaarli\Bookmark\BookmarkServiceInterface;
8use Shaarli\Config\ConfigManager;
9use Shaarli\Feed\FeedBuilder;
10use Shaarli\Formatter\FormatterFactory;
11use Shaarli\History;
12use Shaarli\Http\HttpAccess;
13use Shaarli\Netscape\NetscapeBookmarkUtils;
14use Shaarli\Plugin\PluginManager;
15use Shaarli\Render\PageBuilder;
16use Shaarli\Render\PageCacheManager;
17use Shaarli\Security\CookieManager;
18use Shaarli\Security\LoginManager;
19use Shaarli\Security\SessionManager;
20use Shaarli\Thumbnailer;
21use Shaarli\Updater\Updater;
22use Slim\Container;
23
24/**
25 * Extension of Slim container to document the injected objects.
26 *
27 * @property string $basePath Shaarli's instance base path (e.g. `/shaarli/`)
28 * @property BookmarkServiceInterface $bookmarkService
29 * @property CookieManager $cookieManager
30 * @property ConfigManager $conf
31 * @property mixed[] $environment $_SERVER automatically injected by Slim
32 * @property callable $errorHandler Overrides default Slim exception display
33 * @property FeedBuilder $feedBuilder
34 * @property FormatterFactory $formatterFactory
35 * @property History $history
36 * @property HttpAccess $httpAccess
37 * @property LoginManager $loginManager
38 * @property NetscapeBookmarkUtils $netscapeBookmarkUtils
39 * @property callable $notFoundHandler Overrides default Slim exception display
40 * @property PageBuilder $pageBuilder
41 * @property PageCacheManager $pageCacheManager
42 * @property callable $phpErrorHandler Overrides default Slim PHP error display
43 * @property PluginManager $pluginManager
44 * @property SessionManager $sessionManager
45 * @property Thumbnailer $thumbnailer
46 * @property Updater $updater
47 */
48class ShaarliContainer extends Container
49{
50
51}
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 7c859474..f6def630 100644
--- a/application/feed/FeedBuilder.php
+++ b/application/feed/FeedBuilder.php
@@ -2,6 +2,9 @@
2namespace Shaarli\Feed; 2namespace Shaarli\Feed;
3 3
4use DateTime; 4use DateTime;
5use Shaarli\Bookmark\Bookmark;
6use Shaarli\Bookmark\BookmarkServiceInterface;
7use Shaarli\Formatter\BookmarkFormatter;
5 8
6/** 9/**
7 * FeedBuilder class. 10 * FeedBuilder class.
@@ -26,37 +29,30 @@ class FeedBuilder
26 public static $DEFAULT_LANGUAGE = 'en-en'; 29 public static $DEFAULT_LANGUAGE = 'en-en';
27 30
28 /** 31 /**
29 * @var int Number of links to display in a feed by default. 32 * @var int Number of bookmarks to display in a feed by default.
30 */ 33 */
31 public static $DEFAULT_NB_LINKS = 50; 34 public static $DEFAULT_NB_LINKS = 50;
32 35
33 /** 36 /**
34 * @var \Shaarli\Bookmark\LinkDB instance. 37 * @var BookmarkServiceInterface instance.
35 */ 38 */
36 protected $linkDB; 39 protected $linkDB;
37 40
38 /** 41 /**
39 * @var string RSS or ATOM feed. 42 * @var BookmarkFormatter instance.
40 */ 43 */
41 protected $feedType; 44 protected $formatter;
42 45
43 /** 46 /** @var mixed[] $_SERVER */
44 * @var array $_SERVER
45 */
46 protected $serverInfo; 47 protected $serverInfo;
47 48
48 /** 49 /**
49 * @var array $_GET
50 */
51 protected $userInput;
52
53 /**
54 * @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.
55 */ 51 */
56 protected $isLoggedIn; 52 protected $isLoggedIn;
57 53
58 /** 54 /**
59 * @var boolean Use permalinks instead of direct links if true. 55 * @var boolean Use permalinks instead of direct bookmarks if true.
60 */ 56 */
61 protected $usePermalinks; 57 protected $usePermalinks;
62 58
@@ -69,7 +65,6 @@ class FeedBuilder
69 * @var string server locale. 65 * @var string server locale.
70 */ 66 */
71 protected $locale; 67 protected $locale;
72
73 /** 68 /**
74 * @var DateTime Latest item date. 69 * @var DateTime Latest item date.
75 */ 70 */
@@ -78,38 +73,38 @@ class FeedBuilder
78 /** 73 /**
79 * Feed constructor. 74 * Feed constructor.
80 * 75 *
81 * @param \Shaarli\Bookmark\LinkDB $linkDB LinkDB instance. 76 * @param BookmarkServiceInterface $linkDB LinkDB instance.
82 * @param string $feedType Type of feed. 77 * @param BookmarkFormatter $formatter instance.
83 * @param array $serverInfo $_SERVER. 78 * @param array $serverInfo $_SERVER.
84 * @param array $userInput $_GET. 79 * @param boolean $isLoggedIn True if the user is currently logged in, false otherwise.
85 * @param boolean $isLoggedIn True if the user is currently logged in,
86 * false otherwise.
87 */ 80 */
88 public function __construct($linkDB, $feedType, $serverInfo, $userInput, $isLoggedIn) 81 public function __construct($linkDB, $formatter, $serverInfo, $isLoggedIn)
89 { 82 {
90 $this->linkDB = $linkDB; 83 $this->linkDB = $linkDB;
91 $this->feedType = $feedType; 84 $this->formatter = $formatter;
92 $this->serverInfo = $serverInfo; 85 $this->serverInfo = $serverInfo;
93 $this->userInput = $userInput;
94 $this->isLoggedIn = $isLoggedIn; 86 $this->isLoggedIn = $isLoggedIn;
95 } 87 }
96 88
97 /** 89 /**
98 * Build data for feed templates. 90 * Build data for feed templates.
99 * 91 *
92 * @param string $feedType Type of feed (RSS/ATOM).
93 * @param array $userInput $_GET.
94 *
100 * @return array Formatted data for feeds templates. 95 * @return array Formatted data for feeds templates.
101 */ 96 */
102 public function buildData() 97 public function buildData(string $feedType, ?array $userInput)
103 { 98 {
104 // Search for untagged links 99 // Search for untagged bookmarks
105 if (isset($this->userInput['searchtags']) && empty($this->userInput['searchtags'])) { 100 if (isset($this->userInput['searchtags']) && empty($userInput['searchtags'])) {
106 $this->userInput['searchtags'] = false; 101 $userInput['searchtags'] = false;
107 } 102 }
108 103
109 // Optionally filter the results: 104 // Optionally filter the results:
110 $linksToDisplay = $this->linkDB->filterSearch($this->userInput); 105 $linksToDisplay = $this->linkDB->search($userInput, null, false, false, true);
111 106
112 $nblinksToDisplay = $this->getNbLinks(count($linksToDisplay)); 107 $nblinksToDisplay = $this->getNbLinks(count($linksToDisplay), $userInput);
113 108
114 // 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.
115 $keys = array(); 110 $keys = array();
@@ -118,17 +113,18 @@ class FeedBuilder
118 } 113 }
119 114
120 $pageaddr = escape(index_url($this->serverInfo)); 115 $pageaddr = escape(index_url($this->serverInfo));
116 $this->formatter->addContextData('index_url', $pageaddr);
121 $linkDisplayed = array(); 117 $linkDisplayed = array();
122 for ($i = 0; $i < $nblinksToDisplay && $i < count($keys); $i++) { 118 for ($i = 0; $i < $nblinksToDisplay && $i < count($keys); $i++) {
123 $linkDisplayed[$keys[$i]] = $this->buildItem($linksToDisplay[$keys[$i]], $pageaddr); 119 $linkDisplayed[$keys[$i]] = $this->buildItem($feedType, $linksToDisplay[$keys[$i]], $pageaddr);
124 } 120 }
125 121
126 $data['language'] = $this->getTypeLanguage(); 122 $data['language'] = $this->getTypeLanguage($feedType);
127 $data['last_update'] = $this->getLatestDateFormatted(); 123 $data['last_update'] = $this->getLatestDateFormatted($feedType);
128 $data['show_dates'] = !$this->hideDates || $this->isLoggedIn; 124 $data['show_dates'] = !$this->hideDates || $this->isLoggedIn;
129 // Remove leading slash from REQUEST_URI. 125 // Remove leading path from REQUEST_URI (already contained in $pageaddr).
130 $data['self_link'] = escape(server_url($this->serverInfo)) 126 $requestUri = preg_replace('#(.*?/)(feed.*)#', '$2', escape($this->serverInfo['REQUEST_URI']));
131 . escape($this->serverInfo['REQUEST_URI']); 127 $data['self_link'] = $pageaddr . $requestUri;
132 $data['index_url'] = $pageaddr; 128 $data['index_url'] = $pageaddr;
133 $data['usepermalinks'] = $this->usePermalinks === true; 129 $data['usepermalinks'] = $this->usePermalinks === true;
134 $data['links'] = $linkDisplayed; 130 $data['links'] = $linkDisplayed;
@@ -137,56 +133,7 @@ class FeedBuilder
137 } 133 }
138 134
139 /** 135 /**
140 * Build a feed item (one per shaare). 136 * Set this to true to use permalinks instead of direct bookmarks.
141 *
142 * @param array $link Single link array extracted from LinkDB.
143 * @param string $pageaddr Index URL.
144 *
145 * @return array Link array with feed attributes.
146 */
147 protected function buildItem($link, $pageaddr)
148 {
149 $link['guid'] = $pageaddr . '?' . $link['shorturl'];
150 // Prepend the root URL for notes
151 if (is_note($link['url'])) {
152 $link['url'] = $pageaddr . $link['url'];
153 }
154 if ($this->usePermalinks === true) {
155 $permalink = '<a href="' . $link['url'] . '" title="' . t('Direct link') . '">' . t('Direct link') . '</a>';
156 } else {
157 $permalink = '<a href="' . $link['guid'] . '" title="' . t('Permalink') . '">' . t('Permalink') . '</a>';
158 }
159 $link['description'] = format_description($link['description'], $pageaddr);
160 $link['description'] .= PHP_EOL . '<br>&#8212; ' . $permalink;
161
162 $pubDate = $link['created'];
163 $link['pub_iso_date'] = $this->getIsoDate($pubDate);
164
165 // atom:entry elements MUST contain exactly one atom:updated element.
166 if (!empty($link['updated'])) {
167 $upDate = $link['updated'];
168 $link['up_iso_date'] = $this->getIsoDate($upDate, DateTime::ATOM);
169 } else {
170 $link['up_iso_date'] = $this->getIsoDate($pubDate, DateTime::ATOM);
171 }
172
173 // Save the more recent item.
174 if (empty($this->latestDate) || $this->latestDate < $pubDate) {
175 $this->latestDate = $pubDate;
176 }
177 if (!empty($upDate) && $this->latestDate < $upDate) {
178 $this->latestDate = $upDate;
179 }
180
181 $taglist = array_filter(explode(' ', $link['tags']), 'strlen');
182 uasort($taglist, 'strcasecmp');
183 $link['taglist'] = $taglist;
184
185 return $link;
186 }
187
188 /**
189 * Set this to true to use permalinks instead of direct links.
190 * 137 *
191 * @param boolean $usePermalinks true to force permalinks. 138 * @param boolean $usePermalinks true to force permalinks.
192 */ 139 */
@@ -216,21 +163,63 @@ class FeedBuilder
216 } 163 }
217 164
218 /** 165 /**
166 * Build a feed item (one per shaare).
167 *
168 * @param string $feedType Type of feed (RSS/ATOM).
169 * @param Bookmark $link Single link array extracted from LinkDB.
170 * @param string $pageaddr Index URL.
171 *
172 * @return array Link array with feed attributes.
173 */
174 protected function buildItem(string $feedType, $link, $pageaddr)
175 {
176 $data = $this->formatter->format($link);
177 $data['guid'] = rtrim($pageaddr, '/') . '/shaare/' . $data['shorturl'];
178 if ($this->usePermalinks === true) {
179 $permalink = '<a href="'. $data['url'] .'" title="'. t('Direct link') .'">'. t('Direct link') .'</a>';
180 } else {
181 $permalink = '<a href="'. $data['guid'] .'" title="'. t('Permalink') .'">'. t('Permalink') .'</a>';
182 }
183 $data['description'] .= PHP_EOL . PHP_EOL . '<br>&#8212; ' . $permalink;
184
185 $data['pub_iso_date'] = $this->getIsoDate($feedType, $data['created']);
186
187 // atom:entry elements MUST contain exactly one atom:updated element.
188 if (!empty($link->getUpdated())) {
189 $data['up_iso_date'] = $this->getIsoDate($feedType, $data['updated'], DateTime::ATOM);
190 } else {
191 $data['up_iso_date'] = $this->getIsoDate($feedType, $data['created'], DateTime::ATOM);
192 }
193
194 // Save the more recent item.
195 if (empty($this->latestDate) || $this->latestDate < $data['created']) {
196 $this->latestDate = $data['created'];
197 }
198 if (!empty($data['updated']) && $this->latestDate < $data['updated']) {
199 $this->latestDate = $data['updated'];
200 }
201
202 return $data;
203 }
204
205 /**
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);
@@ -273,23 +265,24 @@ class FeedBuilder
273 * Returns the number of link to display according to 'nb' user input parameter. 265 * Returns the number of link to display according to 'nb' user input parameter.
274 * 266 *
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 links (max parameter). 268 * If 'nb' is set to 'all', display all filtered bookmarks (max parameter).
277 * 269 *
278 * @param int $max maximum number of links to display. 270 * @param int $max maximum number of bookmarks to display.
271 * @param array $userInput $_GET.
279 * 272 *
280 * @return int number of links 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
new file mode 100644
index 00000000..9d4a0fa0
--- /dev/null
+++ b/application/formatter/BookmarkDefaultFormatter.php
@@ -0,0 +1,87 @@
1<?php
2
3namespace Shaarli\Formatter;
4
5/**
6 * Class BookmarkDefaultFormatter
7 *
8 * Default bookmark formatter.
9 * Escape values for HTML display and automatically add link to URL and hashtags.
10 *
11 * @package Shaarli\Formatter
12 */
13class BookmarkDefaultFormatter extends BookmarkFormatter
14{
15 /**
16 * @inheritdoc
17 */
18 public function formatTitle($bookmark)
19 {
20 return escape($bookmark->getTitle());
21 }
22
23 /**
24 * @inheritdoc
25 */
26 public function formatDescription($bookmark)
27 {
28 $indexUrl = ! empty($this->contextData['index_url']) ? $this->contextData['index_url'] : '';
29 return format_description(escape($bookmark->getDescription()), $indexUrl);
30 }
31
32 /**
33 * @inheritdoc
34 */
35 protected function formatTagList($bookmark)
36 {
37 return escape(parent::formatTagList($bookmark));
38 }
39
40 /**
41 * @inheritdoc
42 */
43 public function formatTagString($bookmark)
44 {
45 return implode(' ', $this->formatTagList($bookmark));
46 }
47
48 /**
49 * @inheritdoc
50 */
51 public function formatUrl($bookmark)
52 {
53 if ($bookmark->isNote() && isset($this->contextData['index_url'])) {
54 return rtrim($this->contextData['index_url'], '/') . '/' . escape(ltrim($bookmark->getUrl(), '/'));
55 }
56
57 return escape($bookmark->getUrl());
58 }
59
60 /**
61 * @inheritdoc
62 */
63 protected function formatRealUrl($bookmark)
64 {
65 if ($bookmark->isNote()) {
66 if (isset($this->contextData['index_url'])) {
67 $prefix = rtrim($this->contextData['index_url'], '/') . '/';
68 }
69
70 if (isset($this->contextData['base_path'])) {
71 $prefix = rtrim($this->contextData['base_path'], '/') . '/';
72 }
73
74 return escape($prefix ?? '') . escape(ltrim($bookmark->getUrl(), '/'));
75 }
76
77 return escape($bookmark->getUrl());
78 }
79
80 /**
81 * @inheritdoc
82 */
83 protected function formatThumbnail($bookmark)
84 {
85 return escape($bookmark->getThumbnail());
86 }
87}
diff --git a/application/formatter/BookmarkFormatter.php b/application/formatter/BookmarkFormatter.php
new file mode 100644
index 00000000..0042dafe
--- /dev/null
+++ b/application/formatter/BookmarkFormatter.php
@@ -0,0 +1,313 @@
1<?php
2
3namespace Shaarli\Formatter;
4
5use DateTime;
6use Shaarli\Bookmark\Bookmark;
7use Shaarli\Config\ConfigManager;
8
9/**
10 * Class BookmarkFormatter
11 *
12 * Abstract class processing all bookmark attributes through methods designed to be overridden.
13 *
14 * @package Shaarli\Formatter
15 */
16abstract class BookmarkFormatter
17{
18 /**
19 * @var ConfigManager
20 */
21 protected $conf;
22
23 /** @var bool */
24 protected $isLoggedIn;
25
26 /**
27 * @var array Additional parameters than can be used for specific formatting
28 * e.g. index_url for Feed formatting
29 */
30 protected $contextData = [];
31
32 /**
33 * LinkDefaultFormatter constructor.
34 * @param ConfigManager $conf
35 */
36 public function __construct(ConfigManager $conf, bool $isLoggedIn)
37 {
38 $this->conf = $conf;
39 $this->isLoggedIn = $isLoggedIn;
40 }
41
42 /**
43 * Convert a Bookmark into an array usable by templates and plugins.
44 *
45 * All Bookmark attributes are formatted through a format method
46 * that can be overridden in a formatter extending this class.
47 *
48 * @param Bookmark $bookmark instance
49 *
50 * @return array formatted representation of a Bookmark
51 */
52 public function format($bookmark)
53 {
54 $out['id'] = $this->formatId($bookmark);
55 $out['shorturl'] = $this->formatShortUrl($bookmark);
56 $out['url'] = $this->formatUrl($bookmark);
57 $out['real_url'] = $this->formatRealUrl($bookmark);
58 $out['title'] = $this->formatTitle($bookmark);
59 $out['description'] = $this->formatDescription($bookmark);
60 $out['thumbnail'] = $this->formatThumbnail($bookmark);
61 $out['urlencoded_taglist'] = $this->formatUrlEncodedTagList($bookmark);
62 $out['taglist'] = $this->formatTagList($bookmark);
63 $out['urlencoded_tags'] = $this->formatUrlEncodedTagString($bookmark);
64 $out['tags'] = $this->formatTagString($bookmark);
65 $out['sticky'] = $bookmark->isSticky();
66 $out['private'] = $bookmark->isPrivate();
67 $out['class'] = $this->formatClass($bookmark);
68 $out['created'] = $this->formatCreated($bookmark);
69 $out['updated'] = $this->formatUpdated($bookmark);
70 $out['timestamp'] = $this->formatCreatedTimestamp($bookmark);
71 $out['updated_timestamp'] = $this->formatUpdatedTimestamp($bookmark);
72 return $out;
73 }
74
75 /**
76 * Add additional data available to formatters.
77 * This is used for example to add `index_url` in description's links.
78 *
79 * @param string $key Context data key
80 * @param string $value Context data value
81 */
82 public function addContextData($key, $value)
83 {
84 $this->contextData[$key] = $value;
85
86 return $this;
87 }
88
89 /**
90 * Format ID
91 *
92 * @param Bookmark $bookmark instance
93 *
94 * @return int formatted ID
95 */
96 protected function formatId($bookmark)
97 {
98 return $bookmark->getId();
99 }
100
101 /**
102 * Format ShortUrl
103 *
104 * @param Bookmark $bookmark instance
105 *
106 * @return string formatted ShortUrl
107 */
108 protected function formatShortUrl($bookmark)
109 {
110 return $bookmark->getShortUrl();
111 }
112
113 /**
114 * Format Url
115 *
116 * @param Bookmark $bookmark instance
117 *
118 * @return string formatted Url
119 */
120 protected function formatUrl($bookmark)
121 {
122 return $bookmark->getUrl();
123 }
124
125 /**
126 * Format RealUrl
127 * Legacy: identical to Url
128 *
129 * @param Bookmark $bookmark instance
130 *
131 * @return string formatted RealUrl
132 */
133 protected function formatRealUrl($bookmark)
134 {
135 return $this->formatUrl($bookmark);
136 }
137
138 /**
139 * Format Title
140 *
141 * @param Bookmark $bookmark instance
142 *
143 * @return string formatted Title
144 */
145 protected function formatTitle($bookmark)
146 {
147 return $bookmark->getTitle();
148 }
149
150 /**
151 * Format Description
152 *
153 * @param Bookmark $bookmark instance
154 *
155 * @return string formatted Description
156 */
157 protected function formatDescription($bookmark)
158 {
159 return $bookmark->getDescription();
160 }
161
162 /**
163 * Format Thumbnail
164 *
165 * @param Bookmark $bookmark instance
166 *
167 * @return string formatted Thumbnail
168 */
169 protected function formatThumbnail($bookmark)
170 {
171 return $bookmark->getThumbnail();
172 }
173
174 /**
175 * Format Tags
176 *
177 * @param Bookmark $bookmark instance
178 *
179 * @return array formatted Tags
180 */
181 protected function formatTagList($bookmark)
182 {
183 return $this->filterTagList($bookmark->getTags());
184 }
185
186 /**
187 * Format Url Encoded Tags
188 *
189 * @param Bookmark $bookmark instance
190 *
191 * @return array formatted Tags
192 */
193 protected function formatUrlEncodedTagList($bookmark)
194 {
195 return array_map('urlencode', $this->filterTagList($bookmark->getTags()));
196 }
197
198 /**
199 * Format TagString
200 *
201 * @param Bookmark $bookmark instance
202 *
203 * @return string formatted TagString
204 */
205 protected function formatTagString($bookmark)
206 {
207 return implode(' ', $this->formatTagList($bookmark));
208 }
209
210 /**
211 * Format TagString
212 *
213 * @param Bookmark $bookmark instance
214 *
215 * @return string formatted TagString
216 */
217 protected function formatUrlEncodedTagString($bookmark)
218 {
219 return implode(' ', $this->formatUrlEncodedTagList($bookmark));
220 }
221
222 /**
223 * Format Class
224 * Used to add specific CSS class for a link
225 *
226 * @param Bookmark $bookmark instance
227 *
228 * @return string formatted Class
229 */
230 protected function formatClass($bookmark)
231 {
232 return $bookmark->isPrivate() ? 'private' : '';
233 }
234
235 /**
236 * Format Created
237 *
238 * @param Bookmark $bookmark instance
239 *
240 * @return DateTime instance
241 */
242 protected function formatCreated(Bookmark $bookmark)
243 {
244 return $bookmark->getCreated();
245 }
246
247 /**
248 * Format Updated
249 *
250 * @param Bookmark $bookmark instance
251 *
252 * @return DateTime instance
253 */
254 protected function formatUpdated(Bookmark $bookmark)
255 {
256 return $bookmark->getUpdated();
257 }
258
259 /**
260 * Format CreatedTimestamp
261 *
262 * @param Bookmark $bookmark instance
263 *
264 * @return int formatted CreatedTimestamp
265 */
266 protected function formatCreatedTimestamp(Bookmark $bookmark)
267 {
268 if (! empty($bookmark->getCreated())) {
269 return $bookmark->getCreated()->getTimestamp();
270 }
271 return 0;
272 }
273
274 /**
275 * Format UpdatedTimestamp
276 *
277 * @param Bookmark $bookmark instance
278 *
279 * @return int formatted UpdatedTimestamp
280 */
281 protected function formatUpdatedTimestamp(Bookmark $bookmark)
282 {
283 if (! empty($bookmark->getUpdated())) {
284 return $bookmark->getUpdated()->getTimestamp();
285 }
286 return 0;
287 }
288
289 /**
290 * Format tag list, e.g. remove private tags if the user is not logged in.
291 *
292 * @param array $tags
293 *
294 * @return array
295 */
296 protected function filterTagList(array $tags): array
297 {
298 if ($this->isLoggedIn === true) {
299 return $tags;
300 }
301
302 $out = [];
303 foreach ($tags as $tag) {
304 if (strpos($tag, '.') === 0) {
305 continue;
306 }
307
308 $out[] = $tag;
309 }
310
311 return $out;
312 }
313}
diff --git a/application/formatter/BookmarkMarkdownFormatter.php b/application/formatter/BookmarkMarkdownFormatter.php
new file mode 100644
index 00000000..5d244d4c
--- /dev/null
+++ b/application/formatter/BookmarkMarkdownFormatter.php
@@ -0,0 +1,206 @@
1<?php
2
3namespace Shaarli\Formatter;
4
5use Shaarli\Config\ConfigManager;
6
7/**
8 * Class BookmarkMarkdownFormatter
9 *
10 * Format bookmark description into Markdown format.
11 *
12 * @package Shaarli\Formatter
13 */
14class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter
15{
16 /**
17 * When this tag is present in a bookmark, its description should not be processed with Markdown
18 */
19 const NO_MD_TAG = 'nomarkdown';
20
21 /** @var \Parsedown instance */
22 protected $parsedown;
23
24 /** @var bool used to escape HTML in Markdown or not.
25 * It MUST be set to true for shared instance as HTML content can
26 * introduce XSS vulnerabilities.
27 */
28 protected $escape;
29
30 /**
31 * @var array List of allowed protocols for links inside bookmark's description.
32 */
33 protected $allowedProtocols;
34
35 /**
36 * LinkMarkdownFormatter constructor.
37 *
38 * @param ConfigManager $conf instance
39 * @param bool $isLoggedIn
40 */
41 public function __construct(ConfigManager $conf, bool $isLoggedIn)
42 {
43 parent::__construct($conf, $isLoggedIn);
44
45 $this->parsedown = new \Parsedown();
46 $this->escape = $conf->get('security.markdown_escape', true);
47 $this->allowedProtocols = $conf->get('security.allowed_protocols', []);
48 }
49
50 /**
51 * @inheritdoc
52 */
53 public function formatDescription($bookmark)
54 {
55 if (in_array(self::NO_MD_TAG, $bookmark->getTags())) {
56 return parent::formatDescription($bookmark);
57 }
58
59 $processedDescription = $bookmark->getDescription();
60 $processedDescription = $this->filterProtocols($processedDescription);
61 $processedDescription = $this->formatHashTags($processedDescription);
62 $processedDescription = $this->reverseEscapedHtml($processedDescription);
63 $processedDescription = $this->parsedown
64 ->setMarkupEscaped($this->escape)
65 ->setBreaksEnabled(true)
66 ->text($processedDescription);
67 $processedDescription = $this->sanitizeHtml($processedDescription);
68
69 if (!empty($processedDescription)) {
70 $processedDescription = '<div class="markdown">'. $processedDescription . '</div>';
71 }
72
73 return $processedDescription;
74 }
75
76 /**
77 * Remove the NO markdown tag if it is present
78 *
79 * @inheritdoc
80 */
81 protected function formatTagList($bookmark)
82 {
83 $out = parent::formatTagList($bookmark);
84 if ($this->isLoggedIn === false && ($pos = array_search(self::NO_MD_TAG, $out)) !== false) {
85 unset($out[$pos]);
86 return array_values($out);
87 }
88 return $out;
89 }
90
91 /**
92 * Replace not whitelisted protocols with http:// in given description.
93 * Also adds `index_url` to relative links if it's specified
94 *
95 * @param string $description input description text.
96 *
97 * @return string $description without malicious link.
98 */
99 protected function filterProtocols($description)
100 {
101 $allowedProtocols = $this->allowedProtocols;
102 $indexUrl = ! empty($this->contextData['index_url']) ? $this->contextData['index_url'] : '';
103
104 return preg_replace_callback(
105 '#]\((.*?)\)#is',
106 function ($match) use ($allowedProtocols, $indexUrl) {
107 $link = startsWith($match[1], '?') || startsWith($match[1], '/') ? $indexUrl : '';
108 $link .= whitelist_protocols($match[1], $allowedProtocols);
109 return ']('. $link.')';
110 },
111 $description
112 );
113 }
114
115 /**
116 * Replace hashtag in Markdown links format
117 * E.g. `#hashtag` becomes `[#hashtag](./add-tag/hashtag)`
118 * It includes the index URL if specified.
119 *
120 * @param string $description
121 *
122 * @return string
123 */
124 protected function formatHashTags($description)
125 {
126 $indexUrl = ! empty($this->contextData['index_url']) ? $this->contextData['index_url'] : '';
127
128 /*
129 * To support unicode: http://stackoverflow.com/a/35498078/1484919
130 * \p{Pc} - to match underscore
131 * \p{N} - numeric character in any script
132 * \p{L} - letter from any language
133 * \p{Mn} - any non marking space (accents, umlauts, etc)
134 */
135 $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui';
136 $replacement = '$1[#$2]('. $indexUrl .'./add-tag/$2)';
137
138 $descriptionLines = explode(PHP_EOL, $description);
139 $descriptionOut = '';
140 $codeBlockOn = false;
141 $lineCount = 0;
142
143 foreach ($descriptionLines as $descriptionLine) {
144 // Detect line of code: starting with 4 spaces,
145 // except lists which can start with +/*/- or `2.` after spaces.
146 $codeLineOn = preg_match('/^ +(?=[^\+\*\-])(?=(?!\d\.).)/', $descriptionLine) > 0;
147 // Detect and toggle block of code
148 if (!$codeBlockOn) {
149 $codeBlockOn = preg_match('/^```/', $descriptionLine) > 0;
150 } elseif (preg_match('/^```/', $descriptionLine) > 0) {
151 $codeBlockOn = false;
152 }
153
154 if (!$codeBlockOn && !$codeLineOn) {
155 $descriptionLine = preg_replace($regex, $replacement, $descriptionLine);
156 }
157
158 $descriptionOut .= $descriptionLine;
159 if ($lineCount++ < count($descriptionLines) - 1) {
160 $descriptionOut .= PHP_EOL;
161 }
162 }
163
164 return $descriptionOut;
165 }
166
167 /**
168 * Remove dangerous HTML tags (tags, iframe, etc.).
169 * Doesn't affect <code> content (already escaped by Parsedown).
170 *
171 * @param string $description input description text.
172 *
173 * @return string given string escaped.
174 */
175 protected function sanitizeHtml($description)
176 {
177 $escapeTags = array(
178 'script',
179 'style',
180 'link',
181 'iframe',
182 'frameset',
183 'frame',
184 );
185 foreach ($escapeTags as $tag) {
186 $description = preg_replace_callback(
187 '#<\s*'. $tag .'[^>]*>(.*</\s*'. $tag .'[^>]*>)?#is',
188 function ($match) {
189 return escape($match[0]);
190 },
191 $description
192 );
193 }
194 $description = preg_replace(
195 '#(<[^>]+\s)on[a-z]*="?[^ "]*"?#is',
196 '$1',
197 $description
198 );
199 return $description;
200 }
201
202 protected function reverseEscapedHtml($description)
203 {
204 return unescape($description);
205 }
206}
diff --git a/application/formatter/BookmarkRawFormatter.php b/application/formatter/BookmarkRawFormatter.php
new file mode 100644
index 00000000..bc372273
--- /dev/null
+++ b/application/formatter/BookmarkRawFormatter.php
@@ -0,0 +1,13 @@
1<?php
2
3namespace Shaarli\Formatter;
4
5/**
6 * Class BookmarkRawFormatter
7 *
8 * Used to retrieve bookmarks as array with raw values.
9 * Warning: Do NOT use this for HTML content as it can introduce XSS vulnerabilities.
10 *
11 * @package Shaarli\Formatter
12 */
13class BookmarkRawFormatter extends BookmarkFormatter {}
diff --git a/application/formatter/FormatterFactory.php b/application/formatter/FormatterFactory.php
new file mode 100644
index 00000000..a029579f
--- /dev/null
+++ b/application/formatter/FormatterFactory.php
@@ -0,0 +1,51 @@
1<?php
2
3namespace Shaarli\Formatter;
4
5use Shaarli\Config\ConfigManager;
6
7/**
8 * Class FormatterFactory
9 *
10 * Helper class used to instantiate the proper BookmarkFormatter.
11 *
12 * @package Shaarli\Formatter
13 */
14class FormatterFactory
15{
16 /** @var ConfigManager instance */
17 protected $conf;
18
19 /** @var bool */
20 protected $isLoggedIn;
21
22 /**
23 * FormatterFactory constructor.
24 *
25 * @param ConfigManager $conf
26 * @param bool $isLoggedIn
27 */
28 public function __construct(ConfigManager $conf, bool $isLoggedIn)
29 {
30 $this->conf = $conf;
31 $this->isLoggedIn = $isLoggedIn;
32 }
33
34 /**
35 * Instanciate a BookmarkFormatter depending on the configuration or provided formatter type.
36 *
37 * @param string|null $type force a specific type regardless of the configuration
38 *
39 * @return BookmarkFormatter instance.
40 */
41 public function getFormatter(string $type = null): BookmarkFormatter
42 {
43 $type = $type ? $type : $this->conf->get('formatter', 'default');
44 $className = '\\Shaarli\\Formatter\\Bookmark'. ucfirst($type) .'Formatter';
45 if (!class_exists($className)) {
46 $className = '\\Shaarli\\Formatter\\BookmarkDefaultFormatter';
47 }
48
49 return new $className($this->conf, $this->isLoggedIn);
50 }
51}
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
new file mode 100644
index 00000000..d1aa1399
--- /dev/null
+++ b/application/front/ShaarliMiddleware.php
@@ -0,0 +1,114 @@
1<?php
2
3namespace Shaarli\Front;
4
5use Shaarli\Container\ShaarliContainer;
6use Shaarli\Front\Exception\UnauthorizedException;
7use Slim\Http\Request;
8use Slim\Http\Response;
9
10/**
11 * Class ShaarliMiddleware
12 *
13 * This will be called before accessing any Shaarli controller.
14 */
15class ShaarliMiddleware
16{
17 /** @var ShaarliContainer contains all Shaarli DI */
18 protected $container;
19
20 public function __construct(ShaarliContainer $container)
21 {
22 $this->container = $container;
23 }
24
25 /**
26 * Middleware execution:
27 * - run updates
28 * - if not logged in open shaarli, redirect to login
29 * - execute the controller
30 * - return the response
31 *
32 * In case of error, the error template will be displayed with the exception message.
33 *
34 * @param Request $request Slim request
35 * @param Response $response Slim response
36 * @param callable $next Next action
37 *
38 * @return Response response.
39 */
40 public function __invoke(Request $request, Response $response, callable $next): Response
41 {
42 $this->initBasePath($request);
43
44 try {
45 if (!is_file($this->container->conf->getConfigFileExt())
46 && !in_array($next->getName(), ['displayInstall', 'saveInstall'], true)
47 ) {
48 return $response->withRedirect($this->container->basePath . '/install');
49 }
50
51 $this->runUpdates();
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();
100 }
101
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 }
113 }
114}
diff --git a/application/front/controller/admin/ConfigureController.php b/application/front/controller/admin/ConfigureController.php
new file mode 100644
index 00000000..e675fcca
--- /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']);
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/ManageShaareController.php b/application/front/controller/admin/ManageShaareController.php
new file mode 100644
index 00000000..bb083486
--- /dev/null
+++ b/application/front/controller/admin/ManageShaareController.php
@@ -0,0 +1,371 @@
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\BookmarkMarkdownFormatter;
10use Shaarli\Render\TemplatePage;
11use Shaarli\Thumbnailer;
12use Slim\Http\Request;
13use Slim\Http\Response;
14
15/**
16 * Class PostBookmarkController
17 *
18 * Slim controller used to handle Shaarli create or edit bookmarks.
19 */
20class ManageShaareController extends ShaarliAdminController
21{
22 /**
23 * GET /admin/add-shaare - Displays the form used to create a new bookmark from an URL
24 */
25 public function addShaare(Request $request, Response $response): Response
26 {
27 $this->assignView(
28 'pagetitle',
29 t('Shaare a new link') .' - '. $this->container->conf->get('general.title', 'Shaarli')
30 );
31
32 return $response->write($this->render(TemplatePage::ADDLINK));
33 }
34
35 /**
36 * GET /admin/shaare - Displays the bookmark form for creation.
37 * Note that if the URL is found in existing bookmarks, then it will be in edit mode.
38 */
39 public function displayCreateForm(Request $request, Response $response): Response
40 {
41 $url = cleanup_url($request->getParam('post'));
42
43 $linkIsNew = false;
44 // Check if URL is not already in database (in this case, we will edit the existing link)
45 $bookmark = $this->container->bookmarkService->findByUrl($url);
46 if (null === $bookmark) {
47 $linkIsNew = true;
48 // Get shaare data if it was provided in URL (e.g.: by the bookmarklet).
49 $title = $request->getParam('title');
50 $description = $request->getParam('description');
51 $tags = $request->getParam('tags');
52 $private = filter_var($request->getParam('private'), FILTER_VALIDATE_BOOLEAN);
53
54 // If this is an HTTP(S) link, we try go get the page to extract
55 // the title (otherwise we will to straight to the edit form.)
56 if (empty($title) && strpos(get_url_scheme($url) ?: '', 'http') !== false) {
57 $retrieveDescription = $this->container->conf->get('general.retrieve_description');
58 // Short timeout to keep the application responsive
59 // The callback will fill $charset and $title with data from the downloaded page.
60 $this->container->httpAccess->getHttpResponse(
61 $url,
62 $this->container->conf->get('general.download_timeout', 30),
63 $this->container->conf->get('general.download_max_size', 4194304),
64 $this->container->httpAccess->getCurlDownloadCallback(
65 $charset,
66 $title,
67 $description,
68 $tags,
69 $retrieveDescription
70 )
71 );
72 if (! empty($title) && strtolower($charset) !== 'utf-8' && mb_check_encoding($charset)) {
73 $title = mb_convert_encoding($title, 'utf-8', $charset);
74 }
75 }
76
77 if (empty($url) && empty($title)) {
78 $title = $this->container->conf->get('general.default_note_title', t('Note: '));
79 }
80
81 $link = [
82 'title' => $title,
83 'url' => $url ?? '',
84 'description' => $description ?? '',
85 'tags' => $tags ?? '',
86 'private' => $private,
87 ];
88 } else {
89 $formatter = $this->container->formatterFactory->getFormatter('raw');
90 $link = $formatter->format($bookmark);
91 }
92
93 return $this->displayForm($link, $linkIsNew, $request, $response);
94 }
95
96 /**
97 * GET /admin/shaare/{id} - Displays the bookmark form in edition mode.
98 */
99 public function displayEditForm(Request $request, Response $response, array $args): Response
100 {
101 $id = $args['id'] ?? '';
102 try {
103 if (false === ctype_digit($id)) {
104 throw new BookmarkNotFoundException();
105 }
106 $bookmark = $this->container->bookmarkService->get((int) $id); // Read database
107 } catch (BookmarkNotFoundException $e) {
108 $this->saveErrorMessage(sprintf(
109 t('Bookmark with identifier %s could not be found.'),
110 $id
111 ));
112
113 return $this->redirect($response, '/');
114 }
115
116 $formatter = $this->container->formatterFactory->getFormatter('raw');
117 $link = $formatter->format($bookmark);
118
119 return $this->displayForm($link, false, $request, $response);
120 }
121
122 /**
123 * POST /admin/shaare
124 */
125 public function save(Request $request, Response $response): Response
126 {
127 $this->checkToken($request);
128
129 // lf_id should only be present if the link exists.
130 $id = $request->getParam('lf_id') !== null ? intval(escape($request->getParam('lf_id'))) : null;
131 if (null !== $id && true === $this->container->bookmarkService->exists($id)) {
132 // Edit
133 $bookmark = $this->container->bookmarkService->get($id);
134 } else {
135 // New link
136 $bookmark = new Bookmark();
137 }
138
139 $bookmark->setTitle($request->getParam('lf_title'));
140 $bookmark->setDescription($request->getParam('lf_description'));
141 $bookmark->setUrl($request->getParam('lf_url'), $this->container->conf->get('security.allowed_protocols', []));
142 $bookmark->setPrivate(filter_var($request->getParam('lf_private'), FILTER_VALIDATE_BOOLEAN));
143 $bookmark->setTagsString($request->getParam('lf_tags'));
144
145 if ($this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE
146 && false === $bookmark->isNote()
147 ) {
148 $bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl()));
149 }
150 $this->container->bookmarkService->addOrSet($bookmark, false);
151
152 // To preserve backward compatibility with 3rd parties, plugins still use arrays
153 $formatter = $this->container->formatterFactory->getFormatter('raw');
154 $data = $formatter->format($bookmark);
155 $this->executePageHooks('save_link', $data);
156
157 $bookmark->fromArray($data);
158 $this->container->bookmarkService->set($bookmark);
159
160 // If we are called from the bookmarklet, we must close the popup:
161 if ($request->getParam('source') === 'bookmarklet') {
162 return $response->write('<script>self.close();</script>');
163 }
164
165 if (!empty($request->getParam('returnurl'))) {
166 $this->container->environment['HTTP_REFERER'] = escape($request->getParam('returnurl'));
167 }
168
169 return $this->redirectFromReferer(
170 $request,
171 $response,
172 ['/admin/add-shaare', '/admin/shaare'], ['addlink', 'post', 'edit_link'],
173 $bookmark->getShortUrl()
174 );
175 }
176
177 /**
178 * GET /admin/shaare/delete - Delete one or multiple bookmarks (depending on `id` query parameter).
179 */
180 public function deleteBookmark(Request $request, Response $response): Response
181 {
182 $this->checkToken($request);
183
184 $ids = escape(trim($request->getParam('id') ?? ''));
185 if (empty($ids) || strpos($ids, ' ') !== false) {
186 // multiple, space-separated ids provided
187 $ids = array_values(array_filter(preg_split('/\s+/', $ids), 'ctype_digit'));
188 } else {
189 $ids = [$ids];
190 }
191
192 // assert at least one id is given
193 if (0 === count($ids)) {
194 $this->saveErrorMessage(t('Invalid bookmark ID provided.'));
195
196 return $this->redirectFromReferer($request, $response, [], ['delete-shaare']);
197 }
198
199 $formatter = $this->container->formatterFactory->getFormatter('raw');
200 $count = 0;
201 foreach ($ids as $id) {
202 try {
203 $bookmark = $this->container->bookmarkService->get((int) $id);
204 } catch (BookmarkNotFoundException $e) {
205 $this->saveErrorMessage(sprintf(
206 t('Bookmark with identifier %s could not be found.'),
207 $id
208 ));
209
210 continue;
211 }
212
213 $data = $formatter->format($bookmark);
214 $this->executePageHooks('delete_link', $data);
215 $this->container->bookmarkService->remove($bookmark, false);
216 ++ $count;
217 }
218
219 if ($count > 0) {
220 $this->container->bookmarkService->save();
221 }
222
223 // If we are called from the bookmarklet, we must close the popup:
224 if ($request->getParam('source') === 'bookmarklet') {
225 return $response->write('<script>self.close();</script>');
226 }
227
228 // Don't redirect to where we were previously because the datastore has changed.
229 return $this->redirect($response, '/');
230 }
231
232 /**
233 * GET /admin/shaare/visibility
234 *
235 * Change visibility (public/private) of one or multiple bookmarks (depending on `id` query parameter).
236 */
237 public function changeVisibility(Request $request, Response $response): Response
238 {
239 $this->checkToken($request);
240
241 $ids = trim(escape($request->getParam('id') ?? ''));
242 if (empty($ids) || strpos($ids, ' ') !== false) {
243 // multiple, space-separated ids provided
244 $ids = array_values(array_filter(preg_split('/\s+/', $ids), 'ctype_digit'));
245 } else {
246 // only a single id provided
247 $ids = [$ids];
248 }
249
250 // assert at least one id is given
251 if (0 === count($ids)) {
252 $this->saveErrorMessage(t('Invalid bookmark ID provided.'));
253
254 return $this->redirectFromReferer($request, $response, [], ['change_visibility']);
255 }
256
257 // assert that the visibility is valid
258 $visibility = $request->getParam('newVisibility');
259 if (null === $visibility || false === in_array($visibility, ['public', 'private'], true)) {
260 $this->saveErrorMessage(t('Invalid visibility provided.'));
261
262 return $this->redirectFromReferer($request, $response, [], ['change_visibility']);
263 } else {
264 $isPrivate = $visibility === 'private';
265 }
266
267 $formatter = $this->container->formatterFactory->getFormatter('raw');
268 $count = 0;
269
270 foreach ($ids as $id) {
271 try {
272 $bookmark = $this->container->bookmarkService->get((int) $id);
273 } catch (BookmarkNotFoundException $e) {
274 $this->saveErrorMessage(sprintf(
275 t('Bookmark with identifier %s could not be found.'),
276 $id
277 ));
278
279 continue;
280 }
281
282 $bookmark->setPrivate($isPrivate);
283
284 // To preserve backward compatibility with 3rd parties, plugins still use arrays
285 $data = $formatter->format($bookmark);
286 $this->executePageHooks('save_link', $data);
287 $bookmark->fromArray($data);
288
289 $this->container->bookmarkService->set($bookmark, false);
290 ++$count;
291 }
292
293 if ($count > 0) {
294 $this->container->bookmarkService->save();
295 }
296
297 return $this->redirectFromReferer($request, $response, ['/visibility'], ['change_visibility']);
298 }
299
300 /**
301 * GET /admin/shaare/{id}/pin - Pin or unpin a bookmark.
302 */
303 public function pinBookmark(Request $request, Response $response, array $args): Response
304 {
305 $this->checkToken($request);
306
307 $id = $args['id'] ?? '';
308 try {
309 if (false === ctype_digit($id)) {
310 throw new BookmarkNotFoundException();
311 }
312 $bookmark = $this->container->bookmarkService->get((int) $id); // Read database
313 } catch (BookmarkNotFoundException $e) {
314 $this->saveErrorMessage(sprintf(
315 t('Bookmark with identifier %s could not be found.'),
316 $id
317 ));
318
319 return $this->redirectFromReferer($request, $response, ['/pin'], ['pin']);
320 }
321
322 $formatter = $this->container->formatterFactory->getFormatter('raw');
323
324 $bookmark->setSticky(!$bookmark->isSticky());
325
326 // To preserve backward compatibility with 3rd parties, plugins still use arrays
327 $data = $formatter->format($bookmark);
328 $this->executePageHooks('save_link', $data);
329 $bookmark->fromArray($data);
330
331 $this->container->bookmarkService->set($bookmark);
332
333 return $this->redirectFromReferer($request, $response, ['/pin'], ['pin']);
334 }
335
336 /**
337 * Helper function used to display the shaare form whether it's a new or existing bookmark.
338 *
339 * @param array $link data used in template, either from parameters or from the data store
340 */
341 protected function displayForm(array $link, bool $isNew, Request $request, Response $response): Response
342 {
343 $tags = $this->container->bookmarkService->bookmarksCountPerTag();
344 if ($this->container->conf->get('formatter') === 'markdown') {
345 $tags[BookmarkMarkdownFormatter::NO_MD_TAG] = 1;
346 }
347
348 $data = escape([
349 'link' => $link,
350 'link_is_new' => $isNew,
351 'http_referer' => $this->container->environment['HTTP_REFERER'] ?? '',
352 'source' => $request->getParam('source') ?? '',
353 'tags' => $tags,
354 'default_private_links' => $this->container->conf->get('privacy.default_private_links', false),
355 ]);
356
357 $this->executePageHooks('render_editlink', $data, TemplatePage::EDIT_LINK);
358
359 foreach ($data as $key => $value) {
360 $this->assignView($key, $value);
361 }
362
363 $editLabel = false === $isNew ? t('Edit') .' ' : '';
364 $this->assignView(
365 'pagetitle',
366 $editLabel . t('Shaare') .' - '. $this->container->conf->get('general.title', 'Shaarli')
367 );
368
369 return $response->write($this->render(TemplatePage::EDIT_LINK));
370 }
371}
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/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/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/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..81c87ed0
--- /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($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..18368751
--- /dev/null
+++ b/application/front/controller/visitor/BookmarkListController.php
@@ -0,0 +1,241 @@
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 try {
141 $bookmark = $this->container->bookmarkService->findByHash($args['hash']);
142 } catch (BookmarkNotFoundException $e) {
143 $this->assignView('error_message', $e->getMessage());
144
145 return $response->write($this->render(TemplatePage::ERROR_404));
146 }
147
148 $this->updateThumbnail($bookmark);
149
150 $formatter = $this->container->formatterFactory->getFormatter();
151 $formatter->addContextData('base_path', $this->container->basePath);
152
153 $data = array_merge(
154 $this->initializeTemplateVars(),
155 [
156 'pagetitle' => $bookmark->getTitle() .' - '. $this->container->conf->get('general.title', 'Shaarli'),
157 'links' => [$formatter->format($bookmark)],
158 ]
159 );
160
161 $this->executePageHooks('render_linklist', $data, TemplatePage::LINKLIST);
162 $this->assignAllView($data);
163
164 return $response->write($this->render(TemplatePage::LINKLIST));
165 }
166
167 /**
168 * Update the thumbnail of a single bookmark if necessary.
169 */
170 protected function updateThumbnail(Bookmark $bookmark, bool $writeDatastore = true): bool
171 {
172 // Logged in, thumbnails enabled, not a note, is HTTP
173 // and (never retrieved yet or no valid cache file)
174 if ($this->container->loginManager->isLoggedIn()
175 && $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE
176 && false !== $bookmark->getThumbnail()
177 && !$bookmark->isNote()
178 && (null === $bookmark->getThumbnail() || !is_file($bookmark->getThumbnail()))
179 && startsWith(strtolower($bookmark->getUrl()), 'http')
180 ) {
181 $bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl()));
182 $this->container->bookmarkService->set($bookmark, $writeDatastore);
183
184 return true;
185 }
186
187 return false;
188 }
189
190 /**
191 * @return string[] Default template variables without values.
192 */
193 protected function initializeTemplateVars(): array
194 {
195 return [
196 'previous_page_url' => '',
197 'next_page_url' => '',
198 'page_max' => '',
199 'search_tags' => '',
200 'result_count' => '',
201 ];
202 }
203
204 /**
205 * Process legacy routes if necessary. They used query parameters.
206 * If no legacy routes is passed, return null.
207 */
208 protected function processLegacyController(Request $request, Response $response): ?Response
209 {
210 // Legacy smallhash filter
211 $queryString = $this->container->environment['QUERY_STRING'] ?? null;
212 if (null !== $queryString && 1 === preg_match('/^([a-zA-Z0-9-_@]{6})($|&|#)/', $queryString, $match)) {
213 return $this->redirect($response, '/shaare/' . $match[1]);
214 }
215
216 // Legacy controllers (mostly used for redirections)
217 if (null !== $request->getQueryParam('do')) {
218 $legacyController = new LegacyController($this->container);
219
220 try {
221 return $legacyController->process($request, $response, $request->getQueryParam('do'));
222 } catch (UnknowLegacyRouteException $e) {
223 // We ignore legacy 404
224 return null;
225 }
226 }
227
228 // Legacy GET admin routes
229 $legacyGetRoutes = array_intersect(
230 LegacyController::LEGACY_GET_ROUTES,
231 array_keys($request->getQueryParams() ?? [])
232 );
233 if (1 === count($legacyGetRoutes)) {
234 $legacyController = new LegacyController($this->container);
235
236 return $legacyController->process($request, $response, $legacyGetRoutes[0]);
237 }
238
239 return null;
240 }
241}
diff --git a/application/front/controller/visitor/DailyController.php b/application/front/controller/visitor/DailyController.php
new file mode 100644
index 00000000..07617cf1
--- /dev/null
+++ b/application/front/controller/visitor/DailyController.php
@@ -0,0 +1,192 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Visitor;
6
7use DateTime;
8use DateTimeImmutable;
9use Shaarli\Bookmark\Bookmark;
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 $day = $request->getQueryParam('day') ?? date('Ymd');
30
31 $availableDates = $this->container->bookmarkService->days();
32 $nbAvailableDates = count($availableDates);
33 $index = array_search($day, $availableDates);
34
35 if ($index === false) {
36 // no bookmarks for day, but at least one day with bookmarks
37 $day = $availableDates[$nbAvailableDates - 1] ?? $day;
38 $previousDay = $availableDates[$nbAvailableDates - 2] ?? '';
39 } else {
40 $previousDay = $availableDates[$index - 1] ?? '';
41 $nextDay = $availableDates[$index + 1] ?? '';
42 }
43
44 if ($day === date('Ymd')) {
45 $this->assignView('dayDesc', t('Today'));
46 } elseif ($day === date('Ymd', strtotime('-1 days'))) {
47 $this->assignView('dayDesc', t('Yesterday'));
48 }
49
50 try {
51 $linksToDisplay = $this->container->bookmarkService->filterDay($day);
52 } catch (\Exception $exc) {
53 $linksToDisplay = [];
54 }
55
56 $formatter = $this->container->formatterFactory->getFormatter();
57 $formatter->addContextData('base_path', $this->container->basePath);
58 // We pre-format some fields for proper output.
59 foreach ($linksToDisplay as $key => $bookmark) {
60 $linksToDisplay[$key] = $formatter->format($bookmark);
61 // This page is a bit specific, we need raw description to calculate the length
62 $linksToDisplay[$key]['formatedDescription'] = $linksToDisplay[$key]['description'];
63 $linksToDisplay[$key]['description'] = $bookmark->getDescription();
64 }
65
66 $dayDate = DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, $day.'_000000');
67 $data = [
68 'linksToDisplay' => $linksToDisplay,
69 'day' => $dayDate->getTimestamp(),
70 'dayDate' => $dayDate,
71 'previousday' => $previousDay ?? '',
72 'nextday' => $nextDay ?? '',
73 ];
74
75 // Hooks are called before column construction so that plugins don't have to deal with columns.
76 $this->executePageHooks('render_daily', $data, TemplatePage::DAILY);
77
78 $data['cols'] = $this->calculateColumns($data['linksToDisplay']);
79
80 $this->assignAllView($data);
81
82 $mainTitle = $this->container->conf->get('general.title', 'Shaarli');
83 $this->assignView(
84 'pagetitle',
85 t('Daily') .' - '. format_date($dayDate, false) . ' - ' . $mainTitle
86 );
87
88 return $response->write($this->render(TemplatePage::DAILY));
89 }
90
91 /**
92 * Daily RSS feed: 1 RSS entry per day giving all the bookmarks on that day.
93 * Gives the last 7 days (which have bookmarks).
94 * This RSS feed cannot be filtered and does not trigger plugins yet.
95 */
96 public function rss(Request $request, Response $response): Response
97 {
98 $response = $response->withHeader('Content-Type', 'application/rss+xml; charset=utf-8');
99
100 $pageUrl = page_url($this->container->environment);
101 $cache = $this->container->pageCacheManager->getCachePage($pageUrl);
102
103 $cached = $cache->cachedVersion();
104 if (!empty($cached)) {
105 return $response->write($cached);
106 }
107
108 $days = [];
109 foreach ($this->container->bookmarkService->search() as $bookmark) {
110 $day = $bookmark->getCreated()->format('Ymd');
111
112 // Stop iterating after DAILY_RSS_NB_DAYS entries
113 if (count($days) === static::$DAILY_RSS_NB_DAYS && !isset($days[$day])) {
114 break;
115 }
116
117 $days[$day][] = $bookmark;
118 }
119
120 // Build the RSS feed.
121 $indexUrl = escape(index_url($this->container->environment));
122
123 $formatter = $this->container->formatterFactory->getFormatter();
124 $formatter->addContextData('index_url', $indexUrl);
125
126 $dataPerDay = [];
127
128 /** @var Bookmark[] $bookmarks */
129 foreach ($days as $day => $bookmarks) {
130 $dayDatetime = DateTimeImmutable::createFromFormat(Bookmark::LINK_DATE_FORMAT, $day.'_000000');
131 $dataPerDay[$day] = [
132 'date' => $dayDatetime,
133 'date_rss' => $dayDatetime->format(DateTime::RSS),
134 'date_human' => format_date($dayDatetime, false, true),
135 'absolute_url' => $indexUrl . 'daily?day=' . $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'] = $indexUrl . $bookmark->getUrl();
145 }
146 }
147 }
148
149 $this->assignView('title', $this->container->conf->get('general.title', 'Shaarli'));
150 $this->assignView('index_url', $indexUrl);
151 $this->assignView('page_url', $pageUrl);
152 $this->assignView('hide_timestamps', $this->container->conf->get('privacy.hide_timestamps', false));
153 $this->assignView('days', $dataPerDay);
154
155 $rssContent = $this->render(TemplatePage::DAILY_RSS);
156
157 $cache->cache($rssContent);
158
159 return $response->write($rssContent);
160 }
161
162 /**
163 * We need to spread the articles on 3 columns.
164 * did not want to use a JavaScript lib like http://masonry.desandro.com/
165 * so I manually spread entries with a simple method: I roughly evaluate the
166 * height of a div according to title and description length.
167 */
168 protected function calculateColumns(array $links): array
169 {
170 // Entries to display, for each column.
171 $columns = [[], [], []];
172 // Rough estimate of columns fill.
173 $fill = [0, 0, 0];
174 foreach ($links as $link) {
175 // Roughly estimate length of entry (by counting characters)
176 // Title: 30 chars = 1 line. 1 line is 30 pixels height.
177 // Description: 836 characters gives roughly 342 pixel height.
178 // This is not perfect, but it's usually OK.
179 $length = strlen($link['title'] ?? '') + (342 * strlen($link['description'] ?? '')) / 836;
180 if (! empty($link['thumbnail'])) {
181 $length += 100; // 1 thumbnails roughly takes 100 pixels height.
182 }
183 // Then put in column which is the less filled:
184 $smallest = min($fill); // find smallest value in array.
185 $index = array_search($smallest, $fill); // find index of this smallest value.
186 array_push($columns[$index], $link); // Put entry in this column.
187 $fill[$index] += $length;
188 }
189
190 return $columns;
191 }
192}
diff --git a/application/front/controller/visitor/ErrorController.php b/application/front/controller/visitor/ErrorController.php
new file mode 100644
index 00000000..10aa84c8
--- /dev/null
+++ b/application/front/controller/visitor/ErrorController.php
@@ -0,0 +1,45 @@
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(
32 'stacktrace',
33 nl2br(get_class($throwable) .': '. PHP_EOL . $throwable->getTraceAsString())
34 );
35 } else {
36 $this->assignView('message', t('An unexpected error occurred.'));
37 }
38
39 $response = $response->withStatus(500);
40 }
41
42
43 return $response->write($this->render('error'));
44 }
45}
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..7cb32777
--- /dev/null
+++ b/application/front/controller/visitor/InstallController.php
@@ -0,0 +1,165 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Visitor;
6
7use Shaarli\ApplicationUtils;
8use Shaarli\Container\ShaarliContainer;
9use Shaarli\Front\Exception\AlreadyInstalledException;
10use Shaarli\Front\Exception\ResourcePermissionException;
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 return $response->write($this->render('install'));
57 }
58
59 /**
60 * Route checking that the session parameter has been properly saved between two distinct requests.
61 * If the session parameter is preserved, redirect to install template page, otherwise displays error.
62 */
63 public function sessionTest(Request $request, Response $response): Response
64 {
65 // This part makes sure sessions works correctly.
66 // (Because on some hosts, session.save_path may not be set correctly,
67 // or we may not have write access to it.)
68 if (static::SESSION_TEST_VALUE
69 !== $this->container->sessionManager->getSessionParameter(static::SESSION_TEST_KEY)
70 ) {
71 // Step 2: Check if data in session is correct.
72 $msg = t(
73 '<pre>Sessions do not seem to work correctly on your server.<br>'.
74 'Make sure the variable "session.save_path" is set correctly in your PHP config, '.
75 'and that you have write access to it.<br>'.
76 'It currently points to %s.<br>'.
77 'On some browsers, accessing your server via a hostname like \'localhost\' '.
78 'or any custom hostname without a dot causes cookie storage to fail. '.
79 'We recommend accessing your server via it\'s IP address or Fully Qualified Domain Name.<br>'
80 );
81 $msg = sprintf($msg, $this->container->sessionManager->getSavePath());
82
83 $this->assignView('message', $msg);
84
85 return $response->write($this->render('error'));
86 }
87
88 return $this->redirect($response, '/install');
89 }
90
91 /**
92 * Save installation form and initialize config file and datastore if necessary.
93 */
94 public function save(Request $request, Response $response): Response
95 {
96 $timezone = 'UTC';
97 if (!empty($request->getParam('continent'))
98 && !empty($request->getParam('city'))
99 && isTimeZoneValid($request->getParam('continent'), $request->getParam('city'))
100 ) {
101 $timezone = $request->getParam('continent') . '/' . $request->getParam('city');
102 }
103 $this->container->conf->set('general.timezone', $timezone);
104
105 $login = $request->getParam('setlogin');
106 $this->container->conf->set('credentials.login', $login);
107 $salt = sha1(uniqid('', true) .'_'. mt_rand());
108 $this->container->conf->set('credentials.salt', $salt);
109 $this->container->conf->set('credentials.hash', sha1($request->getParam('setpassword') . $login . $salt));
110
111 if (!empty($request->getParam('title'))) {
112 $this->container->conf->set('general.title', escape($request->getParam('title')));
113 } else {
114 $this->container->conf->set(
115 'general.title',
116 'Shared bookmarks on '.escape(index_url($this->container->environment))
117 );
118 }
119
120 $this->container->conf->set('translation.language', escape($request->getParam('language')));
121 $this->container->conf->set('updates.check_updates', !empty($request->getParam('updateCheck')));
122 $this->container->conf->set('api.enabled', !empty($request->getParam('enableApi')));
123 $this->container->conf->set(
124 'api.secret',
125 generate_api_secret(
126 $this->container->conf->get('credentials.login'),
127 $this->container->conf->get('credentials.salt')
128 )
129 );
130 $this->container->conf->set('general.header_link', $this->container->basePath . '/');
131
132 try {
133 // Everything is ok, let's create config file.
134 $this->container->conf->write($this->container->loginManager->isLoggedIn());
135 } catch (\Exception $e) {
136 $this->assignView('message', t('Error while writing config file after configuration update.'));
137 $this->assignView('stacktrace', $e->getMessage() . PHP_EOL . $e->getTraceAsString());
138
139 return $response->write($this->render('error'));
140 }
141
142 $this->container->sessionManager->setSessionParameter(
143 SessionManager::KEY_SUCCESS_MESSAGES,
144 [t('Shaarli is now configured. Please login and start shaaring your bookmarks!')]
145 );
146
147 return $this->redirect($response, '/login');
148 }
149
150 protected function checkPermissions(): bool
151 {
152 // Ensure Shaarli has proper access to its resources
153 $errors = ApplicationUtils::checkResourcePermissions($this->container->conf);
154 if (empty($errors)) {
155 return true;
156 }
157
158 $message = t('Insufficient permissions:') . PHP_EOL;
159 foreach ($errors as $error) {
160 $message .= PHP_EOL . $error;
161 }
162
163 throw new ResourcePermissionException($message);
164 }
165}
diff --git a/application/front/controller/visitor/LoginController.php b/application/front/controller/visitor/LoginController.php
new file mode 100644
index 00000000..121ba40b
--- /dev/null
+++ b/application/front/controller/visitor/LoginController.php
@@ -0,0 +1,154 @@
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 $this->container->environment['REMOTE_ADDR'],
69 client_ip_id($this->container->environment),
70 $request->getParam('login'),
71 $request->getParam('password')
72 )
73 ) {
74 $this->container->loginManager->handleFailedLogin($this->container->environment);
75
76 $this->container->sessionManager->setSessionParameter(
77 SessionManager::KEY_ERROR_MESSAGES,
78 [t('Wrong login/password.')]
79 );
80
81 // Call controller directly instead of unnecessary redirection
82 return $this->index($request, $response);
83 }
84
85 $this->container->loginManager->handleSuccessfulLogin($this->container->environment);
86
87 $cookiePath = $this->container->basePath . '/';
88 $expirationTime = $this->saveLongLastingSession($request, $cookiePath);
89 $this->renewUserSession($cookiePath, $expirationTime);
90
91 // Force referer from given return URL
92 $this->container->environment['HTTP_REFERER'] = $request->getParam('returnurl');
93
94 return $this->redirectFromReferer($request, $response, ['login', 'install']);
95 }
96
97 /**
98 * Make sure that the user is allowed to login and/or displaying the login page:
99 * - not already logged in
100 * - not open shaarli
101 * - not banned
102 */
103 protected function checkLoginState(): bool
104 {
105 if ($this->container->loginManager->isLoggedIn()
106 || $this->container->conf->get('security.open_shaarli', false)
107 ) {
108 throw new CantLoginException();
109 }
110
111 if (true !== $this->container->loginManager->canLogin($this->container->environment)) {
112 throw new LoginBannedException();
113 }
114
115 return true;
116 }
117
118 /**
119 * @return int Session duration in seconds
120 */
121 protected function saveLongLastingSession(Request $request, string $cookiePath): int
122 {
123 if (empty($request->getParam('longlastingsession'))) {
124 // Standard session expiration (=when browser closes)
125 $expirationTime = 0;
126 } else {
127 // Keep the session cookie even after the browser closes
128 $this->container->sessionManager->setStaySignedIn(true);
129 $expirationTime = $this->container->sessionManager->extendSession();
130 }
131
132 $this->container->cookieManager->setCookieParameter(
133 CookieManager::STAY_SIGNED_IN,
134 $this->container->loginManager->getStaySignedInToken(),
135 $expirationTime,
136 $cookiePath
137 );
138
139 return $expirationTime;
140 }
141
142 protected function renewUserSession(string $cookiePath, int $expirationTime): void
143 {
144 // Send cookie with the new expiration date to the browser
145 $this->container->sessionManager->destroy();
146 $this->container->sessionManager->cookieParameters(
147 $expirationTime,
148 $cookiePath,
149 $this->container->environment['SERVER_NAME']
150 );
151 $this->container->sessionManager->start();
152 $this->container->sessionManager->regenerateId(true);
153 }
154}
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..55c075a2
--- /dev/null
+++ b/application/front/controller/visitor/ShaarliVisitorController.php
@@ -0,0 +1,180 @@
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 'bookmarkService' => $this->container->bookmarkService
110 ];
111 }
112
113 /**
114 * Simple helper which prepend the base path to redirect path.
115 *
116 * @param Response $response
117 * @param string $path Absolute path, e.g.: `/`, or `/admin/shaare/123` regardless of install directory
118 *
119 * @return Response updated
120 */
121 protected function redirect(Response $response, string $path): Response
122 {
123 return $response->withRedirect($this->container->basePath . $path);
124 }
125
126 /**
127 * Generates a redirection to the previous page, based on the HTTP_REFERER.
128 * It fails back to the home page.
129 *
130 * @param array $loopTerms Terms to remove from path and query string to prevent direction loop.
131 * @param array $clearParams List of parameter to remove from the query string of the referrer.
132 */
133 protected function redirectFromReferer(
134 Request $request,
135 Response $response,
136 array $loopTerms = [],
137 array $clearParams = [],
138 string $anchor = null
139 ): Response {
140 $defaultPath = $this->container->basePath . '/';
141 $referer = $this->container->environment['HTTP_REFERER'] ?? null;
142
143 if (null !== $referer) {
144 $currentUrl = parse_url($referer);
145 // If the referer is not related to Shaarli instance, redirect to default
146 if (isset($currentUrl['host'])
147 && strpos(index_url($this->container->environment), $currentUrl['host']) === false
148 ) {
149 return $response->withRedirect($defaultPath);
150 }
151
152 parse_str($currentUrl['query'] ?? '', $params);
153 $path = $currentUrl['path'] ?? $defaultPath;
154 } else {
155 $params = [];
156 $path = $defaultPath;
157 }
158
159 // Prevent redirection loop
160 if (isset($currentUrl)) {
161 foreach ($clearParams as $value) {
162 unset($params[$value]);
163 }
164
165 $checkQuery = implode('', array_keys($params));
166 foreach ($loopTerms as $value) {
167 if (strpos($path . $checkQuery, $value) !== false) {
168 $params = [];
169 $path = $defaultPath;
170 break;
171 }
172 }
173 }
174
175 $queryString = count($params) > 0 ? '?'. http_build_query($params) : '';
176 $anchor = $anchor ? '#' . $anchor : '';
177
178 return $response->withRedirect($path . $queryString . $anchor);
179 }
180}
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/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
new file mode 100644
index 00000000..79d0ea15
--- /dev/null
+++ b/application/front/exceptions/LoginBannedException.php
@@ -0,0 +1,15 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Exception;
6
7class LoginBannedException extends ShaarliFrontException
8{
9 public function __construct()
10 {
11 $message = t('You have been banned after too many failed login attempts. Try again later.');
12
13 parent::__construct($message, 401);
14 }
15}
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/ShaarliFrontException.php b/application/front/exceptions/ShaarliFrontException.php
new file mode 100644
index 00000000..73847e6d
--- /dev/null
+++ b/application/front/exceptions/ShaarliFrontException.php
@@ -0,0 +1,23 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Exception;
6
7use Throwable;
8
9/**
10 * Class ShaarliException
11 *
12 * Exception class used to defined any custom exception thrown during front rendering.
13 *
14 * @package Front\Exception
15 */
16class ShaarliFrontException extends \Exception
17{
18 /** Override parent constructor to force $message and $httpCode parameters to be set. */
19 public function __construct(string $message, int $httpCode, Throwable $previous = null)
20 {
21 parent::__construct($message, $httpCode, $previous);
22 }
23}
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/http/HttpAccess.php b/application/http/HttpAccess.php
new file mode 100644
index 00000000..81d9e076
--- /dev/null
+++ b/application/http/HttpAccess.php
@@ -0,0 +1,39 @@
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($url, $timeout = 30, $maxBytes = 4194304, $curlWriteFunction = null)
18 {
19 return get_http_response($url, $timeout, $maxBytes, $curlWriteFunction);
20 }
21
22 public function getCurlDownloadCallback(
23 &$charset,
24 &$title,
25 &$description,
26 &$keywords,
27 $retrieveDescription,
28 $curlGetInfo = 'curl_getinfo'
29 ) {
30 return get_curl_download_callback(
31 $charset,
32 $title,
33 $description,
34 $keywords,
35 $retrieveDescription,
36 $curlGetInfo
37 );
38 }
39}
diff --git a/application/http/HttpUtils.php b/application/http/HttpUtils.php
index 2ea9195d..9f414073 100644
--- a/application/http/HttpUtils.php
+++ b/application/http/HttpUtils.php
@@ -369,7 +369,11 @@ function server_url($server)
369 */ 369 */
370function index_url($server) 370function index_url($server)
371{ 371{
372 $scriptname = $server['SCRIPT_NAME']; 372 if (defined('SHAARLI_ROOT_URL') && null !== SHAARLI_ROOT_URL) {
373 return rtrim(SHAARLI_ROOT_URL, '/') . '/';
374 }
375
376 $scriptname = !empty($server['SCRIPT_NAME']) ? $server['SCRIPT_NAME'] : '/';
373 if (endsWith($scriptname, 'index.php')) { 377 if (endsWith($scriptname, 'index.php')) {
374 $scriptname = substr($scriptname, 0, -9); 378 $scriptname = substr($scriptname, 0, -9);
375 } 379 }
@@ -377,7 +381,7 @@ function index_url($server)
377} 381}
378 382
379/** 383/**
380 * Returns the absolute URL of the current script, with the query 384 * Returns the absolute URL of the current script, with current route and query
381 * 385 *
382 * If the resource is "index.php", then it is removed (for better-looking URLs) 386 * If the resource is "index.php", then it is removed (for better-looking URLs)
383 * 387 *
@@ -387,10 +391,17 @@ function index_url($server)
387 */ 391 */
388function page_url($server) 392function page_url($server)
389{ 393{
394 $scriptname = $server['SCRIPT_NAME'] ?? '';
395 if (endsWith($scriptname, 'index.php')) {
396 $scriptname = substr($scriptname, 0, -9);
397 }
398
399 $route = preg_replace('@^' . $scriptname . '@', '', $server['REQUEST_URI'] ?? '');
390 if (! empty($server['QUERY_STRING'])) { 400 if (! empty($server['QUERY_STRING'])) {
391 return index_url($server).'?'.$server['QUERY_STRING']; 401 return index_url($server) . $route . '?' . $server['QUERY_STRING'];
392 } 402 }
393 return index_url($server); 403
404 return index_url($server) . $route;
394} 405}
395 406
396/** 407/**
@@ -477,3 +488,109 @@ function is_https($server)
477 488
478 return ! empty($server['HTTPS']); 489 return ! empty($server['HTTPS']);
479} 490}
491
492/**
493 * Get cURL callback function for CURLOPT_WRITEFUNCTION
494 *
495 * @param string $charset to extract from the downloaded page (reference)
496 * @param string $title to extract from the downloaded page (reference)
497 * @param string $description to extract from the downloaded page (reference)
498 * @param string $keywords to extract from the downloaded page (reference)
499 * @param bool $retrieveDescription Automatically tries to retrieve description and keywords from HTML content
500 * @param string $curlGetInfo Optionally overrides curl_getinfo function
501 *
502 * @return Closure
503 */
504function get_curl_download_callback(
505 &$charset,
506 &$title,
507 &$description,
508 &$keywords,
509 $retrieveDescription,
510 $curlGetInfo = 'curl_getinfo'
511) {
512 $isRedirected = false;
513 $currentChunk = 0;
514 $foundChunk = null;
515
516 /**
517 * cURL callback function for CURLOPT_WRITEFUNCTION (called during the download).
518 *
519 * While downloading the remote page, we check that the HTTP code is 200 and content type is 'html/text'
520 * Then we extract the title and the charset and stop the download when it's done.
521 *
522 * @param resource $ch cURL resource
523 * @param string $data chunk of data being downloaded
524 *
525 * @return int|bool length of $data or false if we need to stop the download
526 */
527 return function (&$ch, $data) use (
528 $retrieveDescription,
529 $curlGetInfo,
530 &$charset,
531 &$title,
532 &$description,
533 &$keywords,
534 &$isRedirected,
535 &$currentChunk,
536 &$foundChunk
537 ) {
538 $currentChunk++;
539 $responseCode = $curlGetInfo($ch, CURLINFO_RESPONSE_CODE);
540 if (!empty($responseCode) && in_array($responseCode, [301, 302])) {
541 $isRedirected = true;
542 return strlen($data);
543 }
544 if (!empty($responseCode) && $responseCode !== 200) {
545 return false;
546 }
547 // After a redirection, the content type will keep the previous request value
548 // until it finds the next content-type header.
549 if (! $isRedirected || strpos(strtolower($data), 'content-type') !== false) {
550 $contentType = $curlGetInfo($ch, CURLINFO_CONTENT_TYPE);
551 }
552 if (!empty($contentType) && strpos($contentType, 'text/html') === false) {
553 return false;
554 }
555 if (!empty($contentType) && empty($charset)) {
556 $charset = header_extract_charset($contentType);
557 }
558 if (empty($charset)) {
559 $charset = html_extract_charset($data);
560 }
561 if (empty($title)) {
562 $title = html_extract_title($data);
563 $foundChunk = ! empty($title) ? $currentChunk : $foundChunk;
564 }
565 if ($retrieveDescription && empty($description)) {
566 $description = html_extract_tag('description', $data);
567 $foundChunk = ! empty($description) ? $currentChunk : $foundChunk;
568 }
569 if ($retrieveDescription && empty($keywords)) {
570 $keywords = html_extract_tag('keywords', $data);
571 if (! empty($keywords)) {
572 $foundChunk = $currentChunk;
573 // Keywords use the format tag1, tag2 multiple words, tag
574 // So we format them to match Shaarli's separator and glue multiple words with '-'
575 $keywords = implode(' ', array_map(function($keyword) {
576 return implode('-', preg_split('/\s+/', trim($keyword)));
577 }, explode(',', $keywords)));
578 }
579 }
580
581 // We got everything we want, stop the download.
582 // If we already found either the title, description or keywords,
583 // it's highly unlikely that we'll found the other metas further than
584 // in the same chunk of data or the next one. So we also stop the download after that.
585 if ((!empty($responseCode) && !empty($contentType) && !empty($charset)) && $foundChunk !== null
586 && (! $retrieveDescription
587 || $foundChunk < $currentChunk
588 || (!empty($title) && !empty($description) && !empty($keywords))
589 )
590 ) {
591 return false;
592 }
593
594 return strlen($data);
595 };
596}
diff --git a/application/http/UrlUtils.php b/application/http/UrlUtils.php
index 4bc84b82..e8d1a283 100644
--- a/application/http/UrlUtils.php
+++ b/application/http/UrlUtils.php
@@ -73,7 +73,7 @@ function add_trailing_slash($url)
73 */ 73 */
74function whitelist_protocols($url, $protocols) 74function whitelist_protocols($url, $protocols)
75{ 75{
76 if (startsWith($url, '?') || startsWith($url, '/')) { 76 if (startsWith($url, '?') || startsWith($url, '/') || startsWith($url, '#')) {
77 return $url; 77 return $url;
78 } 78 }
79 $protocols = array_merge(['http', 'https'], $protocols); 79 $protocols = array_merge(['http', 'https'], $protocols);
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/bookmark/LinkDB.php b/application/legacy/LegacyLinkDB.php
index 76ba95f0..7bf76fd4 100644
--- a/application/bookmark/LinkDB.php
+++ b/application/legacy/LegacyLinkDB.php
@@ -1,17 +1,18 @@
1<?php 1<?php
2 2
3namespace Shaarli\Bookmark; 3namespace Shaarli\Legacy;
4 4
5use ArrayAccess; 5use ArrayAccess;
6use Countable; 6use Countable;
7use DateTime; 7use DateTime;
8use Iterator; 8use Iterator;
9use Shaarli\Bookmark\Exception\LinkNotFoundException; 9use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
10use Shaarli\Exceptions\IOException; 10use Shaarli\Exceptions\IOException;
11use Shaarli\FileUtils; 11use Shaarli\FileUtils;
12use Shaarli\Render\PageCacheManager;
12 13
13/** 14/**
14 * Data storage for links. 15 * Data storage for bookmarks.
15 * 16 *
16 * This object behaves like an associative array. 17 * This object behaves like an associative array.
17 * 18 *
@@ -29,8 +30,8 @@ use Shaarli\FileUtils;
29 * - private: Is this link private? 0=no, other value=yes 30 * - private: Is this link private? 0=no, other value=yes
30 * - tags: tags attached to this entry (separated by spaces) 31 * - tags: tags attached to this entry (separated by spaces)
31 * - title Title of the link 32 * - title Title of the link
32 * - url URL of the link. Used for displayable links. 33 * - url URL of the link. Used for displayable bookmarks.
33 * Can be absolute or relative in the database but the relative links 34 * Can be absolute or relative in the database but the relative bookmarks
34 * will be converted to absolute ones in templates. 35 * will be converted to absolute ones in templates.
35 * - real_url Raw URL in stored in the DB (absolute or relative). 36 * - real_url Raw URL in stored in the DB (absolute or relative).
36 * - shorturl Permalink smallhash 37 * - shorturl Permalink smallhash
@@ -49,11 +50,13 @@ use Shaarli\FileUtils;
49 * Example: 50 * Example:
50 * - DB: link #1 (2010-01-01) link #2 (2016-01-01) 51 * - DB: link #1 (2010-01-01) link #2 (2016-01-01)
51 * - Order: #2 #1 52 * - Order: #2 #1
52 * - Import links containing: link #3 (2013-01-01) 53 * - Import bookmarks containing: link #3 (2013-01-01)
53 * - New DB: link #1 (2010-01-01) link #2 (2016-01-01) link #3 (2013-01-01) 54 * - New DB: link #1 (2010-01-01) link #2 (2016-01-01) link #3 (2013-01-01)
54 * - Real order: #2 #3 #1 55 * - Real order: #2 #3 #1
56 *
57 * @deprecated
55 */ 58 */
56class LinkDB implements Iterator, Countable, ArrayAccess 59class LegacyLinkDB implements Iterator, Countable, ArrayAccess
57{ 60{
58 // Links are stored as a PHP serialized string 61 // Links are stored as a PHP serialized string
59 private $datastore; 62 private $datastore;
@@ -61,7 +64,7 @@ class LinkDB implements Iterator, Countable, ArrayAccess
61 // Link date storage format 64 // Link date storage format
62 const LINK_DATE_FORMAT = 'Ymd_His'; 65 const LINK_DATE_FORMAT = 'Ymd_His';
63 66
64 // List of links (associative array) 67 // List of bookmarks (associative array)
65 // - key: link date (e.g. "20110823_124546"), 68 // - key: link date (e.g. "20110823_124546"),
66 // - value: associative array (keys: title, description...) 69 // - value: associative array (keys: title, description...)
67 private $links; 70 private $links;
@@ -71,7 +74,7 @@ class LinkDB implements Iterator, Countable, ArrayAccess
71 private $urls; 74 private $urls;
72 75
73 /** 76 /**
74 * @var array List of all links IDS mapped with their array offset. 77 * @var array List of all bookmarks IDS mapped with their array offset.
75 * Map: id->offset. 78 * Map: id->offset.
76 */ 79 */
77 protected $ids; 80 protected $ids;
@@ -82,10 +85,10 @@ class LinkDB implements Iterator, Countable, ArrayAccess
82 // Position in the $this->keys array (for the Iterator interface) 85 // Position in the $this->keys array (for the Iterator interface)
83 private $position; 86 private $position;
84 87
85 // Is the user logged in? (used to filter private links) 88 // Is the user logged in? (used to filter private bookmarks)
86 private $loggedIn; 89 private $loggedIn;
87 90
88 // Hide public links 91 // Hide public bookmarks
89 private $hidePublicLinks; 92 private $hidePublicLinks;
90 93
91 /** 94 /**
@@ -95,7 +98,7 @@ class LinkDB implements Iterator, Countable, ArrayAccess
95 * 98 *
96 * @param string $datastore datastore file path. 99 * @param string $datastore datastore file path.
97 * @param boolean $isLoggedIn is the user logged in? 100 * @param boolean $isLoggedIn is the user logged in?
98 * @param boolean $hidePublicLinks if true all links are private. 101 * @param boolean $hidePublicLinks if true all bookmarks are private.
99 */ 102 */
100 public function __construct( 103 public function __construct(
101 $datastore, 104 $datastore,
@@ -280,7 +283,7 @@ You use the community supported version of the original Shaarli project, by Seba
280 */ 283 */
281 private function read() 284 private function read()
282 { 285 {
283 // Public links are hidden and user not logged in => nothing to show 286 // Public bookmarks are hidden and user not logged in => nothing to show
284 if ($this->hidePublicLinks && !$this->loggedIn) { 287 if ($this->hidePublicLinks && !$this->loggedIn) {
285 $this->links = array(); 288 $this->links = array();
286 return; 289 return;
@@ -310,7 +313,7 @@ You use the community supported version of the original Shaarli project, by Seba
310 313
311 $link['sticky'] = isset($link['sticky']) ? $link['sticky'] : false; 314 $link['sticky'] = isset($link['sticky']) ? $link['sticky'] : false;
312 315
313 // To be able to load links before running the update, and prepare the update 316 // To be able to load bookmarks before running the update, and prepare the update
314 if (!isset($link['created'])) { 317 if (!isset($link['created'])) {
315 $link['id'] = $link['linkdate']; 318 $link['id'] = $link['linkdate'];
316 $link['created'] = DateTime::createFromFormat(self::LINK_DATE_FORMAT, $link['linkdate']); 319 $link['created'] = DateTime::createFromFormat(self::LINK_DATE_FORMAT, $link['linkdate']);
@@ -350,7 +353,8 @@ You use the community supported version of the original Shaarli project, by Seba
350 353
351 $this->write(); 354 $this->write();
352 355
353 invalidateCaches($pageCacheDir); 356 $pageCacheManager = new PageCacheManager($pageCacheDir, $this->loggedIn);
357 $pageCacheManager->invalidateCaches();
354 } 358 }
355 359
356 /** 360 /**
@@ -375,13 +379,13 @@ You use the community supported version of the original Shaarli project, by Seba
375 * 379 *
376 * @return array $filtered array containing permalink data. 380 * @return array $filtered array containing permalink data.
377 * 381 *
378 * @throws LinkNotFoundException if the smallhash is malformed or doesn't match any link. 382 * @throws BookmarkNotFoundException if the smallhash is malformed or doesn't match any link.
379 */ 383 */
380 public function filterHash($request) 384 public function filterHash($request)
381 { 385 {
382 $request = substr($request, 0, 6); 386 $request = substr($request, 0, 6);
383 $linkFilter = new LinkFilter($this->links); 387 $linkFilter = new LegacyLinkFilter($this->links);
384 return $linkFilter->filter(LinkFilter::$FILTER_HASH, $request); 388 return $linkFilter->filter(LegacyLinkFilter::$FILTER_HASH, $request);
385 } 389 }
386 390
387 /** 391 /**
@@ -393,21 +397,21 @@ You use the community supported version of the original Shaarli project, by Seba
393 */ 397 */
394 public function filterDay($request) 398 public function filterDay($request)
395 { 399 {
396 $linkFilter = new LinkFilter($this->links); 400 $linkFilter = new LegacyLinkFilter($this->links);
397 return $linkFilter->filter(LinkFilter::$FILTER_DAY, $request); 401 return $linkFilter->filter(LegacyLinkFilter::$FILTER_DAY, $request);
398 } 402 }
399 403
400 /** 404 /**
401 * Filter links according to search parameters. 405 * Filter bookmarks according to search parameters.
402 * 406 *
403 * @param array $filterRequest Search request content. Supported keys: 407 * @param array $filterRequest Search request content. Supported keys:
404 * - searchtags: list of tags 408 * - searchtags: list of tags
405 * - searchterm: term search 409 * - searchterm: term search
406 * @param bool $casesensitive Optional: Perform case sensitive filter 410 * @param bool $casesensitive Optional: Perform case sensitive filter
407 * @param string $visibility return only all/private/public links 411 * @param string $visibility return only all/private/public bookmarks
408 * @param bool $untaggedonly return only untagged links 412 * @param bool $untaggedonly return only untagged bookmarks
409 * 413 *
410 * @return array filtered links, all links if no suitable filter was provided. 414 * @return array filtered bookmarks, all bookmarks if no suitable filter was provided.
411 */ 415 */
412 public function filterSearch( 416 public function filterSearch(
413 $filterRequest = array(), 417 $filterRequest = array(),
@@ -420,19 +424,19 @@ You use the community supported version of the original Shaarli project, by Seba
420 $searchtags = isset($filterRequest['searchtags']) ? escape($filterRequest['searchtags']) : ''; 424 $searchtags = isset($filterRequest['searchtags']) ? escape($filterRequest['searchtags']) : '';
421 $searchterm = isset($filterRequest['searchterm']) ? escape($filterRequest['searchterm']) : ''; 425 $searchterm = isset($filterRequest['searchterm']) ? escape($filterRequest['searchterm']) : '';
422 426
423 // Search tags + fullsearch - blank string parameter will return all links. 427 // Search tags + fullsearch - blank string parameter will return all bookmarks.
424 $type = LinkFilter::$FILTER_TAG | LinkFilter::$FILTER_TEXT; // == "vuotext" 428 $type = LegacyLinkFilter::$FILTER_TAG | LegacyLinkFilter::$FILTER_TEXT; // == "vuotext"
425 $request = [$searchtags, $searchterm]; 429 $request = [$searchtags, $searchterm];
426 430
427 $linkFilter = new LinkFilter($this); 431 $linkFilter = new LegacyLinkFilter($this);
428 return $linkFilter->filter($type, $request, $casesensitive, $visibility, $untaggedonly); 432 return $linkFilter->filter($type, $request, $casesensitive, $visibility, $untaggedonly);
429 } 433 }
430 434
431 /** 435 /**
432 * Returns the list tags appearing in the links with the given tags 436 * Returns the list tags appearing in the bookmarks with the given tags
433 * 437 *
434 * @param array $filteringTags tags selecting the links to consider 438 * @param array $filteringTags tags selecting the bookmarks to consider
435 * @param string $visibility process only all/private/public links 439 * @param string $visibility process only all/private/public bookmarks
436 * 440 *
437 * @return array tag => linksCount 441 * @return array tag => linksCount
438 */ 442 */
@@ -471,12 +475,12 @@ You use the community supported version of the original Shaarli project, by Seba
471 } 475 }
472 476
473 /** 477 /**
474 * Rename or delete a tag across all links. 478 * Rename or delete a tag across all bookmarks.
475 * 479 *
476 * @param string $from Tag to rename 480 * @param string $from Tag to rename
477 * @param string $to New tag. If none is provided, the from tag will be deleted 481 * @param string $to New tag. If none is provided, the from tag will be deleted
478 * 482 *
479 * @return array|bool List of altered links or false on error 483 * @return array|bool List of altered bookmarks or false on error
480 */ 484 */
481 public function renameTag($from, $to) 485 public function renameTag($from, $to)
482 { 486 {
@@ -519,7 +523,7 @@ You use the community supported version of the original Shaarli project, by Seba
519 } 523 }
520 524
521 /** 525 /**
522 * Reorder links by creation date (newest first). 526 * Reorder bookmarks by creation date (newest first).
523 * 527 *
524 * Also update the urls and ids mapping arrays. 528 * Also update the urls and ids mapping arrays.
525 * 529 *
@@ -533,6 +537,9 @@ You use the community supported version of the original Shaarli project, by Seba
533 if (isset($a['sticky']) && isset($b['sticky']) && $a['sticky'] !== $b['sticky']) { 537 if (isset($a['sticky']) && isset($b['sticky']) && $a['sticky'] !== $b['sticky']) {
534 return $a['sticky'] ? -1 : 1; 538 return $a['sticky'] ? -1 : 1;
535 } 539 }
540 if ($a['created'] == $b['created']) {
541 return $a['id'] < $b['id'] ? 1 * $order : -1 * $order;
542 }
536 return $a['created'] < $b['created'] ? 1 * $order : -1 * $order; 543 return $a['created'] < $b['created'] ? 1 * $order : -1 * $order;
537 }); 544 });
538 545
@@ -559,7 +566,7 @@ You use the community supported version of the original Shaarli project, by Seba
559 } 566 }
560 567
561 /** 568 /**
562 * Returns a link offset in links array from its unique ID. 569 * Returns a link offset in bookmarks array from its unique ID.
563 * 570 *
564 * @param int $id Persistent ID of a link. 571 * @param int $id Persistent ID of a link.
565 * 572 *
diff --git a/application/bookmark/LinkFilter.php b/application/legacy/LegacyLinkFilter.php
index 9b966307..7cf93d60 100644
--- a/application/bookmark/LinkFilter.php
+++ b/application/legacy/LegacyLinkFilter.php
@@ -1,16 +1,18 @@
1<?php 1<?php
2 2
3namespace Shaarli\Bookmark; 3namespace Shaarli\Legacy;
4 4
5use Exception; 5use Exception;
6use Shaarli\Bookmark\Exception\LinkNotFoundException; 6use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
7 7
8/** 8/**
9 * Class LinkFilter. 9 * Class LinkFilter.
10 * 10 *
11 * Perform search and filter operation on link data list. 11 * Perform search and filter operation on link data list.
12 *
13 * @deprecated
12 */ 14 */
13class LinkFilter 15class LegacyLinkFilter
14{ 16{
15 /** 17 /**
16 * @var string permalinks. 18 * @var string permalinks.
@@ -38,12 +40,12 @@ class LinkFilter
38 public static $HASHTAG_CHARS = '\p{Pc}\p{N}\p{L}\p{Mn}'; 40 public static $HASHTAG_CHARS = '\p{Pc}\p{N}\p{L}\p{Mn}';
39 41
40 /** 42 /**
41 * @var LinkDB all available links. 43 * @var LegacyLinkDB all available links.
42 */ 44 */
43 private $links; 45 private $links;
44 46
45 /** 47 /**
46 * @param LinkDB $links initialization. 48 * @param LegacyLinkDB $links initialization.
47 */ 49 */
48 public function __construct($links) 50 public function __construct($links)
49 { 51 {
@@ -84,10 +86,10 @@ class LinkFilter
84 $filtered = $this->links; 86 $filtered = $this->links;
85 } 87 }
86 if (!empty($request[0])) { 88 if (!empty($request[0])) {
87 $filtered = (new LinkFilter($filtered))->filterTags($request[0], $casesensitive, $visibility); 89 $filtered = (new LegacyLinkFilter($filtered))->filterTags($request[0], $casesensitive, $visibility);
88 } 90 }
89 if (!empty($request[1])) { 91 if (!empty($request[1])) {
90 $filtered = (new LinkFilter($filtered))->filterFulltext($request[1], $visibility); 92 $filtered = (new LegacyLinkFilter($filtered))->filterFulltext($request[1], $visibility);
91 } 93 }
92 return $filtered; 94 return $filtered;
93 case self::$FILTER_TEXT: 95 case self::$FILTER_TEXT:
@@ -137,7 +139,7 @@ class LinkFilter
137 * 139 *
138 * @return array $filtered array containing permalink data. 140 * @return array $filtered array containing permalink data.
139 * 141 *
140 * @throws \Shaarli\Bookmark\Exception\LinkNotFoundException if the smallhash doesn't match any link. 142 * @throws BookmarkNotFoundException if the smallhash doesn't match any link.
141 */ 143 */
142 private function filterSmallHash($smallHash) 144 private function filterSmallHash($smallHash)
143 { 145 {
@@ -151,7 +153,7 @@ class LinkFilter
151 } 153 }
152 154
153 if (empty($filtered)) { 155 if (empty($filtered)) {
154 throw new LinkNotFoundException(); 156 throw new BookmarkNotFoundException();
155 } 157 }
156 158
157 return $filtered; 159 return $filtered;
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
new file mode 100644
index 00000000..0ab3a55b
--- /dev/null
+++ b/application/legacy/LegacyUpdater.php
@@ -0,0 +1,618 @@
1<?php
2
3namespace Shaarli\Legacy;
4
5use Exception;
6use RainTPL;
7use ReflectionClass;
8use ReflectionException;
9use ReflectionMethod;
10use Shaarli\ApplicationUtils;
11use Shaarli\Bookmark\Bookmark;
12use Shaarli\Bookmark\BookmarkArray;
13use Shaarli\Bookmark\BookmarkFilter;
14use Shaarli\Bookmark\BookmarkIO;
15use Shaarli\Bookmark\LinkDB;
16use Shaarli\Config\ConfigJson;
17use Shaarli\Config\ConfigManager;
18use Shaarli\Config\ConfigPhp;
19use Shaarli\Exceptions\IOException;
20use Shaarli\Thumbnailer;
21use Shaarli\Updater\Exception\UpdaterException;
22
23/**
24 * Class updater.
25 * Used to update stuff when a new Shaarli's version is reached.
26 * Update methods are ran only once, and the stored in a JSON file.
27 *
28 * @deprecated
29 */
30class LegacyUpdater
31{
32 /**
33 * @var array Updates which are already done.
34 */
35 protected $doneUpdates;
36
37 /**
38 * @var LegacyLinkDB instance.
39 */
40 protected $linkDB;
41
42 /**
43 * @var ConfigManager $conf Configuration Manager instance.
44 */
45 protected $conf;
46
47 /**
48 * @var bool True if the user is logged in, false otherwise.
49 */
50 protected $isLoggedIn;
51
52 /**
53 * @var array $_SESSION
54 */
55 protected $session;
56
57 /**
58 * @var ReflectionMethod[] List of current class methods.
59 */
60 protected $methods;
61
62 /**
63 * Object constructor.
64 *
65 * @param array $doneUpdates Updates which are already done.
66 * @param LegacyLinkDB $linkDB LinkDB instance.
67 * @param ConfigManager $conf Configuration Manager instance.
68 * @param boolean $isLoggedIn True if the user is logged in.
69 * @param array $session $_SESSION (by reference)
70 *
71 * @throws ReflectionException
72 */
73 public function __construct($doneUpdates, $linkDB, $conf, $isLoggedIn, &$session = [])
74 {
75 $this->doneUpdates = $doneUpdates;
76 $this->linkDB = $linkDB;
77 $this->conf = $conf;
78 $this->isLoggedIn = $isLoggedIn;
79 $this->session = &$session;
80
81 // Retrieve all update methods.
82 $class = new ReflectionClass($this);
83 $this->methods = $class->getMethods();
84 }
85
86 /**
87 * Run all new updates.
88 * Update methods have to start with 'updateMethod' and return true (on success).
89 *
90 * @return array An array containing ran updates.
91 *
92 * @throws UpdaterException If something went wrong.
93 */
94 public function update()
95 {
96 $updatesRan = array();
97
98 // If the user isn't logged in, exit without updating.
99 if ($this->isLoggedIn !== true) {
100 return $updatesRan;
101 }
102
103 if ($this->methods === null) {
104 throw new UpdaterException(t('Couldn\'t retrieve updater class methods.'));
105 }
106
107 foreach ($this->methods as $method) {
108 // Not an update method or already done, pass.
109 if (!startsWith($method->getName(), 'updateMethod')
110 || in_array($method->getName(), $this->doneUpdates)
111 ) {
112 continue;
113 }
114
115 try {
116 $method->setAccessible(true);
117 $res = $method->invoke($this);
118 // Update method must return true to be considered processed.
119 if ($res === true) {
120 $updatesRan[] = $method->getName();
121 }
122 } catch (Exception $e) {
123 throw new UpdaterException($method, $e);
124 }
125 }
126
127 $this->doneUpdates = array_merge($this->doneUpdates, $updatesRan);
128
129 return $updatesRan;
130 }
131
132 /**
133 * @return array Updates methods already processed.
134 */
135 public function getDoneUpdates()
136 {
137 return $this->doneUpdates;
138 }
139
140 /**
141 * Move deprecated options.php to config.php.
142 *
143 * Milestone 0.9 (old versioning) - shaarli/Shaarli#41:
144 * options.php is not supported anymore.
145 */
146 public function updateMethodMergeDeprecatedConfigFile()
147 {
148 if (is_file($this->conf->get('resource.data_dir') . '/options.php')) {
149 include $this->conf->get('resource.data_dir') . '/options.php';
150
151 // Load GLOBALS into config
152 $allowedKeys = array_merge(ConfigPhp::$ROOT_KEYS);
153 $allowedKeys[] = 'config';
154 foreach ($GLOBALS as $key => $value) {
155 if (in_array($key, $allowedKeys)) {
156 $this->conf->set($key, $value);
157 }
158 }
159 $this->conf->write($this->isLoggedIn);
160 unlink($this->conf->get('resource.data_dir') . '/options.php');
161 }
162
163 return true;
164 }
165
166 /**
167 * Move old configuration in PHP to the new config system in JSON format.
168 *
169 * Will rename 'config.php' into 'config.save.php' and create 'config.json.php'.
170 * It will also convert legacy setting keys to the new ones.
171 */
172 public function updateMethodConfigToJson()
173 {
174 // JSON config already exists, nothing to do.
175 if ($this->conf->getConfigIO() instanceof ConfigJson) {
176 return true;
177 }
178
179 $configPhp = new ConfigPhp();
180 $configJson = new ConfigJson();
181 $oldConfig = $configPhp->read($this->conf->getConfigFile() . '.php');
182 rename($this->conf->getConfigFileExt(), $this->conf->getConfigFile() . '.save.php');
183 $this->conf->setConfigIO($configJson);
184 $this->conf->reload();
185
186 $legacyMap = array_flip(ConfigPhp::$LEGACY_KEYS_MAPPING);
187 foreach (ConfigPhp::$ROOT_KEYS as $key) {
188 $this->conf->set($legacyMap[$key], $oldConfig[$key]);
189 }
190
191 // Set sub config keys (config and plugins)
192 $subConfig = array('config', 'plugins');
193 foreach ($subConfig as $sub) {
194 foreach ($oldConfig[$sub] as $key => $value) {
195 if (isset($legacyMap[$sub . '.' . $key])) {
196 $configKey = $legacyMap[$sub . '.' . $key];
197 } else {
198 $configKey = $sub . '.' . $key;
199 }
200 $this->conf->set($configKey, $value);
201 }
202 }
203
204 try {
205 $this->conf->write($this->isLoggedIn);
206 return true;
207 } catch (IOException $e) {
208 error_log($e->getMessage());
209 return false;
210 }
211 }
212
213 /**
214 * Escape settings which have been manually escaped in every request in previous versions:
215 * - general.title
216 * - general.header_link
217 * - redirector.url
218 *
219 * @return bool true if the update is successful, false otherwise.
220 */
221 public function updateMethodEscapeUnescapedConfig()
222 {
223 try {
224 $this->conf->set('general.title', escape($this->conf->get('general.title')));
225 $this->conf->set('general.header_link', escape($this->conf->get('general.header_link')));
226 $this->conf->write($this->isLoggedIn);
227 } catch (Exception $e) {
228 error_log($e->getMessage());
229 return false;
230 }
231 return true;
232 }
233
234 /**
235 * Update the database to use the new ID system, which replaces linkdate primary keys.
236 * Also, creation and update dates are now DateTime objects (done by LinkDB).
237 *
238 * Since this update is very sensitve (changing the whole database), the datastore will be
239 * automatically backed up into the file datastore.<datetime>.php.
240 *
241 * LinkDB also adds the field 'shorturl' with the precedent format (linkdate smallhash),
242 * which will be saved by this method.
243 *
244 * @return bool true if the update is successful, false otherwise.
245 */
246 public function updateMethodDatastoreIds()
247 {
248 $first = 'update';
249 foreach ($this->linkDB as $key => $link) {
250 $first = $key;
251 break;
252 }
253
254 // up to date database
255 if (is_int($first)) {
256 return true;
257 }
258
259 $save = $this->conf->get('resource.data_dir') . '/datastore.' . date('YmdHis') . '.php';
260 copy($this->conf->get('resource.datastore'), $save);
261
262 $links = array();
263 foreach ($this->linkDB as $offset => $value) {
264 $links[] = $value;
265 unset($this->linkDB[$offset]);
266 }
267 $links = array_reverse($links);
268 $cpt = 0;
269 foreach ($links as $l) {
270 unset($l['linkdate']);
271 $l['id'] = $cpt;
272 $this->linkDB[$cpt++] = $l;
273 }
274
275 $this->linkDB->save($this->conf->get('resource.page_cache'));
276 $this->linkDB->reorder();
277
278 return true;
279 }
280
281 /**
282 * Rename tags starting with a '-' to work with tag exclusion search.
283 */
284 public function updateMethodRenameDashTags()
285 {
286 $linklist = $this->linkDB->filterSearch();
287 foreach ($linklist as $key => $link) {
288 $link['tags'] = preg_replace('/(^| )\-/', '$1', $link['tags']);
289 $link['tags'] = implode(' ', array_unique(BookmarkFilter::tagsStrToArray($link['tags'], true)));
290 $this->linkDB[$key] = $link;
291 }
292 $this->linkDB->save($this->conf->get('resource.page_cache'));
293 return true;
294 }
295
296 /**
297 * Initialize API settings:
298 * - api.enabled: true
299 * - api.secret: generated secret
300 */
301 public function updateMethodApiSettings()
302 {
303 if ($this->conf->exists('api.secret')) {
304 return true;
305 }
306
307 $this->conf->set('api.enabled', true);
308 $this->conf->set(
309 'api.secret',
310 generate_api_secret(
311 $this->conf->get('credentials.login'),
312 $this->conf->get('credentials.salt')
313 )
314 );
315 $this->conf->write($this->isLoggedIn);
316 return true;
317 }
318
319 /**
320 * New setting: theme name. If the default theme is used, nothing to do.
321 *
322 * If the user uses a custom theme, raintpl_tpl dir is updated to the parent directory,
323 * and the current theme is set as default in the theme setting.
324 *
325 * @return bool true if the update is successful, false otherwise.
326 */
327 public function updateMethodDefaultTheme()
328 {
329 // raintpl_tpl isn't the root template directory anymore.
330 // We run the update only if this folder still contains the template files.
331 $tplDir = $this->conf->get('resource.raintpl_tpl');
332 $tplFile = $tplDir . '/linklist.html';
333 if (!file_exists($tplFile)) {
334 return true;
335 }
336
337 $parent = dirname($tplDir);
338 $this->conf->set('resource.raintpl_tpl', $parent);
339 $this->conf->set('resource.theme', trim(str_replace($parent, '', $tplDir), '/'));
340 $this->conf->write($this->isLoggedIn);
341
342 // Dependency injection gore
343 RainTPL::$tpl_dir = $tplDir;
344
345 return true;
346 }
347
348 /**
349 * Move the file to inc/user.css to data/user.css.
350 *
351 * Note: Due to hardcoded paths, it's not unit testable. But one line of code should be fine.
352 *
353 * @return bool true if the update is successful, false otherwise.
354 */
355 public function updateMethodMoveUserCss()
356 {
357 if (!is_file('inc/user.css')) {
358 return true;
359 }
360
361 return rename('inc/user.css', 'data/user.css');
362 }
363
364 /**
365 * * `markdown_escape` is a new setting, set to true as default.
366 *
367 * If the markdown plugin was already enabled, escaping is disabled to avoid
368 * breaking existing entries.
369 */
370 public function updateMethodEscapeMarkdown()
371 {
372 if ($this->conf->exists('security.markdown_escape')) {
373 return true;
374 }
375
376 if (in_array('markdown', $this->conf->get('general.enabled_plugins'))) {
377 $this->conf->set('security.markdown_escape', false);
378 } else {
379 $this->conf->set('security.markdown_escape', true);
380 }
381 $this->conf->write($this->isLoggedIn);
382
383 return true;
384 }
385
386 /**
387 * Add 'http://' to Piwik URL the setting is set.
388 *
389 * @return bool true if the update is successful, false otherwise.
390 */
391 public function updateMethodPiwikUrl()
392 {
393 if (!$this->conf->exists('plugins.PIWIK_URL') || startsWith($this->conf->get('plugins.PIWIK_URL'), 'http')) {
394 return true;
395 }
396
397 $this->conf->set('plugins.PIWIK_URL', 'http://' . $this->conf->get('plugins.PIWIK_URL'));
398 $this->conf->write($this->isLoggedIn);
399
400 return true;
401 }
402
403 /**
404 * Use ATOM feed as default.
405 */
406 public function updateMethodAtomDefault()
407 {
408 if (!$this->conf->exists('feed.show_atom') || $this->conf->get('feed.show_atom') === true) {
409 return true;
410 }
411
412 $this->conf->set('feed.show_atom', true);
413 $this->conf->write($this->isLoggedIn);
414
415 return true;
416 }
417
418 /**
419 * Update updates.check_updates_branch setting.
420 *
421 * If the current major version digit matches the latest branch
422 * major version digit, we set the branch to `latest`,
423 * otherwise we'll check updates on the `stable` branch.
424 *
425 * No update required for the dev version.
426 *
427 * Note: due to hardcoded URL and lack of dependency injection, this is not unit testable.
428 *
429 * FIXME! This needs to be removed when we switch to first digit major version
430 * instead of the second one since the versionning process will change.
431 */
432 public function updateMethodCheckUpdateRemoteBranch()
433 {
434 if (SHAARLI_VERSION === 'dev' || $this->conf->get('updates.check_updates_branch') === 'latest') {
435 return true;
436 }
437
438 // Get latest branch major version digit
439 $latestVersion = ApplicationUtils::getLatestGitVersionCode(
440 'https://raw.githubusercontent.com/shaarli/Shaarli/latest/shaarli_version.php',
441 5
442 );
443 if (preg_match('/(\d+)\.\d+$/', $latestVersion, $matches) === false) {
444 return false;
445 }
446 $latestMajor = $matches[1];
447
448 // Get current major version digit
449 preg_match('/(\d+)\.\d+$/', SHAARLI_VERSION, $matches);
450 $currentMajor = $matches[1];
451
452 if ($currentMajor === $latestMajor) {
453 $branch = 'latest';
454 } else {
455 $branch = 'stable';
456 }
457 $this->conf->set('updates.check_updates_branch', $branch);
458 $this->conf->write($this->isLoggedIn);
459 return true;
460 }
461
462 /**
463 * Reset history store file due to date format change.
464 */
465 public function updateMethodResetHistoryFile()
466 {
467 if (is_file($this->conf->get('resource.history'))) {
468 unlink($this->conf->get('resource.history'));
469 }
470 return true;
471 }
472
473 /**
474 * Save the datastore -> the link order is now applied when bookmarks are saved.
475 */
476 public function updateMethodReorderDatastore()
477 {
478 $this->linkDB->save($this->conf->get('resource.page_cache'));
479 return true;
480 }
481
482 /**
483 * Change privateonly session key to visibility.
484 */
485 public function updateMethodVisibilitySession()
486 {
487 if (isset($_SESSION['privateonly'])) {
488 unset($_SESSION['privateonly']);
489 $_SESSION['visibility'] = 'private';
490 }
491 return true;
492 }
493
494 /**
495 * Add download size and timeout to the configuration file
496 *
497 * @return bool true if the update is successful, false otherwise.
498 */
499 public function updateMethodDownloadSizeAndTimeoutConf()
500 {
501 if ($this->conf->exists('general.download_max_size')
502 && $this->conf->exists('general.download_timeout')
503 ) {
504 return true;
505 }
506
507 if (!$this->conf->exists('general.download_max_size')) {
508 $this->conf->set('general.download_max_size', 1024 * 1024 * 4);
509 }
510
511 if (!$this->conf->exists('general.download_timeout')) {
512 $this->conf->set('general.download_timeout', 30);
513 }
514
515 $this->conf->write($this->isLoggedIn);
516 return true;
517 }
518
519 /**
520 * * Move thumbnails management to WebThumbnailer, coming with new settings.
521 */
522 public function updateMethodWebThumbnailer()
523 {
524 if ($this->conf->exists('thumbnails.mode')) {
525 return true;
526 }
527
528 $thumbnailsEnabled = extension_loaded('gd') && $this->conf->get('thumbnail.enable_thumbnails', true);
529 $this->conf->set('thumbnails.mode', $thumbnailsEnabled ? Thumbnailer::MODE_ALL : Thumbnailer::MODE_NONE);
530 $this->conf->set('thumbnails.width', 125);
531 $this->conf->set('thumbnails.height', 90);
532 $this->conf->remove('thumbnail');
533 $this->conf->write(true);
534
535 if ($thumbnailsEnabled) {
536 $this->session['warnings'][] = t(
537 t('You have enabled or changed thumbnails mode.') .
538 '<a href="./admin/thumbnails">' . t('Please synchronize them.') . '</a>'
539 );
540 }
541
542 return true;
543 }
544
545 /**
546 * Set sticky = false on all bookmarks
547 *
548 * @return bool true if the update is successful, false otherwise.
549 */
550 public function updateMethodSetSticky()
551 {
552 foreach ($this->linkDB as $key => $link) {
553 if (isset($link['sticky'])) {
554 return true;
555 }
556 $link['sticky'] = false;
557 $this->linkDB[$key] = $link;
558 }
559
560 $this->linkDB->save($this->conf->get('resource.page_cache'));
561
562 return true;
563 }
564
565 /**
566 * Remove redirector settings.
567 */
568 public function updateMethodRemoveRedirector()
569 {
570 $this->conf->remove('redirector');
571 $this->conf->write(true);
572 return true;
573 }
574
575 /**
576 * Migrate the legacy arrays to Bookmark objects.
577 * Also make a backup of the datastore.
578 */
579 public function updateMethodMigrateDatabase()
580 {
581 $save = $this->conf->get('resource.data_dir') . '/datastore.' . date('YmdHis') . '_1.php';
582 if (! copy($this->conf->get('resource.datastore'), $save)) {
583 die('Could not backup the datastore.');
584 }
585
586 $linksArray = new BookmarkArray();
587 foreach ($this->linkDB as $key => $link) {
588 $linksArray[$key] = (new Bookmark())->fromArray($link);
589 }
590 $linksIo = new BookmarkIO($this->conf);
591 $linksIo->write($linksArray);
592
593 return true;
594 }
595
596 /**
597 * Write the `formatter` setting in config file.
598 * Use markdown if the markdown plugin is enabled, the default one otherwise.
599 * Also remove markdown plugin setting as it is now integrated to the core.
600 */
601 public function updateMethodFormatterSetting()
602 {
603 if (!$this->conf->exists('formatter') || $this->conf->get('formatter') === 'default') {
604 $enabledPlugins = $this->conf->get('general.enabled_plugins');
605 if (($pos = array_search('markdown', $enabledPlugins)) !== false) {
606 $formatter = 'markdown';
607 unset($enabledPlugins[$pos]);
608 $this->conf->set('general.enabled_plugins', array_values($enabledPlugins));
609 } else {
610 $formatter = 'default';
611 }
612 $this->conf->set('formatter', $formatter);
613 $this->conf->write(true);
614 }
615
616 return true;
617 }
618}
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 28665941..b83f16f8 100644
--- a/application/netscape/NetscapeBookmarkUtils.php
+++ b/application/netscape/NetscapeBookmarkUtils.php
@@ -6,56 +6,69 @@ 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\LinkDB; 11use Shaarli\Bookmark\Bookmark;
12use Shaarli\Bookmark\BookmarkServiceInterface;
11use Shaarli\Config\ConfigManager; 13use Shaarli\Config\ConfigManager;
14use Shaarli\Formatter\BookmarkFormatter;
12use Shaarli\History; 15use Shaarli\History;
13use Shaarli\NetscapeBookmarkParser\NetscapeBookmarkParser; 16use Shaarli\NetscapeBookmarkParser\NetscapeBookmarkParser;
14 17
15/** 18/**
16 * Utilities to import and export bookmarks using the Netscape format 19 * Utilities to import and export bookmarks using the Netscape format
17 * TODO: Not static, use a container.
18 */ 20 */
19class NetscapeBookmarkUtils 21class NetscapeBookmarkUtils
20{ 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 }
21 38
22 /** 39 /**
23 * Filters links and adds Netscape-formatted fields 40 * Filters bookmarks and adds Netscape-formatted fields
24 * 41 *
25 * Added fields: 42 * Added fields:
26 * - timestamp link addition date, using the Unix epoch format 43 * - timestamp link addition date, using the Unix epoch format
27 * - taglist comma-separated tag list 44 * - taglist comma-separated tag list
28 * 45 *
29 * @param LinkDB $linkDb Link datastore 46 * @param BookmarkFormatter $formatter instance
30 * @param string $selection Which links to export: (all|private|public) 47 * @param string $selection Which bookmarks to export: (all|private|public)
31 * @param bool $prependNoteUrl Prepend note permalinks with the server's URL 48 * @param bool $prependNoteUrl Prepend note permalinks with the server's URL
32 * @param string $indexUrl Absolute URL of the Shaarli index page 49 * @param string $indexUrl Absolute URL of the Shaarli index page
33 * 50 *
34 * @throws Exception Invalid export selection 51 * @return array The bookmarks to be exported, with additional fields
35 * 52 *
36 * @return array The links to be exported, with additional fields 53 * @throws Exception Invalid export selection
37 */ 54 */
38 public static function filterAndFormat($linkDb, $selection, $prependNoteUrl, $indexUrl) 55 public function filterAndFormat(
39 { 56 $formatter,
57 $selection,
58 $prependNoteUrl,
59 $indexUrl
60 ) {
40 // see tpl/export.html for possible values 61 // see tpl/export.html for possible values
41 if (!in_array($selection, array('all', 'public', 'private'))) { 62 if (!in_array($selection, array('all', 'public', 'private'))) {
42 throw new Exception(t('Invalid export selection:') . ' "' . $selection . '"'); 63 throw new Exception(t('Invalid export selection:') . ' "' . $selection . '"');
43 } 64 }
44 65
45 $bookmarkLinks = array(); 66 $bookmarkLinks = array();
46 foreach ($linkDb as $link) { 67 foreach ($this->bookmarkService->search([], $selection) as $bookmark) {
47 if ($link['private'] != 0 && $selection == 'public') { 68 $link = $formatter->format($bookmark);
48 continue; 69 $link['taglist'] = implode(',', $bookmark->getTags());
49 } 70 if ($bookmark->isNote() && $prependNoteUrl) {
50 if ($link['private'] == 0 && $selection == 'private') { 71 $link['url'] = rtrim($indexUrl, '/') . '/' . ltrim($link['url'], '/');
51 continue;
52 }
53 $date = $link['created'];
54 $link['timestamp'] = $date->getTimestamp();
55 $link['taglist'] = str_replace(' ', ',', $link['tags']);
56
57 if (is_note($link['url']) && $prependNoteUrl) {
58 $link['url'] = $indexUrl . $link['url'];
59 } 72 }
60 73
61 $bookmarkLinks[] = $link; 74 $bookmarkLinks[] = $link;
@@ -65,66 +78,28 @@ 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 links were imported
73 * @param int $overwriteCount how many links were overwritten
74 * @param int $skipCount how many links 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 links imported, %d links overwritten, %d links 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 LinkDB $linkDb 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, $linkDb, $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 links? 99 // Overwrite existing bookmarks?
125 $overwrite = !empty($post['overwrite']); 100 $overwrite = !empty($post['overwrite']);
126 101
127 // Add tags to all imported links? 102 // Add tags to all imported bookmarks?
128 if (empty($post['default_tags'])) { 103 if (empty($post['default_tags'])) {
129 $defaultTags = array(); 104 $defaultTags = array();
130 } else { 105 } else {
@@ -134,18 +109,18 @@ class NetscapeBookmarkUtils
134 ); 109 );
135 } 110 }
136 111
137 // links are imported as public by default 112 // bookmarks are imported as public by default
138 $defaultPrivacy = 0; 113 $defaultPrivacy = 0;
139 114
140 $parser = new NetscapeBookmarkParser( 115 $parser = new NetscapeBookmarkParser(
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',
@@ -164,22 +139,18 @@ class NetscapeBookmarkUtils
164 // use value from the imported file 139 // use value from the imported file
165 $private = $bkm['pub'] == '1' ? 0 : 1; 140 $private = $bkm['pub'] == '1' ? 0 : 1;
166 } elseif ($post['privacy'] == 'private') { 141 } elseif ($post['privacy'] == 'private') {
167 // all imported links are private 142 // all imported bookmarks are private
168 $private = 1; 143 $private = 1;
169 } elseif ($post['privacy'] == 'public') { 144 } elseif ($post['privacy'] == 'public') {
170 // all imported links are public 145 // all imported bookmarks are public
171 $private = 0; 146 $private = 0;
172 } 147 }
173 148
174 $newLink = array( 149 $link = $this->bookmarkService->findByUrl($bkm['uri']);
175 'title' => $bkm['title'], 150 $existingLink = $link !== null;
176 'url' => $bkm['uri'], 151 if (! $existingLink) {
177 'description' => $bkm['note'], 152 $link = new Bookmark();
178 'private' => $private, 153 }
179 'tags' => $bkm['tags']
180 );
181
182 $existingLink = $linkDb->getLinkFromUrl($bkm['uri']);
183 154
184 if ($existingLink !== false) { 155 if ($existingLink !== false) {
185 if ($overwrite === false) { 156 if ($overwrite === false) {
@@ -188,32 +159,30 @@ class NetscapeBookmarkUtils
188 continue; 159 continue;
189 } 160 }
190 161
191 // Overwrite an existing link, keep its date 162 $link->setUpdated(new DateTime());
192 $newLink['id'] = $existingLink['id'];
193 $newLink['created'] = $existingLink['created'];
194 $newLink['updated'] = new DateTime();
195 $newLink['shorturl'] = $existingLink['shorturl'];
196 $linkDb[$existingLink['id']] = $newLink;
197 $importCount++;
198 $overwriteCount++; 163 $overwriteCount++;
199 continue; 164 } else {
165 $newLinkDate = new DateTime('@' . strval($bkm['time']));
166 $newLinkDate->setTimezone(new DateTimeZone(date_default_timezone_get()));
167 $link->setCreated($newLinkDate);
200 } 168 }
201 169
202 // Add a new link - @ used for UNIX timestamps 170 $link->setTitle($bkm['title']);
203 $newLinkDate = new DateTime('@' . strval($bkm['time'])); 171 $link->setUrl($bkm['uri'], $this->conf->get('security.allowed_protocols'));
204 $newLinkDate->setTimezone(new DateTimeZone(date_default_timezone_get())); 172 $link->setDescription($bkm['note']);
205 $newLink['created'] = $newLinkDate; 173 $link->setPrivate($private);
206 $newLink['id'] = $linkDb->getNextId(); 174 $link->setTagsString($bkm['tags']);
207 $newLink['shorturl'] = link_small_hash($newLink['created'], $newLink['id']); 175
208 $linkDb[$newLink['id']] = $newLink; 176 $this->bookmarkService->addOrSet($link, false);
209 $importCount++; 177 $importCount++;
210 } 178 }
211 179
212 $linkDb->save($conf->get('resource.page_cache')); 180 $this->bookmarkService->save();
213 $history->importLinks(); 181 $this->history->importLinks();
214 182
215 $duration = time() - $start; 183 $duration = time() - $start;
216 return self::importStatus( 184
185 return $this->importStatus(
217 $filename, 186 $filename,
218 $filesize, 187 $filesize,
219 $importCount, 188 $importCount,
@@ -222,4 +191,39 @@ class NetscapeBookmarkUtils
222 $duration 191 $duration
223 ); 192 );
224 } 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 }
225} 229}
diff --git a/application/plugin/PluginManager.php b/application/plugin/PluginManager.php
index f7b24a8e..1b2197c9 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,35 @@ 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 'bookmarkService' => '_BOOKMARK_SERVICE_',
108 $data['_LOGGEDIN_'] = $params['loggedin']; 108 ];
109
110 foreach ($metadataParameters as $parameter => $metaKey) {
111 if (array_key_exists($parameter, $params)) {
112 $data[$metaKey] = $params[$parameter];
113 }
109 } 114 }
110 115
111 foreach ($this->loadedPlugins as $plugin) { 116 foreach ($this->loadedPlugins as $plugin) {
112 $hookFunction = $this->buildHookName($hook, $plugin); 117 $hookFunction = $this->buildHookName($hook, $plugin);
113 118
114 if (function_exists($hookFunction)) { 119 if (function_exists($hookFunction)) {
115 $data = call_user_func($hookFunction, $data, $this->conf); 120 try {
121 $data = call_user_func($hookFunction, $data, $this->conf);
122 } catch (\Throwable $e) {
123 $error = $plugin . t(' [plugin incompatibility]: ') . $e->getMessage();
124 $this->errors = array_unique(array_merge($this->errors, [$error]));
125 }
116 } 126 }
117 } 127 }
128
129 foreach ($metadataParameters as $metaKey) {
130 unset($data[$metaKey]);
131 }
118 } 132 }
119 133
120 /** 134 /**
diff --git a/application/render/PageBuilder.php b/application/render/PageBuilder.php
index 3f86fc26..41b357dd 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 exceptions\MissingBasePathException;
6use RainTPL; 7use RainTPL;
7use Shaarli\ApplicationUtils; 8use Shaarli\ApplicationUtils;
8use Shaarli\Bookmark\LinkDB; 9use Shaarli\Bookmark\BookmarkServiceInterface;
9use Shaarli\Config\ConfigManager; 10use Shaarli\Config\ConfigManager;
11use Shaarli\Security\SessionManager;
10use Shaarli\Thumbnailer; 12use Shaarli\Thumbnailer;
11 13
12/** 14/**
@@ -34,9 +36,9 @@ class PageBuilder
34 protected $session; 36 protected $session;
35 37
36 /** 38 /**
37 * @var LinkDB $linkDB instance. 39 * @var BookmarkServiceInterface $bookmarkService instance.
38 */ 40 */
39 protected $linkDB; 41 protected $bookmarkService;
40 42
41 /** 43 /**
42 * @var null|string XSRF token 44 * @var null|string XSRF token
@@ -52,23 +54,32 @@ class PageBuilder
52 * PageBuilder constructor. 54 * PageBuilder constructor.
53 * $tpl is initialized at false for lazy loading. 55 * $tpl is initialized at false for lazy loading.
54 * 56 *
55 * @param ConfigManager $conf Configuration Manager instance (reference). 57 * @param ConfigManager $conf Configuration Manager instance (reference).
56 * @param array $session $_SESSION array 58 * @param array $session $_SESSION array
57 * @param LinkDB $linkDB instance. 59 * @param BookmarkServiceInterface $linkDB instance.
58 * @param string $token Session token 60 * @param string $token Session token
59 * @param bool $isLoggedIn 61 * @param bool $isLoggedIn
60 */ 62 */
61 public function __construct(&$conf, $session, $linkDB = null, $token = null, $isLoggedIn = false) 63 public function __construct(&$conf, $session, $linkDB = null, $token = null, $isLoggedIn = false)
62 { 64 {
63 $this->tpl = false; 65 $this->tpl = false;
64 $this->conf = $conf; 66 $this->conf = $conf;
65 $this->session = $session; 67 $this->session = $session;
66 $this->linkDB = $linkDB; 68 $this->bookmarkService = $linkDB;
67 $this->token = $token; 69 $this->token = $token;
68 $this->isLoggedIn = $isLoggedIn; 70 $this->isLoggedIn = $isLoggedIn;
69 } 71 }
70 72
71 /** 73 /**
74 * Reset current state of template rendering.
75 * Mostly useful for error handling. We remove everything, and display the error template.
76 */
77 public function reset(): void
78 {
79 $this->tpl = false;
80 }
81
82 /**
72 * Initialize all default tpl tags. 83 * Initialize all default tpl tags.
73 */ 84 */
74 private function initialize() 85 private function initialize()
@@ -125,8 +136,8 @@ class PageBuilder
125 136
126 $this->tpl->assign('language', $this->conf->get('translation.language')); 137 $this->tpl->assign('language', $this->conf->get('translation.language'));
127 138
128 if ($this->linkDB !== null) { 139 if ($this->bookmarkService !== null) {
129 $this->tpl->assign('tags', $this->linkDB->linksCountPerTag()); 140 $this->tpl->assign('tags', escape($this->bookmarkService->bookmarksCountPerTag()));
130 } 141 }
131 142
132 $this->tpl->assign( 143 $this->tpl->assign(
@@ -136,16 +147,43 @@ class PageBuilder
136 $this->tpl->assign('thumbnails_width', $this->conf->get('thumbnails.width')); 147 $this->tpl->assign('thumbnails_width', $this->conf->get('thumbnails.width'));
137 $this->tpl->assign('thumbnails_height', $this->conf->get('thumbnails.height')); 148 $this->tpl->assign('thumbnails_height', $this->conf->get('thumbnails.height'));
138 149
139 if (!empty($_SESSION['warnings'])) { 150 $this->tpl->assign('formatter', $this->conf->get('formatter', 'default'));
140 $this->tpl->assign('global_warnings', $_SESSION['warnings']); 151
141 unset($_SESSION['warnings']); 152 $this->tpl->assign('links_per_page', $this->session['LINKS_PER_PAGE']);
142 }
143 153
144 // To be removed with a proper theme configuration. 154 // To be removed with a proper theme configuration.
145 $this->tpl->assign('conf', $this->conf); 155 $this->tpl->assign('conf', $this->conf);
146 } 156 }
147 157
148 /** 158 /**
159 * Affect variable after controller processing.
160 * Used for alert messages.
161 */
162 protected function finalize(string $basePath): void
163 {
164 // TODO: use the SessionManager
165 $messageKeys = [
166 SessionManager::KEY_SUCCESS_MESSAGES,
167 SessionManager::KEY_WARNING_MESSAGES,
168 SessionManager::KEY_ERROR_MESSAGES
169 ];
170 foreach ($messageKeys as $messageKey) {
171 if (!empty($_SESSION[$messageKey])) {
172 $this->tpl->assign('global_' . $messageKey, $_SESSION[$messageKey]);
173 unset($_SESSION[$messageKey]);
174 }
175 }
176
177 $this->assign('base_path', $basePath);
178 $this->assign(
179 'asset_path',
180 $basePath . '/' .
181 rtrim($this->conf->get('resource.raintpl_tpl', 'tpl'), '/') . '/' .
182 $this->conf->get('resource.theme', 'default')
183 );
184 }
185
186 /**
149 * The following assign() method is basically the same as RainTPL (except lazy loading) 187 * The following assign() method is basically the same as RainTPL (except lazy loading)
150 * 188 *
151 * @param string $placeholder Template placeholder. 189 * @param string $placeholder Template placeholder.
@@ -183,33 +221,21 @@ class PageBuilder
183 } 221 }
184 222
185 /** 223 /**
186 * Render a specific page (using a template file). 224 * Render a specific page as string (using a template file).
187 * e.g. $pb->renderPage('picwall'); 225 * e.g. $pb->render('picwall');
188 * 226 *
189 * @param string $page Template filename (without extension). 227 * @param string $page Template filename (without extension).
228 *
229 * @return string Processed template content
190 */ 230 */
191 public function renderPage($page) 231 public function render(string $page, string $basePath): string
192 { 232 {
193 if ($this->tpl === false) { 233 if ($this->tpl === false) {
194 $this->initialize(); 234 $this->initialize();
195 } 235 }
196 236
197 $this->tpl->draw($page); 237 $this->finalize($basePath);
198 }
199 238
200 /** 239 return $this->tpl->draw($page, true);
201 * Render a 404 page (uses the template : tpl/404.tpl)
202 * usage: $PAGE->render404('The link was deleted')
203 *
204 * @param string $message A message to display what is not found
205 */
206 public function render404($message = '')
207 {
208 if (empty($message)) {
209 $message = t('The page you are trying to reach does not exist or has been deleted.');
210 }
211 header($_SERVER['SERVER_PROTOCOL'] . ' ' . t('404 Not Found'));
212 $this->tpl->assign('error_message', $message);
213 $this->renderPage('404');
214 } 240 }
215} 241}
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..8af8228a
--- /dev/null
+++ b/application/render/TemplatePage.php
@@ -0,0 +1,33 @@
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 ERROR = 'error';
18 public const EXPORT = 'export';
19 public const NETSCAPE_EXPORT_BOOKMARKS = 'export.bookmarks';
20 public const FEED_ATOM = 'feed.atom';
21 public const FEED_RSS = 'feed.rss';
22 public const IMPORT = 'import';
23 public const INSTALL = 'install';
24 public const LINKLIST = 'linklist';
25 public const LOGIN = 'loginform';
26 public const OPEN_SEARCH = 'opensearch';
27 public const PICTURE_WALL = 'picwall';
28 public const PLUGINS_ADMIN = 'pluginsadmin';
29 public const TAG_CLOUD = 'tag.cloud';
30 public const TAG_LIST = 'tag.list';
31 public const THUMBNAILS = 'thumbnails';
32 public const TOOLS = 'tools';
33}
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 0b0ce0b1..d74c3118 100644
--- a/application/security/LoginManager.php
+++ b/application/security/LoginManager.php
@@ -1,6 +1,7 @@
1<?php 1<?php
2namespace Shaarli\Security; 2namespace Shaarli\Security;
3 3
4use Exception;
4use Shaarli\Config\ConfigManager; 5use Shaarli\Config\ConfigManager;
5 6
6/** 7/**
@@ -8,9 +9,6 @@ use Shaarli\Config\ConfigManager;
8 */ 9 */
9class LoginManager 10class LoginManager
10{ 11{
11 /** @var string Name of the cookie set after logging in **/
12 public static $STAY_SIGNED_IN_COOKIE = 'shaarli_staySignedIn';
13
14 /** @var array A reference to the $_GLOBALS array */ 12 /** @var array A reference to the $_GLOBALS array */
15 protected $globals = []; 13 protected $globals = [];
16 14
@@ -31,17 +29,21 @@ class LoginManager
31 29
32 /** @var string User sign-in token depending on remote IP and credentials */ 30 /** @var string User sign-in token depending on remote IP and credentials */
33 protected $staySignedInToken = ''; 31 protected $staySignedInToken = '';
32 /** @var CookieManager */
33 protected $cookieManager;
34 34
35 /** 35 /**
36 * Constructor 36 * Constructor
37 * 37 *
38 * @param ConfigManager $configManager Configuration Manager instance 38 * @param ConfigManager $configManager Configuration Manager instance
39 * @param SessionManager $sessionManager SessionManager instance 39 * @param SessionManager $sessionManager SessionManager instance
40 * @param CookieManager $cookieManager CookieManager instance
40 */ 41 */
41 public function __construct($configManager, $sessionManager) 42 public function __construct($configManager, $sessionManager, $cookieManager)
42 { 43 {
43 $this->configManager = $configManager; 44 $this->configManager = $configManager;
44 $this->sessionManager = $sessionManager; 45 $this->sessionManager = $sessionManager;
46 $this->cookieManager = $cookieManager;
45 $this->banManager = new BanManager( 47 $this->banManager = new BanManager(
46 $this->configManager->get('security.trusted_proxies', []), 48 $this->configManager->get('security.trusted_proxies', []),
47 $this->configManager->get('security.ban_after'), 49 $this->configManager->get('security.ban_after'),
@@ -85,10 +87,9 @@ class LoginManager
85 /** 87 /**
86 * Check user session state and validity (expiration) 88 * Check user session state and validity (expiration)
87 * 89 *
88 * @param array $cookie The $_COOKIE array
89 * @param string $clientIpId Client IP address identifier 90 * @param string $clientIpId Client IP address identifier
90 */ 91 */
91 public function checkLoginState($cookie, $clientIpId) 92 public function checkLoginState($clientIpId)
92 { 93 {
93 if (! $this->configManager->exists('credentials.login')) { 94 if (! $this->configManager->exists('credentials.login')) {
94 // Shaarli is not configured yet 95 // Shaarli is not configured yet
@@ -96,9 +97,7 @@ class LoginManager
96 return; 97 return;
97 } 98 }
98 99
99 if (isset($cookie[self::$STAY_SIGNED_IN_COOKIE]) 100 if ($this->staySignedInToken === $this->cookieManager->getCookieParameter(CookieManager::STAY_SIGNED_IN)) {
100 && $cookie[self::$STAY_SIGNED_IN_COOKIE] === $this->staySignedInToken
101 ) {
102 // The user client has a valid stay-signed-in cookie 101 // The user client has a valid stay-signed-in cookie
103 // Session information is updated with the current client information 102 // Session information is updated with the current client information
104 $this->sessionManager->storeLoginInfo($clientIpId); 103 $this->sessionManager->storeLoginInfo($clientIpId);
@@ -139,26 +138,86 @@ class LoginManager
139 */ 138 */
140 public function checkCredentials($remoteIp, $clientIpId, $login, $password) 139 public function checkCredentials($remoteIp, $clientIpId, $login, $password)
141 { 140 {
142 $hash = sha1($password . $login . $this->configManager->get('credentials.salt')); 141 // Check login matches config
142 if ($login !== $this->configManager->get('credentials.login')) {
143 return false;
144 }
143 145
144 if ($login != $this->configManager->get('credentials.login') 146 // Check credentials
145 || $hash != $this->configManager->get('credentials.hash') 147 try {
146 ) { 148 $useLdapLogin = !empty($this->configManager->get('ldap.host'));
149 if ((false === $useLdapLogin && $this->checkCredentialsFromLocalConfig($login, $password))
150 || (true === $useLdapLogin && $this->checkCredentialsFromLdap($login, $password))
151 ) {
152 $this->sessionManager->storeLoginInfo($clientIpId);
153 logm(
154 $this->configManager->get('resource.log'),
155 $remoteIp,
156 'Login successful'
157 );
158 return true;
159 }
160 }
161 catch(Exception $exception) {
147 logm( 162 logm(
148 $this->configManager->get('resource.log'), 163 $this->configManager->get('resource.log'),
149 $remoteIp, 164 $remoteIp,
150 'Login failed for user ' . $login 165 'Exception while checking credentials: ' . $exception
151 ); 166 );
152 return false;
153 } 167 }
154 168
155 $this->sessionManager->storeLoginInfo($clientIpId);
156 logm( 169 logm(
157 $this->configManager->get('resource.log'), 170 $this->configManager->get('resource.log'),
158 $remoteIp, 171 $remoteIp,
159 'Login successful' 172 'Login failed for user ' . $login
173 );
174 return false;
175 }
176
177
178 /**
179 * Check user credentials from local config
180 *
181 * @param string $login Username
182 * @param string $password Password
183 *
184 * @return bool true if the provided credentials are valid, false otherwise
185 */
186 public function checkCredentialsFromLocalConfig($login, $password) {
187 $hash = sha1($password . $login . $this->configManager->get('credentials.salt'));
188
189 return $login == $this->configManager->get('credentials.login')
190 && $hash == $this->configManager->get('credentials.hash');
191 }
192
193 /**
194 * Check user credentials are valid through LDAP bind
195 *
196 * @param string $remoteIp Remote client IP address
197 * @param string $clientIpId Client IP address identifier
198 * @param string $login Username
199 * @param string $password Password
200 *
201 * @return bool true if the provided credentials are valid, false otherwise
202 */
203 public function checkCredentialsFromLdap($login, $password, $connect = null, $bind = null)
204 {
205 $connect = $connect ?? function($host) {
206 $resource = ldap_connect($host);
207
208 ldap_set_option($resource, LDAP_OPT_PROTOCOL_VERSION, 3);
209
210 return $resource;
211 };
212 $bind = $bind ?? function($handle, $dn, $password) {
213 return ldap_bind($handle, $dn, $password);
214 };
215
216 return $bind(
217 $connect($this->configManager->get('ldap.host')),
218 sprintf($this->configManager->get('ldap.dn'), $login),
219 $password
160 ); 220 );
161 return true;
162 } 221 }
163 222
164 /** 223 /**
diff --git a/application/security/SessionManager.php b/application/security/SessionManager.php
index b8b8ab8d..36df8c1c 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
@@ -196,4 +222,84 @@ class SessionManager
196 } 222 }
197 return true; 223 return true;
198 } 224 }
225
226 /** @return array Local reference to the global $_SESSION array */
227 public function getSession(): array
228 {
229 return $this->session;
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 public function cookieParameters(int $lifeTime, string $path, string $domain): bool
297 {
298 return session_set_cookie_params($lifeTime, $path, $domain);
299 }
300
301 public function regenerateId(bool $deleteOldSession = false): bool
302 {
303 return session_regenerate_id($deleteOldSession);
304 }
199} 305}
diff --git a/application/updater/Updater.php b/application/updater/Updater.php
index beb9ea9b..88a7bc7b 100644
--- a/application/updater/Updater.php
+++ b/application/updater/Updater.php
@@ -2,25 +2,14 @@
2 2
3namespace Shaarli\Updater; 3namespace Shaarli\Updater;
4 4
5use Exception; 5use Shaarli\Bookmark\BookmarkServiceInterface;
6use RainTPL;
7use ReflectionClass;
8use ReflectionException;
9use ReflectionMethod;
10use Shaarli\ApplicationUtils;
11use Shaarli\Bookmark\LinkDB;
12use Shaarli\Bookmark\LinkFilter;
13use Shaarli\Config\ConfigJson;
14use Shaarli\Config\ConfigManager; 6use Shaarli\Config\ConfigManager;
15use Shaarli\Config\ConfigPhp;
16use Shaarli\Exceptions\IOException;
17use Shaarli\Thumbnailer;
18use Shaarli\Updater\Exception\UpdaterException; 7use Shaarli\Updater\Exception\UpdaterException;
19 8
20/** 9/**
21 * Class updater. 10 * Class Updater.
22 * Used to update stuff when a new Shaarli's version is reached. 11 * Used to update stuff when a new Shaarli's version is reached.
23 * Update methods are ran only once, and the stored in a JSON file. 12 * Update methods are ran only once, and the stored in a TXT file.
24 */ 13 */
25class Updater 14class Updater
26{ 15{
@@ -30,9 +19,9 @@ class Updater
30 protected $doneUpdates; 19 protected $doneUpdates;
31 20
32 /** 21 /**
33 * @var LinkDB instance. 22 * @var BookmarkServiceInterface instance.
34 */ 23 */
35 protected $linkDB; 24 protected $bookmarkService;
36 25
37 /** 26 /**
38 * @var ConfigManager $conf Configuration Manager instance. 27 * @var ConfigManager $conf Configuration Manager instance.
@@ -45,36 +34,32 @@ class Updater
45 protected $isLoggedIn; 34 protected $isLoggedIn;
46 35
47 /** 36 /**
48 * @var array $_SESSION 37 * @var \ReflectionMethod[] List of current class methods.
49 */ 38 */
50 protected $session; 39 protected $methods;
51 40
52 /** 41 /**
53 * @var ReflectionMethod[] List of current class methods. 42 * @var string $basePath Shaarli root directory (from HTTP Request)
54 */ 43 */
55 protected $methods; 44 protected $basePath = null;
56 45
57 /** 46 /**
58 * Object constructor. 47 * Object constructor.
59 * 48 *
60 * @param array $doneUpdates Updates which are already done. 49 * @param array $doneUpdates Updates which are already done.
61 * @param LinkDB $linkDB LinkDB instance. 50 * @param BookmarkServiceInterface $linkDB LinksService instance.
62 * @param ConfigManager $conf Configuration Manager instance. 51 * @param ConfigManager $conf Configuration Manager instance.
63 * @param boolean $isLoggedIn True if the user is logged in. 52 * @param boolean $isLoggedIn True if the user is logged in.
64 * @param array $session $_SESSION (by reference)
65 *
66 * @throws ReflectionException
67 */ 53 */
68 public function __construct($doneUpdates, $linkDB, $conf, $isLoggedIn, &$session = []) 54 public function __construct($doneUpdates, $linkDB, $conf, $isLoggedIn)
69 { 55 {
70 $this->doneUpdates = $doneUpdates; 56 $this->doneUpdates = $doneUpdates;
71 $this->linkDB = $linkDB; 57 $this->bookmarkService = $linkDB;
72 $this->conf = $conf; 58 $this->conf = $conf;
73 $this->isLoggedIn = $isLoggedIn; 59 $this->isLoggedIn = $isLoggedIn;
74 $this->session = &$session;
75 60
76 // Retrieve all update methods. 61 // Retrieve all update methods.
77 $class = new ReflectionClass($this); 62 $class = new \ReflectionClass($this);
78 $this->methods = $class->getMethods(); 63 $this->methods = $class->getMethods();
79 } 64 }
80 65
@@ -82,13 +67,15 @@ class Updater
82 * Run all new updates. 67 * Run all new updates.
83 * 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).
84 * 69 *
70 * @param string $basePath Shaarli root directory (from HTTP Request)
71 *
85 * @return array An array containing ran updates. 72 * @return array An array containing ran updates.
86 * 73 *
87 * @throws UpdaterException If something went wrong. 74 * @throws UpdaterException If something went wrong.
88 */ 75 */
89 public function update() 76 public function update(string $basePath = null)
90 { 77 {
91 $updatesRan = array(); 78 $updatesRan = [];
92 79
93 // If the user isn't logged in, exit without updating. 80 // If the user isn't logged in, exit without updating.
94 if ($this->isLoggedIn !== true) { 81 if ($this->isLoggedIn !== true) {
@@ -96,12 +83,12 @@ class Updater
96 } 83 }
97 84
98 if ($this->methods === null) { 85 if ($this->methods === null) {
99 throw new UpdaterException(t('Couldn\'t retrieve updater class methods.')); 86 throw new UpdaterException('Couldn\'t retrieve LegacyUpdater class methods.');
100 } 87 }
101 88
102 foreach ($this->methods as $method) { 89 foreach ($this->methods as $method) {
103 // Not an update method or already done, pass. 90 // Not an update method or already done, pass.
104 if (!startsWith($method->getName(), 'updateMethod') 91 if (! startsWith($method->getName(), 'updateMethod')
105 || in_array($method->getName(), $this->doneUpdates) 92 || in_array($method->getName(), $this->doneUpdates)
106 ) { 93 ) {
107 continue; 94 continue;
@@ -114,7 +101,7 @@ class Updater
114 if ($res === true) { 101 if ($res === true) {
115 $updatesRan[] = $method->getName(); 102 $updatesRan[] = $method->getName();
116 } 103 }
117 } catch (Exception $e) { 104 } catch (\Exception $e) {
118 throw new UpdaterException($method, $e); 105 throw new UpdaterException($method, $e);
119 } 106 }
120 } 107 }
@@ -132,431 +119,61 @@ class Updater
132 return $this->doneUpdates; 119 return $this->doneUpdates;
133 } 120 }
134 121
135 /** 122 public function readUpdates(string $updatesFilepath): array
136 * Move deprecated options.php to config.php.
137 *
138 * Milestone 0.9 (old versioning) - shaarli/Shaarli#41:
139 * options.php is not supported anymore.
140 */
141 public function updateMethodMergeDeprecatedConfigFile()
142 {
143 if (is_file($this->conf->get('resource.data_dir') . '/options.php')) {
144 include $this->conf->get('resource.data_dir') . '/options.php';
145
146 // Load GLOBALS into config
147 $allowedKeys = array_merge(ConfigPhp::$ROOT_KEYS);
148 $allowedKeys[] = 'config';
149 foreach ($GLOBALS as $key => $value) {
150 if (in_array($key, $allowedKeys)) {
151 $this->conf->set($key, $value);
152 }
153 }
154 $this->conf->write($this->isLoggedIn);
155 unlink($this->conf->get('resource.data_dir') . '/options.php');
156 }
157
158 return true;
159 }
160
161 /**
162 * Move old configuration in PHP to the new config system in JSON format.
163 *
164 * Will rename 'config.php' into 'config.save.php' and create 'config.json.php'.
165 * It will also convert legacy setting keys to the new ones.
166 */
167 public function updateMethodConfigToJson()
168 {
169 // JSON config already exists, nothing to do.
170 if ($this->conf->getConfigIO() instanceof ConfigJson) {
171 return true;
172 }
173
174 $configPhp = new ConfigPhp();
175 $configJson = new ConfigJson();
176 $oldConfig = $configPhp->read($this->conf->getConfigFile() . '.php');
177 rename($this->conf->getConfigFileExt(), $this->conf->getConfigFile() . '.save.php');
178 $this->conf->setConfigIO($configJson);
179 $this->conf->reload();
180
181 $legacyMap = array_flip(ConfigPhp::$LEGACY_KEYS_MAPPING);
182 foreach (ConfigPhp::$ROOT_KEYS as $key) {
183 $this->conf->set($legacyMap[$key], $oldConfig[$key]);
184 }
185
186 // Set sub config keys (config and plugins)
187 $subConfig = array('config', 'plugins');
188 foreach ($subConfig as $sub) {
189 foreach ($oldConfig[$sub] as $key => $value) {
190 if (isset($legacyMap[$sub . '.' . $key])) {
191 $configKey = $legacyMap[$sub . '.' . $key];
192 } else {
193 $configKey = $sub . '.' . $key;
194 }
195 $this->conf->set($configKey, $value);
196 }
197 }
198
199 try {
200 $this->conf->write($this->isLoggedIn);
201 return true;
202 } catch (IOException $e) {
203 error_log($e->getMessage());
204 return false;
205 }
206 }
207
208 /**
209 * Escape settings which have been manually escaped in every request in previous versions:
210 * - general.title
211 * - general.header_link
212 * - redirector.url
213 *
214 * @return bool true if the update is successful, false otherwise.
215 */
216 public function updateMethodEscapeUnescapedConfig()
217 {
218 try {
219 $this->conf->set('general.title', escape($this->conf->get('general.title')));
220 $this->conf->set('general.header_link', escape($this->conf->get('general.header_link')));
221 $this->conf->write($this->isLoggedIn);
222 } catch (Exception $e) {
223 error_log($e->getMessage());
224 return false;
225 }
226 return true;
227 }
228
229 /**
230 * Update the database to use the new ID system, which replaces linkdate primary keys.
231 * Also, creation and update dates are now DateTime objects (done by LinkDB).
232 *
233 * Since this update is very sensitve (changing the whole database), the datastore will be
234 * automatically backed up into the file datastore.<datetime>.php.
235 *
236 * LinkDB also adds the field 'shorturl' with the precedent format (linkdate smallhash),
237 * which will be saved by this method.
238 *
239 * @return bool true if the update is successful, false otherwise.
240 */
241 public function updateMethodDatastoreIds()
242 {
243 // up to date database
244 if (isset($this->linkDB[0])) {
245 return true;
246 }
247
248 $save = $this->conf->get('resource.data_dir') . '/datastore.' . date('YmdHis') . '.php';
249 copy($this->conf->get('resource.datastore'), $save);
250
251 $links = array();
252 foreach ($this->linkDB as $offset => $value) {
253 $links[] = $value;
254 unset($this->linkDB[$offset]);
255 }
256 $links = array_reverse($links);
257 $cpt = 0;
258 foreach ($links as $l) {
259 unset($l['linkdate']);
260 $l['id'] = $cpt;
261 $this->linkDB[$cpt++] = $l;
262 }
263
264 $this->linkDB->save($this->conf->get('resource.page_cache'));
265 $this->linkDB->reorder();
266
267 return true;
268 }
269
270 /**
271 * Rename tags starting with a '-' to work with tag exclusion search.
272 */
273 public function updateMethodRenameDashTags()
274 { 123 {
275 $linklist = $this->linkDB->filterSearch(); 124 return UpdaterUtils::read_updates_file($updatesFilepath);
276 foreach ($linklist as $key => $link) {
277 $link['tags'] = preg_replace('/(^| )\-/', '$1', $link['tags']);
278 $link['tags'] = implode(' ', array_unique(LinkFilter::tagsStrToArray($link['tags'], true)));
279 $this->linkDB[$key] = $link;
280 }
281 $this->linkDB->save($this->conf->get('resource.page_cache'));
282 return true;
283 } 125 }
284 126
285 /** 127 public function writeUpdates(string $updatesFilepath, array $updates): void
286 * Initialize API settings:
287 * - api.enabled: true
288 * - api.secret: generated secret
289 */
290 public function updateMethodApiSettings()
291 { 128 {
292 if ($this->conf->exists('api.secret')) { 129 UpdaterUtils::write_updates_file($updatesFilepath, $updates);
293 return true;
294 }
295
296 $this->conf->set('api.enabled', true);
297 $this->conf->set(
298 'api.secret',
299 generate_api_secret(
300 $this->conf->get('credentials.login'),
301 $this->conf->get('credentials.salt')
302 )
303 );
304 $this->conf->write($this->isLoggedIn);
305 return true;
306 } 130 }
307 131
308 /** 132 /**
309 * New setting: theme name. If the default theme is used, nothing to do. 133 * With the Slim routing system, default header link should be `/subfolder/` instead of `?`.
310 * 134 * Otherwise you can not go back to the home page.
311 * If the user uses a custom theme, raintpl_tpl dir is updated to the parent directory, 135 * Example: `/subfolder/picture-wall` -> `/subfolder/picture-wall?` instead of `/subfolder/`.
312 * and the current theme is set as default in the theme setting.
313 *
314 * @return bool true if the update is successful, false otherwise.
315 */ 136 */
316 public function updateMethodDefaultTheme() 137 public function updateMethodRelativeHomeLink(): bool
317 { 138 {
318 // raintpl_tpl isn't the root template directory anymore. 139 if ('?' === trim($this->conf->get('general.header_link'))) {
319 // We run the update only if this folder still contains the template files. 140 $this->conf->set('general.header_link', $this->basePath . '/', true, true);
320 $tplDir = $this->conf->get('resource.raintpl_tpl');
321 $tplFile = $tplDir . '/linklist.html';
322 if (!file_exists($tplFile)) {
323 return true;
324 } 141 }
325 142
326 $parent = dirname($tplDir);
327 $this->conf->set('resource.raintpl_tpl', $parent);
328 $this->conf->set('resource.theme', trim(str_replace($parent, '', $tplDir), '/'));
329 $this->conf->write($this->isLoggedIn);
330
331 // Dependency injection gore
332 RainTPL::$tpl_dir = $tplDir;
333
334 return true; 143 return true;
335 } 144 }
336 145
337 /** 146 /**
338 * Move the file to inc/user.css to data/user.css. 147 * With the Slim routing system, note bookmarks URL formatted `?abcdef`
339 * 148 * should be replaced with `/shaare/abcdef`
340 * Note: Due to hardcoded paths, it's not unit testable. But one line of code should be fine.
341 *
342 * @return bool true if the update is successful, false otherwise.
343 */ 149 */
344 public function updateMethodMoveUserCss() 150 public function updateMethodMigrateExistingNotesUrl(): bool
345 { 151 {
346 if (!is_file('inc/user.css')) { 152 $updated = false;
347 return true;
348 }
349
350 return rename('inc/user.css', 'data/user.css');
351 }
352 153
353 /** 154 foreach ($this->bookmarkService->search() as $bookmark) {
354 * * `markdown_escape` is a new setting, set to true as default. 155 if ($bookmark->isNote()
355 * 156 && startsWith($bookmark->getUrl(), '?')
356 * If the markdown plugin was already enabled, escaping is disabled to avoid 157 && 1 === preg_match('/^\?([a-zA-Z0-9-_@]{6})($|&|#)/', $bookmark->getUrl(), $match)
357 * breaking existing entries. 158 ) {
358 */ 159 $updated = true;
359 public function updateMethodEscapeMarkdown() 160 $bookmark = $bookmark->setUrl('/shaare/' . $match[1]);
360 {
361 if ($this->conf->exists('security.markdown_escape')) {
362 return true;
363 }
364
365 if (in_array('markdown', $this->conf->get('general.enabled_plugins'))) {
366 $this->conf->set('security.markdown_escape', false);
367 } else {
368 $this->conf->set('security.markdown_escape', true);
369 }
370 $this->conf->write($this->isLoggedIn);
371
372 return true;
373 }
374
375 /**
376 * Add 'http://' to Piwik URL the setting is set.
377 *
378 * @return bool true if the update is successful, false otherwise.
379 */
380 public function updateMethodPiwikUrl()
381 {
382 if (!$this->conf->exists('plugins.PIWIK_URL') || startsWith($this->conf->get('plugins.PIWIK_URL'), 'http')) {
383 return true;
384 }
385
386 $this->conf->set('plugins.PIWIK_URL', 'http://' . $this->conf->get('plugins.PIWIK_URL'));
387 $this->conf->write($this->isLoggedIn);
388
389 return true;
390 }
391
392 /**
393 * Use ATOM feed as default.
394 */
395 public function updateMethodAtomDefault()
396 {
397 if (!$this->conf->exists('feed.show_atom') || $this->conf->get('feed.show_atom') === true) {
398 return true;
399 }
400
401 $this->conf->set('feed.show_atom', true);
402 $this->conf->write($this->isLoggedIn);
403
404 return true;
405 }
406
407 /**
408 * Update updates.check_updates_branch setting.
409 *
410 * If the current major version digit matches the latest branch
411 * major version digit, we set the branch to `latest`,
412 * otherwise we'll check updates on the `stable` branch.
413 *
414 * No update required for the dev version.
415 *
416 * Note: due to hardcoded URL and lack of dependency injection, this is not unit testable.
417 *
418 * FIXME! This needs to be removed when we switch to first digit major version
419 * instead of the second one since the versionning process will change.
420 */
421 public function updateMethodCheckUpdateRemoteBranch()
422 {
423 if (SHAARLI_VERSION === 'dev' || $this->conf->get('updates.check_updates_branch') === 'latest') {
424 return true;
425 }
426
427 // Get latest branch major version digit
428 $latestVersion = ApplicationUtils::getLatestGitVersionCode(
429 'https://raw.githubusercontent.com/shaarli/Shaarli/latest/shaarli_version.php',
430 5
431 );
432 if (preg_match('/(\d+)\.\d+$/', $latestVersion, $matches) === false) {
433 return false;
434 }
435 $latestMajor = $matches[1];
436
437 // Get current major version digit
438 preg_match('/(\d+)\.\d+$/', SHAARLI_VERSION, $matches);
439 $currentMajor = $matches[1];
440
441 if ($currentMajor === $latestMajor) {
442 $branch = 'latest';
443 } else {
444 $branch = 'stable';
445 }
446 $this->conf->set('updates.check_updates_branch', $branch);
447 $this->conf->write($this->isLoggedIn);
448 return true;
449 }
450
451 /**
452 * Reset history store file due to date format change.
453 */
454 public function updateMethodResetHistoryFile()
455 {
456 if (is_file($this->conf->get('resource.history'))) {
457 unlink($this->conf->get('resource.history'));
458 }
459 return true;
460 }
461
462 /**
463 * Save the datastore -> the link order is now applied when links are saved.
464 */
465 public function updateMethodReorderDatastore()
466 {
467 $this->linkDB->save($this->conf->get('resource.page_cache'));
468 return true;
469 }
470
471 /**
472 * Change privateonly session key to visibility.
473 */
474 public function updateMethodVisibilitySession()
475 {
476 if (isset($_SESSION['privateonly'])) {
477 unset($_SESSION['privateonly']);
478 $_SESSION['visibility'] = 'private';
479 }
480 return true;
481 }
482
483 /**
484 * Add download size and timeout to the configuration file
485 *
486 * @return bool true if the update is successful, false otherwise.
487 */
488 public function updateMethodDownloadSizeAndTimeoutConf()
489 {
490 if ($this->conf->exists('general.download_max_size')
491 && $this->conf->exists('general.download_timeout')
492 ) {
493 return true;
494 }
495
496 if (!$this->conf->exists('general.download_max_size')) {
497 $this->conf->set('general.download_max_size', 1024 * 1024 * 4);
498 }
499
500 if (!$this->conf->exists('general.download_timeout')) {
501 $this->conf->set('general.download_timeout', 30);
502 }
503
504 $this->conf->write($this->isLoggedIn);
505 return true;
506 }
507 161
508 /** 162 $this->bookmarkService->set($bookmark, false);
509 * * Move thumbnails management to WebThumbnailer, coming with new settings. 163 }
510 */
511 public function updateMethodWebThumbnailer()
512 {
513 if ($this->conf->exists('thumbnails.mode')) {
514 return true;
515 } 164 }
516 165
517 $thumbnailsEnabled = extension_loaded('gd') && $this->conf->get('thumbnail.enable_thumbnails', true); 166 if ($updated) {
518 $this->conf->set('thumbnails.mode', $thumbnailsEnabled ? Thumbnailer::MODE_ALL : Thumbnailer::MODE_NONE); 167 $this->bookmarkService->save();
519 $this->conf->set('thumbnails.width', 125);
520 $this->conf->set('thumbnails.height', 90);
521 $this->conf->remove('thumbnail');
522 $this->conf->write(true);
523
524 if ($thumbnailsEnabled) {
525 $this->session['warnings'][] = t(
526 'You have enabled or changed thumbnails mode. <a href="?do=thumbs_update">Please synchronize them</a>.'
527 );
528 } 168 }
529 169
530 return true; 170 return true;
531 } 171 }
532 172
533 /** 173 public function setBasePath(string $basePath): self
534 * Set sticky = false on all links
535 *
536 * @return bool true if the update is successful, false otherwise.
537 */
538 public function updateMethodSetSticky()
539 { 174 {
540 foreach ($this->linkDB as $key => $link) { 175 $this->basePath = $basePath;
541 if (isset($link['sticky'])) {
542 return true;
543 }
544 $link['sticky'] = false;
545 $this->linkDB[$key] = $link;
546 }
547
548 $this->linkDB->save($this->conf->get('resource.page_cache'));
549
550 return true;
551 }
552 176
553 /** 177 return $this;
554 * Remove redirector settings.
555 */
556 public function updateMethodRemoveRedirector()
557 {
558 $this->conf->remove('redirector');
559 $this->conf->write(true);
560 return true;
561 } 178 }
562} 179}
diff --git a/application/updater/UpdaterUtils.php b/application/updater/UpdaterUtils.php
index 34d4f422..828a49fc 100644
--- a/application/updater/UpdaterUtils.php
+++ b/application/updater/UpdaterUtils.php
@@ -1,39 +1,44 @@
1<?php 1<?php
2 2
3/** 3namespace Shaarli\Updater;
4 * Read the updates file, and return already done updates. 4
5 * 5class UpdaterUtils
6 * @param string $updatesFilepath Updates file path.
7 *
8 * @return array Already done update methods.
9 */
10function read_updates_file($updatesFilepath)
11{ 6{
12 if (! empty($updatesFilepath) && is_file($updatesFilepath)) { 7 /**
13 $content = file_get_contents($updatesFilepath); 8 * Read the updates file, and return already done updates.
14 if (! empty($content)) { 9 *
15 return explode(';', $content); 10 * @param string $updatesFilepath Updates file path.
11 *
12 * @return array Already done update methods.
13 */
14 public static function read_updates_file($updatesFilepath)
15 {
16 if (! empty($updatesFilepath) && is_file($updatesFilepath)) {
17 $content = file_get_contents($updatesFilepath);
18 if (! empty($content)) {
19 return explode(';', $content);
20 }
16 } 21 }
22 return array();
17 } 23 }
18 return array();
19}
20 24
21/** 25 /**
22 * Write updates file. 26 * Write updates file.
23 * 27 *
24 * @param string $updatesFilepath Updates file path. 28 * @param string $updatesFilepath Updates file path.
25 * @param array $updates Updates array to write. 29 * @param array $updates Updates array to write.
26 * 30 *
27 * @throws Exception Couldn't write version number. 31 * @throws \Exception Couldn't write version number.
28 */ 32 */
29function write_updates_file($updatesFilepath, $updates) 33 public static function write_updates_file($updatesFilepath, $updates)
30{ 34 {
31 if (empty($updatesFilepath)) { 35 if (empty($updatesFilepath)) {
32 throw new Exception(t('Updates file path is not set, can\'t write updates.')); 36 throw new \Exception('Updates file path is not set, can\'t write updates.');
33 } 37 }
34 38
35 $res = file_put_contents($updatesFilepath, implode(';', $updates)); 39 $res = file_put_contents($updatesFilepath, implode(';', $updates));
36 if ($res === false) { 40 if ($res === false) {
37 throw new Exception(t('Unable to write updates in '. $updatesFilepath . '.')); 41 throw new \Exception('Unable to write updates in '. $updatesFilepath . '.');
42 }
38 } 43 }
39} 44}