diff options
Diffstat (limited to 'application')
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 | */ |
5 | class ApplicationUtils | 5 | class 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 | |||
3 | namespace Shaarli; | ||
4 | |||
5 | |||
6 | /** | ||
7 | * URL-safe Base64 operations | ||
8 | * | ||
9 | * @see https://en.wikipedia.org/wiki/Base64#URL_applications | ||
10 | */ | ||
11 | class 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 @@ | |||
13 | function purgeCachedPages($pageCacheDir) | 13 | function 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>— '. $permalink; | 156 | $link['description'] .= PHP_EOL .'<br>— '. $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 | |||
3 | require_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 | */ |
5 | class IOException extends Exception | 10 | class 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 | */ | ||
23 | class 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 | */ |
32 | function get_http_response($url, $timeout = 30, $maxBytes = 4194304) | 34 | function 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 | */ | ||
426 | function 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 | ||
3 | namespace Shaarli; | ||
4 | |||
5 | use Gettext\GettextTranslator; | ||
6 | use Gettext\Merge; | ||
7 | use Gettext\Translations; | ||
8 | use Gettext\Translator; | ||
9 | use Gettext\TranslatorInterface; | ||
10 | use 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 | */ |
15 | function t($text, $nText = '', $nb = 0) { | 40 | class 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 | ||
261 | To learn how to use Shaarli, consult the link "Help/documentation" at the bottom of this page. | 255 | To learn how to use Shaarli, consult the link "Documentation" at the bottom of this page. |
262 | 256 | ||
263 | You use the community supported version of the original Shaarli project, by Sebastien Sauvage.', | 257 | You 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 | ||
355 | class LinkNotFoundException extends Exception | 445 | class 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 | */ |
10 | function html_extract_title($html) | 12 | function 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 | */ |
31 | function get_charset($headers, $htmlContent, $defaultCharset = 'utf-8') | 59 | function 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 | */ |
51 | function headers_extract_charset($headers) | 74 | function 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 | */ |
108 | function text2clickable($text, $redirector = '') | 132 | function 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 | */ |
169 | function format_description($description, $redirector = '', $indexUrl = '') { | 195 | function 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 | ||
3 | use Psr\Log\LogLevel; | ||
4 | use Shaarli\Config\ConfigManager; | ||
5 | use Shaarli\NetscapeBookmarkParser\NetscapeBookmarkParser; | ||
6 | use 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 | */ |
6 | class NetscapeBookmarkUtils | 12 | class 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 | ||
3 | use 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 | ||
2 | namespace Shaarli; | ||
3 | |||
4 | /** | ||
5 | * Manages the server-side session | ||
6 | */ | ||
7 | class 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 | |||
3 | namespace Shaarli; | ||
4 | |||
5 | /** | ||
6 | * Class ThemeUtils | ||
7 | * | ||
8 | * Utility functions related to theme management. | ||
9 | * | ||
10 | * @package Shaarli | ||
11 | */ | ||
12 | class 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 | **/ |
14 | function generateTimeZoneForm($preselectedTimezone='') | 38 | function 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 .= ' 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 |
2 | use Shaarli\Config\ConfigJson; | ||
3 | use Shaarli\Config\ConfigPhp; | ||
4 | use 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) | |||
364 | function write_updates_file($updatesFilepath, $updates) | 533 | function 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 | */ | ||
74 | function 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 | */ |
92 | function escape($input) | 92 | function 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 | **/ | ||
190 | function 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 | */ |
193 | function is_session_id_valid($sessionId) | 236 | function 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 | */ | ||
269 | function 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 | */ | ||
286 | function 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 | */ | ||
303 | function 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 | */ | ||
334 | function 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 | */ | ||
350 | function 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 | * |
216 | function autoLocale($headerLocale) | 371 | * @return string Human readable size |
372 | */ | ||
373 | function 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 | */ | ||
404 | function 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 | */ | ||
423 | function 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 | */ | ||
455 | function 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 | ||
2 | namespace Shaarli\Api; | ||
3 | |||
4 | use Shaarli\Api\Exceptions\ApiException; | ||
5 | use Shaarli\Api\Exceptions\ApiAuthorizationException; | ||
6 | |||
7 | use Shaarli\Config\ConfigManager; | ||
8 | use Slim\Container; | ||
9 | use Slim\Http\Request; | ||
10 | use 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 | */ | ||
22 | class 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 | ||
2 | namespace Shaarli\Api; | ||
3 | |||
4 | use Shaarli\Base64Url; | ||
5 | use Shaarli\Api\Exceptions\ApiAuthorizationException; | ||
6 | |||
7 | /** | ||
8 | * REST API utilities | ||
9 | */ | ||
10 | class 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 | |||
3 | namespace Shaarli\Api\Controllers; | ||
4 | |||
5 | use Shaarli\Config\ConfigManager; | ||
6 | use \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 | */ | ||
15 | abstract 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 | |||
4 | namespace Shaarli\Api\Controllers; | ||
5 | |||
6 | use Shaarli\Api\Exceptions\ApiBadParametersException; | ||
7 | use Slim\Http\Request; | ||
8 | use Slim\Http\Response; | ||
9 | |||
10 | /** | ||
11 | * Class History | ||
12 | * | ||
13 | * REST API Controller: /history | ||
14 | * | ||
15 | * @package Shaarli\Api\Controllers | ||
16 | */ | ||
17 | class 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 | |||
3 | namespace Shaarli\Api\Controllers; | ||
4 | |||
5 | use Slim\Http\Request; | ||
6 | use 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 | */ | ||
16 | class 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 | |||
3 | namespace Shaarli\Api\Controllers; | ||
4 | |||
5 | use Shaarli\Api\ApiUtils; | ||
6 | use Shaarli\Api\Exceptions\ApiBadParametersException; | ||
7 | use Shaarli\Api\Exceptions\ApiLinkNotFoundException; | ||
8 | use Slim\Http\Request; | ||
9 | use 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 | */ | ||
19 | class 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 | |||
3 | namespace Shaarli\Api\Exceptions; | ||
4 | |||
5 | /** | ||
6 | * Class ApiAuthorizationException | ||
7 | * | ||
8 | * Request not authorized, return a 401 HTTP code. | ||
9 | */ | ||
10 | class 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 | |||
3 | namespace Shaarli\Api\Exceptions; | ||
4 | |||
5 | /** | ||
6 | * Class ApiBadParametersException | ||
7 | * | ||
8 | * Invalid request exception, return a 400 HTTP code. | ||
9 | */ | ||
10 | class 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 | |||
3 | namespace Shaarli\Api\Exceptions; | ||
4 | |||
5 | use 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 | */ | ||
13 | abstract 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 | |||
3 | namespace Shaarli\Api\Exceptions; | ||
4 | |||
5 | /** | ||
6 | * Class ApiInternalException | ||
7 | * | ||
8 | * Generic exception, return a 500 HTTP code. | ||
9 | */ | ||
10 | class 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 | |||
3 | namespace Shaarli\Api\Exceptions; | ||
4 | |||
5 | |||
6 | use 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 | */ | ||
15 | class 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 |
2 | namespace 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 |
2 | namespace 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 |
2 | namespace Shaarli\Config; | ||
2 | 3 | ||
3 | // FIXME! Namespaces... | 4 | use Shaarli\Config\Exception\MissingFieldConfigException; |
4 | require_once 'ConfigIO.php'; | 5 | use Shaarli\Config\Exception\UnauthorizedConfigException; |
5 | require_once 'ConfigJson.php'; | ||
6 | require_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 | */ |
16 | class ConfigManager | 15 | class 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 | */ | ||
366 | class 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 | */ | ||
385 | class 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 |
2 | namespace 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 | |||
3 | use 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 | */ | ||
115 | class 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 | |||
4 | namespace Shaarli\Config\Exception; | ||
5 | |||
6 | /** | ||
7 | * Exception used if a mandatory field is missing in given configuration. | ||
8 | */ | ||
9 | class 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 | |||
3 | namespace Shaarli\Config\Exception; | ||
4 | |||
5 | /** | ||
6 | * Exception used if an error occur while saving plugin configuration. | ||
7 | */ | ||
8 | class 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 | |||
4 | namespace Shaarli\Config\Exception; | ||
5 | |||
6 | /** | ||
7 | * Exception used if an unauthorized attempt to edit configuration has been made. | ||
8 | */ | ||
9 | class 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 | */ | ||
6 | class 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 | } | ||