]> git.immae.eu Git - github/shaarli/Shaarli.git/blame - application/LinkDB.php
namespacing: \Shaarli\FileUtils
[github/shaarli/Shaarli.git] / application / LinkDB.php
CommitLineData
ca74886f 1<?php
f3d2f257
V
2
3use Shaarli\Exceptions\IOException;
a0c4dbd9 4use Shaarli\FileUtils;
f3d2f257 5
ca74886f
V
6/**
7 * Data storage for links.
8 *
9 * This object behaves like an associative array.
10 *
11 * Example:
12 * $myLinks = new LinkDB();
29d10882 13 * echo $myLinks[350]['title'];
ca74886f
V
14 * foreach ($myLinks as $link)
15 * echo $link['title'].' at url '.$link['url'].'; description:'.$link['description'];
16 *
17 * Available keys:
29d10882 18 * - id: primary key, incremental integer identifier (persistent)
ca74886f 19 * - description: description of the entry
29d10882
A
20 * - created: creation date of this entry, DateTime object.
21 * - updated: last modification date of this entry, DateTime object.
ca74886f
V
22 * - private: Is this link private? 0=no, other value=yes
23 * - tags: tags attached to this entry (separated by spaces)
24 * - title Title of the link
49e62f22
A
25 * - url URL of the link. Used for displayable links (no redirector, relative, etc.).
26 * Can be absolute or relative.
ca74886f 27 * Relative URLs are permalinks (e.g.'?m-ukcw')
49e62f22 28 * - real_url Absolute processed URL.
d592daea 29 * - shorturl Permalink smallhash
ca74886f
V
30 *
31 * Implements 3 interfaces:
32 * - ArrayAccess: behaves like an associative array;
33 * - Countable: there is a count() method;
34 * - Iterator: usable in foreach () loops.
29d10882
A
35 *
36 * ID mechanism:
37 * ArrayAccess is implemented in a way that will allow to access a link
38 * with the unique identifier ID directly with $link[ID].
39 * Note that it's not the real key of the link array attribute.
40 * This mechanism is in place to have persistent link IDs,
41 * even though the internal array is reordered by date.
42 * Example:
43 * - DB: link #1 (2010-01-01) link #2 (2016-01-01)
44 * - Order: #2 #1
45 * - Import links containing: link #3 (2013-01-01)
46 * - New DB: link #1 (2010-01-01) link #2 (2016-01-01) link #3 (2013-01-01)
47 * - Real order: #2 #3 #1
ca74886f
V
48 */
49class LinkDB implements Iterator, Countable, ArrayAccess
50{
9c8752a2 51 // Links are stored as a PHP serialized string
628b97cb 52 private $datastore;
9c8752a2 53
205a4277
V
54 // Link date storage format
55 const LINK_DATE_FORMAT = 'Ymd_His';
56
ca74886f
V
57 // List of links (associative array)
58 // - key: link date (e.g. "20110823_124546"),
59 // - value: associative array (keys: title, description...)
628b97cb 60 private $links;
ca74886f 61
29d10882
A
62 // List of all recorded URLs (key=url, value=link offset)
63 // for fast reserve search (url-->link offset)
628b97cb 64 private $urls;
ca74886f 65
29d10882
A
66 /**
67 * @var array List of all links IDS mapped with their array offset.
68 * Map: id->offset.
69 */
70 protected $ids;
71
72 // List of offset keys (for the Iterator interface implementation)
628b97cb 73 private $keys;
ca74886f 74
628b97cb
V
75 // Position in the $this->keys array (for the Iterator interface)
76 private $position;
ca74886f
V
77
78 // Is the user logged in? (used to filter private links)
628b97cb 79 private $loggedIn;
ca74886f 80
9f15ca9e 81 // Hide public links
628b97cb 82 private $hidePublicLinks;
9f15ca9e 83
90e5bd65 84 // link redirector set in user settings.
628b97cb 85 private $redirector;
90e5bd65 86
043eae70
A
87 /**
88 * Set this to `true` to urlencode link behind redirector link, `false` to leave it untouched.
89 *
90 * Example:
91 * anonym.to needs clean URL while dereferer.org needs urlencoded URL.
92 *
93 * @var boolean $redirectorEncode parameter: true or false
94 */
95 private $redirectorEncode;
96
ca74886f
V
97 /**
98 * Creates a new LinkDB
99 *
100 * Checks if the datastore exists; else, attempts to create a dummy one.
101 *
043eae70
A
102 * @param string $datastore datastore file path.
103 * @param boolean $isLoggedIn is the user logged in?
104 * @param boolean $hidePublicLinks if true all links are private.
105 * @param string $redirector link redirector set in user settings.
106 * @param boolean $redirectorEncode Enable urlencode on redirected urls (default: true).
ca74886f 107 */
735ed4a9 108 public function __construct(
043eae70
A
109 $datastore,
110 $isLoggedIn,
111 $hidePublicLinks,
112 $redirector = '',
113 $redirectorEncode = true
f211e417 114 ) {
628b97cb
V
115 $this->datastore = $datastore;
116 $this->loggedIn = $isLoggedIn;
117 $this->hidePublicLinks = $hidePublicLinks;
118 $this->redirector = $redirector;
043eae70 119 $this->redirectorEncode = $redirectorEncode === true;
f21abf32
V
120 $this->check();
121 $this->read();
ca74886f
V
122 }
123
124 /**
125 * Countable - Counts elements of an object
126 */
127 public function count()
128 {
628b97cb 129 return count($this->links);
ca74886f
V
130 }
131
132 /**
133 * ArrayAccess - Assigns a value to the specified offset
134 */
135 public function offsetSet($offset, $value)
136 {
137 // TODO: use exceptions instead of "die"
628b97cb 138 if (!$this->loggedIn) {
12266213 139 die(t('You are not authorized to add a link.'));
ca74886f 140 }
29d10882 141 if (!isset($value['id']) || empty($value['url'])) {
12266213 142 die(t('Internal Error: A link should always have an id and URL.'));
ca74886f 143 }
bc5f1597 144 if (($offset !== null && ! is_int($offset)) || ! is_int($value['id'])) {
12266213 145 die(t('You must specify an integer as a key.'));
29d10882 146 }
bc5f1597 147 if ($offset !== null && $offset !== $value['id']) {
12266213 148 die(t('Array offset and link ID must be equal.'));
29d10882
A
149 }
150
151 // If the link exists, we reuse the real offset, otherwise new entry
152 $existing = $this->getLinkOffset($offset);
153 if ($existing !== null) {
154 $offset = $existing;
155 } else {
156 $offset = count($this->links);
ca74886f 157 }
628b97cb 158 $this->links[$offset] = $value;
29d10882
A
159 $this->urls[$value['url']] = $offset;
160 $this->ids[$value['id']] = $offset;
ca74886f
V
161 }
162
163 /**
164 * ArrayAccess - Whether or not an offset exists
165 */
166 public function offsetExists($offset)
167 {
29d10882 168 return array_key_exists($this->getLinkOffset($offset), $this->links);
ca74886f
V
169 }
170
171 /**
172 * ArrayAccess - Unsets an offset
173 */
174 public function offsetUnset($offset)
175 {
628b97cb 176 if (!$this->loggedIn) {
ca74886f
V
177 // TODO: raise an exception
178 die('You are not authorized to delete a link.');
179 }
29d10882
A
180 $realOffset = $this->getLinkOffset($offset);
181 $url = $this->links[$realOffset]['url'];
628b97cb 182 unset($this->urls[$url]);
29d10882
A
183 unset($this->ids[$realOffset]);
184 unset($this->links[$realOffset]);
ca74886f
V
185 }
186
187 /**
188 * ArrayAccess - Returns the value at specified offset
189 */
190 public function offsetGet($offset)
191 {
29d10882
A
192 $realOffset = $this->getLinkOffset($offset);
193 return isset($this->links[$realOffset]) ? $this->links[$realOffset] : null;
ca74886f
V
194 }
195
196 /**
197 * Iterator - Returns the current element
198 */
735ed4a9 199 public function current()
ca74886f 200 {
29d10882 201 return $this[$this->keys[$this->position]];
ca74886f
V
202 }
203
204 /**
205 * Iterator - Returns the key of the current element
206 */
735ed4a9 207 public function key()
ca74886f 208 {
628b97cb 209 return $this->keys[$this->position];
ca74886f
V
210 }
211
212 /**
213 * Iterator - Moves forward to next element
214 */
735ed4a9 215 public function next()
ca74886f 216 {
628b97cb 217 ++$this->position;
ca74886f
V
218 }
219
220 /**
221 * Iterator - Rewinds the Iterator to the first element
222 *
223 * Entries are sorted by date (latest first)
224 */
735ed4a9 225 public function rewind()
ca74886f 226 {
29d10882 227 $this->keys = array_keys($this->ids);
628b97cb 228 $this->position = 0;
ca74886f
V
229 }
230
231 /**
232 * Iterator - Checks if current position is valid
233 */
735ed4a9 234 public function valid()
ca74886f 235 {
628b97cb 236 return isset($this->keys[$this->position]);
ca74886f
V
237 }
238
239 /**
240 * Checks if the DB directory and file exist
241 *
242 * If no DB file is found, creates a dummy DB.
243 */
f21abf32 244 private function check()
ca74886f 245 {
628b97cb 246 if (file_exists($this->datastore)) {
ca74886f
V
247 return;
248 }
249
250 // Create a dummy database for example
628b97cb 251 $this->links = array();
ca74886f 252 $link = array(
29d10882 253 'id' => 1,
12266213 254 'title'=> t('The personal, minimalist, super-fast, database free, bookmarking service'),
cc8f572b 255 'url'=>'https://shaarli.readthedocs.io',
9d9f6d75
V
256 'description'=>t(
257 'Welcome to Shaarli! This is your first public bookmark. '
258 .'To edit or delete me, you must first login.
598376d4 259
12266213 260To learn how to use Shaarli, consult the link "Documentation" at the bottom of this page.
598376d4 261
9d9f6d75
V
262You use the community supported version of the original Shaarli project, by Sebastien Sauvage.'
263 ),
ca74886f 264 'private'=>0,
29d10882 265 'created'=> new DateTime(),
ca74886f
V
266 'tags'=>'opensource software'
267 );
d592daea 268 $link['shorturl'] = link_small_hash($link['created'], $link['id']);
29d10882 269 $this->links[1] = $link;
ca74886f
V
270
271 $link = array(
29d10882 272 'id' => 0,
12266213 273 'title'=> t('My secret stuff... - Pastebin.com'),
ca74886f 274 'url'=>'http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=',
12266213 275 'description'=> t('Shhhh! I\'m a private link only YOU can see. You can delete me too.'),
ca74886f 276 'private'=>1,
29d10882 277 'created'=> new DateTime('1 minute ago'),
d592daea 278 'tags'=>'secretstuff',
ca74886f 279 );
d592daea 280 $link['shorturl'] = link_small_hash($link['created'], $link['id']);
29d10882 281 $this->links[0] = $link;
ca74886f
V
282
283 // Write database to disk
f21abf32 284 $this->write();
ca74886f
V
285 }
286
287 /**
288 * Reads database from disk to memory
289 */
f21abf32 290 private function read()
ca74886f 291 {
578a84bd 292 // Public links are hidden and user not logged in => nothing to show
628b97cb
V
293 if ($this->hidePublicLinks && !$this->loggedIn) {
294 $this->links = array();
578a84bd 295 return;
296 }
297
9ec0a611
A
298 $this->urls = [];
299 $this->ids = [];
b2306b0c 300 $this->links = FileUtils::readFlatDB($this->datastore, []);
ca74886f 301
29d10882
A
302 $toremove = array();
303 foreach ($this->links as $key => &$link) {
304 if (! $this->loggedIn && $link['private'] != 0) {
305 // Transition for not upgraded databases.
9ec0a611 306 unset($this->links[$key]);
29d10882 307 continue;
ca74886f 308 }
195acf9f 309
510377d2 310 // Sanitize data fields.
90e5bd65 311 sanitizeLink($link);
195acf9f
A
312
313 // Remove private tags if the user is not logged in.
628b97cb 314 if (! $this->loggedIn) {
9866b408 315 $link['tags'] = preg_replace('/(^|\s+)\.[^($|\s)]+\s*/', ' ', $link['tags']);
195acf9f
A
316 }
317
90e5bd65 318 // Do not use the redirector for internal links (Shaarli note URL starting with a '?').
628b97cb
V
319 if (!empty($this->redirector) && !startsWith($link['url'], '?')) {
320 $link['real_url'] = $this->redirector;
043eae70
A
321 if ($this->redirectorEncode) {
322 $link['real_url'] .= urlencode(unescape($link['url']));
323 } else {
324 $link['real_url'] .= $link['url'];
325 }
f211e417 326 } else {
90e5bd65
A
327 $link['real_url'] = $link['url'];
328 }
29d10882
A
329
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}