LinkDB.php 15 KB

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