]> git.immae.eu Git - github/shaarli/Shaarli.git/commitdiff
Merge pull request #257 from ArthurHoaro/tag-http-referer
authorVirtualTam <tamisier.aurelien@gmail.com>
Sun, 12 Jul 2015 17:56:13 +0000 (19:56 +0200)
committerVirtualTam <tamisier.aurelien@gmail.com>
Sun, 12 Jul 2015 17:56:13 +0000 (19:56 +0200)
Prevent redirection loop everytime we rely on HTTP_REFERER

application/Config.php [new file with mode: 0755]
application/LinkDB.php
index.php
tests/ConfigTest.php [new file with mode: 0755]
tests/LinkDBTest.php
tests/utils/ReferenceLinkDB.php
tpl/dailyrss.html
tpl/editlink.html

diff --git a/application/Config.php b/application/Config.php
new file mode 100755 (executable)
index 0000000..0b01b52
--- /dev/null
@@ -0,0 +1,129 @@
+<?php\r
+/**\r
+ * Functions related to configuration management.\r
+ */\r
+\r
+/**\r
+ * Re-write configuration file according to given array.\r
+ * Requires mandatory fields listed in $MANDATORY_FIELDS.\r
+ *\r
+ * @param array $config     contains all configuration fields.\r
+ * @param bool  $isLoggedIn true if user is logged in.\r
+ *\r
+ * @return void\r
+ *\r
+ * @throws MissingFieldConfigException: a mandatory field has not been provided in $config.\r
+ * @throws UnauthorizedConfigException: user is not authorize to change configuration.\r
+ * @throws Exception: an error occured while writing the new config file.\r
+ */\r
+function writeConfig($config, $isLoggedIn)\r
+{\r
+    // These fields are required in configuration.\r
+    $MANDATORY_FIELDS = [\r
+        'login', 'hash', 'salt', 'timezone', 'title', 'titleLink',\r
+        'redirector', 'disablesessionprotection', 'privateLinkByDefault'\r
+    ];\r
+\r
+    if (!isset($config['config']['CONFIG_FILE'])) {\r
+        throw new MissingFieldConfigException('CONFIG_FILE');\r
+    }\r
+\r
+    // Only logged in user can alter config.\r
+    if (is_file($config['config']['CONFIG_FILE']) && !$isLoggedIn) {\r
+        throw new UnauthorizedConfigException();\r
+    }\r
+\r
+    // Check that all mandatory fields are provided in $config.\r
+    foreach ($MANDATORY_FIELDS as $field) {\r
+        if (!isset($config[$field])) {\r
+            throw new MissingFieldConfigException($field);\r
+        }\r
+    }\r
+\r
+    $configStr = '<?php '. PHP_EOL;\r
+    $configStr .= '$GLOBALS[\'login\'] = '.var_export($config['login'], true).';'. PHP_EOL;\r
+    $configStr .= '$GLOBALS[\'hash\'] = '.var_export($config['hash'], true).';'. PHP_EOL;\r
+    $configStr .= '$GLOBALS[\'salt\'] = '.var_export($config['salt'], true).'; '. PHP_EOL;\r
+    $configStr .= '$GLOBALS[\'timezone\'] = '.var_export($config['timezone'], true).';'. PHP_EOL;\r
+    $configStr .= 'date_default_timezone_set('.var_export($config['timezone'], true).');'. PHP_EOL;\r
+    $configStr .= '$GLOBALS[\'title\'] = '.var_export($config['title'], true).';'. PHP_EOL;\r
+    $configStr .= '$GLOBALS[\'titleLink\'] = '.var_export($config['titleLink'], true).'; '. PHP_EOL;\r
+    $configStr .= '$GLOBALS[\'redirector\'] = '.var_export($config['redirector'], true).'; '. PHP_EOL;\r
+    $configStr .= '$GLOBALS[\'disablesessionprotection\'] = '.var_export($config['disablesessionprotection'], true).'; '. PHP_EOL;\r
+    $configStr .= '$GLOBALS[\'privateLinkByDefault\'] = '.var_export($config['privateLinkByDefault'], true).'; '. PHP_EOL;\r
+\r
+    // Store all $config['config']\r
+    foreach ($config['config'] as $key => $value) {\r
+        $configStr .= '$GLOBALS[\'config\'][\''. $key .'\'] = '.var_export($config['config'][$key], true).';'. PHP_EOL;\r
+    }\r
+    $configStr .= '?>';\r
+\r
+    if (!file_put_contents($config['config']['CONFIG_FILE'], $configStr)\r
+        || strcmp(file_get_contents($config['config']['CONFIG_FILE']), $configStr) != 0\r
+    ) {\r
+        throw new Exception(\r
+            'Shaarli could not create the config file.\r
+            Please make sure Shaarli has the right to write in the folder is it installed in.'\r
+        );\r
+    }\r
+}\r
+\r
+/**\r
+ * Milestone 0.9 - shaarli/Shaarli#41: options.php is not supported anymore.\r
+ * ==> if user is loggedIn, merge its content with config.php, then delete options.php.\r
+ *\r
+ * @param array $config     contains all configuration fields.\r
+ * @param bool  $isLoggedIn true if user is logged in.\r
+ *\r
+ * @return void\r
+ */\r
+function mergeDeprecatedConfig($config, $isLoggedIn)\r
+{\r
+    $config_file = $config['config']['CONFIG_FILE'];\r
+\r
+    if (is_file($config['config']['DATADIR'].'/options.php') && $isLoggedIn) {\r
+        include $config['config']['DATADIR'].'/options.php';\r
+\r
+        // Load GLOBALS into config\r
+        foreach ($GLOBALS as $key => $value) {\r
+            $config[$key] = $value;\r
+        }\r
+        $config['config']['CONFIG_FILE'] = $config_file;\r
+        writeConfig($config, $isLoggedIn);\r
+\r
+        unlink($config['config']['DATADIR'].'/options.php');\r
+    }\r
+}\r
+\r
+/**\r
+ * Exception used if a mandatory field is missing in given configuration.\r
+ */\r
+class MissingFieldConfigException extends Exception\r
+{\r
+    public $field;\r
+\r
+    /**\r
+     * Construct exception.\r
+     *\r
+     * @param string $field field name missing.\r
+     */\r
+    public function __construct($field)\r
+    {\r
+        $this->field = $field;\r
+        $this->message = 'Configuration value is required for '. $this->field;\r
+    }\r
+}\r
+\r
+/**\r
+ * Exception used if an unauthorized attempt to edit configuration has been made.\r
+ */\r
+class UnauthorizedConfigException extends Exception\r
+{\r
+    /**\r
+     * Construct exception.\r
+     */\r
+    public function __construct()\r
+    {\r
+        $this->message = 'You are not authorized to alter config.';\r
+    }\r
+}
\ No newline at end of file
index 827636189aa2aa85d111a2e6ffd793538d93acea..1e16fef179fefa843ef7f10eb62d3471e593928c 100644 (file)
@@ -28,7 +28,7 @@
 class LinkDB implements Iterator, Countable, ArrayAccess
 {
     // Links are stored as a PHP serialized string
-    private $datastore;
+    private $_datastore;
 
     // Datastore PHP prefix
     protected static $phpPrefix = '<?php /* ';
@@ -39,23 +39,23 @@ class LinkDB implements Iterator, Countable, ArrayAccess
     // List of links (associative array)
     //  - key:   link date (e.g. "20110823_124546"),
     //  - value: associative array (keys: title, description...)
-    private $links;
+    private $_links;
 
     // List of all recorded URLs (key=url, value=linkdate)
     // for fast reserve search (url-->linkdate)
-    private $urls;
+    private $_urls;
 
     // List of linkdate keys (for the Iterator interface implementation)
-    private $keys;
+    private $_keys;
 
-    // Position in the $this->keys array (for the Iterator interface)
-    private $position;
+    // Position in the $this->_keys array (for the Iterator interface)
+    private $_position;
 
     // Is the user logged in? (used to filter private links)
-    private $loggedIn;
+    private $_loggedIn;
 
     // Hide public links
-    private $hidePublicLinks;
+    private $_hidePublicLinks;
 
     /**
      * Creates a new LinkDB
@@ -66,11 +66,11 @@ class LinkDB implements Iterator, Countable, ArrayAccess
      */
     function __construct($datastore, $isLoggedIn, $hidePublicLinks)
     {
-        $this->datastore = $datastore;
-        $this->loggedIn = $isLoggedIn;
-        $this->hidePublicLinks = $hidePublicLinks;
-        $this->checkDB();
-        $this->readdb();
+        $this->_datastore = $datastore;
+        $this->_loggedIn = $isLoggedIn;
+        $this->_hidePublicLinks = $hidePublicLinks;
+        $this->_checkDB();
+        $this->_readDB();
     }
 
     /**
@@ -78,7 +78,7 @@ class LinkDB implements Iterator, Countable, ArrayAccess
      */
     public function count()
     {
-        return count($this->links);
+        return count($this->_links);
     }
 
     /**
@@ -87,7 +87,7 @@ class LinkDB implements Iterator, Countable, ArrayAccess
     public function offsetSet($offset, $value)
     {
         // TODO: use exceptions instead of "die"
-        if (!$this->loggedIn) {
+        if (!$this->_loggedIn) {
             die('You are not authorized to add a link.');
         }
         if (empty($value['linkdate']) || empty($value['url'])) {
@@ -96,8 +96,8 @@ class LinkDB implements Iterator, Countable, ArrayAccess
         if (empty($offset)) {
             die('You must specify a key.');
         }
-        $this->links[$offset] = $value;
-        $this->urls[$value['url']]=$offset;
+        $this->_links[$offset] = $value;
+        $this->_urls[$value['url']]=$offset;
     }
 
     /**
@@ -105,7 +105,7 @@ class LinkDB implements Iterator, Countable, ArrayAccess
      */
     public function offsetExists($offset)
     {
-        return array_key_exists($offset, $this->links);
+        return array_key_exists($offset, $this->_links);
     }
 
     /**
@@ -113,13 +113,13 @@ class LinkDB implements Iterator, Countable, ArrayAccess
      */
     public function offsetUnset($offset)
     {
-        if (!$this->loggedIn) {
+        if (!$this->_loggedIn) {
             // TODO: raise an exception
             die('You are not authorized to delete a link.');
         }
-        $url = $this->links[$offset]['url'];
-        unset($this->urls[$url]);
-        unset($this->links[$offset]);
+        $url = $this->_links[$offset]['url'];
+        unset($this->_urls[$url]);
+        unset($this->_links[$offset]);
     }
 
     /**
@@ -127,7 +127,7 @@ class LinkDB implements Iterator, Countable, ArrayAccess
      */
     public function offsetGet($offset)
     {
-        return isset($this->links[$offset]) ? $this->links[$offset] : null;
+        return isset($this->_links[$offset]) ? $this->_links[$offset] : null;
     }
 
     /**
@@ -135,7 +135,7 @@ class LinkDB implements Iterator, Countable, ArrayAccess
      */
     function current()
     {
-        return $this->links[$this->keys[$this->position]];
+        return $this->_links[$this->_keys[$this->_position]];
     }
 
     /**
@@ -143,7 +143,7 @@ class LinkDB implements Iterator, Countable, ArrayAccess
      */
     function key()
     {
-        return $this->keys[$this->position];
+        return $this->_keys[$this->_position];
     }
 
     /**
@@ -151,7 +151,7 @@ class LinkDB implements Iterator, Countable, ArrayAccess
      */
     function next()
     {
-        ++$this->position;
+        ++$this->_position;
     }
 
     /**
@@ -161,9 +161,9 @@ class LinkDB implements Iterator, Countable, ArrayAccess
      */
     function rewind()
     {
-        $this->keys = array_keys($this->links);
-        rsort($this->keys);
-        $this->position = 0;
+        $this->_keys = array_keys($this->_links);
+        rsort($this->_keys);
+        $this->_position = 0;
     }
 
     /**
@@ -171,7 +171,7 @@ class LinkDB implements Iterator, Countable, ArrayAccess
      */
     function valid()
     {
-        return isset($this->keys[$this->position]);
+        return isset($this->_keys[$this->_position]);
     }
 
     /**
@@ -179,14 +179,14 @@ class LinkDB implements Iterator, Countable, ArrayAccess
      *
      * If no DB file is found, creates a dummy DB.
      */
-    private function checkDB()
+    private function _checkDB()
     {
-        if (file_exists($this->datastore)) {
+        if (file_exists($this->_datastore)) {
             return;
         }
 
         // Create a dummy database for example
-        $this->links = array();
+        $this->_links = array();
         $link = array(
             'title'=>' Shaarli: the personal, minimalist, super-fast, no-database delicious clone',
             'url'=>'https://github.com/shaarli/Shaarli/wiki',
@@ -199,7 +199,7 @@ You use the community supported version of the original Shaarli project, by Seba
             'linkdate'=> date('Ymd_His'),
             'tags'=>'opensource software'
         );
-        $this->links[$link['linkdate']] = $link;
+        $this->_links[$link['linkdate']] = $link;
 
         $link = array(
             'title'=>'My secret stuff... - Pastebin.com',
@@ -209,60 +209,60 @@ You use the community supported version of the original Shaarli project, by Seba
             'linkdate'=> date('Ymd_His', strtotime('-1 minute')),
             'tags'=>'secretstuff'
         );
-        $this->links[$link['linkdate']] = $link;
+        $this->_links[$link['linkdate']] = $link;
 
         // Write database to disk
         // TODO: raise an exception if the file is not write-able
         file_put_contents(
-            $this->datastore,
-            self::$phpPrefix.base64_encode(gzdeflate(serialize($this->links))).self::$phpSuffix
+            $this->_datastore,
+            self::$phpPrefix.base64_encode(gzdeflate(serialize($this->_links))).self::$phpSuffix
         );
     }
 
     /**
      * Reads database from disk to memory
      */
-    private function readdb()
+    private function _readDB()
     {
 
         // Public links are hidden and user not logged in => nothing to show
-        if ($this->hidePublicLinks && !$this->loggedIn) {
-            $this->links = array();
+        if ($this->_hidePublicLinks && !$this->_loggedIn) {
+            $this->_links = array();
             return;
         }
 
         // Read data
         // Note that gzinflate is faster than gzuncompress.
         // See: http://www.php.net/manual/en/function.gzdeflate.php#96439
-        $this->links = array();
+        $this->_links = array();
 
-        if (file_exists($this->datastore)) {
-            $this->links = unserialize(gzinflate(base64_decode(
-                substr(file_get_contents($this->datastore),
+        if (file_exists($this->_datastore)) {
+            $this->_links = unserialize(gzinflate(base64_decode(
+                substr(file_get_contents($this->_datastore),
                        strlen(self::$phpPrefix), -strlen(self::$phpSuffix)))));
         }
 
         // If user is not logged in, filter private links.
-        if (!$this->loggedIn) {
+        if (!$this->_loggedIn) {
             $toremove = array();
-            foreach ($this->links as $link) {
+            foreach ($this->_links as $link) {
                 if ($link['private'] != 0) {
                     $toremove[] = $link['linkdate'];
                 }
             }
             foreach ($toremove as $linkdate) {
-                unset($this->links[$linkdate]);
+                unset($this->_links[$linkdate]);
             }
         }
 
         // Keep the list of the mapping URLs-->linkdate up-to-date.
-        $this->urls = array();
-        foreach ($this->links as $link) {
-            $this->urls[$link['url']] = $link['linkdate'];
+        $this->_urls = array();
+        foreach ($this->_links as $link) {
+            $this->_urls[$link['url']] = $link['linkdate'];
         }
 
         // Escape links data
-        foreach($this->links as &$link) { 
+        foreach($this->_links as &$link) { 
             sanitizeLink($link); 
         }
     }
@@ -272,13 +272,13 @@ You use the community supported version of the original Shaarli project, by Seba
      */
     public function savedb()
     {
-        if (!$this->loggedIn) {
+        if (!$this->_loggedIn) {
             // TODO: raise an Exception instead
             die('You are not authorized to change the database.');
         }
         file_put_contents(
-            $this->datastore,
-            self::$phpPrefix.base64_encode(gzdeflate(serialize($this->links))).self::$phpSuffix
+            $this->_datastore,
+            self::$phpPrefix.base64_encode(gzdeflate(serialize($this->_links))).self::$phpSuffix
         );
         invalidateCaches();
     }
@@ -288,8 +288,8 @@ You use the community supported version of the original Shaarli project, by Seba
      */
     public function getLinkFromUrl($url)
     {
-        if (isset($this->urls[$url])) {
-            return $this->links[$this->urls[$url]];
+        if (isset($this->_urls[$url])) {
+            return $this->_links[$this->_urls[$url]];
         }
         return false;
     }
@@ -316,7 +316,7 @@ You use the community supported version of the original Shaarli project, by Seba
         $search = mb_convert_case($searchterms, MB_CASE_LOWER, 'UTF-8');
         $keys = array('title', 'description', 'url', 'tags');
 
-        foreach ($this->links as $link) {
+        foreach ($this->_links as $link) {
             $found = false;
 
             foreach ($keys as $key) {
@@ -352,7 +352,7 @@ You use the community supported version of the original Shaarli project, by Seba
         $searchtags = explode(' ', $t);
         $filtered = array();
 
-        foreach ($this->links as $l) {
+        foreach ($this->_links as $l) {
             $linktags = explode(
                 ' ',
                 ($casesensitive ? $l['tags']:mb_convert_case($l['tags'], MB_CASE_LOWER, 'UTF-8'))
@@ -380,7 +380,7 @@ You use the community supported version of the original Shaarli project, by Seba
         }
 
         $filtered = array();
-        foreach ($this->links as $l) {
+        foreach ($this->_links as $l) {
             if (startsWith($l['linkdate'], $day)) {
                 $filtered[$l['linkdate']] = $l;
             }
@@ -395,7 +395,7 @@ You use the community supported version of the original Shaarli project, by Seba
     public function filterSmallHash($smallHash)
     {
         $filtered = array();
-        foreach ($this->links as $l) {
+        foreach ($this->_links as $l) {
             if ($smallHash == smallHash($l['linkdate'])) {
                 // Yes, this is ugly and slow
                 $filtered[$l['linkdate']] = $l;
@@ -412,7 +412,7 @@ You use the community supported version of the original Shaarli project, by Seba
     public function allTags()
     {
         $tags = array();
-        foreach ($this->links as $link) {
+        foreach ($this->_links as $link) {
             foreach (explode(' ', $link['tags']) as $tag) {
                 if (!empty($tag)) {
                     $tags[$tag] = (empty($tags[$tag]) ? 1 : $tags[$tag] + 1);
@@ -431,7 +431,7 @@ You use the community supported version of the original Shaarli project, by Seba
     public function days()
     {
         $linkDays = array();
-        foreach (array_keys($this->links) as $day) {
+        foreach (array_keys($this->_links) as $day) {
             $linkDays[substr($day, 0, 8)] = 0;
         }
         $linkDays = array_keys($linkDays);
index 8e1552c1b6c388e24d73aaffb7266d7416fb01c0..bf0b99e043b7492f8402cd2c62aa8b5b29b13f66 100644 (file)
--- a/index.php
+++ b/index.php
@@ -11,7 +11,8 @@
 date_default_timezone_set('UTC');
 
 // -----------------------------------------------------------------------------------------------
-// Hardcoded parameter (These parameters can be overwritten by creating the file /data/options.php)
+// Hardcoded parameter (These parameters can be overwritten by editing the file /data/config.php)
+// You should not touch any code below (or at your own risks!)
 $GLOBALS['config']['DATADIR'] = 'data'; // Data subdirectory
 $GLOBALS['config']['CONFIG_FILE'] = $GLOBALS['config']['DATADIR'].'/config.php'; // Configuration file (user login/password)
 $GLOBALS['config']['DATASTORE'] = $GLOBALS['config']['DATADIR'].'/datastore.php'; // Data storage file.
@@ -36,10 +37,6 @@ $GLOBALS['config']['ARCHIVE_ORG'] = false; // For each link, add a link to an ar
 $GLOBALS['config']['ENABLE_RSS_PERMALINKS'] = true;  // Enable RSS permalinks by default. This corresponds to the default behavior of shaarli before this was added as an option.
 $GLOBALS['config']['HIDE_PUBLIC_LINKS'] = false;
 // -----------------------------------------------------------------------------------------------
-// You should not touch below (or at your own risks!)
-// Optional config file.
-if (is_file($GLOBALS['config']['DATADIR'].'/options.php')) require($GLOBALS['config']['DATADIR'].'/options.php');
-
 define('shaarli_version','0.0.45beta');
 // http://server.com/x/shaarli --> /shaarli/
 define('WEB_PATH', substr($_SERVER["REQUEST_URI"], 0, 1+strrpos($_SERVER["REQUEST_URI"], '/', 0)));
@@ -66,9 +63,15 @@ checkphpversion();
 error_reporting(E_ALL^E_WARNING);  // See all error except warnings.
 //error_reporting(-1); // See all errors (for debugging only)
 
+// User configuration
+if (is_file($GLOBALS['config']['CONFIG_FILE'])) {
+    require_once $GLOBALS['config']['CONFIG_FILE'];
+}
+
 // Shaarli library
 require_once 'application/LinkDB.php';
 require_once 'application/Utils.php';
+require_once 'application/Config.php';
 
 include "inc/rain.tpl.class.php"; //include Rain TPL
 raintpl::$tpl_dir = $GLOBALS['config']['RAINTPL_TPL']; // template directory
@@ -100,15 +103,15 @@ if (empty($GLOBALS['title'])) $GLOBALS['title']='Shared links on '.escape(indexU
 if (empty($GLOBALS['timezone'])) $GLOBALS['timezone']=date_default_timezone_get();
 if (empty($GLOBALS['redirector'])) $GLOBALS['redirector']='';
 if (empty($GLOBALS['disablesessionprotection'])) $GLOBALS['disablesessionprotection']=false;
-if (empty($GLOBALS['disablejquery'])) $GLOBALS['disablejquery']=false;
 if (empty($GLOBALS['privateLinkByDefault'])) $GLOBALS['privateLinkByDefault']=false;
 if (empty($GLOBALS['titleLink'])) $GLOBALS['titleLink']='?';
 // I really need to rewrite Shaarli with a proper configuation manager.
 
 // Run config screen if first run:
-if (!is_file($GLOBALS['config']['CONFIG_FILE'])) install();
+if (! is_file($GLOBALS['config']['CONFIG_FILE'])) {
+    install();
+}
 
-require $GLOBALS['config']['CONFIG_FILE'];  // Read login/password hash into $GLOBALS.
 $GLOBALS['title'] = !empty($GLOBALS['title']) ? escape($GLOBALS['title']) : '';
 $GLOBALS['titleLink'] = !empty($GLOBALS['titleLink']) ? escape($GLOBALS['titleLink']) : '';
 $GLOBALS['redirector'] = !empty($GLOBALS['redirector']) ? escape($GLOBALS['redirector']) : '';
@@ -856,15 +859,18 @@ function showATOM()
 // Daily RSS feed: 1 RSS entry per day giving all the links on that day.
 // Gives the last 7 days (which have links).
 // This RSS feed cannot be filtered.
-function showDailyRSS()
-{
+function showDailyRSS() {
     // Cache system
     $query = $_SERVER["QUERY_STRING"];
-    $cache = new pageCache(pageUrl(),startsWith($query,'do=dailyrss') && !isLoggedIn());
-    $cached = $cache->cachedVersion(); if (!empty($cached)) { echo $cached; exit; }
-    // If cached was not found (or not usable), then read the database and build the response:
+    $cache = new pageCache(pageUrl(), startsWith($query, 'do=dailyrss') && !isLoggedIn());
+    $cached = $cache->cachedVersion();
+    if (!empty($cached)) {
+        echo $cached;
+        exit;
+    }
 
-// Read links from database (and filter private links if used it not logged in).
+    // If cached was not found (or not usable), then read the database and build the response:
+    // Read links from database (and filter private links if used it not logged in).
     $LINKSDB = new LinkDB(
         $GLOBALS['config']['DATASTORE'],
         isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI'],
@@ -874,60 +880,75 @@ function showDailyRSS()
     /* Some Shaarlies may have very few links, so we need to look
        back in time (rsort()) until we have enough days ($nb_of_days).
     */
-    $linkdates=array(); foreach($LINKSDB as $linkdate=>$value) { $linkdates[]=$linkdate; }
+    $linkdates = array();
+    foreach ($LINKSDB as $linkdate => $value) {
+        $linkdates[] = $linkdate;
+    }
     rsort($linkdates);
-    $nb_of_days=7; // We take 7 days.
-    $today=Date('Ymd');
-    $days=array();
-    foreach($linkdates as $linkdate)
-    {
-        $day=substr($linkdate,0,8); // Extract day (without time)
-        if (strcmp($day,$today)<0)
-        {
-            if (empty($days[$day])) $days[$day]=array();
-            $days[$day][]=$linkdate;
+    $nb_of_days = 7; // We take 7 days.
+    $today = Date('Ymd');
+    $days = array();
+
+    foreach ($linkdates as $linkdate) {
+        $day = substr($linkdate, 0, 8); // Extract day (without time)
+        if (strcmp($day,$today) < 0) {
+            if (empty($days[$day])) {
+                $days[$day] = array();
+            }
+            $days[$day][] = $linkdate;
+        }
+
+        if (count($days) > $nb_of_days) {
+            break; // Have we collected enough days?
         }
-        if (count($days)>$nb_of_days) break; // Have we collected enough days?
     }
 
     // Build the RSS feed.
     header('Content-Type: application/rss+xml; charset=utf-8');
-    $pageaddr=escape(indexUrl());
+    $pageaddr = escape(indexUrl());
     echo '<?xml version="1.0" encoding="UTF-8"?><rss version="2.0">';
-    echo '<channel><title>Daily - '.$GLOBALS['title'].'</title><link>'.$pageaddr.'</link>';
-    echo '<description>Daily shared links</description><language>en-en</language><copyright>'.$pageaddr.'</copyright>'."\n";
-
-    foreach($days as $day=>$linkdates) // For each day.
-    {
-        $daydate = utf8_encode(strftime('%A %d, %B %Y',linkdate2timestamp($day.'_000000'))); // Full text date
+    echo '<channel>';
+    echo '<title>Daily - '. $GLOBALS['title'] . '</title>';
+    echo '<link>'. $pageaddr .'</link>';
+    echo '<description>Daily shared links</description>';
+    echo '<language>en-en</language>';
+    echo '<copyright>'. $pageaddr .'</copyright>'. PHP_EOL;
+
+    // For each day.
+    foreach ($days as $day => $linkdates) {
+        $daydate = linkdate2timestamp($day.'_000000'); // Full text date
         $rfc822date = linkdate2rfc822($day.'_000000');
-        $absurl=escape(indexUrl().'?do=daily&day='.$day);  // Absolute URL of the corresponding "Daily" page.
-        echo '<item><title>'.$GLOBALS['title'].' - '.$daydate.'</title><guid>'.$absurl.'</guid><link>'.$absurl.'</link>';
-        echo '<pubDate>'.escape($rfc822date)."</pubDate>";
+        $absurl = escape(indexUrl().'?do=daily&day='.$day);  // Absolute URL of the corresponding "Daily" page.
 
         // Build the HTML body of this RSS entry.
-        $html='';
-        $href='';
-        $links=array();
+        $html = '';
+        $href = '';
+        $links = array();
+
         // We pre-format some fields for proper output.
-        foreach($linkdates as $linkdate)
-        {
+        foreach ($linkdates as $linkdate) {
             $l = $LINKSDB[$linkdate];
-            $l['formatedDescription']=nl2br(keepMultipleSpaces(text2clickable($l['description'])));
+            $l['formatedDescription'] = nl2br(keepMultipleSpaces(text2clickable($l['description'])));
             $l['thumbnail'] = thumbnail($l['url']);
             $l['timestamp'] = linkdate2timestamp($l['linkdate']);
-            if (startsWith($l['url'],'?')) $l['url']=indexUrl().$l['url'];  // make permalink URL absolute
-            $links[$linkdate]=$l;
+            if (startsWith($l['url'], '?')) {
+                $l['url'] = indexUrl() . $l['url'];  // make permalink URL absolute
+            }
+            $links[$linkdate] = $l;
         }
+
         // Then build the HTML for this day:
         $tpl = new RainTPL;
-        $tpl->assign('links',$links);
-        $html = $tpl->draw('dailyrss',$return_string=true);
-        echo "\n";
-        echo '<description><![CDATA['.$html.']]></description>'."\n</item>\n\n";
+        $tpl->assign('title', $GLOBALS['title']);
+        $tpl->assign('daydate', $daydate);
+        $tpl->assign('absurl', $absurl);
+        $tpl->assign('links', $links);
+        $tpl->assign('rfc822date', escape($rfc822date));
+        $html = $tpl->draw('dailyrss', $return_string=true);
 
+        echo $html . PHP_EOL;
     }
-    echo '</channel></rss><!-- Cached version of '.escape(pageUrl()).' -->';
+    echo '</channel></rss><!-- Cached version of '. escape(pageUrl()) .' -->';
 
     $cache->cache(ob_get_contents());
     ob_end_flush();
@@ -1106,7 +1127,11 @@ function renderPage()
 
         // Check if this tag is already in the search query and ignore it if it is.
         // Each tag is always separated by a space
-        $current_tags = explode(' ', $params['searchtags']);
+        if (isset($params['searchtags'])) {
+            $current_tags = explode(' ', $params['searchtags']);
+        } else {
+            $current_tags = array();
+        }
         $addtag = true;
         foreach ($current_tags as $value) {
             if ($value === $_GET['addtag']) {
@@ -1229,7 +1254,19 @@ function renderPage()
             // Save new password
             $GLOBALS['salt'] = sha1(uniqid('',true).'_'.mt_rand()); // Salt renders rainbow-tables attacks useless.
             $GLOBALS['hash'] = sha1($_POST['setpassword'].$GLOBALS['login'].$GLOBALS['salt']);
-            writeConfig();
+            try {
+                writeConfig($GLOBALS, isLoggedIn());
+            }
+            catch(Exception $e) {
+                error_log(
+                    'ERROR while writing config file after changing password.' . PHP_EOL .
+                    $e->getMessage()
+                );
+
+                // TODO: do not handle exceptions/errors in JS.
+                echo '<script>alert("'. $e->getMessage() .'");document.location=\'?do=tools\';</script>';
+                exit;
+            }
             echo '<script>alert("Your password has been changed.");document.location=\'?do=tools\';</script>';
             exit;
         }
@@ -1258,12 +1295,23 @@ function renderPage()
             $GLOBALS['titleLink']=$_POST['titleLink'];
             $GLOBALS['redirector']=$_POST['redirector'];
             $GLOBALS['disablesessionprotection']=!empty($_POST['disablesessionprotection']);
-            $GLOBALS['disablejquery']=!empty($_POST['disablejquery']);
             $GLOBALS['privateLinkByDefault']=!empty($_POST['privateLinkByDefault']);
             $GLOBALS['config']['ENABLE_RSS_PERMALINKS']= !empty($_POST['enableRssPermalinks']);
             $GLOBALS['config']['ENABLE_UPDATECHECK'] = !empty($_POST['updateCheck']);
             $GLOBALS['config']['HIDE_PUBLIC_LINKS'] = !empty($_POST['hidePublicLinks']);
-            writeConfig();
+            try {
+                writeConfig($GLOBALS, isLoggedIn());
+            }
+            catch(Exception $e) {
+                error_log(
+                    'ERROR while writing config file after configuration update.' . PHP_EOL .
+                    $e->getMessage()
+                );
+
+                // TODO: do not handle exceptions/errors in JS.
+                echo '<script>alert("'. $e->getMessage() .'");document.location=\'?do=tools\';</script>';
+                exit;
+            }
             echo '<script>alert("Configuration was saved.");document.location=\'?do=tools\';</script>';
             exit;
         }
@@ -1345,6 +1393,7 @@ function renderPage()
     {
         if (!tokenOk($_POST['token'])) die('Wrong token.'); // Go away!
         $tags = trim(preg_replace('/\s\s+/',' ', $_POST['lf_tags'])); // Remove multiple spaces.
+        $tags = implode(' ', array_unique(explode(' ', $tags))); // Remove duplicates.
         $linkdate=$_POST['lf_linkdate'];
         $url = trim($_POST['lf_url']);
         if (!startsWith($url,'http:') && !startsWith($url,'https:') && !startsWith($url,'ftp:') && !startsWith($url,'magnet:') && !startsWith($url,'?') && !startsWith($url,'javascript:'))
@@ -1714,7 +1763,7 @@ function buildLinkList($PAGE,$LINKSDB)
         {
             header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found");
             echo '<h1>404 Not found.</h1>Oh crap. The link you are trying to reach does not exist or has been deleted.';
-            echo '<br>You would mind <a href="?">clicking here</a>?';
+            echo '<br>Would you mind <a href="?">clicking here</a>?';
             exit;
         }
         $search_type='permalink';
@@ -2020,7 +2069,19 @@ function install()
         $GLOBALS['hash'] = sha1($_POST['setpassword'].$GLOBALS['login'].$GLOBALS['salt']);
         $GLOBALS['title'] = (empty($_POST['title']) ? 'Shared links on '.escape(indexUrl()) : $_POST['title'] );
         $GLOBALS['config']['ENABLE_UPDATECHECK'] = !empty($_POST['updateCheck']);
-        writeConfig();
+        try {
+            writeConfig($GLOBALS, isLoggedIn());
+        }
+        catch(Exception $e) {
+            error_log(
+                    'ERROR while writing config file after installation.' . PHP_EOL .
+                    $e->getMessage()
+                );
+
+            // TODO: do not handle exceptions/errors in JS.
+            echo '<script>alert("'. $e->getMessage() .'");document.location=\'?\';</script>';
+            exit;
+        }
         echo '<script>alert("Shaarli is now configured. Please enter your login/password and start shaaring your links!");document.location=\'?do=login\';</script>';
         exit;
     }
@@ -2134,30 +2195,7 @@ if (!function_exists('json_encode')) {
     }
 }
 
-// Re-write configuration file according to globals.
-// Requires some $GLOBALS to be set (login,hash,salt,title).
-// If the config file cannot be saved, an error message is displayed and the user is redirected to "Tools" menu.
-// (otherwise, the function simply returns.)
-function writeConfig()
-{
-    if (is_file($GLOBALS['config']['CONFIG_FILE']) && !isLoggedIn()) die('You are not authorized to alter config.'); // Only logged in user can alter config.
-    $config='<?php $GLOBALS[\'login\']='.var_export($GLOBALS['login'],true).'; $GLOBALS[\'hash\']='.var_export($GLOBALS['hash'],true).'; $GLOBALS[\'salt\']='.var_export($GLOBALS['salt'],true).'; ';
-    $config .='$GLOBALS[\'timezone\']='.var_export($GLOBALS['timezone'],true).'; date_default_timezone_set('.var_export($GLOBALS['timezone'],true).'); $GLOBALS[\'title\']='.var_export($GLOBALS['title'],true).';';
-    $config .= '$GLOBALS[\'titleLink\']='.var_export($GLOBALS['titleLink'],true).'; ';
-    $config .= '$GLOBALS[\'redirector\']='.var_export($GLOBALS['redirector'],true).'; ';
-    $config .= '$GLOBALS[\'disablesessionprotection\']='.var_export($GLOBALS['disablesessionprotection'],true).'; ';
-    $config .= '$GLOBALS[\'disablejquery\']='.var_export($GLOBALS['disablejquery'],true).'; ';
-    $config .= '$GLOBALS[\'privateLinkByDefault\']='.var_export($GLOBALS['privateLinkByDefault'],true).'; ';
-    $config .= '$GLOBALS[\'config\'][\'ENABLE_RSS_PERMALINKS\']='.var_export($GLOBALS['config']['ENABLE_RSS_PERMALINKS'], true).'; ';
-    $config .= '$GLOBALS[\'config\'][\'ENABLE_UPDATECHECK\']='.var_export($GLOBALS['config']['ENABLE_UPDATECHECK'], true).'; ';
-    $config .= '$GLOBALS[\'config\'][\'HIDE_PUBLIC_LINKS\']='.var_export($GLOBALS['config']['HIDE_PUBLIC_LINKS'], true).'; ';
-    $config .= ' ?>';
-    if (!file_put_contents($GLOBALS['config']['CONFIG_FILE'],$config) || strcmp(file_get_contents($GLOBALS['config']['CONFIG_FILE']),$config)!=0)
-    {
-        echo '<script>alert("Shaarli could not create the config file. Please make sure Shaarli has the right to write in the folder is it installed in.");document.location=\'?\';</script>';
-        exit;
-    }
-}
+
 
 /* Because some f*cking services like flickr require an extra HTTP request to get the thumbnail URL,
    I have deported the thumbnail URL code generation here, otherwise this would slow down page generation.
@@ -2386,6 +2424,15 @@ function invalidateCaches()
     pageCache::purgeCache();   // Purge page cache shared by sessions.
 }
 
+try {
+    mergeDeprecatedConfig($GLOBALS, isLoggedIn());
+} catch(Exception $e) {
+    error_log(
+        'ERROR while merging deprecated options.php file.' . PHP_EOL .
+        $e->getMessage()
+    );
+}
+
 if (isset($_SERVER["QUERY_STRING"]) && startswith($_SERVER["QUERY_STRING"],'do=genthumbnail')) { genThumbnail(); exit; }  // Thumbnail generation/cache does not need the link database.
 if (isset($_SERVER["QUERY_STRING"]) && startswith($_SERVER["QUERY_STRING"],'do=rss')) { showRSS(); exit; }
 if (isset($_SERVER["QUERY_STRING"]) && startswith($_SERVER["QUERY_STRING"],'do=atom')) { showATOM(); exit; }
diff --git a/tests/ConfigTest.php b/tests/ConfigTest.php
new file mode 100755 (executable)
index 0000000..4279c57
--- /dev/null
@@ -0,0 +1,177 @@
+<?php\r
+/**\r
+ * Config' tests\r
+ */\r
+\r
+require_once 'application/Config.php';\r
+\r
+/**\r
+ * Unitary tests for Shaarli config related functions\r
+ */\r
+class ConfigTest extends PHPUnit_Framework_TestCase\r
+{\r
+    // Configuration input set.\r
+    private static $_configFields;\r
+\r
+    /**\r
+     * Executed before each test.\r
+     */\r
+    public function setUp()\r
+    {\r
+        self::$_configFields = [\r
+            'login' => 'login',\r
+            'hash' => 'hash',\r
+            'salt' => 'salt',\r
+            'timezone' => 'Europe/Paris',\r
+            'title' => 'title',\r
+            'titleLink' => 'titleLink',\r
+            'redirector' => '',\r
+            'disablesessionprotection' => false,\r
+            'privateLinkByDefault' => false,\r
+            'config' => [\r
+                'CONFIG_FILE' => 'tests/config.php',\r
+                'DATADIR' => 'tests',\r
+                'config1' => 'config1data',\r
+                'config2' => 'config2data',\r
+            ]\r
+        ];\r
+    }\r
+\r
+    /**\r
+     * Executed after each test.\r
+     *\r
+     * @return void\r
+     */\r
+    public function tearDown()\r
+    {\r
+        if (is_file(self::$_configFields['config']['CONFIG_FILE'])) {\r
+            unlink(self::$_configFields['config']['CONFIG_FILE']);\r
+        }\r
+    }\r
+\r
+    /**\r
+     * Test writeConfig function, valid use case, while being logged in.\r
+     */\r
+    public function testWriteConfig()\r
+    {\r
+        writeConfig(self::$_configFields, true);\r
+\r
+        include self::$_configFields['config']['CONFIG_FILE'];\r
+        $this->assertEquals(self::$_configFields['login'], $GLOBALS['login']);\r
+        $this->assertEquals(self::$_configFields['hash'], $GLOBALS['hash']);\r
+        $this->assertEquals(self::$_configFields['salt'], $GLOBALS['salt']);\r
+        $this->assertEquals(self::$_configFields['timezone'], $GLOBALS['timezone']);\r
+        $this->assertEquals(self::$_configFields['title'], $GLOBALS['title']);\r
+        $this->assertEquals(self::$_configFields['titleLink'], $GLOBALS['titleLink']);\r
+        $this->assertEquals(self::$_configFields['redirector'], $GLOBALS['redirector']);\r
+        $this->assertEquals(self::$_configFields['disablesessionprotection'], $GLOBALS['disablesessionprotection']);\r
+        $this->assertEquals(self::$_configFields['privateLinkByDefault'], $GLOBALS['privateLinkByDefault']);\r
+        $this->assertEquals(self::$_configFields['config']['config1'], $GLOBALS['config']['config1']);\r
+        $this->assertEquals(self::$_configFields['config']['config2'], $GLOBALS['config']['config2']);\r
+    }\r
+\r
+    /**\r
+     * Test writeConfig option while logged in:\r
+     *      1. init fields.\r
+     *      2. update fields, add new sub config, add new root config.\r
+     *      3. rewrite config.\r
+     *      4. check result.\r
+     */\r
+    public function testWriteConfigFieldUpdate()\r
+    {\r
+        writeConfig(self::$_configFields, true);\r
+        self::$_configFields['title'] = 'ok';\r
+        self::$_configFields['config']['config1'] = 'ok';\r
+        self::$_configFields['config']['config_new'] = 'ok';\r
+        self::$_configFields['new'] = 'should not be saved';\r
+        writeConfig(self::$_configFields, true);\r
+\r
+        include self::$_configFields['config']['CONFIG_FILE'];\r
+        $this->assertEquals('ok', $GLOBALS['title']);\r
+        $this->assertEquals('ok', $GLOBALS['config']['config1']);\r
+        $this->assertEquals('ok', $GLOBALS['config']['config_new']);\r
+        $this->assertFalse(isset($GLOBALS['new']));\r
+    }\r
+\r
+    /**\r
+     * Test writeConfig function with an empty array.\r
+     *\r
+     * @expectedException MissingFieldConfigException\r
+     */\r
+    public function testWriteConfigEmpty()\r
+    {\r
+        writeConfig(array(), true);\r
+    }\r
+\r
+    /**\r
+     * Test writeConfig function with a missing mandatory field.\r
+     *\r
+     * @expectedException MissingFieldConfigException\r
+     */\r
+    public function testWriteConfigMissingField()\r
+    {\r
+        unset(self::$_configFields['login']);\r
+        writeConfig(self::$_configFields, true);\r
+    }\r
+\r
+    /**\r
+     * Test writeConfig function while being logged out, and there is no config file existing.\r
+     */\r
+    public function testWriteConfigLoggedOutNoFile()\r
+    {\r
+        writeConfig(self::$_configFields, false);\r
+    }\r
+\r
+    /**\r
+     * Test writeConfig function while being logged out, and a config file already exists.\r
+     *\r
+     * @expectedException UnauthorizedConfigException\r
+     */\r
+    public function testWriteConfigLoggedOutWithFile()\r
+    {\r
+        file_put_contents(self::$_configFields['config']['CONFIG_FILE'], '');\r
+        writeConfig(self::$_configFields, false);\r
+    }\r
+\r
+    /**\r
+     * Test mergeDeprecatedConfig while being logged in:\r
+     *      1. init a config file.\r
+     *      2. init a options.php file with update value.\r
+     *      3. merge.\r
+     *      4. check updated value in config file.\r
+     */\r
+    public function testMergeDeprecatedConfig()\r
+    {\r
+        // init\r
+        writeConfig(self::$_configFields, true);\r
+        $configCopy = self::$_configFields;\r
+        $invert = !$configCopy['privateLinkByDefault'];\r
+        $configCopy['privateLinkByDefault'] = $invert;\r
+\r
+        // Use writeConfig to create a options.php\r
+        $configCopy['config']['CONFIG_FILE'] = 'tests/options.php';\r
+        writeConfig($configCopy, true);\r
+\r
+        $this->assertTrue(is_file($configCopy['config']['CONFIG_FILE']));\r
+\r
+        // merge configs\r
+        mergeDeprecatedConfig(self::$_configFields, true);\r
+\r
+        // make sure updated field is changed\r
+        include self::$_configFields['config']['CONFIG_FILE'];\r
+        $this->assertEquals($invert, $GLOBALS['privateLinkByDefault']);\r
+        $this->assertFalse(is_file($configCopy['config']['CONFIG_FILE']));\r
+    }\r
+\r
+    /**\r
+     * Test mergeDeprecatedConfig while being logged in without options file.\r
+     */\r
+    public function testMergeDeprecatedConfigNoFile()\r
+    {\r
+        writeConfig(self::$_configFields, true);\r
+        mergeDeprecatedConfig(self::$_configFields, true);\r
+\r
+        include self::$_configFields['config']['CONFIG_FILE'];\r
+        $this->assertEquals(self::$_configFields['login'], $GLOBALS['login']);\r
+    }\r
+}
\ No newline at end of file
index 8b0bd23b6c36ad845aff9596585778d3de1d6122..d34ea4f568bf4dcef51456615bfd9e00cb8dd602 100644 (file)
@@ -103,7 +103,7 @@ class LinkDBTest extends PHPUnit_Framework_TestCase
         unlink(self::$testDatastore);
         $this->assertFileNotExists(self::$testDatastore);
 
-        $checkDB = self::getMethod('checkDB');
+        $checkDB = self::getMethod('_checkDB');
         $checkDB->invokeArgs($linkDB, array());
         $this->assertFileExists(self::$testDatastore);
 
@@ -120,7 +120,7 @@ class LinkDBTest extends PHPUnit_Framework_TestCase
         $datastoreSize = filesize(self::$testDatastore);
         $this->assertGreaterThan(0, $datastoreSize);
 
-        $checkDB = self::getMethod('checkDB');
+        $checkDB = self::getMethod('_checkDB');
         $checkDB->invokeArgs($linkDB, array());
 
         // ensure the datastore is left unmodified
index 59ba671fc703274db61bbebae926ec350d2ac0f4..0b2257205edf5a16f0ebbbcb7a85fb680aebaaea 100644 (file)
@@ -4,9 +4,9 @@
  */
 class ReferenceLinkDB
 {
-    private $links = array();
-    private $publicCount = 0;
-    private $privateCount = 0;
+    private $_links = array();
+    private $_publicCount = 0;
+    private $_privateCount = 0;
 
     /**
      * Populates the test DB with reference data
@@ -81,13 +81,13 @@ class ReferenceLinkDB
             'linkdate' => $date,
             'tags' => $tags,
         );
-        $this->links[$date] = $link;
+        $this->_links[$date] = $link;
 
         if ($private) {
-            $this->privateCount++;
+            $this->_privateCount++;
             return;
         }
-        $this->publicCount++;
+        $this->_publicCount++;
     }
 
     /**
@@ -97,7 +97,7 @@ class ReferenceLinkDB
     {
         file_put_contents(
             $filename,
-            '<?php /* '.base64_encode(gzdeflate(serialize($this->links))).' */ ?>'
+            '<?php /* '.base64_encode(gzdeflate(serialize($this->_links))).' */ ?>'
         );
     }
 
@@ -106,7 +106,7 @@ class ReferenceLinkDB
      */
     public function countLinks()
     {
-        return $this->publicCount + $this->privateCount;
+        return $this->_publicCount + $this->_privateCount;
     }
 
     /**
@@ -114,7 +114,7 @@ class ReferenceLinkDB
      */
     public function countPublicLinks()
     {
-        return $this->publicCount;
+        return $this->_publicCount;
     }
 
     /**
@@ -122,7 +122,7 @@ class ReferenceLinkDB
      */
     public function countPrivateLinks()
     {
-        return $this->privateCount;
+        return $this->_privateCount;
     }
 }
 ?>
index 1b7ab8e944559e38c5198fec35525eccde9c8b81..d959d6be89e1626d9d72aa7a99d8e25f420e95b0 100644 (file)
@@ -1,8 +1,16 @@
-{loop="links"}
-       <h3><a href="{$value.url}">{$value.title}</a></h3>
-       <small>{if="!$GLOBALS['config']['HIDE_TIMESTAMPS']"}{function="strftime('%c', $value.timestamp)"} - {/if}{if="$value.tags"}{$value.tags}{/if}<br>
-       {$value.url}</small><br>
-       {if="$value.thumbnail"}{$value.thumbnail}{/if}<br>
-       {if="$value.description"}{$value.formatedDescription}{/if}
-       <br><br><hr>
-{/loop}
\ No newline at end of file
+<item>
+    <title>{$title} - {function="strftime('%A %e %B %Y', $daydate)"}</title>
+    <guid>{$absurl}</guid>
+    <link>{$absurl}</link>
+    <pubDate>{$rfc822date}</pubDate>
+    <description><![CDATA[
+        {loop="links"}
+               <h3><a href="{$value.url}">{$value.title}</a></h3>
+               <small>{if="!$GLOBALS['config']['HIDE_TIMESTAMPS']"}{function="strftime('%c', $value.timestamp)"} - {/if}{if="$value.tags"}{$value.tags}{/if}<br>
+               {$value.url}</small><br>
+               {if="$value.thumbnail"}{$value.thumbnail}{/if}<br>
+               {if="$value.description"}{$value.formatedDescription}{/if}
+               <br><br><hr>
+        {/loop}
+    ]]></description>
+</item>
\ No newline at end of file
index a32748ab48f11b3ac49783103059d07c40886e5a..3733ca2185a94ca9a3df7cf48410c17175e476eb 100644 (file)
@@ -42,7 +42,7 @@
 {if="($GLOBALS['config']['OPEN_SHAARLI'] || isLoggedIn())"}
 <script>
     $ = Awesomplete.$;
-    new Awesomplete($('input[data-multiple]'), {
+    awesomplete = new Awesomplete($('input[data-multiple]'), {
         filter: function(text, input) {
             return Awesomplete.FILTER_CONTAINS(text, input.match(/[^ ]*$/)[0]);
         },
         },
         minChars: 1
     });
+
+    /**
+     * Remove already selected items from autocompletion list.
+     * HTML list is never updated, so removing a tag will add it back to awesomplete.
+     *
+     * FIXME: This a workaround waiting for awesomplete to handle this.
+     *  https://github.com/LeaVerou/awesomplete/issues/16749
+     */
+    var input = document.querySelector('#lf_tags');
+    input.addEventListener('input', function()
+    {
+        proposedTags = input.getAttribute('data-list').replace(/,/g, '').split(' ');
+        reg = /(\w+) /g;
+        while((match = reg.exec(input.value)) !== null) {
+            id = proposedTags.indexOf(match[1]);
+            if(id != -1 ) {
+                proposedTags.splice(id, 1);
+            }
+        }
+
+        awesomplete.list = proposedTags;
+    });
 </script>
 {/if}
 </body>