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