]> git.immae.eu Git - github/shaarli/Shaarli.git/blob - application/LinkDB.php
LinkDB: explicit method visibility
[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['20110826_161819']['title'];
10 * foreach ($myLinks as $link)
11 * echo $link['title'].' at url '.$link['url'].'; description:'.$link['description'];
12 *
13 * Available keys:
14 * - description: description of the entry
15 * - linkdate: creation date of this entry, format: YYYYMMDD_HHMMSS
16 * (e.g.'20110914_192317')
17 * - updated: last modification date of this entry, format: YYYYMMDD_HHMMSS
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 *
26 * Implements 3 interfaces:
27 * - ArrayAccess: behaves like an associative array;
28 * - Countable: there is a count() method;
29 * - Iterator: usable in foreach () loops.
30 */
31 class LinkDB implements Iterator, Countable, ArrayAccess
32 {
33 // Links are stored as a PHP serialized string
34 private $datastore;
35
36 // Link date storage format
37 const LINK_DATE_FORMAT = 'Ymd_His';
38
39 // Datastore PHP prefix
40 protected static $phpPrefix = '<?php /* ';
41
42 // Datastore PHP suffix
43 protected static $phpSuffix = ' */ ?>';
44
45 // List of links (associative array)
46 // - key: link date (e.g. "20110823_124546"),
47 // - value: associative array (keys: title, description...)
48 private $links;
49
50 // List of all recorded URLs (key=url, value=linkdate)
51 // for fast reserve search (url-->linkdate)
52 private $urls;
53
54 // List of linkdate keys (for the Iterator interface implementation)
55 private $keys;
56
57 // Position in the $this->keys array (for the Iterator interface)
58 private $position;
59
60 // Is the user logged in? (used to filter private links)
61 private $loggedIn;
62
63 // Hide public links
64 private $hidePublicLinks;
65
66 // link redirector set in user settings.
67 private $redirector;
68
69 /**
70 * Set this to `true` to urlencode link behind redirector link, `false` to leave it untouched.
71 *
72 * Example:
73 * anonym.to needs clean URL while dereferer.org needs urlencoded URL.
74 *
75 * @var boolean $redirectorEncode parameter: true or false
76 */
77 private $redirectorEncode;
78
79 /**
80 * Creates a new LinkDB
81 *
82 * Checks if the datastore exists; else, attempts to create a dummy one.
83 *
84 * @param string $datastore datastore file path.
85 * @param boolean $isLoggedIn is the user logged in?
86 * @param boolean $hidePublicLinks if true all links are private.
87 * @param string $redirector link redirector set in user settings.
88 * @param boolean $redirectorEncode Enable urlencode on redirected urls (default: true).
89 */
90 public function __construct(
91 $datastore,
92 $isLoggedIn,
93 $hidePublicLinks,
94 $redirector = '',
95 $redirectorEncode = true
96 )
97 {
98 $this->datastore = $datastore;
99 $this->loggedIn = $isLoggedIn;
100 $this->hidePublicLinks = $hidePublicLinks;
101 $this->redirector = $redirector;
102 $this->redirectorEncode = $redirectorEncode === true;
103 $this->check();
104 $this->read();
105 }
106
107 /**
108 * Countable - Counts elements of an object
109 */
110 public function count()
111 {
112 return count($this->links);
113 }
114
115 /**
116 * ArrayAccess - Assigns a value to the specified offset
117 */
118 public function offsetSet($offset, $value)
119 {
120 // TODO: use exceptions instead of "die"
121 if (!$this->loggedIn) {
122 die('You are not authorized to add a link.');
123 }
124 if (empty($value['linkdate']) || empty($value['url'])) {
125 die('Internal Error: A link should always have a linkdate and URL.');
126 }
127 if (empty($offset)) {
128 die('You must specify a key.');
129 }
130 $this->links[$offset] = $value;
131 $this->urls[$value['url']]=$offset;
132 }
133
134 /**
135 * ArrayAccess - Whether or not an offset exists
136 */
137 public function offsetExists($offset)
138 {
139 return array_key_exists($offset, $this->links);
140 }
141
142 /**
143 * ArrayAccess - Unsets an offset
144 */
145 public function offsetUnset($offset)
146 {
147 if (!$this->loggedIn) {
148 // TODO: raise an exception
149 die('You are not authorized to delete a link.');
150 }
151 $url = $this->links[$offset]['url'];
152 unset($this->urls[$url]);
153 unset($this->links[$offset]);
154 }
155
156 /**
157 * ArrayAccess - Returns the value at specified offset
158 */
159 public function offsetGet($offset)
160 {
161 return isset($this->links[$offset]) ? $this->links[$offset] : null;
162 }
163
164 /**
165 * Iterator - Returns the current element
166 */
167 public function current()
168 {
169 return $this->links[$this->keys[$this->position]];
170 }
171
172 /**
173 * Iterator - Returns the key of the current element
174 */
175 public function key()
176 {
177 return $this->keys[$this->position];
178 }
179
180 /**
181 * Iterator - Moves forward to next element
182 */
183 public function next()
184 {
185 ++$this->position;
186 }
187
188 /**
189 * Iterator - Rewinds the Iterator to the first element
190 *
191 * Entries are sorted by date (latest first)
192 */
193 public function rewind()
194 {
195 $this->keys = array_keys($this->links);
196 rsort($this->keys);
197 $this->position = 0;
198 }
199
200 /**
201 * Iterator - Checks if current position is valid
202 */
203 public function valid()
204 {
205 return isset($this->keys[$this->position]);
206 }
207
208 /**
209 * Checks if the DB directory and file exist
210 *
211 * If no DB file is found, creates a dummy DB.
212 */
213 private function check()
214 {
215 if (file_exists($this->datastore)) {
216 return;
217 }
218
219 // Create a dummy database for example
220 $this->links = array();
221 $link = array(
222 'title'=>' Shaarli: the personal, minimalist, super-fast, no-database delicious clone',
223 'url'=>'https://github.com/shaarli/Shaarli/wiki',
224 'description'=>'Welcome to Shaarli! This is your first public bookmark. To edit or delete me, you must first login.
225
226 To learn how to use Shaarli, consult the link "Help/documentation" at the bottom of this page.
227
228 You use the community supported version of the original Shaarli project, by Sebastien Sauvage.',
229 'private'=>0,
230 'linkdate'=> date('Ymd_His'),
231 'tags'=>'opensource software'
232 );
233 $this->links[$link['linkdate']] = $link;
234
235 $link = array(
236 'title'=>'My secret stuff... - Pastebin.com',
237 'url'=>'http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=',
238 'description'=>'Shhhh! I\'m a private link only YOU can see. You can delete me too.',
239 'private'=>1,
240 'linkdate'=> date('Ymd_His', strtotime('-1 minute')),
241 'tags'=>'secretstuff'
242 );
243 $this->links[$link['linkdate']] = $link;
244
245 // Write database to disk
246 $this->write();
247 }
248
249 /**
250 * Reads database from disk to memory
251 */
252 private function read()
253 {
254
255 // Public links are hidden and user not logged in => nothing to show
256 if ($this->hidePublicLinks && !$this->loggedIn) {
257 $this->links = array();
258 return;
259 }
260
261 // Read data
262 // Note that gzinflate is faster than gzuncompress.
263 // See: http://www.php.net/manual/en/function.gzdeflate.php#96439
264 $this->links = array();
265
266 if (file_exists($this->datastore)) {
267 $this->links = unserialize(gzinflate(base64_decode(
268 substr(file_get_contents($this->datastore),
269 strlen(self::$phpPrefix), -strlen(self::$phpSuffix)))));
270 }
271
272 // If user is not logged in, filter private links.
273 if (!$this->loggedIn) {
274 $toremove = array();
275 foreach ($this->links as $link) {
276 if ($link['private'] != 0) {
277 $toremove[] = $link['linkdate'];
278 }
279 }
280 foreach ($toremove as $linkdate) {
281 unset($this->links[$linkdate]);
282 }
283 }
284
285 $this->urls = array();
286 foreach ($this->links as &$link) {
287 // Keep the list of the mapping URLs-->linkdate up-to-date.
288 $this->urls[$link['url']] = $link['linkdate'];
289
290 // Sanitize data fields.
291 sanitizeLink($link);
292
293 // Remove private tags if the user is not logged in.
294 if (! $this->loggedIn) {
295 $link['tags'] = preg_replace('/(^|\s+)\.[^($|\s)]+\s*/', ' ', $link['tags']);
296 }
297
298 // Do not use the redirector for internal links (Shaarli note URL starting with a '?').
299 if (!empty($this->redirector) && !startsWith($link['url'], '?')) {
300 $link['real_url'] = $this->redirector;
301 if ($this->redirectorEncode) {
302 $link['real_url'] .= urlencode(unescape($link['url']));
303 } else {
304 $link['real_url'] .= $link['url'];
305 }
306 }
307 else {
308 $link['real_url'] = $link['url'];
309 }
310 }
311 }
312
313 /**
314 * Saves the database from memory to disk
315 *
316 * @throws IOException the datastore is not writable
317 */
318 private function write()
319 {
320 if (is_file($this->datastore) && !is_writeable($this->datastore)) {
321 // The datastore exists but is not writeable
322 throw new IOException($this->datastore);
323 } else if (!is_file($this->datastore) && !is_writeable(dirname($this->datastore))) {
324 // The datastore does not exist and its parent directory is not writeable
325 throw new IOException(dirname($this->datastore));
326 }
327
328 file_put_contents(
329 $this->datastore,
330 self::$phpPrefix.base64_encode(gzdeflate(serialize($this->links))).self::$phpSuffix
331 );
332
333 }
334
335 /**
336 * Saves the database from memory to disk
337 *
338 * @param string $pageCacheDir page cache directory
339 */
340 public function save($pageCacheDir)
341 {
342 if (!$this->loggedIn) {
343 // TODO: raise an Exception instead
344 die('You are not authorized to change the database.');
345 }
346
347 $this->write();
348
349 invalidateCaches($pageCacheDir);
350 }
351
352 /**
353 * Returns the link for a given URL, or False if it does not exist.
354 *
355 * @param string $url URL to search for
356 *
357 * @return mixed the existing link if it exists, else 'false'
358 */
359 public function getLinkFromUrl($url)
360 {
361 if (isset($this->urls[$url])) {
362 return $this->links[$this->urls[$url]];
363 }
364 return false;
365 }
366
367 /**
368 * Returns the shaare corresponding to a smallHash.
369 *
370 * @param string $request QUERY_STRING server parameter.
371 *
372 * @return array $filtered array containing permalink data.
373 *
374 * @throws LinkNotFoundException if the smallhash is malformed or doesn't match any link.
375 */
376 public function filterHash($request)
377 {
378 $request = substr($request, 0, 6);
379 $linkFilter = new LinkFilter($this->links);
380 return $linkFilter->filter(LinkFilter::$FILTER_HASH, $request);
381 }
382
383 /**
384 * Returns the list of articles for a given day.
385 *
386 * @param string $request day to filter. Format: YYYYMMDD.
387 *
388 * @return array list of shaare found.
389 */
390 public function filterDay($request) {
391 $linkFilter = new LinkFilter($this->links);
392 return $linkFilter->filter(LinkFilter::$FILTER_DAY, $request);
393 }
394
395 /**
396 * Filter links according to search parameters.
397 *
398 * @param array $filterRequest Search request content. Supported keys:
399 * - searchtags: list of tags
400 * - searchterm: term search
401 * @param bool $casesensitive Optional: Perform case sensitive filter
402 * @param bool $privateonly Optional: Returns private links only if true.
403 *
404 * @return array filtered links, all links if no suitable filter was provided.
405 */
406 public function filterSearch($filterRequest = array(), $casesensitive = false, $privateonly = false)
407 {
408 // Filter link database according to parameters.
409 $searchtags = !empty($filterRequest['searchtags']) ? escape($filterRequest['searchtags']) : '';
410 $searchterm = !empty($filterRequest['searchterm']) ? escape($filterRequest['searchterm']) : '';
411
412 // Search tags + fullsearch.
413 if (! empty($searchtags) && ! empty($searchterm)) {
414 $type = LinkFilter::$FILTER_TAG | LinkFilter::$FILTER_TEXT;
415 $request = array($searchtags, $searchterm);
416 }
417 // Search by tags.
418 elseif (! empty($searchtags)) {
419 $type = LinkFilter::$FILTER_TAG;
420 $request = $searchtags;
421 }
422 // Fulltext search.
423 elseif (! empty($searchterm)) {
424 $type = LinkFilter::$FILTER_TEXT;
425 $request = $searchterm;
426 }
427 // Otherwise, display without filtering.
428 else {
429 $type = '';
430 $request = '';
431 }
432
433 $linkFilter = new LinkFilter($this->links);
434 return $linkFilter->filter($type, $request, $casesensitive, $privateonly);
435 }
436
437 /**
438 * Returns the list of all tags
439 * Output: associative array key=tags, value=0
440 */
441 public function allTags()
442 {
443 $tags = array();
444 $caseMapping = array();
445 foreach ($this->links as $link) {
446 foreach (preg_split('/\s+/', $link['tags'], 0, PREG_SPLIT_NO_EMPTY) as $tag) {
447 if (empty($tag)) {
448 continue;
449 }
450 // The first case found will be displayed.
451 if (!isset($caseMapping[strtolower($tag)])) {
452 $caseMapping[strtolower($tag)] = $tag;
453 $tags[$caseMapping[strtolower($tag)]] = 0;
454 }
455 $tags[$caseMapping[strtolower($tag)]]++;
456 }
457 }
458 // Sort tags by usage (most used tag first)
459 arsort($tags);
460 return $tags;
461 }
462
463 /**
464 * Returns the list of days containing articles (oldest first)
465 * Output: An array containing days (in format YYYYMMDD).
466 */
467 public function days()
468 {
469 $linkDays = array();
470 foreach (array_keys($this->links) as $day) {
471 $linkDays[substr($day, 0, 8)] = 0;
472 }
473 $linkDays = array_keys($linkDays);
474 sort($linkDays);
475
476 return $linkDays;
477 }
478 }