]> git.immae.eu Git - github/shaarli/Shaarli.git/blob - application/LinkDB.php
namespacing: \Shaarli\FileUtils
[github/shaarli/Shaarli.git] / application / LinkDB.php
1 <?php
2
3 use Shaarli\Exceptions\IOException;
4 use Shaarli\FileUtils;
5
6 /**
7 * Data storage for links.
8 *
9 * This object behaves like an associative array.
10 *
11 * Example:
12 * $myLinks = new LinkDB();
13 * echo $myLinks[350]['title'];
14 * foreach ($myLinks as $link)
15 * echo $link['title'].' at url '.$link['url'].'; description:'.$link['description'];
16 *
17 * Available keys:
18 * - id: primary key, incremental integer identifier (persistent)
19 * - description: description of the entry
20 * - created: creation date of this entry, DateTime object.
21 * - updated: last modification date of this entry, DateTime object.
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
25 * - url URL of the link. Used for displayable links (no redirector, relative, etc.).
26 * Can be absolute or relative.
27 * Relative URLs are permalinks (e.g.'?m-ukcw')
28 * - real_url Absolute processed URL.
29 * - shorturl Permalink smallhash
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.
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
48 */
49 class LinkDB implements Iterator, Countable, ArrayAccess
50 {
51 // Links are stored as a PHP serialized string
52 private $datastore;
53
54 // Link date storage format
55 const LINK_DATE_FORMAT = 'Ymd_His';
56
57 // List of links (associative array)
58 // - key: link date (e.g. "20110823_124546"),
59 // - value: associative array (keys: title, description...)
60 private $links;
61
62 // List of all recorded URLs (key=url, value=link offset)
63 // for fast reserve search (url-->link offset)
64 private $urls;
65
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)
73 private $keys;
74
75 // Position in the $this->keys array (for the Iterator interface)
76 private $position;
77
78 // Is the user logged in? (used to filter private links)
79 private $loggedIn;
80
81 // Hide public links
82 private $hidePublicLinks;
83
84 // link redirector set in user settings.
85 private $redirector;
86
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
97 /**
98 * Creates a new LinkDB
99 *
100 * Checks if the datastore exists; else, attempts to create a dummy one.
101 *
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).
107 */
108 public function __construct(
109 $datastore,
110 $isLoggedIn,
111 $hidePublicLinks,
112 $redirector = '',
113 $redirectorEncode = true
114 ) {
115 $this->datastore = $datastore;
116 $this->loggedIn = $isLoggedIn;
117 $this->hidePublicLinks = $hidePublicLinks;
118 $this->redirector = $redirector;
119 $this->redirectorEncode = $redirectorEncode === true;
120 $this->check();
121 $this->read();
122 }
123
124 /**
125 * Countable - Counts elements of an object
126 */
127 public function count()
128 {
129 return count($this->links);
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"
138 if (!$this->loggedIn) {
139 die(t('You are not authorized to add a link.'));
140 }
141 if (!isset($value['id']) || empty($value['url'])) {
142 die(t('Internal Error: A link should always have an id and URL.'));
143 }
144 if (($offset !== null && ! is_int($offset)) || ! is_int($value['id'])) {
145 die(t('You must specify an integer as a key.'));
146 }
147 if ($offset !== null && $offset !== $value['id']) {
148 die(t('Array offset and link ID must be equal.'));
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);
157 }
158 $this->links[$offset] = $value;
159 $this->urls[$value['url']] = $offset;
160 $this->ids[$value['id']] = $offset;
161 }
162
163 /**
164 * ArrayAccess - Whether or not an offset exists
165 */
166 public function offsetExists($offset)
167 {
168 return array_key_exists($this->getLinkOffset($offset), $this->links);
169 }
170
171 /**
172 * ArrayAccess - Unsets an offset
173 */
174 public function offsetUnset($offset)
175 {
176 if (!$this->loggedIn) {
177 // TODO: raise an exception
178 die('You are not authorized to delete a link.');
179 }
180 $realOffset = $this->getLinkOffset($offset);
181 $url = $this->links[$realOffset]['url'];
182 unset($this->urls[$url]);
183 unset($this->ids[$realOffset]);
184 unset($this->links[$realOffset]);
185 }
186
187 /**
188 * ArrayAccess - Returns the value at specified offset
189 */
190 public function offsetGet($offset)
191 {
192 $realOffset = $this->getLinkOffset($offset);
193 return isset($this->links[$realOffset]) ? $this->links[$realOffset] : null;
194 }
195
196 /**
197 * Iterator - Returns the current element
198 */
199 public function current()
200 {
201 return $this[$this->keys[$this->position]];
202 }
203
204 /**
205 * Iterator - Returns the key of the current element
206 */
207 public function key()
208 {
209 return $this->keys[$this->position];
210 }
211
212 /**
213 * Iterator - Moves forward to next element
214 */
215 public function next()
216 {
217 ++$this->position;
218 }
219
220 /**
221 * Iterator - Rewinds the Iterator to the first element
222 *
223 * Entries are sorted by date (latest first)
224 */
225 public function rewind()
226 {
227 $this->keys = array_keys($this->ids);
228 $this->position = 0;
229 }
230
231 /**
232 * Iterator - Checks if current position is valid
233 */
234 public function valid()
235 {
236 return isset($this->keys[$this->position]);
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 */
244 private function check()
245 {
246 if (file_exists($this->datastore)) {
247 return;
248 }
249
250 // Create a dummy database for example
251 $this->links = array();
252 $link = array(
253 'id' => 1,
254 'title'=> t('The personal, minimalist, super-fast, database free, bookmarking service'),
255 'url'=>'https://shaarli.readthedocs.io',
256 'description'=>t(
257 'Welcome to Shaarli! This is your first public bookmark. '
258 .'To edit or delete me, you must first login.
259
260 To learn how to use Shaarli, consult the link "Documentation" at the bottom of this page.
261
262 You use the community supported version of the original Shaarli project, by Sebastien Sauvage.'
263 ),
264 'private'=>0,
265 'created'=> new DateTime(),
266 'tags'=>'opensource software'
267 );
268 $link['shorturl'] = link_small_hash($link['created'], $link['id']);
269 $this->links[1] = $link;
270
271 $link = array(
272 'id' => 0,
273 'title'=> t('My secret stuff... - Pastebin.com'),
274 'url'=>'http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=',
275 'description'=> t('Shhhh! I\'m a private link only YOU can see. You can delete me too.'),
276 'private'=>1,
277 'created'=> new DateTime('1 minute ago'),
278 'tags'=>'secretstuff',
279 );
280 $link['shorturl'] = link_small_hash($link['created'], $link['id']);
281 $this->links[0] = $link;
282
283 // Write database to disk
284 $this->write();
285 }
286
287 /**
288 * Reads database from disk to memory
289 */
290 private function read()
291 {
292 // Public links are hidden and user not logged in => nothing to show
293 if ($this->hidePublicLinks && !$this->loggedIn) {
294 $this->links = array();
295 return;
296 }
297
298 $this->urls = [];
299 $this->ids = [];
300 $this->links = FileUtils::readFlatDB($this->datastore, []);
301
302 $toremove = array();
303 foreach ($this->links as $key => &$link) {
304 if (! $this->loggedIn && $link['private'] != 0) {
305 // Transition for not upgraded databases.
306 unset($this->links[$key]);
307 continue;
308 }
309
310 // Sanitize data fields.
311 sanitizeLink($link);
312
313 // Remove private tags if the user is not logged in.
314 if (! $this->loggedIn) {
315 $link['tags'] = preg_replace('/(^|\s+)\.[^($|\s)]+\s*/', ' ', $link['tags']);
316 }
317
318 // Do not use the redirector for internal links (Shaarli note URL starting with a '?').
319 if (!empty($this->redirector) && !startsWith($link['url'], '?')) {
320 $link['real_url'] = $this->redirector;
321 if ($this->redirectorEncode) {
322 $link['real_url'] .= urlencode(unescape($link['url']));
323 } else {
324 $link['real_url'] .= $link['url'];
325 }
326 } else {
327 $link['real_url'] = $link['url'];
328 }
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'];
333 $link['created'] = DateTime::createFromFormat(self::LINK_DATE_FORMAT, $link['linkdate']);
334 if (! empty($link['updated'])) {
335 $link['updated'] = DateTime::createFromFormat(self::LINK_DATE_FORMAT, $link['updated']);
336 }
337 $link['shorturl'] = smallHash($link['linkdate']);
338 }
339
340 $this->urls[$link['url']] = $key;
341 $this->ids[$link['id']] = $key;
342 }
343 }
344
345 /**
346 * Saves the database from memory to disk
347 *
348 * @throws IOException the datastore is not writable
349 */
350 private function write()
351 {
352 $this->reorder();
353 FileUtils::writeFlatDB($this->datastore, $this->links);
354 }
355
356 /**
357 * Saves the database from memory to disk
358 *
359 * @param string $pageCacheDir page cache directory
360 */
361 public function save($pageCacheDir)
362 {
363 if (!$this->loggedIn) {
364 // TODO: raise an Exception instead
365 die('You are not authorized to change the database.');
366 }
367
368 $this->write();
369
370 invalidateCaches($pageCacheDir);
371 }
372
373 /**
374 * Returns the link for a given URL, or False if it does not exist.
375 *
376 * @param string $url URL to search for
377 *
378 * @return mixed the existing link if it exists, else 'false'
379 */
380 public function getLinkFromUrl($url)
381 {
382 if (isset($this->urls[$url])) {
383 return $this->links[$this->urls[$url]];
384 }
385 return false;
386 }
387
388 /**
389 * Returns the shaare corresponding to a smallHash.
390 *
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);
400 $linkFilter = new LinkFilter($this->links);
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 */
411 public function filterDay($request)
412 {
413 $linkFilter = new LinkFilter($this->links);
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
423 * @param bool $casesensitive Optional: Perform case sensitive filter
424 * @param string $visibility return only all/private/public links
425 * @param string $untaggedonly return only untagged links
426 *
427 * @return array filtered links, all links if no suitable filter was provided.
428 */
429 public function filterSearch(
430 $filterRequest = array(),
431 $casesensitive = false,
432 $visibility = 'all',
433 $untaggedonly = false
434 ) {
435 // Filter link database according to parameters.
436 $searchtags = isset($filterRequest['searchtags']) ? escape($filterRequest['searchtags']) : '';
437 $searchterm = isset($filterRequest['searchterm']) ? escape($filterRequest['searchterm']) : '';
438
439 // Search tags + fullsearch - blank string parameter will return all links.
440 $type = LinkFilter::$FILTER_TAG | LinkFilter::$FILTER_TEXT; // == "vuotext"
441 $request = [$searchtags, $searchterm];
442
443 $linkFilter = new LinkFilter($this);
444 return $linkFilter->filter($type, $request, $casesensitive, $visibility, $untaggedonly);
445 }
446
447 /**
448 * Returns the list tags appearing in the links with the given tags
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
454 */
455 public function linksCountPerTag($filteringTags = [], $visibility = 'all')
456 {
457 $links = $this->filterSearch(['searchtags' => $filteringTags], false, $visibility);
458 $tags = [];
459 $caseMapping = [];
460 foreach ($links as $link) {
461 foreach (preg_split('/\s+/', $link['tags'], 0, PREG_SPLIT_NO_EMPTY) as $tag) {
462 if (empty($tag)) {
463 continue;
464 }
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)]]++;
471 }
472 }
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 */
483 $keys = array_keys($tags);
484 $tmpTags = array_combine($keys, $keys);
485 array_multisort($tags, SORT_DESC, $tmpTags, SORT_ASC, $tags);
486 return $tags;
487 }
488
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);
505 foreach ($linksToAlter as $key => &$value) {
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
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();
528 foreach ($this->links as $link) {
529 $linkDays[$link['created']->format('Ymd')] = 0;
530 }
531 $linkDays = array_keys($linkDays);
532 sort($linkDays);
533
534 return $linkDays;
535 }
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.
548 usort($this->links, function ($a, $b) use ($order) {
549 if (isset($a['sticky']) && isset($b['sticky']) && $a['sticky'] !== $b['sticky']) {
550 return $a['sticky'] ? -1 : 1;
551 }
552 return $a['created'] < $b['created'] ? 1 * $order : -1 * $order;
553 });
554
555 $this->urls = [];
556 $this->ids = [];
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 *
582 * @return int Real offset in local array, or null if doesn't exist.
583 */
584 protected function getLinkOffset($id)
585 {
586 if (isset($this->ids[$id])) {
587 return $this->ids[$id];
588 }
589 return null;
590 }
591 }