diff options
Diffstat (limited to 'application')
-rw-r--r-- | application/FileUtils.php | 75 | ||||
-rw-r--r-- | application/History.php | 200 | ||||
-rw-r--r-- | application/LinkDB.php | 46 | ||||
-rw-r--r-- | application/NetscapeBookmarkUtils.php | 6 | ||||
-rw-r--r-- | application/PageBuilder.php | 16 | ||||
-rw-r--r-- | application/Router.php | 12 | ||||
-rw-r--r-- | application/TimeZone.php | 101 | ||||
-rw-r--r-- | application/Updater.php | 26 | ||||
-rw-r--r-- | application/Utils.php | 129 | ||||
-rw-r--r-- | application/api/ApiMiddleware.php | 5 | ||||
-rw-r--r-- | application/api/ApiUtils.php | 61 | ||||
-rw-r--r-- | application/api/controllers/ApiController.php | 19 | ||||
-rw-r--r-- | application/api/controllers/History.php | 70 | ||||
-rw-r--r-- | application/api/controllers/Links.php | 112 | ||||
-rw-r--r-- | application/config/ConfigManager.php | 1 | ||||
-rw-r--r-- | application/exceptions/IOException.php | 22 |
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 | |||
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/LinkDB.php b/application/LinkDB.php index 4cee2af9..7802cc8a 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 | /** |
@@ -479,14 +452,17 @@ You use the community supported version of the original Shaarli project, by Seba | |||
479 | } | 452 | } |
480 | 453 | ||
481 | /** | 454 | /** |
482 | * Returns the list of all tags | 455 | * Returns the list tags appearing in the links with the given tags |
483 | * Output: associative array key=tags, value=0 | 456 | * @param $filteringTags: tags selecting the links to consider |
457 | * @param $visibility: process only all/private/public links | ||
458 | * @return: a tag=>linksCount array | ||
484 | */ | 459 | */ |
485 | public function allTags() | 460 | public function linksCountPerTag($filteringTags = [], $visibility = 'all') |
486 | { | 461 | { |
462 | $links = empty($filteringTags) ? $this->links : $this->filterSearch(['searchtags' => $filteringTags], false, $visibility); | ||
487 | $tags = array(); | 463 | $tags = array(); |
488 | $caseMapping = array(); | 464 | $caseMapping = array(); |
489 | foreach ($this->links as $link) { | 465 | foreach ($links as $link) { |
490 | foreach (preg_split('/\s+/', $link['tags'], 0, PREG_SPLIT_NO_EMPTY) as $tag) { | 466 | foreach (preg_split('/\s+/', $link['tags'], 0, PREG_SPLIT_NO_EMPTY) as $tag) { |
491 | if (empty($tag)) { | 467 | if (empty($tag)) { |
492 | continue; | 468 | 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 | ||
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 | 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 | **/ |
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 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 5c077450..9d0ebc5e 100644 --- a/application/Utils.php +++ b/application/Utils.php | |||
@@ -321,25 +321,148 @@ function normalize_spaces($string) | |||
321 | * otherwise default format '%c' will be returned. | 321 | * otherwise default format '%c' will be returned. |
322 | * | 322 | * |
323 | * @param DateTime $date to format. | 323 | * @param DateTime $date to format. |
324 | * @param bool $time Displays time if true. | ||
324 | * @param bool $intl Use international format if true. | 325 | * @param bool $intl Use international format if true. |
325 | * | 326 | * |
326 | * @return bool|string Formatted date, or false if the input is invalid. | 327 | * @return bool|string Formatted date, or false if the input is invalid. |
327 | */ | 328 | */ |
328 | function format_date($date, $intl = true) | 329 | function format_date($date, $time = true, $intl = true) |
329 | { | 330 | { |
330 | if (! $date instanceof DateTime) { | 331 | if (! $date instanceof DateTime) { |
331 | return false; | 332 | return false; |
332 | } | 333 | } |
333 | 334 | ||
334 | if (! $intl || ! class_exists('IntlDateFormatter')) { | 335 | if (! $intl || ! class_exists('IntlDateFormatter')) { |
335 | return strftime('%c', $date->getTimestamp()); | 336 | $format = $time ? '%c' : '%x'; |
337 | return strftime($format, $date->getTimestamp()); | ||
336 | } | 338 | } |
337 | 339 | ||
338 | $formatter = new IntlDateFormatter( | 340 | $formatter = new IntlDateFormatter( |
339 | setlocale(LC_TIME, 0), | 341 | setlocale(LC_TIME, 0), |
340 | IntlDateFormatter::LONG, | 342 | IntlDateFormatter::LONG, |
341 | IntlDateFormatter::LONG | 343 | $time ? IntlDateFormatter::LONG : IntlDateFormatter::NONE |
342 | ); | 344 | ); |
343 | 345 | ||
344 | return $formatter->format($date); | 346 | return $formatter->format($date); |
345 | } | 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; | ||
437 | } | ||
438 | |||
439 | /** | ||
440 | * Sort the given array alphabetically using php-intl if available. | ||
441 | * Case sensitive. | ||
442 | * | ||
443 | * Note: doesn't support multidimensional arrays | ||
444 | * | ||
445 | * @param array $data Input array, passed by reference | ||
446 | * @param bool $reverse Reverse sort if set to true | ||
447 | * @param bool $byKeys Sort the array by keys if set to true, by value otherwise. | ||
448 | */ | ||
449 | function alphabetical_sort(&$data, $reverse = false, $byKeys = false) | ||
450 | { | ||
451 | $callback = function($a, $b) use ($reverse) { | ||
452 | // Collator is part of PHP intl. | ||
453 | if (class_exists('Collator')) { | ||
454 | $collator = new Collator(setlocale(LC_COLLATE, 0)); | ||
455 | if (!intl_is_failure(intl_get_error_code())) { | ||
456 | return $collator->compare($a, $b) * ($reverse ? -1 : 1); | ||
457 | } | ||
458 | } | ||
459 | |||
460 | return strcasecmp($a, $b) * ($reverse ? -1 : 1); | ||
461 | }; | ||
462 | |||
463 | if ($byKeys) { | ||
464 | uksort($data, $callback); | ||
465 | } else { | ||
466 | usort($data, $callback); | ||
467 | } | ||
468 | } | ||
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; | |||
4 | use Shaarli\Api\Exceptions\ApiException; | 4 | use Shaarli\Api\Exceptions\ApiException; |
5 | use Shaarli\Api\Exceptions\ApiAuthorizationException; | 5 | use Shaarli\Api\Exceptions\ApiAuthorizationException; |
6 | 6 | ||
7 | use Shaarli\Config\ConfigManager; | ||
7 | use Slim\Container; | 8 | use Slim\Container; |
8 | use Slim\Http\Request; | 9 | use Slim\Http\Request; |
9 | use Slim\Http\Response; | 10 | use 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 | ||
3 | namespace Shaarli\Api\Controllers; | 3 | namespace Shaarli\Api\Controllers; |
4 | 4 | ||
5 | use Shaarli\Config\ConfigManager; | ||
5 | use \Slim\Container; | 6 | use \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 | |||
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/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 | */ | ||
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 | } | ||