aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--.gitignore4
-rw-r--r--.travis.yml10
-rw-r--r--Makefile33
-rw-r--r--application/.htaccess2
-rw-r--r--application/LinkDB.php419
-rw-r--r--application/Utils.php45
-rw-r--r--composer.json1
-rw-r--r--inc/shaarli.css35
-rw-r--r--index.php281
-rw-r--r--phpunit.xml15
-rw-r--r--tests/.htaccess2
-rw-r--r--tests/LinkDBTest.php509
-rw-r--r--tests/UtilsTest.php78
-rw-r--r--tests/utils/ReferenceLinkDB.php128
-rw-r--r--tpl/linklist.html14
-rw-r--r--tpl/loginform.html10
16 files changed, 1302 insertions, 284 deletions
diff --git a/.gitignore b/.gitignore
index 33d8a488..6fd0ccd8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,5 +16,7 @@ pagecache
16composer.lock 16composer.lock
17/vendor/ 17/vendor/
18 18
19# Ignore test output 19# Ignore test data & output
20coverage
21tests/datastore.php
20phpmd.html 22phpmd.html
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 00000000..bcaf682c
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,10 @@
1language: php
2php:
3 - 5.6
4 - 5.5
5 - 5.4
6install:
7 - composer self-update
8 - composer install
9script:
10 - make test
diff --git a/Makefile b/Makefile
index e6f42853..80efcfaa 100644
--- a/Makefile
+++ b/Makefile
@@ -8,12 +8,15 @@
8# - install/update test dependencies: 8# - install/update test dependencies:
9# $ composer install # 1st setup 9# $ composer install # 1st setup
10# $ composer update 10# $ composer update
11# - install Xdebug for PHPUnit code coverage reports:
12# - see http://xdebug.org/docs/install
13# - enable in php.ini
11 14
12BIN = vendor/bin 15BIN = vendor/bin
13PHP_SOURCE = index.php 16PHP_SOURCE = index.php application tests
14MESS_DETECTOR_RULES = cleancode,codesize,controversial,design,naming,unusedcode 17PHP_COMMA_SOURCE = index.php,application,tests
15 18
16all: static_analysis_summary 19all: static_analysis_summary test
17 20
18## 21##
19# Concise status of the project 22# Concise status of the project
@@ -21,6 +24,7 @@ all: static_analysis_summary
21## 24##
22 25
23static_analysis_summary: code_sniffer_source copy_paste mess_detector_summary 26static_analysis_summary: code_sniffer_source copy_paste mess_detector_summary
27 @echo
24 28
25## 29##
26# PHP_CodeSniffer 30# PHP_CodeSniffer
@@ -62,6 +66,7 @@ copy_paste:
62# Detects PHP syntax errors, sorted by category 66# Detects PHP syntax errors, sorted by category
63# Rules documentation: http://phpmd.org/rules/index.html 67# Rules documentation: http://phpmd.org/rules/index.html
64## 68##
69MESS_DETECTOR_RULES = cleancode,codesize,controversial,design,naming,unusedcode
65 70
66mess_title: 71mess_title:
67 @echo "-----------------" 72 @echo "-----------------"
@@ -70,11 +75,11 @@ mess_title:
70 75
71### - all warnings 76### - all warnings
72mess_detector: mess_title 77mess_detector: mess_title
73 @$(BIN)/phpmd $(PHP_SOURCE) text $(MESS_DETECTOR_RULES) | sed 's_.*\/__' 78 @$(BIN)/phpmd $(PHP_COMMA_SOURCE) text $(MESS_DETECTOR_RULES) | sed 's_.*\/__'
74 79
75### - all warnings + HTML output contains links to PHPMD's documentation 80### - all warnings + HTML output contains links to PHPMD's documentation
76mess_detector_html: 81mess_detector_html:
77 @$(BIN)/phpmd $(PHP_SOURCE) html $(MESS_DETECTOR_RULES) \ 82 @$(BIN)/phpmd $(PHP_COMMA_SOURCE) html $(MESS_DETECTOR_RULES) \
78 --reportfile phpmd.html || exit 0 83 --reportfile phpmd.html || exit 0
79 84
80### - warnings grouped by message, sorted by descending frequency order 85### - warnings grouped by message, sorted by descending frequency order
@@ -85,11 +90,25 @@ mess_detector_grouped: mess_title
85### - summary: number of warnings by rule set 90### - summary: number of warnings by rule set
86mess_detector_summary: mess_title 91mess_detector_summary: mess_title
87 @for rule in $$(echo $(MESS_DETECTOR_RULES) | tr ',' ' '); do \ 92 @for rule in $$(echo $(MESS_DETECTOR_RULES) | tr ',' ' '); do \
88 warnings=$$($(BIN)/phpmd $(PHP_SOURCE) text $$rule | wc -l); \ 93 warnings=$$($(BIN)/phpmd $(PHP_COMMA_SOURCE) text $$rule | wc -l); \
89 printf "$$warnings\t$$rule\n"; \ 94 printf "$$warnings\t$$rule\n"; \
90 done; 95 done;
91 96
92## 97##
98# PHPUnit
99# Runs unitary and functional tests
100# Generates an HTML coverage report if Xdebug is enabled
101#
102# See phpunit.xml for configuration
103# https://phpunit.de/manual/current/en/appendixes.configuration.html
104##
105test: clean
106 @echo "-------"
107 @echo "PHPUNIT"
108 @echo "-------"
109 @$(BIN)/phpunit tests
110
111##
93# Targets for repository and documentation maintenance 112# Targets for repository and documentation maintenance
94## 113##
95 114
@@ -107,4 +126,4 @@ doc: clean
107htmldoc: 126htmldoc:
108 for file in `find doc/ -maxdepth 1 -name "*.md"`; do \ 127 for file in `find doc/ -maxdepth 1 -name "*.md"`; do \
109 pandoc -f markdown_github -t html5 -s -c "github-markdown.css" -o doc/`basename $$file .md`.html "$$file"; \ 128 pandoc -f markdown_github -t html5 -s -c "github-markdown.css" -o doc/`basename $$file .md`.html "$$file"; \
110 done; \ No newline at end of file 129 done;
diff --git a/application/.htaccess b/application/.htaccess
new file mode 100644
index 00000000..b584d98c
--- /dev/null
+++ b/application/.htaccess
@@ -0,0 +1,2 @@
1Allow from none
2Deny from all
diff --git a/application/LinkDB.php b/application/LinkDB.php
new file mode 100644
index 00000000..137f42e5
--- /dev/null
+++ b/application/LinkDB.php
@@ -0,0 +1,419 @@
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: date of the creation of this entry, in the form YYYYMMDD_HHMMSS
16 * (e.g.'20110914_192317')
17 * - private: Is this link private? 0=no, other value=yes
18 * - tags: tags attached to this entry (separated by spaces)
19 * - title Title of the link
20 * - url URL of the link. Can be absolute or relative.
21 * Relative URLs are permalinks (e.g.'?m-ukcw')
22 *
23 * Implements 3 interfaces:
24 * - ArrayAccess: behaves like an associative array;
25 * - Countable: there is a count() method;
26 * - Iterator: usable in foreach () loops.
27 */
28class LinkDB implements Iterator, Countable, ArrayAccess
29{
30 // List of links (associative array)
31 // - key: link date (e.g. "20110823_124546"),
32 // - value: associative array (keys: title, description...)
33 private $links;
34
35 // List of all recorded URLs (key=url, value=linkdate)
36 // for fast reserve search (url-->linkdate)
37 private $urls;
38
39 // List of linkdate keys (for the Iterator interface implementation)
40 private $keys;
41
42 // Position in the $this->keys array (for the Iterator interface)
43 private $position;
44
45 // Is the user logged in? (used to filter private links)
46 private $loggedIn;
47
48 /**
49 * Creates a new LinkDB
50 *
51 * Checks if the datastore exists; else, attempts to create a dummy one.
52 *
53 * @param $isLoggedIn is the user logged in?
54 */
55 function __construct($isLoggedIn)
56 {
57 // FIXME: do not access $GLOBALS, pass the datastore instead
58 $this->loggedIn = $isLoggedIn;
59 $this->checkDB();
60 $this->readdb();
61 }
62
63 /**
64 * Countable - Counts elements of an object
65 */
66 public function count()
67 {
68 return count($this->links);
69 }
70
71 /**
72 * ArrayAccess - Assigns a value to the specified offset
73 */
74 public function offsetSet($offset, $value)
75 {
76 // TODO: use exceptions instead of "die"
77 if (!$this->loggedIn) {
78 die('You are not authorized to add a link.');
79 }
80 if (empty($value['linkdate']) || empty($value['url'])) {
81 die('Internal Error: A link should always have a linkdate and URL.');
82 }
83 if (empty($offset)) {
84 die('You must specify a key.');
85 }
86 $this->links[$offset] = $value;
87 $this->urls[$value['url']]=$offset;
88 }
89
90 /**
91 * ArrayAccess - Whether or not an offset exists
92 */
93 public function offsetExists($offset)
94 {
95 return array_key_exists($offset, $this->links);
96 }
97
98 /**
99 * ArrayAccess - Unsets an offset
100 */
101 public function offsetUnset($offset)
102 {
103 if (!$this->loggedIn) {
104 // TODO: raise an exception
105 die('You are not authorized to delete a link.');
106 }
107 $url = $this->links[$offset]['url'];
108 unset($this->urls[$url]);
109 unset($this->links[$offset]);
110 }
111
112 /**
113 * ArrayAccess - Returns the value at specified offset
114 */
115 public function offsetGet($offset)
116 {
117 return isset($this->links[$offset]) ? $this->links[$offset] : null;
118 }
119
120 /**
121 * Iterator - Returns the current element
122 */
123 function current()
124 {
125 return $this->links[$this->keys[$this->position]];
126 }
127
128 /**
129 * Iterator - Returns the key of the current element
130 */
131 function key()
132 {
133 return $this->keys[$this->position];
134 }
135
136 /**
137 * Iterator - Moves forward to next element
138 */
139 function next()
140 {
141 ++$this->position;
142 }
143
144 /**
145 * Iterator - Rewinds the Iterator to the first element
146 *
147 * Entries are sorted by date (latest first)
148 */
149 function rewind()
150 {
151 $this->keys = array_keys($this->links);
152 rsort($this->keys);
153 $this->position = 0;
154 }
155
156 /**
157 * Iterator - Checks if current position is valid
158 */
159 function valid()
160 {
161 return isset($this->keys[$this->position]);
162 }
163
164 /**
165 * Checks if the DB directory and file exist
166 *
167 * If no DB file is found, creates a dummy DB.
168 */
169 private function checkDB()
170 {
171 if (file_exists($GLOBALS['config']['DATASTORE'])) {
172 return;
173 }
174
175 // Create a dummy database for example
176 $this->links = array();
177 $link = array(
178 'title'=>'Shaarli - sebsauvage.net',
179 'url'=>'http://sebsauvage.net/wiki/doku.php?id=php:shaarli',
180 'description'=>'Welcome to Shaarli! This is a bookmark. To edit or delete me, you must first login.',
181 'private'=>0,
182 'linkdate'=>'20110914_190000',
183 'tags'=>'opensource software'
184 );
185 $this->links[$link['linkdate']] = $link;
186
187 $link = array(
188 'title'=>'My secret stuff... - Pastebin.com',
189 'url'=>'http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=',
190 'description'=>'SShhhh!! I\'m a private link only YOU can see. You can delete me too.',
191 'private'=>1,
192 'linkdate'=>'20110914_074522',
193 'tags'=>'secretstuff'
194 );
195 $this->links[$link['linkdate']] = $link;
196
197 // Write database to disk
198 // TODO: raise an exception if the file is not write-able
199 file_put_contents(
200 // FIXME: do not use $GLOBALS
201 $GLOBALS['config']['DATASTORE'],
202 PHPPREFIX.base64_encode(gzdeflate(serialize($this->links))).PHPSUFFIX
203 );
204 }
205
206 /**
207 * Reads database from disk to memory
208 */
209 private function readdb()
210 {
211
212 // Public links are hidden and user not logged in => nothing to show
213 if ($GLOBALS['config']['HIDE_PUBLIC_LINKS'] && !isLoggedIn()) {
214 $this->links = array();
215 return;
216 }
217
218 // Read data
219 // Note that gzinflate is faster than gzuncompress.
220 // See: http://www.php.net/manual/en/function.gzdeflate.php#96439
221 // FIXME: do not use $GLOBALS
222 $this->links = array();
223
224 if (file_exists($GLOBALS['config']['DATASTORE'])) {
225 $this->links = unserialize(gzinflate(base64_decode(
226 substr(file_get_contents($GLOBALS['config']['DATASTORE']),
227 strlen(PHPPREFIX), -strlen(PHPSUFFIX)))));
228 }
229
230 // If user is not logged in, filter private links.
231 if (!$this->loggedIn) {
232 $toremove = array();
233 foreach ($this->links as $link) {
234 if ($link['private'] != 0) {
235 $toremove[] = $link['linkdate'];
236 }
237 }
238 foreach ($toremove as $linkdate) {
239 unset($this->links[$linkdate]);
240 }
241 }
242
243 // Keep the list of the mapping URLs-->linkdate up-to-date.
244 $this->urls = array();
245 foreach ($this->links as $link) {
246 $this->urls[$link['url']] = $link['linkdate'];
247 }
248 }
249
250 /**
251 * Saves the database from memory to disk
252 */
253 public function savedb()
254 {
255 if (!$this->loggedIn) {
256 // TODO: raise an Exception instead
257 die('You are not authorized to change the database.');
258 }
259 file_put_contents(
260 $GLOBALS['config']['DATASTORE'],
261 PHPPREFIX.base64_encode(gzdeflate(serialize($this->links))).PHPSUFFIX
262 );
263 invalidateCaches();
264 }
265
266 /**
267 * Returns the link for a given URL, or False if it does not exist.
268 */
269 public function getLinkFromUrl($url)
270 {
271 if (isset($this->urls[$url])) {
272 return $this->links[$this->urls[$url]];
273 }
274 return false;
275 }
276
277 /**
278 * Returns the list of links corresponding to a full-text search
279 *
280 * Searches:
281 * - in the URLs, title and description;
282 * - are case-insensitive.
283 *
284 * Example:
285 * print_r($mydb->filterFulltext('hollandais'));
286 *
287 * mb_convert_case($val, MB_CASE_LOWER, 'UTF-8')
288 * - allows to perform searches on Unicode text
289 * - see https://github.com/shaarli/Shaarli/issues/75 for examples
290 */
291 public function filterFulltext($searchterms)
292 {
293 // FIXME: explode(' ',$searchterms) and perform a AND search.
294 // FIXME: accept double-quotes to search for a string "as is"?
295 $filtered = array();
296 $search = mb_convert_case($searchterms, MB_CASE_LOWER, 'UTF-8');
297 $keys = ['title', 'description', 'url', 'tags'];
298
299 foreach ($this->links as $link) {
300 $found = false;
301
302 foreach ($keys as $key) {
303 if (strpos(mb_convert_case($link[$key], MB_CASE_LOWER, 'UTF-8'),
304 $search) !== false) {
305 $found = true;
306 }
307 }
308
309 if ($found) {
310 $filtered[$link['linkdate']] = $link;
311 }
312 }
313 krsort($filtered);
314 return $filtered;
315 }
316
317 /**
318 * Returns the list of links associated with a given list of tags
319 *
320 * You can specify one or more tags, separated by space or a comma, e.g.
321 * print_r($mydb->filterTags('linux programming'));
322 */
323 public function filterTags($tags, $casesensitive=false)
324 {
325 // Same as above, we use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek)
326 // FIXME: is $casesensitive ever true?
327 $t = str_replace(
328 ',', ' ',
329 ($casesensitive ? $tags : mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8'))
330 );
331
332 $searchtags = explode(' ', $t);
333 $filtered = array();
334
335 foreach ($this->links as $l) {
336 $linktags = explode(
337 ' ',
338 ($casesensitive ? $l['tags']:mb_convert_case($l['tags'], MB_CASE_LOWER, 'UTF-8'))
339 );
340
341 if (count(array_intersect($linktags, $searchtags)) == count($searchtags)) {
342 $filtered[$l['linkdate']] = $l;
343 }
344 }
345 krsort($filtered);
346 return $filtered;
347 }
348
349
350 /**
351 * Returns the list of articles for a given day, chronologically sorted
352 *
353 * Day must be in the form 'YYYYMMDD' (e.g. '20120125'), e.g.
354 * print_r($mydb->filterDay('20120125'));
355 */
356 public function filterDay($day)
357 {
358 // TODO: check input format
359 $filtered = array();
360 foreach ($this->links as $l) {
361 if (startsWith($l['linkdate'], $day)) {
362 $filtered[$l['linkdate']] = $l;
363 }
364 }
365 ksort($filtered);
366 return $filtered;
367 }
368
369 /**
370 * Returns the article corresponding to a smallHash
371 */
372 public function filterSmallHash($smallHash)
373 {
374 $filtered = array();
375 foreach ($this->links as $l) {
376 if ($smallHash == smallHash($l['linkdate'])) {
377 // Yes, this is ugly and slow
378 $filtered[$l['linkdate']] = $l;
379 return $filtered;
380 }
381 }
382 return $filtered;
383 }
384
385 /**
386 * Returns the list of all tags
387 * Output: associative array key=tags, value=0
388 */
389 public function allTags()
390 {
391 $tags = array();
392 foreach ($this->links as $link) {
393 foreach (explode(' ', $link['tags']) as $tag) {
394 if (!empty($tag)) {
395 $tags[$tag] = (empty($tags[$tag]) ? 1 : $tags[$tag] + 1);
396 }
397 }
398 }
399 // Sort tags by usage (most used tag first)
400 arsort($tags);
401 return $tags;
402 }
403
404 /**
405 * Returns the list of days containing articles (oldest first)
406 * Output: An array containing days (in format YYYYMMDD).
407 */
408 public function days()
409 {
410 $linkDays = array();
411 foreach (array_keys($this->links) as $day) {
412 $linkDays[substr($day, 0, 8)] = 0;
413 }
414 $linkDays = array_keys($linkDays);
415 sort($linkDays);
416 return $linkDays;
417 }
418}
419?>
diff --git a/application/Utils.php b/application/Utils.php
new file mode 100644
index 00000000..737f1502
--- /dev/null
+++ b/application/Utils.php
@@ -0,0 +1,45 @@
1<?php
2/**
3 * Shaarli utilities
4 */
5
6/**
7 * Returns the small hash of a string, using RFC 4648 base64url format
8 *
9 * Small hashes:
10 * - are unique (well, as unique as crc32, at last)
11 * - are always 6 characters long.
12 * - only use the following characters: a-z A-Z 0-9 - _ @
13 * - are NOT cryptographically secure (they CAN be forged)
14 *
15 * In Shaarli, they are used as a tinyurl-like link to individual entries,
16 * e.g. smallHash('20111006_131924') --> yZH23w
17 */
18function smallHash($text)
19{
20 $t = rtrim(base64_encode(hash('crc32', $text, true)), '=');
21 return strtr($t, '+/', '-_');
22}
23
24/**
25 * Tells if a string start with a substring
26 */
27function startsWith($haystack, $needle, $case=true)
28{
29 if ($case) {
30 return (strcmp(substr($haystack, 0, strlen($needle)), $needle) === 0);
31 }
32 return (strcasecmp(substr($haystack, 0, strlen($needle)), $needle) === 0);
33}
34
35/**
36 * Tells if a string ends with a substring
37 */
38function endsWith($haystack, $needle, $case=true)
39{
40 if ($case) {
41 return (strcmp(substr($haystack, strlen($haystack) - strlen($needle)), $needle) === 0);
42 }
43 return (strcasecmp(substr($haystack, strlen($haystack) - strlen($needle)), $needle) === 0);
44}
45?>
diff --git a/composer.json b/composer.json
index d1f613c1..f6d92c92 100644
--- a/composer.json
+++ b/composer.json
@@ -8,6 +8,7 @@
8 "require": {}, 8 "require": {},
9 "require-dev": { 9 "require-dev": {
10 "phpmd/phpmd" : "@stable", 10 "phpmd/phpmd" : "@stable",
11 "phpunit/phpunit": "4.6.*",
11 "sebastian/phpcpd": "*", 12 "sebastian/phpcpd": "*",
12 "squizlabs/php_codesniffer": "2.*" 13 "squizlabs/php_codesniffer": "2.*"
13 } 14 }
diff --git a/inc/shaarli.css b/inc/shaarli.css
index c4348c70..fcd5c6a2 100644
--- a/inc/shaarli.css
+++ b/inc/shaarli.css
@@ -221,8 +221,17 @@ h1 {
221 margin-left:24px; 221 margin-left:24px;
222} 222}
223 223
224.tagfilter div.awesomplete {
225 width: inherit;
226}
227
224.tagfilter #tagfilter_value { 228.tagfilter #tagfilter_value {
225 width: 10%; 229 width: 100%;
230 display: inline;
231}
232
233.tagfilter li {
234 color: black;
226} 235}
227 236
228.tagfilter input.bigbutton, .searchform input.bigbutton, .addform input.bigbutton { 237.tagfilter input.bigbutton, .searchform input.bigbutton, .addform input.bigbutton {
@@ -339,6 +348,16 @@ h1 {
339 font-size: inherit; 348 font-size: inherit;
340} 349}
341 350
351#headerform label {
352 margin-right: 10px;
353}
354
355#headerform label[for=longlastingsession] {
356 display: block;
357 width: 100%;
358 margin-top: 5px;
359}
360
342#toolsdiv { 361#toolsdiv {
343 color: #ffffff; 362 color: #ffffff;
344 padding: 5px 5px 5px 5px; 363 padding: 5px 5px 5px 5px;
@@ -975,6 +994,20 @@ div.dailyNoEntry {
975 margin: 3px; 994 margin: 3px;
976 } 995 }
977 996
997 #headerform label {
998 width: 100%;
999 display: block;
1000 height: auto;
1001 line-height: 25px;
1002 padding-bottom: 10px;
1003 }
1004
1005 #headerform label input[type=text],
1006 #headerform label input[type=password]{
1007 float: right;
1008 width: 70%;
1009 }
1010
978 .searchform, .tagfilter { 1011 .searchform, .tagfilter {
979 display: block !important; 1012 display: block !important;
980 margin: 0px 3px 7px 0px !important; 1013 margin: 0px 3px 7px 0px !important;
diff --git a/index.php b/index.php
index 7e94fd24..5aa7116f 100644
--- a/index.php
+++ b/index.php
@@ -68,6 +68,10 @@ checkphpversion();
68error_reporting(E_ALL^E_WARNING); // See all error except warnings. 68error_reporting(E_ALL^E_WARNING); // See all error except warnings.
69//error_reporting(-1); // See all errors (for debugging only) 69//error_reporting(-1); // See all errors (for debugging only)
70 70
71// Shaarli library
72require_once 'application/LinkDB.php';
73require_once 'application/Utils.php';
74
71include "inc/rain.tpl.class.php"; //include Rain TPL 75include "inc/rain.tpl.class.php"; //include Rain TPL
72raintpl::$tpl_dir = $GLOBALS['config']['RAINTPL_TPL']; // template directory 76raintpl::$tpl_dir = $GLOBALS['config']['RAINTPL_TPL']; // template directory
73raintpl::$cache_dir = $GLOBALS['config']['RAINTPL_TMP']; // cache directory 77raintpl::$cache_dir = $GLOBALS['config']['RAINTPL_TMP']; // cache directory
@@ -268,21 +272,6 @@ function nl2br_escaped($html)
268 return str_replace('>','&gt;',str_replace('<','&lt;',nl2br($html))); 272 return str_replace('>','&gt;',str_replace('<','&lt;',nl2br($html)));
269} 273}
270 274
271/* Returns the small hash of a string, using RFC 4648 base64url format
272 e.g. smallHash('20111006_131924') --> yZH23w
273 Small hashes:
274 - are unique (well, as unique as crc32, at last)
275 - are always 6 characters long.
276 - only use the following characters: a-z A-Z 0-9 - _ @
277 - are NOT cryptographically secure (they CAN be forged)
278 In Shaarli, they are used as a tinyurl-like link to individual entries.
279*/
280function smallHash($text)
281{
282 $t = rtrim(base64_encode(hash('crc32',$text,true)),'=');
283 return strtr($t, '+/', '-_');
284}
285
286// In a string, converts URLs to clickable links. 275// In a string, converts URLs to clickable links.
287// Function inspired from http://www.php.net/manual/en/function.preg-replace.php#85722 276// Function inspired from http://www.php.net/manual/en/function.preg-replace.php#85722
288function text2clickable($url) 277function text2clickable($url)
@@ -536,20 +525,6 @@ function getMaxFileSize()
536 return $maxsize; 525 return $maxsize;
537} 526}
538 527
539// Tells if a string start with a substring or not.
540function startsWith($haystack,$needle,$case=true)
541{
542 if($case){return (strcmp(substr($haystack, 0, strlen($needle)),$needle)===0);}
543 return (strcasecmp(substr($haystack, 0, strlen($needle)),$needle)===0);
544}
545
546// Tells if a string ends with a substring or not.
547function endsWith($haystack,$needle,$case=true)
548{
549 if($case){return (strcmp(substr($haystack, strlen($haystack) - strlen($needle)),$needle)===0);}
550 return (strcasecmp(substr($haystack, strlen($haystack) - strlen($needle)),$needle)===0);
551}
552
553/* Converts a linkdate time (YYYYMMDD_HHMMSS) of an article to a timestamp (Unix epoch) 528/* Converts a linkdate time (YYYYMMDD_HHMMSS) of an article to a timestamp (Unix epoch)
554 (used to build the ADD_DATE attribute in Netscape-bookmarks file) 529 (used to build the ADD_DATE attribute in Netscape-bookmarks file)
555 PS: I could have used strptime(), but it does not exist on Windows. I'm too kind. */ 530 PS: I could have used strptime(), but it does not exist on Windows. I'm too kind. */
@@ -711,220 +686,6 @@ class pageBuilder
711} 686}
712 687
713// ------------------------------------------------------------------------------------------ 688// ------------------------------------------------------------------------------------------
714/* Data storage for links.
715 This object behaves like an associative array.
716 Example:
717 $mylinks = new linkdb();
718 echo $mylinks['20110826_161819']['title'];
719 foreach($mylinks as $link)
720 echo $link['title'].' at url '.$link['url'].' ; description:'.$link['description'];
721
722 Available keys:
723 title : Title of the link
724 url : URL of the link. Can be absolute or relative. Relative URLs are permalinks (e.g.'?m-ukcw')
725 description : description of the entry
726 private : Is this link private? 0=no, other value=yes
727 linkdate : date of the creation of this entry, in the form YYYYMMDD_HHMMSS (e.g.'20110914_192317')
728 tags : tags attached to this entry (separated by spaces)
729
730 We implement 3 interfaces:
731 - ArrayAccess so that this object behaves like an associative array.
732 - Iterator so that this object can be used in foreach() loops.
733 - Countable interface so that we can do a count() on this object.
734*/
735class linkdb implements Iterator, Countable, ArrayAccess
736{
737 private $links; // List of links (associative array. Key=linkdate (e.g. "20110823_124546"), value= associative array (keys:title,description...)
738 private $urls; // List of all recorded URLs (key=url, value=linkdate) for fast reserve search (url-->linkdate)
739 private $keys; // List of linkdate keys (for the Iterator interface implementation)
740 private $position; // Position in the $this->keys array. (for the Iterator interface implementation.)
741 private $loggedin; // Is the user logged in? (used to filter private links)
742
743 // Constructor:
744 function __construct($isLoggedIn)
745 // Input : $isLoggedIn : is the user logged in?
746 {
747 $this->loggedin = $isLoggedIn;
748 $this->checkdb(); // Make sure data file exists.
749 $this->readdb(); // Then read it.
750 }
751
752 // ---- Countable interface implementation
753 public function count() { return count($this->links); }
754
755 // ---- ArrayAccess interface implementation
756 public function offsetSet($offset, $value)
757 {
758 if (!$this->loggedin) die('You are not authorized to add a link.');
759 if (empty($value['linkdate']) || empty($value['url'])) die('Internal Error: A link should always have a linkdate and URL.');
760 if (empty($offset)) die('You must specify a key.');
761 $this->links[$offset] = $value;
762 $this->urls[$value['url']]=$offset;
763 }
764 public function offsetExists($offset) { return array_key_exists($offset,$this->links); }
765 public function offsetUnset($offset)
766 {
767 if (!$this->loggedin) die('You are not authorized to delete a link.');
768 $url = $this->links[$offset]['url']; unset($this->urls[$url]);
769 unset($this->links[$offset]);
770 }
771 public function offsetGet($offset) { return isset($this->links[$offset]) ? $this->links[$offset] : null; }
772
773 // ---- Iterator interface implementation
774 function rewind() { $this->keys=array_keys($this->links); rsort($this->keys); $this->position=0; } // Start over for iteration, ordered by date (latest first).
775 function key() { return $this->keys[$this->position]; } // current key
776 function current() { return $this->links[$this->keys[$this->position]]; } // current value
777 function next() { ++$this->position; } // go to next item
778 function valid() { return isset($this->keys[$this->position]); } // Check if current position is valid.
779
780 // ---- Misc methods
781 private function checkdb() // Check if db directory and file exists.
782 {
783 if (!file_exists($GLOBALS['config']['DATASTORE'])) // Create a dummy database for example.
784 {
785 $this->links = array();
786 $link = array('title'=>'Shaarli - sebsauvage.net','url'=>'http://sebsauvage.net/wiki/doku.php?id=php:shaarli','description'=>'Welcome to Shaarli ! This is a bookmark. To edit or delete me, you must first login.','private'=>0,'linkdate'=>'20110914_190000','tags'=>'opensource software');
787 $this->links[$link['linkdate']] = $link;
788 $link = array('title'=>'My secret stuff... - Pastebin.com','url'=>'http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=','description'=>'SShhhh!! I\'m a private link only YOU can see. You can delete me too.','private'=>1,'linkdate'=>'20110914_074522','tags'=>'secretstuff');
789 $this->links[$link['linkdate']] = $link;
790 file_put_contents($GLOBALS['config']['DATASTORE'], PHPPREFIX.base64_encode(gzdeflate(serialize($this->links))).PHPSUFFIX); // Write database to disk
791 }
792 }
793
794 // Read database from disk to memory
795 private function readdb()
796 {
797 // Read data
798 $this->links=(file_exists($GLOBALS['config']['DATASTORE']) ? unserialize(gzinflate(base64_decode(substr(file_get_contents($GLOBALS['config']['DATASTORE']),strlen(PHPPREFIX),-strlen(PHPSUFFIX))))) : array() );
799 // Note that gzinflate is faster than gzuncompress. See: http://www.php.net/manual/en/function.gzdeflate.php#96439
800
801 // If user is not logged in, filter private links.
802 if (!$this->loggedin)
803 {
804 $toremove=array();
805 foreach($this->links as $link) { if ($link['private']!=0) $toremove[]=$link['linkdate']; }
806 foreach($toremove as $linkdate) { unset($this->links[$linkdate]); }
807 }
808
809 // Keep the list of the mapping URLs-->linkdate up-to-date.
810 $this->urls=array();
811 foreach($this->links as $link) { $this->urls[$link['url']]=$link['linkdate']; }
812 }
813
814 // Save database from memory to disk.
815 public function savedb()
816 {
817 if (!$this->loggedin) die('You are not authorized to change the database.');
818 file_put_contents($GLOBALS['config']['DATASTORE'], PHPPREFIX.base64_encode(gzdeflate(serialize($this->links))).PHPSUFFIX);
819 invalidateCaches();
820 }
821
822 // Returns the link for a given URL (if it exists). False if it does not exist.
823 public function getLinkFromUrl($url)
824 {
825 if (isset($this->urls[$url])) return $this->links[$this->urls[$url]];
826 return false;
827 }
828
829 // Case insensitive search among links (in the URLs, title and description). Returns filtered list of links.
830 // e.g. print_r($mydb->filterFulltext('hollandais'));
831 public function filterFulltext($searchterms)
832 {
833 // FIXME: explode(' ',$searchterms) and perform a AND search.
834 // FIXME: accept double-quotes to search for a string "as is"?
835 // Using mb_convert_case($val, MB_CASE_LOWER, 'UTF-8') allows us to perform searches on
836 // Unicode text. See https://github.com/shaarli/Shaarli/issues/75 for examples.
837 $filtered=array();
838 $s = mb_convert_case($searchterms, MB_CASE_LOWER, 'UTF-8');
839 foreach($this->links as $l)
840 {
841 $found= (strpos(mb_convert_case($l['title'], MB_CASE_LOWER, 'UTF-8'),$s) !== false)
842 || (strpos(mb_convert_case($l['description'], MB_CASE_LOWER, 'UTF-8'),$s) !== false)
843 || (strpos(mb_convert_case($l['url'], MB_CASE_LOWER, 'UTF-8'),$s) !== false)
844 || (strpos(mb_convert_case($l['tags'], MB_CASE_LOWER, 'UTF-8'),$s) !== false);
845 if ($found) $filtered[$l['linkdate']] = $l;
846 }
847 krsort($filtered);
848 return $filtered;
849 }
850
851 // Filter by tag.
852 // You can specify one or more tags (tags can be separated by space or comma).
853 // e.g. print_r($mydb->filterTags('linux programming'));
854 public function filterTags($tags,$casesensitive=false)
855 {
856 // Same as above, we use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek)
857 // TODO: is $casesensitive ever true ?
858 $t = str_replace(',',' ',($casesensitive?$tags:mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8')));
859 $searchtags=explode(' ',$t);
860 $filtered=array();
861 foreach($this->links as $l)
862 {
863 $linktags = explode(' ',($casesensitive?$l['tags']:mb_convert_case($l['tags'], MB_CASE_LOWER, 'UTF-8')));
864 if (count(array_intersect($linktags,$searchtags)) == count($searchtags))
865 $filtered[$l['linkdate']] = $l;
866 }
867 krsort($filtered);
868 return $filtered;
869 }
870
871 // Filter by day. Day must be in the form 'YYYYMMDD' (e.g. '20120125')
872 // Sort order is: older articles first.
873 // e.g. print_r($mydb->filterDay('20120125'));
874 public function filterDay($day)
875 {
876 $filtered=array();
877 foreach($this->links as $l)
878 {
879 if (startsWith($l['linkdate'],$day)) $filtered[$l['linkdate']] = $l;
880 }
881 ksort($filtered);
882 return $filtered;
883 }
884 // Filter by smallHash.
885 // Only 1 article is returned.
886 public function filterSmallHash($smallHash)
887 {
888 $filtered=array();
889 foreach($this->links as $l)
890 {
891 if ($smallHash==smallHash($l['linkdate'])) // Yes, this is ugly and slow
892 {
893 $filtered[$l['linkdate']] = $l;
894 return $filtered;
895 }
896 }
897 return $filtered;
898 }
899
900 // Returns the list of all tags
901 // Output: associative array key=tags, value=0
902 public function allTags()
903 {
904 $tags=array();
905 foreach($this->links as $link)
906 foreach(explode(' ',$link['tags']) as $tag)
907 if (!empty($tag)) $tags[$tag]=(empty($tags[$tag]) ? 1 : $tags[$tag]+1);
908 arsort($tags); // Sort tags by usage (most used tag first)
909 return $tags;
910 }
911
912 // Returns the list of days containing articles (oldest first)
913 // Output: An array containing days (in format YYYYMMDD).
914 public function days()
915 {
916 $linkdays=array();
917 foreach(array_keys($this->links) as $day)
918 {
919 $linkdays[substr($day,0,8)]=0;
920 }
921 $linkdays=array_keys($linkdays);
922 sort($linkdays);
923 return $linkdays;
924 }
925}
926
927// ------------------------------------------------------------------------------------------
928// Output the last N links in RSS 2.0 format. 689// Output the last N links in RSS 2.0 format.
929function showRSS() 690function showRSS()
930{ 691{
@@ -941,16 +702,13 @@ function showRSS()
941 $cached = $cache->cachedVersion(); if (!empty($cached)) { echo $cached; exit; } 702 $cached = $cache->cachedVersion(); if (!empty($cached)) { echo $cached; exit; }
942 703
943 // If cached was not found (or not usable), then read the database and build the response: 704 // If cached was not found (or not usable), then read the database and build the response:
944 $LINKSDB=new linkdb(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']); // Read links from database (and filter private links if user it not logged in). 705 $LINKSDB = new LinkDB(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']); // Read links from database (and filter private links if user it not logged in).
945 706
946 // Optionally filter the results: 707 // Optionally filter the results:
947 $linksToDisplay=array(); 708 $linksToDisplay=array();
948 if (!empty($_GET['searchterm'])) $linksToDisplay = $LINKSDB->filterFulltext($_GET['searchterm']); 709 if (!empty($_GET['searchterm'])) $linksToDisplay = $LINKSDB->filterFulltext($_GET['searchterm']);
949 else if (!empty($_GET['searchtags'])) $linksToDisplay = $LINKSDB->filterTags(trim($_GET['searchtags'])); 710 else if (!empty($_GET['searchtags'])) $linksToDisplay = $LINKSDB->filterTags(trim($_GET['searchtags']));
950 else $linksToDisplay = $LINKSDB; 711 else $linksToDisplay = $LINKSDB;
951
952 if ($GLOBALS['config']['HIDE_PUBLIC_LINKS'] && !isLoggedIn())
953 $linksToDisplay = array();
954 712
955 $nblinksToDisplay = 50; // Number of links to display. 713 $nblinksToDisplay = 50; // Number of links to display.
956 if (!empty($_GET['nb'])) // In URL, you can specificy the number of links. Example: nb=200 or nb=all for all links. 714 if (!empty($_GET['nb'])) // In URL, you can specificy the number of links. Example: nb=200 or nb=all for all links.
@@ -1019,7 +777,7 @@ function showATOM()
1019 $cached = $cache->cachedVersion(); if (!empty($cached)) { echo $cached; exit; } 777 $cached = $cache->cachedVersion(); if (!empty($cached)) { echo $cached; exit; }
1020 // If cached was not found (or not usable), then read the database and build the response: 778 // If cached was not found (or not usable), then read the database and build the response:
1021 779
1022 $LINKSDB=new linkdb(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']); // Read links from database (and filter private links if used it not logged in). 780 $LINKSDB = new LinkDB(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']); // Read links from database (and filter private links if used it not logged in).
1023 781
1024 782
1025 // Optionally filter the results: 783 // Optionally filter the results:
@@ -1027,9 +785,6 @@ function showATOM()
1027 if (!empty($_GET['searchterm'])) $linksToDisplay = $LINKSDB->filterFulltext($_GET['searchterm']); 785 if (!empty($_GET['searchterm'])) $linksToDisplay = $LINKSDB->filterFulltext($_GET['searchterm']);
1028 else if (!empty($_GET['searchtags'])) $linksToDisplay = $LINKSDB->filterTags(trim($_GET['searchtags'])); 786 else if (!empty($_GET['searchtags'])) $linksToDisplay = $LINKSDB->filterTags(trim($_GET['searchtags']));
1029 else $linksToDisplay = $LINKSDB; 787 else $linksToDisplay = $LINKSDB;
1030
1031 if ($GLOBALS['config']['HIDE_PUBLIC_LINKS'] && !isLoggedIn())
1032 $linksToDisplay = array();
1033 788
1034 $nblinksToDisplay = 50; // Number of links to display. 789 $nblinksToDisplay = 50; // Number of links to display.
1035 if (!empty($_GET['nb'])) // In URL, you can specificy the number of links. Example: nb=200 or nb=all for all links. 790 if (!empty($_GET['nb'])) // In URL, you can specificy the number of links. Example: nb=200 or nb=all for all links.
@@ -1104,7 +859,7 @@ function showDailyRSS()
1104 $cache = new pageCache(pageUrl(),startsWith($query,'do=dailyrss') && !isLoggedIn()); 859 $cache = new pageCache(pageUrl(),startsWith($query,'do=dailyrss') && !isLoggedIn());
1105 $cached = $cache->cachedVersion(); if (!empty($cached)) { echo $cached; exit; } 860 $cached = $cache->cachedVersion(); if (!empty($cached)) { echo $cached; exit; }
1106 // If cached was not found (or not usable), then read the database and build the response: 861 // If cached was not found (or not usable), then read the database and build the response:
1107 $LINKSDB=new linkdb(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']); // Read links from database (and filter private links if used it not logged in). 862 $LINKSDB = new LinkDB(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']); // Read links from database (and filter private links if used it not logged in).
1108 863
1109 /* Some Shaarlies may have very few links, so we need to look 864 /* Some Shaarlies may have very few links, so we need to look
1110 back in time (rsort()) until we have enough days ($nb_of_days). 865 back in time (rsort()) until we have enough days ($nb_of_days).
@@ -1172,7 +927,7 @@ function showDailyRSS()
1172// "Daily" page. 927// "Daily" page.
1173function showDaily() 928function showDaily()
1174{ 929{
1175 $LINKSDB=new linkdb(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']); // Read links from database (and filter private links if used it not logged in). 930 $LINKSDB = new LinkDB(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']); // Read links from database (and filter private links if used it not logged in).
1176 931
1177 932
1178 $day=Date('Ymd',strtotime('-1 day')); // Yesterday, in format YYYYMMDD. 933 $day=Date('Ymd',strtotime('-1 day')); // Yesterday, in format YYYYMMDD.
@@ -1190,8 +945,6 @@ function showDaily()
1190 } 945 }
1191 946
1192 $linksToDisplay=$LINKSDB->filterDay($day); 947 $linksToDisplay=$LINKSDB->filterDay($day);
1193 if ($GLOBALS['config']['HIDE_PUBLIC_LINKS'] && !isLoggedIn())
1194 $linksToDisplay = array();
1195 // We pre-format some fields for proper output. 948 // We pre-format some fields for proper output.
1196 foreach($linksToDisplay as $key=>$link) 949 foreach($linksToDisplay as $key=>$link)
1197 { 950 {
@@ -1240,7 +993,7 @@ function showDaily()
1240// Render HTML page (according to URL parameters and user rights) 993// Render HTML page (according to URL parameters and user rights)
1241function renderPage() 994function renderPage()
1242{ 995{
1243 $LINKSDB=new linkdb(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']); // Read links from database (and filter private links if used it not logged in). 996 $LINKSDB = new LinkDB(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']); // Read links from database (and filter private links if used it not logged in).
1244 997
1245 // -------- Display login form. 998 // -------- Display login form.
1246 if (isset($_SERVER["QUERY_STRING"]) && startswith($_SERVER["QUERY_STRING"],'do=login')) 999 if (isset($_SERVER["QUERY_STRING"]) && startswith($_SERVER["QUERY_STRING"],'do=login'))
@@ -1270,9 +1023,6 @@ function renderPage()
1270 if (!empty($_GET['searchterm'])) $links = $LINKSDB->filterFulltext($_GET['searchterm']); 1023 if (!empty($_GET['searchterm'])) $links = $LINKSDB->filterFulltext($_GET['searchterm']);
1271 elseif (!empty($_GET['searchtags'])) $links = $LINKSDB->filterTags(trim($_GET['searchtags'])); 1024 elseif (!empty($_GET['searchtags'])) $links = $LINKSDB->filterTags(trim($_GET['searchtags']));
1272 else $links = $LINKSDB; 1025 else $links = $LINKSDB;
1273
1274 if ($GLOBALS['config']['HIDE_PUBLIC_LINKS'] && !isLoggedIn())
1275 $links = array();
1276 1026
1277 $body=''; 1027 $body='';
1278 $linksToDisplay=array(); 1028 $linksToDisplay=array();
@@ -1300,8 +1050,7 @@ function renderPage()
1300 if (isset($_SERVER["QUERY_STRING"]) && startswith($_SERVER["QUERY_STRING"],'do=tagcloud')) 1050 if (isset($_SERVER["QUERY_STRING"]) && startswith($_SERVER["QUERY_STRING"],'do=tagcloud'))
1301 { 1051 {
1302 $tags= $LINKSDB->allTags(); 1052 $tags= $LINKSDB->allTags();
1303 if ($GLOBALS['config']['HIDE_PUBLIC_LINKS'] && !isLoggedIn()) 1053
1304 $tags = array();
1305 // We sort tags alphabetically, then choose a font size according to count. 1054 // We sort tags alphabetically, then choose a font size according to count.
1306 // First, find max value. 1055 // First, find max value.
1307 $maxcount=0; foreach($tags as $key=>$value) $maxcount=max($maxcount,$value); 1056 $maxcount=0; foreach($tags as $key=>$value) $maxcount=max($maxcount,$value);
@@ -1822,7 +1571,7 @@ HTML;
1822function importFile() 1571function importFile()
1823{ 1572{
1824 if (!(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI'])) { die('Not allowed.'); } 1573 if (!(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI'])) { die('Not allowed.'); }
1825 $LINKSDB=new linkdb(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']); // Read links from database (and filter private links if used it not logged in). 1574 $LINKSDB = new LinkDB(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']); // Read links from database (and filter private links if used it not logged in).
1826 $filename=$_FILES['filetoupload']['name']; 1575 $filename=$_FILES['filetoupload']['name'];
1827 $filesize=$_FILES['filetoupload']['size']; 1576 $filesize=$_FILES['filetoupload']['size'];
1828 $data=file_get_contents($_FILES['filetoupload']['tmp_name']); 1577 $data=file_get_contents($_FILES['filetoupload']['tmp_name']);
@@ -1914,16 +1663,12 @@ function buildLinkList($PAGE,$LINKSDB)
1914 if (isset($_GET['searchterm'])) // Fulltext search 1663 if (isset($_GET['searchterm'])) // Fulltext search
1915 { 1664 {
1916 $linksToDisplay = $LINKSDB->filterFulltext(trim($_GET['searchterm'])); 1665 $linksToDisplay = $LINKSDB->filterFulltext(trim($_GET['searchterm']));
1917 if ($GLOBALS['config']['HIDE_PUBLIC_LINKS'] && !isLoggedIn())
1918 $linksToDisplay = array();
1919 $search_crits=htmlspecialchars(trim($_GET['searchterm'])); 1666 $search_crits=htmlspecialchars(trim($_GET['searchterm']));
1920 $search_type='fulltext'; 1667 $search_type='fulltext';
1921 } 1668 }
1922 elseif (isset($_GET['searchtags'])) // Search by tag 1669 elseif (isset($_GET['searchtags'])) // Search by tag
1923 { 1670 {
1924 $linksToDisplay = $LINKSDB->filterTags(trim($_GET['searchtags'])); 1671 $linksToDisplay = $LINKSDB->filterTags(trim($_GET['searchtags']));
1925 if ($GLOBALS['config']['HIDE_PUBLIC_LINKS'] && !isLoggedIn())
1926 $linksToDisplay = array();
1927 $search_crits=explode(' ',trim($_GET['searchtags'])); 1672 $search_crits=explode(' ',trim($_GET['searchtags']));
1928 $search_type='tags'; 1673 $search_type='tags';
1929 } 1674 }
@@ -1939,9 +1684,6 @@ function buildLinkList($PAGE,$LINKSDB)
1939 } 1684 }
1940 $search_type='permalink'; 1685 $search_type='permalink';
1941 } 1686 }
1942 // We chose to disable all private links and the user isn't logged in, do not return any link.
1943 else if ($GLOBALS['config']['HIDE_PUBLIC_LINKS'] && !isLoggedIn())
1944 $linksToDisplay = array();
1945 else 1687 else
1946 $linksToDisplay = $LINKSDB; // Otherwise, display without filtering. 1688 $linksToDisplay = $LINKSDB; // Otherwise, display without filtering.
1947 1689
@@ -2018,6 +1760,7 @@ function buildLinkList($PAGE,$LINKSDB)
2018 $PAGE->assign('redirector',empty($GLOBALS['redirector']) ? '' : $GLOBALS['redirector']); // Optional redirector URL. 1760 $PAGE->assign('redirector',empty($GLOBALS['redirector']) ? '' : $GLOBALS['redirector']); // Optional redirector URL.
2019 $PAGE->assign('token',$token); 1761 $PAGE->assign('token',$token);
2020 $PAGE->assign('links',$linkDisp); 1762 $PAGE->assign('links',$linkDisp);
1763 $PAGE->assign('tags', $LINKSDB->allTags());
2021 return; 1764 return;
2022} 1765}
2023 1766
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 00000000..d6e01c35
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,15 @@
1<?xml version="1.0" encoding="UTF-8"?>
2<phpunit
3 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4 xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/4.5/phpunit.xsd"
5 colors="true">
6 <filter>
7 <whitelist addUncoveredFilesFromWhitelist="true">
8 <directory suffix=".php">application</directory>
9 </whitelist>
10 </filter>
11 <logging>
12 <log type="coverage-html" target="coverage" lowUpperBound="30" highLowerBound="80"/>
13 <log type="coverage-text" target="php://stdout" showUncoveredFiles="true"/>
14 </logging>
15</phpunit>
diff --git a/tests/.htaccess b/tests/.htaccess
new file mode 100644
index 00000000..b584d98c
--- /dev/null
+++ b/tests/.htaccess
@@ -0,0 +1,2 @@
1Allow from none
2Deny from all
diff --git a/tests/LinkDBTest.php b/tests/LinkDBTest.php
new file mode 100644
index 00000000..bbe4e026
--- /dev/null
+++ b/tests/LinkDBTest.php
@@ -0,0 +1,509 @@
1<?php
2/**
3 * Link datastore tests
4 */
5
6require_once 'application/LinkDB.php';
7require_once 'application/Utils.php';
8require_once 'tests/utils/ReferenceLinkDB.php';
9
10define('PHPPREFIX', '<?php /* ');
11define('PHPSUFFIX', ' */ ?>');
12
13
14/**
15 * Unitary tests for LinkDB
16 */
17class LinkDBTest extends PHPUnit_Framework_TestCase
18{
19 // datastore to test write operations
20 protected static $testDatastore = 'tests/datastore.php';
21 protected static $dummyDatastoreSHA1 = 'e3edea8ea7bb50be4bcb404df53fbb4546a7156e';
22 protected static $refDB = null;
23 protected static $publicLinkDB = null;
24 protected static $privateLinkDB = null;
25
26 /**
27 * Instantiates public and private LinkDBs with test data
28 *
29 * The reference datastore contains public and private links that
30 * will be used to test LinkDB's methods:
31 * - access filtering (public/private),
32 * - link searches:
33 * - by day,
34 * - by tag,
35 * - by text,
36 * - etc.
37 */
38 public static function setUpBeforeClass()
39 {
40 self::$refDB = new ReferenceLinkDB();
41 self::$refDB->write(self::$testDatastore, PHPPREFIX, PHPSUFFIX);
42
43 $GLOBALS['config']['DATASTORE'] = self::$testDatastore;
44 self::$publicLinkDB = new LinkDB(false);
45 self::$privateLinkDB = new LinkDB(true);
46 }
47
48 /**
49 * Resets test data for each test
50 */
51 protected function setUp()
52 {
53 $GLOBALS['config']['DATASTORE'] = self::$testDatastore;
54 if (file_exists(self::$testDatastore)) {
55 unlink(self::$testDatastore);
56 }
57 }
58
59 /**
60 * Allows to test LinkDB's private methods
61 *
62 * @see
63 * https://sebastian-bergmann.de/archives/881-Testing-Your-Privates.html
64 * http://stackoverflow.com/a/2798203
65 */
66 protected static function getMethod($name)
67 {
68 $class = new ReflectionClass('LinkDB');
69 $method = $class->getMethod($name);
70 $method->setAccessible(true);
71 return $method;
72 }
73
74 /**
75 * Instantiate LinkDB objects - logged in user
76 */
77 public function testConstructLoggedIn()
78 {
79 new LinkDB(true);
80 $this->assertFileExists(self::$testDatastore);
81 }
82
83 /**
84 * Instantiate LinkDB objects - logged out or public instance
85 */
86 public function testConstructLoggedOut()
87 {
88 new LinkDB(false);
89 $this->assertFileExists(self::$testDatastore);
90 }
91
92 /**
93 * Attempt to instantiate a LinkDB whereas the datastore is not writable
94 *
95 * @expectedException PHPUnit_Framework_Error_Warning
96 * @expectedExceptionMessageRegExp /failed to open stream: No such file or directory/
97 */
98 public function testConstructDatastoreNotWriteable()
99 {
100 $GLOBALS['config']['DATASTORE'] = 'null/store.db';
101 new LinkDB(false);
102 }
103
104 /**
105 * The DB doesn't exist, ensure it is created with dummy content
106 */
107 public function testCheckDBNew()
108 {
109 $linkDB = new LinkDB(false);
110 unlink(self::$testDatastore);
111 $this->assertFileNotExists(self::$testDatastore);
112
113 $checkDB = self::getMethod('checkDB');
114 $checkDB->invokeArgs($linkDB, array());
115 $this->assertFileExists(self::$testDatastore);
116
117 // ensure the correct data has been written
118 $this->assertEquals(
119 self::$dummyDatastoreSHA1,
120 sha1_file(self::$testDatastore)
121 );
122 }
123
124 /**
125 * The DB exists, don't do anything
126 */
127 public function testCheckDBLoad()
128 {
129 $linkDB = new LinkDB(false);
130 $this->assertEquals(
131 self::$dummyDatastoreSHA1,
132 sha1_file(self::$testDatastore)
133 );
134
135 $checkDB = self::getMethod('checkDB');
136 $checkDB->invokeArgs($linkDB, array());
137
138 // ensure the datastore is left unmodified
139 $this->assertEquals(
140 self::$dummyDatastoreSHA1,
141 sha1_file(self::$testDatastore)
142 );
143 }
144
145 /**
146 * Load an empty DB
147 */
148 public function testReadEmptyDB()
149 {
150 file_put_contents(self::$testDatastore, PHPPREFIX.'S7QysKquBQA='.PHPSUFFIX);
151 $emptyDB = new LinkDB(false);
152 $this->assertEquals(0, sizeof($emptyDB));
153 $this->assertEquals(0, count($emptyDB));
154 }
155
156 /**
157 * Load public links from the DB
158 */
159 public function testReadPublicDB()
160 {
161 $this->assertEquals(
162 self::$refDB->countPublicLinks(),
163 sizeof(self::$publicLinkDB)
164 );
165 }
166
167 /**
168 * Load public and private links from the DB
169 */
170 public function testReadPrivateDB()
171 {
172 $this->assertEquals(
173 self::$refDB->countLinks(),
174 sizeof(self::$privateLinkDB)
175 );
176 }
177
178 /**
179 * Save the links to the DB
180 */
181 public function testSaveDB()
182 {
183 $testDB = new LinkDB(true);
184 $dbSize = sizeof($testDB);
185
186 $link = array(
187 'title'=>'an additional link',
188 'url'=>'http://dum.my',
189 'description'=>'One more',
190 'private'=>0,
191 'linkdate'=>'20150518_190000',
192 'tags'=>'unit test'
193 );
194 $testDB[$link['linkdate']] = $link;
195
196 // TODO: move PageCache to a proper class/file
197 function invalidateCaches() {}
198
199 $testDB->savedb();
200
201 $testDB = new LinkDB(true);
202 $this->assertEquals($dbSize + 1, sizeof($testDB));
203 }
204
205 /**
206 * Count existing links
207 */
208 public function testCount()
209 {
210 $this->assertEquals(
211 self::$refDB->countPublicLinks(),
212 self::$publicLinkDB->count()
213 );
214 $this->assertEquals(
215 self::$refDB->countLinks(),
216 self::$privateLinkDB->count()
217 );
218 }
219
220 /**
221 * List the days for which links have been posted
222 */
223 public function testDays()
224 {
225 $this->assertEquals(
226 ['20121206', '20130614', '20150310'],
227 self::$publicLinkDB->days()
228 );
229
230 $this->assertEquals(
231 ['20121206', '20130614', '20141125', '20150310'],
232 self::$privateLinkDB->days()
233 );
234 }
235
236 /**
237 * The URL corresponds to an existing entry in the DB
238 */
239 public function testGetKnownLinkFromURL()
240 {
241 $link = self::$publicLinkDB->getLinkFromUrl('http://mediagoblin.org/');
242
243 $this->assertNotEquals(false, $link);
244 $this->assertEquals(
245 'A free software media publishing platform',
246 $link['description']
247 );
248 }
249
250 /**
251 * The URL is not in the DB
252 */
253 public function testGetUnknownLinkFromURL()
254 {
255 $this->assertEquals(
256 false,
257 self::$publicLinkDB->getLinkFromUrl('http://dev.null')
258 );
259 }
260
261 /**
262 * Lists all tags
263 */
264 public function testAllTags()
265 {
266 $this->assertEquals(
267 [
268 'web' => 3,
269 'cartoon' => 2,
270 'gnu' => 2,
271 'dev' => 1,
272 'samba' => 1,
273 'media' => 1,
274 'software' => 1,
275 'stallman' => 1,
276 'free' => 1
277 ],
278 self::$publicLinkDB->allTags()
279 );
280
281 $this->assertEquals(
282 [
283 'web' => 4,
284 'cartoon' => 3,
285 'gnu' => 2,
286 'dev' => 2,
287 'samba' => 1,
288 'media' => 1,
289 'software' => 1,
290 'stallman' => 1,
291 'free' => 1,
292 'html' => 1,
293 'w3c' => 1,
294 'css' => 1,
295 'Mercurial' => 1
296 ],
297 self::$privateLinkDB->allTags()
298 );
299 }
300
301 /**
302 * Filter links using a tag
303 */
304 public function testFilterOneTag()
305 {
306 $this->assertEquals(
307 3,
308 sizeof(self::$publicLinkDB->filterTags('web', false))
309 );
310
311 $this->assertEquals(
312 4,
313 sizeof(self::$privateLinkDB->filterTags('web', false))
314 );
315 }
316
317 /**
318 * Filter links using a tag - case-sensitive
319 */
320 public function testFilterCaseSensitiveTag()
321 {
322 $this->assertEquals(
323 0,
324 sizeof(self::$privateLinkDB->filterTags('mercurial', true))
325 );
326
327 $this->assertEquals(
328 1,
329 sizeof(self::$privateLinkDB->filterTags('Mercurial', true))
330 );
331 }
332
333 /**
334 * Filter links using a tag combination
335 */
336 public function testFilterMultipleTags()
337 {
338 $this->assertEquals(
339 1,
340 sizeof(self::$publicLinkDB->filterTags('dev cartoon', false))
341 );
342
343 $this->assertEquals(
344 2,
345 sizeof(self::$privateLinkDB->filterTags('dev cartoon', false))
346 );
347 }
348
349 /**
350 * Filter links using a non-existent tag
351 */
352 public function testFilterUnknownTag()
353 {
354 $this->assertEquals(
355 0,
356 sizeof(self::$publicLinkDB->filterTags('null', false))
357 );
358 }
359
360 /**
361 * Return links for a given day
362 */
363 public function testFilterDay()
364 {
365 $this->assertEquals(
366 2,
367 sizeof(self::$publicLinkDB->filterDay('20121206'))
368 );
369
370 $this->assertEquals(
371 3,
372 sizeof(self::$privateLinkDB->filterDay('20121206'))
373 );
374 }
375
376 /**
377 * 404 - day not found
378 */
379 public function testFilterUnknownDay()
380 {
381 $this->assertEquals(
382 0,
383 sizeof(self::$publicLinkDB->filterDay('19700101'))
384 );
385
386 $this->assertEquals(
387 0,
388 sizeof(self::$privateLinkDB->filterDay('19700101'))
389 );
390 }
391
392 /**
393 * Use an invalid date format
394 */
395 public function testFilterInvalidDay()
396 {
397 $this->assertEquals(
398 0,
399 sizeof(self::$privateLinkDB->filterDay('Rainy day, dream away'))
400 );
401
402 // TODO: check input format
403 $this->assertEquals(
404 6,
405 sizeof(self::$privateLinkDB->filterDay('20'))
406 );
407 }
408
409 /**
410 * Retrieve a link entry with its hash
411 */
412 public function testFilterSmallHash()
413 {
414 $links = self::$privateLinkDB->filterSmallHash('IuWvgA');
415
416 $this->assertEquals(
417 1,
418 sizeof($links)
419 );
420
421 $this->assertEquals(
422 'MediaGoblin',
423 $links['20130614_184135']['title']
424 );
425
426 }
427
428 /**
429 * No link for this hash
430 */
431 public function testFilterUnknownSmallHash()
432 {
433 $this->assertEquals(
434 0,
435 sizeof(self::$privateLinkDB->filterSmallHash('Iblaah'))
436 );
437 }
438
439 /**
440 * Full-text search - result from a link's URL
441 */
442 public function testFilterFullTextURL()
443 {
444 $this->assertEquals(
445 2,
446 sizeof(self::$publicLinkDB->filterFullText('ars.userfriendly.org'))
447 );
448 }
449
450 /**
451 * Full-text search - result from a link's title only
452 */
453 public function testFilterFullTextTitle()
454 {
455 // use miscellaneous cases
456 $this->assertEquals(
457 2,
458 sizeof(self::$publicLinkDB->filterFullText('userfriendly -'))
459 );
460 $this->assertEquals(
461 2,
462 sizeof(self::$publicLinkDB->filterFullText('UserFriendly -'))
463 );
464 $this->assertEquals(
465 2,
466 sizeof(self::$publicLinkDB->filterFullText('uSeRFrIendlY -'))
467 );
468
469 // use miscellaneous case and offset
470 $this->assertEquals(
471 2,
472 sizeof(self::$publicLinkDB->filterFullText('RFrIendL'))
473 );
474 }
475
476 /**
477 * Full-text search - result from the link's description only
478 */
479 public function testFilterFullTextDescription()
480 {
481 $this->assertEquals(
482 1,
483 sizeof(self::$publicLinkDB->filterFullText('media publishing'))
484 );
485 }
486
487 /**
488 * Full-text search - result from the link's tags only
489 */
490 public function testFilterFullTextTags()
491 {
492 $this->assertEquals(
493 2,
494 sizeof(self::$publicLinkDB->filterFullText('gnu'))
495 );
496 }
497
498 /**
499 * Full-text search - result set from mixed sources
500 */
501 public function testFilterFullTextMixed()
502 {
503 $this->assertEquals(
504 2,
505 sizeof(self::$publicLinkDB->filterFullText('free software'))
506 );
507 }
508}
509?>
diff --git a/tests/UtilsTest.php b/tests/UtilsTest.php
new file mode 100644
index 00000000..bbba99f2
--- /dev/null
+++ b/tests/UtilsTest.php
@@ -0,0 +1,78 @@
1<?php
2/**
3 * Utilities' tests
4 */
5
6require_once 'application/Utils.php';
7
8/**
9 * Unitary tests for Shaarli utilities
10 */
11class UtilsTest extends PHPUnit_Framework_TestCase
12{
13 /**
14 * Represent a link by its hash
15 */
16 public function testSmallHash()
17 {
18 $this->assertEquals('CyAAJw', smallHash('http://test.io'));
19 $this->assertEquals(6, strlen(smallHash('https://github.com')));
20 }
21
22 /**
23 * Look for a substring at the beginning of a string
24 */
25 public function testStartsWithCaseInsensitive()
26 {
27 $this->assertTrue(startsWith('Lorem ipsum', 'lorem', false));
28 $this->assertTrue(startsWith('Lorem ipsum', 'LoReM i', false));
29 }
30
31 /**
32 * Look for a substring at the beginning of a string (case-sensitive)
33 */
34 public function testStartsWithCaseSensitive()
35 {
36 $this->assertTrue(startsWith('Lorem ipsum', 'Lorem', true));
37 $this->assertFalse(startsWith('Lorem ipsum', 'lorem', true));
38 $this->assertFalse(startsWith('Lorem ipsum', 'LoReM i', true));
39 }
40
41 /**
42 * Look for a substring at the beginning of a string (Unicode)
43 */
44 public function testStartsWithSpecialChars()
45 {
46 $this->assertTrue(startsWith('å!ùµ', 'å!', false));
47 $this->assertTrue(startsWith('µ$åù', 'µ$', true));
48 }
49
50 /**
51 * Look for a substring at the end of a string
52 */
53 public function testEndsWithCaseInsensitive()
54 {
55 $this->assertTrue(endsWith('Lorem ipsum', 'ipsum', false));
56 $this->assertTrue(endsWith('Lorem ipsum', 'm IpsUM', false));
57 }
58
59 /**
60 * Look for a substring at the end of a string (case-sensitive)
61 */
62 public function testEndsWithCaseSensitive()
63 {
64 $this->assertTrue(endsWith('lorem Ipsum', 'Ipsum', true));
65 $this->assertFalse(endsWith('lorem Ipsum', 'ipsum', true));
66 $this->assertFalse(endsWith('lorem Ipsum', 'M IPsuM', true));
67 }
68
69 /**
70 * Look for a substring at the end of a string (Unicode)
71 */
72 public function testEndsWithSpecialChars()
73 {
74 $this->assertTrue(endsWith('å!ùµ', 'ùµ', false));
75 $this->assertTrue(endsWith('µ$åù', 'åù', true));
76 }
77}
78?>
diff --git a/tests/utils/ReferenceLinkDB.php b/tests/utils/ReferenceLinkDB.php
new file mode 100644
index 00000000..2cb05bae
--- /dev/null
+++ b/tests/utils/ReferenceLinkDB.php
@@ -0,0 +1,128 @@
1<?php
2/**
3 * Populates a reference datastore to test LinkDB
4 */
5class ReferenceLinkDB
6{
7 private $links = array();
8 private $publicCount = 0;
9 private $privateCount = 0;
10
11 /**
12 * Populates the test DB with reference data
13 */
14 function __construct()
15 {
16 $this->addLink(
17 'Free as in Freedom 2.0',
18 'https://static.fsf.org/nosvn/faif-2.0.pdf',
19 'Richard Stallman and the Free Software Revolution',
20 0,
21 '20150310_114633',
22 'free gnu software stallman'
23 );
24
25 $this->addLink(
26 'MediaGoblin',
27 'http://mediagoblin.org/',
28 'A free software media publishing platform',
29 0,
30 '20130614_184135',
31 'gnu media web'
32 );
33
34 $this->addLink(
35 'w3c-markup-validator',
36 'https://dvcs.w3.org/hg/markup-validator/summary',
37 'Mercurial repository for the W3C Validator',
38 1,
39 '20141125_084734',
40 'css html w3c web Mercurial'
41 );
42
43 $this->addLink(
44 'UserFriendly - Web Designer',
45 'http://ars.userfriendly.org/cartoons/?id=20121206',
46 'Naming conventions...',
47 0,
48 '20121206_142300',
49 'dev cartoon web'
50 );
51
52 $this->addLink(
53 'UserFriendly - Samba',
54 'http://ars.userfriendly.org/cartoons/?id=20010306',
55 'Tropical printing',
56 0,
57 '20121206_172539',
58 'samba cartoon web'
59 );
60
61 $this->addLink(
62 'Geek and Poke',
63 'http://geek-and-poke.com/',
64 '',
65 1,
66 '20121206_182539',
67 'dev cartoon'
68 );
69 }
70
71 /**
72 * Adds a new link
73 */
74 protected function addLink($title, $url, $description, $private, $date, $tags)
75 {
76 $link = array(
77 'title' => $title,
78 'url' => $url,
79 'description' => $description,
80 'private' => $private,
81 'linkdate' => $date,
82 'tags' => $tags,
83 );
84 $this->links[$date] = $link;
85
86 if ($private) {
87 $this->privateCount++;
88 return;
89 }
90 $this->publicCount++;
91 }
92
93 /**
94 * Writes data to the datastore
95 */
96 public function write($filename, $prefix, $suffix)
97 {
98 file_put_contents(
99 $filename,
100 $prefix.base64_encode(gzdeflate(serialize($this->links))).$suffix
101 );
102 }
103
104 /**
105 * Returns the number of links in the reference data
106 */
107 public function countLinks()
108 {
109 return $this->publicCount + $this->privateCount;
110 }
111
112 /**
113 * Returns the number of public links in the reference data
114 */
115 public function countPublicLinks()
116 {
117 return $this->publicCount;
118 }
119
120 /**
121 * Returns the number of private links in the reference data
122 */
123 public function countPrivateLinks()
124 {
125 return $this->privateCount;
126 }
127}
128?>
diff --git a/tpl/linklist.html b/tpl/linklist.html
index 766a80ce..47e67e71 100644
--- a/tpl/linklist.html
+++ b/tpl/linklist.html
@@ -1,12 +1,21 @@
1<!DOCTYPE html> 1<!DOCTYPE html>
2<html> 2<html>
3<head>{include="includes"}</head> 3<head>
4 <link type="text/css" rel="stylesheet" href="../inc/awesomplete.css" />
5 {include="includes"}
6</head>
4<body> 7<body>
5<div id="pageheader"> 8<div id="pageheader">
6 {include="page.header"} 9 {include="page.header"}
7 <div id="headerform" class="search"> 10 <div id="headerform" class="search">
8 <form method="GET" class="searchform" name="searchform"><input type="text" id="searchform_value" name="searchterm" placeholder="Search text" value=""> <input type="submit" value="Search" class="bigbutton"></form> 11 <form method="GET" class="searchform" name="searchform"><input type="text" id="searchform_value" name="searchterm" placeholder="Search text" value=""> <input type="submit" value="Search" class="bigbutton"></form>
9 <form method="GET" class="tagfilter" name="tagfilter"><input type="text" name="searchtags" id="tagfilter_value" placeholder="Filter by tag" value=""> <input type="submit" value="Search" class="bigbutton"></form> 12 <form method="GET" class="tagfilter" name="tagfilter">
13 <input type="text" name="searchtags" id="tagfilter_value" placeholder="Filter by tag" value="" list="tagsList" autocomplete="off" class="awesomplete" data-minChars="1">
14 <datalist id="tagsList">
15 {loop="$tags"}<option>{$key}</option>{/loop}
16 </datalist>
17 <input type="submit" value="Search" class="bigbutton">
18 </form>
10 </div> 19 </div>
11</div> 20</div>
12 21
@@ -129,5 +138,6 @@ function showQrCode(caller,loading)
129 return false; 138 return false;
130} 139}
131</script> 140</script>
141<script src="inc/awesomplete.min.js#"></script>
132</body> 142</body>
133</html> 143</html>
diff --git a/tpl/loginform.html b/tpl/loginform.html
index 954f6f1f..91b948dd 100644
--- a/tpl/loginform.html
+++ b/tpl/loginform.html
@@ -10,10 +10,12 @@
10 You have been banned from login after too many failed attempts. Try later. 10 You have been banned from login after too many failed attempts. Try later.
11{else} 11{else}
12 <form method="post" name="loginform"> 12 <form method="post" name="loginform">
13 Login: <input type="text" name="login" tabindex="1">&nbsp;&nbsp;&nbsp; 13 <label for="login">Login: <input type="text" id="login" name="login" tabindex="1"></label>
14 Password : <input type="password" name="password" tabindex="2"> 14 <label for="password">Password: <input type="password" id="password" name="password" tabindex="2"></label>
15 <input type="submit" value="Login" class="bigbutton" tabindex="4"><br> 15 <input type="submit" value="Login" class="bigbutton" tabindex="4">
16 <input type="checkbox" name="longlastingsession" id="longlastingsession" tabindex="3"><label for="longlastingsession">&nbsp;Stay signed in (Do not check on public computers)</label> 16 <label for="longlastingsession">
17 <input type="checkbox" name="longlastingsession" id="longlastingsession" tabindex="3">
18 Stay signed in (Do not check on public computers)</label>
17 <input type="hidden" name="token" value="{$token}"> 19 <input type="hidden" name="token" value="{$token}">
18 {if="$returnurl"}<input type="hidden" name="returnurl" value="{$returnurl|htmlspecialchars}">{/if} 20 {if="$returnurl"}<input type="hidden" name="returnurl" value="{$returnurl|htmlspecialchars}">{/if}
19 </form> 21 </form>