diff options
Diffstat (limited to 'application')
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 | */ |
5 | class ApplicationUtils | 5 | class ApplicationUtils |
6 | { | 6 | { |
7 | /** | ||
8 | * @var string File containing the current version | ||
9 | */ | ||
10 | public static $VERSION_FILE = 'shaarli_version.php'; | ||
11 | |||
7 | private static $GIT_URL = 'https://raw.githubusercontent.com/shaarli/Shaarli'; | 12 | private static $GIT_URL = 'https://raw.githubusercontent.com/shaarli/Shaarli'; |
8 | private static $GIT_BRANCHES = array('master', 'stable'); | 13 | private static $GIT_BRANCHES = array('latest', 'stable'); |
9 | private static $VERSION_FILE = 'shaarli_version.php'; | ||
10 | private static $VERSION_START_TAG = '<?php /* '; | 14 | private static $VERSION_START_TAG = '<?php /* '; |
11 | private static $VERSION_END_TAG = ' */ ?>'; | 15 | private static $VERSION_END_TAG = ' */ ?>'; |
12 | 16 | ||
@@ -29,6 +33,30 @@ class ApplicationUtils | |||
29 | return false; | 33 | return false; |
30 | } | 34 | } |
31 | 35 | ||
36 | return $data; | ||
37 | } | ||
38 | |||
39 | /** | ||
40 | * Retrieve the version from a remote URL or a file. | ||
41 | * | ||
42 | * @param string $remote URL or file to fetch. | ||
43 | * @param int $timeout For URLs fetching. | ||
44 | * | ||
45 | * @return bool|string The version or false if it couldn't be retrieved. | ||
46 | */ | ||
47 | public static function getVersion($remote, $timeout = 2) | ||
48 | { | ||
49 | if (startsWith($remote, 'http')) { | ||
50 | if (($data = static::getLatestGitVersionCode($remote, $timeout)) === false) { | ||
51 | return false; | ||
52 | } | ||
53 | } else { | ||
54 | if (! is_file($remote)) { | ||
55 | return false; | ||
56 | } | ||
57 | $data = file_get_contents($remote); | ||
58 | } | ||
59 | |||
32 | return str_replace( | 60 | return str_replace( |
33 | array(self::$VERSION_START_TAG, self::$VERSION_END_TAG, PHP_EOL), | 61 | array(self::$VERSION_START_TAG, self::$VERSION_END_TAG, PHP_EOL), |
34 | array('', '', ''), | 62 | array('', '', ''), |
@@ -65,13 +93,10 @@ class ApplicationUtils | |||
65 | $isLoggedIn, | 93 | $isLoggedIn, |
66 | $branch='stable') | 94 | $branch='stable') |
67 | { | 95 | { |
68 | if (! $isLoggedIn) { | 96 | // Do not check versions for visitors |
69 | // Do not check versions for visitors | 97 | // Do not check if the user doesn't want to |
70 | return false; | 98 | // Do not check with dev version |
71 | } | 99 | if (! $isLoggedIn || empty($enableCheck) || $currentVersion === 'dev') { |
72 | |||
73 | if (empty($enableCheck)) { | ||
74 | // Do not check if the user doesn't want to | ||
75 | return false; | 100 | return false; |
76 | } | 101 | } |
77 | 102 | ||
@@ -93,7 +118,7 @@ class ApplicationUtils | |||
93 | 118 | ||
94 | // Late Static Binding allows overriding within tests | 119 | // Late Static Binding allows overriding within tests |
95 | // See http://php.net/manual/en/language.oop5.late-static-bindings.php | 120 | // See http://php.net/manual/en/language.oop5.late-static-bindings.php |
96 | $latestVersion = static::getLatestGitVersionCode( | 121 | $latestVersion = static::getVersion( |
97 | self::$GIT_URL . '/' . $branch . '/' . self::$VERSION_FILE | 122 | self::$GIT_URL . '/' . $branch . '/' . self::$VERSION_FILE |
98 | ); | 123 | ); |
99 | 124 | ||
@@ -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 | |||
3 | namespace Shaarli; | ||
4 | |||
5 | |||
6 | /** | ||
7 | * URL-safe Base64 operations | ||
8 | * | ||
9 | * @see https://en.wikipedia.org/wiki/Base64#URL_applications | ||
10 | */ | ||
11 | class Base64Url | ||
12 | { | ||
13 | /** | ||
14 | * Base64Url-encodes data | ||
15 | * | ||
16 | * @param string $data Data to encode | ||
17 | * | ||
18 | * @return string Base64Url-encoded data | ||
19 | */ | ||
20 | public static function encode($data) { | ||
21 | return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); | ||
22 | } | ||
23 | |||
24 | /** | ||
25 | * Decodes Base64Url-encoded data | ||
26 | * | ||
27 | * @param string $data Data to decode | ||
28 | * | ||
29 | * @return string Decoded data | ||
30 | */ | ||
31 | public static function decode($data) { | ||
32 | return base64_decode(str_pad(strtr($data, '-_', '+/'), strlen($data) % 4, '=', STR_PAD_RIGHT)); | ||
33 | } | ||
34 | } | ||
diff --git a/application/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 | |||
3 | require_once 'exceptions/IOException.php'; | ||
4 | |||
2 | /** | 5 | /** |
3 | * Exception class thrown when a filesystem access failure happens | 6 | * Class FileUtils |
7 | * | ||
8 | * Utility class for file manipulation. | ||
4 | */ | 9 | */ |
5 | class IOException extends Exception | 10 | class FileUtils |
6 | { | 11 | { |
7 | private $path; | 12 | /** |
13 | * @var string | ||
14 | */ | ||
15 | protected static $phpPrefix = '<?php /* '; | ||
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 | */ | ||
22 | class 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 | ||
3 | use Psr\Log\LogLevel; | ||
4 | use Shaarli\Config\ConfigManager; | ||
5 | use Shaarli\NetscapeBookmarkParser\NetscapeBookmarkParser; | ||
6 | use Katzgrau\KLogger\Logger; | ||
7 | |||
3 | /** | 8 | /** |
4 | * Utilities to import and export bookmarks using the Netscape format | 9 | * Utilities to import and export bookmarks using the Netscape format |
10 | * TODO: Not static, use a container. | ||
5 | */ | 11 | */ |
6 | class NetscapeBookmarkUtils | 12 | class NetscapeBookmarkUtils |
7 | { | 13 | { |
@@ -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 | ||
3 | use Shaarli\Config\ConfigManager; | ||
4 | |||
3 | /** | 5 | /** |
4 | * This class is in charge of building the final page. | 6 | * This class is in charge of building the final page. |
5 | * (This is basically a wrapper around RainTPL which pre-fills some fields.) | 7 | * (This is basically a wrapper around RainTPL which pre-fills some fields.) |
@@ -20,15 +22,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 | |||
3 | namespace Shaarli; | ||
4 | |||
5 | /** | ||
6 | * Class ThemeUtils | ||
7 | * | ||
8 | * Utility functions related to theme management. | ||
9 | * | ||
10 | * @package Shaarli | ||
11 | */ | ||
12 | class ThemeUtils | ||
13 | { | ||
14 | /** | ||
15 | * Get a list of available themes. | ||
16 | * | ||
17 | * It will return the name of any directory present in the template folder. | ||
18 | * | ||
19 | * @param string $tplDir Templates main directory. | ||
20 | * | ||
21 | * @return array List of theme names. | ||
22 | */ | ||
23 | public static function getThemes($tplDir) | ||
24 | { | ||
25 | $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 | **/ |
14 | function generateTimeZoneForm($preselectedTimezone='') | 38 | function generateTimeZoneData($installedTimeZones, $preselectedTimezone = '') |
15 | { | 39 | { |
16 | // Select the server timezone | ||
17 | if ($preselectedTimezone == '') { | ||
18 | $preselectedTimezone = date_default_timezone_get(); | ||
19 | } | ||
20 | |||
21 | if ($preselectedTimezone == 'UTC') { | 40 | if ($preselectedTimezone == 'UTC') { |
22 | $pcity = $pcontinent = 'UTC'; | 41 | $pcity = $pcontinent = 'UTC'; |
23 | } else { | 42 | } else { |
@@ -27,62 +46,30 @@ function generateTimeZoneForm($preselectedTimezone='') | |||
27 | $pcity = substr($preselectedTimezone, $spos+1); | 46 | $pcity = substr($preselectedTimezone, $spos+1); |
28 | } | 47 | } |
29 | 48 | ||
30 | // The list is in the form 'Europe/Paris', 'America/Argentina/Buenos_Aires' | 49 | $continents = []; |
31 | // We split the list in continents/cities. | 50 | $cities = []; |
32 | $continents = array(); | 51 | foreach ($installedTimeZones as $tz) { |
33 | $cities = array(); | ||
34 | |||
35 | // TODO: use a template to generate the HTML/Javascript form | ||
36 | |||
37 | foreach (timezone_identifiers_list() as $tz) { | ||
38 | if ($tz == 'UTC') { | 52 | if ($tz == 'UTC') { |
39 | $tz = 'UTC/UTC'; | 53 | $tz = 'UTC/UTC'; |
40 | } | 54 | } |
41 | $spos = strpos($tz, '/'); | 55 | $spos = strpos($tz, '/'); |
42 | 56 | ||
43 | if ($spos !== false) { | 57 | // Ignore invalid timezones |
44 | $continent = substr($tz, 0, $spos); | 58 | if ($spos === false) { |
45 | $city = substr($tz, $spos+1); | 59 | continue; |
46 | $continents[$continent] = 1; | ||
47 | |||
48 | if (!isset($cities[$continent])) { | ||
49 | $cities[$continent] = ''; | ||
50 | } | ||
51 | $cities[$continent] .= '<option value="'.$city.'"'; | ||
52 | if ($pcity == $city) { | ||
53 | $cities[$continent] .= ' selected="selected"'; | ||
54 | } | ||
55 | $cities[$continent] .= '>'.$city.'</option>'; | ||
56 | } | 60 | } |
57 | } | ||
58 | |||
59 | $continentsHtml = ''; | ||
60 | $continents = array_keys($continents); | ||
61 | 61 | ||
62 | foreach ($continents as $continent) { | 62 | $continent = substr($tz, 0, $spos); |
63 | $continentsHtml .= '<option value="'.$continent.'"'; | 63 | $city = substr($tz, $spos+1); |
64 | if ($pcontinent == $continent) { | 64 | $cities[] = ['continent' => $continent, 'city' => $city]; |
65 | $continentsHtml .= ' selected="selected"'; | 65 | $continents[$continent] = true; |
66 | } | ||
67 | $continentsHtml .= '>'.$continent.'</option>'; | ||
68 | } | 66 | } |
69 | 67 | ||
70 | // Timezone selection form | 68 | $continents = array_keys($continents); |
71 | $timezoneForm = 'Continent:'; | 69 | $continents['selected'] = $pcontinent; |
72 | $timezoneForm .= '<select name="continent" id="continent" onChange="onChangecontinent();">'; | 70 | $cities['selected'] = $pcity; |
73 | $timezoneForm .= $continentsHtml.'</select>'; | ||
74 | $timezoneForm .= ' City:'; | ||
75 | $timezoneForm .= '<select name="city" id="city">'.$cities[$pcontinent].'</select><br />'; | ||
76 | |||
77 | // Javascript handler - updates the city list when the user selects a continent | ||
78 | $timezoneJs = '<script>'; | ||
79 | $timezoneJs .= 'function onChangecontinent() {'; | ||
80 | $timezoneJs .= 'document.getElementById("city").innerHTML ='; | ||
81 | $timezoneJs .= ' citiescontinent[document.getElementById("continent").value]; }'; | ||
82 | $timezoneJs .= 'var citiescontinent = '.json_encode($cities).';'; | ||
83 | $timezoneJs .= '</script>'; | ||
84 | 71 | ||
85 | return array($timezoneForm, $timezoneJs); | 72 | return [$continents, $cities]; |
86 | } | 73 | } |
87 | 74 | ||
88 | /** | 75 | /** |
diff --git a/application/Updater.php b/application/Updater.php index 555d4c25..03d93a6f 100644 --- a/application/Updater.php +++ b/application/Updater.php | |||
@@ -1,4 +1,7 @@ | |||
1 | <?php | 1 | <?php |
2 | use Shaarli\Config\ConfigJson; | ||
3 | use Shaarli\Config\ConfigPhp; | ||
4 | use Shaarli\Config\ConfigManager; | ||
2 | 5 | ||
3 | /** | 6 | /** |
4 | * Class Updater. | 7 | * Class Updater. |
@@ -69,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) | |||
216 | function autoLocale($headerLocale) | 216 | function 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 | */ | ||
262 | function 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 | */ | ||
295 | function 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 | */ | ||
312 | function 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 | */ | ||
329 | function 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 | */ | ||
360 | function 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 | */ | ||
376 | function 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 | */ | ||
399 | function 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 | */ | ||
430 | function 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 | ||
2 | namespace Shaarli\Api; | ||
3 | |||
4 | use Shaarli\Api\Exceptions\ApiException; | ||
5 | use Shaarli\Api\Exceptions\ApiAuthorizationException; | ||
6 | |||
7 | use Shaarli\Config\ConfigManager; | ||
8 | use Slim\Container; | ||
9 | use Slim\Http\Request; | ||
10 | use Slim\Http\Response; | ||
11 | |||
12 | /** | ||
13 | * Class ApiMiddleware | ||
14 | * | ||
15 | * This will be called before accessing any API Controller. | ||
16 | * Its role is to make sure that the API is enabled, configured, and to validate the JWT token. | ||
17 | * | ||
18 | * If the request is validated, the controller is called, otherwise a JSON error response is returned. | ||
19 | * | ||
20 | * @package Api | ||
21 | */ | ||
22 | class ApiMiddleware | ||
23 | { | ||
24 | /** | ||
25 | * @var int JWT token validity in seconds (9 min). | ||
26 | */ | ||
27 | public static $TOKEN_DURATION = 540; | ||
28 | |||
29 | /** | ||
30 | * @var Container: contains conf, plugins, etc. | ||
31 | */ | ||
32 | protected $container; | ||
33 | |||
34 | /** | ||
35 | * @var ConfigManager instance. | ||
36 | */ | ||
37 | protected $conf; | ||
38 | |||
39 | /** | ||
40 | * ApiMiddleware constructor. | ||
41 | * | ||
42 | * @param Container $container instance. | ||
43 | */ | ||
44 | public function __construct($container) | ||
45 | { | ||
46 | $this->container = $container; | ||
47 | $this->conf = $this->container->get('conf'); | ||
48 | $this->setLinkDb($this->conf); | ||
49 | } | ||
50 | |||
51 | /** | ||
52 | * Middleware execution: | ||
53 | * - check the API request | ||
54 | * - execute the controller | ||
55 | * - return the response | ||
56 | * | ||
57 | * @param Request $request Slim request | ||
58 | * @param Response $response Slim response | ||
59 | * @param callable $next Next action | ||
60 | * | ||
61 | * @return Response response. | ||
62 | */ | ||
63 | public function __invoke($request, $response, $next) | ||
64 | { | ||
65 | try { | ||
66 | $this->checkRequest($request); | ||
67 | $response = $next($request, $response); | ||
68 | } catch(ApiException $e) { | ||
69 | $e->setResponse($response); | ||
70 | $e->setDebug($this->conf->get('dev.debug', false)); | ||
71 | $response = $e->getApiResponse(); | ||
72 | } | ||
73 | |||
74 | return $response; | ||
75 | } | ||
76 | |||
77 | /** | ||
78 | * Check the request validity (HTTP method, request value, etc.), | ||
79 | * that the API is enabled, and the JWT token validity. | ||
80 | * | ||
81 | * @param Request $request Slim request | ||
82 | * | ||
83 | * @throws ApiAuthorizationException The API is disabled or the token is invalid. | ||
84 | */ | ||
85 | protected function checkRequest($request) | ||
86 | { | ||
87 | if (! $this->conf->get('api.enabled', true)) { | ||
88 | throw new ApiAuthorizationException('API is disabled'); | ||
89 | } | ||
90 | $this->checkToken($request); | ||
91 | } | ||
92 | |||
93 | /** | ||
94 | * Check that the JWT token is set and valid. | ||
95 | * The API secret setting must be set. | ||
96 | * | ||
97 | * @param Request $request Slim request | ||
98 | * | ||
99 | * @throws ApiAuthorizationException The token couldn't be validated. | ||
100 | */ | ||
101 | protected function checkToken($request) { | ||
102 | if (! $request->hasHeader('Authorization')) { | ||
103 | throw new ApiAuthorizationException('JWT token not provided'); | ||
104 | } | ||
105 | |||
106 | if (empty($this->conf->get('api.secret'))) { | ||
107 | throw new ApiAuthorizationException('Token secret must be set in Shaarli\'s administration'); | ||
108 | } | ||
109 | |||
110 | $authorization = $request->getHeaderLine('Authorization'); | ||
111 | |||
112 | if (! preg_match('/^Bearer (.*)/i', $authorization, $matches)) { | ||
113 | throw new ApiAuthorizationException('Invalid JWT header'); | ||
114 | } | ||
115 | |||
116 | ApiUtils::validateJwtToken($matches[1], $this->conf->get('api.secret')); | ||
117 | } | ||
118 | |||
119 | /** | ||
120 | * Instantiate a new LinkDB including private links, | ||
121 | * and load in the Slim container. | ||
122 | * | ||
123 | * FIXME! LinkDB could use a refactoring to avoid this trick. | ||
124 | * | ||
125 | * @param ConfigManager $conf instance. | ||
126 | */ | ||
127 | protected function setLinkDb($conf) | ||
128 | { | ||
129 | $linkDb = new \LinkDB( | ||
130 | $conf->get('resource.datastore'), | ||
131 | true, | ||
132 | $conf->get('privacy.hide_public_links'), | ||
133 | $conf->get('redirector.url'), | ||
134 | $conf->get('redirector.encode_url') | ||
135 | ); | ||
136 | $this->container['db'] = $linkDb; | ||
137 | } | ||
138 | } | ||
diff --git a/application/api/ApiUtils.php b/application/api/ApiUtils.php new file mode 100644 index 00000000..f154bb52 --- /dev/null +++ b/application/api/ApiUtils.php | |||
@@ -0,0 +1,137 @@ | |||
1 | <?php | ||
2 | namespace Shaarli\Api; | ||
3 | |||
4 | use Shaarli\Base64Url; | ||
5 | use Shaarli\Api\Exceptions\ApiAuthorizationException; | ||
6 | |||
7 | /** | ||
8 | * REST API utilities | ||
9 | */ | ||
10 | class ApiUtils | ||
11 | { | ||
12 | /** | ||
13 | * Validates a JWT token authenticity. | ||
14 | * | ||
15 | * @param string $token JWT token extracted from the headers. | ||
16 | * @param string $secret API secret set in the settings. | ||
17 | * | ||
18 | * @throws ApiAuthorizationException the token is not valid. | ||
19 | */ | ||
20 | public static function validateJwtToken($token, $secret) | ||
21 | { | ||
22 | $parts = explode('.', $token); | ||
23 | if (count($parts) != 3 || strlen($parts[0]) == 0 || strlen($parts[1]) == 0) { | ||
24 | throw new ApiAuthorizationException('Malformed JWT token'); | ||
25 | } | ||
26 | |||
27 | $genSign = Base64Url::encode(hash_hmac('sha512', $parts[0] .'.'. $parts[1], $secret, true)); | ||
28 | if ($parts[2] != $genSign) { | ||
29 | throw new ApiAuthorizationException('Invalid JWT signature'); | ||
30 | } | ||
31 | |||
32 | $header = json_decode(Base64Url::decode($parts[0])); | ||
33 | if ($header === null) { | ||
34 | throw new ApiAuthorizationException('Invalid JWT header'); | ||
35 | } | ||
36 | |||
37 | $payload = json_decode(Base64Url::decode($parts[1])); | ||
38 | if ($payload === null) { | ||
39 | throw new ApiAuthorizationException('Invalid JWT payload'); | ||
40 | } | ||
41 | |||
42 | if (empty($payload->iat) | ||
43 | || $payload->iat > time() | ||
44 | || time() - $payload->iat > ApiMiddleware::$TOKEN_DURATION | ||
45 | ) { | ||
46 | throw new ApiAuthorizationException('Invalid JWT issued time'); | ||
47 | } | ||
48 | } | ||
49 | |||
50 | /** | ||
51 | * Format a Link for the REST API. | ||
52 | * | ||
53 | * @param array $link Link data read from the datastore. | ||
54 | * @param string $indexUrl Shaarli's index URL (used for relative URL). | ||
55 | * | ||
56 | * @return array Link data formatted for the REST API. | ||
57 | */ | ||
58 | public static function formatLink($link, $indexUrl) | ||
59 | { | ||
60 | $out['id'] = $link['id']; | ||
61 | // Not an internal link | ||
62 | if ($link['url'][0] != '?') { | ||
63 | $out['url'] = $link['url']; | ||
64 | } else { | ||
65 | $out['url'] = $indexUrl . $link['url']; | ||
66 | } | ||
67 | $out['shorturl'] = $link['shorturl']; | ||
68 | $out['title'] = $link['title']; | ||
69 | $out['description'] = $link['description']; | ||
70 | $out['tags'] = preg_split('/\s+/', $link['tags'], -1, PREG_SPLIT_NO_EMPTY); | ||
71 | $out['private'] = $link['private'] == true; | ||
72 | $out['created'] = $link['created']->format(\DateTime::ATOM); | ||
73 | if (! empty($link['updated'])) { | ||
74 | $out['updated'] = $link['updated']->format(\DateTime::ATOM); | ||
75 | } else { | ||
76 | $out['updated'] = ''; | ||
77 | } | ||
78 | return $out; | ||
79 | } | ||
80 | |||
81 | /** | ||
82 | * Convert a link given through a request, to a valid link for LinkDB. | ||
83 | * | ||
84 | * If no URL is provided, it will generate a local note URL. | ||
85 | * If no title is provided, it will use the URL as title. | ||
86 | * | ||
87 | * @param array $input Request Link. | ||
88 | * @param bool $defaultPrivate Request Link. | ||
89 | * | ||
90 | * @return array Formatted link. | ||
91 | */ | ||
92 | public static function buildLinkFromRequest($input, $defaultPrivate) | ||
93 | { | ||
94 | $input['url'] = ! empty($input['url']) ? cleanup_url($input['url']) : ''; | ||
95 | if (isset($input['private'])) { | ||
96 | $private = filter_var($input['private'], FILTER_VALIDATE_BOOLEAN); | ||
97 | } else { | ||
98 | $private = $defaultPrivate; | ||
99 | } | ||
100 | |||
101 | $link = [ | ||
102 | 'title' => ! empty($input['title']) ? $input['title'] : $input['url'], | ||
103 | 'url' => $input['url'], | ||
104 | 'description' => ! empty($input['description']) ? $input['description'] : '', | ||
105 | 'tags' => ! empty($input['tags']) ? implode(' ', $input['tags']) : '', | ||
106 | 'private' => $private, | ||
107 | 'created' => new \DateTime(), | ||
108 | ]; | ||
109 | return $link; | ||
110 | } | ||
111 | |||
112 | /** | ||
113 | * Update link fields using an updated link object. | ||
114 | * | ||
115 | * @param array $oldLink data | ||
116 | * @param array $newLink data | ||
117 | * | ||
118 | * @return array $oldLink updated with $newLink values | ||
119 | */ | ||
120 | public static function updateLink($oldLink, $newLink) | ||
121 | { | ||
122 | foreach (['title', 'url', 'description', 'tags', 'private'] as $field) { | ||
123 | $oldLink[$field] = $newLink[$field]; | ||
124 | } | ||
125 | $oldLink['updated'] = new \DateTime(); | ||
126 | |||
127 | if (empty($oldLink['url'])) { | ||
128 | $oldLink['url'] = '?' . $oldLink['shorturl']; | ||
129 | } | ||
130 | |||
131 | if (empty($oldLink['title'])) { | ||
132 | $oldLink['title'] = $oldLink['url']; | ||
133 | } | ||
134 | |||
135 | return $oldLink; | ||
136 | } | ||
137 | } | ||
diff --git a/application/api/controllers/ApiController.php b/application/api/controllers/ApiController.php new file mode 100644 index 00000000..3be85b98 --- /dev/null +++ b/application/api/controllers/ApiController.php | |||
@@ -0,0 +1,71 @@ | |||
1 | <?php | ||
2 | |||
3 | namespace Shaarli\Api\Controllers; | ||
4 | |||
5 | use Shaarli\Config\ConfigManager; | ||
6 | use \Slim\Container; | ||
7 | |||
8 | /** | ||
9 | * Abstract Class ApiController | ||
10 | * | ||
11 | * Defines REST API Controller dependencies injected from the container. | ||
12 | * | ||
13 | * @package Api\Controllers | ||
14 | */ | ||
15 | abstract class ApiController | ||
16 | { | ||
17 | /** | ||
18 | * @var Container | ||
19 | */ | ||
20 | protected $ci; | ||
21 | |||
22 | /** | ||
23 | * @var ConfigManager | ||
24 | */ | ||
25 | protected $conf; | ||
26 | |||
27 | /** | ||
28 | * @var \LinkDB | ||
29 | */ | ||
30 | protected $linkDb; | ||
31 | |||
32 | /** | ||
33 | * @var \History | ||
34 | */ | ||
35 | protected $history; | ||
36 | |||
37 | /** | ||
38 | * @var int|null JSON style option. | ||
39 | */ | ||
40 | protected $jsonStyle; | ||
41 | |||
42 | /** | ||
43 | * ApiController constructor. | ||
44 | * | ||
45 | * Note: enabling debug mode displays JSON with readable formatting. | ||
46 | * | ||
47 | * @param Container $ci Slim container. | ||
48 | */ | ||
49 | public function __construct(Container $ci) | ||
50 | { | ||
51 | $this->ci = $ci; | ||
52 | $this->conf = $ci->get('conf'); | ||
53 | $this->linkDb = $ci->get('db'); | ||
54 | $this->history = $ci->get('history'); | ||
55 | if ($this->conf->get('dev.debug', false)) { | ||
56 | $this->jsonStyle = JSON_PRETTY_PRINT; | ||
57 | } else { | ||
58 | $this->jsonStyle = null; | ||
59 | } | ||
60 | } | ||
61 | |||
62 | /** | ||
63 | * Get the container. | ||
64 | * | ||
65 | * @return Container | ||
66 | */ | ||
67 | public function getCi() | ||
68 | { | ||
69 | return $this->ci; | ||
70 | } | ||
71 | } | ||
diff --git a/application/api/controllers/History.php b/application/api/controllers/History.php new file mode 100644 index 00000000..2ff9deaf --- /dev/null +++ b/application/api/controllers/History.php | |||
@@ -0,0 +1,70 @@ | |||
1 | <?php | ||
2 | |||
3 | |||
4 | namespace Shaarli\Api\Controllers; | ||
5 | |||
6 | use Shaarli\Api\Exceptions\ApiBadParametersException; | ||
7 | use Slim\Http\Request; | ||
8 | use Slim\Http\Response; | ||
9 | |||
10 | /** | ||
11 | * Class History | ||
12 | * | ||
13 | * REST API Controller: /history | ||
14 | * | ||
15 | * @package Shaarli\Api\Controllers | ||
16 | */ | ||
17 | class History extends ApiController | ||
18 | { | ||
19 | /** | ||
20 | * Service providing operation regarding Shaarli datastore and settings. | ||
21 | * | ||
22 | * @param Request $request Slim request. | ||
23 | * @param Response $response Slim response. | ||
24 | * | ||
25 | * @return Response response. | ||
26 | * | ||
27 | * @throws ApiBadParametersException Invalid parameters. | ||
28 | */ | ||
29 | public function getHistory($request, $response) | ||
30 | { | ||
31 | $history = $this->history->getHistory(); | ||
32 | |||
33 | // Return history operations from the {offset}th, starting from {since}. | ||
34 | $since = \DateTime::createFromFormat(\DateTime::ATOM, $request->getParam('since')); | ||
35 | $offset = $request->getParam('offset'); | ||
36 | if (empty($offset)) { | ||
37 | $offset = 0; | ||
38 | } | ||
39 | else if (ctype_digit($offset)) { | ||
40 | $offset = (int) $offset; | ||
41 | } else { | ||
42 | throw new ApiBadParametersException('Invalid offset'); | ||
43 | } | ||
44 | |||
45 | // limit parameter is either a number of links or 'all' for everything. | ||
46 | $limit = $request->getParam('limit'); | ||
47 | if (empty($limit)) { | ||
48 | $limit = count($history); | ||
49 | } else if (ctype_digit($limit)) { | ||
50 | $limit = (int) $limit; | ||
51 | } else { | ||
52 | throw new ApiBadParametersException('Invalid limit'); | ||
53 | } | ||
54 | |||
55 | $out = []; | ||
56 | $i = 0; | ||
57 | foreach ($history as $entry) { | ||
58 | if ((! empty($since) && $entry['datetime'] <= $since) || count($out) >= $limit) { | ||
59 | break; | ||
60 | } | ||
61 | if (++$i > $offset) { | ||
62 | $out[$i] = $entry; | ||
63 | $out[$i]['datetime'] = $out[$i]['datetime']->format(\DateTime::ATOM); | ||
64 | } | ||
65 | } | ||
66 | $out = array_values($out); | ||
67 | |||
68 | return $response->withJson($out, 200, $this->jsonStyle); | ||
69 | } | ||
70 | } | ||
diff --git a/application/api/controllers/Info.php b/application/api/controllers/Info.php new file mode 100644 index 00000000..25433f72 --- /dev/null +++ b/application/api/controllers/Info.php | |||
@@ -0,0 +1,42 @@ | |||
1 | <?php | ||
2 | |||
3 | namespace Shaarli\Api\Controllers; | ||
4 | |||
5 | use Slim\Http\Request; | ||
6 | use Slim\Http\Response; | ||
7 | |||
8 | /** | ||
9 | * Class Info | ||
10 | * | ||
11 | * REST API Controller: /info | ||
12 | * | ||
13 | * @package Api\Controllers | ||
14 | * @see http://shaarli.github.io/api-documentation/#links-instance-information-get | ||
15 | */ | ||
16 | class Info extends ApiController | ||
17 | { | ||
18 | /** | ||
19 | * Service providing various information about Shaarli instance. | ||
20 | * | ||
21 | * @param Request $request Slim request. | ||
22 | * @param Response $response Slim response. | ||
23 | * | ||
24 | * @return Response response. | ||
25 | */ | ||
26 | public function getInfo($request, $response) | ||
27 | { | ||
28 | $info = [ | ||
29 | 'global_counter' => count($this->linkDb), | ||
30 | 'private_counter' => count_private($this->linkDb), | ||
31 | 'settings' => array( | ||
32 | 'title' => $this->conf->get('general.title', 'Shaarli'), | ||
33 | 'header_link' => $this->conf->get('general.header_link', '?'), | ||
34 | 'timezone' => $this->conf->get('general.timezone', 'UTC'), | ||
35 | 'enabled_plugins' => $this->conf->get('general.enabled_plugins', []), | ||
36 | 'default_private_links' => $this->conf->get('privacy.default_private_links', false), | ||
37 | ), | ||
38 | ]; | ||
39 | |||
40 | return $response->withJson($info, 200, $this->jsonStyle); | ||
41 | } | ||
42 | } | ||
diff --git a/application/api/controllers/Links.php b/application/api/controllers/Links.php new file mode 100644 index 00000000..eb78dd26 --- /dev/null +++ b/application/api/controllers/Links.php | |||
@@ -0,0 +1,217 @@ | |||
1 | <?php | ||
2 | |||
3 | namespace Shaarli\Api\Controllers; | ||
4 | |||
5 | use Shaarli\Api\ApiUtils; | ||
6 | use Shaarli\Api\Exceptions\ApiBadParametersException; | ||
7 | use Shaarli\Api\Exceptions\ApiLinkNotFoundException; | ||
8 | use Slim\Http\Request; | ||
9 | use Slim\Http\Response; | ||
10 | |||
11 | /** | ||
12 | * Class Links | ||
13 | * | ||
14 | * REST API Controller: all services related to links collection. | ||
15 | * | ||
16 | * @package Api\Controllers | ||
17 | * @see http://shaarli.github.io/api-documentation/#links-links-collection | ||
18 | */ | ||
19 | class Links extends ApiController | ||
20 | { | ||
21 | /** | ||
22 | * @var int Number of links returned if no limit is provided. | ||
23 | */ | ||
24 | public static $DEFAULT_LIMIT = 20; | ||
25 | |||
26 | /** | ||
27 | * Retrieve a list of links, allowing different filters. | ||
28 | * | ||
29 | * @param Request $request Slim request. | ||
30 | * @param Response $response Slim response. | ||
31 | * | ||
32 | * @return Response response. | ||
33 | * | ||
34 | * @throws ApiBadParametersException Invalid parameters. | ||
35 | */ | ||
36 | public function getLinks($request, $response) | ||
37 | { | ||
38 | $private = $request->getParam('visibility'); | ||
39 | $links = $this->linkDb->filterSearch( | ||
40 | [ | ||
41 | 'searchtags' => $request->getParam('searchtags', ''), | ||
42 | 'searchterm' => $request->getParam('searchterm', ''), | ||
43 | ], | ||
44 | false, | ||
45 | $private | ||
46 | ); | ||
47 | |||
48 | // Return links from the {offset}th link, starting from 0. | ||
49 | $offset = $request->getParam('offset'); | ||
50 | if (! empty($offset) && ! ctype_digit($offset)) { | ||
51 | throw new ApiBadParametersException('Invalid offset'); | ||
52 | } | ||
53 | $offset = ! empty($offset) ? intval($offset) : 0; | ||
54 | if ($offset > count($links)) { | ||
55 | return $response->withJson([], 200, $this->jsonStyle); | ||
56 | } | ||
57 | |||
58 | // limit parameter is either a number of links or 'all' for everything. | ||
59 | $limit = $request->getParam('limit'); | ||
60 | if (empty($limit)) { | ||
61 | $limit = self::$DEFAULT_LIMIT; | ||
62 | } else if (ctype_digit($limit)) { | ||
63 | $limit = intval($limit); | ||
64 | } else if ($limit === 'all') { | ||
65 | $limit = count($links); | ||
66 | } else { | ||
67 | throw new ApiBadParametersException('Invalid limit'); | ||
68 | } | ||
69 | |||
70 | // 'environment' is set by Slim and encapsulate $_SERVER. | ||
71 | $index = index_url($this->ci['environment']); | ||
72 | |||
73 | $out = []; | ||
74 | $cpt = 0; | ||
75 | foreach ($links as $link) { | ||
76 | if (count($out) >= $limit) { | ||
77 | break; | ||
78 | } | ||
79 | if ($cpt++ >= $offset) { | ||
80 | $out[] = ApiUtils::formatLink($link, $index); | ||
81 | } | ||
82 | } | ||
83 | |||
84 | return $response->withJson($out, 200, $this->jsonStyle); | ||
85 | } | ||
86 | |||
87 | /** | ||
88 | * Return a single formatted link by its ID. | ||
89 | * | ||
90 | * @param Request $request Slim request. | ||
91 | * @param Response $response Slim response. | ||
92 | * @param array $args Path parameters. including the ID. | ||
93 | * | ||
94 | * @return Response containing the link array. | ||
95 | * | ||
96 | * @throws ApiLinkNotFoundException generating a 404 error. | ||
97 | */ | ||
98 | public function getLink($request, $response, $args) | ||
99 | { | ||
100 | if (!isset($this->linkDb[$args['id']])) { | ||
101 | throw new ApiLinkNotFoundException(); | ||
102 | } | ||
103 | $index = index_url($this->ci['environment']); | ||
104 | $out = ApiUtils::formatLink($this->linkDb[$args['id']], $index); | ||
105 | |||
106 | return $response->withJson($out, 200, $this->jsonStyle); | ||
107 | } | ||
108 | |||
109 | /** | ||
110 | * Creates a new link from posted request body. | ||
111 | * | ||
112 | * @param Request $request Slim request. | ||
113 | * @param Response $response Slim response. | ||
114 | * | ||
115 | * @return Response response. | ||
116 | */ | ||
117 | public function postLink($request, $response) | ||
118 | { | ||
119 | $data = $request->getParsedBody(); | ||
120 | $link = ApiUtils::buildLinkFromRequest($data, $this->conf->get('privacy.default_private_links')); | ||
121 | // duplicate by URL, return 409 Conflict | ||
122 | if (! empty($link['url']) && ! empty($dup = $this->linkDb->getLinkFromUrl($link['url']))) { | ||
123 | return $response->withJson( | ||
124 | ApiUtils::formatLink($dup, index_url($this->ci['environment'])), | ||
125 | 409, | ||
126 | $this->jsonStyle | ||
127 | ); | ||
128 | } | ||
129 | |||
130 | $link['id'] = $this->linkDb->getNextId(); | ||
131 | $link['shorturl'] = link_small_hash($link['created'], $link['id']); | ||
132 | |||
133 | // note: general relative URL | ||
134 | if (empty($link['url'])) { | ||
135 | $link['url'] = '?' . $link['shorturl']; | ||
136 | } | ||
137 | |||
138 | if (empty($link['title'])) { | ||
139 | $link['title'] = $link['url']; | ||
140 | } | ||
141 | |||
142 | $this->linkDb[$link['id']] = $link; | ||
143 | $this->linkDb->save($this->conf->get('resource.page_cache')); | ||
144 | $this->history->addLink($link); | ||
145 | $out = ApiUtils::formatLink($link, index_url($this->ci['environment'])); | ||
146 | $redirect = $this->ci->router->relativePathFor('getLink', ['id' => $link['id']]); | ||
147 | return $response->withAddedHeader('Location', $redirect) | ||
148 | ->withJson($out, 201, $this->jsonStyle); | ||
149 | } | ||
150 | |||
151 | /** | ||
152 | * Updates an existing link from posted request body. | ||
153 | * | ||
154 | * @param Request $request Slim request. | ||
155 | * @param Response $response Slim response. | ||
156 | * @param array $args Path parameters. including the ID. | ||
157 | * | ||
158 | * @return Response response. | ||
159 | * | ||
160 | * @throws ApiLinkNotFoundException generating a 404 error. | ||
161 | */ | ||
162 | public function putLink($request, $response, $args) | ||
163 | { | ||
164 | if (! isset($this->linkDb[$args['id']])) { | ||
165 | throw new ApiLinkNotFoundException(); | ||
166 | } | ||
167 | |||
168 | $index = index_url($this->ci['environment']); | ||
169 | $data = $request->getParsedBody(); | ||
170 | |||
171 | $requestLink = ApiUtils::buildLinkFromRequest($data, $this->conf->get('privacy.default_private_links')); | ||
172 | // duplicate URL on a different link, return 409 Conflict | ||
173 | if (! empty($requestLink['url']) | ||
174 | && ! empty($dup = $this->linkDb->getLinkFromUrl($requestLink['url'])) | ||
175 | && $dup['id'] != $args['id'] | ||
176 | ) { | ||
177 | return $response->withJson( | ||
178 | ApiUtils::formatLink($dup, $index), | ||
179 | 409, | ||
180 | $this->jsonStyle | ||
181 | ); | ||
182 | } | ||
183 | |||
184 | $responseLink = $this->linkDb[$args['id']]; | ||
185 | $responseLink = ApiUtils::updateLink($responseLink, $requestLink); | ||
186 | $this->linkDb[$responseLink['id']] = $responseLink; | ||
187 | $this->linkDb->save($this->conf->get('resource.page_cache')); | ||
188 | $this->history->updateLink($responseLink); | ||
189 | |||
190 | $out = ApiUtils::formatLink($responseLink, $index); | ||
191 | return $response->withJson($out, 200, $this->jsonStyle); | ||
192 | } | ||
193 | |||
194 | /** | ||
195 | * Delete an existing link by its ID. | ||
196 | * | ||
197 | * @param Request $request Slim request. | ||
198 | * @param Response $response Slim response. | ||
199 | * @param array $args Path parameters. including the ID. | ||
200 | * | ||
201 | * @return Response response. | ||
202 | * | ||
203 | * @throws ApiLinkNotFoundException generating a 404 error. | ||
204 | */ | ||
205 | public function deleteLink($request, $response, $args) | ||
206 | { | ||
207 | if (! isset($this->linkDb[$args['id']])) { | ||
208 | throw new ApiLinkNotFoundException(); | ||
209 | } | ||
210 | $link = $this->linkDb[$args['id']]; | ||
211 | unset($this->linkDb[(int) $args['id']]); | ||
212 | $this->linkDb->save($this->conf->get('resource.page_cache')); | ||
213 | $this->history->deleteLink($link); | ||
214 | |||
215 | return $response->withStatus(204); | ||
216 | } | ||
217 | } | ||
diff --git a/application/api/exceptions/ApiAuthorizationException.php b/application/api/exceptions/ApiAuthorizationException.php new file mode 100644 index 00000000..0e3f4776 --- /dev/null +++ b/application/api/exceptions/ApiAuthorizationException.php | |||
@@ -0,0 +1,34 @@ | |||
1 | <?php | ||
2 | |||
3 | namespace Shaarli\Api\Exceptions; | ||
4 | |||
5 | /** | ||
6 | * Class ApiAuthorizationException | ||
7 | * | ||
8 | * Request not authorized, return a 401 HTTP code. | ||
9 | */ | ||
10 | class ApiAuthorizationException extends ApiException | ||
11 | { | ||
12 | /** | ||
13 | * {@inheritdoc} | ||
14 | */ | ||
15 | public function getApiResponse() | ||
16 | { | ||
17 | $this->setMessage('Not authorized'); | ||
18 | return $this->buildApiResponse(401); | ||
19 | } | ||
20 | |||
21 | /** | ||
22 | * Set the exception message. | ||
23 | * | ||
24 | * We only return a generic error message in production mode to avoid giving | ||
25 | * to much security information. | ||
26 | * | ||
27 | * @param $message string the exception message. | ||
28 | */ | ||
29 | public function setMessage($message) | ||
30 | { | ||
31 | $original = $this->debug === true ? ': '. $this->getMessage() : ''; | ||
32 | $this->message = $message . $original; | ||
33 | } | ||
34 | } | ||
diff --git a/application/api/exceptions/ApiBadParametersException.php b/application/api/exceptions/ApiBadParametersException.php new file mode 100644 index 00000000..e5cc19ea --- /dev/null +++ b/application/api/exceptions/ApiBadParametersException.php | |||
@@ -0,0 +1,19 @@ | |||
1 | <?php | ||
2 | |||
3 | namespace Shaarli\Api\Exceptions; | ||
4 | |||
5 | /** | ||
6 | * Class ApiBadParametersException | ||
7 | * | ||
8 | * Invalid request exception, return a 400 HTTP code. | ||
9 | */ | ||
10 | class ApiBadParametersException extends ApiException | ||
11 | { | ||
12 | /** | ||
13 | * {@inheritdoc} | ||
14 | */ | ||
15 | public function getApiResponse() | ||
16 | { | ||
17 | return $this->buildApiResponse(400); | ||
18 | } | ||
19 | } | ||
diff --git a/application/api/exceptions/ApiException.php b/application/api/exceptions/ApiException.php new file mode 100644 index 00000000..c8490e0c --- /dev/null +++ b/application/api/exceptions/ApiException.php | |||
@@ -0,0 +1,77 @@ | |||
1 | <?php | ||
2 | |||
3 | namespace Shaarli\Api\Exceptions; | ||
4 | |||
5 | use Slim\Http\Response; | ||
6 | |||
7 | /** | ||
8 | * Abstract class ApiException | ||
9 | * | ||
10 | * Parent Exception related to the API, able to generate a valid Response (ResponseInterface). | ||
11 | * Also can include various information in debug mode. | ||
12 | */ | ||
13 | abstract class ApiException extends \Exception { | ||
14 | |||
15 | /** | ||
16 | * @var Response instance from Slim. | ||
17 | */ | ||
18 | protected $response; | ||
19 | |||
20 | /** | ||
21 | * @var bool Debug mode enabled/disabled. | ||
22 | */ | ||
23 | protected $debug; | ||
24 | |||
25 | /** | ||
26 | * Build the final response. | ||
27 | * | ||
28 | * @return Response Final response to give. | ||
29 | */ | ||
30 | public abstract function getApiResponse(); | ||
31 | |||
32 | /** | ||
33 | * Creates ApiResponse body. | ||
34 | * In production mode, it will only return the exception message, | ||
35 | * but in dev mode, it includes additional information in an array. | ||
36 | * | ||
37 | * @return array|string response body | ||
38 | */ | ||
39 | protected function getApiResponseBody() { | ||
40 | if ($this->debug !== true) { | ||
41 | return $this->getMessage(); | ||
42 | } | ||
43 | return [ | ||
44 | 'message' => $this->getMessage(), | ||
45 | 'stacktrace' => get_class($this) .': '. $this->getTraceAsString() | ||
46 | ]; | ||
47 | } | ||
48 | |||
49 | /** | ||
50 | * Build the Response object to return. | ||
51 | * | ||
52 | * @param int $code HTTP status. | ||
53 | * | ||
54 | * @return Response with status + body. | ||
55 | */ | ||
56 | protected function buildApiResponse($code) | ||
57 | { | ||
58 | $style = $this->debug ? JSON_PRETTY_PRINT : null; | ||
59 | return $this->response->withJson($this->getApiResponseBody(), $code, $style); | ||
60 | } | ||
61 | |||
62 | /** | ||
63 | * @param Response $response | ||
64 | */ | ||
65 | public function setResponse($response) | ||
66 | { | ||
67 | $this->response = $response; | ||
68 | } | ||
69 | |||
70 | /** | ||
71 | * @param bool $debug | ||
72 | */ | ||
73 | public function setDebug($debug) | ||
74 | { | ||
75 | $this->debug = $debug; | ||
76 | } | ||
77 | } | ||
diff --git a/application/api/exceptions/ApiInternalException.php b/application/api/exceptions/ApiInternalException.php new file mode 100644 index 00000000..1cb05532 --- /dev/null +++ b/application/api/exceptions/ApiInternalException.php | |||
@@ -0,0 +1,19 @@ | |||
1 | <?php | ||
2 | |||
3 | namespace Shaarli\Api\Exceptions; | ||
4 | |||
5 | /** | ||
6 | * Class ApiInternalException | ||
7 | * | ||
8 | * Generic exception, return a 500 HTTP code. | ||
9 | */ | ||
10 | class ApiInternalException extends ApiException | ||
11 | { | ||
12 | /** | ||
13 | * @inheritdoc | ||
14 | */ | ||
15 | public function getApiResponse() | ||
16 | { | ||
17 | return $this->buildApiResponse(500); | ||
18 | } | ||
19 | } | ||
diff --git a/application/api/exceptions/ApiLinkNotFoundException.php b/application/api/exceptions/ApiLinkNotFoundException.php new file mode 100644 index 00000000..de7e14f5 --- /dev/null +++ b/application/api/exceptions/ApiLinkNotFoundException.php | |||
@@ -0,0 +1,32 @@ | |||
1 | <?php | ||
2 | |||
3 | namespace Shaarli\Api\Exceptions; | ||
4 | |||
5 | |||
6 | use Slim\Http\Response; | ||
7 | |||
8 | /** | ||
9 | * Class ApiLinkNotFoundException | ||
10 | * | ||
11 | * Link selected by ID couldn't be found, results in a 404 error. | ||
12 | * | ||
13 | * @package Shaarli\Api\Exceptions | ||
14 | */ | ||
15 | class ApiLinkNotFoundException extends ApiException | ||
16 | { | ||
17 | /** | ||
18 | * ApiLinkNotFoundException constructor. | ||
19 | */ | ||
20 | public function __construct() | ||
21 | { | ||
22 | $this->message = 'Link not found'; | ||
23 | } | ||
24 | |||
25 | /** | ||
26 | * {@inheritdoc} | ||
27 | */ | ||
28 | public function getApiResponse() | ||
29 | { | ||
30 | return $this->buildApiResponse(404); | ||
31 | } | ||
32 | } | ||
diff --git a/application/config/ConfigIO.php b/application/config/ConfigIO.php index 2b68fe6a..3efe5b6f 100644 --- a/application/config/ConfigIO.php +++ b/application/config/ConfigIO.php | |||
@@ -1,4 +1,5 @@ | |||
1 | <?php | 1 | <?php |
2 | namespace Shaarli\Config; | ||
2 | 3 | ||
3 | /** | 4 | /** |
4 | * Interface ConfigIO | 5 | * Interface ConfigIO |
@@ -14,7 +15,7 @@ interface ConfigIO | |||
14 | * | 15 | * |
15 | * @return array All configuration in an array. | 16 | * @return array All configuration in an array. |
16 | */ | 17 | */ |
17 | function read($filepath); | 18 | public function read($filepath); |
18 | 19 | ||
19 | /** | 20 | /** |
20 | * Write configuration. | 21 | * Write configuration. |
@@ -22,12 +23,12 @@ interface ConfigIO | |||
22 | * @param string $filepath Config file absolute path. | 23 | * @param string $filepath Config file absolute path. |
23 | * @param array $conf All configuration in an array. | 24 | * @param array $conf All configuration in an array. |
24 | */ | 25 | */ |
25 | function write($filepath, $conf); | 26 | public function write($filepath, $conf); |
26 | 27 | ||
27 | /** | 28 | /** |
28 | * Get config file extension according to config type. | 29 | * Get config file extension according to config type. |
29 | * | 30 | * |
30 | * @return string Config file extension. | 31 | * @return string Config file extension. |
31 | */ | 32 | */ |
32 | function getExtension(); | 33 | public function getExtension(); |
33 | } | 34 | } |
diff --git a/application/config/ConfigJson.php b/application/config/ConfigJson.php index 30007eb4..9ef2ef56 100644 --- a/application/config/ConfigJson.php +++ b/application/config/ConfigJson.php | |||
@@ -1,4 +1,5 @@ | |||
1 | <?php | 1 | <?php |
2 | namespace Shaarli\Config; | ||
2 | 3 | ||
3 | /** | 4 | /** |
4 | * Class ConfigJson (ConfigIO implementation) | 5 | * Class ConfigJson (ConfigIO implementation) |
@@ -10,7 +11,7 @@ class ConfigJson implements ConfigIO | |||
10 | /** | 11 | /** |
11 | * @inheritdoc | 12 | * @inheritdoc |
12 | */ | 13 | */ |
13 | function read($filepath) | 14 | public function read($filepath) |
14 | { | 15 | { |
15 | if (! is_readable($filepath)) { | 16 | if (! is_readable($filepath)) { |
16 | return array(); | 17 | return array(); |
@@ -20,8 +21,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 |
2 | namespace Shaarli\Config; | ||
2 | 3 | ||
3 | // FIXME! Namespaces... | 4 | use Shaarli\Config\Exception\MissingFieldConfigException; |
4 | require_once 'ConfigIO.php'; | 5 | use Shaarli\Config\Exception\UnauthorizedConfigException; |
5 | require_once 'ConfigJson.php'; | ||
6 | require_once 'ConfigPhp.php'; | ||
7 | 6 | ||
8 | /** | 7 | /** |
9 | * Class ConfigManager | 8 | * Class ConfigManager |
@@ -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 | */ | ||
366 | class MissingFieldConfigException extends Exception | ||
367 | { | ||
368 | public $field; | ||
369 | |||
370 | /** | ||
371 | * Construct exception. | ||
372 | * | ||
373 | * @param string $field field name missing. | ||
374 | */ | ||
375 | public function __construct($field) | ||
376 | { | ||
377 | $this->field = $field; | ||
378 | $this->message = 'Configuration value is required for '. $this->field; | ||
379 | } | ||
380 | } | ||
381 | |||
382 | /** | ||
383 | * Exception used if an unauthorized attempt to edit configuration has been made. | ||
384 | */ | ||
385 | class UnauthorizedConfigException extends Exception | ||
386 | { | ||
387 | /** | ||
388 | * Construct exception. | ||
389 | */ | ||
390 | public function __construct() | ||
391 | { | ||
392 | $this->message = 'You are not authorized to alter config.'; | ||
393 | } | ||
394 | } | ||
diff --git a/application/config/ConfigPhp.php b/application/config/ConfigPhp.php index 27187b66..2633824d 100644 --- a/application/config/ConfigPhp.php +++ b/application/config/ConfigPhp.php | |||
@@ -1,4 +1,5 @@ | |||
1 | <?php | 1 | <?php |
2 | namespace Shaarli\Config; | ||
2 | 3 | ||
3 | /** | 4 | /** |
4 | * Class ConfigPhp (ConfigIO implementation) | 5 | * Class ConfigPhp (ConfigIO implementation) |
@@ -41,6 +42,7 @@ class ConfigPhp implements ConfigIO | |||
41 | 'resource.log' => 'config.LOG_FILE', | 42 | 'resource.log' => 'config.LOG_FILE', |
42 | 'resource.update_check' => 'config.UPDATECHECK_FILENAME', | 43 | 'resource.update_check' => 'config.UPDATECHECK_FILENAME', |
43 | 'resource.raintpl_tpl' => 'config.RAINTPL_TPL', | 44 | 'resource.raintpl_tpl' => 'config.RAINTPL_TPL', |
45 | 'resource.theme' => 'config.theme', | ||
44 | 'resource.raintpl_tmp' => 'config.RAINTPL_TMP', | 46 | 'resource.raintpl_tmp' => 'config.RAINTPL_TMP', |
45 | 'resource.thumbnails_cache' => 'config.CACHEDIR', | 47 | 'resource.thumbnails_cache' => 'config.CACHEDIR', |
46 | 'resource.page_cache' => 'config.PAGECACHE', | 48 | 'resource.page_cache' => 'config.PAGECACHE', |
@@ -71,7 +73,7 @@ class ConfigPhp implements ConfigIO | |||
71 | /** | 73 | /** |
72 | * @inheritdoc | 74 | * @inheritdoc |
73 | */ | 75 | */ |
74 | function read($filepath) | 76 | public function read($filepath) |
75 | { | 77 | { |
76 | if (! file_exists($filepath) || ! is_readable($filepath)) { | 78 | if (! file_exists($filepath) || ! is_readable($filepath)) { |
77 | return array(); | 79 | return array(); |
@@ -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 | |||
3 | use Shaarli\Config\Exception\PluginConfigOrderException; | ||
4 | |||
2 | /** | 5 | /** |
3 | * Plugin configuration helper functions. | 6 | * Plugin configuration helper functions. |
4 | * | 7 | * |
@@ -108,17 +111,3 @@ function load_plugin_parameter_values($plugins, $conf) | |||
108 | 111 | ||
109 | return $out; | 112 | return $out; |
110 | } | 113 | } |
111 | |||
112 | /** | ||
113 | * Exception used if an error occur while saving plugin configuration. | ||
114 | */ | ||
115 | class PluginConfigOrderException extends Exception | ||
116 | { | ||
117 | /** | ||
118 | * Construct exception. | ||
119 | */ | ||
120 | public function __construct() | ||
121 | { | ||
122 | $this->message = 'An error occurred while trying to save plugins loading order.'; | ||
123 | } | ||
124 | } | ||
diff --git a/application/config/exception/MissingFieldConfigException.php b/application/config/exception/MissingFieldConfigException.php new file mode 100644 index 00000000..6346c6a9 --- /dev/null +++ b/application/config/exception/MissingFieldConfigException.php | |||
@@ -0,0 +1,23 @@ | |||
1 | <?php | ||
2 | |||
3 | |||
4 | namespace Shaarli\Config\Exception; | ||
5 | |||
6 | /** | ||
7 | * Exception used if a mandatory field is missing in given configuration. | ||
8 | */ | ||
9 | class MissingFieldConfigException extends \Exception | ||
10 | { | ||
11 | public $field; | ||
12 | |||
13 | /** | ||
14 | * Construct exception. | ||
15 | * | ||
16 | * @param string $field field name missing. | ||
17 | */ | ||
18 | public function __construct($field) | ||
19 | { | ||
20 | $this->field = $field; | ||
21 | $this->message = '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 | |||
3 | namespace Shaarli\Config\Exception; | ||
4 | |||
5 | /** | ||
6 | * Exception used if an error occur while saving plugin configuration. | ||
7 | */ | ||
8 | class PluginConfigOrderException extends \Exception | ||
9 | { | ||
10 | /** | ||
11 | * Construct exception. | ||
12 | */ | ||
13 | public function __construct() | ||
14 | { | ||
15 | $this->message = '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 | |||
4 | namespace Shaarli\Config\Exception; | ||
5 | |||
6 | /** | ||
7 | * Exception used if an unauthorized attempt to edit configuration has been made. | ||
8 | */ | ||
9 | class UnauthorizedConfigException extends \Exception | ||
10 | { | ||
11 | /** | ||
12 | * Construct exception. | ||
13 | */ | ||
14 | public function __construct() | ||
15 | { | ||
16 | $this->message = '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 | */ | ||
6 | class IOException extends Exception | ||
7 | { | ||
8 | private $path; | ||
9 | |||
10 | /** | ||
11 | * Construct a new IOException | ||
12 | * | ||
13 | * @param string $path path to the resource that cannot be accessed | ||
14 | * @param string $message Custom exception message. | ||
15 | */ | ||
16 | public function __construct($path, $message = '') | ||
17 | { | ||
18 | $this->path = $path; | ||
19 | $this->message = empty($message) ? 'Error accessing' : $message; | ||
20 | $this->message .= ' "' . $this->path .'"'; | ||
21 | } | ||
22 | } | ||