aboutsummaryrefslogtreecommitdiffhomepage
path: root/application
diff options
context:
space:
mode:
Diffstat (limited to 'application')
-rw-r--r--application/FileUtils.php75
-rw-r--r--application/History.php200
-rw-r--r--application/LinkDB.php46
-rw-r--r--application/NetscapeBookmarkUtils.php6
-rw-r--r--application/PageBuilder.php16
-rw-r--r--application/Router.php12
-rw-r--r--application/TimeZone.php101
-rw-r--r--application/Updater.php26
-rw-r--r--application/Utils.php129
-rw-r--r--application/api/ApiMiddleware.php5
-rw-r--r--application/api/ApiUtils.php61
-rw-r--r--application/api/controllers/ApiController.php19
-rw-r--r--application/api/controllers/History.php70
-rw-r--r--application/api/controllers/Links.php112
-rw-r--r--application/config/ConfigManager.php1
-rw-r--r--application/exceptions/IOException.php22
16 files changed, 772 insertions, 129 deletions
diff --git a/application/FileUtils.php b/application/FileUtils.php
index 6cac9825..a167f642 100644
--- a/application/FileUtils.php
+++ b/application/FileUtils.php
@@ -1,21 +1,76 @@
1<?php 1<?php
2
3require_once 'exceptions/IOException.php';
4
2/** 5/**
3 * Exception class thrown when a filesystem access failure happens 6 * Class FileUtils
7 *
8 * Utility class for file manipulation.
4 */ 9 */
5class IOException extends Exception 10class FileUtils
6{ 11{
7 private $path; 12 /**
13 * @var string
14 */
15 protected static $phpPrefix = '<?php /* ';
16
17 /**
18 * @var string
19 */
20 protected static $phpSuffix = ' */ ?>';
8 21
9 /** 22 /**
10 * Construct a new IOException 23 * Write data into a file (Shaarli database format).
24 * The data is stored in a PHP file, as a comment, in compressed base64 format.
25 *
26 * The file will be created if it doesn't exist.
27 *
28 * @param string $file File path.
29 * @param mixed $content Content to write.
30 *
31 * @return int|bool Number of bytes written or false if it fails.
11 * 32 *
12 * @param string $path path to the resource that cannot be accessed 33 * @throws IOException The destination file can't be written.
13 * @param string $message Custom exception message.
14 */ 34 */
15 public function __construct($path, $message = '') 35 public static function writeFlatDB($file, $content)
16 { 36 {
17 $this->path = $path; 37 if (is_file($file) && !is_writeable($file)) {
18 $this->message = empty($message) ? 'Error accessing' : $message; 38 // The datastore exists but is not writeable
19 $this->message .= PHP_EOL . $this->path; 39 throw new IOException($file);
40 } else if (!is_file($file) && !is_writeable(dirname($file))) {
41 // The datastore does not exist and its parent directory is not writeable
42 throw new IOException(dirname($file));
43 }
44
45 return file_put_contents(
46 $file,
47 self::$phpPrefix.base64_encode(gzdeflate(serialize($content))).self::$phpSuffix
48 );
49 }
50
51 /**
52 * Read data from a file containing Shaarli database format content.
53 * If the file isn't readable or doesn't exists, default data will be returned.
54 *
55 * @param string $file File path.
56 * @param mixed $default The default value to return if the file isn't readable.
57 *
58 * @return mixed The content unserialized, or default if the file isn't readable, or false if it fails.
59 */
60 public static function readFlatDB($file, $default = null)
61 {
62 // Note that gzinflate is faster than gzuncompress.
63 // See: http://www.php.net/manual/en/function.gzdeflate.php#96439
64 if (is_readable($file)) {
65 return unserialize(
66 gzinflate(
67 base64_decode(
68 substr(file_get_contents($file), strlen(self::$phpPrefix), -strlen(self::$phpSuffix))
69 )
70 )
71 );
72 }
73
74 return $default;
20 } 75 }
21} 76}
diff --git a/application/History.php b/application/History.php
new file mode 100644
index 00000000..116b9264
--- /dev/null
+++ b/application/History.php
@@ -0,0 +1,200 @@
1<?php
2
3/**
4 * Class History
5 *
6 * Handle the history file tracing events in Shaarli.
7 * The history is stored as JSON in a file set by 'resource.history' setting.
8 *
9 * Available data:
10 * - event: event key
11 * - datetime: event date, in ISO8601 format.
12 * - id: event item identifier (currently only link IDs).
13 *
14 * Available event keys:
15 * - CREATED: new link
16 * - UPDATED: link updated
17 * - DELETED: link deleted
18 * - SETTINGS: the settings have been updated through the UI.
19 *
20 * Note: new events are put at the beginning of the file and history array.
21 */
22class History
23{
24 /**
25 * @var string Action key: a new link has been created.
26 */
27 const CREATED = 'CREATED';
28
29 /**
30 * @var string Action key: a link has been updated.
31 */
32 const UPDATED = 'UPDATED';
33
34 /**
35 * @var string Action key: a link has been deleted.
36 */
37 const DELETED = 'DELETED';
38
39 /**
40 * @var string Action key: settings have been updated.
41 */
42 const SETTINGS = 'SETTINGS';
43
44 /**
45 * @var string History file path.
46 */
47 protected $historyFilePath;
48
49 /**
50 * @var array History data.
51 */
52 protected $history;
53
54 /**
55 * @var int History retention time in seconds (1 month).
56 */
57 protected $retentionTime = 2678400;
58
59 /**
60 * History constructor.
61 *
62 * @param string $historyFilePath History file path.
63 * @param int $retentionTime History content rentention time in seconds.
64 *
65 * @throws Exception if something goes wrong.
66 */
67 public function __construct($historyFilePath, $retentionTime = null)
68 {
69 $this->historyFilePath = $historyFilePath;
70 if ($retentionTime !== null) {
71 $this->retentionTime = $retentionTime;
72 }
73 }
74
75 /**
76 * Initialize: read history file.
77 *
78 * Allow lazy loading (don't read the file if it isn't necessary).
79 */
80 protected function initialize()
81 {
82 $this->check();
83 $this->read();
84 }
85
86 /**
87 * Add Event: new link.
88 *
89 * @param array $link Link data.
90 */
91 public function addLink($link)
92 {
93 $this->addEvent(self::CREATED, $link['id']);
94 }
95
96 /**
97 * Add Event: update existing link.
98 *
99 * @param array $link Link data.
100 */
101 public function updateLink($link)
102 {
103 $this->addEvent(self::UPDATED, $link['id']);
104 }
105
106 /**
107 * Add Event: delete existing link.
108 *
109 * @param array $link Link data.
110 */
111 public function deleteLink($link)
112 {
113 $this->addEvent(self::DELETED, $link['id']);
114 }
115
116 /**
117 * Add Event: settings updated.
118 */
119 public function updateSettings()
120 {
121 $this->addEvent(self::SETTINGS);
122 }
123
124 /**
125 * Save a new event and write it in the history file.
126 *
127 * @param string $status Event key, should be defined as constant.
128 * @param mixed $id Event item identifier (e.g. link ID).
129 */
130 protected function addEvent($status, $id = null)
131 {
132 if ($this->history === null) {
133 $this->initialize();
134 }
135
136 $item = [
137 'event' => $status,
138 'datetime' => new DateTime(),
139 'id' => $id !== null ? $id : '',
140 ];
141 $this->history = array_merge([$item], $this->history);
142 $this->write();
143 }
144
145 /**
146 * Check that the history file is writable.
147 * Create the file if it doesn't exist.
148 *
149 * @throws Exception if it isn't writable.
150 */
151 protected function check()
152 {
153 if (! is_file($this->historyFilePath)) {
154 FileUtils::writeFlatDB($this->historyFilePath, []);
155 }
156
157 if (! is_writable($this->historyFilePath)) {
158 throw new Exception('History file isn\'t readable or writable');
159 }
160 }
161
162 /**
163 * Read JSON history file.
164 */
165 protected function read()
166 {
167 $this->history = FileUtils::readFlatDB($this->historyFilePath, []);
168 if ($this->history === false) {
169 throw new Exception('Could not parse history file');
170 }
171 }
172
173 /**
174 * Write JSON history file and delete old entries.
175 */
176 protected function write()
177 {
178 $comparaison = new DateTime('-'. $this->retentionTime . ' seconds');
179 foreach ($this->history as $key => $value) {
180 if ($value['datetime'] < $comparaison) {
181 unset($this->history[$key]);
182 }
183 }
184 FileUtils::writeFlatDB($this->historyFilePath, array_values($this->history));
185 }
186
187 /**
188 * Get the History.
189 *
190 * @return array
191 */
192 public function getHistory()
193 {
194 if ($this->history === null) {
195 $this->initialize();
196 }
197
198 return $this->history;
199 }
200}
diff --git a/application/LinkDB.php b/application/LinkDB.php
index a03c2c06..8ca0fab3 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 /**
@@ -462,14 +435,17 @@ You use the community supported version of the original Shaarli project, by Seba
462 } 435 }
463 436
464 /** 437 /**
465 * Returns the list of all tags 438 * Returns the list tags appearing in the links with the given tags
466 * Output: associative array key=tags, value=0 439 * @param $filteringTags: tags selecting the links to consider
440 * @param $visibility: process only all/private/public links
441 * @return: a tag=>linksCount array
467 */ 442 */
468 public function allTags() 443 public function linksCountPerTag($filteringTags = [], $visibility = 'all')
469 { 444 {
445 $links = empty($filteringTags) ? $this->links : $this->filterSearch(['searchtags' => $filteringTags], false, $visibility);
470 $tags = array(); 446 $tags = array();
471 $caseMapping = array(); 447 $caseMapping = array();
472 foreach ($this->links as $link) { 448 foreach ($links as $link) {
473 foreach (preg_split('/\s+/', $link['tags'], 0, PREG_SPLIT_NO_EMPTY) as $tag) { 449 foreach (preg_split('/\s+/', $link['tags'], 0, PREG_SPLIT_NO_EMPTY) as $tag) {
474 if (empty($tag)) { 450 if (empty($tag)) {
475 continue; 451 continue;
diff --git a/application/NetscapeBookmarkUtils.php b/application/NetscapeBookmarkUtils.php
index ab346f81..2a10ff22 100644
--- a/application/NetscapeBookmarkUtils.php
+++ b/application/NetscapeBookmarkUtils.php
@@ -95,10 +95,11 @@ class NetscapeBookmarkUtils
95 * @param array $files Server $_FILES parameters 95 * @param array $files Server $_FILES parameters
96 * @param LinkDB $linkDb Loaded LinkDB instance 96 * @param LinkDB $linkDb Loaded LinkDB instance
97 * @param ConfigManager $conf instance 97 * @param ConfigManager $conf instance
98 * @param History $history History instance
98 * 99 *
99 * @return string Summary of the bookmark import status 100 * @return string Summary of the bookmark import status
100 */ 101 */
101 public static function import($post, $files, $linkDb, $conf) 102 public static function import($post, $files, $linkDb, $conf, $history)
102 { 103 {
103 $filename = $files['filetoupload']['name']; 104 $filename = $files['filetoupload']['name'];
104 $filesize = $files['filetoupload']['size']; 105 $filesize = $files['filetoupload']['size'];
@@ -179,9 +180,11 @@ class NetscapeBookmarkUtils
179 $newLink['id'] = $existingLink['id']; 180 $newLink['id'] = $existingLink['id'];
180 $newLink['created'] = $existingLink['created']; 181 $newLink['created'] = $existingLink['created'];
181 $newLink['updated'] = new DateTime(); 182 $newLink['updated'] = new DateTime();
183 $newLink['shorturl'] = $existingLink['shorturl'];
182 $linkDb[$existingLink['id']] = $newLink; 184 $linkDb[$existingLink['id']] = $newLink;
183 $importCount++; 185 $importCount++;
184 $overwriteCount++; 186 $overwriteCount++;
187 $history->updateLink($newLink);
185 continue; 188 continue;
186 } 189 }
187 190
@@ -193,6 +196,7 @@ class NetscapeBookmarkUtils
193 $newLink['shorturl'] = link_small_hash($newLink['created'], $newLink['id']); 196 $newLink['shorturl'] = link_small_hash($newLink['created'], $newLink['id']);
194 $linkDb[$newLink['id']] = $newLink; 197 $linkDb[$newLink['id']] = $newLink;
195 $importCount++; 198 $importCount++;
199 $history->addLink($newLink);
196 } 200 }
197 201
198 $linkDb->save($conf->get('resource.page_cache')); 202 $linkDb->save($conf->get('resource.page_cache'));
diff --git a/application/PageBuilder.php b/application/PageBuilder.php
index b133dee8..c86621a2 100644
--- a/application/PageBuilder.php
+++ b/application/PageBuilder.php
@@ -1,5 +1,7 @@
1<?php 1<?php
2 2
3use Shaarli\Config\ConfigManager;
4
3/** 5/**
4 * This class is in charge of building the final page. 6 * This class is in charge of building the final page.
5 * (This is basically a wrapper around RainTPL which pre-fills some fields.) 7 * (This is basically a wrapper around RainTPL which pre-fills some fields.)
@@ -20,15 +22,22 @@ class PageBuilder
20 protected $conf; 22 protected $conf;
21 23
22 /** 24 /**
25 * @var LinkDB $linkDB instance.
26 */
27 protected $linkDB;
28
29 /**
23 * PageBuilder constructor. 30 * PageBuilder constructor.
24 * $tpl is initialized at false for lazy loading. 31 * $tpl is initialized at false for lazy loading.
25 * 32 *
26 * @param ConfigManager $conf Configuration Manager instance (reference). 33 * @param ConfigManager $conf Configuration Manager instance (reference).
34 * @param LinkDB $linkDB instance.
27 */ 35 */
28 public 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 /**
@@ -79,6 +88,9 @@ class PageBuilder
79 $this->tpl->assign('feed_type', $this->conf->get('feed.show_atom', true) !== false ? 'atom' : 'rss'); 88 $this->tpl->assign('feed_type', $this->conf->get('feed.show_atom', true) !== false ? 'atom' : 'rss');
80 $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));
81 $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->linksCountPerTag());
93 }
82 // To be removed with a proper theme configuration. 94 // To be removed with a proper theme configuration.
83 $this->tpl->assign('conf', $this->conf); 95 $this->tpl->assign('conf', $this->conf);
84 } 96 }
diff --git a/application/Router.php b/application/Router.php
index c9a51912..4df0387c 100644
--- a/application/Router.php
+++ b/application/Router.php
@@ -13,6 +13,8 @@ class Router
13 13
14 public static $PAGE_TAGCLOUD = 'tagcloud'; 14 public static $PAGE_TAGCLOUD = 'tagcloud';
15 15
16 public static $PAGE_TAGLIST = 'taglist';
17
16 public static $PAGE_DAILY = 'daily'; 18 public static $PAGE_DAILY = 'daily';
17 19
18 public static $PAGE_FEED_ATOM = 'atom'; 20 public static $PAGE_FEED_ATOM = 'atom';
@@ -45,6 +47,8 @@ class Router
45 47
46 public static $PAGE_SAVE_PLUGINSADMIN = 'save_pluginadmin'; 48 public static $PAGE_SAVE_PLUGINSADMIN = 'save_pluginadmin';
47 49
50 public static $GET_TOKEN = 'token';
51
48 /** 52 /**
49 * Reproducing renderPage() if hell, to avoid regression. 53 * Reproducing renderPage() if hell, to avoid regression.
50 * 54 *
@@ -77,6 +81,10 @@ class Router
77 return self::$PAGE_TAGCLOUD; 81 return self::$PAGE_TAGCLOUD;
78 } 82 }
79 83
84 if (startsWith($query, 'do='. self::$PAGE_TAGLIST)) {
85 return self::$PAGE_TAGLIST;
86 }
87
80 if (startsWith($query, 'do='. self::$PAGE_OPENSEARCH)) { 88 if (startsWith($query, 'do='. self::$PAGE_OPENSEARCH)) {
81 return self::$PAGE_OPENSEARCH; 89 return self::$PAGE_OPENSEARCH;
82 } 90 }
@@ -142,6 +150,10 @@ class Router
142 return self::$PAGE_SAVE_PLUGINSADMIN; 150 return self::$PAGE_SAVE_PLUGINSADMIN;
143 } 151 }
144 152
153 if (startsWith($query, 'do='. self::$GET_TOKEN)) {
154 return self::$GET_TOKEN;
155 }
156
145 return self::$PAGE_LINKLIST; 157 return self::$PAGE_LINKLIST;
146 } 158 }
147} 159}
diff --git a/application/TimeZone.php b/application/TimeZone.php
index 36a8fb12..c1869ef8 100644
--- a/application/TimeZone.php
+++ b/application/TimeZone.php
@@ -1,23 +1,42 @@
1<?php 1<?php
2/** 2/**
3 * Generates the timezone selection form and JavaScript. 3 * Generates a list of available timezone continents and cities.
4 * 4 *
5 * Note: 'UTC/UTC' is mapped to 'UTC' to form a valid option 5 * Two distinct array based on available timezones
6 * and the one selected in the settings:
7 * - (0) continents:
8 * + list of available continents
9 * + special key 'selected' containing the value of the selected timezone's continent
10 * - (1) cities:
11 * + list of available cities associated with their continent
12 * + special key 'selected' containing the value of the selected timezone's city (without the continent)
6 * 13 *
7 * Example: preselect Europe/Paris 14 * Example:
8 * list($htmlform, $js) = generateTimeZoneForm('Europe/Paris'); 15 * [
16 * [
17 * 'America',
18 * 'Europe',
19 * 'selected' => 'Europe',
20 * ],
21 * [
22 * ['continent' => 'America', 'city' => 'Toronto'],
23 * ['continent' => 'Europe', 'city' => 'Paris'],
24 * 'selected' => 'Paris',
25 * ],
26 * ];
9 * 27 *
28 * Notes:
29 * - 'UTC/UTC' is mapped to 'UTC' to form a valid option
30 * - a few timezone cities includes the country/state, such as Argentina/Buenos_Aires
31 * - these arrays are designed to build timezone selects in template files with any HTML structure
32 *
33 * @param array $installedTimeZones List of installed timezones as string
10 * @param string $preselectedTimezone preselected timezone (optional) 34 * @param string $preselectedTimezone preselected timezone (optional)
11 * 35 *
12 * @return array containing the generated HTML form and Javascript code 36 * @return array[] continents and cities
13 **/ 37 **/
14function generateTimeZoneForm($preselectedTimezone='') 38function generateTimeZoneData($installedTimeZones, $preselectedTimezone = '')
15{ 39{
16 // Select the server timezone
17 if ($preselectedTimezone == '') {
18 $preselectedTimezone = date_default_timezone_get();
19 }
20
21 if ($preselectedTimezone == 'UTC') { 40 if ($preselectedTimezone == 'UTC') {
22 $pcity = $pcontinent = 'UTC'; 41 $pcity = $pcontinent = 'UTC';
23 } else { 42 } else {
@@ -27,62 +46,30 @@ function generateTimeZoneForm($preselectedTimezone='')
27 $pcity = substr($preselectedTimezone, $spos+1); 46 $pcity = substr($preselectedTimezone, $spos+1);
28 } 47 }
29 48
30 // The list is in the form 'Europe/Paris', 'America/Argentina/Buenos_Aires' 49 $continents = [];
31 // We split the list in continents/cities. 50 $cities = [];
32 $continents = array(); 51 foreach ($installedTimeZones as $tz) {
33 $cities = array();
34
35 // TODO: use a template to generate the HTML/Javascript form
36
37 foreach (timezone_identifiers_list() as $tz) {
38 if ($tz == 'UTC') { 52 if ($tz == 'UTC') {
39 $tz = 'UTC/UTC'; 53 $tz = 'UTC/UTC';
40 } 54 }
41 $spos = strpos($tz, '/'); 55 $spos = strpos($tz, '/');
42 56
43 if ($spos !== false) { 57 // Ignore invalid timezones
44 $continent = substr($tz, 0, $spos); 58 if ($spos === false) {
45 $city = substr($tz, $spos+1); 59 continue;
46 $continents[$continent] = 1;
47
48 if (!isset($cities[$continent])) {
49 $cities[$continent] = '';
50 }
51 $cities[$continent] .= '<option value="'.$city.'"';
52 if ($pcity == $city) {
53 $cities[$continent] .= ' selected="selected"';
54 }
55 $cities[$continent] .= '>'.$city.'</option>';
56 } 60 }
57 }
58
59 $continentsHtml = '';
60 $continents = array_keys($continents);
61 61
62 foreach ($continents as $continent) { 62 $continent = substr($tz, 0, $spos);
63 $continentsHtml .= '<option value="'.$continent.'"'; 63 $city = substr($tz, $spos+1);
64 if ($pcontinent == $continent) { 64 $cities[] = ['continent' => $continent, 'city' => $city];
65 $continentsHtml .= ' selected="selected"'; 65 $continents[$continent] = true;
66 }
67 $continentsHtml .= '>'.$continent.'</option>';
68 } 66 }
69 67
70 // Timezone selection form 68 $continents = array_keys($continents);
71 $timezoneForm = 'Continent:'; 69 $continents['selected'] = $pcontinent;
72 $timezoneForm .= '<select name="continent" id="continent" onChange="onChangecontinent();">'; 70 $cities['selected'] = $pcity;
73 $timezoneForm .= $continentsHtml.'</select>';
74 $timezoneForm .= '&nbsp;&nbsp;&nbsp;&nbsp;City:';
75 $timezoneForm .= '<select name="city" id="city">'.$cities[$pcontinent].'</select><br />';
76
77 // Javascript handler - updates the city list when the user selects a continent
78 $timezoneJs = '<script>';
79 $timezoneJs .= 'function onChangecontinent() {';
80 $timezoneJs .= 'document.getElementById("city").innerHTML =';
81 $timezoneJs .= ' citiescontinent[document.getElementById("continent").value]; }';
82 $timezoneJs .= 'var citiescontinent = '.json_encode($cities).';';
83 $timezoneJs .= '</script>';
84 71
85 return array($timezoneForm, $timezoneJs); 72 return [$continents, $cities];
86} 73}
87 74
88/** 75/**
diff --git a/application/Updater.php b/application/Updater.php
index 0fb68c5a..40a15906 100644
--- a/application/Updater.php
+++ b/application/Updater.php
@@ -329,21 +329,6 @@ class Updater
329 } 329 }
330 330
331 /** 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 /**
347 * * `markdown_escape` is a new setting, set to true as default. 332 * * `markdown_escape` is a new setting, set to true as default.
348 * 333 *
349 * If the markdown plugin was already enabled, escaping is disabled to avoid 334 * If the markdown plugin was already enabled, escaping is disabled to avoid
@@ -440,6 +425,17 @@ class Updater
440 $this->conf->write($this->isLoggedIn); 425 $this->conf->write($this->isLoggedIn);
441 return true; 426 return true;
442 } 427 }
428
429 /**
430 * Reset history store file due to date format change.
431 */
432 public function updateMethodResetHistoryFile()
433 {
434 if (is_file($this->conf->get('resource.history'))) {
435 unlink($this->conf->get('resource.history'));
436 }
437 return true;
438 }
443} 439}
444 440
445/** 441/**
diff --git a/application/Utils.php b/application/Utils.php
index 87e5cc8f..4a2f5561 100644
--- a/application/Utils.php
+++ b/application/Utils.php
@@ -325,25 +325,148 @@ function normalize_spaces($string)
325 * otherwise default format '%c' will be returned. 325 * otherwise default format '%c' will be returned.
326 * 326 *
327 * @param DateTime $date to format. 327 * @param DateTime $date to format.
328 * @param bool $time Displays time if true.
328 * @param bool $intl Use international format if true. 329 * @param bool $intl Use international format if true.
329 * 330 *
330 * @return bool|string Formatted date, or false if the input is invalid. 331 * @return bool|string Formatted date, or false if the input is invalid.
331 */ 332 */
332function format_date($date, $intl = true) 333function format_date($date, $time = true, $intl = true)
333{ 334{
334 if (! $date instanceof DateTime) { 335 if (! $date instanceof DateTime) {
335 return false; 336 return false;
336 } 337 }
337 338
338 if (! $intl || ! class_exists('IntlDateFormatter')) { 339 if (! $intl || ! class_exists('IntlDateFormatter')) {
339 return strftime('%c', $date->getTimestamp()); 340 $format = $time ? '%c' : '%x';
341 return strftime($format, $date->getTimestamp());
340 } 342 }
341 343
342 $formatter = new IntlDateFormatter( 344 $formatter = new IntlDateFormatter(
343 setlocale(LC_TIME, 0), 345 setlocale(LC_TIME, 0),
344 IntlDateFormatter::LONG, 346 IntlDateFormatter::LONG,
345 IntlDateFormatter::LONG 347 $time ? IntlDateFormatter::LONG : IntlDateFormatter::NONE
346 ); 348 );
347 349
348 return $formatter->format($date); 350 return $formatter->format($date);
349} 351}
352
353/**
354 * Check if the input is an integer, no matter its real type.
355 *
356 * PHP is a bit messy regarding this:
357 * - is_int returns false if the input is a string
358 * - ctype_digit returns false if the input is an integer or negative
359 *
360 * @param mixed $input value
361 *
362 * @return bool true if the input is an integer, false otherwise
363 */
364function is_integer_mixed($input)
365{
366 if (is_array($input) || is_bool($input) || is_object($input)) {
367 return false;
368 }
369 $input = strval($input);
370 return ctype_digit($input) || (startsWith($input, '-') && ctype_digit(substr($input, 1)));
371}
372
373/**
374 * Convert post_max_size/upload_max_filesize (e.g. '16M') parameters to bytes.
375 *
376 * @param string $val Size expressed in string.
377 *
378 * @return int Size expressed in bytes.
379 */
380function return_bytes($val)
381{
382 if (is_integer_mixed($val) || $val === '0' || empty($val)) {
383 return $val;
384 }
385 $val = trim($val);
386 $last = strtolower($val[strlen($val)-1]);
387 $val = intval(substr($val, 0, -1));
388 switch($last) {
389 case 'g': $val *= 1024;
390 case 'm': $val *= 1024;
391 case 'k': $val *= 1024;
392 }
393 return $val;
394}
395
396/**
397 * Return a human readable size from bytes.
398 *
399 * @param int $bytes value
400 *
401 * @return string Human readable size
402 */
403function human_bytes($bytes)
404{
405 if ($bytes === '') {
406 return t('Setting not set');
407 }
408 if (! is_integer_mixed($bytes)) {
409 return $bytes;
410 }
411 $bytes = intval($bytes);
412 if ($bytes === 0) {
413 return t('Unlimited');
414 }
415
416 $units = [t('B'), t('kiB'), t('MiB'), t('GiB')];
417 for ($i = 0; $i < count($units) && $bytes >= 1024; ++$i) {
418 $bytes /= 1024;
419 }
420
421 return round($bytes) . $units[$i];
422}
423
424/**
425 * Try to determine max file size for uploads (POST).
426 * Returns an integer (in bytes) or formatted depending on $format.
427 *
428 * @param mixed $limitPost post_max_size PHP setting
429 * @param mixed $limitUpload upload_max_filesize PHP setting
430 * @param bool $format Format max upload size to human readable size
431 *
432 * @return int|string max upload file size
433 */
434function get_max_upload_size($limitPost, $limitUpload, $format = true)
435{
436 $size1 = return_bytes($limitPost);
437 $size2 = return_bytes($limitUpload);
438 // Return the smaller of two:
439 $maxsize = min($size1, $size2);
440 return $format ? human_bytes($maxsize) : $maxsize;
441}
442
443/**
444 * Sort the given array alphabetically using php-intl if available.
445 * Case sensitive.
446 *
447 * Note: doesn't support multidimensional arrays
448 *
449 * @param array $data Input array, passed by reference
450 * @param bool $reverse Reverse sort if set to true
451 * @param bool $byKeys Sort the array by keys if set to true, by value otherwise.
452 */
453function alphabetical_sort(&$data, $reverse = false, $byKeys = false)
454{
455 $callback = function($a, $b) use ($reverse) {
456 // Collator is part of PHP intl.
457 if (class_exists('Collator')) {
458 $collator = new Collator(setlocale(LC_COLLATE, 0));
459 if (!intl_is_failure(intl_get_error_code())) {
460 return $collator->compare($a, $b) * ($reverse ? -1 : 1);
461 }
462 }
463
464 return strcasecmp($a, $b) * ($reverse ? -1 : 1);
465 };
466
467 if ($byKeys) {
468 uksort($data, $callback);
469 } else {
470 usort($data, $callback);
471 }
472}
diff --git a/application/api/ApiMiddleware.php b/application/api/ApiMiddleware.php
index 4120f7a9..ff209393 100644
--- a/application/api/ApiMiddleware.php
+++ b/application/api/ApiMiddleware.php
@@ -4,6 +4,7 @@ namespace Shaarli\Api;
4use Shaarli\Api\Exceptions\ApiException; 4use Shaarli\Api\Exceptions\ApiException;
5use Shaarli\Api\Exceptions\ApiAuthorizationException; 5use Shaarli\Api\Exceptions\ApiAuthorizationException;
6 6
7use Shaarli\Config\ConfigManager;
7use Slim\Container; 8use Slim\Container;
8use Slim\Http\Request; 9use Slim\Http\Request;
9use Slim\Http\Response; 10use Slim\Http\Response;
@@ -31,7 +32,7 @@ class ApiMiddleware
31 protected $container; 32 protected $container;
32 33
33 /** 34 /**
34 * @var \ConfigManager instance. 35 * @var ConfigManager instance.
35 */ 36 */
36 protected $conf; 37 protected $conf;
37 38
@@ -121,7 +122,7 @@ class ApiMiddleware
121 * 122 *
122 * FIXME! LinkDB could use a refactoring to avoid this trick. 123 * FIXME! LinkDB could use a refactoring to avoid this trick.
123 * 124 *
124 * @param \ConfigManager $conf instance. 125 * @param ConfigManager $conf instance.
125 */ 126 */
126 protected function setLinkDb($conf) 127 protected function setLinkDb($conf)
127 { 128 {
diff --git a/application/api/ApiUtils.php b/application/api/ApiUtils.php
index d4015865..f154bb52 100644
--- a/application/api/ApiUtils.php
+++ b/application/api/ApiUtils.php
@@ -12,7 +12,7 @@ class ApiUtils
12 /** 12 /**
13 * Validates a JWT token authenticity. 13 * Validates a JWT token authenticity.
14 * 14 *
15 * @param string $token JWT token extracted from the headers. 15 * @param string $token JWT token extracted from the headers.
16 * @param string $secret API secret set in the settings. 16 * @param string $secret API secret set in the settings.
17 * 17 *
18 * @throws ApiAuthorizationException the token is not valid. 18 * @throws ApiAuthorizationException the token is not valid.
@@ -50,7 +50,7 @@ class ApiUtils
50 /** 50 /**
51 * Format a Link for the REST API. 51 * Format a Link for the REST API.
52 * 52 *
53 * @param array $link Link data read from the datastore. 53 * @param array $link Link data read from the datastore.
54 * @param string $indexUrl Shaarli's index URL (used for relative URL). 54 * @param string $indexUrl Shaarli's index URL (used for relative URL).
55 * 55 *
56 * @return array Link data formatted for the REST API. 56 * @return array Link data formatted for the REST API.
@@ -77,4 +77,61 @@ class ApiUtils
77 } 77 }
78 return $out; 78 return $out;
79 } 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 }
80} 137}
diff --git a/application/api/controllers/ApiController.php b/application/api/controllers/ApiController.php
index 1dd47f17..3be85b98 100644
--- a/application/api/controllers/ApiController.php
+++ b/application/api/controllers/ApiController.php
@@ -2,6 +2,7 @@
2 2
3namespace Shaarli\Api\Controllers; 3namespace Shaarli\Api\Controllers;
4 4
5use Shaarli\Config\ConfigManager;
5use \Slim\Container; 6use \Slim\Container;
6 7
7/** 8/**
@@ -19,7 +20,7 @@ abstract class ApiController
19 protected $ci; 20 protected $ci;
20 21
21 /** 22 /**
22 * @var \ConfigManager 23 * @var ConfigManager
23 */ 24 */
24 protected $conf; 25 protected $conf;
25 26
@@ -29,6 +30,11 @@ abstract class ApiController
29 protected $linkDb; 30 protected $linkDb;
30 31
31 /** 32 /**
33 * @var \History
34 */
35 protected $history;
36
37 /**
32 * @var int|null JSON style option. 38 * @var int|null JSON style option.
33 */ 39 */
34 protected $jsonStyle; 40 protected $jsonStyle;
@@ -45,10 +51,21 @@ abstract class ApiController
45 $this->ci = $ci; 51 $this->ci = $ci;
46 $this->conf = $ci->get('conf'); 52 $this->conf = $ci->get('conf');
47 $this->linkDb = $ci->get('db'); 53 $this->linkDb = $ci->get('db');
54 $this->history = $ci->get('history');
48 if ($this->conf->get('dev.debug', false)) { 55 if ($this->conf->get('dev.debug', false)) {
49 $this->jsonStyle = JSON_PRETTY_PRINT; 56 $this->jsonStyle = JSON_PRETTY_PRINT;
50 } else { 57 } else {
51 $this->jsonStyle = null; 58 $this->jsonStyle = null;
52 } 59 }
53 } 60 }
61
62 /**
63 * Get the container.
64 *
65 * @return Container
66 */
67 public function getCi()
68 {
69 return $this->ci;
70 }
54} 71}
diff --git a/application/api/controllers/History.php b/application/api/controllers/History.php
new file mode 100644
index 00000000..2ff9deaf
--- /dev/null
+++ b/application/api/controllers/History.php
@@ -0,0 +1,70 @@
1<?php
2
3
4namespace Shaarli\Api\Controllers;
5
6use Shaarli\Api\Exceptions\ApiBadParametersException;
7use Slim\Http\Request;
8use Slim\Http\Response;
9
10/**
11 * Class History
12 *
13 * REST API Controller: /history
14 *
15 * @package Shaarli\Api\Controllers
16 */
17class History extends ApiController
18{
19 /**
20 * Service providing operation regarding Shaarli datastore and settings.
21 *
22 * @param Request $request Slim request.
23 * @param Response $response Slim response.
24 *
25 * @return Response response.
26 *
27 * @throws ApiBadParametersException Invalid parameters.
28 */
29 public function getHistory($request, $response)
30 {
31 $history = $this->history->getHistory();
32
33 // Return history operations from the {offset}th, starting from {since}.
34 $since = \DateTime::createFromFormat(\DateTime::ATOM, $request->getParam('since'));
35 $offset = $request->getParam('offset');
36 if (empty($offset)) {
37 $offset = 0;
38 }
39 else if (ctype_digit($offset)) {
40 $offset = (int) $offset;
41 } else {
42 throw new ApiBadParametersException('Invalid offset');
43 }
44
45 // limit parameter is either a number of links or 'all' for everything.
46 $limit = $request->getParam('limit');
47 if (empty($limit)) {
48 $limit = count($history);
49 } else if (ctype_digit($limit)) {
50 $limit = (int) $limit;
51 } else {
52 throw new ApiBadParametersException('Invalid limit');
53 }
54
55 $out = [];
56 $i = 0;
57 foreach ($history as $entry) {
58 if ((! empty($since) && $entry['datetime'] <= $since) || count($out) >= $limit) {
59 break;
60 }
61 if (++$i > $offset) {
62 $out[$i] = $entry;
63 $out[$i]['datetime'] = $out[$i]['datetime']->format(\DateTime::ATOM);
64 }
65 }
66 $out = array_values($out);
67
68 return $response->withJson($out, 200, $this->jsonStyle);
69 }
70}
diff --git a/application/api/controllers/Links.php b/application/api/controllers/Links.php
index d4f1a09c..eb78dd26 100644
--- a/application/api/controllers/Links.php
+++ b/application/api/controllers/Links.php
@@ -97,11 +97,121 @@ class Links extends ApiController
97 */ 97 */
98 public function getLink($request, $response, $args) 98 public function getLink($request, $response, $args)
99 { 99 {
100 if (! isset($this->linkDb[$args['id']])) { 100 if (!isset($this->linkDb[$args['id']])) {
101 throw new ApiLinkNotFoundException(); 101 throw new ApiLinkNotFoundException();
102 } 102 }
103 $index = index_url($this->ci['environment']); 103 $index = index_url($this->ci['environment']);
104 $out = ApiUtils::formatLink($this->linkDb[$args['id']], $index); 104 $out = ApiUtils::formatLink($this->linkDb[$args['id']], $index);
105
105 return $response->withJson($out, 200, $this->jsonStyle); 106 return $response->withJson($out, 200, $this->jsonStyle);
106 } 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 }
107} 217}
diff --git a/application/config/ConfigManager.php b/application/config/ConfigManager.php
index 7bfbfc72..86a917fb 100644
--- a/application/config/ConfigManager.php
+++ b/application/config/ConfigManager.php
@@ -301,6 +301,7 @@ class ConfigManager
301 $this->setEmpty('resource.updates', 'data/updates.txt'); 301 $this->setEmpty('resource.updates', 'data/updates.txt');
302 $this->setEmpty('resource.log', 'data/log.txt'); 302 $this->setEmpty('resource.log', 'data/log.txt');
303 $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');
304 $this->setEmpty('resource.raintpl_tpl', 'tpl/'); 305 $this->setEmpty('resource.raintpl_tpl', 'tpl/');
305 $this->setEmpty('resource.theme', 'default'); 306 $this->setEmpty('resource.theme', 'default');
306 $this->setEmpty('resource.raintpl_tmp', 'tmp/'); 307 $this->setEmpty('resource.raintpl_tmp', 'tmp/');
diff --git a/application/exceptions/IOException.php b/application/exceptions/IOException.php
new file mode 100644
index 00000000..b563b23d
--- /dev/null
+++ b/application/exceptions/IOException.php
@@ -0,0 +1,22 @@
1<?php
2
3/**
4 * Exception class thrown when a filesystem access failure happens
5 */
6class IOException extends Exception
7{
8 private $path;
9
10 /**
11 * Construct a new IOException
12 *
13 * @param string $path path to the resource that cannot be accessed
14 * @param string $message Custom exception message.
15 */
16 public function __construct($path, $message = '')
17 {
18 $this->path = $path;
19 $this->message = empty($message) ? 'Error accessing' : $message;
20 $this->message .= ' "' . $this->path .'"';
21 }
22}