aboutsummaryrefslogtreecommitdiffhomepage
path: root/application
diff options
context:
space:
mode:
Diffstat (limited to 'application')
-rw-r--r--application/ApplicationUtils.php46
-rw-r--r--application/Base64Url.php34
-rw-r--r--application/CachedPage.php4
-rw-r--r--application/FeedBuilder.php16
-rw-r--r--application/FileUtils.php75
-rw-r--r--application/History.php200
-rw-r--r--application/HttpUtils.php14
-rw-r--r--application/LinkDB.php41
-rw-r--r--application/LinkFilter.php62
-rw-r--r--application/LinkUtils.php4
-rw-r--r--application/NetscapeBookmarkUtils.php38
-rw-r--r--application/PageBuilder.php19
-rw-r--r--application/Router.php6
-rw-r--r--application/ThemeUtils.php33
-rw-r--r--application/TimeZone.php101
-rw-r--r--application/Updater.php205
-rw-r--r--application/Url.php5
-rw-r--r--application/Utils.php228
-rw-r--r--application/api/ApiMiddleware.php138
-rw-r--r--application/api/ApiUtils.php137
-rw-r--r--application/api/controllers/ApiController.php71
-rw-r--r--application/api/controllers/History.php70
-rw-r--r--application/api/controllers/Info.php42
-rw-r--r--application/api/controllers/Links.php217
-rw-r--r--application/api/exceptions/ApiAuthorizationException.php34
-rw-r--r--application/api/exceptions/ApiBadParametersException.php19
-rw-r--r--application/api/exceptions/ApiException.php77
-rw-r--r--application/api/exceptions/ApiInternalException.php19
-rw-r--r--application/api/exceptions/ApiLinkNotFoundException.php32
-rw-r--r--application/config/ConfigIO.php7
-rw-r--r--application/config/ConfigJson.php19
-rw-r--r--application/config/ConfigManager.php60
-rw-r--r--application/config/ConfigPhp.php12
-rw-r--r--application/config/ConfigPlugin.php17
-rw-r--r--application/config/exception/MissingFieldConfigException.php23
-rw-r--r--application/config/exception/PluginConfigOrderException.php17
-rw-r--r--application/config/exception/UnauthorizedConfigException.php18
-rw-r--r--application/exceptions/IOException.php22
38 files changed, 1911 insertions, 271 deletions
diff --git a/application/ApplicationUtils.php b/application/ApplicationUtils.php
index 7f963e97..85dcbeeb 100644
--- a/application/ApplicationUtils.php
+++ b/application/ApplicationUtils.php
@@ -4,9 +4,13 @@
4 */ 4 */
5class ApplicationUtils 5class ApplicationUtils
6{ 6{
7 /**
8 * @var string File containing the current version
9 */
10 public static $VERSION_FILE = 'shaarli_version.php';
11
7 private static $GIT_URL = 'https://raw.githubusercontent.com/shaarli/Shaarli'; 12 private static $GIT_URL = 'https://raw.githubusercontent.com/shaarli/Shaarli';
8 private static $GIT_BRANCHES = array('master', 'stable'); 13 private static $GIT_BRANCHES = array('latest', 'stable');
9 private static $VERSION_FILE = 'shaarli_version.php';
10 private static $VERSION_START_TAG = '<?php /* '; 14 private static $VERSION_START_TAG = '<?php /* ';
11 private static $VERSION_END_TAG = ' */ ?>'; 15 private static $VERSION_END_TAG = ' */ ?>';
12 16
@@ -29,6 +33,30 @@ class ApplicationUtils
29 return false; 33 return false;
30 } 34 }
31 35
36 return $data;
37 }
38
39 /**
40 * Retrieve the version from a remote URL or a file.
41 *
42 * @param string $remote URL or file to fetch.
43 * @param int $timeout For URLs fetching.
44 *
45 * @return bool|string The version or false if it couldn't be retrieved.
46 */
47 public static function getVersion($remote, $timeout = 2)
48 {
49 if (startsWith($remote, 'http')) {
50 if (($data = static::getLatestGitVersionCode($remote, $timeout)) === false) {
51 return false;
52 }
53 } else {
54 if (! is_file($remote)) {
55 return false;
56 }
57 $data = file_get_contents($remote);
58 }
59
32 return str_replace( 60 return str_replace(
33 array(self::$VERSION_START_TAG, self::$VERSION_END_TAG, PHP_EOL), 61 array(self::$VERSION_START_TAG, self::$VERSION_END_TAG, PHP_EOL),
34 array('', '', ''), 62 array('', '', ''),
@@ -65,13 +93,10 @@ class ApplicationUtils
65 $isLoggedIn, 93 $isLoggedIn,
66 $branch='stable') 94 $branch='stable')
67 { 95 {
68 if (! $isLoggedIn) { 96 // Do not check versions for visitors
69 // Do not check versions for visitors 97 // Do not check if the user doesn't want to
70 return false; 98 // Do not check with dev version
71 } 99 if (! $isLoggedIn || empty($enableCheck) || $currentVersion === 'dev') {
72
73 if (empty($enableCheck)) {
74 // Do not check if the user doesn't want to
75 return false; 100 return false;
76 } 101 }
77 102
@@ -93,7 +118,7 @@ class ApplicationUtils
93 118
94 // Late Static Binding allows overriding within tests 119 // Late Static Binding allows overriding within tests
95 // See http://php.net/manual/en/language.oop5.late-static-bindings.php 120 // See http://php.net/manual/en/language.oop5.late-static-bindings.php
96 $latestVersion = static::getLatestGitVersionCode( 121 $latestVersion = static::getVersion(
97 self::$GIT_URL . '/' . $branch . '/' . self::$VERSION_FILE 122 self::$GIT_URL . '/' . $branch . '/' . self::$VERSION_FILE
98 ); 123 );
99 124
@@ -150,6 +175,7 @@ class ApplicationUtils
150 'inc', 175 'inc',
151 'plugins', 176 'plugins',
152 $conf->get('resource.raintpl_tpl'), 177 $conf->get('resource.raintpl_tpl'),
178 $conf->get('resource.raintpl_tpl').'/'.$conf->get('resource.theme'),
153 ) as $path) { 179 ) as $path) {
154 if (! is_readable(realpath($path))) { 180 if (! is_readable(realpath($path))) {
155 $errors[] = '"'.$path.'" directory is not readable'; 181 $errors[] = '"'.$path.'" directory is not readable';
diff --git a/application/Base64Url.php b/application/Base64Url.php
new file mode 100644
index 00000000..61590e43
--- /dev/null
+++ b/application/Base64Url.php
@@ -0,0 +1,34 @@
1<?php
2
3namespace Shaarli;
4
5
6/**
7 * URL-safe Base64 operations
8 *
9 * @see https://en.wikipedia.org/wiki/Base64#URL_applications
10 */
11class Base64Url
12{
13 /**
14 * Base64Url-encodes data
15 *
16 * @param string $data Data to encode
17 *
18 * @return string Base64Url-encoded data
19 */
20 public static function encode($data) {
21 return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
22 }
23
24 /**
25 * Decodes Base64Url-encoded data
26 *
27 * @param string $data Data to decode
28 *
29 * @return string Decoded data
30 */
31 public static function decode($data) {
32 return base64_decode(str_pad(strtr($data, '-_', '+/'), strlen($data) % 4, '=', STR_PAD_RIGHT));
33 }
34}
diff --git a/application/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..a1f4da48 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;
@@ -120,7 +115,6 @@ class FeedBuilder
120 } 115 }
121 116
122 $data['language'] = $this->getTypeLanguage(); 117 $data['language'] = $this->getTypeLanguage();
123 $data['pubsubhub_url'] = $this->pubsubhubUrl;
124 $data['last_update'] = $this->getLatestDateFormatted(); 118 $data['last_update'] = $this->getLatestDateFormatted();
125 $data['show_dates'] = !$this->hideDates || $this->isLoggedIn; 119 $data['show_dates'] = !$this->hideDates || $this->isLoggedIn;
126 // Remove leading slash from REQUEST_URI. 120 // Remove leading slash from REQUEST_URI.
@@ -183,16 +177,6 @@ class FeedBuilder
183 } 177 }
184 178
185 /** 179 /**
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. 180 * Set this to true to use permalinks instead of direct links.
197 * 181 *
198 * @param boolean $usePermalinks true to force permalinks. 182 * @param boolean $usePermalinks true to force permalinks.
diff --git a/application/FileUtils.php b/application/FileUtils.php
index 6cac9825..a167f642 100644
--- a/application/FileUtils.php
+++ b/application/FileUtils.php
@@ -1,21 +1,76 @@
1<?php 1<?php
2
3require_once 'exceptions/IOException.php';
4
2/** 5/**
3 * Exception class thrown when a filesystem access failure happens 6 * Class FileUtils
7 *
8 * Utility class for file manipulation.
4 */ 9 */
5class IOException extends Exception 10class FileUtils
6{ 11{
7 private $path; 12 /**
13 * @var string
14 */
15 protected static $phpPrefix = '<?php /* ';
16
17 /**
18 * @var string
19 */
20 protected static $phpSuffix = ' */ ?>';
8 21
9 /** 22 /**
10 * Construct a new IOException 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.
30 *
31 * @return int|bool Number of bytes written or false if it fails.
11 * 32 *
12 * @param string $path path to the resource that cannot be accessed 33 * @throws IOException The destination file can't be written.
13 * @param string $message Custom exception message.
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 * If the file isn't readable or doesn't exists, default data will be returned.
54 *
55 * @param string $file File path.
56 * @param mixed $default The default value to return if the file isn't readable.
57 *
58 * @return mixed The content unserialized, or default if the file isn't readable, or false if it fails.
59 */
60 public static function readFlatDB($file, $default = null)
61 {
62 // Note that gzinflate is faster than gzuncompress.
63 // See: http://www.php.net/manual/en/function.gzdeflate.php#96439
64 if (is_readable($file)) {
65 return unserialize(
66 gzinflate(
67 base64_decode(
68 substr(file_get_contents($file), strlen(self::$phpPrefix), -strlen(self::$phpSuffix))
69 )
70 )
71 );
72 }
73
74 return $default;
20 } 75 }
21} 76}
diff --git a/application/History.php b/application/History.php
new file mode 100644
index 00000000..116b9264
--- /dev/null
+++ b/application/History.php
@@ -0,0 +1,200 @@
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 *
20 * Note: new events are put at the beginning of the file and history array.
21 */
22class History
23{
24 /**
25 * @var string Action key: a new link has been created.
26 */
27 const CREATED = 'CREATED';
28
29 /**
30 * @var string Action key: a link has been updated.
31 */
32 const UPDATED = 'UPDATED';
33
34 /**
35 * @var string Action key: a link has been deleted.
36 */
37 const DELETED = 'DELETED';
38
39 /**
40 * @var string Action key: settings have been updated.
41 */
42 const SETTINGS = 'SETTINGS';
43
44 /**
45 * @var string History file path.
46 */
47 protected $historyFilePath;
48
49 /**
50 * @var array History data.
51 */
52 protected $history;
53
54 /**
55 * @var int History retention time in seconds (1 month).
56 */
57 protected $retentionTime = 2678400;
58
59 /**
60 * History constructor.
61 *
62 * @param string $historyFilePath History file path.
63 * @param int $retentionTime History content rentention time in seconds.
64 *
65 * @throws Exception if something goes wrong.
66 */
67 public function __construct($historyFilePath, $retentionTime = null)
68 {
69 $this->historyFilePath = $historyFilePath;
70 if ($retentionTime !== null) {
71 $this->retentionTime = $retentionTime;
72 }
73 }
74
75 /**
76 * Initialize: read history file.
77 *
78 * Allow lazy loading (don't read the file if it isn't necessary).
79 */
80 protected function initialize()
81 {
82 $this->check();
83 $this->read();
84 }
85
86 /**
87 * Add Event: new link.
88 *
89 * @param array $link Link data.
90 */
91 public function addLink($link)
92 {
93 $this->addEvent(self::CREATED, $link['id']);
94 }
95
96 /**
97 * Add Event: update existing link.
98 *
99 * @param array $link Link data.
100 */
101 public function updateLink($link)
102 {
103 $this->addEvent(self::UPDATED, $link['id']);
104 }
105
106 /**
107 * Add Event: delete existing link.
108 *
109 * @param array $link Link data.
110 */
111 public function deleteLink($link)
112 {
113 $this->addEvent(self::DELETED, $link['id']);
114 }
115
116 /**
117 * Add Event: settings updated.
118 */
119 public function updateSettings()
120 {
121 $this->addEvent(self::SETTINGS);
122 }
123
124 /**
125 * Save a new event and write it in the history file.
126 *
127 * @param string $status Event key, should be defined as constant.
128 * @param mixed $id Event item identifier (e.g. link ID).
129 */
130 protected function addEvent($status, $id = null)
131 {
132 if ($this->history === null) {
133 $this->initialize();
134 }
135
136 $item = [
137 'event' => $status,
138 'datetime' => new DateTime(),
139 'id' => $id !== null ? $id : '',
140 ];
141 $this->history = array_merge([$item], $this->history);
142 $this->write();
143 }
144
145 /**
146 * Check that the history file is writable.
147 * Create the file if it doesn't exist.
148 *
149 * @throws Exception if it isn't writable.
150 */
151 protected function check()
152 {
153 if (! is_file($this->historyFilePath)) {
154 FileUtils::writeFlatDB($this->historyFilePath, []);
155 }
156
157 if (! is_writable($this->historyFilePath)) {
158 throw new Exception('History file isn\'t readable or writable');
159 }
160 }
161
162 /**
163 * Read JSON history file.
164 */
165 protected function read()
166 {
167 $this->history = FileUtils::readFlatDB($this->historyFilePath, []);
168 if ($this->history === false) {
169 throw new Exception('Could not parse history file');
170 }
171 }
172
173 /**
174 * Write JSON history file and delete old entries.
175 */
176 protected function write()
177 {
178 $comparaison = new DateTime('-'. $this->retentionTime . ' seconds');
179 foreach ($this->history as $key => $value) {
180 if ($value['datetime'] < $comparaison) {
181 unset($this->history[$key]);
182 }
183 }
184 FileUtils::writeFlatDB($this->historyFilePath, array_values($this->history));
185 }
186
187 /**
188 * Get the History.
189 *
190 * @return array
191 */
192 public function getHistory()
193 {
194 if ($this->history === null) {
195 $this->initialize();
196 }
197
198 return $this->history;
199 }
200}
diff --git a/application/HttpUtils.php b/application/HttpUtils.php
index e705cfd6..a81f9056 100644
--- a/application/HttpUtils.php
+++ b/application/HttpUtils.php
@@ -122,7 +122,7 @@ function get_http_response($url, $timeout = 30, $maxBytes = 4194304)
122 $content = substr($response, $headSize); 122 $content = substr($response, $headSize);
123 $headers = array(); 123 $headers = array();
124 foreach (preg_split('~[\r\n]+~', $rawHeadersLastRedir) as $line) { 124 foreach (preg_split('~[\r\n]+~', $rawHeadersLastRedir) as $line) {
125 if (empty($line) or ctype_space($line)) { 125 if (empty($line) || ctype_space($line)) {
126 continue; 126 continue;
127 } 127 }
128 $splitLine = explode(': ', $line, 2); 128 $splitLine = explode(': ', $line, 2);
@@ -297,9 +297,17 @@ function server_url($server)
297 // Keep forwarded port 297 // Keep forwarded port
298 if (strpos($server['HTTP_X_FORWARDED_PORT'], ',') !== false) { 298 if (strpos($server['HTTP_X_FORWARDED_PORT'], ',') !== false) {
299 $ports = explode(',', $server['HTTP_X_FORWARDED_PORT']); 299 $ports = explode(',', $server['HTTP_X_FORWARDED_PORT']);
300 $port = ':' . trim($ports[0]); 300 $port = trim($ports[0]);
301 } else { 301 } else {
302 $port = ':' . $server['HTTP_X_FORWARDED_PORT']; 302 $port = $server['HTTP_X_FORWARDED_PORT'];
303 }
304
305 if (($scheme == 'http' && $port != '80')
306 || ($scheme == 'https' && $port != '443')
307 ) {
308 $port = ':' . $port;
309 } else {
310 $port = '';
303 } 311 }
304 } 312 }
305 313
diff --git a/application/LinkDB.php b/application/LinkDB.php
index 1e13286a..0d3c85bd 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...)
@@ -144,10 +138,10 @@ class LinkDB implements Iterator, Countable, ArrayAccess
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('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('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('Array offset and link ID must be equal.');
152 } 146 }
153 147
@@ -295,16 +289,7 @@ 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->links = FileUtils::readFlatDB($this->datastore, []);
299 // Note that gzinflate is faster than gzuncompress.
300 // See: http://www.php.net/manual/en/function.gzdeflate.php#96439
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 293
309 $toremove = array(); 294 $toremove = array();
310 foreach ($this->links as $key => &$link) { 295 foreach ($this->links as $key => &$link) {
@@ -361,19 +346,7 @@ You use the community supported version of the original Shaarli project, by Seba
361 */ 346 */
362 private function write() 347 private function write()
363 { 348 {
364 if (is_file($this->datastore) && !is_writeable($this->datastore)) { 349 FileUtils::writeFlatDB($this->datastore, $this->links);
365 // The datastore exists but is not writeable
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 } 350 }
378 351
379 /** 352 /**
@@ -443,11 +416,11 @@ You use the community supported version of the original Shaarli project, by Seba
443 * - searchtags: list of tags 416 * - searchtags: list of tags
444 * - searchterm: term search 417 * - searchterm: term search
445 * @param bool $casesensitive Optional: Perform case sensitive filter 418 * @param bool $casesensitive Optional: Perform case sensitive filter
446 * @param bool $privateonly Optional: Returns private links only if true. 419 * @param string $visibility return only all/private/public 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')
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 = !empty($filterRequest['searchtags']) ? escape($filterRequest['searchtags']) : '';
@@ -475,7 +448,7 @@ You use the community supported version of the original Shaarli project, by Seba
475 } 448 }
476 449
477 $linkFilter = new LinkFilter($this); 450 $linkFilter = new LinkFilter($this);
478 return $linkFilter->filter($type, $request, $casesensitive, $privateonly); 451 return $linkFilter->filter($type, $request, $casesensitive, $visibility);
479 } 452 }
480 453
481 /** 454 /**
diff --git a/application/LinkFilter.php b/application/LinkFilter.php
index daa6d9cc..81832a4b 100644
--- a/application/LinkFilter.php
+++ b/application/LinkFilter.php
@@ -51,12 +51,16 @@ 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 * 55 *
56 * @return array filtered link list. 56 * @return array filtered link list.
57 */ 57 */
58 public function filter($type, $request, $casesensitive = false, $privateonly = false) 58 public function filter($type, $request, $casesensitive = false, $visibility = 'all')
59 { 59 {
60 if (! in_array($visibility, ['all', 'public', 'private'])) {
61 $visibility = 'all';
62 }
63
60 switch($type) { 64 switch($type) {
61 case self::$FILTER_HASH: 65 case self::$FILTER_HASH:
62 return $this->filterSmallHash($request); 66 return $this->filterSmallHash($request);
@@ -64,42 +68,44 @@ class LinkFilter
64 if (!empty($request)) { 68 if (!empty($request)) {
65 $filtered = $this->links; 69 $filtered = $this->links;
66 if (isset($request[0])) { 70 if (isset($request[0])) {
67 $filtered = $this->filterTags($request[0], $casesensitive, $privateonly); 71 $filtered = $this->filterTags($request[0], $casesensitive, $visibility);
68 } 72 }
69 if (isset($request[1])) { 73 if (isset($request[1])) {
70 $lf = new LinkFilter($filtered); 74 $lf = new LinkFilter($filtered);
71 $filtered = $lf->filterFulltext($request[1], $privateonly); 75 $filtered = $lf->filterFulltext($request[1], $visibility);
72 } 76 }
73 return $filtered; 77 return $filtered;
74 } 78 }
75 return $this->noFilter($privateonly); 79 return $this->noFilter($visibility);
76 case self::$FILTER_TEXT: 80 case self::$FILTER_TEXT:
77 return $this->filterFulltext($request, $privateonly); 81 return $this->filterFulltext($request, $visibility);
78 case self::$FILTER_TAG: 82 case self::$FILTER_TAG:
79 return $this->filterTags($request, $casesensitive, $privateonly); 83 return $this->filterTags($request, $casesensitive, $visibility);
80 case self::$FILTER_DAY: 84 case self::$FILTER_DAY:
81 return $this->filterDay($request); 85 return $this->filterDay($request);
82 default: 86 default:
83 return $this->noFilter($privateonly); 87 return $this->noFilter($visibility);
84 } 88 }
85 } 89 }
86 90
87 /** 91 /**
88 * Unknown filter, but handle private only. 92 * Unknown filter, but handle private only.
89 * 93 *
90 * @param bool $privateonly returns private link only if true. 94 * @param string $visibility Optional: return only all/private/public links
91 * 95 *
92 * @return array filtered links. 96 * @return array filtered links.
93 */ 97 */
94 private function noFilter($privateonly = false) 98 private function noFilter($visibility = 'all')
95 { 99 {
96 if (! $privateonly) { 100 if ($visibility === 'all') {
97 return $this->links; 101 return $this->links;
98 } 102 }
99 103
100 $out = array(); 104 $out = array();
101 foreach ($this->links as $key => $value) { 105 foreach ($this->links as $key => $value) {
102 if ($value['private']) { 106 if ($value['private'] && $visibility === 'private') {
107 $out[$key] = $value;
108 } else if (! $value['private'] && $visibility === 'public') {
103 $out[$key] = $value; 109 $out[$key] = $value;
104 } 110 }
105 } 111 }
@@ -151,14 +157,14 @@ class LinkFilter
151 * - see https://github.com/shaarli/Shaarli/issues/75 for examples 157 * - see https://github.com/shaarli/Shaarli/issues/75 for examples
152 * 158 *
153 * @param string $searchterms search query. 159 * @param string $searchterms search query.
154 * @param bool $privateonly return only private links if true. 160 * @param string $visibility Optional: return only all/private/public links.
155 * 161 *
156 * @return array search results. 162 * @return array search results.
157 */ 163 */
158 private function filterFulltext($searchterms, $privateonly = false) 164 private function filterFulltext($searchterms, $visibility = 'all')
159 { 165 {
160 if (empty($searchterms)) { 166 if (empty($searchterms)) {
161 return $this->links; 167 return $this->noFilter($visibility);
162 } 168 }
163 169
164 $filtered = array(); 170 $filtered = array();
@@ -189,8 +195,12 @@ class LinkFilter
189 foreach ($this->links as $id => $link) { 195 foreach ($this->links as $id => $link) {
190 196
191 // ignore non private links when 'privatonly' is on. 197 // ignore non private links when 'privatonly' is on.
192 if (! $link['private'] && $privateonly === true) { 198 if ($visibility !== 'all') {
193 continue; 199 if (! $link['private'] && $visibility === 'private') {
200 continue;
201 } else if ($link['private'] && $visibility === 'public') {
202 continue;
203 }
194 } 204 }
195 205
196 // Concatenate link fields to search across fields. 206 // Concatenate link fields to search across fields.
@@ -235,16 +245,16 @@ class LinkFilter
235 * 245 *
236 * @param string $tags list of tags separated by commas or blank spaces. 246 * @param string $tags list of tags separated by commas or blank spaces.
237 * @param bool $casesensitive ignore case if false. 247 * @param bool $casesensitive ignore case if false.
238 * @param bool $privateonly returns private links only. 248 * @param string $visibility Optional: return only all/private/public links.
239 * 249 *
240 * @return array filtered links. 250 * @return array filtered links.
241 */ 251 */
242 public function filterTags($tags, $casesensitive = false, $privateonly = false) 252 public function filterTags($tags, $casesensitive = false, $visibility = 'all')
243 { 253 {
244 // Implode if array for clean up. 254 // Implode if array for clean up.
245 $tags = is_array($tags) ? trim(implode(' ', $tags)) : $tags; 255 $tags = is_array($tags) ? trim(implode(' ', $tags)) : $tags;
246 if (empty($tags)) { 256 if (empty($tags)) {
247 return $this->links; 257 return $this->noFilter($visibility);
248 } 258 }
249 259
250 $searchtags = self::tagsStrToArray($tags, $casesensitive); 260 $searchtags = self::tagsStrToArray($tags, $casesensitive);
@@ -255,8 +265,12 @@ class LinkFilter
255 265
256 foreach ($this->links as $key => $link) { 266 foreach ($this->links as $key => $link) {
257 // ignore non private links when 'privatonly' is on. 267 // ignore non private links when 'privatonly' is on.
258 if (! $link['private'] && $privateonly === true) { 268 if ($visibility !== 'all') {
259 continue; 269 if (! $link['private'] && $visibility === 'private') {
270 continue;
271 } else if ($link['private'] && $visibility === 'public') {
272 continue;
273 }
260 } 274 }
261 275
262 $linktags = self::tagsStrToArray($link['tags'], $casesensitive); 276 $linktags = self::tagsStrToArray($link['tags'], $casesensitive);
@@ -341,14 +355,14 @@ class LinkFilter
341 * @param bool $casesensitive will convert everything to lowercase if false. 355 * @param bool $casesensitive will convert everything to lowercase if false.
342 * 356 *
343 * @return array filtered tags string. 357 * @return array filtered tags string.
344 */ 358 */
345 public static function tagsStrToArray($tags, $casesensitive) 359 public static function tagsStrToArray($tags, $casesensitive)
346 { 360 {
347 // We use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek) 361 // 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'); 362 $tagsOut = $casesensitive ? $tags : mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8');
349 $tagsOut = str_replace(',', ' ', $tagsOut); 363 $tagsOut = str_replace(',', ' ', $tagsOut);
350 364
351 return array_values(array_filter(explode(' ', trim($tagsOut)), 'strlen')); 365 return preg_split('/\s+/', $tagsOut, -1, PREG_SPLIT_NO_EMPTY);
352 } 366 }
353} 367}
354 368
diff --git a/application/LinkUtils.php b/application/LinkUtils.php
index cf58f808..976474de 100644
--- a/application/LinkUtils.php
+++ b/application/LinkUtils.php
@@ -89,7 +89,9 @@ function count_private($links)
89{ 89{
90 $cpt = 0; 90 $cpt = 0;
91 foreach ($links as $link) { 91 foreach ($links as $link) {
92 $cpt = $link['private'] == true ? $cpt + 1 : $cpt; 92 if ($link['private']) {
93 $cpt += 1;
94 }
93 } 95 }
94 96
95 return $cpt; 97 return $cpt;
diff --git a/application/NetscapeBookmarkUtils.php b/application/NetscapeBookmarkUtils.php
index e7148d00..2a10ff22 100644
--- a/application/NetscapeBookmarkUtils.php
+++ b/application/NetscapeBookmarkUtils.php
@@ -1,7 +1,13 @@
1<?php 1<?php
2 2
3use Psr\Log\LogLevel;
4use Shaarli\Config\ConfigManager;
5use Shaarli\NetscapeBookmarkParser\NetscapeBookmarkParser;
6use Katzgrau\KLogger\Logger;
7
3/** 8/**
4 * Utilities to import and export bookmarks using the Netscape format 9 * Utilities to import and export bookmarks using the Netscape format
10 * TODO: Not static, use a container.
5 */ 11 */
6class NetscapeBookmarkUtils 12class NetscapeBookmarkUtils
7{ 13{
@@ -85,14 +91,15 @@ class NetscapeBookmarkUtils
85 /** 91 /**
86 * Imports Web bookmarks from an uploaded Netscape bookmark dump 92 * Imports Web bookmarks from an uploaded Netscape bookmark dump
87 * 93 *
88 * @param array $post Server $_POST parameters 94 * @param array $post Server $_POST parameters
89 * @param array $files Server $_FILES parameters 95 * @param array $files Server $_FILES parameters
90 * @param LinkDB $linkDb Loaded LinkDB instance 96 * @param LinkDB $linkDb Loaded LinkDB instance
91 * @param string $pagecache Page cache 97 * @param ConfigManager $conf instance
98 * @param History $history History instance
92 * 99 *
93 * @return string Summary of the bookmark import status 100 * @return string Summary of the bookmark import status
94 */ 101 */
95 public static function import($post, $files, $linkDb, $pagecache) 102 public static function import($post, $files, $linkDb, $conf, $history)
96 { 103 {
97 $filename = $files['filetoupload']['name']; 104 $filename = $files['filetoupload']['name'];
98 $filesize = $files['filetoupload']['size']; 105 $filesize = $files['filetoupload']['size'];
@@ -119,10 +126,20 @@ class NetscapeBookmarkUtils
119 $defaultPrivacy = 0; 126 $defaultPrivacy = 0;
120 127
121 $parser = new NetscapeBookmarkParser( 128 $parser = new NetscapeBookmarkParser(
122 true, // nested tag support 129 true, // nested tag support
123 $defaultTags, // additional user-specified tags 130 $defaultTags, // additional user-specified tags
124 strval(1 - $defaultPrivacy) // defaultPub = 1 - defaultPrivacy 131 strval(1 - $defaultPrivacy), // defaultPub = 1 - defaultPrivacy
132 $conf->get('resource.data_dir') // log path, will be overridden
133 );
134 $logger = new Logger(
135 $conf->get('resource.data_dir'),
136 ! $conf->get('dev.debug') ? LogLevel::INFO : LogLevel::DEBUG,
137 [
138 'prefix' => 'import.',
139 'extension' => 'log',
140 ]
125 ); 141 );
142 $parser->setLogger($logger);
126 $bookmarks = $parser->parseString($data); 143 $bookmarks = $parser->parseString($data);
127 144
128 $importCount = 0; 145 $importCount = 0;
@@ -163,9 +180,11 @@ class NetscapeBookmarkUtils
163 $newLink['id'] = $existingLink['id']; 180 $newLink['id'] = $existingLink['id'];
164 $newLink['created'] = $existingLink['created']; 181 $newLink['created'] = $existingLink['created'];
165 $newLink['updated'] = new DateTime(); 182 $newLink['updated'] = new DateTime();
183 $newLink['shorturl'] = $existingLink['shorturl'];
166 $linkDb[$existingLink['id']] = $newLink; 184 $linkDb[$existingLink['id']] = $newLink;
167 $importCount++; 185 $importCount++;
168 $overwriteCount++; 186 $overwriteCount++;
187 $history->updateLink($newLink);
169 continue; 188 continue;
170 } 189 }
171 190
@@ -177,9 +196,10 @@ class NetscapeBookmarkUtils
177 $newLink['shorturl'] = link_small_hash($newLink['created'], $newLink['id']); 196 $newLink['shorturl'] = link_small_hash($newLink['created'], $newLink['id']);
178 $linkDb[$newLink['id']] = $newLink; 197 $linkDb[$newLink['id']] = $newLink;
179 $importCount++; 198 $importCount++;
199 $history->addLink($newLink);
180 } 200 }
181 201
182 $linkDb->save($pagecache); 202 $linkDb->save($conf->get('resource.page_cache'));
183 return self::importStatus( 203 return self::importStatus(
184 $filename, 204 $filename,
185 $filesize, 205 $filesize,
diff --git a/application/PageBuilder.php b/application/PageBuilder.php
index 32c7f9f1..50e3f124 100644
--- a/application/PageBuilder.php
+++ b/application/PageBuilder.php
@@ -1,5 +1,7 @@
1<?php 1<?php
2 2
3use Shaarli\Config\ConfigManager;
4
3/** 5/**
4 * This class is in charge of building the final page. 6 * This class is in charge of building the final page.
5 * (This is basically a wrapper around RainTPL which pre-fills some fields.) 7 * (This is basically a wrapper around RainTPL which pre-fills some fields.)
@@ -20,15 +22,22 @@ 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.
27 */ 35 */
28 function __construct(&$conf) 36 public function __construct(&$conf, $linkDB = null)
29 { 37 {
30 $this->tpl = false; 38 $this->tpl = false;
31 $this->conf = $conf; 39 $this->conf = $conf;
40 $this->linkDB = $linkDB;
32 } 41 }
33 42
34 /** 43 /**
@@ -75,9 +84,13 @@ class PageBuilder
75 } 84 }
76 $this->tpl->assign('shaarlititle', $this->conf->get('general.title', 'Shaarli')); 85 $this->tpl->assign('shaarlititle', $this->conf->get('general.title', 'Shaarli'));
77 $this->tpl->assign('openshaarli', $this->conf->get('security.open_shaarli', false)); 86 $this->tpl->assign('openshaarli', $this->conf->get('security.open_shaarli', false));
78 $this->tpl->assign('showatom', $this->conf->get('feed.show_atom', false)); 87 $this->tpl->assign('showatom', $this->conf->get('feed.show_atom', true));
88 $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)); 89 $this->tpl->assign('hide_timestamps', $this->conf->get('privacy.hide_timestamps', false));
80 $this->tpl->assign('token', getToken($this->conf)); 90 $this->tpl->assign('token', getToken($this->conf));
91 if ($this->linkDB !== null) {
92 $this->tpl->assign('tags', $this->linkDB->allTags());
93 }
81 // To be removed with a proper theme configuration. 94 // To be removed with a proper theme configuration.
82 $this->tpl->assign('conf', $this->conf); 95 $this->tpl->assign('conf', $this->conf);
83 } 96 }
diff --git a/application/Router.php b/application/Router.php
index caed4a28..c9a51912 100644
--- a/application/Router.php
+++ b/application/Router.php
@@ -31,6 +31,8 @@ class Router
31 31
32 public static $PAGE_EDITLINK = 'edit_link'; 32 public static $PAGE_EDITLINK = 'edit_link';
33 33
34 public static $PAGE_DELETELINK = 'delete_link';
35
34 public static $PAGE_EXPORT = 'export'; 36 public static $PAGE_EXPORT = 'export';
35 37
36 public static $PAGE_IMPORT = 'import'; 38 public static $PAGE_IMPORT = 'import';
@@ -120,6 +122,10 @@ class Router
120 return self::$PAGE_EDITLINK; 122 return self::$PAGE_EDITLINK;
121 } 123 }
122 124
125 if (isset($get['delete_link'])) {
126 return self::$PAGE_DELETELINK;
127 }
128
123 if (startsWith($query, 'do='. self::$PAGE_EXPORT)) { 129 if (startsWith($query, 'do='. self::$PAGE_EXPORT)) {
124 return self::$PAGE_EXPORT; 130 return self::$PAGE_EXPORT;
125 } 131 }
diff --git a/application/ThemeUtils.php b/application/ThemeUtils.php
new file mode 100644
index 00000000..2718ed13
--- /dev/null
+++ b/application/ThemeUtils.php
@@ -0,0 +1,33 @@
1<?php
2
3namespace Shaarli;
4
5/**
6 * Class ThemeUtils
7 *
8 * Utility functions related to theme management.
9 *
10 * @package Shaarli
11 */
12class ThemeUtils
13{
14 /**
15 * Get a list of available themes.
16 *
17 * It will return the name of any directory present in the template folder.
18 *
19 * @param string $tplDir Templates main directory.
20 *
21 * @return array List of theme names.
22 */
23 public static function getThemes($tplDir)
24 {
25 $allTheme = glob($tplDir.'/*', GLOB_ONLYDIR);
26 $themes = [];
27 foreach ($allTheme as $value) {
28 $themes[] = str_replace($tplDir.'/', '', $value);
29 }
30
31 return $themes;
32 }
33}
diff --git a/application/TimeZone.php b/application/TimeZone.php
index 36a8fb12..c1869ef8 100644
--- a/application/TimeZone.php
+++ b/application/TimeZone.php
@@ -1,23 +1,42 @@
1<?php 1<?php
2/** 2/**
3 * Generates the timezone selection form and JavaScript. 3 * Generates a list of available timezone continents and cities.
4 * 4 *
5 * Note: 'UTC/UTC' is mapped to 'UTC' to form a valid option 5 * Two distinct array based on available timezones
6 * and the one selected in the settings:
7 * - (0) continents:
8 * + list of available continents
9 * + special key 'selected' containing the value of the selected timezone's continent
10 * - (1) cities:
11 * + list of available cities associated with their continent
12 * + special key 'selected' containing the value of the selected timezone's city (without the continent)
6 * 13 *
7 * Example: preselect Europe/Paris 14 * Example:
8 * list($htmlform, $js) = generateTimeZoneForm('Europe/Paris'); 15 * [
16 * [
17 * 'America',
18 * 'Europe',
19 * 'selected' => 'Europe',
20 * ],
21 * [
22 * ['continent' => 'America', 'city' => 'Toronto'],
23 * ['continent' => 'Europe', 'city' => 'Paris'],
24 * 'selected' => 'Paris',
25 * ],
26 * ];
9 * 27 *
28 * Notes:
29 * - 'UTC/UTC' is mapped to 'UTC' to form a valid option
30 * - a few timezone cities includes the country/state, such as Argentina/Buenos_Aires
31 * - these arrays are designed to build timezone selects in template files with any HTML structure
32 *
33 * @param array $installedTimeZones List of installed timezones as string
10 * @param string $preselectedTimezone preselected timezone (optional) 34 * @param string $preselectedTimezone preselected timezone (optional)
11 * 35 *
12 * @return array containing the generated HTML form and Javascript code 36 * @return array[] continents and cities
13 **/ 37 **/
14function generateTimeZoneForm($preselectedTimezone='') 38function generateTimeZoneData($installedTimeZones, $preselectedTimezone = '')
15{ 39{
16 // Select the server timezone
17 if ($preselectedTimezone == '') {
18 $preselectedTimezone = date_default_timezone_get();
19 }
20
21 if ($preselectedTimezone == 'UTC') { 40 if ($preselectedTimezone == 'UTC') {
22 $pcity = $pcontinent = 'UTC'; 41 $pcity = $pcontinent = 'UTC';
23 } else { 42 } else {
@@ -27,62 +46,30 @@ function generateTimeZoneForm($preselectedTimezone='')
27 $pcity = substr($preselectedTimezone, $spos+1); 46 $pcity = substr($preselectedTimezone, $spos+1);
28 } 47 }
29 48
30 // The list is in the form 'Europe/Paris', 'America/Argentina/Buenos_Aires' 49 $continents = [];
31 // We split the list in continents/cities. 50 $cities = [];
32 $continents = array(); 51 foreach ($installedTimeZones as $tz) {
33 $cities = array();
34
35 // TODO: use a template to generate the HTML/Javascript form
36
37 foreach (timezone_identifiers_list() as $tz) {
38 if ($tz == 'UTC') { 52 if ($tz == 'UTC') {
39 $tz = 'UTC/UTC'; 53 $tz = 'UTC/UTC';
40 } 54 }
41 $spos = strpos($tz, '/'); 55 $spos = strpos($tz, '/');
42 56
43 if ($spos !== false) { 57 // Ignore invalid timezones
44 $continent = substr($tz, 0, $spos); 58 if ($spos === false) {
45 $city = substr($tz, $spos+1); 59 continue;
46 $continents[$continent] = 1;
47
48 if (!isset($cities[$continent])) {
49 $cities[$continent] = '';
50 }
51 $cities[$continent] .= '<option value="'.$city.'"';
52 if ($pcity == $city) {
53 $cities[$continent] .= ' selected="selected"';
54 }
55 $cities[$continent] .= '>'.$city.'</option>';
56 } 60 }
57 }
58
59 $continentsHtml = '';
60 $continents = array_keys($continents);
61 61
62 foreach ($continents as $continent) { 62 $continent = substr($tz, 0, $spos);
63 $continentsHtml .= '<option value="'.$continent.'"'; 63 $city = substr($tz, $spos+1);
64 if ($pcontinent == $continent) { 64 $cities[] = ['continent' => $continent, 'city' => $city];
65 $continentsHtml .= ' selected="selected"'; 65 $continents[$continent] = true;
66 }
67 $continentsHtml .= '>'.$continent.'</option>';
68 } 66 }
69 67
70 // Timezone selection form 68 $continents = array_keys($continents);
71 $timezoneForm = 'Continent:'; 69 $continents['selected'] = $pcontinent;
72 $timezoneForm .= '<select name="continent" id="continent" onChange="onChangecontinent();">'; 70 $cities['selected'] = $pcity;
73 $timezoneForm .= $continentsHtml.'</select>';
74 $timezoneForm .= '&nbsp;&nbsp;&nbsp;&nbsp;City:';
75 $timezoneForm .= '<select name="city" id="city">'.$cities[$pcontinent].'</select><br />';
76
77 // Javascript handler - updates the city list when the user selects a continent
78 $timezoneJs = '<script>';
79 $timezoneJs .= 'function onChangecontinent() {';
80 $timezoneJs .= 'document.getElementById("city").innerHTML =';
81 $timezoneJs .= ' citiescontinent[document.getElementById("continent").value]; }';
82 $timezoneJs .= 'var citiescontinent = '.json_encode($cities).';';
83 $timezoneJs .= '</script>';
84 71
85 return array($timezoneForm, $timezoneJs); 72 return [$continents, $cities];
86} 73}
87 74
88/** 75/**
diff --git a/application/Updater.php b/application/Updater.php
index 555d4c25..03d93a6f 100644
--- a/application/Updater.php
+++ b/application/Updater.php
@@ -1,4 +1,7 @@
1<?php 1<?php
2use Shaarli\Config\ConfigJson;
3use Shaarli\Config\ConfigPhp;
4use Shaarli\Config\ConfigManager;
2 5
3/** 6/**
4 * Class Updater. 7 * Class Updater.
@@ -69,7 +72,7 @@ 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('Couldn\'t retrieve Updater class methods.');
74 } 77 }
75 78
@@ -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,104 @@ class Updater
258 } 246 }
259 247
260 /** 248 /**
249 * Rename tags starting with a '-' to work with tag exclusion search.
250 */
251 public function updateMethodRenameDashTags()
252 {
253 $linklist = $this->linkDB->filterSearch();
254 foreach ($linklist as $key => $link) {
255 $link['tags'] = preg_replace('/(^| )\-/', '$1', $link['tags']);
256 $link['tags'] = implode(' ', array_unique(LinkFilter::tagsStrToArray($link['tags'], true)));
257 $this->linkDB[$key] = $link;
258 }
259 $this->linkDB->save($this->conf->get('resource.page_cache'));
260 return true;
261 }
262
263 /**
264 * Initialize API settings:
265 * - api.enabled: true
266 * - api.secret: generated secret
267 */
268 public function updateMethodApiSettings()
269 {
270 if ($this->conf->exists('api.secret')) {
271 return true;
272 }
273
274 $this->conf->set('api.enabled', true);
275 $this->conf->set(
276 'api.secret',
277 generate_api_secret(
278 $this->conf->get('credentials.login'),
279 $this->conf->get('credentials.salt')
280 )
281 );
282 $this->conf->write($this->isLoggedIn);
283 return true;
284 }
285
286 /**
287 * New setting: theme name. If the default theme is used, nothing to do.
288 *
289 * If the user uses a custom theme, raintpl_tpl dir is updated to the parent directory,
290 * and the current theme is set as default in the theme setting.
291 *
292 * @return bool true if the update is successful, false otherwise.
293 */
294 public function updateMethodDefaultTheme()
295 {
296 // raintpl_tpl isn't the root template directory anymore.
297 // We run the update only if this folder still contains the template files.
298 $tplDir = $this->conf->get('resource.raintpl_tpl');
299 $tplFile = $tplDir . '/linklist.html';
300 if (! file_exists($tplFile)) {
301 return true;
302 }
303
304 $parent = dirname($tplDir);
305 $this->conf->set('resource.raintpl_tpl', $parent);
306 $this->conf->set('resource.theme', trim(str_replace($parent, '', $tplDir), '/'));
307 $this->conf->write($this->isLoggedIn);
308
309 // Dependency injection gore
310 RainTPL::$tpl_dir = $tplDir;
311
312 return true;
313 }
314
315 /**
316 * Move the file to inc/user.css to data/user.css.
317 *
318 * Note: Due to hardcoded paths, it's not unit testable. But one line of code should be fine.
319 *
320 * @return bool true if the update is successful, false otherwise.
321 */
322 public function updateMethodMoveUserCss()
323 {
324 if (! is_file('inc/user.css')) {
325 return true;
326 }
327
328 return rename('inc/user.css', 'data/user.css');
329 }
330
331 /**
332 * While the new default theme is in an unstable state
333 * continue to use the vintage theme
334 */
335 public function updateMethodDefaultThemeVintage()
336 {
337 if ($this->conf->get('resource.theme') !== 'default') {
338 return true;
339 }
340 $this->conf->set('resource.theme', 'vintage');
341 $this->conf->write($this->isLoggedIn);
342
343 return true;
344 }
345
346 /**
261 * * `markdown_escape` is a new setting, set to true as default. 347 * * `markdown_escape` is a new setting, set to true as default.
262 * 348 *
263 * If the markdown plugin was already enabled, escaping is disabled to avoid 349 * If the markdown plugin was already enabled, escaping is disabled to avoid
@@ -278,6 +364,93 @@ class Updater
278 364
279 return true; 365 return true;
280 } 366 }
367
368 /**
369 * Add 'http://' to Piwik URL the setting is set.
370 *
371 * @return bool true if the update is successful, false otherwise.
372 */
373 public function updateMethodPiwikUrl()
374 {
375 if (! $this->conf->exists('plugins.PIWIK_URL') || startsWith($this->conf->get('plugins.PIWIK_URL'), 'http')) {
376 return true;
377 }
378
379 $this->conf->set('plugins.PIWIK_URL', 'http://'. $this->conf->get('plugins.PIWIK_URL'));
380 $this->conf->write($this->isLoggedIn);
381
382 return true;
383 }
384
385 /**
386 * Use ATOM feed as default.
387 */
388 public function updateMethodAtomDefault()
389 {
390 if (!$this->conf->exists('feed.show_atom') || $this->conf->get('feed.show_atom') === true) {
391 return true;
392 }
393
394 $this->conf->set('feed.show_atom', true);
395 $this->conf->write($this->isLoggedIn);
396
397 return true;
398 }
399
400 /**
401 * Update updates.check_updates_branch setting.
402 *
403 * If the current major version digit matches the latest branch
404 * major version digit, we set the branch to `latest`,
405 * otherwise we'll check updates on the `stable` branch.
406 *
407 * No update required for the dev version.
408 *
409 * Note: due to hardcoded URL and lack of dependency injection, this is not unit testable.
410 *
411 * FIXME! This needs to be removed when we switch to first digit major version
412 * instead of the second one since the versionning process will change.
413 */
414 public function updateMethodCheckUpdateRemoteBranch()
415 {
416 if (shaarli_version === 'dev' || $this->conf->get('updates.check_updates_branch') === 'latest') {
417 return true;
418 }
419
420 // Get latest branch major version digit
421 $latestVersion = ApplicationUtils::getLatestGitVersionCode(
422 'https://raw.githubusercontent.com/shaarli/Shaarli/latest/shaarli_version.php',
423 5
424 );
425 if (preg_match('/(\d+)\.\d+$/', $latestVersion, $matches) === false) {
426 return false;
427 }
428 $latestMajor = $matches[1];
429
430 // Get current major version digit
431 preg_match('/(\d+)\.\d+$/', shaarli_version, $matches);
432 $currentMajor = $matches[1];
433
434 if ($currentMajor === $latestMajor) {
435 $branch = 'latest';
436 } else {
437 $branch = 'stable';
438 }
439 $this->conf->set('updates.check_updates_branch', $branch);
440 $this->conf->write($this->isLoggedIn);
441 return true;
442 }
443
444 /**
445 * Reset history store file due to date format change.
446 */
447 public function updateMethodResetHistoryFile()
448 {
449 if (is_file($this->conf->get('resource.history'))) {
450 unlink($this->conf->get('resource.history'));
451 }
452 return true;
453 }
281} 454}
282 455
283/** 456/**
diff --git a/application/Url.php b/application/Url.php
index c5c7dd18..25a62a8a 100644
--- a/application/Url.php
+++ b/application/Url.php
@@ -94,7 +94,10 @@ class Url
94 'utm_', 94 'utm_',
95 95
96 // ATInternet 96 // ATInternet
97 'xtor=' 97 'xtor=',
98
99 // Other
100 'campaign_'
98 ); 101 );
99 102
100 private static $annoyingFragments = array( 103 private static $annoyingFragments = array(
diff --git a/application/Utils.php b/application/Utils.php
index 0a5b476e..ab463af9 100644
--- a/application/Utils.php
+++ b/application/Utils.php
@@ -216,18 +216,222 @@ function is_session_id_valid($sessionId)
216function autoLocale($headerLocale) 216function autoLocale($headerLocale)
217{ 217{
218 // Default if browser does not send HTTP_ACCEPT_LANGUAGE 218 // Default if browser does not send HTTP_ACCEPT_LANGUAGE
219 $attempts = array('en_US'); 219 $locales = array('en_US', 'en_US.utf8', 'en_US.UTF-8');
220 if (isset($headerLocale)) { 220 if (! empty($headerLocale)) {
221 // (It's a bit crude, but it works very well. Preferred language is always presented first.) 221 if (preg_match_all('/([a-z]{2,3})[-_]?([a-z]{2})?,?/i', $headerLocale, $matches, PREG_SET_ORDER)) {
222 if (preg_match('/([a-z]{2})-?([a-z]{2})?/i', $headerLocale, $matches)) { 222 $attempts = [];
223 $loc = $matches[1] . (!empty($matches[2]) ? '_' . strtoupper($matches[2]) : ''); 223 foreach ($matches as $match) {
224 $attempts = array( 224 $first = [strtolower($match[1]), strtoupper($match[1])];
225 $loc.'.UTF-8', $loc, str_replace('_', '-', $loc).'.UTF-8', str_replace('_', '-', $loc), 225 $separators = ['_', '-'];
226 $loc . '_' . strtoupper($loc).'.UTF-8', $loc . '_' . strtoupper($loc), 226 $encodings = ['utf8', 'UTF-8'];
227 $loc . '_' . $loc.'.UTF-8', $loc . '_' . $loc, $loc . '-' . strtoupper($loc).'.UTF-8', 227 if (!empty($match[2])) {
228 $loc . '-' . strtoupper($loc), $loc . '-' . $loc.'.UTF-8', $loc . '-' . $loc 228 $second = [strtoupper($match[2]), strtolower($match[2])];
229 ); 229 $items = [$first, $separators, $second, ['.'], $encodings];
230 } else {
231 $items = [$first, $separators, $first, ['.'], $encodings];
232 }
233 $attempts = array_merge($attempts, iterator_to_array(cartesian_product_generator($items)));
234 }
235
236 if (! empty($attempts)) {
237 $locales = array_merge(array_map('implode', $attempts), $locales);
238 }
239 }
240 }
241
242 setlocale(LC_ALL, $locales);
243}
244
245/**
246 * Build a Generator object representing the cartesian product from given $items.
247 *
248 * Example:
249 * [['a'], ['b', 'c']]
250 * will generate:
251 * [
252 * ['a', 'b'],
253 * ['a', 'c'],
254 * ]
255 *
256 * @param array $items array of array of string
257 *
258 * @return Generator representing the cartesian product of given array.
259 *
260 * @see https://en.wikipedia.org/wiki/Cartesian_product
261 */
262function cartesian_product_generator($items)
263{
264 if (empty($items)) {
265 yield [];
266 }
267 $subArray = array_pop($items);
268 if (empty($subArray)) {
269 return;
270 }
271 foreach (cartesian_product_generator($items) as $item) {
272 foreach ($subArray as $value) {
273 yield $item + [count($item) => $value];
230 } 274 }
231 } 275 }
232 setlocale(LC_ALL, $attempts); 276}
277
278/**
279 * Generates a default API secret.
280 *
281 * Note that the random-ish methods used in this function are predictable,
282 * which makes them NOT suitable for crypto.
283 * BUT the random string is salted with the salt and hashed with the username.
284 * It makes the generated API secret secured enough for Shaarli.
285 *
286 * PHP 7 provides random_int(), designed for cryptography.
287 * More info: http://stackoverflow.com/questions/4356289/php-random-string-generator
288
289 * @param string $username Shaarli login username
290 * @param string $salt Shaarli password hash salt
291 *
292 * @return string|bool Generated API secret, 12 char length.
293 * Or false if invalid parameters are provided (which will make the API unusable).
294 */
295function generate_api_secret($username, $salt)
296{
297 if (empty($username) || empty($salt)) {
298 return false;
299 }
300
301 return str_shuffle(substr(hash_hmac('sha512', uniqid($salt), $username), 10, 12));
302}
303
304/**
305 * Trim string, replace sequences of whitespaces by a single space.
306 * PHP equivalent to `normalize-space` XSLT function.
307 *
308 * @param string $string Input string.
309 *
310 * @return mixed Normalized string.
311 */
312function normalize_spaces($string)
313{
314 return preg_replace('/\s{2,}/', ' ', trim($string));
315}
316
317/**
318 * Format the date according to the locale.
319 *
320 * Requires php-intl to display international datetimes,
321 * otherwise default format '%c' will be returned.
322 *
323 * @param DateTime $date to format.
324 * @param bool $time Displays time if true.
325 * @param bool $intl Use international format if true.
326 *
327 * @return bool|string Formatted date, or false if the input is invalid.
328 */
329function format_date($date, $time = true, $intl = true)
330{
331 if (! $date instanceof DateTime) {
332 return false;
333 }
334
335 if (! $intl || ! class_exists('IntlDateFormatter')) {
336 $format = $time ? '%c' : '%x';
337 return strftime($format, $date->getTimestamp());
338 }
339
340 $formatter = new IntlDateFormatter(
341 setlocale(LC_TIME, 0),
342 IntlDateFormatter::LONG,
343 $time ? IntlDateFormatter::LONG : IntlDateFormatter::NONE
344 );
345
346 return $formatter->format($date);
347}
348
349/**
350 * Check if the input is an integer, no matter its real type.
351 *
352 * PHP is a bit messy regarding this:
353 * - is_int returns false if the input is a string
354 * - ctype_digit returns false if the input is an integer or negative
355 *
356 * @param mixed $input value
357 *
358 * @return bool true if the input is an integer, false otherwise
359 */
360function is_integer_mixed($input)
361{
362 if (is_array($input) || is_bool($input) || is_object($input)) {
363 return false;
364 }
365 $input = strval($input);
366 return ctype_digit($input) || (startsWith($input, '-') && ctype_digit(substr($input, 1)));
367}
368
369/**
370 * Convert post_max_size/upload_max_filesize (e.g. '16M') parameters to bytes.
371 *
372 * @param string $val Size expressed in string.
373 *
374 * @return int Size expressed in bytes.
375 */
376function return_bytes($val)
377{
378 if (is_integer_mixed($val) || $val === '0' || empty($val)) {
379 return $val;
380 }
381 $val = trim($val);
382 $last = strtolower($val[strlen($val)-1]);
383 $val = intval(substr($val, 0, -1));
384 switch($last) {
385 case 'g': $val *= 1024;
386 case 'm': $val *= 1024;
387 case 'k': $val *= 1024;
388 }
389 return $val;
390}
391
392/**
393 * Return a human readable size from bytes.
394 *
395 * @param int $bytes value
396 *
397 * @return string Human readable size
398 */
399function human_bytes($bytes)
400{
401 if ($bytes === '') {
402 return t('Setting not set');
403 }
404 if (! is_integer_mixed($bytes)) {
405 return $bytes;
406 }
407 $bytes = intval($bytes);
408 if ($bytes === 0) {
409 return t('Unlimited');
410 }
411
412 $units = [t('B'), t('kiB'), t('MiB'), t('GiB')];
413 for ($i = 0; $i < count($units) && $bytes >= 1024; ++$i) {
414 $bytes /= 1024;
415 }
416
417 return round($bytes) . $units[$i];
418}
419
420/**
421 * Try to determine max file size for uploads (POST).
422 * Returns an integer (in bytes) or formatted depending on $format.
423 *
424 * @param mixed $limitPost post_max_size PHP setting
425 * @param mixed $limitUpload upload_max_filesize PHP setting
426 * @param bool $format Format max upload size to human readable size
427 *
428 * @return int|string max upload file size
429 */
430function get_max_upload_size($limitPost, $limitUpload, $format = true)
431{
432 $size1 = return_bytes($limitPost);
433 $size2 = return_bytes($limitUpload);
434 // Return the smaller of two:
435 $maxsize = min($size1, $size2);
436 return $format ? human_bytes($maxsize) : $maxsize;
233} 437}
diff --git a/application/api/ApiMiddleware.php b/application/api/ApiMiddleware.php
new file mode 100644
index 00000000..ff209393
--- /dev/null
+++ b/application/api/ApiMiddleware.php
@@ -0,0 +1,138 @@
1<?php
2namespace Shaarli\Api;
3
4use Shaarli\Api\Exceptions\ApiException;
5use Shaarli\Api\Exceptions\ApiAuthorizationException;
6
7use Shaarli\Config\ConfigManager;
8use Slim\Container;
9use Slim\Http\Request;
10use Slim\Http\Response;
11
12/**
13 * Class ApiMiddleware
14 *
15 * This will be called before accessing any API Controller.
16 * Its role is to make sure that the API is enabled, configured, and to validate the JWT token.
17 *
18 * If the request is validated, the controller is called, otherwise a JSON error response is returned.
19 *
20 * @package Api
21 */
22class ApiMiddleware
23{
24 /**
25 * @var int JWT token validity in seconds (9 min).
26 */
27 public static $TOKEN_DURATION = 540;
28
29 /**
30 * @var Container: contains conf, plugins, etc.
31 */
32 protected $container;
33
34 /**
35 * @var ConfigManager instance.
36 */
37 protected $conf;
38
39 /**
40 * ApiMiddleware constructor.
41 *
42 * @param Container $container instance.
43 */
44 public function __construct($container)
45 {
46 $this->container = $container;
47 $this->conf = $this->container->get('conf');
48 $this->setLinkDb($this->conf);
49 }
50
51 /**
52 * Middleware execution:
53 * - check the API request
54 * - execute the controller
55 * - return the response
56 *
57 * @param Request $request Slim request
58 * @param Response $response Slim response
59 * @param callable $next Next action
60 *
61 * @return Response response.
62 */
63 public function __invoke($request, $response, $next)
64 {
65 try {
66 $this->checkRequest($request);
67 $response = $next($request, $response);
68 } catch(ApiException $e) {
69 $e->setResponse($response);
70 $e->setDebug($this->conf->get('dev.debug', false));
71 $response = $e->getApiResponse();
72 }
73
74 return $response;
75 }
76
77 /**
78 * Check the request validity (HTTP method, request value, etc.),
79 * that the API is enabled, and the JWT token validity.
80 *
81 * @param Request $request Slim request
82 *
83 * @throws ApiAuthorizationException The API is disabled or the token is invalid.
84 */
85 protected function checkRequest($request)
86 {
87 if (! $this->conf->get('api.enabled', true)) {
88 throw new ApiAuthorizationException('API is disabled');
89 }
90 $this->checkToken($request);
91 }
92
93 /**
94 * Check that the JWT token is set and valid.
95 * The API secret setting must be set.
96 *
97 * @param Request $request Slim request
98 *
99 * @throws ApiAuthorizationException The token couldn't be validated.
100 */
101 protected function checkToken($request) {
102 if (! $request->hasHeader('Authorization')) {
103 throw new ApiAuthorizationException('JWT token not provided');
104 }
105
106 if (empty($this->conf->get('api.secret'))) {
107 throw new ApiAuthorizationException('Token secret must be set in Shaarli\'s administration');
108 }
109
110 $authorization = $request->getHeaderLine('Authorization');
111
112 if (! preg_match('/^Bearer (.*)/i', $authorization, $matches)) {
113 throw new ApiAuthorizationException('Invalid JWT header');
114 }
115
116 ApiUtils::validateJwtToken($matches[1], $this->conf->get('api.secret'));
117 }
118
119 /**
120 * Instantiate a new LinkDB including private links,
121 * and load in the Slim container.
122 *
123 * FIXME! LinkDB could use a refactoring to avoid this trick.
124 *
125 * @param ConfigManager $conf instance.
126 */
127 protected function setLinkDb($conf)
128 {
129 $linkDb = new \LinkDB(
130 $conf->get('resource.datastore'),
131 true,
132 $conf->get('privacy.hide_public_links'),
133 $conf->get('redirector.url'),
134 $conf->get('redirector.encode_url')
135 );
136 $this->container['db'] = $linkDb;
137 }
138}
diff --git a/application/api/ApiUtils.php b/application/api/ApiUtils.php
new file mode 100644
index 00000000..f154bb52
--- /dev/null
+++ b/application/api/ApiUtils.php
@@ -0,0 +1,137 @@
1<?php
2namespace Shaarli\Api;
3
4use Shaarli\Base64Url;
5use Shaarli\Api\Exceptions\ApiAuthorizationException;
6
7/**
8 * REST API utilities
9 */
10class ApiUtils
11{
12 /**
13 * Validates a JWT token authenticity.
14 *
15 * @param string $token JWT token extracted from the headers.
16 * @param string $secret API secret set in the settings.
17 *
18 * @throws ApiAuthorizationException the token is not valid.
19 */
20 public static function validateJwtToken($token, $secret)
21 {
22 $parts = explode('.', $token);
23 if (count($parts) != 3 || strlen($parts[0]) == 0 || strlen($parts[1]) == 0) {
24 throw new ApiAuthorizationException('Malformed JWT token');
25 }
26
27 $genSign = Base64Url::encode(hash_hmac('sha512', $parts[0] .'.'. $parts[1], $secret, true));
28 if ($parts[2] != $genSign) {
29 throw new ApiAuthorizationException('Invalid JWT signature');
30 }
31
32 $header = json_decode(Base64Url::decode($parts[0]));
33 if ($header === null) {
34 throw new ApiAuthorizationException('Invalid JWT header');
35 }
36
37 $payload = json_decode(Base64Url::decode($parts[1]));
38 if ($payload === null) {
39 throw new ApiAuthorizationException('Invalid JWT payload');
40 }
41
42 if (empty($payload->iat)
43 || $payload->iat > time()
44 || time() - $payload->iat > ApiMiddleware::$TOKEN_DURATION
45 ) {
46 throw new ApiAuthorizationException('Invalid JWT issued time');
47 }
48 }
49
50 /**
51 * Format a Link for the REST API.
52 *
53 * @param array $link Link data read from the datastore.
54 * @param string $indexUrl Shaarli's index URL (used for relative URL).
55 *
56 * @return array Link data formatted for the REST API.
57 */
58 public static function formatLink($link, $indexUrl)
59 {
60 $out['id'] = $link['id'];
61 // Not an internal link
62 if ($link['url'][0] != '?') {
63 $out['url'] = $link['url'];
64 } else {
65 $out['url'] = $indexUrl . $link['url'];
66 }
67 $out['shorturl'] = $link['shorturl'];
68 $out['title'] = $link['title'];
69 $out['description'] = $link['description'];
70 $out['tags'] = preg_split('/\s+/', $link['tags'], -1, PREG_SPLIT_NO_EMPTY);
71 $out['private'] = $link['private'] == true;
72 $out['created'] = $link['created']->format(\DateTime::ATOM);
73 if (! empty($link['updated'])) {
74 $out['updated'] = $link['updated']->format(\DateTime::ATOM);
75 } else {
76 $out['updated'] = '';
77 }
78 return $out;
79 }
80
81 /**
82 * Convert a link given through a request, to a valid link for LinkDB.
83 *
84 * If no URL is provided, it will generate a local note URL.
85 * If no title is provided, it will use the URL as title.
86 *
87 * @param array $input Request Link.
88 * @param bool $defaultPrivate Request Link.
89 *
90 * @return array Formatted link.
91 */
92 public static function buildLinkFromRequest($input, $defaultPrivate)
93 {
94 $input['url'] = ! empty($input['url']) ? cleanup_url($input['url']) : '';
95 if (isset($input['private'])) {
96 $private = filter_var($input['private'], FILTER_VALIDATE_BOOLEAN);
97 } else {
98 $private = $defaultPrivate;
99 }
100
101 $link = [
102 'title' => ! empty($input['title']) ? $input['title'] : $input['url'],
103 'url' => $input['url'],
104 'description' => ! empty($input['description']) ? $input['description'] : '',
105 'tags' => ! empty($input['tags']) ? implode(' ', $input['tags']) : '',
106 'private' => $private,
107 'created' => new \DateTime(),
108 ];
109 return $link;
110 }
111
112 /**
113 * Update link fields using an updated link object.
114 *
115 * @param array $oldLink data
116 * @param array $newLink data
117 *
118 * @return array $oldLink updated with $newLink values
119 */
120 public static function updateLink($oldLink, $newLink)
121 {
122 foreach (['title', 'url', 'description', 'tags', 'private'] as $field) {
123 $oldLink[$field] = $newLink[$field];
124 }
125 $oldLink['updated'] = new \DateTime();
126
127 if (empty($oldLink['url'])) {
128 $oldLink['url'] = '?' . $oldLink['shorturl'];
129 }
130
131 if (empty($oldLink['title'])) {
132 $oldLink['title'] = $oldLink['url'];
133 }
134
135 return $oldLink;
136 }
137}
diff --git a/application/api/controllers/ApiController.php b/application/api/controllers/ApiController.php
new file mode 100644
index 00000000..3be85b98
--- /dev/null
+++ b/application/api/controllers/ApiController.php
@@ -0,0 +1,71 @@
1<?php
2
3namespace Shaarli\Api\Controllers;
4
5use Shaarli\Config\ConfigManager;
6use \Slim\Container;
7
8/**
9 * Abstract Class ApiController
10 *
11 * Defines REST API Controller dependencies injected from the container.
12 *
13 * @package Api\Controllers
14 */
15abstract class ApiController
16{
17 /**
18 * @var Container
19 */
20 protected $ci;
21
22 /**
23 * @var ConfigManager
24 */
25 protected $conf;
26
27 /**
28 * @var \LinkDB
29 */
30 protected $linkDb;
31
32 /**
33 * @var \History
34 */
35 protected $history;
36
37 /**
38 * @var int|null JSON style option.
39 */
40 protected $jsonStyle;
41
42 /**
43 * ApiController constructor.
44 *
45 * Note: enabling debug mode displays JSON with readable formatting.
46 *
47 * @param Container $ci Slim container.
48 */
49 public function __construct(Container $ci)
50 {
51 $this->ci = $ci;
52 $this->conf = $ci->get('conf');
53 $this->linkDb = $ci->get('db');
54 $this->history = $ci->get('history');
55 if ($this->conf->get('dev.debug', false)) {
56 $this->jsonStyle = JSON_PRETTY_PRINT;
57 } else {
58 $this->jsonStyle = null;
59 }
60 }
61
62 /**
63 * Get the container.
64 *
65 * @return Container
66 */
67 public function getCi()
68 {
69 return $this->ci;
70 }
71}
diff --git a/application/api/controllers/History.php b/application/api/controllers/History.php
new file mode 100644
index 00000000..2ff9deaf
--- /dev/null
+++ b/application/api/controllers/History.php
@@ -0,0 +1,70 @@
1<?php
2
3
4namespace Shaarli\Api\Controllers;
5
6use Shaarli\Api\Exceptions\ApiBadParametersException;
7use Slim\Http\Request;
8use Slim\Http\Response;
9
10/**
11 * Class History
12 *
13 * REST API Controller: /history
14 *
15 * @package Shaarli\Api\Controllers
16 */
17class History extends ApiController
18{
19 /**
20 * Service providing operation regarding Shaarli datastore and settings.
21 *
22 * @param Request $request Slim request.
23 * @param Response $response Slim response.
24 *
25 * @return Response response.
26 *
27 * @throws ApiBadParametersException Invalid parameters.
28 */
29 public function getHistory($request, $response)
30 {
31 $history = $this->history->getHistory();
32
33 // Return history operations from the {offset}th, starting from {since}.
34 $since = \DateTime::createFromFormat(\DateTime::ATOM, $request->getParam('since'));
35 $offset = $request->getParam('offset');
36 if (empty($offset)) {
37 $offset = 0;
38 }
39 else if (ctype_digit($offset)) {
40 $offset = (int) $offset;
41 } else {
42 throw new ApiBadParametersException('Invalid offset');
43 }
44
45 // limit parameter is either a number of links or 'all' for everything.
46 $limit = $request->getParam('limit');
47 if (empty($limit)) {
48 $limit = count($history);
49 } else if (ctype_digit($limit)) {
50 $limit = (int) $limit;
51 } else {
52 throw new ApiBadParametersException('Invalid limit');
53 }
54
55 $out = [];
56 $i = 0;
57 foreach ($history as $entry) {
58 if ((! empty($since) && $entry['datetime'] <= $since) || count($out) >= $limit) {
59 break;
60 }
61 if (++$i > $offset) {
62 $out[$i] = $entry;
63 $out[$i]['datetime'] = $out[$i]['datetime']->format(\DateTime::ATOM);
64 }
65 }
66 $out = array_values($out);
67
68 return $response->withJson($out, 200, $this->jsonStyle);
69 }
70}
diff --git a/application/api/controllers/Info.php b/application/api/controllers/Info.php
new file mode 100644
index 00000000..25433f72
--- /dev/null
+++ b/application/api/controllers/Info.php
@@ -0,0 +1,42 @@
1<?php
2
3namespace Shaarli\Api\Controllers;
4
5use Slim\Http\Request;
6use Slim\Http\Response;
7
8/**
9 * Class Info
10 *
11 * REST API Controller: /info
12 *
13 * @package Api\Controllers
14 * @see http://shaarli.github.io/api-documentation/#links-instance-information-get
15 */
16class Info extends ApiController
17{
18 /**
19 * Service providing various information about Shaarli instance.
20 *
21 * @param Request $request Slim request.
22 * @param Response $response Slim response.
23 *
24 * @return Response response.
25 */
26 public function getInfo($request, $response)
27 {
28 $info = [
29 'global_counter' => count($this->linkDb),
30 'private_counter' => count_private($this->linkDb),
31 'settings' => array(
32 'title' => $this->conf->get('general.title', 'Shaarli'),
33 'header_link' => $this->conf->get('general.header_link', '?'),
34 'timezone' => $this->conf->get('general.timezone', 'UTC'),
35 'enabled_plugins' => $this->conf->get('general.enabled_plugins', []),
36 'default_private_links' => $this->conf->get('privacy.default_private_links', false),
37 ),
38 ];
39
40 return $response->withJson($info, 200, $this->jsonStyle);
41 }
42}
diff --git a/application/api/controllers/Links.php b/application/api/controllers/Links.php
new file mode 100644
index 00000000..eb78dd26
--- /dev/null
+++ b/application/api/controllers/Links.php
@@ -0,0 +1,217 @@
1<?php
2
3namespace Shaarli\Api\Controllers;
4
5use Shaarli\Api\ApiUtils;
6use Shaarli\Api\Exceptions\ApiBadParametersException;
7use Shaarli\Api\Exceptions\ApiLinkNotFoundException;
8use Slim\Http\Request;
9use Slim\Http\Response;
10
11/**
12 * Class Links
13 *
14 * REST API Controller: all services related to links collection.
15 *
16 * @package Api\Controllers
17 * @see http://shaarli.github.io/api-documentation/#links-links-collection
18 */
19class Links extends ApiController
20{
21 /**
22 * @var int Number of links returned if no limit is provided.
23 */
24 public static $DEFAULT_LIMIT = 20;
25
26 /**
27 * Retrieve a list of links, allowing different filters.
28 *
29 * @param Request $request Slim request.
30 * @param Response $response Slim response.
31 *
32 * @return Response response.
33 *
34 * @throws ApiBadParametersException Invalid parameters.
35 */
36 public function getLinks($request, $response)
37 {
38 $private = $request->getParam('visibility');
39 $links = $this->linkDb->filterSearch(
40 [
41 'searchtags' => $request->getParam('searchtags', ''),
42 'searchterm' => $request->getParam('searchterm', ''),
43 ],
44 false,
45 $private
46 );
47
48 // Return links from the {offset}th link, starting from 0.
49 $offset = $request->getParam('offset');
50 if (! empty($offset) && ! ctype_digit($offset)) {
51 throw new ApiBadParametersException('Invalid offset');
52 }
53 $offset = ! empty($offset) ? intval($offset) : 0;
54 if ($offset > count($links)) {
55 return $response->withJson([], 200, $this->jsonStyle);
56 }
57
58 // limit parameter is either a number of links or 'all' for everything.
59 $limit = $request->getParam('limit');
60 if (empty($limit)) {
61 $limit = self::$DEFAULT_LIMIT;
62 } else if (ctype_digit($limit)) {
63 $limit = intval($limit);
64 } else if ($limit === 'all') {
65 $limit = count($links);
66 } else {
67 throw new ApiBadParametersException('Invalid limit');
68 }
69
70 // 'environment' is set by Slim and encapsulate $_SERVER.
71 $index = index_url($this->ci['environment']);
72
73 $out = [];
74 $cpt = 0;
75 foreach ($links as $link) {
76 if (count($out) >= $limit) {
77 break;
78 }
79 if ($cpt++ >= $offset) {
80 $out[] = ApiUtils::formatLink($link, $index);
81 }
82 }
83
84 return $response->withJson($out, 200, $this->jsonStyle);
85 }
86
87 /**
88 * Return a single formatted link by its ID.
89 *
90 * @param Request $request Slim request.
91 * @param Response $response Slim response.
92 * @param array $args Path parameters. including the ID.
93 *
94 * @return Response containing the link array.
95 *
96 * @throws ApiLinkNotFoundException generating a 404 error.
97 */
98 public function getLink($request, $response, $args)
99 {
100 if (!isset($this->linkDb[$args['id']])) {
101 throw new ApiLinkNotFoundException();
102 }
103 $index = index_url($this->ci['environment']);
104 $out = ApiUtils::formatLink($this->linkDb[$args['id']], $index);
105
106 return $response->withJson($out, 200, $this->jsonStyle);
107 }
108
109 /**
110 * Creates a new link from posted request body.
111 *
112 * @param Request $request Slim request.
113 * @param Response $response Slim response.
114 *
115 * @return Response response.
116 */
117 public function postLink($request, $response)
118 {
119 $data = $request->getParsedBody();
120 $link = ApiUtils::buildLinkFromRequest($data, $this->conf->get('privacy.default_private_links'));
121 // duplicate by URL, return 409 Conflict
122 if (! empty($link['url']) && ! empty($dup = $this->linkDb->getLinkFromUrl($link['url']))) {
123 return $response->withJson(
124 ApiUtils::formatLink($dup, index_url($this->ci['environment'])),
125 409,
126 $this->jsonStyle
127 );
128 }
129
130 $link['id'] = $this->linkDb->getNextId();
131 $link['shorturl'] = link_small_hash($link['created'], $link['id']);
132
133 // note: general relative URL
134 if (empty($link['url'])) {
135 $link['url'] = '?' . $link['shorturl'];
136 }
137
138 if (empty($link['title'])) {
139 $link['title'] = $link['url'];
140 }
141
142 $this->linkDb[$link['id']] = $link;
143 $this->linkDb->save($this->conf->get('resource.page_cache'));
144 $this->history->addLink($link);
145 $out = ApiUtils::formatLink($link, index_url($this->ci['environment']));
146 $redirect = $this->ci->router->relativePathFor('getLink', ['id' => $link['id']]);
147 return $response->withAddedHeader('Location', $redirect)
148 ->withJson($out, 201, $this->jsonStyle);
149 }
150
151 /**
152 * Updates an existing link from posted request body.
153 *
154 * @param Request $request Slim request.
155 * @param Response $response Slim response.
156 * @param array $args Path parameters. including the ID.
157 *
158 * @return Response response.
159 *
160 * @throws ApiLinkNotFoundException generating a 404 error.
161 */
162 public function putLink($request, $response, $args)
163 {
164 if (! isset($this->linkDb[$args['id']])) {
165 throw new ApiLinkNotFoundException();
166 }
167
168 $index = index_url($this->ci['environment']);
169 $data = $request->getParsedBody();
170
171 $requestLink = ApiUtils::buildLinkFromRequest($data, $this->conf->get('privacy.default_private_links'));
172 // duplicate URL on a different link, return 409 Conflict
173 if (! empty($requestLink['url'])
174 && ! empty($dup = $this->linkDb->getLinkFromUrl($requestLink['url']))
175 && $dup['id'] != $args['id']
176 ) {
177 return $response->withJson(
178 ApiUtils::formatLink($dup, $index),
179 409,
180 $this->jsonStyle
181 );
182 }
183
184 $responseLink = $this->linkDb[$args['id']];
185 $responseLink = ApiUtils::updateLink($responseLink, $requestLink);
186 $this->linkDb[$responseLink['id']] = $responseLink;
187 $this->linkDb->save($this->conf->get('resource.page_cache'));
188 $this->history->updateLink($responseLink);
189
190 $out = ApiUtils::formatLink($responseLink, $index);
191 return $response->withJson($out, 200, $this->jsonStyle);
192 }
193
194 /**
195 * Delete an existing link by its ID.
196 *
197 * @param Request $request Slim request.
198 * @param Response $response Slim response.
199 * @param array $args Path parameters. including the ID.
200 *
201 * @return Response response.
202 *
203 * @throws ApiLinkNotFoundException generating a 404 error.
204 */
205 public function deleteLink($request, $response, $args)
206 {
207 if (! isset($this->linkDb[$args['id']])) {
208 throw new ApiLinkNotFoundException();
209 }
210 $link = $this->linkDb[$args['id']];
211 unset($this->linkDb[(int) $args['id']]);
212 $this->linkDb->save($this->conf->get('resource.page_cache'));
213 $this->history->deleteLink($link);
214
215 return $response->withStatus(204);
216 }
217}
diff --git a/application/api/exceptions/ApiAuthorizationException.php b/application/api/exceptions/ApiAuthorizationException.php
new file mode 100644
index 00000000..0e3f4776
--- /dev/null
+++ b/application/api/exceptions/ApiAuthorizationException.php
@@ -0,0 +1,34 @@
1<?php
2
3namespace Shaarli\Api\Exceptions;
4
5/**
6 * Class ApiAuthorizationException
7 *
8 * Request not authorized, return a 401 HTTP code.
9 */
10class ApiAuthorizationException extends ApiException
11{
12 /**
13 * {@inheritdoc}
14 */
15 public function getApiResponse()
16 {
17 $this->setMessage('Not authorized');
18 return $this->buildApiResponse(401);
19 }
20
21 /**
22 * Set the exception message.
23 *
24 * We only return a generic error message in production mode to avoid giving
25 * to much security information.
26 *
27 * @param $message string the exception message.
28 */
29 public function setMessage($message)
30 {
31 $original = $this->debug === true ? ': '. $this->getMessage() : '';
32 $this->message = $message . $original;
33 }
34}
diff --git a/application/api/exceptions/ApiBadParametersException.php b/application/api/exceptions/ApiBadParametersException.php
new file mode 100644
index 00000000..e5cc19ea
--- /dev/null
+++ b/application/api/exceptions/ApiBadParametersException.php
@@ -0,0 +1,19 @@
1<?php
2
3namespace Shaarli\Api\Exceptions;
4
5/**
6 * Class ApiBadParametersException
7 *
8 * Invalid request exception, return a 400 HTTP code.
9 */
10class ApiBadParametersException extends ApiException
11{
12 /**
13 * {@inheritdoc}
14 */
15 public function getApiResponse()
16 {
17 return $this->buildApiResponse(400);
18 }
19}
diff --git a/application/api/exceptions/ApiException.php b/application/api/exceptions/ApiException.php
new file mode 100644
index 00000000..c8490e0c
--- /dev/null
+++ b/application/api/exceptions/ApiException.php
@@ -0,0 +1,77 @@
1<?php
2
3namespace Shaarli\Api\Exceptions;
4
5use Slim\Http\Response;
6
7/**
8 * Abstract class ApiException
9 *
10 * Parent Exception related to the API, able to generate a valid Response (ResponseInterface).
11 * Also can include various information in debug mode.
12 */
13abstract class ApiException extends \Exception {
14
15 /**
16 * @var Response instance from Slim.
17 */
18 protected $response;
19
20 /**
21 * @var bool Debug mode enabled/disabled.
22 */
23 protected $debug;
24
25 /**
26 * Build the final response.
27 *
28 * @return Response Final response to give.
29 */
30 public abstract function getApiResponse();
31
32 /**
33 * Creates ApiResponse body.
34 * In production mode, it will only return the exception message,
35 * but in dev mode, it includes additional information in an array.
36 *
37 * @return array|string response body
38 */
39 protected function getApiResponseBody() {
40 if ($this->debug !== true) {
41 return $this->getMessage();
42 }
43 return [
44 'message' => $this->getMessage(),
45 'stacktrace' => get_class($this) .': '. $this->getTraceAsString()
46 ];
47 }
48
49 /**
50 * Build the Response object to return.
51 *
52 * @param int $code HTTP status.
53 *
54 * @return Response with status + body.
55 */
56 protected function buildApiResponse($code)
57 {
58 $style = $this->debug ? JSON_PRETTY_PRINT : null;
59 return $this->response->withJson($this->getApiResponseBody(), $code, $style);
60 }
61
62 /**
63 * @param Response $response
64 */
65 public function setResponse($response)
66 {
67 $this->response = $response;
68 }
69
70 /**
71 * @param bool $debug
72 */
73 public function setDebug($debug)
74 {
75 $this->debug = $debug;
76 }
77}
diff --git a/application/api/exceptions/ApiInternalException.php b/application/api/exceptions/ApiInternalException.php
new file mode 100644
index 00000000..1cb05532
--- /dev/null
+++ b/application/api/exceptions/ApiInternalException.php
@@ -0,0 +1,19 @@
1<?php
2
3namespace Shaarli\Api\Exceptions;
4
5/**
6 * Class ApiInternalException
7 *
8 * Generic exception, return a 500 HTTP code.
9 */
10class ApiInternalException extends ApiException
11{
12 /**
13 * @inheritdoc
14 */
15 public function getApiResponse()
16 {
17 return $this->buildApiResponse(500);
18 }
19}
diff --git a/application/api/exceptions/ApiLinkNotFoundException.php b/application/api/exceptions/ApiLinkNotFoundException.php
new file mode 100644
index 00000000..de7e14f5
--- /dev/null
+++ b/application/api/exceptions/ApiLinkNotFoundException.php
@@ -0,0 +1,32 @@
1<?php
2
3namespace Shaarli\Api\Exceptions;
4
5
6use Slim\Http\Response;
7
8/**
9 * Class ApiLinkNotFoundException
10 *
11 * Link selected by ID couldn't be found, results in a 404 error.
12 *
13 * @package Shaarli\Api\Exceptions
14 */
15class ApiLinkNotFoundException extends ApiException
16{
17 /**
18 * ApiLinkNotFoundException constructor.
19 */
20 public function __construct()
21 {
22 $this->message = 'Link not found';
23 }
24
25 /**
26 * {@inheritdoc}
27 */
28 public function getApiResponse()
29 {
30 return $this->buildApiResponse(404);
31 }
32}
diff --git a/application/config/ConfigIO.php b/application/config/ConfigIO.php
index 2b68fe6a..3efe5b6f 100644
--- a/application/config/ConfigIO.php
+++ b/application/config/ConfigIO.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2namespace Shaarli\Config;
2 3
3/** 4/**
4 * Interface ConfigIO 5 * Interface ConfigIO
@@ -14,7 +15,7 @@ interface ConfigIO
14 * 15 *
15 * @return array All configuration in an array. 16 * @return array All configuration in an array.
16 */ 17 */
17 function read($filepath); 18 public function read($filepath);
18 19
19 /** 20 /**
20 * Write configuration. 21 * Write configuration.
@@ -22,12 +23,12 @@ interface ConfigIO
22 * @param string $filepath Config file absolute path. 23 * @param string $filepath Config file absolute path.
23 * @param array $conf All configuration in an array. 24 * @param array $conf All configuration in an array.
24 */ 25 */
25 function write($filepath, $conf); 26 public function write($filepath, $conf);
26 27
27 /** 28 /**
28 * Get config file extension according to config type. 29 * Get config file extension according to config type.
29 * 30 *
30 * @return string Config file extension. 31 * @return string Config file extension.
31 */ 32 */
32 function getExtension(); 33 public function getExtension();
33} 34}
diff --git a/application/config/ConfigJson.php b/application/config/ConfigJson.php
index 30007eb4..9ef2ef56 100644
--- a/application/config/ConfigJson.php
+++ b/application/config/ConfigJson.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2namespace Shaarli\Config;
2 3
3/** 4/**
4 * Class ConfigJson (ConfigIO implementation) 5 * Class ConfigJson (ConfigIO implementation)
@@ -10,7 +11,7 @@ class ConfigJson implements ConfigIO
10 /** 11 /**
11 * @inheritdoc 12 * @inheritdoc
12 */ 13 */
13 function read($filepath) 14 public function read($filepath)
14 { 15 {
15 if (! is_readable($filepath)) { 16 if (! is_readable($filepath)) {
16 return array(); 17 return array();
@@ -20,8 +21,14 @@ 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 = 'An error occurred while parsing JSON configuration file ('. $filepath .'): error code #';
26 $error .= $errorCode. '<br>➜ <code>' . json_last_error_msg() .'</code>';
27 if ($errorCode === JSON_ERROR_SYNTAX) {
28 $error .= '<br>Please check your JSON syntax (without PHP comment tags) using a JSON lint tool such as ';
29 $error .= '<a href="http://jsonlint.com/">jsonlint.com</a>.';
30 }
31 throw new \Exception($error);
25 } 32 }
26 return $data; 33 return $data;
27 } 34 }
@@ -29,13 +36,13 @@ class ConfigJson implements ConfigIO
29 /** 36 /**
30 * @inheritdoc 37 * @inheritdoc
31 */ 38 */
32 function write($filepath, $conf) 39 public function write($filepath, $conf)
33 { 40 {
34 // JSON_PRETTY_PRINT is available from PHP 5.4. 41 // JSON_PRETTY_PRINT is available from PHP 5.4.
35 $print = defined('JSON_PRETTY_PRINT') ? JSON_PRETTY_PRINT : 0; 42 $print = defined('JSON_PRETTY_PRINT') ? JSON_PRETTY_PRINT : 0;
36 $data = self::getPhpHeaders() . json_encode($conf, $print) . self::getPhpSuffix(); 43 $data = self::getPhpHeaders() . json_encode($conf, $print) . self::getPhpSuffix();
37 if (!file_put_contents($filepath, $data)) { 44 if (!file_put_contents($filepath, $data)) {
38 throw new IOException( 45 throw new \IOException(
39 $filepath, 46 $filepath,
40 'Shaarli could not create the config file. 47 'Shaarli could not create the config file.
41 Please make sure Shaarli has the right to write in the folder is it installed in.' 48 Please make sure Shaarli has the right to write in the folder is it installed in.'
@@ -46,7 +53,7 @@ class ConfigJson implements ConfigIO
46 /** 53 /**
47 * @inheritdoc 54 * @inheritdoc
48 */ 55 */
49 function getExtension() 56 public function getExtension()
50 { 57 {
51 return '.json.php'; 58 return '.json.php';
52 } 59 }
diff --git a/application/config/ConfigManager.php b/application/config/ConfigManager.php
index f5f753f8..86a917fb 100644
--- a/application/config/ConfigManager.php
+++ b/application/config/ConfigManager.php
@@ -1,9 +1,8 @@
1<?php 1<?php
2namespace Shaarli\Config;
2 3
3// FIXME! Namespaces... 4use Shaarli\Config\Exception\MissingFieldConfigException;
4require_once 'ConfigIO.php'; 5use Shaarli\Config\Exception\UnauthorizedConfigException;
5require_once 'ConfigJson.php';
6require_once 'ConfigPhp.php';
7 6
8/** 7/**
9 * Class ConfigManager 8 * Class ConfigManager
@@ -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('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');
@@ -308,14 +315,14 @@ class ConfigManager
308 315
309 $this->setEmpty('general.header_link', '?'); 316 $this->setEmpty('general.header_link', '?');
310 $this->setEmpty('general.links_per_page', 20); 317 $this->setEmpty('general.links_per_page', 20);
311 $this->setEmpty('general.enabled_plugins', array('qrcode')); 318 $this->setEmpty('general.enabled_plugins', self::$DEFAULT_PLUGINS);
312 319
313 $this->setEmpty('updates.check_updates', false); 320 $this->setEmpty('updates.check_updates', false);
314 $this->setEmpty('updates.check_updates_branch', 'stable'); 321 $this->setEmpty('updates.check_updates_branch', 'stable');
315 $this->setEmpty('updates.check_updates_interval', 86400); 322 $this->setEmpty('updates.check_updates_interval', 86400);
316 323
317 $this->setEmpty('feed.rss_permalinks', true); 324 $this->setEmpty('feed.rss_permalinks', true);
318 $this->setEmpty('feed.show_atom', false); 325 $this->setEmpty('feed.show_atom', true);
319 326
320 $this->setEmpty('privacy.default_private_links', false); 327 $this->setEmpty('privacy.default_private_links', false);
321 $this->setEmpty('privacy.hide_public_links', false); 328 $this->setEmpty('privacy.hide_public_links', false);
@@ -359,36 +366,3 @@ class ConfigManager
359 $this->configIO = $configIO; 366 $this->configIO = $configIO;
360 } 367 }
361} 368}
362
363/**
364 * Exception used if a mandatory field is missing in given configuration.
365 */
366class MissingFieldConfigException extends Exception
367{
368 public $field;
369
370 /**
371 * Construct exception.
372 *
373 * @param string $field field name missing.
374 */
375 public function __construct($field)
376 {
377 $this->field = $field;
378 $this->message = 'Configuration value is required for '. $this->field;
379 }
380}
381
382/**
383 * Exception used if an unauthorized attempt to edit configuration has been made.
384 */
385class UnauthorizedConfigException extends Exception
386{
387 /**
388 * Construct exception.
389 */
390 public function __construct()
391 {
392 $this->message = 'You are not authorized to alter config.';
393 }
394}
diff --git a/application/config/ConfigPhp.php b/application/config/ConfigPhp.php
index 27187b66..2633824d 100644
--- a/application/config/ConfigPhp.php
+++ b/application/config/ConfigPhp.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2namespace Shaarli\Config;
2 3
3/** 4/**
4 * Class ConfigPhp (ConfigIO implementation) 5 * Class ConfigPhp (ConfigIO implementation)
@@ -41,6 +42,7 @@ class ConfigPhp implements ConfigIO
41 'resource.log' => 'config.LOG_FILE', 42 'resource.log' => 'config.LOG_FILE',
42 'resource.update_check' => 'config.UPDATECHECK_FILENAME', 43 'resource.update_check' => 'config.UPDATECHECK_FILENAME',
43 'resource.raintpl_tpl' => 'config.RAINTPL_TPL', 44 'resource.raintpl_tpl' => 'config.RAINTPL_TPL',
45 'resource.theme' => 'config.theme',
44 'resource.raintpl_tmp' => 'config.RAINTPL_TMP', 46 'resource.raintpl_tmp' => 'config.RAINTPL_TMP',
45 'resource.thumbnails_cache' => 'config.CACHEDIR', 47 'resource.thumbnails_cache' => 'config.CACHEDIR',
46 'resource.page_cache' => 'config.PAGECACHE', 48 'resource.page_cache' => 'config.PAGECACHE',
@@ -71,7 +73,7 @@ class ConfigPhp implements ConfigIO
71 /** 73 /**
72 * @inheritdoc 74 * @inheritdoc
73 */ 75 */
74 function read($filepath) 76 public function read($filepath)
75 { 77 {
76 if (! file_exists($filepath) || ! is_readable($filepath)) { 78 if (! file_exists($filepath) || ! is_readable($filepath)) {
77 return array(); 79 return array();
@@ -91,7 +93,7 @@ class ConfigPhp implements ConfigIO
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,7 +116,7 @@ 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 '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.'
@@ -125,7 +127,7 @@ class ConfigPhp implements ConfigIO
125 /** 127 /**
126 * @inheritdoc 128 * @inheritdoc
127 */ 129 */
128 function getExtension() 130 public function getExtension()
129 { 131 {
130 return '.php'; 132 return '.php';
131 } 133 }
diff --git a/application/config/ConfigPlugin.php b/application/config/ConfigPlugin.php
index cb0b6fce..b3d9752b 100644
--- a/application/config/ConfigPlugin.php
+++ b/application/config/ConfigPlugin.php
@@ -1,4 +1,7 @@
1<?php 1<?php
2
3use Shaarli\Config\Exception\PluginConfigOrderException;
4
2/** 5/**
3 * Plugin configuration helper functions. 6 * Plugin configuration helper functions.
4 * 7 *
@@ -108,17 +111,3 @@ function load_plugin_parameter_values($plugins, $conf)
108 111
109 return $out; 112 return $out;
110} 113}
111
112/**
113 * Exception used if an error occur while saving plugin configuration.
114 */
115class PluginConfigOrderException extends Exception
116{
117 /**
118 * Construct exception.
119 */
120 public function __construct()
121 {
122 $this->message = 'An error occurred while trying to save plugins loading order.';
123 }
124}
diff --git a/application/config/exception/MissingFieldConfigException.php b/application/config/exception/MissingFieldConfigException.php
new file mode 100644
index 00000000..6346c6a9
--- /dev/null
+++ b/application/config/exception/MissingFieldConfigException.php
@@ -0,0 +1,23 @@
1<?php
2
3
4namespace Shaarli\Config\Exception;
5
6/**
7 * Exception used if a mandatory field is missing in given configuration.
8 */
9class MissingFieldConfigException extends \Exception
10{
11 public $field;
12
13 /**
14 * Construct exception.
15 *
16 * @param string $field field name missing.
17 */
18 public function __construct($field)
19 {
20 $this->field = $field;
21 $this->message = 'Configuration value is required for '. $this->field;
22 }
23}
diff --git a/application/config/exception/PluginConfigOrderException.php b/application/config/exception/PluginConfigOrderException.php
new file mode 100644
index 00000000..f9d68750
--- /dev/null
+++ b/application/config/exception/PluginConfigOrderException.php
@@ -0,0 +1,17 @@
1<?php
2
3namespace Shaarli\Config\Exception;
4
5/**
6 * Exception used if an error occur while saving plugin configuration.
7 */
8class PluginConfigOrderException extends \Exception
9{
10 /**
11 * Construct exception.
12 */
13 public function __construct()
14 {
15 $this->message = '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..79672c1b
--- /dev/null
+++ b/application/config/exception/UnauthorizedConfigException.php
@@ -0,0 +1,18 @@
1<?php
2
3
4namespace Shaarli\Config\Exception;
5
6/**
7 * Exception used if an unauthorized attempt to edit configuration has been made.
8 */
9class UnauthorizedConfigException extends \Exception
10{
11 /**
12 * Construct exception.
13 */
14 public function __construct()
15 {
16 $this->message = '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..b563b23d
--- /dev/null
+++ b/application/exceptions/IOException.php
@@ -0,0 +1,22 @@
1<?php
2
3/**
4 * Exception class thrown when a filesystem access failure happens
5 */
6class IOException extends Exception
7{
8 private $path;
9
10 /**
11 * Construct a new IOException
12 *
13 * @param string $path path to the resource that cannot be accessed
14 * @param string $message Custom exception message.
15 */
16 public function __construct($path, $message = '')
17 {
18 $this->path = $path;
19 $this->message = empty($message) ? 'Error accessing' : $message;
20 $this->message .= ' "' . $this->path .'"';
21 }
22}