diff options
Diffstat (limited to 'application')
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 | ||
4 | use DateTime; | 4 | use DateTime; |
5 | use Exception; | 5 | use Exception; |
6 | use 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 | ||
2 | namespace Shaarli; | ||
3 | |||
4 | /** | ||
5 | * Class Router | ||
6 | * | ||
7 | * (only displayable pages here) | ||
8 | */ | ||
9 | class 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 | ||
5 | use Shaarli\Config\ConfigManager; | 5 | use Shaarli\Config\ConfigManager; |
6 | use WebThumbnailer\Application\ConfigManager as WTConfigManager; | 6 | use WebThumbnailer\Application\ConfigManager as WTConfigManager; |
7 | use WebThumbnailer\Exception\WebThumbnailerException; | ||
8 | use WebThumbnailer\WebThumbnailer; | 7 | use 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 | */ |
92 | function escape($input) | 92 | function 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 | */ |
160 | function generateLocation($referer, $host, $loopTerms = array()) | 164 | function 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 | */ |
303 | function format_date($date, $time = true, $intl = true) | 307 | function 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 | ||
4 | use Shaarli\Api\Exceptions\ApiAuthorizationException; | 4 | use Shaarli\Api\Exceptions\ApiAuthorizationException; |
5 | use Shaarli\Api\Exceptions\ApiException; | 5 | use Shaarli\Api\Exceptions\ApiException; |
6 | use Shaarli\Bookmark\BookmarkFileService; | ||
6 | use Shaarli\Config\ConfigManager; | 7 | use Shaarli\Config\ConfigManager; |
7 | use Slim\Container; | 8 | use Slim\Container; |
8 | use Slim\Http\Request; | 9 | use 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 @@ | |||
2 | namespace Shaarli\Api; | 2 | namespace Shaarli\Api; |
3 | 3 | ||
4 | use Shaarli\Api\Exceptions\ApiAuthorizationException; | 4 | use Shaarli\Api\Exceptions\ApiAuthorizationException; |
5 | use Shaarli\Bookmark\Bookmark; | ||
5 | use Shaarli\Http\Base64Url; | 6 | use 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 | ||
3 | namespace Shaarli\Api\Controllers; | 3 | namespace Shaarli\Api\Controllers; |
4 | 4 | ||
5 | use Shaarli\Bookmark\LinkDB; | 5 | use Shaarli\Bookmark\BookmarkServiceInterface; |
6 | use Shaarli\Config\ConfigManager; | 6 | use Shaarli\Config\ConfigManager; |
7 | use Slim\Container; | 7 | use 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 | ||
3 | namespace Shaarli\Api\Controllers; | 3 | namespace Shaarli\Api\Controllers; |
4 | 4 | ||
5 | use Shaarli\Bookmark\BookmarkFilter; | ||
5 | use Slim\Http\Request; | 6 | use Slim\Http\Request; |
6 | use Slim\Http\Response; | 7 | use 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; | |||
19 | class Links extends ApiController | 19 | class 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; | |||
5 | use Shaarli\Api\ApiUtils; | 5 | use Shaarli\Api\ApiUtils; |
6 | use Shaarli\Api\Exceptions\ApiBadParametersException; | 6 | use Shaarli\Api\Exceptions\ApiBadParametersException; |
7 | use Shaarli\Api\Exceptions\ApiTagNotFoundException; | 7 | use Shaarli\Api\Exceptions\ApiTagNotFoundException; |
8 | use Shaarli\Bookmark\BookmarkFilter; | ||
8 | use Slim\Http\Request; | 9 | use Slim\Http\Request; |
9 | use Slim\Http\Response; | 10 | use Slim\Http\Response; |
10 | 11 | ||
@@ -18,7 +19,7 @@ use Slim\Http\Response; | |||
18 | class Tags extends ApiController | 19 | class 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 | |||
3 | namespace Shaarli\Bookmark; | ||
4 | |||
5 | use DateTime; | ||
6 | use DateTimeInterface; | ||
7 | use 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 | */ | ||
17 | class 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 | |||
3 | namespace Shaarli\Bookmark; | ||
4 | |||
5 | use 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 | */ | ||
15 | class 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 | |||
4 | namespace Shaarli\Bookmark; | ||
5 | |||
6 | |||
7 | use Exception; | ||
8 | use Shaarli\Bookmark\Exception\BookmarkNotFoundException; | ||
9 | use Shaarli\Bookmark\Exception\DatastoreNotInitializedException; | ||
10 | use Shaarli\Bookmark\Exception\EmptyDataStoreException; | ||
11 | use Shaarli\Config\ConfigManager; | ||
12 | use Shaarli\Formatter\BookmarkMarkdownFormatter; | ||
13 | use Shaarli\History; | ||
14 | use Shaarli\Legacy\LegacyLinkDB; | ||
15 | use Shaarli\Legacy\LegacyUpdater; | ||
16 | use Shaarli\Render\PageCacheManager; | ||
17 | use 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 | */ | ||
27 | class 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 | |||
3 | namespace Shaarli\Bookmark; | ||
4 | |||
5 | use Exception; | ||
6 | use Shaarli\Bookmark\Exception\BookmarkNotFoundException; | ||
7 | |||
8 | /** | ||
9 | * Class LinkFilter. | ||
10 | * | ||
11 | * Perform search and filter operation on link data list. | ||
12 | */ | ||
13 | class 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 | |||
3 | namespace Shaarli\Bookmark; | ||
4 | |||
5 | use Shaarli\Bookmark\Exception\DatastoreNotInitializedException; | ||
6 | use Shaarli\Bookmark\Exception\EmptyDataStoreException; | ||
7 | use Shaarli\Bookmark\Exception\NotWritableDataStoreException; | ||
8 | use 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 | */ | ||
18 | class 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 | |||
3 | namespace 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 | */ | ||
16 | class 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 | |||
42 | Explore your new Shaarli instance by trying out controls and menus. | ||
43 | Visit the project on [Github](https://github.com/shaarli/Shaarli) or [the documentation](https://shaarli.readthedocs.io/en/master/) to learn more about Shaarli. | ||
44 | |||
45 | Now 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. | ||
56 | This note is private, so you are the only one able to see it while logged in. | ||
57 | |||
58 | You can use this to keep notes, post articles, code snippets, and much more. | ||
59 | |||
60 | The 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 | |||
73 | Markdown 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 | |||
94 | Shaarli allows you to bookmark your favorite pages, and share them with others or store them privately. | ||
95 | You can add a description to your bookmarks, such as this one, and tag them. | ||
96 | |||
97 | Create a new shaare by clicking the `+Shaare` button, or using any of the recommended tools (browser extension, mobile app, bookmarklet, REST API, etc.). | ||
98 | |||
99 | You can easily retrieve your links, even with thousands of them, using the internal search engine, or search through tags (e.g. this Shaare is tagged with `shaarli` and `help`). | ||
100 | Hashtags such as #shaarli #help are also supported. | ||
101 | You can also filter the available [RSS feed](/feed/atom) and picture wall by tag or plaintext search. | ||
102 | |||
103 | We hope that you will enjoy using Shaarli, maintained with ❤️ by the community! | ||
104 | Feel 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 | |||
3 | namespace Shaarli\Bookmark; | ||
4 | |||
5 | |||
6 | use Shaarli\Bookmark\Exception\BookmarkNotFoundException; | ||
7 | use Shaarli\Bookmark\Exception\NotWritableDataStoreException; | ||
8 | use Shaarli\Config\ConfigManager; | ||
9 | use Shaarli\History; | ||
10 | |||
11 | /** | ||
12 | * Class BookmarksService | ||
13 | * | ||
14 | * This is the entry point to manipulate the bookmark DB. | ||
15 | */ | ||
16 | interface 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 | ||
3 | use Shaarli\Bookmark\LinkDB; | 3 | use 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 | */ | ||
17 | function 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 | */ |
133 | function header_extract_charset($header) | 27 | function 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 | */ | ||
197 | function 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 | */ |
280 | function link_small_hash($date, $id) | 155 | function 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 | ||
4 | use Exception; | 4 | use Exception; |
5 | 5 | ||
6 | class LinkNotFoundException extends Exception | 6 | class 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Bookmark\Exception; | ||
6 | |||
7 | class 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 | |||
4 | namespace Shaarli\Bookmark\Exception; | ||
5 | |||
6 | |||
7 | class 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 | |||
3 | namespace Shaarli\Bookmark\Exception; | ||
4 | |||
5 | use Shaarli\Bookmark\Bookmark; | ||
6 | |||
7 | class 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 | |||
4 | namespace Shaarli\Bookmark\Exception; | ||
5 | |||
6 | |||
7 | class 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 | ||
4 | use Shaarli\Config\Exception\MissingFieldConfigException; | 4 | use Shaarli\Config\Exception\MissingFieldConfigException; |
5 | use Shaarli\Config\Exception\UnauthorizedConfigException; | 5 | use Shaarli\Config\Exception\UnauthorizedConfigException; |
6 | use 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 | ||
3 | use Shaarli\Config\Exception\PluginConfigOrderException; | 3 | use Shaarli\Config\Exception\PluginConfigOrderException; |
4 | use 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 | */ |
20 | function save_plugin_config($formData) | 21 | function 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Container; | ||
6 | |||
7 | use Shaarli\Bookmark\BookmarkFileService; | ||
8 | use Shaarli\Bookmark\BookmarkServiceInterface; | ||
9 | use Shaarli\Config\ConfigManager; | ||
10 | use Shaarli\Feed\FeedBuilder; | ||
11 | use Shaarli\Formatter\FormatterFactory; | ||
12 | use Shaarli\Front\Controller\Visitor\ErrorController; | ||
13 | use Shaarli\Front\Controller\Visitor\ErrorNotFoundController; | ||
14 | use Shaarli\History; | ||
15 | use Shaarli\Http\HttpAccess; | ||
16 | use Shaarli\Netscape\NetscapeBookmarkUtils; | ||
17 | use Shaarli\Plugin\PluginManager; | ||
18 | use Shaarli\Render\PageBuilder; | ||
19 | use Shaarli\Render\PageCacheManager; | ||
20 | use Shaarli\Security\CookieManager; | ||
21 | use Shaarli\Security\LoginManager; | ||
22 | use Shaarli\Security\SessionManager; | ||
23 | use Shaarli\Thumbnailer; | ||
24 | use Shaarli\Updater\Updater; | ||
25 | use 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 | */ | ||
36 | class 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Container; | ||
6 | |||
7 | use Shaarli\Bookmark\BookmarkServiceInterface; | ||
8 | use Shaarli\Config\ConfigManager; | ||
9 | use Shaarli\Feed\FeedBuilder; | ||
10 | use Shaarli\Formatter\FormatterFactory; | ||
11 | use Shaarli\History; | ||
12 | use Shaarli\Http\HttpAccess; | ||
13 | use Shaarli\Netscape\NetscapeBookmarkUtils; | ||
14 | use Shaarli\Plugin\PluginManager; | ||
15 | use Shaarli\Render\PageBuilder; | ||
16 | use Shaarli\Render\PageCacheManager; | ||
17 | use Shaarli\Security\CookieManager; | ||
18 | use Shaarli\Security\LoginManager; | ||
19 | use Shaarli\Security\SessionManager; | ||
20 | use Shaarli\Thumbnailer; | ||
21 | use Shaarli\Updater\Updater; | ||
22 | use 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 | */ | ||
48 | class 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 | */ | ||
13 | function 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 | */ | ||
29 | function 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 @@ | |||
2 | namespace Shaarli\Feed; | 2 | namespace Shaarli\Feed; |
3 | 3 | ||
4 | use DateTime; | 4 | use DateTime; |
5 | use Shaarli\Bookmark\Bookmark; | ||
6 | use Shaarli\Bookmark\BookmarkServiceInterface; | ||
7 | use 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>— ' . $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>— ' . $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 | |||
3 | namespace 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 | */ | ||
13 | class 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 | |||
3 | namespace Shaarli\Formatter; | ||
4 | |||
5 | use DateTime; | ||
6 | use Shaarli\Bookmark\Bookmark; | ||
7 | use 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 | */ | ||
16 | abstract 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 | |||
3 | namespace Shaarli\Formatter; | ||
4 | |||
5 | use Shaarli\Config\ConfigManager; | ||
6 | |||
7 | /** | ||
8 | * Class BookmarkMarkdownFormatter | ||
9 | * | ||
10 | * Format bookmark description into Markdown format. | ||
11 | * | ||
12 | * @package Shaarli\Formatter | ||
13 | */ | ||
14 | class 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 | |||
3 | namespace 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 | */ | ||
13 | class 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 | |||
3 | namespace Shaarli\Formatter; | ||
4 | |||
5 | use Shaarli\Config\ConfigManager; | ||
6 | |||
7 | /** | ||
8 | * Class FormatterFactory | ||
9 | * | ||
10 | * Helper class used to instantiate the proper BookmarkFormatter. | ||
11 | * | ||
12 | * @package Shaarli\Formatter | ||
13 | */ | ||
14 | class 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 | |||
3 | namespace Shaarli\Front; | ||
4 | |||
5 | use Slim\Http\Request; | ||
6 | use 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 | */ | ||
13 | class 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 | |||
3 | namespace Shaarli\Front; | ||
4 | |||
5 | use Shaarli\Container\ShaarliContainer; | ||
6 | use Shaarli\Front\Exception\UnauthorizedException; | ||
7 | use Slim\Http\Request; | ||
8 | use Slim\Http\Response; | ||
9 | |||
10 | /** | ||
11 | * Class ShaarliMiddleware | ||
12 | * | ||
13 | * This will be called before accessing any Shaarli controller. | ||
14 | */ | ||
15 | class 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Shaarli\Languages; | ||
8 | use Shaarli\Render\TemplatePage; | ||
9 | use Shaarli\Render\ThemeUtils; | ||
10 | use Shaarli\Thumbnailer; | ||
11 | use Slim\Http\Request; | ||
12 | use Slim\Http\Response; | ||
13 | use Throwable; | ||
14 | |||
15 | /** | ||
16 | * Class ConfigureController | ||
17 | * | ||
18 | * Slim controller used to handle Shaarli configuration page (display + save new config). | ||
19 | */ | ||
20 | class 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use DateTime; | ||
8 | use Shaarli\Bookmark\Bookmark; | ||
9 | use Shaarli\Render\TemplatePage; | ||
10 | use Slim\Http\Request; | ||
11 | use 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 | */ | ||
19 | class 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Psr\Http\Message\UploadedFileInterface; | ||
8 | use Shaarli\Render\TemplatePage; | ||
9 | use Slim\Http\Request; | ||
10 | use 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 | */ | ||
18 | class 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Shaarli\Security\CookieManager; | ||
8 | use Shaarli\Security\LoginManager; | ||
9 | use Slim\Http\Request; | ||
10 | use 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 | */ | ||
18 | class 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Shaarli\Bookmark\Bookmark; | ||
8 | use Shaarli\Bookmark\Exception\BookmarkNotFoundException; | ||
9 | use Shaarli\Formatter\BookmarkMarkdownFormatter; | ||
10 | use Shaarli\Render\TemplatePage; | ||
11 | use Shaarli\Thumbnailer; | ||
12 | use Slim\Http\Request; | ||
13 | use Slim\Http\Response; | ||
14 | |||
15 | /** | ||
16 | * Class PostBookmarkController | ||
17 | * | ||
18 | * Slim controller used to handle Shaarli create or edit bookmarks. | ||
19 | */ | ||
20 | class 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Shaarli\Bookmark\BookmarkFilter; | ||
8 | use Shaarli\Render\TemplatePage; | ||
9 | use Slim\Http\Request; | ||
10 | use Slim\Http\Response; | ||
11 | |||
12 | /** | ||
13 | * Class ManageTagController | ||
14 | * | ||
15 | * Slim controller used to handle Shaarli manage tags page (rename and delete tags). | ||
16 | */ | ||
17 | class 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Shaarli\Container\ShaarliContainer; | ||
8 | use Shaarli\Front\Exception\OpenShaarliPasswordException; | ||
9 | use Shaarli\Front\Exception\ShaarliFrontException; | ||
10 | use Shaarli\Render\TemplatePage; | ||
11 | use Slim\Http\Request; | ||
12 | use Slim\Http\Response; | ||
13 | use Throwable; | ||
14 | |||
15 | /** | ||
16 | * Class PasswordController | ||
17 | * | ||
18 | * Slim controller used to handle passwords update. | ||
19 | */ | ||
20 | class 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Exception; | ||
8 | use Shaarli\Render\TemplatePage; | ||
9 | use Slim\Http\Request; | ||
10 | use Slim\Http\Response; | ||
11 | |||
12 | /** | ||
13 | * Class PluginsController | ||
14 | * | ||
15 | * Slim controller used to handle Shaarli plugins configuration page (display + save new config). | ||
16 | */ | ||
17 | class 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Shaarli\Bookmark\BookmarkFilter; | ||
8 | use Shaarli\Security\SessionManager; | ||
9 | use Slim\Http\Request; | ||
10 | use 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 | */ | ||
17 | class 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Shaarli\Front\Controller\Visitor\ShaarliVisitorController; | ||
8 | use Shaarli\Front\Exception\WrongTokenException; | ||
9 | use Shaarli\Security\SessionManager; | ||
10 | use 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 | */ | ||
21 | abstract 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Shaarli\Bookmark\Exception\BookmarkNotFoundException; | ||
8 | use Shaarli\Render\TemplatePage; | ||
9 | use Slim\Http\Request; | ||
10 | use Slim\Http\Response; | ||
11 | |||
12 | /** | ||
13 | * Class ToolsController | ||
14 | * | ||
15 | * Slim controller used to handle thumbnails update. | ||
16 | */ | ||
17 | class 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Slim\Http\Request; | ||
8 | use Slim\Http\Response; | ||
9 | |||
10 | /** | ||
11 | * Class TokenController | ||
12 | * | ||
13 | * Endpoint used to retrieve a XSRF token. Useful for AJAX requests. | ||
14 | */ | ||
15 | class 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Shaarli\Render\TemplatePage; | ||
8 | use Slim\Http\Request; | ||
9 | use Slim\Http\Response; | ||
10 | |||
11 | /** | ||
12 | * Class ToolsController | ||
13 | * | ||
14 | * Slim controller used to display the tools page. | ||
15 | */ | ||
16 | class 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Visitor; | ||
6 | |||
7 | use Shaarli\Bookmark\Bookmark; | ||
8 | use Shaarli\Bookmark\Exception\BookmarkNotFoundException; | ||
9 | use Shaarli\Legacy\LegacyController; | ||
10 | use Shaarli\Legacy\UnknowLegacyRouteException; | ||
11 | use Shaarli\Render\TemplatePage; | ||
12 | use Shaarli\Thumbnailer; | ||
13 | use Slim\Http\Request; | ||
14 | use 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 | */ | ||
22 | class 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Visitor; | ||
6 | |||
7 | use DateTime; | ||
8 | use DateTimeImmutable; | ||
9 | use Shaarli\Bookmark\Bookmark; | ||
10 | use Shaarli\Render\TemplatePage; | ||
11 | use Slim\Http\Request; | ||
12 | use Slim\Http\Response; | ||
13 | |||
14 | /** | ||
15 | * Class DailyController | ||
16 | * | ||
17 | * Slim controller used to render the daily page. | ||
18 | */ | ||
19 | class 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Visitor; | ||
6 | |||
7 | use Shaarli\Front\Exception\ShaarliFrontException; | ||
8 | use Slim\Http\Request; | ||
9 | use 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 | */ | ||
15 | class 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Visitor; | ||
6 | |||
7 | use Slim\Http\Request; | ||
8 | use Slim\Http\Response; | ||
9 | |||
10 | /** | ||
11 | * Controller used to render the 404 error page. | ||
12 | */ | ||
13 | class 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Visitor; | ||
6 | |||
7 | use Shaarli\Feed\FeedBuilder; | ||
8 | use Slim\Http\Request; | ||
9 | use Slim\Http\Response; | ||
10 | |||
11 | /** | ||
12 | * Class FeedController | ||
13 | * | ||
14 | * Slim controller handling ATOM and RSS feed. | ||
15 | */ | ||
16 | class 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Visitor; | ||
6 | |||
7 | use Shaarli\ApplicationUtils; | ||
8 | use Shaarli\Container\ShaarliContainer; | ||
9 | use Shaarli\Front\Exception\AlreadyInstalledException; | ||
10 | use Shaarli\Front\Exception\ResourcePermissionException; | ||
11 | use Shaarli\Languages; | ||
12 | use Shaarli\Security\SessionManager; | ||
13 | use Slim\Http\Request; | ||
14 | use Slim\Http\Response; | ||
15 | |||
16 | /** | ||
17 | * Slim controller used to render install page, and create initial configuration file. | ||
18 | */ | ||
19 | class 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Visitor; | ||
6 | |||
7 | use Shaarli\Front\Exception\CantLoginException; | ||
8 | use Shaarli\Front\Exception\LoginBannedException; | ||
9 | use Shaarli\Front\Exception\WrongTokenException; | ||
10 | use Shaarli\Render\TemplatePage; | ||
11 | use Shaarli\Security\CookieManager; | ||
12 | use Shaarli\Security\SessionManager; | ||
13 | use Slim\Http\Request; | ||
14 | use 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 | */ | ||
24 | class 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Visitor; | ||
6 | |||
7 | use Shaarli\Render\TemplatePage; | ||
8 | use Slim\Http\Request; | ||
9 | use 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 | */ | ||
17 | class 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Visitor; | ||
6 | |||
7 | use Shaarli\Front\Exception\ThumbnailsDisabledException; | ||
8 | use Shaarli\Render\TemplatePage; | ||
9 | use Shaarli\Thumbnailer; | ||
10 | use Slim\Http\Request; | ||
11 | use 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 | */ | ||
19 | class 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Visitor; | ||
6 | |||
7 | use Shaarli\Security\SessionManager; | ||
8 | use Slim\Http\Request; | ||
9 | use Slim\Http\Response; | ||
10 | |||
11 | /** | ||
12 | * Slim controller used to handle filters stored in the visitor session, links per page, etc. | ||
13 | */ | ||
14 | class 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Visitor; | ||
6 | |||
7 | use Shaarli\Bookmark\BookmarkFilter; | ||
8 | use Shaarli\Container\ShaarliContainer; | ||
9 | use Slim\Http\Request; | ||
10 | use 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 | */ | ||
20 | abstract 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Visitor; | ||
6 | |||
7 | use Slim\Http\Request; | ||
8 | use Slim\Http\Response; | ||
9 | |||
10 | /** | ||
11 | * Class TagCloud | ||
12 | * | ||
13 | * Slim controller used to render the tag cloud and tag list pages. | ||
14 | */ | ||
15 | class 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Visitor; | ||
6 | |||
7 | use Slim\Http\Request; | ||
8 | use Slim\Http\Response; | ||
9 | |||
10 | /** | ||
11 | * Class TagController | ||
12 | * | ||
13 | * Slim controller handle tags. | ||
14 | */ | ||
15 | class 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Exception; | ||
6 | |||
7 | class 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Exception; | ||
6 | |||
7 | class 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Exception; | ||
6 | |||
7 | class 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace 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 | */ | ||
12 | class 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Exception; | ||
6 | |||
7 | class 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Exception; | ||
6 | |||
7 | use 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 | */ | ||
16 | class 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Exception; | ||
6 | |||
7 | class 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Exception; | ||
6 | |||
7 | /** | ||
8 | * Class UnauthorizedException | ||
9 | * | ||
10 | * Exception raised if the user tries to access a ShaarliAdminController while logged out. | ||
11 | */ | ||
12 | class 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace 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 | */ | ||
12 | class 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace 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 | */ | ||
15 | class 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 | */ |
370 | function index_url($server) | 370 | function 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 | */ |
388 | function page_url($server) | 392 | function 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 | */ | ||
504 | function 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 | */ |
74 | function whitelist_protocols($url, $protocols) | 74 | function 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Legacy; | ||
6 | |||
7 | use Shaarli\Feed\FeedBuilder; | ||
8 | use Shaarli\Front\Controller\Visitor\ShaarliVisitorController; | ||
9 | use Slim\Http\Request; | ||
10 | use 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 | */ | ||
19 | class 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 | ||
3 | namespace Shaarli\Bookmark; | 3 | namespace Shaarli\Legacy; |
4 | 4 | ||
5 | use ArrayAccess; | 5 | use ArrayAccess; |
6 | use Countable; | 6 | use Countable; |
7 | use DateTime; | 7 | use DateTime; |
8 | use Iterator; | 8 | use Iterator; |
9 | use Shaarli\Bookmark\Exception\LinkNotFoundException; | 9 | use Shaarli\Bookmark\Exception\BookmarkNotFoundException; |
10 | use Shaarli\Exceptions\IOException; | 10 | use Shaarli\Exceptions\IOException; |
11 | use Shaarli\FileUtils; | 11 | use Shaarli\FileUtils; |
12 | use 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 | */ |
56 | class LinkDB implements Iterator, Countable, ArrayAccess | 59 | class 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 | ||
3 | namespace Shaarli\Bookmark; | 3 | namespace Shaarli\Legacy; |
4 | 4 | ||
5 | use Exception; | 5 | use Exception; |
6 | use Shaarli\Bookmark\Exception\LinkNotFoundException; | 6 | use 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 | */ |
13 | class LinkFilter | 15 | class 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 | |||
3 | namespace Shaarli\Legacy; | ||
4 | |||
5 | /** | ||
6 | * Class Router | ||
7 | * | ||
8 | * (only displayable pages here) | ||
9 | * | ||
10 | * @deprecated | ||
11 | */ | ||
12 | class 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 | |||
3 | namespace Shaarli\Legacy; | ||
4 | |||
5 | use Exception; | ||
6 | use RainTPL; | ||
7 | use ReflectionClass; | ||
8 | use ReflectionException; | ||
9 | use ReflectionMethod; | ||
10 | use Shaarli\ApplicationUtils; | ||
11 | use Shaarli\Bookmark\Bookmark; | ||
12 | use Shaarli\Bookmark\BookmarkArray; | ||
13 | use Shaarli\Bookmark\BookmarkFilter; | ||
14 | use Shaarli\Bookmark\BookmarkIO; | ||
15 | use Shaarli\Bookmark\LinkDB; | ||
16 | use Shaarli\Config\ConfigJson; | ||
17 | use Shaarli\Config\ConfigManager; | ||
18 | use Shaarli\Config\ConfigPhp; | ||
19 | use Shaarli\Exceptions\IOException; | ||
20 | use Shaarli\Thumbnailer; | ||
21 | use 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 | */ | ||
30 | class 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Legacy; | ||
6 | |||
7 | class 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; | |||
6 | use DateTimeZone; | 6 | use DateTimeZone; |
7 | use Exception; | 7 | use Exception; |
8 | use Katzgrau\KLogger\Logger; | 8 | use Katzgrau\KLogger\Logger; |
9 | use Psr\Http\Message\UploadedFileInterface; | ||
9 | use Psr\Log\LogLevel; | 10 | use Psr\Log\LogLevel; |
10 | use Shaarli\Bookmark\LinkDB; | 11 | use Shaarli\Bookmark\Bookmark; |
12 | use Shaarli\Bookmark\BookmarkServiceInterface; | ||
11 | use Shaarli\Config\ConfigManager; | 13 | use Shaarli\Config\ConfigManager; |
14 | use Shaarli\Formatter\BookmarkFormatter; | ||
12 | use Shaarli\History; | 15 | use Shaarli\History; |
13 | use Shaarli\NetscapeBookmarkParser\NetscapeBookmarkParser; | 16 | use 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 | */ |
19 | class NetscapeBookmarkUtils | 21 | class 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 @@ | |||
3 | namespace Shaarli\Render; | 3 | namespace Shaarli\Render; |
4 | 4 | ||
5 | use Exception; | 5 | use Exception; |
6 | use exceptions\MissingBasePathException; | ||
6 | use RainTPL; | 7 | use RainTPL; |
7 | use Shaarli\ApplicationUtils; | 8 | use Shaarli\ApplicationUtils; |
8 | use Shaarli\Bookmark\LinkDB; | 9 | use Shaarli\Bookmark\BookmarkServiceInterface; |
9 | use Shaarli\Config\ConfigManager; | 10 | use Shaarli\Config\ConfigManager; |
11 | use Shaarli\Security\SessionManager; | ||
10 | use Shaarli\Thumbnailer; | 12 | use 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 | |||
3 | namespace Shaarli\Render; | ||
4 | |||
5 | use Shaarli\Feed\CachedPage; | ||
6 | |||
7 | /** | ||
8 | * Cache utilities | ||
9 | */ | ||
10 | class 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Render; | ||
6 | |||
7 | interface 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Security; | ||
6 | |||
7 | class 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 |
2 | namespace Shaarli\Security; | 2 | namespace Shaarli\Security; |
3 | 3 | ||
4 | use Exception; | ||
4 | use Shaarli\Config\ConfigManager; | 5 | use Shaarli\Config\ConfigManager; |
5 | 6 | ||
6 | /** | 7 | /** |
@@ -8,9 +9,6 @@ use Shaarli\Config\ConfigManager; | |||
8 | */ | 9 | */ |
9 | class LoginManager | 10 | class 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 | */ |
9 | class SessionManager | 9 | class 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 | ||
3 | namespace Shaarli\Updater; | 3 | namespace Shaarli\Updater; |
4 | 4 | ||
5 | use Exception; | 5 | use Shaarli\Bookmark\BookmarkServiceInterface; |
6 | use RainTPL; | ||
7 | use ReflectionClass; | ||
8 | use ReflectionException; | ||
9 | use ReflectionMethod; | ||
10 | use Shaarli\ApplicationUtils; | ||
11 | use Shaarli\Bookmark\LinkDB; | ||
12 | use Shaarli\Bookmark\LinkFilter; | ||
13 | use Shaarli\Config\ConfigJson; | ||
14 | use Shaarli\Config\ConfigManager; | 6 | use Shaarli\Config\ConfigManager; |
15 | use Shaarli\Config\ConfigPhp; | ||
16 | use Shaarli\Exceptions\IOException; | ||
17 | use Shaarli\Thumbnailer; | ||
18 | use Shaarli\Updater\Exception\UpdaterException; | 7 | use 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 | */ |
25 | class Updater | 14 | class 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 | /** | 3 | namespace Shaarli\Updater; |
4 | * Read the updates file, and return already done updates. | 4 | |
5 | * | 5 | class UpdaterUtils |
6 | * @param string $updatesFilepath Updates file path. | ||
7 | * | ||
8 | * @return array Already done update methods. | ||
9 | */ | ||
10 | function 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 | */ |
29 | function 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 | } |