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