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