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