]> git.immae.eu Git - github/shaarli/Shaarli.git/commitdiff
Merge pull request #977 from ArthurHoaro/feature/dl-filter
authorArthurHoaro <arthur@hoa.ro>
Tue, 23 Jan 2018 17:41:38 +0000 (18:41 +0100)
committerGitHub <noreply@github.com>
Tue, 23 Jan 2018 17:41:38 +0000 (18:41 +0100)
Extract the title/charset during page download, and check content type

1  2 
application/HttpUtils.php
application/LinkUtils.php
index.php
tests/LinkUtilsTest.php

index c9371b55c5cc6ddacd9a888984889a466075b63a,2edf5ce2df74bcb39fd5a1ce15c401302ff666c1..83a4c5e28699eff2472e4b7c132cef1e8bc53e8b
@@@ -3,9 -3,11 +3,11 @@@
   * GET an HTTP URL to retrieve its content
   * Uses the cURL library or a fallback method 
   *
-  * @param string $url      URL to get (http://...)
-  * @param int    $timeout  network timeout (in seconds)
-  * @param int    $maxBytes maximum downloaded bytes (default: 4 MiB)
+  * @param string          $url               URL to get (http://...)
+  * @param int             $timeout           network timeout (in seconds)
+  * @param int             $maxBytes          maximum downloaded bytes (default: 4 MiB)
+  * @param callable|string $curlWriteFunction Optional callback called during the download (cURL CURLOPT_WRITEFUNCTION).
+  *                                           Can be used to add download conditions on the headers (response code, content type, etc.).
   *
   * @return array HTTP response headers, downloaded content
   *
@@@ -29,7 -31,7 +31,7 @@@
   * @see http://stackoverflow.com/q/9183178
   * @see http://stackoverflow.com/q/1462720
   */
- function get_http_response($url, $timeout = 30, $maxBytes = 4194304)
+ function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteFunction = null)
  {
      $urlObj = new Url($url);
      $cleanUrl = $urlObj->idnToAscii();
      curl_setopt($ch, CURLOPT_TIMEOUT,           $timeout);
      curl_setopt($ch, CURLOPT_USERAGENT,         $userAgent);
  
+     if (is_callable($curlWriteFunction)) {
+         curl_setopt($ch, CURLOPT_WRITEFUNCTION, $curlWriteFunction);
+     }
      // Max download size management
 -    curl_setopt($ch, CURLOPT_BUFFERSIZE,        1024);
 +    curl_setopt($ch, CURLOPT_BUFFERSIZE,        1024*16);
      curl_setopt($ch, CURLOPT_NOPROGRESS,        false);
      curl_setopt($ch, CURLOPT_PROGRESSFUNCTION,
          function($arg0, $arg1, $arg2, $arg3, $arg4 = 0) use ($maxBytes)
@@@ -302,13 -308,6 +308,13 @@@ function server_url($server
                  $port = $server['HTTP_X_FORWARDED_PORT'];
              }
  
 +            // This is a workaround for proxies that don't forward the scheme properly.
 +            // Connecting over port 443 has to be in HTTPS.
 +            // See https://github.com/shaarli/Shaarli/issues/1022
 +            if ($port == '443') {
 +                $scheme = 'https';
 +            }
 +
              if (($scheme == 'http' && $port != '80')
                  || ($scheme == 'https' && $port != '443')
              ) {
index e3d95d0860d872dd371173a3b45eecf5c558f4f3,c0dd32a66cfa0160e4b37160fdf226d4abb14499..3705f7e919c4a1ea021830d65565639a6694df1f
@@@ -1,60 -1,81 +1,81 @@@
  <?php
  
  /**
-  * Extract title from an HTML document.
+  * Get cURL callback function for CURLOPT_WRITEFUNCTION
   *
-  * @param string $html HTML content where to look for a title.
+  * @param string $charset     to extract from the downloaded page (reference)
+  * @param string $title       to extract from the downloaded page (reference)
+  * @param string $curlGetInfo Optionnaly overrides curl_getinfo function
   *
-  * @return bool|string Extracted title if found, false otherwise.
+  * @return Closure
   */
- function html_extract_title($html)
+ function get_curl_download_callback(&$charset, &$title, $curlGetInfo = 'curl_getinfo')
  {
-     if (preg_match('!<title.*?>(.*?)</title>!is', $html, $matches)) {
-         return trim(str_replace("\n", '', $matches[1]));
-     }
-     return false;
+     /**
+      * cURL callback function for CURLOPT_WRITEFUNCTION (called during the download).
+      *
+      * While downloading the remote page, we check that the HTTP code is 200 and content type is 'html/text'
+      * Then we extract the title and the charset and stop the download when it's done.
+      *
+      * @param resource $ch   cURL resource
+      * @param string   $data chunk of data being downloaded
+      *
+      * @return int|bool length of $data or false if we need to stop the download
+      */
+     return function(&$ch, $data) use ($curlGetInfo, &$charset, &$title) {
+         $responseCode = $curlGetInfo($ch, CURLINFO_RESPONSE_CODE);
+         if (!empty($responseCode) && $responseCode != 200) {
+             return false;
+         }
+         $contentType = $curlGetInfo($ch, CURLINFO_CONTENT_TYPE);
+         if (!empty($contentType) && strpos($contentType, 'text/html') === false) {
+             return false;
+         }
+         if (empty($charset)) {
+             $charset = header_extract_charset($contentType);
+         }
+         if (empty($charset)) {
+             $charset = html_extract_charset($data);
+         }
+         if (empty($title)) {
+             $title = html_extract_title($data);
+         }
+         // We got everything we want, stop the download.
+         if (!empty($responseCode) && !empty($contentType) && !empty($charset) && !empty($title)) {
+             return false;
+         }
+         return strlen($data);
+     };
  }
  
  /**
-  * Determine charset from downloaded page.
-  * Priority:
-  *   1. HTTP headers (Content type).
-  *   2. HTML content page (tag <meta charset>).
-  *   3. Use a default charset (default: UTF-8).
+  * Extract title from an HTML document.
   *
-  * @param array  $headers           HTTP headers array.
-  * @param string $htmlContent       HTML content where to look for charset.
-  * @param string $defaultCharset    Default charset to apply if other methods failed.
+  * @param string $html HTML content where to look for a title.
   *
-  * @return string Determined charset.
+  * @return bool|string Extracted title if found, false otherwise.
   */
- function get_charset($headers, $htmlContent, $defaultCharset = 'utf-8')
+ function html_extract_title($html)
  {
-     if ($charset = headers_extract_charset($headers)) {
-         return $charset;
-     }
-     if ($charset = html_extract_charset($htmlContent)) {
-         return $charset;
+     if (preg_match('!<title.*?>(.*?)</title>!is', $html, $matches)) {
+         return trim(str_replace("\n", '', $matches[1]));
      }
-     return $defaultCharset;
+     return false;
  }
  
  /**
-  * Extract charset from HTTP headers if it's defined.
+  * Extract charset from HTTP header if it's defined.
   *
-  * @param array $headers HTTP headers array.
+  * @param string $header HTTP header Content-Type line.
   *
   * @return bool|string Charset string if found (lowercase), false otherwise.
   */
- function headers_extract_charset($headers)
+ function header_extract_charset($header)
  {
-     if (! empty($headers['Content-Type']) && strpos($headers['Content-Type'], 'charset=') !== false) {
-         preg_match('/charset="?([^; ]+)/i', $headers['Content-Type'], $match);
-         if (! empty($match[1])) {
-             return strtolower(trim($match[1]));
-         }
+     preg_match('/charset="?([^; ]+)/i', $header, $match);
+     if (! empty($match[1])) {
+         return strtolower(trim($match[1]));
      }
  
      return false;
@@@ -102,15 -123,14 +123,15 @@@ function count_private($links
   *
   * @param string $text       input string.
   * @param string $redirector if a redirector is set, use it to gerenate links.
 + * @param bool   $urlEncode  Use `urlencode()` on the URL after the redirector or not.
   *
   * @return string returns $text with all links converted to HTML links.
   *
   * @see Function inspired from http://www.php.net/manual/en/function.preg-replace.php#85722
   */
 -function text2clickable($text, $redirector = '')
 +function text2clickable($text, $redirector = '', $urlEncode = true)
  {
 -    $regex = '!(((?:https?|ftp|file)://|apt:|magnet:)\S+[[:alnum:]]/?)!si';
 +    $regex = '!(((?:https?|ftp|file)://|apt:|magnet:)\S+[a-z0-9\(\)]/?)!si';
  
      if (empty($redirector)) {
          return preg_replace($regex, '<a href="$1">$1</a>', $text);
      // Redirector is set, urlencode the final URL.
      return preg_replace_callback(
          $regex,
 -        function ($matches) use ($redirector) {
 -            return '<a href="' . $redirector . urlencode($matches[1]) .'">'. $matches[1] .'</a>';
 +        function ($matches) use ($redirector, $urlEncode) {
 +            $url = $urlEncode ? urlencode($matches[1]) : $matches[1];
 +            return '<a href="' . $redirector . $url .'">'. $matches[1] .'</a>';
          },
          $text
      );
@@@ -166,13 -185,12 +187,13 @@@ function space2nbsp($text
   *
   * @param string $description shaare's description.
   * @param string $redirector  if a redirector is set, use it to gerenate links.
 + * @param bool   $urlEncode  Use `urlencode()` on the URL after the redirector or not.
   * @param string $indexUrl    URL to Shaarli's index.
 - *
 +
   * @return string formatted description.
   */
 -function format_description($description, $redirector = '', $indexUrl = '') {
 -    return nl2br(space2nbsp(hashtag_autolink(text2clickable($description, $redirector), $indexUrl)));
 +function format_description($description, $redirector = '', $urlEncode = true, $indexUrl = '') {
 +    return nl2br(space2nbsp(hashtag_autolink(text2clickable($description, $redirector, $urlEncode), $indexUrl)));
  }
  
  /**
diff --combined index.php
index 27335a3692997c29a45094484c4b3185f5c5c1e1,ac51038d7f1f50e940a2a6ad66aa3806dff742b7..d57789e656749c248fbffe1e60a862b0aad4fbe6
+++ b/index.php
@@@ -64,6 -64,7 +64,6 @@@ require_once 'application/FeedBuilder.p
  require_once 'application/FileUtils.php';
  require_once 'application/History.php';
  require_once 'application/HttpUtils.php';
 -require_once 'application/Languages.php';
  require_once 'application/LinkDB.php';
  require_once 'application/LinkFilter.php';
  require_once 'application/LinkUtils.php';
@@@ -75,10 -76,8 +75,10 @@@ require_once 'application/Utils.php'
  require_once 'application/PluginManager.php';
  require_once 'application/Router.php';
  require_once 'application/Updater.php';
 +use \Shaarli\Languages;
  use \Shaarli\ThemeUtils;
  use \Shaarli\Config\ConfigManager;
 +use \Shaarli\SessionManager;
  
  // Ensure the PHP version is supported
  try {
@@@ -89,7 -88,7 +89,7 @@@
      exit;
  }
  
 -define('shaarli_version', ApplicationUtils::getVersion(__DIR__ .'/'. ApplicationUtils::$VERSION_FILE));
 +define('SHAARLI_VERSION', ApplicationUtils::getVersion(__DIR__ .'/'. ApplicationUtils::$VERSION_FILE));
  
  // Force cookie path (but do not change lifetime)
  $cookie = session_get_cookie_params();
@@@ -116,23 -115,14 +116,23 @@@ if (session_id() == '') 
  }
  
  // Regenerate session ID if invalid or not defined in cookie.
 -if (isset($_COOKIE['shaarli']) && !is_session_id_valid($_COOKIE['shaarli'])) {
 +if (isset($_COOKIE['shaarli']) && !SessionManager::checkId($_COOKIE['shaarli'])) {
      session_regenerate_id(true);
      $_COOKIE['shaarli'] = session_id();
  }
  
  $conf = new ConfigManager();
 +$sessionManager = new SessionManager($_SESSION, $conf);
 +
 +// Sniff browser language and set date format accordingly.
 +if (isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) {
 +    autoLocale($_SERVER['HTTP_ACCEPT_LANGUAGE']);
 +}
 +
 +new Languages(setlocale(LC_MESSAGES, 0), $conf);
 +
  $conf->setEmpty('general.timezone', date_default_timezone_get());
 -$conf->setEmpty('general.title', 'Shared links on '. escape(index_url($_SERVER)));
 +$conf->setEmpty('general.title', t('Shared links on '). escape(index_url($_SERVER)));
  RainTPL::$tpl_dir = $conf->get('resource.raintpl_tpl').'/'.$conf->get('resource.theme').'/'; // template directory
  RainTPL::$cache_dir = $conf->get('resource.raintpl_tmp'); // cache directory
  
@@@ -154,7 -144,7 +154,7 @@@ if (! is_file($conf->getConfigFileExt()
      $errors = ApplicationUtils::checkResourcePermissions($conf);
  
      if ($errors != array()) {
 -        $message = '<p>Insufficient permissions:</p><ul>';
 +        $message = '<p>'. t('Insufficient permissions:') .'</p><ul>';
  
          foreach ($errors as $error) {
              $message .= '<li>'.$error.'</li>';
      }
  
      // Display the installation form if no existing config is found
 -    install($conf);
 +    install($conf, $sessionManager);
  }
  
  // a token depending of deployment salt, user password, and the current ip
  define('STAY_SIGNED_IN_TOKEN', sha1($conf->get('credentials.hash') . $_SERVER['REMOTE_ADDR'] . $conf->get('credentials.salt')));
  
 -// Sniff browser language and set date format accordingly.
 -if (isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) {
 -    autoLocale($_SERVER['HTTP_ACCEPT_LANGUAGE']);
 -}
 -
  /**
   * Checking session state (i.e. is the user still logged in)
   *
@@@ -381,9 -376,9 +381,9 @@@ function ban_canLogin($conf
  // Process login form: Check if login/password is correct.
  if (isset($_POST['login']))
  {
 -    if (!ban_canLogin($conf)) die('I said: NO. You are banned for the moment. Go away.');
 +    if (!ban_canLogin($conf)) die(t('I said: NO. You are banned for the moment. Go away.'));
      if (isset($_POST['password'])
 -        && tokenOk($_POST['token'])
 +        && $sessionManager->checkToken($_POST['token'])
          && (check_auth($_POST['login'], $_POST['password'], $conf))
      ) {   // Login/password is OK.
          ban_loginOk($conf);
      else
      {
          ban_loginFailed($conf);
 -        $redir = '&username='. $_POST['login'];
 +        $redir = '&username='. urlencode($_POST['login']);
          if (isset($_GET['post'])) {
              $redir .= '&post=' . urlencode($_GET['post']);
              foreach (array('description', 'source', 'title', 'tags') as $param) {
                  }
              }
          }
 -        echo '<script>alert("Wrong login/password.");document.location=\'?do=login'.$redir.'\';</script>'; // Redirect to login screen.
 +        // Redirect to login screen.
 +        echo '<script>alert("'. t("Wrong login/password.") .'");document.location=\'?do=login'.$redir.'\';</script>';
          exit;
      }
  }
  // Token should be used in any form which acts on data (create,update,delete,import...).
  if (!isset($_SESSION['tokens'])) $_SESSION['tokens']=array();  // Token are attached to the session.
  
 -/**
 - * Returns a token.
 - *
 - * @param ConfigManager $conf Configuration Manager instance.
 - *
 - * @return string token.
 - */
 -function getToken($conf)
 -{
 -    $rnd = sha1(uniqid('', true) .'_'. mt_rand() . $conf->get('credentials.salt'));  // We generate a random string.
 -    $_SESSION['tokens'][$rnd]=1;  // Store it on the server side.
 -    return $rnd;
 -}
 -
 -// Tells if a token is OK. Using this function will destroy the token.
 -// true=token is OK.
 -function tokenOk($token)
 -{
 -    if (isset($_SESSION['tokens'][$token]))
 -    {
 -        unset($_SESSION['tokens'][$token]); // Token is used: destroy it.
 -        return true; // Token is OK.
 -    }
 -    return false; // Wrong token, or already used.
 -}
 -
  /**
   * Daily RSS feed: 1 RSS entry per day giving all the links on that day.
   * Gives the last 7 days (which have links).
@@@ -526,11 -546,7 +526,11 @@@ function showDailyRSS($conf) 
  
          // We pre-format some fields for proper output.
          foreach ($links as &$link) {
 -            $link['formatedDescription'] = format_description($link['description'], $conf->get('redirector.url'));
 +            $link['formatedDescription'] = format_description(
 +                $link['description'],
 +                $conf->get('redirector.url'),
 +                $conf->get('redirector.encode_url')
 +            );
              $link['thumbnail'] = thumbnail($conf, $link['url']);
              $link['timestamp'] = $link['created']->getTimestamp();
              if (startsWith($link['url'], '?')) {
@@@ -602,11 -618,7 +602,11 @@@ function showDaily($pageBuilder, $LINKS
          $taglist = explode(' ',$link['tags']);
          uasort($taglist, 'strcasecmp');
          $linksToDisplay[$key]['taglist']=$taglist;
 -        $linksToDisplay[$key]['formatedDescription'] = format_description($link['description'], $conf->get('redirector.url'));
 +        $linksToDisplay[$key]['formatedDescription'] = format_description(
 +            $link['description'],
 +            $conf->get('redirector.url'),
 +            $conf->get('redirector.encode_url')
 +        );
          $linksToDisplay[$key]['thumbnail'] = thumbnail($conf, $link['url']);
          $linksToDisplay[$key]['timestamp'] =  $link['created']->getTimestamp();
      }
@@@ -671,13 -683,12 +671,13 @@@ function showLinkList($PAGE, $LINKSDB, 
  /**
   * Render HTML page (according to URL parameters and user rights)
   *
 - * @param ConfigManager $conf          Configuration Manager instance.
 - * @param PluginManager $pluginManager Plugin Manager instance,
 - * @param LinkDB        $LINKSDB
 - * @param History       $history       instance
 + * @param ConfigManager  $conf           Configuration Manager instance.
 + * @param PluginManager  $pluginManager  Plugin Manager instance,
 + * @param LinkDB         $LINKSDB
 + * @param History        $history        instance
 + * @param SessionManager $sessionManager SessionManager instance
   */
 -function renderPage($conf, $pluginManager, $LINKSDB, $history)
 +function renderPage($conf, $pluginManager, $LINKSDB, $history, $sessionManager)
  {
      $updater = new Updater(
          read_updates_file($conf->get('resource.updates')),
          die($e->getMessage());
      }
  
 -    $PAGE = new PageBuilder($conf, $LINKSDB);
 +    $PAGE = new PageBuilder($conf, $LINKSDB, $sessionManager->generateToken());
      $PAGE->assign('linkcount', count($LINKSDB));
      $PAGE->assign('privateLinkcount', count_private($LINKSDB));
      $PAGE->assign('plugin_errors', $pluginManager->getErrors());
          }
  
          $data = array(
 -            'search_tags' => implode(' ', $filteringTags),
 +            'search_tags' => implode(' ', escape($filteringTags)),
              'tags' => $tagList,
          );
          $pluginManager->executeHooks('render_tagcloud', $data, array('loggedin' => isLoggedIn()));
          }
  
          $data = [
 -            'search_tags' => implode(' ', $filteringTags),
 +            'search_tags' => implode(' ', escape($filteringTags)),
              'tags' => $tags,
          ];
          $pluginManager->executeHooks('render_taglist', $data, ['loggedin' => isLoggedIn()]);
      if ($targetPage == Router::$PAGE_CHANGEPASSWORD)
      {
          if ($conf->get('security.open_shaarli')) {
 -            die('You are not supposed to change a password on an Open Shaarli.');
 +            die(t('You are not supposed to change a password on an Open Shaarli.'));
          }
  
          if (!empty($_POST['setpassword']) && !empty($_POST['oldpassword']))
          {
 -            if (!tokenOk($_POST['token'])) die('Wrong token.'); // Go away!
 +            if (!$sessionManager->checkToken($_POST['token'])) die(t('Wrong token.')); // Go away!
  
              // Make sure old password is correct.
              $oldhash = sha1($_POST['oldpassword'].$conf->get('credentials.login').$conf->get('credentials.salt'));
 -            if ($oldhash!= $conf->get('credentials.hash')) { echo '<script>alert("The old password is not correct.");document.location=\'?do=changepasswd\';</script>'; exit; }
 +            if ($oldhash!= $conf->get('credentials.hash')) {
 +                echo '<script>alert("'. t('The old password is not correct.') .'");document.location=\'?do=changepasswd\';</script>';
 +                exit;
 +            }
              // Save new password
              // Salt renders rainbow-tables attacks useless.
              $conf->set('credentials.salt', sha1(uniqid('', true) .'_'. mt_rand()));
                  echo '<script>alert("'. $e->getMessage() .'");document.location=\'?do=tools\';</script>';
                  exit;
              }
 -            echo '<script>alert("Your password has been changed.");document.location=\'?do=tools\';</script>';
 +            echo '<script>alert("'. t('Your password has been changed') .'");document.location=\'?do=tools\';</script>';
              exit;
          }
          else // show the change password form.
      {
          if (!empty($_POST['title']) )
          {
 -            if (!tokenOk($_POST['token'])) {
 -                die('Wrong token.'); // Go away!
 +            if (!$sessionManager->checkToken($_POST['token'])) {
 +                die(t('Wrong token.')); // Go away!
              }
              $tz = 'UTC';
              if (!empty($_POST['continent']) && !empty($_POST['city'])
              $conf->set('privacy.hide_public_links', !empty($_POST['hidePublicLinks']));
              $conf->set('api.enabled', !empty($_POST['enableApi']));
              $conf->set('api.secret', escape($_POST['apiSecret']));
 +            $conf->set('translation.language', escape($_POST['language']));
 +
              try {
                  $conf->write(isLoggedIn());
                  $history->updateSettings();
                  echo '<script>alert("'. $e->getMessage() .'");document.location=\'?do=configure\';</script>';
                  exit;
              }
 -            echo '<script>alert("Configuration was saved.");document.location=\'?do=configure\';</script>';
 +            echo '<script>alert("'. t('Configuration was saved.') .'");document.location=\'?do=configure\';</script>';
              exit;
          }
          else // Show the configuration form.
              $PAGE->assign('hide_public_links', $conf->get('privacy.hide_public_links', false));
              $PAGE->assign('api_enabled', $conf->get('api.enabled', true));
              $PAGE->assign('api_secret', $conf->get('api.secret'));
 +            $PAGE->assign('languages', Languages::getAvailableLanguages());
 +            $PAGE->assign('language', $conf->get('translation.language'));
              $PAGE->renderPage('configure');
              exit;
          }
              exit;
          }
  
 -        if (!tokenOk($_POST['token'])) {
 -            die('Wrong token.');
 +        if (!$sessionManager->checkToken($_POST['token'])) {
 +            die(t('Wrong token.'));
          }
  
          $alteredLinks = $LINKSDB->renameTag(escape($_POST['fromtag']), escape($_POST['totag']));
          }
          $delete = empty($_POST['totag']);
          $redirect = $delete ? 'do=changetag' : 'searchtags='. urlencode(escape($_POST['totag']));
 +        $count = count($alteredLinks);
          $alert = $delete
 -            ? sprintf(t('The tag was removed from %d links.'), count($alteredLinks))
 -            : sprintf(t('The tag was renamed in %d links.'), count($alteredLinks));
 +            ? sprintf(t('The tag was removed from %d link.', 'The tag was removed from %d links.', $count), $count)
 +            : sprintf(t('The tag was renamed in %d link.', 'The tag was renamed in %d links.', $count), $count);
          echo '<script>alert("'. $alert .'");document.location=\'?'. $redirect .'\';</script>';
          exit;
      }
      if (isset($_POST['save_edit']))
      {
          // Go away!
 -        if (! tokenOk($_POST['token'])) {
 -            die('Wrong token.');
 +        if (! $sessionManager->checkToken($_POST['token'])) {
 +            die(t('Wrong token.'));
          }
  
          // lf_id should only be present if the link exists.
      // -------- User clicked the "Delete" button when editing a link: Delete link from database.
      if ($targetPage == Router::$PAGE_DELETELINK)
      {
 -        if (! tokenOk($_GET['token'])) {
 -            die('Wrong token.');
 +        if (! $sessionManager->checkToken($_GET['token'])) {
 +            die(t('Wrong token.'));
          }
  
          $ids = trim($_GET['lf_linkdate']);
              // If this is an HTTP(S) link, we try go get the page to extract the title (otherwise we will to straight to the edit form.)
              if (empty($title) && strpos(get_url_scheme($url), 'http') !== false) {
                  // Short timeout to keep the application responsive
-                 list($headers, $content) = get_http_response($url, 4);
-                 if (strpos($headers[0], '200 OK') !== false) {
-                     // Retrieve charset.
-                     $charset = get_charset($headers, $content);
-                     // Extract title.
-                     $title = html_extract_title($content);
-                     // Re-encode title in utf-8 if necessary.
-                     if (! empty($title) && strtolower($charset) != 'utf-8') {
-                         $title = mb_convert_encoding($title, 'utf-8', $charset);
-                     }
+                 // The callback will fill $charset and $title with data from the downloaded page.
+                 get_http_response($url, 25, 4194304, get_curl_download_callback($charset, $title));
+                 if (! empty($title) && strtolower($charset) != 'utf-8') {
+                     $title = mb_convert_encoding($title, 'utf-8', $charset);
                  }
              }
  
              if ($url == '') {
                  $url = '?' . smallHash($linkdate . $LINKSDB->getNextId());
 -                $title = 'Note: ';
 +                $title = $conf->get('general.default_note_title', t('Note: '));
              }
              $url = escape($url);
              $title = escape($title);
          // Import bookmarks from an uploaded file
          if (isset($_FILES['filetoupload']['size']) && $_FILES['filetoupload']['size'] == 0) {
              // The file is too big or some form field may be missing.
 -            echo '<script>alert("The file you are trying to upload is probably'
 -                .' bigger than what this webserver can accept ('
 -                .get_max_upload_size(ini_get('post_max_size'), ini_get('upload_max_filesize')).').'
 -                .' Please upload in smaller chunks.");document.location=\'?do='
 -                .Router::$PAGE_IMPORT .'\';</script>';
 +            $msg = sprintf(
 +                t(
 +                    'The file you are trying to upload is probably bigger than what this webserver can accept'
 +                    .' (%s). Please upload in smaller chunks.'
 +                ),
 +                get_max_upload_size(ini_get('post_max_size'), ini_get('upload_max_filesize'))
 +            );
 +            echo '<script>alert("'. $msg .'");document.location=\'?do='.Router::$PAGE_IMPORT .'\';</script>';
              exit;
          }
 -        if (! tokenOk($_POST['token'])) {
 +        if (! $sessionManager->checkToken($_POST['token'])) {
              die('Wrong token.');
          }
          $status = NetscapeBookmarkUtils::import(
      // Get a fresh token
      if ($targetPage == Router::$GET_TOKEN) {
          header('Content-Type:text/plain');
 -        echo getToken($conf);
 +        echo $sessionManager->generateToken($conf);
          exit;
      }
  
@@@ -1696,11 -1690,7 +1690,11 @@@ function buildLinkList($PAGE,$LINKSDB, 
      while ($i<$end && $i<count($keys))
      {
          $link = $linksToDisplay[$keys[$i]];
 -        $link['description'] = format_description($link['description'], $conf->get('redirector.url'));
 +        $link['description'] = format_description(
 +            $link['description'],
 +            $conf->get('redirector.url'),
 +            $conf->get('redirector.encode_url')
 +        );
          $classLi =  ($i % 2) != 0 ? '' : 'publicLinkHightLight';
          $link['class'] = $link['private'] == 0 ? $classLi : 'private';
          $link['timestamp'] = $link['created']->getTimestamp();
@@@ -1954,10 -1944,10 +1948,10 @@@ function lazyThumbnail($conf, $url,$hre
   * Installation
   * This function should NEVER be called if the file data/config.php exists.
   *
 - * @param ConfigManager $conf Configuration Manager instance.
 + * @param ConfigManager  $conf           Configuration Manager instance.
 + * @param SessionManager $sessionManager SessionManager instance
   */
 -function install($conf)
 -{
 +function install($conf, $sessionManager) {
      // On free.fr host, make sure the /sessions directory exists, otherwise login will not work.
      if (endsWith($_SERVER['HTTP_HOST'],'.free.fr') && !is_dir($_SERVER['DOCUMENT_ROOT'].'/sessions')) mkdir($_SERVER['DOCUMENT_ROOT'].'/sessions',0705);
  
      // (Because on some hosts, session.save_path may not be set correctly,
      // or we may not have write access to it.)
      if (isset($_GET['test_session']) && ( !isset($_SESSION) || !isset($_SESSION['session_tested']) || $_SESSION['session_tested']!='Working'))
 -    {   // Step 2: Check if data in session is correct.
 -        echo '<pre>Sessions do not seem to work correctly on your server.<br>';
 -        echo 'Make sure the variable session.save_path is set correctly in your php config, and that you have write access to it.<br>';
 -        echo 'It currently points to '.session_save_path().'<br>';
 -        echo 'Check that the hostname used to access Shaarli contains a dot. On some browsers, accessing your server via a hostname like \'localhost\' or any custom hostname without a dot causes cookie storage to fail. We recommend accessing your server via it\'s IP address or Fully Qualified Domain Name.<br>';
 -        echo '<br><a href="?">Click to try again.</a></pre>';
 +    {
 +        // Step 2: Check if data in session is correct.
 +        $msg = t(
 +            '<pre>Sessions do not seem to work correctly on your server.<br>'.
 +            'Make sure the variable "session.save_path" is set correctly in your PHP config, '.
 +            'and that you have write access to it.<br>'.
 +            'It currently points to %s.<br>'.
 +            'On some browsers, accessing your server via a hostname like \'localhost\' '.
 +            'or any custom hostname without a dot causes cookie storage to fail. '.
 +            'We recommend accessing your server via it\'s IP address or Fully Qualified Domain Name.<br>'
 +        );
 +        $msg = sprintf($msg, session_save_path());
 +        echo $msg;
 +        echo '<br><a href="?">'. t('Click to try again.') .'</a></pre>';
          die;
      }
      if (!isset($_SESSION['session_tested']))
          } else {
              $conf->set('general.title', 'Shared links on '.escape(index_url($_SERVER)));
          }
 +        $conf->set('translation.language', escape($_POST['language']));
          $conf->set('updates.check_updates', !empty($_POST['updateCheck']));
          $conf->set('api.enabled', !empty($_POST['enableApi']));
          $conf->set(
          exit;
      }
  
 -    $PAGE = new PageBuilder($conf);
 +    $PAGE = new PageBuilder($conf, null, $sessionManager->generateToken());
      list($continents, $cities) = generateTimeZoneData(timezone_identifiers_list(), date_default_timezone_get());
      $PAGE->assign('continents', $continents);
      $PAGE->assign('cities', $cities);
 +    $PAGE->assign('languages', Languages::getAvailableLanguages());
      $PAGE->renderPage('install');
      exit;
  }
@@@ -2317,7 -2297,7 +2311,7 @@@ $response = $app->run(true)
  if ($response->getStatusCode() == 404 && strpos($_SERVER['REQUEST_URI'], '/api/v1') === false) {
      // We use UTF-8 for proper international characters handling.
      header('Content-Type: text/html; charset=utf-8');
 -    renderPage($conf, $pluginManager, $linkDb, $history);
 +    renderPage($conf, $pluginManager, $linkDb, $history, $sessionManager);
  } else {
      $app->respond($response);
  }
diff --combined tests/LinkUtilsTest.php
index 99679320ae3ea696762abea4d127ac6b8e5dde84,ef650f448d382fd2e4ab7ab7959186f831c255c9..7fbd59b0b80489d7b9e3521664e8aeb7d204b19d
@@@ -28,28 -28,14 +28,14 @@@ class LinkUtilsTest extends PHPUnit_Fra
          $this->assertFalse(html_extract_title($html));
      }
  
-     /**
-      * Test get_charset() with all priorities.
-      */
-     public function testGetCharset()
-     {
-         $headers = array('Content-Type' => 'text/html; charset=Headers');
-         $html = '<html><meta>stuff</meta><meta charset="Html"/></html>';
-         $default = 'default';
-         $this->assertEquals('headers', get_charset($headers, $html, $default));
-         $this->assertEquals('html', get_charset(array(), $html, $default));
-         $this->assertEquals($default, get_charset(array(), '', $default));
-         $this->assertEquals('utf-8', get_charset(array(), ''));
-     }
      /**
       * Test headers_extract_charset() when the charset is found.
       */
      public function testHeadersExtractExistentCharset()
      {
          $charset = 'x-MacCroatian';
-         $headers = array('Content-Type' => 'text/html; charset='. $charset);
-         $this->assertEquals(strtolower($charset), headers_extract_charset($headers));
+         $headers = 'text/html; charset='. $charset;
+         $this->assertEquals(strtolower($charset), header_extract_charset($headers));
      }
  
      /**
       */
      public function testHeadersExtractNonExistentCharset()
      {
-         $headers = array();
-         $this->assertFalse(headers_extract_charset($headers));
+         $headers = '';
+         $this->assertFalse(header_extract_charset($headers));
  
-         $headers = array('Content-Type' => 'text/html');
-         $this->assertFalse(headers_extract_charset($headers));
+         $headers = 'text/html';
+         $this->assertFalse(header_extract_charset($headers));
      }
  
      /**
          $this->assertFalse(html_extract_charset($html));
      }
  
+     /**
+      * Test the download callback with valid value
+      */
+     public function testCurlDownloadCallbackOk()
+     {
+         $callback = get_curl_download_callback($charset, $title, 'ut_curl_getinfo_ok');
+         $data = [
+             'HTTP/1.1 200 OK',
+             'Server: GitHub.com',
+             'Date: Sat, 28 Oct 2017 12:01:33 GMT',
+             'Content-Type: text/html; charset=utf-8',
+             'Status: 200 OK',
+             'end' => 'th=device-width"><title>Refactoring · GitHub</title><link rel="search" type="application/opensea',
+             '<title>ignored</title>',
+         ];
+         foreach ($data as $key => $line) {
+             $ignore = null;
+             $expected = $key !== 'end' ? strlen($line) : false;
+             $this->assertEquals($expected, $callback($ignore, $line));
+             if ($expected === false) {
+                 break;
+             }
+         }
+         $this->assertEquals('utf-8', $charset);
+         $this->assertEquals('Refactoring · GitHub', $title);
+     }
+     /**
+      * Test the download callback with valid values and no charset
+      */
+     public function testCurlDownloadCallbackOkNoCharset()
+     {
+         $callback = get_curl_download_callback($charset, $title, 'ut_curl_getinfo_no_charset');
+         $data = [
+             'HTTP/1.1 200 OK',
+             'end' => 'th=device-width"><title>Refactoring · GitHub</title><link rel="search" type="application/opensea',
+             '<title>ignored</title>',
+         ];
+         foreach ($data as $key => $line) {
+             $ignore = null;
+             $this->assertEquals(strlen($line), $callback($ignore, $line));
+         }
+         $this->assertEmpty($charset);
+         $this->assertEquals('Refactoring · GitHub', $title);
+     }
+     /**
+      * Test the download callback with valid values and no charset
+      */
+     public function testCurlDownloadCallbackOkHtmlCharset()
+     {
+         $callback = get_curl_download_callback($charset, $title, 'ut_curl_getinfo_no_charset');
+         $data = [
+             'HTTP/1.1 200 OK',
+             '<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />',
+             'end' => 'th=device-width"><title>Refactoring · GitHub</title><link rel="search" type="application/opensea',
+             '<title>ignored</title>',
+         ];
+         foreach ($data as $key => $line) {
+             $ignore = null;
+             $expected = $key !== 'end' ? strlen($line) : false;
+             $this->assertEquals($expected, $callback($ignore, $line));
+             if ($expected === false) {
+                 break;
+             }
+         }
+         $this->assertEquals('utf-8', $charset);
+         $this->assertEquals('Refactoring · GitHub', $title);
+     }
+     /**
+      * Test the download callback with valid values and no title
+      */
+     public function testCurlDownloadCallbackOkNoTitle()
+     {
+         $callback = get_curl_download_callback($charset, $title, 'ut_curl_getinfo_ok');
+         $data = [
+             'HTTP/1.1 200 OK',
+             'end' => 'th=device-width">Refactoring · GitHub<link rel="search" type="application/opensea',
+             'ignored',
+         ];
+         foreach ($data as $key => $line) {
+             $ignore = null;
+             $this->assertEquals(strlen($line), $callback($ignore, $line));
+         }
+         $this->assertEquals('utf-8', $charset);
+         $this->assertEmpty($title);
+     }
+     /**
+      * Test the download callback with an invalid content type.
+      */
+     public function testCurlDownloadCallbackInvalidContentType()
+     {
+         $callback = get_curl_download_callback($charset, $title, 'ut_curl_getinfo_ct_ko');
+         $ignore = null;
+         $this->assertFalse($callback($ignore, ''));
+         $this->assertEmpty($charset);
+         $this->assertEmpty($title);
+     }
+     /**
+      * Test the download callback with an invalid response code.
+      */
+     public function testCurlDownloadCallbackInvalidResponseCode()
+     {
+         $callback = get_curl_download_callback($charset, $title, 'ut_curl_getinfo_rc_ko');
+         $ignore = null;
+         $this->assertFalse($callback($ignore, ''));
+         $this->assertEmpty($charset);
+         $this->assertEmpty($title);
+     }
+     /**
+      * Test the download callback with an invalid content type and response code.
+      */
+     public function testCurlDownloadCallbackInvalidContentTypeAndResponseCode()
+     {
+         $callback = get_curl_download_callback($charset, $title, 'ut_curl_getinfo_rs_ct_ko');
+         $ignore = null;
+         $this->assertFalse($callback($ignore, ''));
+         $this->assertEmpty($charset);
+         $this->assertEmpty($title);
+     }
      /**
       * Test count_private.
       */
          $expectedText = 'stuff <a href="http://hello.there/is=someone#here">http://hello.there/is=someone#here</a> otherstuff';
          $processedText = text2clickable($text, '');
          $this->assertEquals($expectedText, $processedText);
 +
 +        $text = 'stuff http://hello.there/is=someone#here(please) otherstuff';
 +        $expectedText = 'stuff <a href="http://hello.there/is=someone#here(please)">http://hello.there/is=someone#here(please)</a> otherstuff';
 +        $processedText = text2clickable($text, '');
 +        $this->assertEquals($expectedText, $processedText);
 +
 +        $text = 'stuff http://hello.there/is=someone#here(please)&no otherstuff';
 +        $expectedText = 'stuff <a href="http://hello.there/is=someone#here(please)&no">http://hello.there/is=someone#here(please)&no</a> otherstuff';
 +        $processedText = text2clickable($text, '');
 +        $this->assertEquals($expectedText, $processedText);
      }
  
      /**
          $this->assertEquals($expectedText, $processedText);
      }
  
 +    /**
 +     * Test text2clickable a redirector set and without URL encode.
 +     */
 +    public function testText2clickableWithRedirectorDontEncode()
 +    {
 +        $text = 'stuff http://hello.there/?is=someone&or=something#here otherstuff';
 +        $redirector = 'http://redirector.to';
 +        $expectedText = 'stuff <a href="'.
 +            $redirector .
 +            'http://hello.there/?is=someone&or=something#here' .
 +            '">http://hello.there/?is=someone&or=something#here</a> otherstuff';
 +        $processedText = text2clickable($text, $redirector, false);
 +        $this->assertEquals($expectedText, $processedText);
 +    }
 +
      /**
       * Test testSpace2nbsp.
       */
          return str_replace('$1', $hashtag, $hashtagLink);
      }
  }
+ // old style mock: PHPUnit doesn't allow function mock
+ /**
+  * Returns code 200 or html content type.
+  *
+  * @param resource $ch   cURL resource
+  * @param int      $type cURL info type
+  *
+  * @return int|string 200 or 'text/html'
+  */
+ function ut_curl_getinfo_ok($ch, $type)
+ {
+     switch ($type) {
+         case CURLINFO_RESPONSE_CODE:
+             return 200;
+         case CURLINFO_CONTENT_TYPE:
+             return 'text/html; charset=utf-8';
+     }
+ }
+ /**
+  * Returns code 200 or html content type without charset.
+  *
+  * @param resource $ch   cURL resource
+  * @param int      $type cURL info type
+  *
+  * @return int|string 200 or 'text/html'
+  */
+ function ut_curl_getinfo_no_charset($ch, $type)
+ {
+     switch ($type) {
+         case CURLINFO_RESPONSE_CODE:
+             return 200;
+         case CURLINFO_CONTENT_TYPE:
+             return 'text/html';
+     }
+ }
+ /**
+  * Invalid response code.
+  *
+  * @param resource $ch   cURL resource
+  * @param int      $type cURL info type
+  *
+  * @return int|string 404 or 'text/html'
+  */
+ function ut_curl_getinfo_rc_ko($ch, $type)
+ {
+     switch ($type) {
+         case CURLINFO_RESPONSE_CODE:
+             return 404;
+         case CURLINFO_CONTENT_TYPE:
+             return 'text/html; charset=utf-8';
+     }
+ }
+ /**
+  * Invalid content type.
+  *
+  * @param resource $ch   cURL resource
+  * @param int      $type cURL info type
+  *
+  * @return int|string 200 or 'text/plain'
+  */
+ function ut_curl_getinfo_ct_ko($ch, $type)
+ {
+     switch ($type) {
+         case CURLINFO_RESPONSE_CODE:
+             return 200;
+         case CURLINFO_CONTENT_TYPE:
+             return 'text/plain';
+     }
+ }
+ /**
+  * Invalid response code and content type.
+  *
+  * @param resource $ch   cURL resource
+  * @param int      $type cURL info type
+  *
+  * @return int|string 404 or 'text/plain'
+  */
+ function ut_curl_getinfo_rs_ct_ko($ch, $type)
+ {
+     switch ($type) {
+         case CURLINFO_RESPONSE_CODE:
+             return 404;
+         case CURLINFO_CONTENT_TYPE:
+             return 'text/plain';
+     }
+ }