aboutsummaryrefslogtreecommitdiffhomepage
path: root/application
diff options
context:
space:
mode:
Diffstat (limited to 'application')
-rw-r--r--application/ApplicationUtils.php83
-rw-r--r--application/Base64Url.php34
-rw-r--r--application/Cache.php2
-rw-r--r--application/CachedPage.php4
-rw-r--r--application/FeedBuilder.php27
-rw-r--r--application/FileUtils.php81
-rw-r--r--application/History.php216
-rw-r--r--application/HttpUtils.php79
-rw-r--r--application/Languages.php169
-rw-r--r--application/LinkDB.php152
-rw-r--r--application/LinkFilter.php246
-rw-r--r--application/LinkUtils.php110
-rw-r--r--application/NetscapeBookmarkUtils.php61
-rw-r--r--application/PageBuilder.php40
-rw-r--r--application/PluginManager.php7
-rw-r--r--application/Router.php18
-rw-r--r--application/SessionManager.php83
-rw-r--r--application/ThemeUtils.php34
-rw-r--r--application/TimeZone.php101
-rw-r--r--application/Updater.php209
-rw-r--r--application/Url.php29
-rw-r--r--application/Utils.php284
-rw-r--r--application/api/ApiMiddleware.php138
-rw-r--r--application/api/ApiUtils.php137
-rw-r--r--application/api/controllers/ApiController.php71
-rw-r--r--application/api/controllers/History.php70
-rw-r--r--application/api/controllers/Info.php42
-rw-r--r--application/api/controllers/Links.php217
-rw-r--r--application/api/exceptions/ApiAuthorizationException.php34
-rw-r--r--application/api/exceptions/ApiBadParametersException.php19
-rw-r--r--application/api/exceptions/ApiException.php77
-rw-r--r--application/api/exceptions/ApiInternalException.php19
-rw-r--r--application/api/exceptions/ApiLinkNotFoundException.php32
-rw-r--r--application/config/ConfigIO.php7
-rw-r--r--application/config/ConfigJson.php28
-rw-r--r--application/config/ConfigManager.php73
-rw-r--r--application/config/ConfigPhp.php22
-rw-r--r--application/config/ConfigPlugin.php17
-rw-r--r--application/config/exception/MissingFieldConfigException.php23
-rw-r--r--application/config/exception/PluginConfigOrderException.php17
-rw-r--r--application/config/exception/UnauthorizedConfigException.php18
-rw-r--r--application/exceptions/IOException.php22
42 files changed, 2664 insertions, 488 deletions
diff --git a/application/ApplicationUtils.php b/application/ApplicationUtils.php
index 7f963e97..911873a0 100644
--- a/application/ApplicationUtils.php
+++ b/application/ApplicationUtils.php
@@ -4,9 +4,13 @@
4 */ 4 */
5class ApplicationUtils 5class ApplicationUtils
6{ 6{
7 /**
8 * @var string File containing the current version
9 */
10 public static $VERSION_FILE = 'shaarli_version.php';
11
7 private static $GIT_URL = 'https://raw.githubusercontent.com/shaarli/Shaarli'; 12 private static $GIT_URL = 'https://raw.githubusercontent.com/shaarli/Shaarli';
8 private static $GIT_BRANCHES = array('master', 'stable'); 13 private static $GIT_BRANCHES = array('latest', 'stable');
9 private static $VERSION_FILE = 'shaarli_version.php';
10 private static $VERSION_START_TAG = '<?php /* '; 14 private static $VERSION_START_TAG = '<?php /* ';
11 private static $VERSION_END_TAG = ' */ ?>'; 15 private static $VERSION_END_TAG = ' */ ?>';
12 16
@@ -29,6 +33,30 @@ class ApplicationUtils
29 return false; 33 return false;
30 } 34 }
31 35
36 return $data;
37 }
38
39 /**
40 * Retrieve the version from a remote URL or a file.
41 *
42 * @param string $remote URL or file to fetch.
43 * @param int $timeout For URLs fetching.
44 *
45 * @return bool|string The version or false if it couldn't be retrieved.
46 */
47 public static function getVersion($remote, $timeout = 2)
48 {
49 if (startsWith($remote, 'http')) {
50 if (($data = static::getLatestGitVersionCode($remote, $timeout)) === false) {
51 return false;
52 }
53 } else {
54 if (! is_file($remote)) {
55 return false;
56 }
57 $data = file_get_contents($remote);
58 }
59
32 return str_replace( 60 return str_replace(
33 array(self::$VERSION_START_TAG, self::$VERSION_END_TAG, PHP_EOL), 61 array(self::$VERSION_START_TAG, self::$VERSION_END_TAG, PHP_EOL),
34 array('', '', ''), 62 array('', '', ''),
@@ -65,13 +93,10 @@ class ApplicationUtils
65 $isLoggedIn, 93 $isLoggedIn,
66 $branch='stable') 94 $branch='stable')
67 { 95 {
68 if (! $isLoggedIn) { 96 // Do not check versions for visitors
69 // Do not check versions for visitors 97 // Do not check if the user doesn't want to
70 return false; 98 // Do not check with dev version
71 } 99 if (! $isLoggedIn || empty($enableCheck) || $currentVersion === 'dev') {
72
73 if (empty($enableCheck)) {
74 // Do not check if the user doesn't want to
75 return false; 100 return false;
76 } 101 }
77 102
@@ -93,7 +118,7 @@ class ApplicationUtils
93 118
94 // Late Static Binding allows overriding within tests 119 // Late Static Binding allows overriding within tests
95 // See http://php.net/manual/en/language.oop5.late-static-bindings.php 120 // See http://php.net/manual/en/language.oop5.late-static-bindings.php
96 $latestVersion = static::getLatestGitVersionCode( 121 $latestVersion = static::getVersion(
97 self::$GIT_URL . '/' . $branch . '/' . self::$VERSION_FILE 122 self::$GIT_URL . '/' . $branch . '/' . self::$VERSION_FILE
98 ); 123 );
99 124
@@ -124,12 +149,13 @@ class ApplicationUtils
124 public static function checkPHPVersion($minVersion, $curVersion) 149 public static function checkPHPVersion($minVersion, $curVersion)
125 { 150 {
126 if (version_compare($curVersion, $minVersion) < 0) { 151 if (version_compare($curVersion, $minVersion) < 0) {
127 throw new Exception( 152 $msg = t(
128 'Your PHP version is obsolete!' 153 'Your PHP version is obsolete!'
129 .' Shaarli requires at least PHP '.$minVersion.', and thus cannot run.' 154 . ' Shaarli requires at least PHP %s, and thus cannot run.'
130 .' Your PHP version has known security vulnerabilities and should be' 155 . ' Your PHP version has known security vulnerabilities and should be'
131 .' updated as soon as possible.' 156 . ' updated as soon as possible.'
132 ); 157 );
158 throw new Exception(sprintf($msg, $minVersion));
133 } 159 }
134 } 160 }
135 161
@@ -143,16 +169,18 @@ class ApplicationUtils
143 public static function checkResourcePermissions($conf) 169 public static function checkResourcePermissions($conf)
144 { 170 {
145 $errors = array(); 171 $errors = array();
172 $rainTplDir = rtrim($conf->get('resource.raintpl_tpl'), '/');
146 173
147 // Check script and template directories are readable 174 // Check script and template directories are readable
148 foreach (array( 175 foreach (array(
149 'application', 176 'application',
150 'inc', 177 'inc',
151 'plugins', 178 'plugins',
152 $conf->get('resource.raintpl_tpl'), 179 $rainTplDir,
180 $rainTplDir.'/'.$conf->get('resource.theme'),
153 ) as $path) { 181 ) as $path) {
154 if (! is_readable(realpath($path))) { 182 if (! is_readable(realpath($path))) {
155 $errors[] = '"'.$path.'" directory is not readable'; 183 $errors[] = '"'.$path.'" '. t('directory is not readable');
156 } 184 }
157 } 185 }
158 186
@@ -164,10 +192,10 @@ class ApplicationUtils
164 $conf->get('resource.raintpl_tmp'), 192 $conf->get('resource.raintpl_tmp'),
165 ) as $path) { 193 ) as $path) {
166 if (! is_readable(realpath($path))) { 194 if (! is_readable(realpath($path))) {
167 $errors[] = '"'.$path.'" directory is not readable'; 195 $errors[] = '"'.$path.'" '. t('directory is not readable');
168 } 196 }
169 if (! is_writable(realpath($path))) { 197 if (! is_writable(realpath($path))) {
170 $errors[] = '"'.$path.'" directory is not writable'; 198 $errors[] = '"'.$path.'" '. t('directory is not writable');
171 } 199 }
172 } 200 }
173 201
@@ -185,13 +213,28 @@ class ApplicationUtils
185 } 213 }
186 214
187 if (! is_readable(realpath($path))) { 215 if (! is_readable(realpath($path))) {
188 $errors[] = '"'.$path.'" file is not readable'; 216 $errors[] = '"'.$path.'" '. t('file is not readable');
189 } 217 }
190 if (! is_writable(realpath($path))) { 218 if (! is_writable(realpath($path))) {
191 $errors[] = '"'.$path.'" file is not writable'; 219 $errors[] = '"'.$path.'" '. t('file is not writable');
192 } 220 }
193 } 221 }
194 222
195 return $errors; 223 return $errors;
196 } 224 }
225
226 /**
227 * Returns a salted hash representing the current Shaarli version.
228 *
229 * Useful for assets browser cache.
230 *
231 * @param string $currentVersion of Shaarli
232 * @param string $salt User personal salt, also used for the authentication
233 *
234 * @return string version hash
235 */
236 public static function getVersionHash($currentVersion, $salt)
237 {
238 return hash_hmac('sha256', $currentVersion, $salt);
239 }
197} 240}
diff --git a/application/Base64Url.php b/application/Base64Url.php
new file mode 100644
index 00000000..61590e43
--- /dev/null
+++ b/application/Base64Url.php
@@ -0,0 +1,34 @@
1<?php
2
3namespace Shaarli;
4
5
6/**
7 * URL-safe Base64 operations
8 *
9 * @see https://en.wikipedia.org/wiki/Base64#URL_applications
10 */
11class Base64Url
12{
13 /**
14 * Base64Url-encodes data
15 *
16 * @param string $data Data to encode
17 *
18 * @return string Base64Url-encoded data
19 */
20 public static function encode($data) {
21 return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
22 }
23
24 /**
25 * Decodes Base64Url-encoded data
26 *
27 * @param string $data Data to decode
28 *
29 * @return string Decoded data
30 */
31 public static function decode($data) {
32 return base64_decode(str_pad(strtr($data, '-_', '+/'), strlen($data) % 4, '=', STR_PAD_RIGHT));
33 }
34}
diff --git a/application/Cache.php b/application/Cache.php
index 5d050165..e5d43e61 100644
--- a/application/Cache.php
+++ b/application/Cache.php
@@ -13,7 +13,7 @@
13function purgeCachedPages($pageCacheDir) 13function purgeCachedPages($pageCacheDir)
14{ 14{
15 if (! is_dir($pageCacheDir)) { 15 if (! is_dir($pageCacheDir)) {
16 $error = 'Cannot purge '.$pageCacheDir.': no directory'; 16 $error = sprintf(t('Cannot purge %s: no directory'), $pageCacheDir);
17 error_log($error); 17 error_log($error);
18 return $error; 18 return $error;
19 } 19 }
diff --git a/application/CachedPage.php b/application/CachedPage.php
index 5087d0c4..e11cc52d 100644
--- a/application/CachedPage.php
+++ b/application/CachedPage.php
@@ -7,9 +7,6 @@ class CachedPage
7 // Directory containing page caches 7 // Directory containing page caches
8 private $cacheDir; 8 private $cacheDir;
9 9
10 // Full URL of the page to cache -typically the value returned by pageUrl()
11 private $url;
12
13 // Should this URL be cached (boolean)? 10 // Should this URL be cached (boolean)?
14 private $shouldBeCached; 11 private $shouldBeCached;
15 12
@@ -27,7 +24,6 @@ class CachedPage
27 { 24 {
28 // TODO: check write access to the cache directory 25 // TODO: check write access to the cache directory
29 $this->cacheDir = $cacheDir; 26 $this->cacheDir = $cacheDir;
30 $this->url = $url;
31 $this->filename = $this->cacheDir.'/'.sha1($url).'.cache'; 27 $this->filename = $this->cacheDir.'/'.sha1($url).'.cache';
32 $this->shouldBeCached = $shouldBeCached; 28 $this->shouldBeCached = $shouldBeCached;
33 } 29 }
diff --git a/application/FeedBuilder.php b/application/FeedBuilder.php
index fedd90e6..ebae18b4 100644
--- a/application/FeedBuilder.php
+++ b/application/FeedBuilder.php
@@ -63,11 +63,6 @@ class FeedBuilder
63 protected $hideDates; 63 protected $hideDates;
64 64
65 /** 65 /**
66 * @var string PubSub hub URL.
67 */
68 protected $pubsubhubUrl;
69
70 /**
71 * @var string server locale. 66 * @var string server locale.
72 */ 67 */
73 protected $locale; 68 protected $locale;
@@ -102,6 +97,11 @@ class FeedBuilder
102 */ 97 */
103 public function buildData() 98 public function buildData()
104 { 99 {
100 // Search for untagged links
101 if (isset($this->userInput['searchtags']) && empty($this->userInput['searchtags'])) {
102 $this->userInput['searchtags'] = false;
103 }
104
105 // Optionally filter the results: 105 // Optionally filter the results:
106 $linksToDisplay = $this->linkDB->filterSearch($this->userInput); 106 $linksToDisplay = $this->linkDB->filterSearch($this->userInput);
107 107
@@ -120,7 +120,6 @@ class FeedBuilder
120 } 120 }
121 121
122 $data['language'] = $this->getTypeLanguage(); 122 $data['language'] = $this->getTypeLanguage();
123 $data['pubsubhub_url'] = $this->pubsubhubUrl;
124 $data['last_update'] = $this->getLatestDateFormatted(); 123 $data['last_update'] = $this->getLatestDateFormatted();
125 $data['show_dates'] = !$this->hideDates || $this->isLoggedIn; 124 $data['show_dates'] = !$this->hideDates || $this->isLoggedIn;
126 // Remove leading slash from REQUEST_URI. 125 // Remove leading slash from REQUEST_URI.
@@ -149,11 +148,11 @@ class FeedBuilder
149 $link['url'] = $pageaddr . $link['url']; 148 $link['url'] = $pageaddr . $link['url'];
150 } 149 }
151 if ($this->usePermalinks === true) { 150 if ($this->usePermalinks === true) {
152 $permalink = '<a href="'. $link['url'] .'" title="Direct link">Direct link</a>'; 151 $permalink = '<a href="'. $link['url'] .'" title="'. t('Direct link') .'">'. t('Direct link') .'</a>';
153 } else { 152 } else {
154 $permalink = '<a href="'. $link['guid'] .'" title="Permalink">Permalink</a>'; 153 $permalink = '<a href="'. $link['guid'] .'" title="'. t('Permalink') .'">'. t('Permalink') .'</a>';
155 } 154 }
156 $link['description'] = format_description($link['description'], '', $pageaddr); 155 $link['description'] = format_description($link['description'], '', false, $pageaddr);
157 $link['description'] .= PHP_EOL .'<br>&#8212; '. $permalink; 156 $link['description'] .= PHP_EOL .'<br>&#8212; '. $permalink;
158 157
159 $pubDate = $link['created']; 158 $pubDate = $link['created'];
@@ -183,16 +182,6 @@ class FeedBuilder
183 } 182 }
184 183
185 /** 184 /**
186 * Assign PubSub hub URL.
187 *
188 * @param string $pubsubhubUrl PubSub hub url.
189 */
190 public function setPubsubhubUrl($pubsubhubUrl)
191 {
192 $this->pubsubhubUrl = $pubsubhubUrl;
193 }
194
195 /**
196 * Set this to true to use permalinks instead of direct links. 185 * Set this to true to use permalinks instead of direct links.
197 * 186 *
198 * @param boolean $usePermalinks true to force permalinks. 187 * @param boolean $usePermalinks true to force permalinks.
diff --git a/application/FileUtils.php b/application/FileUtils.php
index 6cac9825..918cb83b 100644
--- a/application/FileUtils.php
+++ b/application/FileUtils.php
@@ -1,21 +1,82 @@
1<?php 1<?php
2
3require_once 'exceptions/IOException.php';
4
2/** 5/**
3 * Exception class thrown when a filesystem access failure happens 6 * Class FileUtils
7 *
8 * Utility class for file manipulation.
4 */ 9 */
5class IOException extends Exception 10class FileUtils
6{ 11{
7 private $path; 12 /**
13 * @var string
14 */
15 protected static $phpPrefix = '<?php /* ';
8 16
9 /** 17 /**
10 * Construct a new IOException 18 * @var string
19 */
20 protected static $phpSuffix = ' */ ?>';
21
22 /**
23 * Write data into a file (Shaarli database format).
24 * The data is stored in a PHP file, as a comment, in compressed base64 format.
25 *
26 * The file will be created if it doesn't exist.
27 *
28 * @param string $file File path.
29 * @param mixed $content Content to write.
11 * 30 *
12 * @param string $path path to the resource that cannot be accessed 31 * @return int|bool Number of bytes written or false if it fails.
13 * @param string $message Custom exception message. 32 *
33 * @throws IOException The destination file can't be written.
14 */ 34 */
15 public function __construct($path, $message = '') 35 public static function writeFlatDB($file, $content)
16 { 36 {
17 $this->path = $path; 37 if (is_file($file) && !is_writeable($file)) {
18 $this->message = empty($message) ? 'Error accessing' : $message; 38 // The datastore exists but is not writeable
19 $this->message .= PHP_EOL . $this->path; 39 throw new IOException($file);
40 } else if (!is_file($file) && !is_writeable(dirname($file))) {
41 // The datastore does not exist and its parent directory is not writeable
42 throw new IOException(dirname($file));
43 }
44
45 return file_put_contents(
46 $file,
47 self::$phpPrefix.base64_encode(gzdeflate(serialize($content))).self::$phpSuffix
48 );
49 }
50
51 /**
52 * Read data from a file containing Shaarli database format content.
53 *
54 * If the file isn't readable or doesn't exist, default data will be returned.
55 *
56 * @param string $file File path.
57 * @param mixed $default The default value to return if the file isn't readable.
58 *
59 * @return mixed The content unserialized, or default if the file isn't readable, or false if it fails.
60 */
61 public static function readFlatDB($file, $default = null)
62 {
63 // Note that gzinflate is faster than gzuncompress.
64 // See: http://www.php.net/manual/en/function.gzdeflate.php#96439
65 if (! is_readable($file)) {
66 return $default;
67 }
68
69 $data = file_get_contents($file);
70 if ($data == '') {
71 return $default;
72 }
73
74 return unserialize(
75 gzinflate(
76 base64_decode(
77 substr($data, strlen(self::$phpPrefix), -strlen(self::$phpSuffix))
78 )
79 )
80 );
20 } 81 }
21} 82}
diff --git a/application/History.php b/application/History.php
new file mode 100644
index 00000000..35ec016a
--- /dev/null
+++ b/application/History.php
@@ -0,0 +1,216 @@
1<?php
2
3/**
4 * Class History
5 *
6 * Handle the history file tracing events in Shaarli.
7 * The history is stored as JSON in a file set by 'resource.history' setting.
8 *
9 * Available data:
10 * - event: event key
11 * - datetime: event date, in ISO8601 format.
12 * - id: event item identifier (currently only link IDs).
13 *
14 * Available event keys:
15 * - CREATED: new link
16 * - UPDATED: link updated
17 * - DELETED: link deleted
18 * - SETTINGS: the settings have been updated through the UI.
19 * - IMPORT: bulk links import
20 *
21 * Note: new events are put at the beginning of the file and history array.
22 */
23class History
24{
25 /**
26 * @var string Action key: a new link has been created.
27 */
28 const CREATED = 'CREATED';
29
30 /**
31 * @var string Action key: a link has been updated.
32 */
33 const UPDATED = 'UPDATED';
34
35 /**
36 * @var string Action key: a link has been deleted.
37 */
38 const DELETED = 'DELETED';
39
40 /**
41 * @var string Action key: settings have been updated.
42 */
43 const SETTINGS = 'SETTINGS';
44
45 /**
46 * @var string Action key: a bulk import has been processed.
47 */
48 const IMPORT = 'IMPORT';
49
50 /**
51 * @var string History file path.
52 */
53 protected $historyFilePath;
54
55 /**
56 * @var array History data.
57 */
58 protected $history;
59
60 /**
61 * @var int History retention time in seconds (1 month).
62 */
63 protected $retentionTime = 2678400;
64
65 /**
66 * History constructor.
67 *
68 * @param string $historyFilePath History file path.
69 * @param int $retentionTime History content rentention time in seconds.
70 *
71 * @throws Exception if something goes wrong.
72 */
73 public function __construct($historyFilePath, $retentionTime = null)
74 {
75 $this->historyFilePath = $historyFilePath;
76 if ($retentionTime !== null) {
77 $this->retentionTime = $retentionTime;
78 }
79 }
80
81 /**
82 * Initialize: read history file.
83 *
84 * Allow lazy loading (don't read the file if it isn't necessary).
85 */
86 protected function initialize()
87 {
88 $this->check();
89 $this->read();
90 }
91
92 /**
93 * Add Event: new link.
94 *
95 * @param array $link Link data.
96 */
97 public function addLink($link)
98 {
99 $this->addEvent(self::CREATED, $link['id']);
100 }
101
102 /**
103 * Add Event: update existing link.
104 *
105 * @param array $link Link data.
106 */
107 public function updateLink($link)
108 {
109 $this->addEvent(self::UPDATED, $link['id']);
110 }
111
112 /**
113 * Add Event: delete existing link.
114 *
115 * @param array $link Link data.
116 */
117 public function deleteLink($link)
118 {
119 $this->addEvent(self::DELETED, $link['id']);
120 }
121
122 /**
123 * Add Event: settings updated.
124 */
125 public function updateSettings()
126 {
127 $this->addEvent(self::SETTINGS);
128 }
129
130 /**
131 * Add Event: bulk import.
132 *
133 * Note: we don't store links add/update one by one since it can have a huge impact on performances.
134 */
135 public function importLinks()
136 {
137 $this->addEvent(self::IMPORT);
138 }
139
140 /**
141 * Save a new event and write it in the history file.
142 *
143 * @param string $status Event key, should be defined as constant.
144 * @param mixed $id Event item identifier (e.g. link ID).
145 */
146 protected function addEvent($status, $id = null)
147 {
148 if ($this->history === null) {
149 $this->initialize();
150 }
151
152 $item = [
153 'event' => $status,
154 'datetime' => new DateTime(),
155 'id' => $id !== null ? $id : '',
156 ];
157 $this->history = array_merge([$item], $this->history);
158 $this->write();
159 }
160
161 /**
162 * Check that the history file is writable.
163 * Create the file if it doesn't exist.
164 *
165 * @throws Exception if it isn't writable.
166 */
167 protected function check()
168 {
169 if (! is_file($this->historyFilePath)) {
170 FileUtils::writeFlatDB($this->historyFilePath, []);
171 }
172
173 if (! is_writable($this->historyFilePath)) {
174 throw new Exception(t('History file isn\'t readable or writable'));
175 }
176 }
177
178 /**
179 * Read JSON history file.
180 */
181 protected function read()
182 {
183 $this->history = FileUtils::readFlatDB($this->historyFilePath, []);
184 if ($this->history === false) {
185 throw new Exception(t('Could not parse history file'));
186 }
187 }
188
189 /**
190 * Write JSON history file and delete old entries.
191 */
192 protected function write()
193 {
194 $comparaison = new DateTime('-'. $this->retentionTime . ' seconds');
195 foreach ($this->history as $key => $value) {
196 if ($value['datetime'] < $comparaison) {
197 unset($this->history[$key]);
198 }
199 }
200 FileUtils::writeFlatDB($this->historyFilePath, array_values($this->history));
201 }
202
203 /**
204 * Get the History.
205 *
206 * @return array
207 */
208 public function getHistory()
209 {
210 if ($this->history === null) {
211 $this->initialize();
212 }
213
214 return $this->history;
215 }
216}
diff --git a/application/HttpUtils.php b/application/HttpUtils.php
index e705cfd6..83a4c5e2 100644
--- a/application/HttpUtils.php
+++ b/application/HttpUtils.php
@@ -3,9 +3,11 @@
3 * GET an HTTP URL to retrieve its content 3 * GET an HTTP URL to retrieve its content
4 * Uses the cURL library or a fallback method 4 * Uses the cURL library or a fallback method
5 * 5 *
6 * @param string $url URL to get (http://...) 6 * @param string $url URL to get (http://...)
7 * @param int $timeout network timeout (in seconds) 7 * @param int $timeout network timeout (in seconds)
8 * @param int $maxBytes maximum downloaded bytes (default: 4 MiB) 8 * @param int $maxBytes maximum downloaded bytes (default: 4 MiB)
9 * @param callable|string $curlWriteFunction Optional callback called during the download (cURL CURLOPT_WRITEFUNCTION).
10 * Can be used to add download conditions on the headers (response code, content type, etc.).
9 * 11 *
10 * @return array HTTP response headers, downloaded content 12 * @return array HTTP response headers, downloaded content
11 * 13 *
@@ -29,7 +31,7 @@
29 * @see http://stackoverflow.com/q/9183178 31 * @see http://stackoverflow.com/q/9183178
30 * @see http://stackoverflow.com/q/1462720 32 * @see http://stackoverflow.com/q/1462720
31 */ 33 */
32function get_http_response($url, $timeout = 30, $maxBytes = 4194304) 34function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteFunction = null)
33{ 35{
34 $urlObj = new Url($url); 36 $urlObj = new Url($url);
35 $cleanUrl = $urlObj->idnToAscii(); 37 $cleanUrl = $urlObj->idnToAscii();
@@ -75,8 +77,12 @@ function get_http_response($url, $timeout = 30, $maxBytes = 4194304)
75 curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); 77 curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
76 curl_setopt($ch, CURLOPT_USERAGENT, $userAgent); 78 curl_setopt($ch, CURLOPT_USERAGENT, $userAgent);
77 79
80 if (is_callable($curlWriteFunction)) {
81 curl_setopt($ch, CURLOPT_WRITEFUNCTION, $curlWriteFunction);
82 }
83
78 // Max download size management 84 // Max download size management
79 curl_setopt($ch, CURLOPT_BUFFERSIZE, 1024); 85 curl_setopt($ch, CURLOPT_BUFFERSIZE, 1024*16);
80 curl_setopt($ch, CURLOPT_NOPROGRESS, false); 86 curl_setopt($ch, CURLOPT_NOPROGRESS, false);
81 curl_setopt($ch, CURLOPT_PROGRESSFUNCTION, 87 curl_setopt($ch, CURLOPT_PROGRESSFUNCTION,
82 function($arg0, $arg1, $arg2, $arg3, $arg4 = 0) use ($maxBytes) 88 function($arg0, $arg1, $arg2, $arg3, $arg4 = 0) use ($maxBytes)
@@ -122,7 +128,7 @@ function get_http_response($url, $timeout = 30, $maxBytes = 4194304)
122 $content = substr($response, $headSize); 128 $content = substr($response, $headSize);
123 $headers = array(); 129 $headers = array();
124 foreach (preg_split('~[\r\n]+~', $rawHeadersLastRedir) as $line) { 130 foreach (preg_split('~[\r\n]+~', $rawHeadersLastRedir) as $line) {
125 if (empty($line) or ctype_space($line)) { 131 if (empty($line) || ctype_space($line)) {
126 continue; 132 continue;
127 } 133 }
128 $splitLine = explode(': ', $line, 2); 134 $splitLine = explode(': ', $line, 2);
@@ -297,13 +303,40 @@ function server_url($server)
297 // Keep forwarded port 303 // Keep forwarded port
298 if (strpos($server['HTTP_X_FORWARDED_PORT'], ',') !== false) { 304 if (strpos($server['HTTP_X_FORWARDED_PORT'], ',') !== false) {
299 $ports = explode(',', $server['HTTP_X_FORWARDED_PORT']); 305 $ports = explode(',', $server['HTTP_X_FORWARDED_PORT']);
300 $port = ':' . trim($ports[0]); 306 $port = trim($ports[0]);
307 } else {
308 $port = $server['HTTP_X_FORWARDED_PORT'];
309 }
310
311 // This is a workaround for proxies that don't forward the scheme properly.
312 // Connecting over port 443 has to be in HTTPS.
313 // See https://github.com/shaarli/Shaarli/issues/1022
314 if ($port == '443') {
315 $scheme = 'https';
316 }
317
318 if (($scheme == 'http' && $port != '80')
319 || ($scheme == 'https' && $port != '443')
320 ) {
321 $port = ':' . $port;
301 } else { 322 } else {
302 $port = ':' . $server['HTTP_X_FORWARDED_PORT']; 323 $port = '';
303 } 324 }
304 } 325 }
305 326
306 return $scheme.'://'.$server['SERVER_NAME'].$port; 327 if (isset($server['HTTP_X_FORWARDED_HOST'])) {
328 // Keep forwarded host
329 if (strpos($server['HTTP_X_FORWARDED_HOST'], ',') !== false) {
330 $hosts = explode(',', $server['HTTP_X_FORWARDED_HOST']);
331 $host = trim($hosts[0]);
332 } else {
333 $host = $server['HTTP_X_FORWARDED_HOST'];
334 }
335 } else {
336 $host = $server['SERVER_NAME'];
337 }
338
339 return $scheme.'://'.$host.$port;
307 } 340 }
308 341
309 // SSL detection 342 // SSL detection
@@ -381,3 +414,31 @@ function getIpAddressFromProxy($server, $trustedIps)
381 414
382 return array_pop($ips); 415 return array_pop($ips);
383} 416}
417
418/**
419 * Returns true if Shaarli's currently browsed in HTTPS.
420 * Supports reverse proxies (if the headers are correctly set).
421 *
422 * @param array $server $_SERVER.
423 *
424 * @return bool true if HTTPS, false otherwise.
425 */
426function is_https($server)
427{
428
429 if (isset($server['HTTP_X_FORWARDED_PORT'])) {
430 // Keep forwarded port
431 if (strpos($server['HTTP_X_FORWARDED_PORT'], ',') !== false) {
432 $ports = explode(',', $server['HTTP_X_FORWARDED_PORT']);
433 $port = trim($ports[0]);
434 } else {
435 $port = $server['HTTP_X_FORWARDED_PORT'];
436 }
437
438 if ($port == '443') {
439 return true;
440 }
441 }
442
443 return ! empty($server['HTTPS']);
444}
diff --git a/application/Languages.php b/application/Languages.php
index c8b0a25a..3eb3388f 100644
--- a/application/Languages.php
+++ b/application/Languages.php
@@ -1,21 +1,166 @@
1<?php 1<?php
2 2
3namespace Shaarli;
4
5use Gettext\GettextTranslator;
6use Gettext\Merge;
7use Gettext\Translations;
8use Gettext\Translator;
9use Gettext\TranslatorInterface;
10use Shaarli\Config\ConfigManager;
11
3/** 12/**
4 * Wrapper function for translation which match the API 13 * Class Languages
5 * of gettext()/_() and ngettext(). 14 *
15 * Load Shaarli translations using 'gettext/gettext'.
16 * This class allows to either use PHP gettext extension, or a PHP implementation of gettext,
17 * with a fixed language, or dynamically using autoLocale().
6 * 18 *
7 * Not doing translation for now. 19 * Translation files PO/MO files follow gettext standard and must be placed under:
20 * <translation path>/<language>/LC_MESSAGES/<domain>.[po|mo]
8 * 21 *
9 * @param string $text Text to translate. 22 * Pros/cons:
10 * @param string $nText The plural message ID. 23 * - gettext extension is faster
11 * @param int $nb The number of items for plural forms. 24 * - gettext is very system dependent (PHP extension, the locale must be installed, and web server reloaded)
12 * 25 *
13 * @return String Text translated. 26 * Settings:
27 * - translation.mode:
28 * - auto: use default setting (PHP implementation)
29 * - php: use PHP implementation
30 * - gettext: use gettext wrapper
31 * - translation.language:
32 * - auto: use autoLocale() and the language change according to user HTTP headers
33 * - fixed language: e.g. 'fr'
34 * - translation.extensions:
35 * - domain => translation_path: allow plugins and themes to extend the defaut extension
36 * The domain must be unique, and translation path must be relative, and contains the tree mentioned above.
37 *
38 * @package Shaarli
14 */ 39 */
15function t($text, $nText = '', $nb = 0) { 40class Languages
16 if (empty($nText)) { 41{
17 return $text; 42 /**
43 * Core translations domain
44 */
45 const DEFAULT_DOMAIN = 'shaarli';
46
47 /**
48 * @var TranslatorInterface
49 */
50 protected $translator;
51
52 /**
53 * @var string
54 */
55 protected $language;
56
57 /**
58 * @var ConfigManager
59 */
60 protected $conf;
61
62 /**
63 * Languages constructor.
64 *
65 * @param string $language lang determined by autoLocale(), can be overridden.
66 * @param ConfigManager $conf instance.
67 */
68 public function __construct($language, $conf)
69 {
70 $this->conf = $conf;
71 $confLanguage = $this->conf->get('translation.language', 'auto');
72 // Auto mode or invalid parameter, use the detected language.
73 // If the detected language is invalid, it doesn't matter, it will use English.
74 if ($confLanguage === 'auto' || ! $this->isValidLanguage($confLanguage)) {
75 $this->language = substr($language, 0, 5);
76 } else {
77 $this->language = $confLanguage;
78 }
79
80 if (! extension_loaded('gettext')
81 || in_array($this->conf->get('translation.mode', 'auto'), ['auto', 'php'])
82 ) {
83 $this->initPhpTranslator();
84 } else {
85 $this->initGettextTranslator();
86 }
87
88 // Register default functions (e.g. '__()') to use our Translator
89 $this->translator->register();
90 }
91
92 /**
93 * Initialize the translator using php gettext extension (gettext dependency act as a wrapper).
94 */
95 protected function initGettextTranslator ()
96 {
97 $this->translator = new GettextTranslator();
98 $this->translator->setLanguage($this->language);
99 $this->translator->loadDomain(self::DEFAULT_DOMAIN, 'inc/languages');
100
101 foreach ($this->conf->get('translation.extensions', []) as $domain => $translationPath) {
102 if ($domain !== self::DEFAULT_DOMAIN) {
103 $this->translator->loadDomain($domain, $translationPath, false);
104 }
105 }
106 }
107
108 /**
109 * Initialize the translator using a PHP implementation of gettext.
110 *
111 * Note that if language po file doesn't exist, errors are ignored (e.g. not installed language).
112 */
113 protected function initPhpTranslator()
114 {
115 $this->translator = new Translator();
116 $translations = new Translations();
117 // Core translations
118 try {
119 /** @var Translations $translations */
120 $translations = $translations->addFromPoFile('inc/languages/'. $this->language .'/LC_MESSAGES/shaarli.po');
121 $translations->setDomain('shaarli');
122 $this->translator->loadTranslations($translations);
123 } catch (\InvalidArgumentException $e) {}
124
125
126 // Extension translations (plugins, themes, etc.).
127 foreach ($this->conf->get('translation.extensions', []) as $domain => $translationPath) {
128 if ($domain === self::DEFAULT_DOMAIN) {
129 continue;
130 }
131
132 try {
133 /** @var Translations $extension */
134 $extension = Translations::fromPoFile($translationPath . $this->language .'/LC_MESSAGES/'. $domain .'.po');
135 $extension->setDomain($domain);
136 $this->translator->loadTranslations($extension);
137 } catch (\InvalidArgumentException $e) {}
138 }
139 }
140
141 /**
142 * Checks if a language string is valid.
143 *
144 * @param string $language e.g. 'fr' or 'en_US'
145 *
146 * @return bool true if valid, false otherwise
147 */
148 protected function isValidLanguage($language)
149 {
150 return preg_match('/^[a-z]{2}(_[A-Z]{2})?/', $language) === 1;
151 }
152
153 /**
154 * Get the list of available languages for Shaarli.
155 *
156 * @return array List of available languages, with their label.
157 */
158 public static function getAvailableLanguages()
159 {
160 return [
161 'auto' => t('Automatic'),
162 'en' => t('English'),
163 'fr' => t('French'),
164 ];
18 } 165 }
19 $actualForm = $nb > 1 ? $nText : $text;
20 return sprintf($actualForm, $nb);
21} 166}
diff --git a/application/LinkDB.php b/application/LinkDB.php
index 1e13286a..c1661d52 100644
--- a/application/LinkDB.php
+++ b/application/LinkDB.php
@@ -50,12 +50,6 @@ class LinkDB implements Iterator, Countable, ArrayAccess
50 // Link date storage format 50 // Link date storage format
51 const LINK_DATE_FORMAT = 'Ymd_His'; 51 const LINK_DATE_FORMAT = 'Ymd_His';
52 52
53 // Datastore PHP prefix
54 protected static $phpPrefix = '<?php /* ';
55
56 // Datastore PHP suffix
57 protected static $phpSuffix = ' */ ?>';
58
59 // List of links (associative array) 53 // List of links (associative array)
60 // - key: link date (e.g. "20110823_124546"), 54 // - key: link date (e.g. "20110823_124546"),
61 // - value: associative array (keys: title, description...) 55 // - value: associative array (keys: title, description...)
@@ -139,16 +133,16 @@ class LinkDB implements Iterator, Countable, ArrayAccess
139 { 133 {
140 // TODO: use exceptions instead of "die" 134 // TODO: use exceptions instead of "die"
141 if (!$this->loggedIn) { 135 if (!$this->loggedIn) {
142 die('You are not authorized to add a link.'); 136 die(t('You are not authorized to add a link.'));
143 } 137 }
144 if (!isset($value['id']) || empty($value['url'])) { 138 if (!isset($value['id']) || empty($value['url'])) {
145 die('Internal Error: A link should always have an id and URL.'); 139 die(t('Internal Error: A link should always have an id and URL.'));
146 } 140 }
147 if ((! empty($offset) && ! is_int($offset)) || ! is_int($value['id'])) { 141 if (($offset !== null && ! is_int($offset)) || ! is_int($value['id'])) {
148 die('You must specify an integer as a key.'); 142 die(t('You must specify an integer as a key.'));
149 } 143 }
150 if (! empty($offset) && $offset !== $value['id']) { 144 if ($offset !== null && $offset !== $value['id']) {
151 die('Array offset and link ID must be equal.'); 145 die(t('Array offset and link ID must be equal.'));
152 } 146 }
153 147
154 // If the link exists, we reuse the real offset, otherwise new entry 148 // If the link exists, we reuse the real offset, otherwise new entry
@@ -254,13 +248,13 @@ class LinkDB implements Iterator, Countable, ArrayAccess
254 $this->links = array(); 248 $this->links = array();
255 $link = array( 249 $link = array(
256 'id' => 1, 250 'id' => 1,
257 'title'=>' Shaarli: the personal, minimalist, super-fast, no-database delicious clone', 251 'title'=> t('The personal, minimalist, super-fast, database free, bookmarking service'),
258 'url'=>'https://github.com/shaarli/Shaarli/wiki', 252 'url'=>'https://shaarli.readthedocs.io',
259 'description'=>'Welcome to Shaarli! This is your first public bookmark. To edit or delete me, you must first login. 253 'description'=>t('Welcome to Shaarli! This is your first public bookmark. To edit or delete me, you must first login.
260 254
261To learn how to use Shaarli, consult the link "Help/documentation" at the bottom of this page. 255To learn how to use Shaarli, consult the link "Documentation" at the bottom of this page.
262 256
263You use the community supported version of the original Shaarli project, by Sebastien Sauvage.', 257You use the community supported version of the original Shaarli project, by Sebastien Sauvage.'),
264 'private'=>0, 258 'private'=>0,
265 'created'=> new DateTime(), 259 'created'=> new DateTime(),
266 'tags'=>'opensource software' 260 'tags'=>'opensource software'
@@ -270,9 +264,9 @@ You use the community supported version of the original Shaarli project, by Seba
270 264
271 $link = array( 265 $link = array(
272 'id' => 0, 266 'id' => 0,
273 'title'=>'My secret stuff... - Pastebin.com', 267 'title'=> t('My secret stuff... - Pastebin.com'),
274 'url'=>'http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=', 268 'url'=>'http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=',
275 'description'=>'Shhhh! I\'m a private link only YOU can see. You can delete me too.', 269 'description'=> t('Shhhh! I\'m a private link only YOU can see. You can delete me too.'),
276 'private'=>1, 270 'private'=>1,
277 'created'=> new DateTime('1 minute ago'), 271 'created'=> new DateTime('1 minute ago'),
278 'tags'=>'secretstuff', 272 'tags'=>'secretstuff',
@@ -295,22 +289,15 @@ You use the community supported version of the original Shaarli project, by Seba
295 return; 289 return;
296 } 290 }
297 291
298 // Read data 292 $this->urls = [];
299 // Note that gzinflate is faster than gzuncompress. 293 $this->ids = [];
300 // See: http://www.php.net/manual/en/function.gzdeflate.php#96439 294 $this->links = FileUtils::readFlatDB($this->datastore, []);
301 $this->links = array();
302
303 if (file_exists($this->datastore)) {
304 $this->links = unserialize(gzinflate(base64_decode(
305 substr(file_get_contents($this->datastore),
306 strlen(self::$phpPrefix), -strlen(self::$phpSuffix)))));
307 }
308 295
309 $toremove = array(); 296 $toremove = array();
310 foreach ($this->links as $key => &$link) { 297 foreach ($this->links as $key => &$link) {
311 if (! $this->loggedIn && $link['private'] != 0) { 298 if (! $this->loggedIn && $link['private'] != 0) {
312 // Transition for not upgraded databases. 299 // Transition for not upgraded databases.
313 $toremove[] = $key; 300 unset($this->links[$key]);
314 continue; 301 continue;
315 } 302 }
316 303
@@ -344,14 +331,10 @@ You use the community supported version of the original Shaarli project, by Seba
344 } 331 }
345 $link['shorturl'] = smallHash($link['linkdate']); 332 $link['shorturl'] = smallHash($link['linkdate']);
346 } 333 }
347 }
348 334
349 // If user is not logged in, filter private links. 335 $this->urls[$link['url']] = $key;
350 foreach ($toremove as $offset) { 336 $this->ids[$link['id']] = $key;
351 unset($this->links[$offset]);
352 } 337 }
353
354 $this->reorder();
355 } 338 }
356 339
357 /** 340 /**
@@ -361,19 +344,8 @@ You use the community supported version of the original Shaarli project, by Seba
361 */ 344 */
362 private function write() 345 private function write()
363 { 346 {
364 if (is_file($this->datastore) && !is_writeable($this->datastore)) { 347 $this->reorder();
365 // The datastore exists but is not writeable 348 FileUtils::writeFlatDB($this->datastore, $this->links);
366 throw new IOException($this->datastore);
367 } else if (!is_file($this->datastore) && !is_writeable(dirname($this->datastore))) {
368 // The datastore does not exist and its parent directory is not writeable
369 throw new IOException(dirname($this->datastore));
370 }
371
372 file_put_contents(
373 $this->datastore,
374 self::$phpPrefix.base64_encode(gzdeflate(serialize($this->links))).self::$phpSuffix
375 );
376
377 } 349 }
378 350
379 /** 351 /**
@@ -443,50 +415,37 @@ You use the community supported version of the original Shaarli project, by Seba
443 * - searchtags: list of tags 415 * - searchtags: list of tags
444 * - searchterm: term search 416 * - searchterm: term search
445 * @param bool $casesensitive Optional: Perform case sensitive filter 417 * @param bool $casesensitive Optional: Perform case sensitive filter
446 * @param bool $privateonly Optional: Returns private links only if true. 418 * @param string $visibility return only all/private/public links
419 * @param string $untaggedonly return only untagged links
447 * 420 *
448 * @return array filtered links, all links if no suitable filter was provided. 421 * @return array filtered links, all links if no suitable filter was provided.
449 */ 422 */
450 public function filterSearch($filterRequest = array(), $casesensitive = false, $privateonly = false) 423 public function filterSearch($filterRequest = array(), $casesensitive = false, $visibility = 'all', $untaggedonly = false)
451 { 424 {
452 // Filter link database according to parameters. 425 // Filter link database according to parameters.
453 $searchtags = !empty($filterRequest['searchtags']) ? escape($filterRequest['searchtags']) : ''; 426 $searchtags = isset($filterRequest['searchtags']) ? escape($filterRequest['searchtags']) : '';
454 $searchterm = !empty($filterRequest['searchterm']) ? escape($filterRequest['searchterm']) : ''; 427 $searchterm = isset($filterRequest['searchterm']) ? escape($filterRequest['searchterm']) : '';
455 428
456 // Search tags + fullsearch. 429 // Search tags + fullsearch - blank string parameter will return all links.
457 if (! empty($searchtags) && ! empty($searchterm)) { 430 $type = LinkFilter::$FILTER_TAG | LinkFilter::$FILTER_TEXT; // == "vuotext"
458 $type = LinkFilter::$FILTER_TAG | LinkFilter::$FILTER_TEXT; 431 $request = [$searchtags, $searchterm];
459 $request = array($searchtags, $searchterm);
460 }
461 // Search by tags.
462 elseif (! empty($searchtags)) {
463 $type = LinkFilter::$FILTER_TAG;
464 $request = $searchtags;
465 }
466 // Fulltext search.
467 elseif (! empty($searchterm)) {
468 $type = LinkFilter::$FILTER_TEXT;
469 $request = $searchterm;
470 }
471 // Otherwise, display without filtering.
472 else {
473 $type = '';
474 $request = '';
475 }
476 432
477 $linkFilter = new LinkFilter($this); 433 $linkFilter = new LinkFilter($this);
478 return $linkFilter->filter($type, $request, $casesensitive, $privateonly); 434 return $linkFilter->filter($type, $request, $casesensitive, $visibility, $untaggedonly);
479 } 435 }
480 436
481 /** 437 /**
482 * Returns the list of all tags 438 * Returns the list tags appearing in the links with the given tags
483 * Output: associative array key=tags, value=0 439 * @param $filteringTags: tags selecting the links to consider
440 * @param $visibility: process only all/private/public links
441 * @return: a tag=>linksCount array
484 */ 442 */
485 public function allTags() 443 public function linksCountPerTag($filteringTags = [], $visibility = 'all')
486 { 444 {
445 $links = empty($filteringTags) ? $this->links : $this->filterSearch(['searchtags' => $filteringTags], false, $visibility);
487 $tags = array(); 446 $tags = array();
488 $caseMapping = array(); 447 $caseMapping = array();
489 foreach ($this->links as $link) { 448 foreach ($links as $link) {
490 foreach (preg_split('/\s+/', $link['tags'], 0, PREG_SPLIT_NO_EMPTY) as $tag) { 449 foreach (preg_split('/\s+/', $link['tags'], 0, PREG_SPLIT_NO_EMPTY) as $tag) {
491 if (empty($tag)) { 450 if (empty($tag)) {
492 continue; 451 continue;
@@ -505,6 +464,39 @@ You use the community supported version of the original Shaarli project, by Seba
505 } 464 }
506 465
507 /** 466 /**
467 * Rename or delete a tag across all links.
468 *
469 * @param string $from Tag to rename
470 * @param string $to New tag. If none is provided, the from tag will be deleted
471 *
472 * @return array|bool List of altered links or false on error
473 */
474 public function renameTag($from, $to)
475 {
476 if (empty($from)) {
477 return false;
478 }
479 $delete = empty($to);
480 // True for case-sensitive tag search.
481 $linksToAlter = $this->filterSearch(['searchtags' => $from], true);
482 foreach($linksToAlter as $key => &$value)
483 {
484 $tags = preg_split('/\s+/', trim($value['tags']));
485 if (($pos = array_search($from, $tags)) !== false) {
486 if ($delete) {
487 unset($tags[$pos]); // Remove tag.
488 } else {
489 $tags[$pos] = trim($to);
490 }
491 $value['tags'] = trim(implode(' ', array_unique($tags)));
492 $this[$value['id']] = $value;
493 }
494 }
495
496 return $linksToAlter;
497 }
498
499 /**
508 * Returns the list of days containing articles (oldest first) 500 * Returns the list of days containing articles (oldest first)
509 * Output: An array containing days (in format YYYYMMDD). 501 * Output: An array containing days (in format YYYYMMDD).
510 */ 502 */
@@ -535,8 +527,8 @@ You use the community supported version of the original Shaarli project, by Seba
535 return $a['created'] < $b['created'] ? 1 * $order : -1 * $order; 527 return $a['created'] < $b['created'] ? 1 * $order : -1 * $order;
536 }); 528 });
537 529
538 $this->urls = array(); 530 $this->urls = [];
539 $this->ids = array(); 531 $this->ids = [];
540 foreach ($this->links as $key => $link) { 532 foreach ($this->links as $key => $link) {
541 $this->urls[$link['url']] = $key; 533 $this->urls[$link['url']] = $key;
542 $this->ids[$link['id']] = $key; 534 $this->ids[$link['id']] = $key;
diff --git a/application/LinkFilter.php b/application/LinkFilter.php
index daa6d9cc..12376e27 100644
--- a/application/LinkFilter.php
+++ b/application/LinkFilter.php
@@ -51,55 +51,73 @@ class LinkFilter
51 * @param string $type Type of filter (eg. tags, permalink, etc.). 51 * @param string $type Type of filter (eg. tags, permalink, etc.).
52 * @param mixed $request Filter content. 52 * @param mixed $request Filter content.
53 * @param bool $casesensitive Optional: Perform case sensitive filter if true. 53 * @param bool $casesensitive Optional: Perform case sensitive filter if true.
54 * @param bool $privateonly Optional: Only returns private links if true. 54 * @param string $visibility Optional: return only all/private/public links
55 * @param string $untaggedonly Optional: return only untagged links. Applies only if $type includes FILTER_TAG
55 * 56 *
56 * @return array filtered link list. 57 * @return array filtered link list.
57 */ 58 */
58 public function filter($type, $request, $casesensitive = false, $privateonly = false) 59 public function filter($type, $request, $casesensitive = false, $visibility = 'all', $untaggedonly = false)
59 { 60 {
61 if (! in_array($visibility, ['all', 'public', 'private'])) {
62 $visibility = 'all';
63 }
64
60 switch($type) { 65 switch($type) {
61 case self::$FILTER_HASH: 66 case self::$FILTER_HASH:
62 return $this->filterSmallHash($request); 67 return $this->filterSmallHash($request);
63 case self::$FILTER_TAG | self::$FILTER_TEXT: 68 case self::$FILTER_TAG | self::$FILTER_TEXT: // == "vuotext"
64 if (!empty($request)) { 69 $noRequest = empty($request) || (empty($request[0]) && empty($request[1]));
65 $filtered = $this->links; 70 if ($noRequest) {
66 if (isset($request[0])) { 71 if ($untaggedonly) {
67 $filtered = $this->filterTags($request[0], $casesensitive, $privateonly); 72 return $this->filterUntagged($visibility);
68 } 73 }
69 if (isset($request[1])) { 74 return $this->noFilter($visibility);
70 $lf = new LinkFilter($filtered);
71 $filtered = $lf->filterFulltext($request[1], $privateonly);
72 }
73 return $filtered;
74 } 75 }
75 return $this->noFilter($privateonly); 76 if ($untaggedonly) {
77 $filtered = $this->filterUntagged($visibility);
78 } else {
79 $filtered = $this->links;
80 }
81 if (!empty($request[0])) {
82 $filtered = (new LinkFilter($filtered))->filterTags($request[0], $casesensitive, $visibility);
83 }
84 if (!empty($request[1])) {
85 $filtered = (new LinkFilter($filtered))->filterFulltext($request[1], $visibility);
86 }
87 return $filtered;
76 case self::$FILTER_TEXT: 88 case self::$FILTER_TEXT:
77 return $this->filterFulltext($request, $privateonly); 89 return $this->filterFulltext($request, $visibility);
78 case self::$FILTER_TAG: 90 case self::$FILTER_TAG:
79 return $this->filterTags($request, $casesensitive, $privateonly); 91 if ($untaggedonly) {
92 return $this->filterUntagged($visibility);
93 } else {
94 return $this->filterTags($request, $casesensitive, $visibility);
95 }
80 case self::$FILTER_DAY: 96 case self::$FILTER_DAY:
81 return $this->filterDay($request); 97 return $this->filterDay($request);
82 default: 98 default:
83 return $this->noFilter($privateonly); 99 return $this->noFilter($visibility);
84 } 100 }
85 } 101 }
86 102
87 /** 103 /**
88 * Unknown filter, but handle private only. 104 * Unknown filter, but handle private only.
89 * 105 *
90 * @param bool $privateonly returns private link only if true. 106 * @param string $visibility Optional: return only all/private/public links
91 * 107 *
92 * @return array filtered links. 108 * @return array filtered links.
93 */ 109 */
94 private function noFilter($privateonly = false) 110 private function noFilter($visibility = 'all')
95 { 111 {
96 if (! $privateonly) { 112 if ($visibility === 'all') {
97 return $this->links; 113 return $this->links;
98 } 114 }
99 115
100 $out = array(); 116 $out = array();
101 foreach ($this->links as $key => $value) { 117 foreach ($this->links as $key => $value) {
102 if ($value['private']) { 118 if ($value['private'] && $visibility === 'private') {
119 $out[$key] = $value;
120 } else if (! $value['private'] && $visibility === 'public') {
103 $out[$key] = $value; 121 $out[$key] = $value;
104 } 122 }
105 } 123 }
@@ -151,14 +169,14 @@ class LinkFilter
151 * - see https://github.com/shaarli/Shaarli/issues/75 for examples 169 * - see https://github.com/shaarli/Shaarli/issues/75 for examples
152 * 170 *
153 * @param string $searchterms search query. 171 * @param string $searchterms search query.
154 * @param bool $privateonly return only private links if true. 172 * @param string $visibility Optional: return only all/private/public links.
155 * 173 *
156 * @return array search results. 174 * @return array search results.
157 */ 175 */
158 private function filterFulltext($searchterms, $privateonly = false) 176 private function filterFulltext($searchterms, $visibility = 'all')
159 { 177 {
160 if (empty($searchterms)) { 178 if (empty($searchterms)) {
161 return $this->links; 179 return $this->noFilter($visibility);
162 } 180 }
163 181
164 $filtered = array(); 182 $filtered = array();
@@ -189,8 +207,12 @@ class LinkFilter
189 foreach ($this->links as $id => $link) { 207 foreach ($this->links as $id => $link) {
190 208
191 // ignore non private links when 'privatonly' is on. 209 // ignore non private links when 'privatonly' is on.
192 if (! $link['private'] && $privateonly === true) { 210 if ($visibility !== 'all') {
193 continue; 211 if (! $link['private'] && $visibility === 'private') {
212 continue;
213 } else if ($link['private'] && $visibility === 'public') {
214 continue;
215 }
194 } 216 }
195 217
196 // Concatenate link fields to search across fields. 218 // Concatenate link fields to search across fields.
@@ -228,6 +250,51 @@ class LinkFilter
228 } 250 }
229 251
230 /** 252 /**
253 * generate a regex fragment out of a tag
254 * @param string $tag to to generate regexs from. may start with '-' to negate, contain '*' as wildcard
255 * @return string generated regex fragment
256 */
257 private static function tag2regex($tag)
258 {
259 $len = strlen($tag);
260 if(!$len || $tag === "-" || $tag === "*"){
261 // nothing to search, return empty regex
262 return '';
263 }
264 if($tag[0] === "-") {
265 // query is negated
266 $i = 1; // use offset to start after '-' character
267 $regex = '(?!'; // create negative lookahead
268 } else {
269 $i = 0; // start at first character
270 $regex = '(?='; // use positive lookahead
271 }
272 $regex .= '.*(?:^| )'; // before tag may only be a space or the beginning
273 // iterate over string, separating it into placeholder and content
274 for(; $i < $len; $i++){
275 if($tag[$i] === '*'){
276 // placeholder found
277 $regex .= '[^ ]*?';
278 } else {
279 // regular characters
280 $offset = strpos($tag, '*', $i);
281 if($offset === false){
282 // no placeholder found, set offset to end of string
283 $offset = $len;
284 }
285 // subtract one, as we want to get before the placeholder or end of string
286 $offset -= 1;
287 // we got a tag name that we want to search for. escape any regex characters to prevent conflicts.
288 $regex .= preg_quote(substr($tag, $i, $offset - $i + 1), '/');
289 // move $i on
290 $i = $offset;
291 }
292 }
293 $regex .= '(?:$| ))'; // after the tag may only be a space or the end
294 return $regex;
295 }
296
297 /**
231 * Returns the list of links associated with a given list of tags 298 * Returns the list of links associated with a given list of tags
232 * 299 *
233 * You can specify one or more tags, separated by space or a comma, e.g. 300 * You can specify one or more tags, separated by space or a comma, e.g.
@@ -235,49 +302,94 @@ class LinkFilter
235 * 302 *
236 * @param string $tags list of tags separated by commas or blank spaces. 303 * @param string $tags list of tags separated by commas or blank spaces.
237 * @param bool $casesensitive ignore case if false. 304 * @param bool $casesensitive ignore case if false.
238 * @param bool $privateonly returns private links only. 305 * @param string $visibility Optional: return only all/private/public links.
239 * 306 *
240 * @return array filtered links. 307 * @return array filtered links.
241 */ 308 */
242 public function filterTags($tags, $casesensitive = false, $privateonly = false) 309 public function filterTags($tags, $casesensitive = false, $visibility = 'all')
243 { 310 {
244 // Implode if array for clean up. 311 // get single tags (we may get passed an array, even though the docs say different)
245 $tags = is_array($tags) ? trim(implode(' ', $tags)) : $tags; 312 $inputTags = $tags;
246 if (empty($tags)) { 313 if(!is_array($tags)) {
247 return $this->links; 314 // we got an input string, split tags
315 $inputTags = preg_split('/(?:\s+)|,/', $inputTags, -1, PREG_SPLIT_NO_EMPTY);
248 } 316 }
249 317
250 $searchtags = self::tagsStrToArray($tags, $casesensitive); 318 if(!count($inputTags)){
251 $filtered = array(); 319 // no input tags
252 if (empty($searchtags)) { 320 return $this->noFilter($visibility);
253 return $filtered; 321 }
322
323 // build regex from all tags
324 $re = '/^' . implode(array_map("self::tag2regex", $inputTags)) . '.*$/';
325 if(!$casesensitive) {
326 // make regex case insensitive
327 $re .= 'i';
254 } 328 }
255 329
330 // create resulting array
331 $filtered = array();
332
333 // iterate over each link
256 foreach ($this->links as $key => $link) { 334 foreach ($this->links as $key => $link) {
257 // ignore non private links when 'privatonly' is on. 335 // check level of visibility
258 if (! $link['private'] && $privateonly === true) { 336 // ignore non private links when 'privateonly' is on.
337 if ($visibility !== 'all') {
338 if (! $link['private'] && $visibility === 'private') {
339 continue;
340 } else if ($link['private'] && $visibility === 'public') {
341 continue;
342 }
343 }
344 $search = $link['tags']; // build search string, start with tags of current link
345 if(strlen(trim($link['description'])) && strpos($link['description'], '#') !== false){
346 // description given and at least one possible tag found
347 $descTags = array();
348 // find all tags in the form of #tag in the description
349 preg_match_all(
350 '/(?<![' . self::$HASHTAG_CHARS . '])#([' . self::$HASHTAG_CHARS . ']+?)\b/sm',
351 $link['description'],
352 $descTags
353 );
354 if(count($descTags[1])){
355 // there were some tags in the description, add them to the search string
356 $search .= ' ' . implode(' ', $descTags[1]);
357 }
358 };
359 // match regular expression with search string
360 if(!preg_match($re, $search)){
361 // this entry does _not_ match our regex
259 continue; 362 continue;
260 } 363 }
364 $filtered[$key] = $link;
365 }
366 return $filtered;
367 }
261 368
262 $linktags = self::tagsStrToArray($link['tags'], $casesensitive); 369 /**
263 370 * Return only links without any tag.
264 $found = true; 371 *
265 for ($i = 0 ; $i < count($searchtags) && $found; $i++) { 372 * @param string $visibility return only all/private/public links.
266 // Exclusive search, quit if tag found. 373 *
267 // Or, tag not found in the link, quit. 374 * @return array filtered links.
268 if (($searchtags[$i][0] == '-' 375 */
269 && $this->searchTagAndHashTag(substr($searchtags[$i], 1), $linktags, $link['description'])) 376 public function filterUntagged($visibility)
270 || ($searchtags[$i][0] != '-') 377 {
271 && ! $this->searchTagAndHashTag($searchtags[$i], $linktags, $link['description']) 378 $filtered = [];
272 ) { 379 foreach ($this->links as $key => $link) {
273 $found = false; 380 if ($visibility !== 'all') {
381 if (! $link['private'] && $visibility === 'private') {
382 continue;
383 } else if ($link['private'] && $visibility === 'public') {
384 continue;
274 } 385 }
275 } 386 }
276 387
277 if ($found) { 388 if (empty(trim($link['tags']))) {
278 $filtered[$key] = $link; 389 $filtered[$key] = $link;
279 } 390 }
280 } 391 }
392
281 return $filtered; 393 return $filtered;
282 } 394 }
283 395
@@ -311,28 +423,6 @@ class LinkFilter
311 } 423 }
312 424
313 /** 425 /**
314 * Check if a tag is found in the taglist, or as an hashtag in the link description.
315 *
316 * @param string $tag Tag to search.
317 * @param array $taglist List of tags for the current link.
318 * @param string $description Link description.
319 *
320 * @return bool True if found, false otherwise.
321 */
322 protected function searchTagAndHashTag($tag, $taglist, $description)
323 {
324 if (in_array($tag, $taglist)) {
325 return true;
326 }
327
328 if (preg_match('/(^| )#'. $tag .'([^'. self::$HASHTAG_CHARS .']|$)/mui', $description) > 0) {
329 return true;
330 }
331
332 return false;
333 }
334
335 /**
336 * Convert a list of tags (str) to an array. Also 426 * Convert a list of tags (str) to an array. Also
337 * - handle case sensitivity. 427 * - handle case sensitivity.
338 * - accepts spaces commas as separator. 428 * - accepts spaces commas as separator.
@@ -341,18 +431,24 @@ class LinkFilter
341 * @param bool $casesensitive will convert everything to lowercase if false. 431 * @param bool $casesensitive will convert everything to lowercase if false.
342 * 432 *
343 * @return array filtered tags string. 433 * @return array filtered tags string.
344 */ 434 */
345 public static function tagsStrToArray($tags, $casesensitive) 435 public static function tagsStrToArray($tags, $casesensitive)
346 { 436 {
347 // We use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek) 437 // We use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek)
348 $tagsOut = $casesensitive ? $tags : mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8'); 438 $tagsOut = $casesensitive ? $tags : mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8');
349 $tagsOut = str_replace(',', ' ', $tagsOut); 439 $tagsOut = str_replace(',', ' ', $tagsOut);
350 440
351 return array_values(array_filter(explode(' ', trim($tagsOut)), 'strlen')); 441 return preg_split('/\s+/', $tagsOut, -1, PREG_SPLIT_NO_EMPTY);
352 } 442 }
353} 443}
354 444
355class LinkNotFoundException extends Exception 445class LinkNotFoundException extends Exception
356{ 446{
357 protected $message = 'The link you are trying to reach does not exist or has been deleted.'; 447 /**
448 * LinkNotFoundException constructor.
449 */
450 public function __construct()
451 {
452 $this->message = t('The link you are trying to reach does not exist or has been deleted.');
453 }
358} 454}
diff --git a/application/LinkUtils.php b/application/LinkUtils.php
index cf58f808..3705f7e9 100644
--- a/application/LinkUtils.php
+++ b/application/LinkUtils.php
@@ -1,60 +1,81 @@
1<?php 1<?php
2 2
3/** 3/**
4 * Extract title from an HTML document. 4 * Get cURL callback function for CURLOPT_WRITEFUNCTION
5 * 5 *
6 * @param string $html HTML content where to look for a title. 6 * @param string $charset to extract from the downloaded page (reference)
7 * @param string $title to extract from the downloaded page (reference)
8 * @param string $curlGetInfo Optionnaly overrides curl_getinfo function
7 * 9 *
8 * @return bool|string Extracted title if found, false otherwise. 10 * @return Closure
9 */ 11 */
10function html_extract_title($html) 12function get_curl_download_callback(&$charset, &$title, $curlGetInfo = 'curl_getinfo')
11{ 13{
12 if (preg_match('!<title.*?>(.*?)</title>!is', $html, $matches)) { 14 /**
13 return trim(str_replace("\n", '', $matches[1])); 15 * cURL callback function for CURLOPT_WRITEFUNCTION (called during the download).
14 } 16 *
15 return false; 17 * While downloading the remote page, we check that the HTTP code is 200 and content type is 'html/text'
18 * Then we extract the title and the charset and stop the download when it's done.
19 *
20 * @param resource $ch cURL resource
21 * @param string $data chunk of data being downloaded
22 *
23 * @return int|bool length of $data or false if we need to stop the download
24 */
25 return function(&$ch, $data) use ($curlGetInfo, &$charset, &$title) {
26 $responseCode = $curlGetInfo($ch, CURLINFO_RESPONSE_CODE);
27 if (!empty($responseCode) && $responseCode != 200) {
28 return false;
29 }
30 $contentType = $curlGetInfo($ch, CURLINFO_CONTENT_TYPE);
31 if (!empty($contentType) && strpos($contentType, 'text/html') === false) {
32 return false;
33 }
34 if (empty($charset)) {
35 $charset = header_extract_charset($contentType);
36 }
37 if (empty($charset)) {
38 $charset = html_extract_charset($data);
39 }
40 if (empty($title)) {
41 $title = html_extract_title($data);
42 }
43 // We got everything we want, stop the download.
44 if (!empty($responseCode) && !empty($contentType) && !empty($charset) && !empty($title)) {
45 return false;
46 }
47
48 return strlen($data);
49 };
16} 50}
17 51
18/** 52/**
19 * Determine charset from downloaded page. 53 * Extract title from an HTML document.
20 * Priority:
21 * 1. HTTP headers (Content type).
22 * 2. HTML content page (tag <meta charset>).
23 * 3. Use a default charset (default: UTF-8).
24 * 54 *
25 * @param array $headers HTTP headers array. 55 * @param string $html HTML content where to look for a title.
26 * @param string $htmlContent HTML content where to look for charset.
27 * @param string $defaultCharset Default charset to apply if other methods failed.
28 * 56 *
29 * @return string Determined charset. 57 * @return bool|string Extracted title if found, false otherwise.
30 */ 58 */
31function get_charset($headers, $htmlContent, $defaultCharset = 'utf-8') 59function html_extract_title($html)
32{ 60{
33 if ($charset = headers_extract_charset($headers)) { 61 if (preg_match('!<title.*?>(.*?)</title>!is', $html, $matches)) {
34 return $charset; 62 return trim(str_replace("\n", '', $matches[1]));
35 }
36
37 if ($charset = html_extract_charset($htmlContent)) {
38 return $charset;
39 } 63 }
40 64 return false;
41 return $defaultCharset;
42} 65}
43 66
44/** 67/**
45 * Extract charset from HTTP headers if it's defined. 68 * Extract charset from HTTP header if it's defined.
46 * 69 *
47 * @param array $headers HTTP headers array. 70 * @param string $header HTTP header Content-Type line.
48 * 71 *
49 * @return bool|string Charset string if found (lowercase), false otherwise. 72 * @return bool|string Charset string if found (lowercase), false otherwise.
50 */ 73 */
51function headers_extract_charset($headers) 74function header_extract_charset($header)
52{ 75{
53 if (! empty($headers['Content-Type']) && strpos($headers['Content-Type'], 'charset=') !== false) { 76 preg_match('/charset="?([^; ]+)/i', $header, $match);
54 preg_match('/charset="?([^; ]+)/i', $headers['Content-Type'], $match); 77 if (! empty($match[1])) {
55 if (! empty($match[1])) { 78 return strtolower(trim($match[1]));
56 return strtolower(trim($match[1]));
57 }
58 } 79 }
59 80
60 return false; 81 return false;
@@ -89,7 +110,9 @@ function count_private($links)
89{ 110{
90 $cpt = 0; 111 $cpt = 0;
91 foreach ($links as $link) { 112 foreach ($links as $link) {
92 $cpt = $link['private'] == true ? $cpt + 1 : $cpt; 113 if ($link['private']) {
114 $cpt += 1;
115 }
93 } 116 }
94 117
95 return $cpt; 118 return $cpt;
@@ -100,14 +123,15 @@ function count_private($links)
100 * 123 *
101 * @param string $text input string. 124 * @param string $text input string.
102 * @param string $redirector if a redirector is set, use it to gerenate links. 125 * @param string $redirector if a redirector is set, use it to gerenate links.
126 * @param bool $urlEncode Use `urlencode()` on the URL after the redirector or not.
103 * 127 *
104 * @return string returns $text with all links converted to HTML links. 128 * @return string returns $text with all links converted to HTML links.
105 * 129 *
106 * @see Function inspired from http://www.php.net/manual/en/function.preg-replace.php#85722 130 * @see Function inspired from http://www.php.net/manual/en/function.preg-replace.php#85722
107 */ 131 */
108function text2clickable($text, $redirector = '') 132function text2clickable($text, $redirector = '', $urlEncode = true)
109{ 133{
110 $regex = '!(((?:https?|ftp|file)://|apt:|magnet:)\S+[[:alnum:]]/?)!si'; 134 $regex = '!(((?:https?|ftp|file)://|apt:|magnet:)\S+[a-z0-9\(\)]/?)!si';
111 135
112 if (empty($redirector)) { 136 if (empty($redirector)) {
113 return preg_replace($regex, '<a href="$1">$1</a>', $text); 137 return preg_replace($regex, '<a href="$1">$1</a>', $text);
@@ -115,8 +139,9 @@ function text2clickable($text, $redirector = '')
115 // Redirector is set, urlencode the final URL. 139 // Redirector is set, urlencode the final URL.
116 return preg_replace_callback( 140 return preg_replace_callback(
117 $regex, 141 $regex,
118 function ($matches) use ($redirector) { 142 function ($matches) use ($redirector, $urlEncode) {
119 return '<a href="' . $redirector . urlencode($matches[1]) .'">'. $matches[1] .'</a>'; 143 $url = $urlEncode ? urlencode($matches[1]) : $matches[1];
144 return '<a href="' . $redirector . $url .'">'. $matches[1] .'</a>';
120 }, 145 },
121 $text 146 $text
122 ); 147 );
@@ -162,12 +187,13 @@ function space2nbsp($text)
162 * 187 *
163 * @param string $description shaare's description. 188 * @param string $description shaare's description.
164 * @param string $redirector if a redirector is set, use it to gerenate links. 189 * @param string $redirector if a redirector is set, use it to gerenate links.
190 * @param bool $urlEncode Use `urlencode()` on the URL after the redirector or not.
165 * @param string $indexUrl URL to Shaarli's index. 191 * @param string $indexUrl URL to Shaarli's index.
166 * 192
167 * @return string formatted description. 193 * @return string formatted description.
168 */ 194 */
169function format_description($description, $redirector = '', $indexUrl = '') { 195function format_description($description, $redirector = '', $urlEncode = true, $indexUrl = '') {
170 return nl2br(space2nbsp(hashtag_autolink(text2clickable($description, $redirector), $indexUrl))); 196 return nl2br(space2nbsp(hashtag_autolink(text2clickable($description, $redirector, $urlEncode), $indexUrl)));
171} 197}
172 198
173/** 199/**
diff --git a/application/NetscapeBookmarkUtils.php b/application/NetscapeBookmarkUtils.php
index e7148d00..dd7057f8 100644
--- a/application/NetscapeBookmarkUtils.php
+++ b/application/NetscapeBookmarkUtils.php
@@ -1,7 +1,13 @@
1<?php 1<?php
2 2
3use Psr\Log\LogLevel;
4use Shaarli\Config\ConfigManager;
5use Shaarli\NetscapeBookmarkParser\NetscapeBookmarkParser;
6use Katzgrau\KLogger\Logger;
7
3/** 8/**
4 * Utilities to import and export bookmarks using the Netscape format 9 * Utilities to import and export bookmarks using the Netscape format
10 * TODO: Not static, use a container.
5 */ 11 */
6class NetscapeBookmarkUtils 12class NetscapeBookmarkUtils
7{ 13{
@@ -26,11 +32,10 @@ class NetscapeBookmarkUtils
26 { 32 {
27 // see tpl/export.html for possible values 33 // see tpl/export.html for possible values
28 if (! in_array($selection, array('all', 'public', 'private'))) { 34 if (! in_array($selection, array('all', 'public', 'private'))) {
29 throw new Exception('Invalid export selection: "'.$selection.'"'); 35 throw new Exception(t('Invalid export selection:') .' "'.$selection.'"');
30 } 36 }
31 37
32 $bookmarkLinks = array(); 38 $bookmarkLinks = array();
33
34 foreach ($linkDb as $link) { 39 foreach ($linkDb as $link) {
35 if ($link['private'] != 0 && $selection == 'public') { 40 if ($link['private'] != 0 && $selection == 'public') {
36 continue; 41 continue;
@@ -60,6 +65,7 @@ class NetscapeBookmarkUtils
60 * @param int $importCount how many links were imported 65 * @param int $importCount how many links were imported
61 * @param int $overwriteCount how many links were overwritten 66 * @param int $overwriteCount how many links were overwritten
62 * @param int $skipCount how many links were skipped 67 * @param int $skipCount how many links were skipped
68 * @param int $duration how many seconds did the import take
63 * 69 *
64 * @return string Summary of the bookmark import status 70 * @return string Summary of the bookmark import status
65 */ 71 */
@@ -68,16 +74,18 @@ class NetscapeBookmarkUtils
68 $filesize, 74 $filesize,
69 $importCount=0, 75 $importCount=0,
70 $overwriteCount=0, 76 $overwriteCount=0,
71 $skipCount=0 77 $skipCount=0,
78 $duration=0
72 ) 79 )
73 { 80 {
74 $status = 'File '.$filename.' ('.$filesize.' bytes) '; 81 $status = sprintf(t('File %s (%d bytes) '), $filename, $filesize);
75 if ($importCount == 0 && $overwriteCount == 0 && $skipCount == 0) { 82 if ($importCount == 0 && $overwriteCount == 0 && $skipCount == 0) {
76 $status .= 'has an unknown file format. Nothing was imported.'; 83 $status .= t('has an unknown file format. Nothing was imported.');
77 } else { 84 } else {
78 $status .= 'was successfully processed: '.$importCount.' links imported, '; 85 $status .= vsprintf(
79 $status .= $overwriteCount.' links overwritten, '; 86 t('was successfully processed in %d seconds: %d links imported, %d links overwritten, %d links skipped.'),
80 $status .= $skipCount.' links skipped.'; 87 [$duration, $importCount, $overwriteCount, $skipCount]
88 );
81 } 89 }
82 return $status; 90 return $status;
83 } 91 }
@@ -85,15 +93,17 @@ class NetscapeBookmarkUtils
85 /** 93 /**
86 * Imports Web bookmarks from an uploaded Netscape bookmark dump 94 * Imports Web bookmarks from an uploaded Netscape bookmark dump
87 * 95 *
88 * @param array $post Server $_POST parameters 96 * @param array $post Server $_POST parameters
89 * @param array $files Server $_FILES parameters 97 * @param array $files Server $_FILES parameters
90 * @param LinkDB $linkDb Loaded LinkDB instance 98 * @param LinkDB $linkDb Loaded LinkDB instance
91 * @param string $pagecache Page cache 99 * @param ConfigManager $conf instance
100 * @param History $history History instance
92 * 101 *
93 * @return string Summary of the bookmark import status 102 * @return string Summary of the bookmark import status
94 */ 103 */
95 public static function import($post, $files, $linkDb, $pagecache) 104 public static function import($post, $files, $linkDb, $conf, $history)
96 { 105 {
106 $start = time();
97 $filename = $files['filetoupload']['name']; 107 $filename = $files['filetoupload']['name'];
98 $filesize = $files['filetoupload']['size']; 108 $filesize = $files['filetoupload']['size'];
99 $data = file_get_contents($files['filetoupload']['tmp_name']); 109 $data = file_get_contents($files['filetoupload']['tmp_name']);
@@ -119,10 +129,20 @@ class NetscapeBookmarkUtils
119 $defaultPrivacy = 0; 129 $defaultPrivacy = 0;
120 130
121 $parser = new NetscapeBookmarkParser( 131 $parser = new NetscapeBookmarkParser(
122 true, // nested tag support 132 true, // nested tag support
123 $defaultTags, // additional user-specified tags 133 $defaultTags, // additional user-specified tags
124 strval(1 - $defaultPrivacy) // defaultPub = 1 - defaultPrivacy 134 strval(1 - $defaultPrivacy), // defaultPub = 1 - defaultPrivacy
135 $conf->get('resource.data_dir') // log path, will be overridden
125 ); 136 );
137 $logger = new Logger(
138 $conf->get('resource.data_dir'),
139 ! $conf->get('dev.debug') ? LogLevel::INFO : LogLevel::DEBUG,
140 [
141 'prefix' => 'import.',
142 'extension' => 'log',
143 ]
144 );
145 $parser->setLogger($logger);
126 $bookmarks = $parser->parseString($data); 146 $bookmarks = $parser->parseString($data);
127 147
128 $importCount = 0; 148 $importCount = 0;
@@ -163,6 +183,7 @@ class NetscapeBookmarkUtils
163 $newLink['id'] = $existingLink['id']; 183 $newLink['id'] = $existingLink['id'];
164 $newLink['created'] = $existingLink['created']; 184 $newLink['created'] = $existingLink['created'];
165 $newLink['updated'] = new DateTime(); 185 $newLink['updated'] = new DateTime();
186 $newLink['shorturl'] = $existingLink['shorturl'];
166 $linkDb[$existingLink['id']] = $newLink; 187 $linkDb[$existingLink['id']] = $newLink;
167 $importCount++; 188 $importCount++;
168 $overwriteCount++; 189 $overwriteCount++;
@@ -179,13 +200,17 @@ class NetscapeBookmarkUtils
179 $importCount++; 200 $importCount++;
180 } 201 }
181 202
182 $linkDb->save($pagecache); 203 $linkDb->save($conf->get('resource.page_cache'));
204 $history->importLinks();
205
206 $duration = time() - $start;
183 return self::importStatus( 207 return self::importStatus(
184 $filename, 208 $filename,
185 $filesize, 209 $filesize,
186 $importCount, 210 $importCount,
187 $overwriteCount, 211 $overwriteCount,
188 $skipCount 212 $skipCount,
213 $duration
189 ); 214 );
190 } 215 }
191} 216}
diff --git a/application/PageBuilder.php b/application/PageBuilder.php
index 32c7f9f1..468f144b 100644
--- a/application/PageBuilder.php
+++ b/application/PageBuilder.php
@@ -1,5 +1,7 @@
1<?php 1<?php
2 2
3use Shaarli\Config\ConfigManager;
4
3/** 5/**
4 * This class is in charge of building the final page. 6 * This class is in charge of building the final page.
5 * (This is basically a wrapper around RainTPL which pre-fills some fields.) 7 * (This is basically a wrapper around RainTPL which pre-fills some fields.)
@@ -20,15 +22,24 @@ class PageBuilder
20 protected $conf; 22 protected $conf;
21 23
22 /** 24 /**
25 * @var LinkDB $linkDB instance.
26 */
27 protected $linkDB;
28
29 /**
23 * PageBuilder constructor. 30 * PageBuilder constructor.
24 * $tpl is initialized at false for lazy loading. 31 * $tpl is initialized at false for lazy loading.
25 * 32 *
26 * @param ConfigManager $conf Configuration Manager instance (reference). 33 * @param ConfigManager $conf Configuration Manager instance (reference).
34 * @param LinkDB $linkDB instance.
35 * @param string $token Session token
27 */ 36 */
28 function __construct(&$conf) 37 public function __construct(&$conf, $linkDB = null, $token = null)
29 { 38 {
30 $this->tpl = false; 39 $this->tpl = false;
31 $this->conf = $conf; 40 $this->conf = $conf;
41 $this->linkDB = $linkDB;
42 $this->token = $token;
32 } 43 }
33 44
34 /** 45 /**
@@ -40,7 +51,7 @@ class PageBuilder
40 51
41 try { 52 try {
42 $version = ApplicationUtils::checkUpdate( 53 $version = ApplicationUtils::checkUpdate(
43 shaarli_version, 54 SHAARLI_VERSION,
44 $this->conf->get('resource.update_check'), 55 $this->conf->get('resource.update_check'),
45 $this->conf->get('updates.check_updates_interval'), 56 $this->conf->get('updates.check_updates_interval'),
46 $this->conf->get('updates.check_updates'), 57 $this->conf->get('updates.check_updates'),
@@ -66,18 +77,28 @@ class PageBuilder
66 } 77 }
67 $this->tpl->assign('searchcrits', $searchcrits); 78 $this->tpl->assign('searchcrits', $searchcrits);
68 $this->tpl->assign('source', index_url($_SERVER)); 79 $this->tpl->assign('source', index_url($_SERVER));
69 $this->tpl->assign('version', shaarli_version); 80 $this->tpl->assign('version', SHAARLI_VERSION);
81 $this->tpl->assign(
82 'version_hash',
83 ApplicationUtils::getVersionHash(SHAARLI_VERSION, $this->conf->get('credentials.salt'))
84 );
70 $this->tpl->assign('scripturl', index_url($_SERVER)); 85 $this->tpl->assign('scripturl', index_url($_SERVER));
71 $this->tpl->assign('privateonly', !empty($_SESSION['privateonly'])); // Show only private links? 86 $this->tpl->assign('privateonly', !empty($_SESSION['privateonly'])); // Show only private links?
87 $this->tpl->assign('untaggedonly', !empty($_SESSION['untaggedonly']));
72 $this->tpl->assign('pagetitle', $this->conf->get('general.title', 'Shaarli')); 88 $this->tpl->assign('pagetitle', $this->conf->get('general.title', 'Shaarli'));
73 if ($this->conf->exists('general.header_link')) { 89 if ($this->conf->exists('general.header_link')) {
74 $this->tpl->assign('titleLink', $this->conf->get('general.header_link')); 90 $this->tpl->assign('titleLink', $this->conf->get('general.header_link'));
75 } 91 }
76 $this->tpl->assign('shaarlititle', $this->conf->get('general.title', 'Shaarli')); 92 $this->tpl->assign('shaarlititle', $this->conf->get('general.title', 'Shaarli'));
77 $this->tpl->assign('openshaarli', $this->conf->get('security.open_shaarli', false)); 93 $this->tpl->assign('openshaarli', $this->conf->get('security.open_shaarli', false));
78 $this->tpl->assign('showatom', $this->conf->get('feed.show_atom', false)); 94 $this->tpl->assign('showatom', $this->conf->get('feed.show_atom', true));
95 $this->tpl->assign('feed_type', $this->conf->get('feed.show_atom', true) !== false ? 'atom' : 'rss');
79 $this->tpl->assign('hide_timestamps', $this->conf->get('privacy.hide_timestamps', false)); 96 $this->tpl->assign('hide_timestamps', $this->conf->get('privacy.hide_timestamps', false));
80 $this->tpl->assign('token', getToken($this->conf)); 97 $this->tpl->assign('token', $this->token);
98
99 if ($this->linkDB !== null) {
100 $this->tpl->assign('tags', $this->linkDB->linksCountPerTag());
101 }
81 // To be removed with a proper theme configuration. 102 // To be removed with a proper theme configuration.
82 $this->tpl->assign('conf', $this->conf); 103 $this->tpl->assign('conf', $this->conf);
83 } 104 }
@@ -140,9 +161,12 @@ class PageBuilder
140 * 161 *
141 * @param string $message A messate to display what is not found 162 * @param string $message A messate to display what is not found
142 */ 163 */
143 public function render404($message = 'The page you are trying to reach does not exist or has been deleted.') 164 public function render404($message = '')
144 { 165 {
145 header($_SERVER['SERVER_PROTOCOL'] . ' 404 Not Found'); 166 if (empty($message)) {
167 $message = t('The page you are trying to reach does not exist or has been deleted.');
168 }
169 header($_SERVER['SERVER_PROTOCOL'] .' '. t('404 Not Found'));
146 $this->tpl->assign('error_message', $message); 170 $this->tpl->assign('error_message', $message);
147 $this->renderPage('404'); 171 $this->renderPage('404');
148 } 172 }
diff --git a/application/PluginManager.php b/application/PluginManager.php
index 59ece4fa..cf603845 100644
--- a/application/PluginManager.php
+++ b/application/PluginManager.php
@@ -188,6 +188,9 @@ class PluginManager
188 $metaData[$plugin] = parse_ini_file($metaFile); 188 $metaData[$plugin] = parse_ini_file($metaFile);
189 $metaData[$plugin]['order'] = array_search($plugin, $this->authorizedPlugins); 189 $metaData[$plugin]['order'] = array_search($plugin, $this->authorizedPlugins);
190 190
191 if (isset($metaData[$plugin]['description'])) {
192 $metaData[$plugin]['description'] = t($metaData[$plugin]['description']);
193 }
191 // Read parameters and format them into an array. 194 // Read parameters and format them into an array.
192 if (isset($metaData[$plugin]['parameters'])) { 195 if (isset($metaData[$plugin]['parameters'])) {
193 $params = explode(';', $metaData[$plugin]['parameters']); 196 $params = explode(';', $metaData[$plugin]['parameters']);
@@ -203,7 +206,7 @@ class PluginManager
203 $metaData[$plugin]['parameters'][$param]['value'] = ''; 206 $metaData[$plugin]['parameters'][$param]['value'] = '';
204 // Optional parameter description in parameter.PARAM_NAME= 207 // Optional parameter description in parameter.PARAM_NAME=
205 if (isset($metaData[$plugin]['parameter.'. $param])) { 208 if (isset($metaData[$plugin]['parameter.'. $param])) {
206 $metaData[$plugin]['parameters'][$param]['desc'] = $metaData[$plugin]['parameter.'. $param]; 209 $metaData[$plugin]['parameters'][$param]['desc'] = t($metaData[$plugin]['parameter.'. $param]);
207 } 210 }
208 } 211 }
209 } 212 }
@@ -237,6 +240,6 @@ class PluginFileNotFoundException extends Exception
237 */ 240 */
238 public function __construct($pluginName) 241 public function __construct($pluginName)
239 { 242 {
240 $this->message = 'Plugin "'. $pluginName .'" files not found.'; 243 $this->message = sprintf(t('Plugin "%s" files not found.'), $pluginName);
241 } 244 }
242} 245}
diff --git a/application/Router.php b/application/Router.php
index caed4a28..4df0387c 100644
--- a/application/Router.php
+++ b/application/Router.php
@@ -13,6 +13,8 @@ class Router
13 13
14 public static $PAGE_TAGCLOUD = 'tagcloud'; 14 public static $PAGE_TAGCLOUD = 'tagcloud';
15 15
16 public static $PAGE_TAGLIST = 'taglist';
17
16 public static $PAGE_DAILY = 'daily'; 18 public static $PAGE_DAILY = 'daily';
17 19
18 public static $PAGE_FEED_ATOM = 'atom'; 20 public static $PAGE_FEED_ATOM = 'atom';
@@ -31,6 +33,8 @@ class Router
31 33
32 public static $PAGE_EDITLINK = 'edit_link'; 34 public static $PAGE_EDITLINK = 'edit_link';
33 35
36 public static $PAGE_DELETELINK = 'delete_link';
37
34 public static $PAGE_EXPORT = 'export'; 38 public static $PAGE_EXPORT = 'export';
35 39
36 public static $PAGE_IMPORT = 'import'; 40 public static $PAGE_IMPORT = 'import';
@@ -43,6 +47,8 @@ class Router
43 47
44 public static $PAGE_SAVE_PLUGINSADMIN = 'save_pluginadmin'; 48 public static $PAGE_SAVE_PLUGINSADMIN = 'save_pluginadmin';
45 49
50 public static $GET_TOKEN = 'token';
51
46 /** 52 /**
47 * Reproducing renderPage() if hell, to avoid regression. 53 * Reproducing renderPage() if hell, to avoid regression.
48 * 54 *
@@ -75,6 +81,10 @@ class Router
75 return self::$PAGE_TAGCLOUD; 81 return self::$PAGE_TAGCLOUD;
76 } 82 }
77 83
84 if (startsWith($query, 'do='. self::$PAGE_TAGLIST)) {
85 return self::$PAGE_TAGLIST;
86 }
87
78 if (startsWith($query, 'do='. self::$PAGE_OPENSEARCH)) { 88 if (startsWith($query, 'do='. self::$PAGE_OPENSEARCH)) {
79 return self::$PAGE_OPENSEARCH; 89 return self::$PAGE_OPENSEARCH;
80 } 90 }
@@ -120,6 +130,10 @@ class Router
120 return self::$PAGE_EDITLINK; 130 return self::$PAGE_EDITLINK;
121 } 131 }
122 132
133 if (isset($get['delete_link'])) {
134 return self::$PAGE_DELETELINK;
135 }
136
123 if (startsWith($query, 'do='. self::$PAGE_EXPORT)) { 137 if (startsWith($query, 'do='. self::$PAGE_EXPORT)) {
124 return self::$PAGE_EXPORT; 138 return self::$PAGE_EXPORT;
125 } 139 }
@@ -136,6 +150,10 @@ class Router
136 return self::$PAGE_SAVE_PLUGINSADMIN; 150 return self::$PAGE_SAVE_PLUGINSADMIN;
137 } 151 }
138 152
153 if (startsWith($query, 'do='. self::$GET_TOKEN)) {
154 return self::$GET_TOKEN;
155 }
156
139 return self::$PAGE_LINKLIST; 157 return self::$PAGE_LINKLIST;
140 } 158 }
141} 159}
diff --git a/application/SessionManager.php b/application/SessionManager.php
new file mode 100644
index 00000000..71f0b38d
--- /dev/null
+++ b/application/SessionManager.php
@@ -0,0 +1,83 @@
1<?php
2namespace Shaarli;
3
4/**
5 * Manages the server-side session
6 */
7class SessionManager
8{
9 protected $session = [];
10
11 /**
12 * Constructor
13 *
14 * @param array $session The $_SESSION array (reference)
15 * @param ConfigManager $conf ConfigManager instance
16 */
17 public function __construct(& $session, $conf)
18 {
19 $this->session = &$session;
20 $this->conf = $conf;
21 }
22
23 /**
24 * Generates a session token
25 *
26 * @return string token
27 */
28 public function generateToken()
29 {
30 $token = sha1(uniqid('', true) .'_'. mt_rand() . $this->conf->get('credentials.salt'));
31 $this->session['tokens'][$token] = 1;
32 return $token;
33 }
34
35 /**
36 * Checks the validity of a session token, and destroys it afterwards
37 *
38 * @param string $token The token to check
39 *
40 * @return bool true if the token is valid, else false
41 */
42 public function checkToken($token)
43 {
44 if (! isset($this->session['tokens'][$token])) {
45 // the token is wrong, or has already been used
46 return false;
47 }
48
49 // destroy the token to prevent future use
50 unset($this->session['tokens'][$token]);
51 return true;
52 }
53
54 /**
55 * Validate session ID to prevent Full Path Disclosure.
56 *
57 * See #298.
58 * The session ID's format depends on the hash algorithm set in PHP settings
59 *
60 * @param string $sessionId Session ID
61 *
62 * @return true if valid, false otherwise.
63 *
64 * @see http://php.net/manual/en/function.hash-algos.php
65 * @see http://php.net/manual/en/session.configuration.php
66 */
67 public static function checkId($sessionId)
68 {
69 if (empty($sessionId)) {
70 return false;
71 }
72
73 if (!$sessionId) {
74 return false;
75 }
76
77 if (!preg_match('/^[a-zA-Z0-9,-]{2,128}$/', $sessionId)) {
78 return false;
79 }
80
81 return true;
82 }
83}
diff --git a/application/ThemeUtils.php b/application/ThemeUtils.php
new file mode 100644
index 00000000..16f2f6a2
--- /dev/null
+++ b/application/ThemeUtils.php
@@ -0,0 +1,34 @@
1<?php
2
3namespace Shaarli;
4
5/**
6 * Class ThemeUtils
7 *
8 * Utility functions related to theme management.
9 *
10 * @package Shaarli
11 */
12class ThemeUtils
13{
14 /**
15 * Get a list of available themes.
16 *
17 * It will return the name of any directory present in the template folder.
18 *
19 * @param string $tplDir Templates main directory.
20 *
21 * @return array List of theme names.
22 */
23 public static function getThemes($tplDir)
24 {
25 $tplDir = rtrim($tplDir, '/');
26 $allTheme = glob($tplDir.'/*', GLOB_ONLYDIR);
27 $themes = [];
28 foreach ($allTheme as $value) {
29 $themes[] = str_replace($tplDir.'/', '', $value);
30 }
31
32 return $themes;
33 }
34}
diff --git a/application/TimeZone.php b/application/TimeZone.php
index 36a8fb12..c1869ef8 100644
--- a/application/TimeZone.php
+++ b/application/TimeZone.php
@@ -1,23 +1,42 @@
1<?php 1<?php
2/** 2/**
3 * Generates the timezone selection form and JavaScript. 3 * Generates a list of available timezone continents and cities.
4 * 4 *
5 * Note: 'UTC/UTC' is mapped to 'UTC' to form a valid option 5 * Two distinct array based on available timezones
6 * and the one selected in the settings:
7 * - (0) continents:
8 * + list of available continents
9 * + special key 'selected' containing the value of the selected timezone's continent
10 * - (1) cities:
11 * + list of available cities associated with their continent
12 * + special key 'selected' containing the value of the selected timezone's city (without the continent)
6 * 13 *
7 * Example: preselect Europe/Paris 14 * Example:
8 * list($htmlform, $js) = generateTimeZoneForm('Europe/Paris'); 15 * [
16 * [
17 * 'America',
18 * 'Europe',
19 * 'selected' => 'Europe',
20 * ],
21 * [
22 * ['continent' => 'America', 'city' => 'Toronto'],
23 * ['continent' => 'Europe', 'city' => 'Paris'],
24 * 'selected' => 'Paris',
25 * ],
26 * ];
9 * 27 *
28 * Notes:
29 * - 'UTC/UTC' is mapped to 'UTC' to form a valid option
30 * - a few timezone cities includes the country/state, such as Argentina/Buenos_Aires
31 * - these arrays are designed to build timezone selects in template files with any HTML structure
32 *
33 * @param array $installedTimeZones List of installed timezones as string
10 * @param string $preselectedTimezone preselected timezone (optional) 34 * @param string $preselectedTimezone preselected timezone (optional)
11 * 35 *
12 * @return array containing the generated HTML form and Javascript code 36 * @return array[] continents and cities
13 **/ 37 **/
14function generateTimeZoneForm($preselectedTimezone='') 38function generateTimeZoneData($installedTimeZones, $preselectedTimezone = '')
15{ 39{
16 // Select the server timezone
17 if ($preselectedTimezone == '') {
18 $preselectedTimezone = date_default_timezone_get();
19 }
20
21 if ($preselectedTimezone == 'UTC') { 40 if ($preselectedTimezone == 'UTC') {
22 $pcity = $pcontinent = 'UTC'; 41 $pcity = $pcontinent = 'UTC';
23 } else { 42 } else {
@@ -27,62 +46,30 @@ function generateTimeZoneForm($preselectedTimezone='')
27 $pcity = substr($preselectedTimezone, $spos+1); 46 $pcity = substr($preselectedTimezone, $spos+1);
28 } 47 }
29 48
30 // The list is in the form 'Europe/Paris', 'America/Argentina/Buenos_Aires' 49 $continents = [];
31 // We split the list in continents/cities. 50 $cities = [];
32 $continents = array(); 51 foreach ($installedTimeZones as $tz) {
33 $cities = array();
34
35 // TODO: use a template to generate the HTML/Javascript form
36
37 foreach (timezone_identifiers_list() as $tz) {
38 if ($tz == 'UTC') { 52 if ($tz == 'UTC') {
39 $tz = 'UTC/UTC'; 53 $tz = 'UTC/UTC';
40 } 54 }
41 $spos = strpos($tz, '/'); 55 $spos = strpos($tz, '/');
42 56
43 if ($spos !== false) { 57 // Ignore invalid timezones
44 $continent = substr($tz, 0, $spos); 58 if ($spos === false) {
45 $city = substr($tz, $spos+1); 59 continue;
46 $continents[$continent] = 1;
47
48 if (!isset($cities[$continent])) {
49 $cities[$continent] = '';
50 }
51 $cities[$continent] .= '<option value="'.$city.'"';
52 if ($pcity == $city) {
53 $cities[$continent] .= ' selected="selected"';
54 }
55 $cities[$continent] .= '>'.$city.'</option>';
56 } 60 }
57 }
58
59 $continentsHtml = '';
60 $continents = array_keys($continents);
61 61
62 foreach ($continents as $continent) { 62 $continent = substr($tz, 0, $spos);
63 $continentsHtml .= '<option value="'.$continent.'"'; 63 $city = substr($tz, $spos+1);
64 if ($pcontinent == $continent) { 64 $cities[] = ['continent' => $continent, 'city' => $city];
65 $continentsHtml .= ' selected="selected"'; 65 $continents[$continent] = true;
66 }
67 $continentsHtml .= '>'.$continent.'</option>';
68 } 66 }
69 67
70 // Timezone selection form 68 $continents = array_keys($continents);
71 $timezoneForm = 'Continent:'; 69 $continents['selected'] = $pcontinent;
72 $timezoneForm .= '<select name="continent" id="continent" onChange="onChangecontinent();">'; 70 $cities['selected'] = $pcity;
73 $timezoneForm .= $continentsHtml.'</select>';
74 $timezoneForm .= '&nbsp;&nbsp;&nbsp;&nbsp;City:';
75 $timezoneForm .= '<select name="city" id="city">'.$cities[$pcontinent].'</select><br />';
76
77 // Javascript handler - updates the city list when the user selects a continent
78 $timezoneJs = '<script>';
79 $timezoneJs .= 'function onChangecontinent() {';
80 $timezoneJs .= 'document.getElementById("city").innerHTML =';
81 $timezoneJs .= ' citiescontinent[document.getElementById("continent").value]; }';
82 $timezoneJs .= 'var citiescontinent = '.json_encode($cities).';';
83 $timezoneJs .= '</script>';
84 71
85 return array($timezoneForm, $timezoneJs); 72 return [$continents, $cities];
86} 73}
87 74
88/** 75/**
diff --git a/application/Updater.php b/application/Updater.php
index 555d4c25..034b8ed8 100644
--- a/application/Updater.php
+++ b/application/Updater.php
@@ -1,4 +1,7 @@
1<?php 1<?php
2use Shaarli\Config\ConfigJson;
3use Shaarli\Config\ConfigPhp;
4use Shaarli\Config\ConfigManager;
2 5
3/** 6/**
4 * Class Updater. 7 * Class Updater.
@@ -69,8 +72,8 @@ class Updater
69 return $updatesRan; 72 return $updatesRan;
70 } 73 }
71 74
72 if ($this->methods == null) { 75 if ($this->methods === null) {
73 throw new UpdaterException('Couldn\'t retrieve Updater class methods.'); 76 throw new UpdaterException(t('Couldn\'t retrieve Updater class methods.'));
74 } 77 }
75 78
76 foreach ($this->methods as $method) { 79 foreach ($this->methods as $method) {
@@ -133,21 +136,6 @@ class Updater
133 } 136 }
134 137
135 /** 138 /**
136 * Rename tags starting with a '-' to work with tag exclusion search.
137 */
138 public function updateMethodRenameDashTags()
139 {
140 $linklist = $this->linkDB->filterSearch();
141 foreach ($linklist as $key => $link) {
142 $link['tags'] = preg_replace('/(^| )\-/', '$1', $link['tags']);
143 $link['tags'] = implode(' ', array_unique(LinkFilter::tagsStrToArray($link['tags'], true)));
144 $this->linkDB[$key] = $link;
145 }
146 $this->linkDB->save($this->conf->get('resource.page_cache'));
147 return true;
148 }
149
150 /**
151 * Move old configuration in PHP to the new config system in JSON format. 139 * Move old configuration in PHP to the new config system in JSON format.
152 * 140 *
153 * Will rename 'config.php' into 'config.save.php' and create 'config.json.php'. 141 * Will rename 'config.php' into 'config.save.php' and create 'config.json.php'.
@@ -258,6 +246,91 @@ class Updater
258 } 246 }
259 247
260 /** 248 /**
249<<<<<<< HEAD
250=======
251 * Rename tags starting with a '-' to work with tag exclusion search.
252 */
253 public function updateMethodRenameDashTags()
254 {
255 $linklist = $this->linkDB->filterSearch();
256 foreach ($linklist as $key => $link) {
257 $link['tags'] = preg_replace('/(^| )\-/', '$1', $link['tags']);
258 $link['tags'] = implode(' ', array_unique(LinkFilter::tagsStrToArray($link['tags'], true)));
259 $this->linkDB[$key] = $link;
260 }
261 $this->linkDB->save($this->conf->get('resource.page_cache'));
262 return true;
263 }
264
265 /**
266 * Initialize API settings:
267 * - api.enabled: true
268 * - api.secret: generated secret
269 */
270 public function updateMethodApiSettings()
271 {
272 if ($this->conf->exists('api.secret')) {
273 return true;
274 }
275
276 $this->conf->set('api.enabled', true);
277 $this->conf->set(
278 'api.secret',
279 generate_api_secret(
280 $this->conf->get('credentials.login'),
281 $this->conf->get('credentials.salt')
282 )
283 );
284 $this->conf->write($this->isLoggedIn);
285 return true;
286 }
287
288 /**
289 * New setting: theme name. If the default theme is used, nothing to do.
290 *
291 * If the user uses a custom theme, raintpl_tpl dir is updated to the parent directory,
292 * and the current theme is set as default in the theme setting.
293 *
294 * @return bool true if the update is successful, false otherwise.
295 */
296 public function updateMethodDefaultTheme()
297 {
298 // raintpl_tpl isn't the root template directory anymore.
299 // We run the update only if this folder still contains the template files.
300 $tplDir = $this->conf->get('resource.raintpl_tpl');
301 $tplFile = $tplDir . '/linklist.html';
302 if (! file_exists($tplFile)) {
303 return true;
304 }
305
306 $parent = dirname($tplDir);
307 $this->conf->set('resource.raintpl_tpl', $parent);
308 $this->conf->set('resource.theme', trim(str_replace($parent, '', $tplDir), '/'));
309 $this->conf->write($this->isLoggedIn);
310
311 // Dependency injection gore
312 RainTPL::$tpl_dir = $tplDir;
313
314 return true;
315 }
316
317 /**
318 * Move the file to inc/user.css to data/user.css.
319 *
320 * Note: Due to hardcoded paths, it's not unit testable. But one line of code should be fine.
321 *
322 * @return bool true if the update is successful, false otherwise.
323 */
324 public function updateMethodMoveUserCss()
325 {
326 if (! is_file('inc/user.css')) {
327 return true;
328 }
329
330 return rename('inc/user.css', 'data/user.css');
331 }
332
333 /**
261 * * `markdown_escape` is a new setting, set to true as default. 334 * * `markdown_escape` is a new setting, set to true as default.
262 * 335 *
263 * If the markdown plugin was already enabled, escaping is disabled to avoid 336 * If the markdown plugin was already enabled, escaping is disabled to avoid
@@ -278,6 +351,102 @@ class Updater
278 351
279 return true; 352 return true;
280 } 353 }
354
355 /**
356 * Add 'http://' to Piwik URL the setting is set.
357 *
358 * @return bool true if the update is successful, false otherwise.
359 */
360 public function updateMethodPiwikUrl()
361 {
362 if (! $this->conf->exists('plugins.PIWIK_URL') || startsWith($this->conf->get('plugins.PIWIK_URL'), 'http')) {
363 return true;
364 }
365
366 $this->conf->set('plugins.PIWIK_URL', 'http://'. $this->conf->get('plugins.PIWIK_URL'));
367 $this->conf->write($this->isLoggedIn);
368
369 return true;
370 }
371
372 /**
373 * Use ATOM feed as default.
374 */
375 public function updateMethodAtomDefault()
376 {
377 if (!$this->conf->exists('feed.show_atom') || $this->conf->get('feed.show_atom') === true) {
378 return true;
379 }
380
381 $this->conf->set('feed.show_atom', true);
382 $this->conf->write($this->isLoggedIn);
383
384 return true;
385 }
386
387 /**
388 * Update updates.check_updates_branch setting.
389 *
390 * If the current major version digit matches the latest branch
391 * major version digit, we set the branch to `latest`,
392 * otherwise we'll check updates on the `stable` branch.
393 *
394 * No update required for the dev version.
395 *
396 * Note: due to hardcoded URL and lack of dependency injection, this is not unit testable.
397 *
398 * FIXME! This needs to be removed when we switch to first digit major version
399 * instead of the second one since the versionning process will change.
400 */
401 public function updateMethodCheckUpdateRemoteBranch()
402 {
403 if (SHAARLI_VERSION === 'dev' || $this->conf->get('updates.check_updates_branch') === 'latest') {
404 return true;
405 }
406
407 // Get latest branch major version digit
408 $latestVersion = ApplicationUtils::getLatestGitVersionCode(
409 'https://raw.githubusercontent.com/shaarli/Shaarli/latest/shaarli_version.php',
410 5
411 );
412 if (preg_match('/(\d+)\.\d+$/', $latestVersion, $matches) === false) {
413 return false;
414 }
415 $latestMajor = $matches[1];
416
417 // Get current major version digit
418 preg_match('/(\d+)\.\d+$/', SHAARLI_VERSION, $matches);
419 $currentMajor = $matches[1];
420
421 if ($currentMajor === $latestMajor) {
422 $branch = 'latest';
423 } else {
424 $branch = 'stable';
425 }
426 $this->conf->set('updates.check_updates_branch', $branch);
427 $this->conf->write($this->isLoggedIn);
428 return true;
429 }
430
431 /**
432 * Reset history store file due to date format change.
433 */
434 public function updateMethodResetHistoryFile()
435 {
436 if (is_file($this->conf->get('resource.history'))) {
437 unlink($this->conf->get('resource.history'));
438 }
439 return true;
440 }
441
442 /**
443 * Save the datastore -> the link order is now applied when links are saved.
444 */
445 public function updateMethodReorderDatastore()
446 {
447 $this->linkDB->save($this->conf->get('resource.page_cache'));
448 return true;
449 }
281} 450}
282 451
283/** 452/**
@@ -324,7 +493,7 @@ class UpdaterException extends Exception
324 } 493 }
325 494
326 if (! empty($this->method)) { 495 if (! empty($this->method)) {
327 $out .= 'An error occurred while running the update '. $this->method . PHP_EOL; 496 $out .= t('An error occurred while running the update ') . $this->method . PHP_EOL;
328 } 497 }
329 498
330 if (! empty($this->previous)) { 499 if (! empty($this->previous)) {
@@ -364,11 +533,11 @@ function read_updates_file($updatesFilepath)
364function write_updates_file($updatesFilepath, $updates) 533function write_updates_file($updatesFilepath, $updates)
365{ 534{
366 if (empty($updatesFilepath)) { 535 if (empty($updatesFilepath)) {
367 throw new Exception('Updates file path is not set, can\'t write updates.'); 536 throw new Exception(t('Updates file path is not set, can\'t write updates.'));
368 } 537 }
369 538
370 $res = file_put_contents($updatesFilepath, implode(';', $updates)); 539 $res = file_put_contents($updatesFilepath, implode(';', $updates));
371 if ($res === false) { 540 if ($res === false) {
372 throw new Exception('Unable to write updates in '. $updatesFilepath . '.'); 541 throw new Exception(t('Unable to write updates in '. $updatesFilepath . '.'));
373 } 542 }
374} 543}
diff --git a/application/Url.php b/application/Url.php
index c5c7dd18..b3759377 100644
--- a/application/Url.php
+++ b/application/Url.php
@@ -64,6 +64,30 @@ function add_trailing_slash($url)
64} 64}
65 65
66/** 66/**
67 * Replace not whitelisted protocols by 'http://' from given URL.
68 *
69 * @param string $url URL to clean
70 * @param array $protocols List of allowed protocols (aside from http(s)).
71 *
72 * @return string URL with allowed protocol
73 */
74function whitelist_protocols($url, $protocols)
75{
76 if (startsWith($url, '?') || startsWith($url, '/')) {
77 return $url;
78 }
79 $protocols = array_merge(['http', 'https'], $protocols);
80 $protocol = preg_match('#^(\w+):/?/?#', $url, $match);
81 // Protocol not allowed: we remove it and replace it with http
82 if ($protocol === 1 && ! in_array($match[1], $protocols)) {
83 $url = str_replace($match[0], 'http://', $url);
84 } else if ($protocol !== 1) {
85 $url = 'http://' . $url;
86 }
87 return $url;
88}
89
90/**
67 * URL representation and cleanup utilities 91 * URL representation and cleanup utilities
68 * 92 *
69 * Form 93 * Form
@@ -94,7 +118,10 @@ class Url
94 'utm_', 118 'utm_',
95 119
96 // ATInternet 120 // ATInternet
97 'xtor=' 121 'xtor=',
122
123 // Other
124 'campaign_'
98 ); 125 );
99 126
100 private static $annoyingFragments = array( 127 private static $annoyingFragments = array(
diff --git a/application/Utils.php b/application/Utils.php
index 0a5b476e..97b12fcf 100644
--- a/application/Utils.php
+++ b/application/Utils.php
@@ -91,6 +91,10 @@ function endsWith($haystack, $needle, $case = true)
91 */ 91 */
92function escape($input) 92function escape($input)
93{ 93{
94 if (is_bool($input)) {
95 return $input;
96 }
97
94 if (is_array($input)) { 98 if (is_array($input)) {
95 $out = array(); 99 $out = array();
96 foreach($input as $key => $value) { 100 foreach($input as $key => $value) {
@@ -178,56 +182,276 @@ function generateLocation($referer, $host, $loopTerms = array())
178} 182}
179 183
180/** 184/**
181 * Validate session ID to prevent Full Path Disclosure. 185 * Sniff browser language to set the locale automatically.
186 * Note that is may not work on your server if the corresponding locale is not installed.
187 *
188 * @param string $headerLocale Locale send in HTTP headers (e.g. "fr,fr-fr;q=0.8,en;q=0.5,en-us;q=0.3").
189 **/
190function autoLocale($headerLocale)
191{
192 // Default if browser does not send HTTP_ACCEPT_LANGUAGE
193 $locales = array('en_US', 'en_US.utf8', 'en_US.UTF-8');
194 if (! empty($headerLocale)) {
195 if (preg_match_all('/([a-z]{2,3})[-_]?([a-z]{2})?,?/i', $headerLocale, $matches, PREG_SET_ORDER)) {
196 $attempts = [];
197 foreach ($matches as $match) {
198 $first = [strtolower($match[1]), strtoupper($match[1])];
199 $separators = ['_', '-'];
200 $encodings = ['utf8', 'UTF-8'];
201 if (!empty($match[2])) {
202 $second = [strtoupper($match[2]), strtolower($match[2])];
203 $items = [$first, $separators, $second, ['.'], $encodings];
204 } else {
205 $items = [$first, $separators, $first, ['.'], $encodings];
206 }
207 $attempts = array_merge($attempts, iterator_to_array(cartesian_product_generator($items)));
208 }
209
210 if (! empty($attempts)) {
211 $locales = array_merge(array_map('implode', $attempts), $locales);
212 }
213 }
214 }
215
216 setlocale(LC_ALL, $locales);
217}
218
219/**
220 * Build a Generator object representing the cartesian product from given $items.
182 * 221 *
183 * See #298. 222 * Example:
184 * The session ID's format depends on the hash algorithm set in PHP settings 223 * [['a'], ['b', 'c']]
224 * will generate:
225 * [
226 * ['a', 'b'],
227 * ['a', 'c'],
228 * ]
185 * 229 *
186 * @param string $sessionId Session ID 230 * @param array $items array of array of string
187 * 231 *
188 * @return true if valid, false otherwise. 232 * @return Generator representing the cartesian product of given array.
189 * 233 *
190 * @see http://php.net/manual/en/function.hash-algos.php 234 * @see https://en.wikipedia.org/wiki/Cartesian_product
191 * @see http://php.net/manual/en/session.configuration.php
192 */ 235 */
193function is_session_id_valid($sessionId) 236function cartesian_product_generator($items)
194{ 237{
195 if (empty($sessionId)) { 238 if (empty($items)) {
239 yield [];
240 }
241 $subArray = array_pop($items);
242 if (empty($subArray)) {
243 return;
244 }
245 foreach (cartesian_product_generator($items) as $item) {
246 foreach ($subArray as $value) {
247 yield $item + [count($item) => $value];
248 }
249 }
250}
251
252/**
253 * Generates a default API secret.
254 *
255 * Note that the random-ish methods used in this function are predictable,
256 * which makes them NOT suitable for crypto.
257 * BUT the random string is salted with the salt and hashed with the username.
258 * It makes the generated API secret secured enough for Shaarli.
259 *
260 * PHP 7 provides random_int(), designed for cryptography.
261 * More info: http://stackoverflow.com/questions/4356289/php-random-string-generator
262
263 * @param string $username Shaarli login username
264 * @param string $salt Shaarli password hash salt
265 *
266 * @return string|bool Generated API secret, 12 char length.
267 * Or false if invalid parameters are provided (which will make the API unusable).
268 */
269function generate_api_secret($username, $salt)
270{
271 if (empty($username) || empty($salt)) {
196 return false; 272 return false;
197 } 273 }
198 274
199 if (!$sessionId) { 275 return str_shuffle(substr(hash_hmac('sha512', uniqid($salt), $username), 10, 12));
276}
277
278/**
279 * Trim string, replace sequences of whitespaces by a single space.
280 * PHP equivalent to `normalize-space` XSLT function.
281 *
282 * @param string $string Input string.
283 *
284 * @return mixed Normalized string.
285 */
286function normalize_spaces($string)
287{
288 return preg_replace('/\s{2,}/', ' ', trim($string));
289}
290
291/**
292 * Format the date according to the locale.
293 *
294 * Requires php-intl to display international datetimes,
295 * otherwise default format '%c' will be returned.
296 *
297 * @param DateTime $date to format.
298 * @param bool $time Displays time if true.
299 * @param bool $intl Use international format if true.
300 *
301 * @return bool|string Formatted date, or false if the input is invalid.
302 */
303function format_date($date, $time = true, $intl = true)
304{
305 if (! $date instanceof DateTime) {
200 return false; 306 return false;
201 } 307 }
202 308
203 if (!preg_match('/^[a-zA-Z0-9,-]{2,128}$/', $sessionId)) { 309 if (! $intl || ! class_exists('IntlDateFormatter')) {
310 $format = $time ? '%c' : '%x';
311 return strftime($format, $date->getTimestamp());
312 }
313
314 $formatter = new IntlDateFormatter(
315 setlocale(LC_TIME, 0),
316 IntlDateFormatter::LONG,
317 $time ? IntlDateFormatter::LONG : IntlDateFormatter::NONE
318 );
319
320 return $formatter->format($date);
321}
322
323/**
324 * Check if the input is an integer, no matter its real type.
325 *
326 * PHP is a bit messy regarding this:
327 * - is_int returns false if the input is a string
328 * - ctype_digit returns false if the input is an integer or negative
329 *
330 * @param mixed $input value
331 *
332 * @return bool true if the input is an integer, false otherwise
333 */
334function is_integer_mixed($input)
335{
336 if (is_array($input) || is_bool($input) || is_object($input)) {
204 return false; 337 return false;
205 } 338 }
339 $input = strval($input);
340 return ctype_digit($input) || (startsWith($input, '-') && ctype_digit(substr($input, 1)));
341}
206 342
207 return true; 343/**
344 * Convert post_max_size/upload_max_filesize (e.g. '16M') parameters to bytes.
345 *
346 * @param string $val Size expressed in string.
347 *
348 * @return int Size expressed in bytes.
349 */
350function return_bytes($val)
351{
352 if (is_integer_mixed($val) || $val === '0' || empty($val)) {
353 return $val;
354 }
355 $val = trim($val);
356 $last = strtolower($val[strlen($val)-1]);
357 $val = intval(substr($val, 0, -1));
358 switch($last) {
359 case 'g': $val *= 1024;
360 case 'm': $val *= 1024;
361 case 'k': $val *= 1024;
362 }
363 return $val;
208} 364}
209 365
210/** 366/**
211 * Sniff browser language to set the locale automatically. 367 * Return a human readable size from bytes.
212 * Note that is may not work on your server if the corresponding locale is not installed.
213 * 368 *
214 * @param string $headerLocale Locale send in HTTP headers (e.g. "fr,fr-fr;q=0.8,en;q=0.5,en-us;q=0.3"). 369 * @param int $bytes value
215 **/ 370 *
216function autoLocale($headerLocale) 371 * @return string Human readable size
372 */
373function human_bytes($bytes)
217{ 374{
218 // Default if browser does not send HTTP_ACCEPT_LANGUAGE 375 if ($bytes === '') {
219 $attempts = array('en_US'); 376 return t('Setting not set');
220 if (isset($headerLocale)) { 377 }
221 // (It's a bit crude, but it works very well. Preferred language is always presented first.) 378 if (! is_integer_mixed($bytes)) {
222 if (preg_match('/([a-z]{2})-?([a-z]{2})?/i', $headerLocale, $matches)) { 379 return $bytes;
223 $loc = $matches[1] . (!empty($matches[2]) ? '_' . strtoupper($matches[2]) : ''); 380 }
224 $attempts = array( 381 $bytes = intval($bytes);
225 $loc.'.UTF-8', $loc, str_replace('_', '-', $loc).'.UTF-8', str_replace('_', '-', $loc), 382 if ($bytes === 0) {
226 $loc . '_' . strtoupper($loc).'.UTF-8', $loc . '_' . strtoupper($loc), 383 return t('Unlimited');
227 $loc . '_' . $loc.'.UTF-8', $loc . '_' . $loc, $loc . '-' . strtoupper($loc).'.UTF-8', 384 }
228 $loc . '-' . strtoupper($loc), $loc . '-' . $loc.'.UTF-8', $loc . '-' . $loc 385
229 ); 386 $units = [t('B'), t('kiB'), t('MiB'), t('GiB')];
387 for ($i = 0; $i < count($units) && $bytes >= 1024; ++$i) {
388 $bytes /= 1024;
389 }
390
391 return round($bytes) . $units[$i];
392}
393
394/**
395 * Try to determine max file size for uploads (POST).
396 * Returns an integer (in bytes) or formatted depending on $format.
397 *
398 * @param mixed $limitPost post_max_size PHP setting
399 * @param mixed $limitUpload upload_max_filesize PHP setting
400 * @param bool $format Format max upload size to human readable size
401 *
402 * @return int|string max upload file size
403 */
404function get_max_upload_size($limitPost, $limitUpload, $format = true)
405{
406 $size1 = return_bytes($limitPost);
407 $size2 = return_bytes($limitUpload);
408 // Return the smaller of two:
409 $maxsize = min($size1, $size2);
410 return $format ? human_bytes($maxsize) : $maxsize;
411}
412
413/**
414 * Sort the given array alphabetically using php-intl if available.
415 * Case sensitive.
416 *
417 * Note: doesn't support multidimensional arrays
418 *
419 * @param array $data Input array, passed by reference
420 * @param bool $reverse Reverse sort if set to true
421 * @param bool $byKeys Sort the array by keys if set to true, by value otherwise.
422 */
423function alphabetical_sort(&$data, $reverse = false, $byKeys = false)
424{
425 $callback = function ($a, $b) use ($reverse) {
426 // Collator is part of PHP intl.
427 if (class_exists('Collator')) {
428 $collator = new Collator(setlocale(LC_COLLATE, 0));
429 if (!intl_is_failure(intl_get_error_code())) {
430 return $collator->compare($a, $b) * ($reverse ? -1 : 1);
431 }
230 } 432 }
433
434 return strcasecmp($a, $b) * ($reverse ? -1 : 1);
435 };
436
437 if ($byKeys) {
438 uksort($data, $callback);
439 } else {
440 usort($data, $callback);
231 } 441 }
232 setlocale(LC_ALL, $attempts); 442}
443
444/**
445 * Wrapper function for translation which match the API
446 * of gettext()/_() and ngettext().
447 *
448 * @param string $text Text to translate.
449 * @param string $nText The plural message ID.
450 * @param int $nb The number of items for plural forms.
451 * @param string $domain The domain where the translation is stored (default: shaarli).
452 *
453 * @return string Text translated.
454 */
455function t($text, $nText = '', $nb = 1, $domain = 'shaarli') {
456 return dn__($domain, $text, $nText, $nb);
233} 457}
diff --git a/application/api/ApiMiddleware.php b/application/api/ApiMiddleware.php
new file mode 100644
index 00000000..ff209393
--- /dev/null
+++ b/application/api/ApiMiddleware.php
@@ -0,0 +1,138 @@
1<?php
2namespace Shaarli\Api;
3
4use Shaarli\Api\Exceptions\ApiException;
5use Shaarli\Api\Exceptions\ApiAuthorizationException;
6
7use Shaarli\Config\ConfigManager;
8use Slim\Container;
9use Slim\Http\Request;
10use Slim\Http\Response;
11
12/**
13 * Class ApiMiddleware
14 *
15 * This will be called before accessing any API Controller.
16 * Its role is to make sure that the API is enabled, configured, and to validate the JWT token.
17 *
18 * If the request is validated, the controller is called, otherwise a JSON error response is returned.
19 *
20 * @package Api
21 */
22class ApiMiddleware
23{
24 /**
25 * @var int JWT token validity in seconds (9 min).
26 */
27 public static $TOKEN_DURATION = 540;
28
29 /**
30 * @var Container: contains conf, plugins, etc.
31 */
32 protected $container;
33
34 /**
35 * @var ConfigManager instance.
36 */
37 protected $conf;
38
39 /**
40 * ApiMiddleware constructor.
41 *
42 * @param Container $container instance.
43 */
44 public function __construct($container)
45 {
46 $this->container = $container;
47 $this->conf = $this->container->get('conf');
48 $this->setLinkDb($this->conf);
49 }
50
51 /**
52 * Middleware execution:
53 * - check the API request
54 * - execute the controller
55 * - return the response
56 *
57 * @param Request $request Slim request
58 * @param Response $response Slim response
59 * @param callable $next Next action
60 *
61 * @return Response response.
62 */
63 public function __invoke($request, $response, $next)
64 {
65 try {
66 $this->checkRequest($request);
67 $response = $next($request, $response);
68 } catch(ApiException $e) {
69 $e->setResponse($response);
70 $e->setDebug($this->conf->get('dev.debug', false));
71 $response = $e->getApiResponse();
72 }
73
74 return $response;
75 }
76
77 /**
78 * Check the request validity (HTTP method, request value, etc.),
79 * that the API is enabled, and the JWT token validity.
80 *
81 * @param Request $request Slim request
82 *
83 * @throws ApiAuthorizationException The API is disabled or the token is invalid.
84 */
85 protected function checkRequest($request)
86 {
87 if (! $this->conf->get('api.enabled', true)) {
88 throw new ApiAuthorizationException('API is disabled');
89 }
90 $this->checkToken($request);
91 }
92
93 /**
94 * Check that the JWT token is set and valid.
95 * The API secret setting must be set.
96 *
97 * @param Request $request Slim request
98 *
99 * @throws ApiAuthorizationException The token couldn't be validated.
100 */
101 protected function checkToken($request) {
102 if (! $request->hasHeader('Authorization')) {
103 throw new ApiAuthorizationException('JWT token not provided');
104 }
105
106 if (empty($this->conf->get('api.secret'))) {
107 throw new ApiAuthorizationException('Token secret must be set in Shaarli\'s administration');
108 }
109
110 $authorization = $request->getHeaderLine('Authorization');
111
112 if (! preg_match('/^Bearer (.*)/i', $authorization, $matches)) {
113 throw new ApiAuthorizationException('Invalid JWT header');
114 }
115
116 ApiUtils::validateJwtToken($matches[1], $this->conf->get('api.secret'));
117 }
118
119 /**
120 * Instantiate a new LinkDB including private links,
121 * and load in the Slim container.
122 *
123 * FIXME! LinkDB could use a refactoring to avoid this trick.
124 *
125 * @param ConfigManager $conf instance.
126 */
127 protected function setLinkDb($conf)
128 {
129 $linkDb = new \LinkDB(
130 $conf->get('resource.datastore'),
131 true,
132 $conf->get('privacy.hide_public_links'),
133 $conf->get('redirector.url'),
134 $conf->get('redirector.encode_url')
135 );
136 $this->container['db'] = $linkDb;
137 }
138}
diff --git a/application/api/ApiUtils.php b/application/api/ApiUtils.php
new file mode 100644
index 00000000..f154bb52
--- /dev/null
+++ b/application/api/ApiUtils.php
@@ -0,0 +1,137 @@
1<?php
2namespace Shaarli\Api;
3
4use Shaarli\Base64Url;
5use Shaarli\Api\Exceptions\ApiAuthorizationException;
6
7/**
8 * REST API utilities
9 */
10class ApiUtils
11{
12 /**
13 * Validates a JWT token authenticity.
14 *
15 * @param string $token JWT token extracted from the headers.
16 * @param string $secret API secret set in the settings.
17 *
18 * @throws ApiAuthorizationException the token is not valid.
19 */
20 public static function validateJwtToken($token, $secret)
21 {
22 $parts = explode('.', $token);
23 if (count($parts) != 3 || strlen($parts[0]) == 0 || strlen($parts[1]) == 0) {
24 throw new ApiAuthorizationException('Malformed JWT token');
25 }
26
27 $genSign = Base64Url::encode(hash_hmac('sha512', $parts[0] .'.'. $parts[1], $secret, true));
28 if ($parts[2] != $genSign) {
29 throw new ApiAuthorizationException('Invalid JWT signature');
30 }
31
32 $header = json_decode(Base64Url::decode($parts[0]));
33 if ($header === null) {
34 throw new ApiAuthorizationException('Invalid JWT header');
35 }
36
37 $payload = json_decode(Base64Url::decode($parts[1]));
38 if ($payload === null) {
39 throw new ApiAuthorizationException('Invalid JWT payload');
40 }
41
42 if (empty($payload->iat)
43 || $payload->iat > time()
44 || time() - $payload->iat > ApiMiddleware::$TOKEN_DURATION
45 ) {
46 throw new ApiAuthorizationException('Invalid JWT issued time');
47 }
48 }
49
50 /**
51 * Format a Link for the REST API.
52 *
53 * @param array $link Link data read from the datastore.
54 * @param string $indexUrl Shaarli's index URL (used for relative URL).
55 *
56 * @return array Link data formatted for the REST API.
57 */
58 public static function formatLink($link, $indexUrl)
59 {
60 $out['id'] = $link['id'];
61 // Not an internal link
62 if ($link['url'][0] != '?') {
63 $out['url'] = $link['url'];
64 } else {
65 $out['url'] = $indexUrl . $link['url'];
66 }
67 $out['shorturl'] = $link['shorturl'];
68 $out['title'] = $link['title'];
69 $out['description'] = $link['description'];
70 $out['tags'] = preg_split('/\s+/', $link['tags'], -1, PREG_SPLIT_NO_EMPTY);
71 $out['private'] = $link['private'] == true;
72 $out['created'] = $link['created']->format(\DateTime::ATOM);
73 if (! empty($link['updated'])) {
74 $out['updated'] = $link['updated']->format(\DateTime::ATOM);
75 } else {
76 $out['updated'] = '';
77 }
78 return $out;
79 }
80
81 /**
82 * Convert a link given through a request, to a valid link for LinkDB.
83 *
84 * 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.
86 *
87 * @param array $input Request Link.
88 * @param bool $defaultPrivate Request Link.
89 *
90 * @return array Formatted link.
91 */
92 public static function buildLinkFromRequest($input, $defaultPrivate)
93 {
94 $input['url'] = ! empty($input['url']) ? cleanup_url($input['url']) : '';
95 if (isset($input['private'])) {
96 $private = filter_var($input['private'], FILTER_VALIDATE_BOOLEAN);
97 } else {
98 $private = $defaultPrivate;
99 }
100
101 $link = [
102 'title' => ! empty($input['title']) ? $input['title'] : $input['url'],
103 'url' => $input['url'],
104 'description' => ! empty($input['description']) ? $input['description'] : '',
105 'tags' => ! empty($input['tags']) ? implode(' ', $input['tags']) : '',
106 'private' => $private,
107 'created' => new \DateTime(),
108 ];
109 return $link;
110 }
111
112 /**
113 * Update link fields using an updated link object.
114 *
115 * @param array $oldLink data
116 * @param array $newLink data
117 *
118 * @return array $oldLink updated with $newLink values
119 */
120 public static function updateLink($oldLink, $newLink)
121 {
122 foreach (['title', 'url', 'description', 'tags', 'private'] as $field) {
123 $oldLink[$field] = $newLink[$field];
124 }
125 $oldLink['updated'] = new \DateTime();
126
127 if (empty($oldLink['url'])) {
128 $oldLink['url'] = '?' . $oldLink['shorturl'];
129 }
130
131 if (empty($oldLink['title'])) {
132 $oldLink['title'] = $oldLink['url'];
133 }
134
135 return $oldLink;
136 }
137}
diff --git a/application/api/controllers/ApiController.php b/application/api/controllers/ApiController.php
new file mode 100644
index 00000000..3be85b98
--- /dev/null
+++ b/application/api/controllers/ApiController.php
@@ -0,0 +1,71 @@
1<?php
2
3namespace Shaarli\Api\Controllers;
4
5use Shaarli\Config\ConfigManager;
6use \Slim\Container;
7
8/**
9 * Abstract Class ApiController
10 *
11 * Defines REST API Controller dependencies injected from the container.
12 *
13 * @package Api\Controllers
14 */
15abstract class ApiController
16{
17 /**
18 * @var Container
19 */
20 protected $ci;
21
22 /**
23 * @var ConfigManager
24 */
25 protected $conf;
26
27 /**
28 * @var \LinkDB
29 */
30 protected $linkDb;
31
32 /**
33 * @var \History
34 */
35 protected $history;
36
37 /**
38 * @var int|null JSON style option.
39 */
40 protected $jsonStyle;
41
42 /**
43 * ApiController constructor.
44 *
45 * Note: enabling debug mode displays JSON with readable formatting.
46 *
47 * @param Container $ci Slim container.
48 */
49 public function __construct(Container $ci)
50 {
51 $this->ci = $ci;
52 $this->conf = $ci->get('conf');
53 $this->linkDb = $ci->get('db');
54 $this->history = $ci->get('history');
55 if ($this->conf->get('dev.debug', false)) {
56 $this->jsonStyle = JSON_PRETTY_PRINT;
57 } else {
58 $this->jsonStyle = null;
59 }
60 }
61
62 /**
63 * Get the container.
64 *
65 * @return Container
66 */
67 public function getCi()
68 {
69 return $this->ci;
70 }
71}
diff --git a/application/api/controllers/History.php b/application/api/controllers/History.php
new file mode 100644
index 00000000..2ff9deaf
--- /dev/null
+++ b/application/api/controllers/History.php
@@ -0,0 +1,70 @@
1<?php
2
3
4namespace Shaarli\Api\Controllers;
5
6use Shaarli\Api\Exceptions\ApiBadParametersException;
7use Slim\Http\Request;
8use Slim\Http\Response;
9
10/**
11 * Class History
12 *
13 * REST API Controller: /history
14 *
15 * @package Shaarli\Api\Controllers
16 */
17class History extends ApiController
18{
19 /**
20 * Service providing operation regarding Shaarli datastore and settings.
21 *
22 * @param Request $request Slim request.
23 * @param Response $response Slim response.
24 *
25 * @return Response response.
26 *
27 * @throws ApiBadParametersException Invalid parameters.
28 */
29 public function getHistory($request, $response)
30 {
31 $history = $this->history->getHistory();
32
33 // Return history operations from the {offset}th, starting from {since}.
34 $since = \DateTime::createFromFormat(\DateTime::ATOM, $request->getParam('since'));
35 $offset = $request->getParam('offset');
36 if (empty($offset)) {
37 $offset = 0;
38 }
39 else if (ctype_digit($offset)) {
40 $offset = (int) $offset;
41 } else {
42 throw new ApiBadParametersException('Invalid offset');
43 }
44
45 // limit parameter is either a number of links or 'all' for everything.
46 $limit = $request->getParam('limit');
47 if (empty($limit)) {
48 $limit = count($history);
49 } else if (ctype_digit($limit)) {
50 $limit = (int) $limit;
51 } else {
52 throw new ApiBadParametersException('Invalid limit');
53 }
54
55 $out = [];
56 $i = 0;
57 foreach ($history as $entry) {
58 if ((! empty($since) && $entry['datetime'] <= $since) || count($out) >= $limit) {
59 break;
60 }
61 if (++$i > $offset) {
62 $out[$i] = $entry;
63 $out[$i]['datetime'] = $out[$i]['datetime']->format(\DateTime::ATOM);
64 }
65 }
66 $out = array_values($out);
67
68 return $response->withJson($out, 200, $this->jsonStyle);
69 }
70}
diff --git a/application/api/controllers/Info.php b/application/api/controllers/Info.php
new file mode 100644
index 00000000..25433f72
--- /dev/null
+++ b/application/api/controllers/Info.php
@@ -0,0 +1,42 @@
1<?php
2
3namespace Shaarli\Api\Controllers;
4
5use Slim\Http\Request;
6use Slim\Http\Response;
7
8/**
9 * Class Info
10 *
11 * REST API Controller: /info
12 *
13 * @package Api\Controllers
14 * @see http://shaarli.github.io/api-documentation/#links-instance-information-get
15 */
16class Info extends ApiController
17{
18 /**
19 * Service providing various information about Shaarli instance.
20 *
21 * @param Request $request Slim request.
22 * @param Response $response Slim response.
23 *
24 * @return Response response.
25 */
26 public function getInfo($request, $response)
27 {
28 $info = [
29 'global_counter' => count($this->linkDb),
30 'private_counter' => count_private($this->linkDb),
31 'settings' => array(
32 'title' => $this->conf->get('general.title', 'Shaarli'),
33 'header_link' => $this->conf->get('general.header_link', '?'),
34 'timezone' => $this->conf->get('general.timezone', 'UTC'),
35 'enabled_plugins' => $this->conf->get('general.enabled_plugins', []),
36 'default_private_links' => $this->conf->get('privacy.default_private_links', false),
37 ),
38 ];
39
40 return $response->withJson($info, 200, $this->jsonStyle);
41 }
42}
diff --git a/application/api/controllers/Links.php b/application/api/controllers/Links.php
new file mode 100644
index 00000000..eb78dd26
--- /dev/null
+++ b/application/api/controllers/Links.php
@@ -0,0 +1,217 @@
1<?php
2
3namespace Shaarli\Api\Controllers;
4
5use Shaarli\Api\ApiUtils;
6use Shaarli\Api\Exceptions\ApiBadParametersException;
7use Shaarli\Api\Exceptions\ApiLinkNotFoundException;
8use Slim\Http\Request;
9use Slim\Http\Response;
10
11/**
12 * Class Links
13 *
14 * REST API Controller: all services related to links collection.
15 *
16 * @package Api\Controllers
17 * @see http://shaarli.github.io/api-documentation/#links-links-collection
18 */
19class Links extends ApiController
20{
21 /**
22 * @var int Number of links returned if no limit is provided.
23 */
24 public static $DEFAULT_LIMIT = 20;
25
26 /**
27 * Retrieve a list of links, allowing different filters.
28 *
29 * @param Request $request Slim request.
30 * @param Response $response Slim response.
31 *
32 * @return Response response.
33 *
34 * @throws ApiBadParametersException Invalid parameters.
35 */
36 public function getLinks($request, $response)
37 {
38 $private = $request->getParam('visibility');
39 $links = $this->linkDb->filterSearch(
40 [
41 'searchtags' => $request->getParam('searchtags', ''),
42 'searchterm' => $request->getParam('searchterm', ''),
43 ],
44 false,
45 $private
46 );
47
48 // Return links from the {offset}th link, starting from 0.
49 $offset = $request->getParam('offset');
50 if (! empty($offset) && ! ctype_digit($offset)) {
51 throw new ApiBadParametersException('Invalid offset');
52 }
53 $offset = ! empty($offset) ? intval($offset) : 0;
54 if ($offset > count($links)) {
55 return $response->withJson([], 200, $this->jsonStyle);
56 }
57
58 // limit parameter is either a number of links or 'all' for everything.
59 $limit = $request->getParam('limit');
60 if (empty($limit)) {
61 $limit = self::$DEFAULT_LIMIT;
62 } else if (ctype_digit($limit)) {
63 $limit = intval($limit);
64 } else if ($limit === 'all') {
65 $limit = count($links);
66 } else {
67 throw new ApiBadParametersException('Invalid limit');
68 }
69
70 // 'environment' is set by Slim and encapsulate $_SERVER.
71 $index = index_url($this->ci['environment']);
72
73 $out = [];
74 $cpt = 0;
75 foreach ($links as $link) {
76 if (count($out) >= $limit) {
77 break;
78 }
79 if ($cpt++ >= $offset) {
80 $out[] = ApiUtils::formatLink($link, $index);
81 }
82 }
83
84 return $response->withJson($out, 200, $this->jsonStyle);
85 }
86
87 /**
88 * Return a single formatted link by its ID.
89 *
90 * @param Request $request Slim request.
91 * @param Response $response Slim response.
92 * @param array $args Path parameters. including the ID.
93 *
94 * @return Response containing the link array.
95 *
96 * @throws ApiLinkNotFoundException generating a 404 error.
97 */
98 public function getLink($request, $response, $args)
99 {
100 if (!isset($this->linkDb[$args['id']])) {
101 throw new ApiLinkNotFoundException();
102 }
103 $index = index_url($this->ci['environment']);
104 $out = ApiUtils::formatLink($this->linkDb[$args['id']], $index);
105
106 return $response->withJson($out, 200, $this->jsonStyle);
107 }
108
109 /**
110 * Creates a new link from posted request body.
111 *
112 * @param Request $request Slim request.
113 * @param Response $response Slim response.
114 *
115 * @return Response response.
116 */
117 public function postLink($request, $response)
118 {
119 $data = $request->getParsedBody();
120 $link = ApiUtils::buildLinkFromRequest($data, $this->conf->get('privacy.default_private_links'));
121 // duplicate by URL, return 409 Conflict
122 if (! empty($link['url']) && ! empty($dup = $this->linkDb->getLinkFromUrl($link['url']))) {
123 return $response->withJson(
124 ApiUtils::formatLink($dup, index_url($this->ci['environment'])),
125 409,
126 $this->jsonStyle
127 );
128 }
129
130 $link['id'] = $this->linkDb->getNextId();
131 $link['shorturl'] = link_small_hash($link['created'], $link['id']);
132
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)
148 ->withJson($out, 201, $this->jsonStyle);
149 }
150
151 /**
152 * Updates an existing link from posted request body.
153 *
154 * @param Request $request Slim request.
155 * @param Response $response Slim response.
156 * @param array $args Path parameters. including the ID.
157 *
158 * @return Response response.
159 *
160 * @throws ApiLinkNotFoundException generating a 404 error.
161 */
162 public function putLink($request, $response, $args)
163 {
164 if (! isset($this->linkDb[$args['id']])) {
165 throw new ApiLinkNotFoundException();
166 }
167
168 $index = index_url($this->ci['environment']);
169 $data = $request->getParsedBody();
170
171 $requestLink = ApiUtils::buildLinkFromRequest($data, $this->conf->get('privacy.default_private_links'));
172 // duplicate URL on a different link, return 409 Conflict
173 if (! empty($requestLink['url'])
174 && ! empty($dup = $this->linkDb->getLinkFromUrl($requestLink['url']))
175 && $dup['id'] != $args['id']
176 ) {
177 return $response->withJson(
178 ApiUtils::formatLink($dup, $index),
179 409,
180 $this->jsonStyle
181 );
182 }
183
184 $responseLink = $this->linkDb[$args['id']];
185 $responseLink = ApiUtils::updateLink($responseLink, $requestLink);
186 $this->linkDb[$responseLink['id']] = $responseLink;
187 $this->linkDb->save($this->conf->get('resource.page_cache'));
188 $this->history->updateLink($responseLink);
189
190 $out = ApiUtils::formatLink($responseLink, $index);
191 return $response->withJson($out, 200, $this->jsonStyle);
192 }
193
194 /**
195 * Delete an existing link by its ID.
196 *
197 * @param Request $request Slim request.
198 * @param Response $response Slim response.
199 * @param array $args Path parameters. including the ID.
200 *
201 * @return Response response.
202 *
203 * @throws ApiLinkNotFoundException generating a 404 error.
204 */
205 public function deleteLink($request, $response, $args)
206 {
207 if (! isset($this->linkDb[$args['id']])) {
208 throw new ApiLinkNotFoundException();
209 }
210 $link = $this->linkDb[$args['id']];
211 unset($this->linkDb[(int) $args['id']]);
212 $this->linkDb->save($this->conf->get('resource.page_cache'));
213 $this->history->deleteLink($link);
214
215 return $response->withStatus(204);
216 }
217}
diff --git a/application/api/exceptions/ApiAuthorizationException.php b/application/api/exceptions/ApiAuthorizationException.php
new file mode 100644
index 00000000..0e3f4776
--- /dev/null
+++ b/application/api/exceptions/ApiAuthorizationException.php
@@ -0,0 +1,34 @@
1<?php
2
3namespace Shaarli\Api\Exceptions;
4
5/**
6 * Class ApiAuthorizationException
7 *
8 * Request not authorized, return a 401 HTTP code.
9 */
10class ApiAuthorizationException extends ApiException
11{
12 /**
13 * {@inheritdoc}
14 */
15 public function getApiResponse()
16 {
17 $this->setMessage('Not authorized');
18 return $this->buildApiResponse(401);
19 }
20
21 /**
22 * Set the exception message.
23 *
24 * We only return a generic error message in production mode to avoid giving
25 * to much security information.
26 *
27 * @param $message string the exception message.
28 */
29 public function setMessage($message)
30 {
31 $original = $this->debug === true ? ': '. $this->getMessage() : '';
32 $this->message = $message . $original;
33 }
34}
diff --git a/application/api/exceptions/ApiBadParametersException.php b/application/api/exceptions/ApiBadParametersException.php
new file mode 100644
index 00000000..e5cc19ea
--- /dev/null
+++ b/application/api/exceptions/ApiBadParametersException.php
@@ -0,0 +1,19 @@
1<?php
2
3namespace Shaarli\Api\Exceptions;
4
5/**
6 * Class ApiBadParametersException
7 *
8 * Invalid request exception, return a 400 HTTP code.
9 */
10class ApiBadParametersException extends ApiException
11{
12 /**
13 * {@inheritdoc}
14 */
15 public function getApiResponse()
16 {
17 return $this->buildApiResponse(400);
18 }
19}
diff --git a/application/api/exceptions/ApiException.php b/application/api/exceptions/ApiException.php
new file mode 100644
index 00000000..c8490e0c
--- /dev/null
+++ b/application/api/exceptions/ApiException.php
@@ -0,0 +1,77 @@
1<?php
2
3namespace Shaarli\Api\Exceptions;
4
5use Slim\Http\Response;
6
7/**
8 * Abstract class ApiException
9 *
10 * Parent Exception related to the API, able to generate a valid Response (ResponseInterface).
11 * Also can include various information in debug mode.
12 */
13abstract class ApiException extends \Exception {
14
15 /**
16 * @var Response instance from Slim.
17 */
18 protected $response;
19
20 /**
21 * @var bool Debug mode enabled/disabled.
22 */
23 protected $debug;
24
25 /**
26 * Build the final response.
27 *
28 * @return Response Final response to give.
29 */
30 public abstract function getApiResponse();
31
32 /**
33 * Creates ApiResponse body.
34 * In production mode, it will only return the exception message,
35 * but in dev mode, it includes additional information in an array.
36 *
37 * @return array|string response body
38 */
39 protected function getApiResponseBody() {
40 if ($this->debug !== true) {
41 return $this->getMessage();
42 }
43 return [
44 'message' => $this->getMessage(),
45 'stacktrace' => get_class($this) .': '. $this->getTraceAsString()
46 ];
47 }
48
49 /**
50 * Build the Response object to return.
51 *
52 * @param int $code HTTP status.
53 *
54 * @return Response with status + body.
55 */
56 protected function buildApiResponse($code)
57 {
58 $style = $this->debug ? JSON_PRETTY_PRINT : null;
59 return $this->response->withJson($this->getApiResponseBody(), $code, $style);
60 }
61
62 /**
63 * @param Response $response
64 */
65 public function setResponse($response)
66 {
67 $this->response = $response;
68 }
69
70 /**
71 * @param bool $debug
72 */
73 public function setDebug($debug)
74 {
75 $this->debug = $debug;
76 }
77}
diff --git a/application/api/exceptions/ApiInternalException.php b/application/api/exceptions/ApiInternalException.php
new file mode 100644
index 00000000..1cb05532
--- /dev/null
+++ b/application/api/exceptions/ApiInternalException.php
@@ -0,0 +1,19 @@
1<?php
2
3namespace Shaarli\Api\Exceptions;
4
5/**
6 * Class ApiInternalException
7 *
8 * Generic exception, return a 500 HTTP code.
9 */
10class ApiInternalException extends ApiException
11{
12 /**
13 * @inheritdoc
14 */
15 public function getApiResponse()
16 {
17 return $this->buildApiResponse(500);
18 }
19}
diff --git a/application/api/exceptions/ApiLinkNotFoundException.php b/application/api/exceptions/ApiLinkNotFoundException.php
new file mode 100644
index 00000000..de7e14f5
--- /dev/null
+++ b/application/api/exceptions/ApiLinkNotFoundException.php
@@ -0,0 +1,32 @@
1<?php
2
3namespace Shaarli\Api\Exceptions;
4
5
6use Slim\Http\Response;
7
8/**
9 * Class ApiLinkNotFoundException
10 *
11 * Link selected by ID couldn't be found, results in a 404 error.
12 *
13 * @package Shaarli\Api\Exceptions
14 */
15class ApiLinkNotFoundException extends ApiException
16{
17 /**
18 * ApiLinkNotFoundException constructor.
19 */
20 public function __construct()
21 {
22 $this->message = 'Link not found';
23 }
24
25 /**
26 * {@inheritdoc}
27 */
28 public function getApiResponse()
29 {
30 return $this->buildApiResponse(404);
31 }
32}
diff --git a/application/config/ConfigIO.php b/application/config/ConfigIO.php
index 2b68fe6a..3efe5b6f 100644
--- a/application/config/ConfigIO.php
+++ b/application/config/ConfigIO.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2namespace Shaarli\Config;
2 3
3/** 4/**
4 * Interface ConfigIO 5 * Interface ConfigIO
@@ -14,7 +15,7 @@ interface ConfigIO
14 * 15 *
15 * @return array All configuration in an array. 16 * @return array All configuration in an array.
16 */ 17 */
17 function read($filepath); 18 public function read($filepath);
18 19
19 /** 20 /**
20 * Write configuration. 21 * Write configuration.
@@ -22,12 +23,12 @@ interface ConfigIO
22 * @param string $filepath Config file absolute path. 23 * @param string $filepath Config file absolute path.
23 * @param array $conf All configuration in an array. 24 * @param array $conf All configuration in an array.
24 */ 25 */
25 function write($filepath, $conf); 26 public function write($filepath, $conf);
26 27
27 /** 28 /**
28 * Get config file extension according to config type. 29 * Get config file extension according to config type.
29 * 30 *
30 * @return string Config file extension. 31 * @return string Config file extension.
31 */ 32 */
32 function getExtension(); 33 public function getExtension();
33} 34}
diff --git a/application/config/ConfigJson.php b/application/config/ConfigJson.php
index 30007eb4..8c8d5610 100644
--- a/application/config/ConfigJson.php
+++ b/application/config/ConfigJson.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2namespace Shaarli\Config;
2 3
3/** 4/**
4 * Class ConfigJson (ConfigIO implementation) 5 * Class ConfigJson (ConfigIO implementation)
@@ -10,7 +11,7 @@ class ConfigJson implements ConfigIO
10 /** 11 /**
11 * @inheritdoc 12 * @inheritdoc
12 */ 13 */
13 function read($filepath) 14 public function read($filepath)
14 { 15 {
15 if (! is_readable($filepath)) { 16 if (! is_readable($filepath)) {
16 return array(); 17 return array();
@@ -20,8 +21,19 @@ class ConfigJson implements ConfigIO
20 $data = str_replace(self::getPhpSuffix(), '', $data); 21 $data = str_replace(self::getPhpSuffix(), '', $data);
21 $data = json_decode($data, true); 22 $data = json_decode($data, true);
22 if ($data === null) { 23 if ($data === null) {
23 $error = json_last_error(); 24 $errorCode = json_last_error();
24 throw new Exception('An error occurred while parsing JSON file: error code #'. $error); 25 $error = sprintf(
26 'An error occurred while parsing JSON configuration file (%s): error code #%d',
27 $filepath,
28 $errorCode
29 );
30 $error .= '<br>➜ <code>' . json_last_error_msg() .'</code>';
31 if ($errorCode === JSON_ERROR_SYNTAX) {
32 $error .= '<br>';
33 $error .= 'Please check your JSON syntax (without PHP comment tags) using a JSON lint tool such as ';
34 $error .= '<a href="http://jsonlint.com/">jsonlint.com</a>.';
35 }
36 throw new \Exception($error);
25 } 37 }
26 return $data; 38 return $data;
27 } 39 }
@@ -29,16 +41,16 @@ class ConfigJson implements ConfigIO
29 /** 41 /**
30 * @inheritdoc 42 * @inheritdoc
31 */ 43 */
32 function write($filepath, $conf) 44 public function write($filepath, $conf)
33 { 45 {
34 // JSON_PRETTY_PRINT is available from PHP 5.4. 46 // JSON_PRETTY_PRINT is available from PHP 5.4.
35 $print = defined('JSON_PRETTY_PRINT') ? JSON_PRETTY_PRINT : 0; 47 $print = defined('JSON_PRETTY_PRINT') ? JSON_PRETTY_PRINT : 0;
36 $data = self::getPhpHeaders() . json_encode($conf, $print) . self::getPhpSuffix(); 48 $data = self::getPhpHeaders() . json_encode($conf, $print) . self::getPhpSuffix();
37 if (!file_put_contents($filepath, $data)) { 49 if (!file_put_contents($filepath, $data)) {
38 throw new IOException( 50 throw new \IOException(
39 $filepath, 51 $filepath,
40 'Shaarli could not create the config file. 52 t('Shaarli could not create the config file. '.
41 Please make sure Shaarli has the right to write in the folder is it installed in.' 53 'Please make sure Shaarli has the right to write in the folder is it installed in.')
42 ); 54 );
43 } 55 }
44 } 56 }
@@ -46,7 +58,7 @@ class ConfigJson implements ConfigIO
46 /** 58 /**
47 * @inheritdoc 59 * @inheritdoc
48 */ 60 */
49 function getExtension() 61 public function getExtension()
50 { 62 {
51 return '.json.php'; 63 return '.json.php';
52 } 64 }
diff --git a/application/config/ConfigManager.php b/application/config/ConfigManager.php
index f5f753f8..9e4c9f63 100644
--- a/application/config/ConfigManager.php
+++ b/application/config/ConfigManager.php
@@ -1,17 +1,16 @@
1<?php 1<?php
2namespace Shaarli\Config;
2 3
3// FIXME! Namespaces... 4use Shaarli\Config\Exception\MissingFieldConfigException;
4require_once 'ConfigIO.php'; 5use Shaarli\Config\Exception\UnauthorizedConfigException;
5require_once 'ConfigJson.php';
6require_once 'ConfigPhp.php';
7 6
8/** 7/**
9 * Class ConfigManager 8 * Class ConfigManager
10 * 9 *
11 * Manages all Shaarli's settings. 10 * Manages all Shaarli's settings.
12 * See the documentation for more information on settings: 11 * See the documentation for more information on settings:
13 * - doc/Shaarli-configuration.html 12 * - doc/md/Shaarli-configuration.md
14 * - https://github.com/shaarli/Shaarli/wiki/Shaarli-configuration 13 * - https://shaarli.readthedocs.io/en/master/Shaarli-configuration/#configuration
15 */ 14 */
16class ConfigManager 15class ConfigManager
17{ 16{
@@ -20,6 +19,8 @@ class ConfigManager
20 */ 19 */
21 protected static $NOT_FOUND = 'NOT_FOUND'; 20 protected static $NOT_FOUND = 'NOT_FOUND';
22 21
22 public static $DEFAULT_PLUGINS = array('qrcode');
23
23 /** 24 /**
24 * @var string Config folder. 25 * @var string Config folder.
25 */ 26 */
@@ -80,7 +81,11 @@ class ConfigManager
80 */ 81 */
81 protected function load() 82 protected function load()
82 { 83 {
83 $this->loadedConfig = $this->configIO->read($this->getConfigFileExt()); 84 try {
85 $this->loadedConfig = $this->configIO->read($this->getConfigFileExt());
86 } catch (\Exception $e) {
87 die($e->getMessage());
88 }
84 $this->setDefaultValues(); 89 $this->setDefaultValues();
85 } 90 }
86 91
@@ -122,12 +127,12 @@ class ConfigManager
122 * @param bool $write Write the new setting in the config file, default false. 127 * @param bool $write Write the new setting in the config file, default false.
123 * @param bool $isLoggedIn User login state, default false. 128 * @param bool $isLoggedIn User login state, default false.
124 * 129 *
125 * @throws Exception Invalid 130 * @throws \Exception Invalid
126 */ 131 */
127 public function set($setting, $value, $write = false, $isLoggedIn = false) 132 public function set($setting, $value, $write = false, $isLoggedIn = false)
128 { 133 {
129 if (empty($setting) || ! is_string($setting)) { 134 if (empty($setting) || ! is_string($setting)) {
130 throw new Exception('Invalid setting key parameter. String expected, got: '. gettype($setting)); 135 throw new \Exception(t('Invalid setting key parameter. String expected, got: '). gettype($setting));
131 } 136 }
132 137
133 // During the ConfigIO transition, map legacy settings to the new ones. 138 // During the ConfigIO transition, map legacy settings to the new ones.
@@ -175,7 +180,7 @@ class ConfigManager
175 * 180 *
176 * @throws MissingFieldConfigException: a mandatory field has not been provided in $conf. 181 * @throws MissingFieldConfigException: a mandatory field has not been provided in $conf.
177 * @throws UnauthorizedConfigException: user is not authorize to change configuration. 182 * @throws UnauthorizedConfigException: user is not authorize to change configuration.
178 * @throws IOException: an error occurred while writing the new config file. 183 * @throws \IOException: an error occurred while writing the new config file.
179 */ 184 */
180 public function write($isLoggedIn) 185 public function write($isLoggedIn)
181 { 186 {
@@ -296,7 +301,9 @@ class ConfigManager
296 $this->setEmpty('resource.updates', 'data/updates.txt'); 301 $this->setEmpty('resource.updates', 'data/updates.txt');
297 $this->setEmpty('resource.log', 'data/log.txt'); 302 $this->setEmpty('resource.log', 'data/log.txt');
298 $this->setEmpty('resource.update_check', 'data/lastupdatecheck.txt'); 303 $this->setEmpty('resource.update_check', 'data/lastupdatecheck.txt');
304 $this->setEmpty('resource.history', 'data/history.php');
299 $this->setEmpty('resource.raintpl_tpl', 'tpl/'); 305 $this->setEmpty('resource.raintpl_tpl', 'tpl/');
306 $this->setEmpty('resource.theme', 'default');
300 $this->setEmpty('resource.raintpl_tmp', 'tmp/'); 307 $this->setEmpty('resource.raintpl_tmp', 'tmp/');
301 $this->setEmpty('resource.thumbnails_cache', 'cache'); 308 $this->setEmpty('resource.thumbnails_cache', 'cache');
302 $this->setEmpty('resource.page_cache', 'pagecache'); 309 $this->setEmpty('resource.page_cache', 'pagecache');
@@ -305,21 +312,26 @@ class ConfigManager
305 $this->setEmpty('security.ban_duration', 1800); 312 $this->setEmpty('security.ban_duration', 1800);
306 $this->setEmpty('security.session_protection_disabled', false); 313 $this->setEmpty('security.session_protection_disabled', false);
307 $this->setEmpty('security.open_shaarli', false); 314 $this->setEmpty('security.open_shaarli', false);
315 $this->setEmpty('security.allowed_protocols', ['ftp', 'ftps', 'magnet']);
308 316
309 $this->setEmpty('general.header_link', '?'); 317 $this->setEmpty('general.header_link', '?');
310 $this->setEmpty('general.links_per_page', 20); 318 $this->setEmpty('general.links_per_page', 20);
311 $this->setEmpty('general.enabled_plugins', array('qrcode')); 319 $this->setEmpty('general.enabled_plugins', self::$DEFAULT_PLUGINS);
320 $this->setEmpty('general.default_note_title', 'Note: ');
312 321
313 $this->setEmpty('updates.check_updates', false); 322 $this->setEmpty('updates.check_updates', false);
314 $this->setEmpty('updates.check_updates_branch', 'stable'); 323 $this->setEmpty('updates.check_updates_branch', 'stable');
315 $this->setEmpty('updates.check_updates_interval', 86400); 324 $this->setEmpty('updates.check_updates_interval', 86400);
316 325
317 $this->setEmpty('feed.rss_permalinks', true); 326 $this->setEmpty('feed.rss_permalinks', true);
318 $this->setEmpty('feed.show_atom', false); 327 $this->setEmpty('feed.show_atom', true);
319 328
320 $this->setEmpty('privacy.default_private_links', false); 329 $this->setEmpty('privacy.default_private_links', false);
321 $this->setEmpty('privacy.hide_public_links', false); 330 $this->setEmpty('privacy.hide_public_links', false);
331 $this->setEmpty('privacy.force_login', false);
322 $this->setEmpty('privacy.hide_timestamps', false); 332 $this->setEmpty('privacy.hide_timestamps', false);
333 // default state of the 'remember me' checkbox of the login form
334 $this->setEmpty('privacy.remember_user_default', true);
323 335
324 $this->setEmpty('thumbnail.enable_thumbnails', true); 336 $this->setEmpty('thumbnail.enable_thumbnails', true);
325 $this->setEmpty('thumbnail.enable_localcache', true); 337 $this->setEmpty('thumbnail.enable_localcache', true);
@@ -327,6 +339,10 @@ class ConfigManager
327 $this->setEmpty('redirector.url', ''); 339 $this->setEmpty('redirector.url', '');
328 $this->setEmpty('redirector.encode_url', true); 340 $this->setEmpty('redirector.encode_url', true);
329 341
342 $this->setEmpty('translation.language', 'auto');
343 $this->setEmpty('translation.mode', 'php');
344 $this->setEmpty('translation.extensions', []);
345
330 $this->setEmpty('plugins', array()); 346 $this->setEmpty('plugins', array());
331 } 347 }
332 348
@@ -359,36 +375,3 @@ class ConfigManager
359 $this->configIO = $configIO; 375 $this->configIO = $configIO;
360 } 376 }
361} 377}
362
363/**
364 * Exception used if a mandatory field is missing in given configuration.
365 */
366class MissingFieldConfigException extends Exception
367{
368 public $field;
369
370 /**
371 * Construct exception.
372 *
373 * @param string $field field name missing.
374 */
375 public function __construct($field)
376 {
377 $this->field = $field;
378 $this->message = 'Configuration value is required for '. $this->field;
379 }
380}
381
382/**
383 * Exception used if an unauthorized attempt to edit configuration has been made.
384 */
385class UnauthorizedConfigException extends Exception
386{
387 /**
388 * Construct exception.
389 */
390 public function __construct()
391 {
392 $this->message = 'You are not authorized to alter config.';
393 }
394}
diff --git a/application/config/ConfigPhp.php b/application/config/ConfigPhp.php
index 27187b66..8add8bcd 100644
--- a/application/config/ConfigPhp.php
+++ b/application/config/ConfigPhp.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2namespace Shaarli\Config;
2 3
3/** 4/**
4 * Class ConfigPhp (ConfigIO implementation) 5 * Class ConfigPhp (ConfigIO implementation)
@@ -41,6 +42,7 @@ class ConfigPhp implements ConfigIO
41 'resource.log' => 'config.LOG_FILE', 42 'resource.log' => 'config.LOG_FILE',
42 'resource.update_check' => 'config.UPDATECHECK_FILENAME', 43 'resource.update_check' => 'config.UPDATECHECK_FILENAME',
43 'resource.raintpl_tpl' => 'config.RAINTPL_TPL', 44 'resource.raintpl_tpl' => 'config.RAINTPL_TPL',
45 'resource.theme' => 'config.theme',
44 'resource.raintpl_tmp' => 'config.RAINTPL_TMP', 46 'resource.raintpl_tmp' => 'config.RAINTPL_TMP',
45 'resource.thumbnails_cache' => 'config.CACHEDIR', 47 'resource.thumbnails_cache' => 'config.CACHEDIR',
46 'resource.page_cache' => 'config.PAGECACHE', 48 'resource.page_cache' => 'config.PAGECACHE',
@@ -71,7 +73,7 @@ class ConfigPhp implements ConfigIO
71 /** 73 /**
72 * @inheritdoc 74 * @inheritdoc
73 */ 75 */
74 function read($filepath) 76 public function read($filepath)
75 { 77 {
76 if (! file_exists($filepath) || ! is_readable($filepath)) { 78 if (! file_exists($filepath) || ! is_readable($filepath)) {
77 return array(); 79 return array();
@@ -81,17 +83,17 @@ class ConfigPhp implements ConfigIO
81 83
82 $out = array(); 84 $out = array();
83 foreach (self::$ROOT_KEYS as $key) { 85 foreach (self::$ROOT_KEYS as $key) {
84 $out[$key] = $GLOBALS[$key]; 86 $out[$key] = isset($GLOBALS[$key]) ? $GLOBALS[$key] : '';
85 } 87 }
86 $out['config'] = $GLOBALS['config']; 88 $out['config'] = isset($GLOBALS['config']) ? $GLOBALS['config'] : [];
87 $out['plugins'] = !empty($GLOBALS['plugins']) ? $GLOBALS['plugins'] : array(); 89 $out['plugins'] = isset($GLOBALS['plugins']) ? $GLOBALS['plugins'] : [];
88 return $out; 90 return $out;
89 } 91 }
90 92
91 /** 93 /**
92 * @inheritdoc 94 * @inheritdoc
93 */ 95 */
94 function write($filepath, $conf) 96 public function write($filepath, $conf)
95 { 97 {
96 $configStr = '<?php '. PHP_EOL; 98 $configStr = '<?php '. PHP_EOL;
97 foreach (self::$ROOT_KEYS as $key) { 99 foreach (self::$ROOT_KEYS as $key) {
@@ -99,7 +101,7 @@ class ConfigPhp implements ConfigIO
99 $configStr .= '$GLOBALS[\'' . $key . '\'] = ' . var_export($conf[$key], true) . ';' . PHP_EOL; 101 $configStr .= '$GLOBALS[\'' . $key . '\'] = ' . var_export($conf[$key], true) . ';' . PHP_EOL;
100 } 102 }
101 } 103 }
102 104
103 // Store all $conf['config'] 105 // Store all $conf['config']
104 foreach ($conf['config'] as $key => $value) { 106 foreach ($conf['config'] as $key => $value) {
105 $configStr .= '$GLOBALS[\'config\'][\''. $key .'\'] = '.var_export($conf['config'][$key], true).';'. PHP_EOL; 107 $configStr .= '$GLOBALS[\'config\'][\''. $key .'\'] = '.var_export($conf['config'][$key], true).';'. PHP_EOL;
@@ -114,10 +116,10 @@ class ConfigPhp implements ConfigIO
114 if (!file_put_contents($filepath, $configStr) 116 if (!file_put_contents($filepath, $configStr)
115 || strcmp(file_get_contents($filepath), $configStr) != 0 117 || strcmp(file_get_contents($filepath), $configStr) != 0
116 ) { 118 ) {
117 throw new IOException( 119 throw new \IOException(
118 $filepath, 120 $filepath,
119 'Shaarli could not create the config file. 121 t('Shaarli could not create the config file. '.
120 Please make sure Shaarli has the right to write in the folder is it installed in.' 122 'Please make sure Shaarli has the right to write in the folder is it installed in.')
121 ); 123 );
122 } 124 }
123 } 125 }
@@ -125,7 +127,7 @@ class ConfigPhp implements ConfigIO
125 /** 127 /**
126 * @inheritdoc 128 * @inheritdoc
127 */ 129 */
128 function getExtension() 130 public function getExtension()
129 { 131 {
130 return '.php'; 132 return '.php';
131 } 133 }
diff --git a/application/config/ConfigPlugin.php b/application/config/ConfigPlugin.php
index cb0b6fce..b3d9752b 100644
--- a/application/config/ConfigPlugin.php
+++ b/application/config/ConfigPlugin.php
@@ -1,4 +1,7 @@
1<?php 1<?php
2
3use Shaarli\Config\Exception\PluginConfigOrderException;
4
2/** 5/**
3 * Plugin configuration helper functions. 6 * Plugin configuration helper functions.
4 * 7 *
@@ -108,17 +111,3 @@ function load_plugin_parameter_values($plugins, $conf)
108 111
109 return $out; 112 return $out;
110} 113}
111
112/**
113 * Exception used if an error occur while saving plugin configuration.
114 */
115class PluginConfigOrderException extends Exception
116{
117 /**
118 * Construct exception.
119 */
120 public function __construct()
121 {
122 $this->message = 'An error occurred while trying to save plugins loading order.';
123 }
124}
diff --git a/application/config/exception/MissingFieldConfigException.php b/application/config/exception/MissingFieldConfigException.php
new file mode 100644
index 00000000..9e0a9359
--- /dev/null
+++ b/application/config/exception/MissingFieldConfigException.php
@@ -0,0 +1,23 @@
1<?php
2
3
4namespace Shaarli\Config\Exception;
5
6/**
7 * Exception used if a mandatory field is missing in given configuration.
8 */
9class MissingFieldConfigException extends \Exception
10{
11 public $field;
12
13 /**
14 * Construct exception.
15 *
16 * @param string $field field name missing.
17 */
18 public function __construct($field)
19 {
20 $this->field = $field;
21 $this->message = sprintf(t('Configuration value is required for %s'), $this->field);
22 }
23}
diff --git a/application/config/exception/PluginConfigOrderException.php b/application/config/exception/PluginConfigOrderException.php
new file mode 100644
index 00000000..f82ec26e
--- /dev/null
+++ b/application/config/exception/PluginConfigOrderException.php
@@ -0,0 +1,17 @@
1<?php
2
3namespace Shaarli\Config\Exception;
4
5/**
6 * Exception used if an error occur while saving plugin configuration.
7 */
8class PluginConfigOrderException extends \Exception
9{
10 /**
11 * Construct exception.
12 */
13 public function __construct()
14 {
15 $this->message = t('An error occurred while trying to save plugins loading order.');
16 }
17}
diff --git a/application/config/exception/UnauthorizedConfigException.php b/application/config/exception/UnauthorizedConfigException.php
new file mode 100644
index 00000000..72311fae
--- /dev/null
+++ b/application/config/exception/UnauthorizedConfigException.php
@@ -0,0 +1,18 @@
1<?php
2
3
4namespace Shaarli\Config\Exception;
5
6/**
7 * Exception used if an unauthorized attempt to edit configuration has been made.
8 */
9class UnauthorizedConfigException extends \Exception
10{
11 /**
12 * Construct exception.
13 */
14 public function __construct()
15 {
16 $this->message = t('You are not authorized to alter config.');
17 }
18}
diff --git a/application/exceptions/IOException.php b/application/exceptions/IOException.php
new file mode 100644
index 00000000..18e46b77
--- /dev/null
+++ b/application/exceptions/IOException.php
@@ -0,0 +1,22 @@
1<?php
2
3/**
4 * Exception class thrown when a filesystem access failure happens
5 */
6class IOException extends Exception
7{
8 private $path;
9
10 /**
11 * Construct a new IOException
12 *
13 * @param string $path path to the resource that cannot be accessed
14 * @param string $message Custom exception message.
15 */
16 public function __construct($path, $message = '')
17 {
18 $this->path = $path;
19 $this->message = empty($message) ? t('Error accessing') : $message;
20 $this->message .= ' "' . $this->path .'"';
21 }
22}