]> git.immae.eu Git - github/shaarli/Shaarli.git/blame - application/bookmark/LinkDB.php
Remove the redirector setting
[github/shaarli/Shaarli.git] / application / bookmark / LinkDB.php
CommitLineData
ca74886f 1<?php
f3d2f257 2
f24896b2
V
3namespace Shaarli\Bookmark;
4
5use ArrayAccess;
6use Countable;
7use DateTime;
8use Iterator;
6696729b 9use Shaarli\Bookmark\Exception\LinkNotFoundException;
f3d2f257 10use Shaarli\Exceptions\IOException;
a0c4dbd9 11use Shaarli\FileUtils;
f3d2f257 12
ca74886f
V
13/**
14 * Data storage for links.
15 *
16 * This object behaves like an associative array.
17 *
18 * Example:
19 * $myLinks = new LinkDB();
29d10882 20 * echo $myLinks[350]['title'];
ca74886f
V
21 * foreach ($myLinks as $link)
22 * echo $link['title'].' at url '.$link['url'].'; description:'.$link['description'];
23 *
24 * Available keys:
29d10882 25 * - id: primary key, incremental integer identifier (persistent)
ca74886f 26 * - description: description of the entry
29d10882
A
27 * - created: creation date of this entry, DateTime object.
28 * - updated: last modification date of this entry, DateTime object.
ca74886f
V
29 * - private: Is this link private? 0=no, other value=yes
30 * - tags: tags attached to this entry (separated by spaces)
31 * - title Title of the link
520d2957
A
32 * - url URL of the link. Used for displayable links.
33 * Can be absolute or relative in the database but the relative links
34 * will be converted to absolute ones in templates.
35 * - real_url Raw URL in stored in the DB (absolute or relative).
d592daea 36 * - shorturl Permalink smallhash
ca74886f
V
37 *
38 * Implements 3 interfaces:
39 * - ArrayAccess: behaves like an associative array;
40 * - Countable: there is a count() method;
41 * - Iterator: usable in foreach () loops.
29d10882
A
42 *
43 * ID mechanism:
44 * ArrayAccess is implemented in a way that will allow to access a link
45 * with the unique identifier ID directly with $link[ID].
46 * Note that it's not the real key of the link array attribute.
47 * This mechanism is in place to have persistent link IDs,
48 * even though the internal array is reordered by date.
49 * Example:
50 * - DB: link #1 (2010-01-01) link #2 (2016-01-01)
51 * - Order: #2 #1
52 * - Import links containing: link #3 (2013-01-01)
53 * - New DB: link #1 (2010-01-01) link #2 (2016-01-01) link #3 (2013-01-01)
54 * - Real order: #2 #3 #1
ca74886f
V
55 */
56class LinkDB implements Iterator, Countable, ArrayAccess
57{
9c8752a2 58 // Links are stored as a PHP serialized string
628b97cb 59 private $datastore;
9c8752a2 60
205a4277
V
61 // Link date storage format
62 const LINK_DATE_FORMAT = 'Ymd_His';
63
ca74886f
V
64 // List of links (associative array)
65 // - key: link date (e.g. "20110823_124546"),
66 // - value: associative array (keys: title, description...)
628b97cb 67 private $links;
ca74886f 68
29d10882
A
69 // List of all recorded URLs (key=url, value=link offset)
70 // for fast reserve search (url-->link offset)
628b97cb 71 private $urls;
ca74886f 72
29d10882
A
73 /**
74 * @var array List of all links IDS mapped with their array offset.
75 * Map: id->offset.
76 */
77 protected $ids;
78
79 // List of offset keys (for the Iterator interface implementation)
628b97cb 80 private $keys;
ca74886f 81
628b97cb
V
82 // Position in the $this->keys array (for the Iterator interface)
83 private $position;
ca74886f
V
84
85 // Is the user logged in? (used to filter private links)
628b97cb 86 private $loggedIn;
ca74886f 87
9f15ca9e 88 // Hide public links
628b97cb 89 private $hidePublicLinks;
9f15ca9e 90
ca74886f
V
91 /**
92 * Creates a new LinkDB
93 *
94 * Checks if the datastore exists; else, attempts to create a dummy one.
95 *
6696729b
V
96 * @param string $datastore datastore file path.
97 * @param boolean $isLoggedIn is the user logged in?
98 * @param boolean $hidePublicLinks if true all links are private.
ca74886f 99 */
735ed4a9 100 public function __construct(
043eae70
A
101 $datastore,
102 $isLoggedIn,
520d2957 103 $hidePublicLinks
f211e417 104 ) {
f24896b2 105
628b97cb
V
106 $this->datastore = $datastore;
107 $this->loggedIn = $isLoggedIn;
108 $this->hidePublicLinks = $hidePublicLinks;
f21abf32
V
109 $this->check();
110 $this->read();
ca74886f
V
111 }
112
113 /**
114 * Countable - Counts elements of an object
115 */
116 public function count()
117 {
628b97cb 118 return count($this->links);
ca74886f
V
119 }
120
121 /**
122 * ArrayAccess - Assigns a value to the specified offset
123 */
124 public function offsetSet($offset, $value)
125 {
126 // TODO: use exceptions instead of "die"
628b97cb 127 if (!$this->loggedIn) {
12266213 128 die(t('You are not authorized to add a link.'));
ca74886f 129 }
29d10882 130 if (!isset($value['id']) || empty($value['url'])) {
12266213 131 die(t('Internal Error: A link should always have an id and URL.'));
ca74886f 132 }
f24896b2 133 if (($offset !== null && !is_int($offset)) || !is_int($value['id'])) {
12266213 134 die(t('You must specify an integer as a key.'));
29d10882 135 }
bc5f1597 136 if ($offset !== null && $offset !== $value['id']) {
12266213 137 die(t('Array offset and link ID must be equal.'));
29d10882
A
138 }
139
140 // If the link exists, we reuse the real offset, otherwise new entry
141 $existing = $this->getLinkOffset($offset);
142 if ($existing !== null) {
143 $offset = $existing;
144 } else {
145 $offset = count($this->links);
ca74886f 146 }
628b97cb 147 $this->links[$offset] = $value;
29d10882
A
148 $this->urls[$value['url']] = $offset;
149 $this->ids[$value['id']] = $offset;
ca74886f
V
150 }
151
152 /**
153 * ArrayAccess - Whether or not an offset exists
154 */
155 public function offsetExists($offset)
156 {
29d10882 157 return array_key_exists($this->getLinkOffset($offset), $this->links);
ca74886f
V
158 }
159
160 /**
161 * ArrayAccess - Unsets an offset
162 */
163 public function offsetUnset($offset)
164 {
628b97cb 165 if (!$this->loggedIn) {
ca74886f
V
166 // TODO: raise an exception
167 die('You are not authorized to delete a link.');
168 }
29d10882
A
169 $realOffset = $this->getLinkOffset($offset);
170 $url = $this->links[$realOffset]['url'];
628b97cb 171 unset($this->urls[$url]);
29d10882
A
172 unset($this->ids[$realOffset]);
173 unset($this->links[$realOffset]);
ca74886f
V
174 }
175
176 /**
177 * ArrayAccess - Returns the value at specified offset
178 */
179 public function offsetGet($offset)
180 {
29d10882
A
181 $realOffset = $this->getLinkOffset($offset);
182 return isset($this->links[$realOffset]) ? $this->links[$realOffset] : null;
ca74886f
V
183 }
184
185 /**
186 * Iterator - Returns the current element
187 */
735ed4a9 188 public function current()
ca74886f 189 {
29d10882 190 return $this[$this->keys[$this->position]];
ca74886f
V
191 }
192
193 /**
194 * Iterator - Returns the key of the current element
195 */
735ed4a9 196 public function key()
ca74886f 197 {
628b97cb 198 return $this->keys[$this->position];
ca74886f
V
199 }
200
201 /**
202 * Iterator - Moves forward to next element
203 */
735ed4a9 204 public function next()
ca74886f 205 {
628b97cb 206 ++$this->position;
ca74886f
V
207 }
208
209 /**
210 * Iterator - Rewinds the Iterator to the first element
211 *
212 * Entries are sorted by date (latest first)
213 */
735ed4a9 214 public function rewind()
ca74886f 215 {
29d10882 216 $this->keys = array_keys($this->ids);
628b97cb 217 $this->position = 0;
ca74886f
V
218 }
219
220 /**
221 * Iterator - Checks if current position is valid
222 */
735ed4a9 223 public function valid()
ca74886f 224 {
628b97cb 225 return isset($this->keys[$this->position]);
ca74886f
V
226 }
227
228 /**
229 * Checks if the DB directory and file exist
230 *
231 * If no DB file is found, creates a dummy DB.
232 */
f21abf32 233 private function check()
ca74886f 234 {
628b97cb 235 if (file_exists($this->datastore)) {
ca74886f
V
236 return;
237 }
238
239 // Create a dummy database for example
628b97cb 240 $this->links = array();
ca74886f 241 $link = array(
29d10882 242 'id' => 1,
f24896b2
V
243 'title' => t('The personal, minimalist, super-fast, database free, bookmarking service'),
244 'url' => 'https://shaarli.readthedocs.io',
245 'description' => t(
9d9f6d75 246 'Welcome to Shaarli! This is your first public bookmark. '
f24896b2 247 . 'To edit or delete me, you must first login.
598376d4 248
12266213 249To learn how to use Shaarli, consult the link "Documentation" at the bottom of this page.
598376d4 250
9d9f6d75
V
251You use the community supported version of the original Shaarli project, by Sebastien Sauvage.'
252 ),
f24896b2
V
253 'private' => 0,
254 'created' => new DateTime(),
255 'tags' => 'opensource software'
ca74886f 256 );
d592daea 257 $link['shorturl'] = link_small_hash($link['created'], $link['id']);
29d10882 258 $this->links[1] = $link;
ca74886f
V
259
260 $link = array(
29d10882 261 'id' => 0,
f24896b2
V
262 'title' => t('My secret stuff... - Pastebin.com'),
263 'url' => 'http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=',
264 'description' => t('Shhhh! I\'m a private link only YOU can see. You can delete me too.'),
265 'private' => 1,
266 'created' => new DateTime('1 minute ago'),
267 'tags' => 'secretstuff',
ca74886f 268 );
d592daea 269 $link['shorturl'] = link_small_hash($link['created'], $link['id']);
29d10882 270 $this->links[0] = $link;
ca74886f
V
271
272 // Write database to disk
f21abf32 273 $this->write();
ca74886f
V
274 }
275
276 /**
277 * Reads database from disk to memory
278 */
f21abf32 279 private function read()
ca74886f 280 {
578a84bd 281 // Public links are hidden and user not logged in => nothing to show
628b97cb
V
282 if ($this->hidePublicLinks && !$this->loggedIn) {
283 $this->links = array();
578a84bd 284 return;
285 }
286
9ec0a611
A
287 $this->urls = [];
288 $this->ids = [];
b2306b0c 289 $this->links = FileUtils::readFlatDB($this->datastore, []);
ca74886f 290
29d10882
A
291 $toremove = array();
292 foreach ($this->links as $key => &$link) {
f24896b2 293 if (!$this->loggedIn && $link['private'] != 0) {
29d10882 294 // Transition for not upgraded databases.
9ec0a611 295 unset($this->links[$key]);
29d10882 296 continue;
ca74886f 297 }
195acf9f 298
510377d2 299 // Sanitize data fields.
90e5bd65 300 sanitizeLink($link);
195acf9f
A
301
302 // Remove private tags if the user is not logged in.
f24896b2 303 if (!$this->loggedIn) {
9866b408 304 $link['tags'] = preg_replace('/(^|\s+)\.[^($|\s)]+\s*/', ' ', $link['tags']);
195acf9f
A
305 }
306
520d2957 307 $link['real_url'] = $link['url'];
29d10882
A
308
309 // To be able to load links before running the update, and prepare the update
f24896b2 310 if (!isset($link['created'])) {
29d10882 311 $link['id'] = $link['linkdate'];
d592daea 312 $link['created'] = DateTime::createFromFormat(self::LINK_DATE_FORMAT, $link['linkdate']);
f24896b2 313 if (!empty($link['updated'])) {
d592daea 314 $link['updated'] = DateTime::createFromFormat(self::LINK_DATE_FORMAT, $link['updated']);
29d10882 315 }
d592daea 316 $link['shorturl'] = smallHash($link['linkdate']);
29d10882 317 }
29d10882 318
9ec0a611
A
319 $this->urls[$link['url']] = $key;
320 $this->ids[$link['id']] = $key;
5f85fcd8 321 }
ca74886f
V
322 }
323
2e28269b
V
324 /**
325 * Saves the database from memory to disk
326 *
327 * @throws IOException the datastore is not writable
328 */
f21abf32 329 private function write()
2e28269b 330 {
9ec0a611 331 $this->reorder();
b2306b0c 332 FileUtils::writeFlatDB($this->datastore, $this->links);
2e28269b
V
333 }
334
ca74886f
V
335 /**
336 * Saves the database from memory to disk
01e48f26
V
337 *
338 * @param string $pageCacheDir page cache directory
ca74886f 339 */
f21abf32 340 public function save($pageCacheDir)
ca74886f 341 {
628b97cb 342 if (!$this->loggedIn) {
ca74886f
V
343 // TODO: raise an Exception instead
344 die('You are not authorized to change the database.');
345 }
2e28269b 346
f21abf32 347 $this->write();
2e28269b 348
01e48f26 349 invalidateCaches($pageCacheDir);
ca74886f
V
350 }
351
352 /**
353 * Returns the link for a given URL, or False if it does not exist.
ef591e7e
GV
354 *
355 * @param string $url URL to search for
356 *
357 * @return mixed the existing link if it exists, else 'false'
ca74886f
V
358 */
359 public function getLinkFromUrl($url)
360 {
628b97cb
V
361 if (isset($this->urls[$url])) {
362 return $this->links[$this->urls[$url]];
ca74886f
V
363 }
364 return false;
365 }
366
367 /**
528a6f8a 368 * Returns the shaare corresponding to a smallHash.
ca74886f 369 *
528a6f8a
A
370 * @param string $request QUERY_STRING server parameter.
371 *
372 * @return array $filtered array containing permalink data.
373 *
374 * @throws LinkNotFoundException if the smallhash is malformed or doesn't match any link.
375 */
376 public function filterHash($request)
377 {
378 $request = substr($request, 0, 6);
628b97cb 379 $linkFilter = new LinkFilter($this->links);
528a6f8a
A
380 return $linkFilter->filter(LinkFilter::$FILTER_HASH, $request);
381 }
382
383 /**
384 * Returns the list of articles for a given day.
385 *
386 * @param string $request day to filter. Format: YYYYMMDD.
387 *
388 * @return array list of shaare found.
389 */
f211e417
V
390 public function filterDay($request)
391 {
628b97cb 392 $linkFilter = new LinkFilter($this->links);
528a6f8a
A
393 return $linkFilter->filter(LinkFilter::$FILTER_DAY, $request);
394 }
395
396 /**
397 * Filter links according to search parameters.
398 *
6696729b 399 * @param array $filterRequest Search request content. Supported keys:
528a6f8a
A
400 * - searchtags: list of tags
401 * - searchterm: term search
6696729b
V
402 * @param bool $casesensitive Optional: Perform case sensitive filter
403 * @param string $visibility return only all/private/public links
404 * @param bool $untaggedonly return only untagged links
ca74886f 405 *
528a6f8a 406 * @return array filtered links, all links if no suitable filter was provided.
ca74886f 407 */
9d9f6d75
V
408 public function filterSearch(
409 $filterRequest = array(),
410 $casesensitive = false,
411 $visibility = 'all',
412 $untaggedonly = false
413 ) {
f24896b2 414
528a6f8a 415 // Filter link database according to parameters.
7d86f40b
A
416 $searchtags = isset($filterRequest['searchtags']) ? escape($filterRequest['searchtags']) : '';
417 $searchterm = isset($filterRequest['searchterm']) ? escape($filterRequest['searchterm']) : '';
528a6f8a 418
7d86f40b 419 // Search tags + fullsearch - blank string parameter will return all links.
f210d94f 420 $type = LinkFilter::$FILTER_TAG | LinkFilter::$FILTER_TEXT; // == "vuotext"
7d86f40b 421 $request = [$searchtags, $searchterm];
528a6f8a 422
29d10882 423 $linkFilter = new LinkFilter($this);
f210d94f 424 return $linkFilter->filter($type, $request, $casesensitive, $visibility, $untaggedonly);
ca74886f
V
425 }
426
427 /**
6ccd0b21 428 * Returns the list tags appearing in the links with the given tags
f8c5660d 429 *
6696729b
V
430 * @param array $filteringTags tags selecting the links to consider
431 * @param string $visibility process only all/private/public links
f8c5660d
A
432 *
433 * @return array tag => linksCount
ca74886f 434 */
6ccd0b21 435 public function linksCountPerTag($filteringTags = [], $visibility = 'all')
ca74886f 436 {
f8c5660d
A
437 $links = $this->filterSearch(['searchtags' => $filteringTags], false, $visibility);
438 $tags = [];
439 $caseMapping = [];
6ccd0b21 440 foreach ($links as $link) {
4b35853d 441 foreach (preg_split('/\s+/', $link['tags'], 0, PREG_SPLIT_NO_EMPTY) as $tag) {
b1eb5d1d
A
442 if (empty($tag)) {
443 continue;
ca74886f 444 }
b1eb5d1d
A
445 // The first case found will be displayed.
446 if (!isset($caseMapping[strtolower($tag)])) {
447 $caseMapping[strtolower($tag)] = $tag;
448 $tags[$caseMapping[strtolower($tag)]] = 0;
449 }
450 $tags[$caseMapping[strtolower($tag)]]++;
ca74886f
V
451 }
452 }
f8c5660d
A
453
454 /*
455 * Formerly used arsort(), which doesn't define the sort behaviour for equal values.
456 * Also, this function doesn't produce the same result between PHP 5.6 and 7.
457 *
458 * So we now use array_multisort() to sort tags by DESC occurrences,
459 * then ASC alphabetically for equal values.
460 *
461 * @see https://github.com/shaarli/Shaarli/issues/1142
462 */
f28396a2
A
463 $keys = array_keys($tags);
464 $tmpTags = array_combine($keys, $keys);
f28396a2 465 array_multisort($tags, SORT_DESC, $tmpTags, SORT_ASC, $tags);
ca74886f
V
466 return $tags;
467 }
468
3b67b222
A
469 /**
470 * Rename or delete a tag across all links.
471 *
472 * @param string $from Tag to rename
6696729b 473 * @param string $to New tag. If none is provided, the from tag will be deleted
3b67b222
A
474 *
475 * @return array|bool List of altered links or false on error
476 */
477 public function renameTag($from, $to)
478 {
479 if (empty($from)) {
480 return false;
481 }
482 $delete = empty($to);
483 // True for case-sensitive tag search.
484 $linksToAlter = $this->filterSearch(['searchtags' => $from], true);
f211e417 485 foreach ($linksToAlter as $key => &$value) {
3b67b222
A
486 $tags = preg_split('/\s+/', trim($value['tags']));
487 if (($pos = array_search($from, $tags)) !== false) {
488 if ($delete) {
489 unset($tags[$pos]); // Remove tag.
490 } else {
491 $tags[$pos] = trim($to);
492 }
493 $value['tags'] = trim(implode(' ', array_unique($tags)));
494 $this[$value['id']] = $value;
495 }
496 }
497
498 return $linksToAlter;
499 }
500
ca74886f
V
501 /**
502 * Returns the list of days containing articles (oldest first)
503 * Output: An array containing days (in format YYYYMMDD).
504 */
505 public function days()
506 {
507 $linkDays = array();
29d10882
A
508 foreach ($this->links as $link) {
509 $linkDays[$link['created']->format('Ymd')] = 0;
ca74886f
V
510 }
511 $linkDays = array_keys($linkDays);
512 sort($linkDays);
510377d2 513
ca74886f
V
514 return $linkDays;
515 }
29d10882
A
516
517 /**
518 * Reorder links by creation date (newest first).
519 *
520 * Also update the urls and ids mapping arrays.
521 *
522 * @param string $order ASC|DESC
523 */
524 public function reorder($order = 'DESC')
525 {
526 $order = $order === 'ASC' ? -1 : 1;
527 // Reorder array by dates.
f211e417 528 usort($this->links, function ($a, $b) use ($order) {
4154c25b
A
529 if (isset($a['sticky']) && isset($b['sticky']) && $a['sticky'] !== $b['sticky']) {
530 return $a['sticky'] ? -1 : 1;
531 }
29d10882
A
532 return $a['created'] < $b['created'] ? 1 * $order : -1 * $order;
533 });
534
9ec0a611
A
535 $this->urls = [];
536 $this->ids = [];
29d10882
A
537 foreach ($this->links as $key => $link) {
538 $this->urls[$link['url']] = $key;
539 $this->ids[$link['id']] = $key;
540 }
541 }
542
543 /**
544 * Return the next key for link creation.
545 * E.g. If the last ID is 597, the next will be 598.
546 *
547 * @return int next ID.
548 */
549 public function getNextId()
550 {
551 if (!empty($this->ids)) {
552 return max(array_keys($this->ids)) + 1;
553 }
554 return 0;
555 }
556
557 /**
558 * Returns a link offset in links array from its unique ID.
559 *
560 * @param int $id Persistent ID of a link.
561 *
d592daea 562 * @return int Real offset in local array, or null if doesn't exist.
29d10882
A
563 */
564 protected function getLinkOffset($id)
565 {
566 if (isset($this->ids[$id])) {
567 return $this->ids[$id];
568 }
569 return null;
570 }
ca74886f 571}