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