Merge pull request #1511 from ArthurHoaro/wip-slim-routing

This commit is contained in:
ArthurHoaro 2020-08-27 10:27:34 +02:00 committed by GitHub
commit af41d5ab5d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
225 changed files with 12764 additions and 4036 deletions

View file

@ -14,7 +14,7 @@ indent_size = 4
indent_size = 2
[*.php]
max_line_length = 100
max_line_length = 120
[Dockerfile]
max_line_length = 80

View file

@ -4,7 +4,6 @@
use Shaarli\Config\ConfigManager;
use WebThumbnailer\Application\ConfigManager as WTConfigManager;
use WebThumbnailer\Exception\WebThumbnailerException;
use WebThumbnailer\WebThumbnailer;
/**
@ -90,7 +89,7 @@ public function get($url)
try {
return $this->wt->thumbnail($url);
} catch (WebThumbnailerException $e) {
} catch (\Throwable $e) {
// Exceptions are only thrown in debug mode.
error_log(get_class($e) . ': ' . $e->getMessage());
}

View file

@ -87,10 +87,14 @@ function endsWith($haystack, $needle, $case = true)
*
* @param mixed $input Data to escape: a single string or an array of strings.
*
* @return string escaped.
* @return string|array escaped.
*/
function escape($input)
{
if (null === $input) {
return null;
}
if (is_bool($input)) {
return $input;
}
@ -294,7 +298,7 @@ function normalize_spaces($string)
* Requires php-intl to display international datetimes,
* otherwise default format '%c' will be returned.
*
* @param DateTime $date to format.
* @param DateTimeInterface $date to format.
* @param bool $time Displays time if true.
* @param bool $intl Use international format if true.
*
@ -302,7 +306,7 @@ function normalize_spaces($string)
*/
function format_date($date, $time = true, $intl = true)
{
if (! $date instanceof DateTime) {
if (! $date instanceof DateTimeInterface) {
return false;
}

View file

@ -71,7 +71,14 @@ public function __invoke($request, $response, $next)
$response = $e->getApiResponse();
}
return $response;
return $response
->withHeader('Access-Control-Allow-Origin', '*')
->withHeader(
'Access-Control-Allow-Headers',
'X-Requested-With, Content-Type, Accept, Origin, Authorization'
)
->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
;
}
/**

View file

@ -67,7 +67,7 @@ public static function formatLink($bookmark, $indexUrl)
if (! $bookmark->isNote()) {
$out['url'] = $bookmark->getUrl();
} else {
$out['url'] = $indexUrl . $bookmark->getUrl();
$out['url'] = rtrim($indexUrl, '/') . '/' . ltrim($bookmark->getUrl(), '/');
}
$out['shorturl'] = $bookmark->getShortUrl();
$out['title'] = $bookmark->getTitle();

View file

@ -3,6 +3,7 @@
namespace Shaarli\Bookmark;
use DateTime;
use DateTimeInterface;
use Shaarli\Bookmark\Exception\InvalidBookmarkException;
/**
@ -36,16 +37,16 @@ class Bookmark
/** @var array List of bookmark's tags */
protected $tags;
/** @var string Thumbnail's URL - false if no thumbnail could be found */
/** @var string|bool|null Thumbnail's URL - initialized at null, false if no thumbnail could be found */
protected $thumbnail;
/** @var bool Set to true if the bookmark is set as sticky */
protected $sticky;
/** @var DateTime Creation datetime */
/** @var DateTimeInterface Creation datetime */
protected $created;
/** @var DateTime Update datetime */
/** @var DateTimeInterface datetime */
protected $updated;
/** @var bool True if the bookmark can only be seen while logged in */
@ -100,12 +101,12 @@ public function validate()
|| ! is_int($this->id)
|| empty($this->shortUrl)
|| empty($this->created)
|| ! $this->created instanceof DateTime
|| ! $this->created instanceof DateTimeInterface
) {
throw new InvalidBookmarkException($this);
}
if (empty($this->url)) {
$this->url = '?'. $this->shortUrl;
$this->url = '/shaare/'. $this->shortUrl;
}
if (empty($this->title)) {
$this->title = $this->url;
@ -188,7 +189,7 @@ public function getDescription()
/**
* Get the Created.
*
* @return DateTime
* @return DateTimeInterface
*/
public function getCreated()
{
@ -198,7 +199,7 @@ public function getCreated()
/**
* Get the Updated.
*
* @return DateTime
* @return DateTimeInterface
*/
public function getUpdated()
{
@ -270,7 +271,7 @@ public function setDescription($description)
* Set the Created.
* Note: you shouldn't set this manually except for special cases (like bookmark import)
*
* @param DateTime $created
* @param DateTimeInterface $created
*
* @return Bookmark
*/
@ -284,7 +285,7 @@ public function setCreated($created)
/**
* Set the Updated.
*
* @param DateTime $updated
* @param DateTimeInterface $updated
*
* @return Bookmark
*/
@ -346,7 +347,7 @@ public function setTags($tags)
/**
* Get the Thumbnail.
*
* @return string|bool
* @return string|bool|null Thumbnail's URL - initialized at null, false if no thumbnail could be found
*/
public function getThumbnail()
{
@ -356,7 +357,7 @@ public function getThumbnail()
/**
* Set the Thumbnail.
*
* @param string|bool $thumbnail
* @param string|bool $thumbnail Thumbnail's URL - false if no thumbnail could be found
*
* @return Bookmark
*/
@ -405,7 +406,7 @@ public function getTagsString()
public function isNote()
{
// We check empty value to get a valid result if the link has not been saved yet
return empty($this->url) || $this->url[0] === '?';
return empty($this->url) || startsWith($this->url, '/shaare/') || $this->url[0] === '?';
}
/**

View file

@ -6,12 +6,14 @@
use Exception;
use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
use Shaarli\Bookmark\Exception\DatastoreNotInitializedException;
use Shaarli\Bookmark\Exception\EmptyDataStoreException;
use Shaarli\Config\ConfigManager;
use Shaarli\Formatter\BookmarkMarkdownFormatter;
use Shaarli\History;
use Shaarli\Legacy\LegacyLinkDB;
use Shaarli\Legacy\LegacyUpdater;
use Shaarli\Render\PageCacheManager;
use Shaarli\Updater\UpdaterUtils;
/**
@ -39,6 +41,9 @@ class BookmarkFileService implements BookmarkServiceInterface
/** @var History instance */
protected $history;
/** @var PageCacheManager instance */
protected $pageCacheManager;
/** @var bool true for logged in users. Default value to retrieve private bookmarks. */
protected $isLoggedIn;
@ -49,6 +54,7 @@ public function __construct(ConfigManager $conf, History $history, $isLoggedIn)
{
$this->conf = $conf;
$this->history = $history;
$this->pageCacheManager = new PageCacheManager($this->conf->get('resource.page_cache'), $isLoggedIn);
$this->bookmarksIO = new BookmarkIO($this->conf);
$this->isLoggedIn = $isLoggedIn;
@ -57,12 +63,18 @@ public function __construct(ConfigManager $conf, History $history, $isLoggedIn)
} else {
try {
$this->bookmarks = $this->bookmarksIO->read();
} catch (EmptyDataStoreException $e) {
} catch (EmptyDataStoreException|DatastoreNotInitializedException $e) {
$this->bookmarks = new BookmarkArray();
if ($isLoggedIn) {
if ($this->isLoggedIn) {
// Datastore file does not exists, we initialize it with default bookmarks.
if ($e instanceof DatastoreNotInitializedException) {
$this->initialize();
} else {
$this->save();
}
}
}
if (! $this->bookmarks instanceof BookmarkArray) {
$this->migrate();
@ -88,7 +100,7 @@ public function findByHash($hash)
throw new Exception('Not authorized');
}
return $bookmark;
return $first;
}
/**
@ -149,7 +161,7 @@ public function get($id, $visibility = null)
*/
public function set($bookmark, $save = true)
{
if ($this->isLoggedIn !== true) {
if (true !== $this->isLoggedIn) {
throw new Exception(t('You\'re not authorized to alter the datastore'));
}
if (! $bookmark instanceof Bookmark) {
@ -174,7 +186,7 @@ public function set($bookmark, $save = true)
*/
public function add($bookmark, $save = true)
{
if ($this->isLoggedIn !== true) {
if (true !== $this->isLoggedIn) {
throw new Exception(t('You\'re not authorized to alter the datastore'));
}
if (! $bookmark instanceof Bookmark) {
@ -199,7 +211,7 @@ public function add($bookmark, $save = true)
*/
public function addOrSet($bookmark, $save = true)
{
if ($this->isLoggedIn !== true) {
if (true !== $this->isLoggedIn) {
throw new Exception(t('You\'re not authorized to alter the datastore'));
}
if (! $bookmark instanceof Bookmark) {
@ -216,7 +228,7 @@ public function addOrSet($bookmark, $save = true)
*/
public function remove($bookmark, $save = true)
{
if ($this->isLoggedIn !== true) {
if (true !== $this->isLoggedIn) {
throw new Exception(t('You\'re not authorized to alter the datastore'));
}
if (! $bookmark instanceof Bookmark) {
@ -269,13 +281,14 @@ public function count($visibility = null)
*/
public function save()
{
if (!$this->isLoggedIn) {
if (true !== $this->isLoggedIn) {
// TODO: raise an Exception instead
die('You are not authorized to change the database.');
}
$this->bookmarks->reorder();
$this->bookmarksIO->write($this->bookmarks);
invalidateCaches($this->conf->get('resource.page_cache'));
$this->pageCacheManager->invalidateCaches();
}
/**
@ -291,6 +304,7 @@ public function bookmarksCountPerTag($filteringTags = [], $visibility = null)
if (empty($tag)
|| (! $this->isLoggedIn && startsWith($tag, '.'))
|| $tag === BookmarkMarkdownFormatter::NO_MD_TAG
|| in_array($tag, $filteringTags, true)
) {
continue;
}
@ -349,6 +363,10 @@ public function initialize()
{
$initializer = new BookmarkInitializer($this);
$initializer->initialize();
if (true === $this->isLoggedIn) {
$this->save();
}
}
/**

View file

@ -436,7 +436,7 @@ public function filterDay($day)
throw new Exception('Invalid date format');
}
$filtered = array();
$filtered = [];
foreach ($this->bookmarks as $key => $l) {
if ($l->getCreated()->format('Ymd') == $day) {
$filtered[$key] = $l;

View file

@ -2,6 +2,7 @@
namespace Shaarli\Bookmark;
use Shaarli\Bookmark\Exception\DatastoreNotInitializedException;
use Shaarli\Bookmark\Exception\EmptyDataStoreException;
use Shaarli\Bookmark\Exception\NotWritableDataStoreException;
use Shaarli\Config\ConfigManager;
@ -53,12 +54,13 @@ public function __construct($conf)
* @return BookmarkArray instance
*
* @throws NotWritableDataStoreException Data couldn't be loaded
* @throws EmptyDataStoreException Datastore doesn't exist
* @throws EmptyDataStoreException Datastore file exists but does not contain any bookmark
* @throws DatastoreNotInitializedException File does not exists
*/
public function read()
{
if (! file_exists($this->datastore)) {
throw new EmptyDataStoreException();
throw new DatastoreNotInitializedException();
}
if (!is_writable($this->datastore)) {
@ -102,7 +104,5 @@ public function write($links)
$this->datastore,
self::$phpPrefix.base64_encode(gzdeflate(serialize($links))).self::$phpSuffix
);
invalidateCaches($this->conf->get('resource.page_cache'));
}
}

View file

@ -6,8 +6,7 @@
* Class BookmarkInitializer
*
* This class is used to initialized default bookmarks after a fresh install of Shaarli.
* It is no longer call when the data store is empty,
* because user might want to delete default bookmarks after the install.
* It should be only called if the datastore file does not exist(users might want to delete the default bookmarks).
*
* To prevent data corruption, it does not overwrite existing bookmarks,
* even though there should not be any.
@ -36,11 +35,11 @@ public function initialize()
{
$bookmark = new Bookmark();
$bookmark->setTitle(t('My secret stuff... - Pastebin.com'));
$bookmark->setUrl('http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=', []);
$bookmark->setUrl('http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=');
$bookmark->setDescription(t('Shhhh! I\'m a private link only YOU can see. You can delete me too.'));
$bookmark->setTagsString('secretstuff');
$bookmark->setPrivate(true);
$this->bookmarkService->add($bookmark);
$this->bookmarkService->add($bookmark, false);
$bookmark = new Bookmark();
$bookmark->setTitle(t('The personal, minimalist, super-fast, database free, bookmarking service'));
@ -54,6 +53,6 @@ public function initialize()
You use the community supported version of the original Shaarli project, by Sebastien Sauvage.'
));
$bookmark->setTagsString('opensource software');
$this->bookmarkService->add($bookmark);
$this->bookmarkService->add($bookmark, false);
}
}

View file

@ -6,7 +6,6 @@
use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
use Shaarli\Bookmark\Exception\NotWritableDataStoreException;
use Shaarli\Config\ConfigManager;
use Shaarli\Exceptions\IOException;
use Shaarli\History;
/**

View file

@ -2,112 +2,6 @@
use Shaarli\Bookmark\Bookmark;
/**
* Get cURL callback function for CURLOPT_WRITEFUNCTION
*
* @param string $charset to extract from the downloaded page (reference)
* @param string $title to extract from the downloaded page (reference)
* @param string $description to extract from the downloaded page (reference)
* @param string $keywords to extract from the downloaded page (reference)
* @param bool $retrieveDescription Automatically tries to retrieve description and keywords from HTML content
* @param string $curlGetInfo Optionally overrides curl_getinfo function
*
* @return Closure
*/
function get_curl_download_callback(
&$charset,
&$title,
&$description,
&$keywords,
$retrieveDescription,
$curlGetInfo = 'curl_getinfo'
) {
$isRedirected = false;
$currentChunk = 0;
$foundChunk = null;
/**
* 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 (
$retrieveDescription,
$curlGetInfo,
&$charset,
&$title,
&$description,
&$keywords,
&$isRedirected,
&$currentChunk,
&$foundChunk
) {
$currentChunk++;
$responseCode = $curlGetInfo($ch, CURLINFO_RESPONSE_CODE);
if (!empty($responseCode) && in_array($responseCode, [301, 302])) {
$isRedirected = true;
return strlen($data);
}
if (!empty($responseCode) && $responseCode !== 200) {
return false;
}
// After a redirection, the content type will keep the previous request value
// until it finds the next content-type header.
if (! $isRedirected || strpos(strtolower($data), 'content-type') !== false) {
$contentType = $curlGetInfo($ch, CURLINFO_CONTENT_TYPE);
}
if (!empty($contentType) && strpos($contentType, 'text/html') === false) {
return false;
}
if (!empty($contentType) && empty($charset)) {
$charset = header_extract_charset($contentType);
}
if (empty($charset)) {
$charset = html_extract_charset($data);
}
if (empty($title)) {
$title = html_extract_title($data);
$foundChunk = ! empty($title) ? $currentChunk : $foundChunk;
}
if ($retrieveDescription && empty($description)) {
$description = html_extract_tag('description', $data);
$foundChunk = ! empty($description) ? $currentChunk : $foundChunk;
}
if ($retrieveDescription && empty($keywords)) {
$keywords = html_extract_tag('keywords', $data);
if (! empty($keywords)) {
$foundChunk = $currentChunk;
// Keywords use the format tag1, tag2 multiple words, tag
// So we format them to match Shaarli's separator and glue multiple words with '-'
$keywords = implode(' ', array_map(function($keyword) {
return implode('-', preg_split('/\s+/', trim($keyword)));
}, explode(',', $keywords)));
}
}
// We got everything we want, stop the download.
// If we already found either the title, description or keywords,
// it's highly unlikely that we'll found the other metas further than
// in the same chunk of data or the next one. So we also stop the download after that.
if ((!empty($responseCode) && !empty($contentType) && !empty($charset)) && $foundChunk !== null
&& (! $retrieveDescription
|| $foundChunk < $currentChunk
|| (!empty($title) && !empty($description) && !empty($keywords))
)
) {
return false;
}
return strlen($data);
};
}
/**
* Extract title from an HTML document.
*
@ -220,7 +114,7 @@ function hashtag_autolink($description, $indexUrl = '')
* \p{Mn} - any non marking space (accents, umlauts, etc)
*/
$regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui';
$replacement = '$1<a href="'. $indexUrl .'?addtag=$2" title="Hashtag $2">#$2</a>';
$replacement = '$1<a href="'. $indexUrl .'./add-tag/$2" title="Hashtag $2">#$2</a>';
return preg_replace($regex, $replacement, $description);
}

View file

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Shaarli\Bookmark\Exception;
class DatastoreNotInitializedException extends \Exception
{
}

View file

@ -3,6 +3,7 @@
use Shaarli\Config\Exception\MissingFieldConfigException;
use Shaarli\Config\Exception\UnauthorizedConfigException;
use Shaarli\Thumbnailer;
/**
* Class ConfigManager
@ -361,7 +362,7 @@ protected function setDefaultValues()
$this->setEmpty('security.open_shaarli', false);
$this->setEmpty('security.allowed_protocols', ['ftp', 'ftps', 'magnet']);
$this->setEmpty('general.header_link', '?');
$this->setEmpty('general.header_link', '/');
$this->setEmpty('general.links_per_page', 20);
$this->setEmpty('general.enabled_plugins', self::$DEFAULT_PLUGINS);
$this->setEmpty('general.default_note_title', 'Note: ');
@ -381,6 +382,7 @@ protected function setDefaultValues()
// default state of the 'remember me' checkbox of the login form
$this->setEmpty('privacy.remember_user_default', true);
$this->setEmpty('thumbnails.mode', Thumbnailer::MODE_ALL);
$this->setEmpty('thumbnails.width', '125');
$this->setEmpty('thumbnails.height', '90');

View file

@ -1,6 +1,7 @@
<?php
use Shaarli\Config\Exception\PluginConfigOrderException;
use Shaarli\Plugin\PluginManager;
/**
* Plugin configuration helper functions.
@ -19,6 +20,20 @@
*/
function save_plugin_config($formData)
{
// We can only save existing plugins
$directories = str_replace(
PluginManager::$PLUGINS_PATH . '/',
'',
glob(PluginManager::$PLUGINS_PATH . '/*')
);
$formData = array_filter(
$formData,
function ($value, string $key) use ($directories) {
return startsWith($key, 'order') || in_array($key, $directories);
},
ARRAY_FILTER_USE_BOTH
);
// Make sure there are no duplicates in orders.
if (!validate_plugin_order($formData)) {
throw new PluginConfigOrderException();
@ -69,7 +84,7 @@ function validate_plugin_order($formData)
$orders = array();
foreach ($formData as $key => $value) {
// No duplicate order allowed.
if (in_array($value, $orders)) {
if (in_array($value, $orders, true)) {
return false;
}

View file

@ -7,11 +7,21 @@
use Shaarli\Bookmark\BookmarkFileService;
use Shaarli\Bookmark\BookmarkServiceInterface;
use Shaarli\Config\ConfigManager;
use Shaarli\Feed\FeedBuilder;
use Shaarli\Formatter\FormatterFactory;
use Shaarli\Front\Controller\Visitor\ErrorController;
use Shaarli\History;
use Shaarli\Http\HttpAccess;
use Shaarli\Netscape\NetscapeBookmarkUtils;
use Shaarli\Plugin\PluginManager;
use Shaarli\Render\PageBuilder;
use Shaarli\Render\PageCacheManager;
use Shaarli\Security\CookieManager;
use Shaarli\Security\LoginManager;
use Shaarli\Security\SessionManager;
use Shaarli\Thumbnailer;
use Shaarli\Updater\Updater;
use Shaarli\Updater\UpdaterUtils;
/**
* Class ContainerBuilder
@ -30,22 +40,37 @@ class ContainerBuilder
/** @var SessionManager */
protected $session;
/** @var CookieManager */
protected $cookieManager;
/** @var LoginManager */
protected $login;
public function __construct(ConfigManager $conf, SessionManager $session, LoginManager $login)
{
/** @var string|null */
protected $basePath = null;
public function __construct(
ConfigManager $conf,
SessionManager $session,
CookieManager $cookieManager,
LoginManager $login
) {
$this->conf = $conf;
$this->session = $session;
$this->login = $login;
$this->cookieManager = $cookieManager;
}
public function build(): ShaarliContainer
{
$container = new ShaarliContainer();
$container['conf'] = $this->conf;
$container['sessionManager'] = $this->session;
$container['cookieManager'] = $this->cookieManager;
$container['loginManager'] = $this->login;
$container['basePath'] = $this->basePath;
$container['plugins'] = function (ShaarliContainer $container): PluginManager {
return new PluginManager($container->conf);
};
@ -73,7 +98,59 @@ public function build(): ShaarliContainer
};
$container['pluginManager'] = function (ShaarliContainer $container): PluginManager {
return new PluginManager($container->conf);
$pluginManager = new PluginManager($container->conf);
$pluginManager->load($container->conf->get('general.enabled_plugins'));
return $pluginManager;
};
$container['formatterFactory'] = function (ShaarliContainer $container): FormatterFactory {
return new FormatterFactory(
$container->conf,
$container->loginManager->isLoggedIn()
);
};
$container['pageCacheManager'] = function (ShaarliContainer $container): PageCacheManager {
return new PageCacheManager(
$container->conf->get('resource.page_cache'),
$container->loginManager->isLoggedIn()
);
};
$container['feedBuilder'] = function (ShaarliContainer $container): FeedBuilder {
return new FeedBuilder(
$container->bookmarkService,
$container->formatterFactory->getFormatter(),
$container->environment,
$container->loginManager->isLoggedIn()
);
};
$container['thumbnailer'] = function (ShaarliContainer $container): Thumbnailer {
return new Thumbnailer($container->conf);
};
$container['httpAccess'] = function (): HttpAccess {
return new HttpAccess();
};
$container['netscapeBookmarkUtils'] = function (ShaarliContainer $container): NetscapeBookmarkUtils {
return new NetscapeBookmarkUtils($container->bookmarkService, $container->conf, $container->history);
};
$container['updater'] = function (ShaarliContainer $container): Updater {
return new Updater(
UpdaterUtils::read_updates_file($container->conf->get('resource.updates')),
$container->bookmarkService,
$container->conf,
$container->loginManager->isLoggedIn()
);
};
$container['errorHandler'] = function (ShaarliContainer $container): ErrorController {
return new ErrorController($container);
};
return $container;

View file

@ -4,25 +4,45 @@
namespace Shaarli\Container;
use http\Cookie;
use Shaarli\Bookmark\BookmarkServiceInterface;
use Shaarli\Config\ConfigManager;
use Shaarli\Feed\FeedBuilder;
use Shaarli\Formatter\FormatterFactory;
use Shaarli\History;
use Shaarli\Http\HttpAccess;
use Shaarli\Netscape\NetscapeBookmarkUtils;
use Shaarli\Plugin\PluginManager;
use Shaarli\Render\PageBuilder;
use Shaarli\Render\PageCacheManager;
use Shaarli\Security\CookieManager;
use Shaarli\Security\LoginManager;
use Shaarli\Security\SessionManager;
use Shaarli\Thumbnailer;
use Shaarli\Updater\Updater;
use Slim\Container;
/**
* Extension of Slim container to document the injected objects.
*
* @property ConfigManager $conf
* @property SessionManager $sessionManager
* @property LoginManager $loginManager
* @property History $history
* @property string $basePath Shaarli's instance base path (e.g. `/shaarli/`)
* @property BookmarkServiceInterface $bookmarkService
* @property CookieManager $cookieManager
* @property ConfigManager $conf
* @property mixed[] $environment $_SERVER automatically injected by Slim
* @property callable $errorHandler Overrides default Slim error display
* @property FeedBuilder $feedBuilder
* @property FormatterFactory $formatterFactory
* @property History $history
* @property HttpAccess $httpAccess
* @property LoginManager $loginManager
* @property NetscapeBookmarkUtils $netscapeBookmarkUtils
* @property PageBuilder $pageBuilder
* @property PageCacheManager $pageCacheManager
* @property PluginManager $pluginManager
* @property SessionManager $sessionManager
* @property Thumbnailer $thumbnailer
* @property Updater $updater
*/
class ShaarliContainer extends Container
{

View file

@ -1,38 +0,0 @@
<?php
/**
* Cache utilities
*/
/**
* Purges all cached pages
*
* @param string $pageCacheDir page cache directory
*
* @return mixed an error string if the directory is missing
*/
function purgeCachedPages($pageCacheDir)
{
if (! is_dir($pageCacheDir)) {
$error = sprintf(t('Cannot purge %s: no directory'), $pageCacheDir);
error_log($error);
return $error;
}
array_map('unlink', glob($pageCacheDir.'/*.cache'));
}
/**
* Invalidates caches when the database is changed or the user logs out.
*
* @param string $pageCacheDir page cache directory
*/
function invalidateCaches($pageCacheDir)
{
// Purge cache attached to session.
if (isset($_SESSION['tags'])) {
unset($_SESSION['tags']);
}
// Purge page cache shared by sessions.
purgeCachedPages($pageCacheDir);
}

View file

@ -43,21 +43,9 @@ class FeedBuilder
*/
protected $formatter;
/**
* @var string RSS or ATOM feed.
*/
protected $feedType;
/**
* @var array $_SERVER
*/
/** @var mixed[] $_SERVER */
protected $serverInfo;
/**
* @var array $_GET
*/
protected $userInput;
/**
* @var boolean True if the user is currently logged in, false otherwise.
*/
@ -77,7 +65,6 @@ class FeedBuilder
* @var string server locale.
*/
protected $locale;
/**
* @var DateTime Latest item date.
*/
@ -88,37 +75,36 @@ class FeedBuilder
*
* @param BookmarkServiceInterface $linkDB LinkDB instance.
* @param BookmarkFormatter $formatter instance.
* @param string $feedType Type of feed.
* @param array $serverInfo $_SERVER.
* @param array $userInput $_GET.
* @param boolean $isLoggedIn True if the user is currently logged in, false otherwise.
*/
public function __construct($linkDB, $formatter, $feedType, $serverInfo, $userInput, $isLoggedIn)
public function __construct($linkDB, $formatter, $serverInfo, $isLoggedIn)
{
$this->linkDB = $linkDB;
$this->formatter = $formatter;
$this->feedType = $feedType;
$this->serverInfo = $serverInfo;
$this->userInput = $userInput;
$this->isLoggedIn = $isLoggedIn;
}
/**
* Build data for feed templates.
*
* @param string $feedType Type of feed (RSS/ATOM).
* @param array $userInput $_GET.
*
* @return array Formatted data for feeds templates.
*/
public function buildData()
public function buildData(string $feedType, ?array $userInput)
{
// Search for untagged bookmarks
if (isset($this->userInput['searchtags']) && empty($this->userInput['searchtags'])) {
$this->userInput['searchtags'] = false;
if (isset($this->userInput['searchtags']) && empty($userInput['searchtags'])) {
$userInput['searchtags'] = false;
}
// Optionally filter the results:
$linksToDisplay = $this->linkDB->search($this->userInput);
$linksToDisplay = $this->linkDB->search($userInput);
$nblinksToDisplay = $this->getNbLinks(count($linksToDisplay));
$nblinksToDisplay = $this->getNbLinks(count($linksToDisplay), $userInput);
// Can't use array_keys() because $link is a LinkDB instance and not a real array.
$keys = array();
@ -130,11 +116,11 @@ public function buildData()
$this->formatter->addContextData('index_url', $pageaddr);
$linkDisplayed = array();
for ($i = 0; $i < $nblinksToDisplay && $i < count($keys); $i++) {
$linkDisplayed[$keys[$i]] = $this->buildItem($linksToDisplay[$keys[$i]], $pageaddr);
$linkDisplayed[$keys[$i]] = $this->buildItem($feedType, $linksToDisplay[$keys[$i]], $pageaddr);
}
$data['language'] = $this->getTypeLanguage();
$data['last_update'] = $this->getLatestDateFormatted();
$data['language'] = $this->getTypeLanguage($feedType);
$data['last_update'] = $this->getLatestDateFormatted($feedType);
$data['show_dates'] = !$this->hideDates || $this->isLoggedIn;
// Remove leading slash from REQUEST_URI.
$data['self_link'] = escape(server_url($this->serverInfo))
@ -146,45 +132,6 @@ public function buildData()
return $data;
}
/**
* Build a feed item (one per shaare).
*
* @param Bookmark $link Single link array extracted from LinkDB.
* @param string $pageaddr Index URL.
*
* @return array Link array with feed attributes.
*/
protected function buildItem($link, $pageaddr)
{
$data = $this->formatter->format($link);
$data['guid'] = $pageaddr . '?' . $data['shorturl'];
if ($this->usePermalinks === true) {
$permalink = '<a href="'. $data['url'] .'" title="'. t('Direct link') .'">'. t('Direct link') .'</a>';
} else {
$permalink = '<a href="'. $data['guid'] .'" title="'. t('Permalink') .'">'. t('Permalink') .'</a>';
}
$data['description'] .= PHP_EOL . PHP_EOL . '<br>&#8212; ' . $permalink;
$data['pub_iso_date'] = $this->getIsoDate($data['created']);
// atom:entry elements MUST contain exactly one atom:updated element.
if (!empty($link->getUpdated())) {
$data['up_iso_date'] = $this->getIsoDate($data['updated'], DateTime::ATOM);
} else {
$data['up_iso_date'] = $this->getIsoDate($data['created'], DateTime::ATOM);
}
// Save the more recent item.
if (empty($this->latestDate) || $this->latestDate < $data['created']) {
$this->latestDate = $data['created'];
}
if (!empty($data['updated']) && $this->latestDate < $data['updated']) {
$this->latestDate = $data['updated'];
}
return $data;
}
/**
* Set this to true to use permalinks instead of direct bookmarks.
*
@ -215,22 +162,64 @@ public function setLocale($locale)
$this->locale = strtolower($locale);
}
/**
* Build a feed item (one per shaare).
*
* @param string $feedType Type of feed (RSS/ATOM).
* @param Bookmark $link Single link array extracted from LinkDB.
* @param string $pageaddr Index URL.
*
* @return array Link array with feed attributes.
*/
protected function buildItem(string $feedType, $link, $pageaddr)
{
$data = $this->formatter->format($link);
$data['guid'] = rtrim($pageaddr, '/') . '/shaare/' . $data['shorturl'];
if ($this->usePermalinks === true) {
$permalink = '<a href="'. $data['url'] .'" title="'. t('Direct link') .'">'. t('Direct link') .'</a>';
} else {
$permalink = '<a href="'. $data['guid'] .'" title="'. t('Permalink') .'">'. t('Permalink') .'</a>';
}
$data['description'] .= PHP_EOL . PHP_EOL . '<br>&#8212; ' . $permalink;
$data['pub_iso_date'] = $this->getIsoDate($feedType, $data['created']);
// atom:entry elements MUST contain exactly one atom:updated element.
if (!empty($link->getUpdated())) {
$data['up_iso_date'] = $this->getIsoDate($feedType, $data['updated'], DateTime::ATOM);
} else {
$data['up_iso_date'] = $this->getIsoDate($feedType, $data['created'], DateTime::ATOM);
}
// Save the more recent item.
if (empty($this->latestDate) || $this->latestDate < $data['created']) {
$this->latestDate = $data['created'];
}
if (!empty($data['updated']) && $this->latestDate < $data['updated']) {
$this->latestDate = $data['updated'];
}
return $data;
}
/**
* Get the language according to the feed type, based on the locale:
*
* - RSS format: en-us (default: 'en-en').
* - ATOM format: fr (default: 'en').
*
* @param string $feedType Type of feed (RSS/ATOM).
*
* @return string The language.
*/
public function getTypeLanguage()
protected function getTypeLanguage(string $feedType)
{
// Use the locale do define the language, if available.
if (!empty($this->locale) && preg_match('/^\w{2}[_\-]\w{2}/', $this->locale)) {
$length = ($this->feedType === self::$FEED_RSS) ? 5 : 2;
$length = ($feedType === self::$FEED_RSS) ? 5 : 2;
return str_replace('_', '-', substr($this->locale, 0, $length));
}
return ($this->feedType === self::$FEED_RSS) ? 'en-en' : 'en';
return ($feedType === self::$FEED_RSS) ? 'en-en' : 'en';
}
/**
@ -238,32 +227,35 @@ public function getTypeLanguage()
*
* Return an empty string if invalid DateTime is passed.
*
* @param string $feedType Type of feed (RSS/ATOM).
*
* @return string Formatted date.
*/
protected function getLatestDateFormatted()
protected function getLatestDateFormatted(string $feedType)
{
if (empty($this->latestDate) || !$this->latestDate instanceof DateTime) {
return '';
}
$type = ($this->feedType == self::$FEED_RSS) ? DateTime::RSS : DateTime::ATOM;
$type = ($feedType == self::$FEED_RSS) ? DateTime::RSS : DateTime::ATOM;
return $this->latestDate->format($type);
}
/**
* Get ISO date from DateTime according to feed type.
*
* @param string $feedType Type of feed (RSS/ATOM).
* @param DateTime $date Date to format.
* @param string|bool $format Force format.
*
* @return string Formatted date.
*/
protected function getIsoDate(DateTime $date, $format = false)
protected function getIsoDate(string $feedType, DateTime $date, $format = false)
{
if ($format !== false) {
return $date->format($format);
}
if ($this->feedType == self::$FEED_RSS) {
if ($feedType == self::$FEED_RSS) {
return $date->format(DateTime::RSS);
}
return $date->format(DateTime::ATOM);
@ -276,20 +268,21 @@ protected function getIsoDate(DateTime $date, $format = false)
* If 'nb' is set to 'all', display all filtered bookmarks (max parameter).
*
* @param int $max maximum number of bookmarks to display.
* @param array $userInput $_GET.
*
* @return int number of bookmarks to display.
*/
public function getNbLinks($max)
protected function getNbLinks($max, ?array $userInput)
{
if (empty($this->userInput['nb'])) {
if (empty($userInput['nb'])) {
return self::$DEFAULT_NB_LINKS;
}
if ($this->userInput['nb'] == 'all') {
if ($userInput['nb'] == 'all') {
return $max;
}
$intNb = intval($this->userInput['nb']);
$intNb = intval($userInput['nb']);
if (!is_int($intNb) || $intNb == 0) {
return self::$DEFAULT_NB_LINKS;
}

View file

@ -50,11 +50,10 @@ public function formatTagString($bookmark)
*/
public function formatUrl($bookmark)
{
if (! empty($this->contextData['index_url']) && (
startsWith($bookmark->getUrl(), '?') || startsWith($bookmark->getUrl(), '/')
)) {
return $this->contextData['index_url'] . escape($bookmark->getUrl());
if ($bookmark->isNote() && isset($this->contextData['index_url'])) {
return rtrim($this->contextData['index_url'], '/') . '/' . escape(ltrim($bookmark->getUrl(), '/'));
}
return escape($bookmark->getUrl());
}
@ -63,11 +62,18 @@ public function formatUrl($bookmark)
*/
protected function formatRealUrl($bookmark)
{
if (! empty($this->contextData['index_url']) && (
startsWith($bookmark->getUrl(), '?') || startsWith($bookmark->getUrl(), '/')
)) {
return $this->contextData['index_url'] . escape($bookmark->getUrl());
if ($bookmark->isNote()) {
if (isset($this->contextData['index_url'])) {
$prefix = rtrim($this->contextData['index_url'], '/') . '/';
}
if (isset($this->contextData['base_path'])) {
$prefix = rtrim($this->contextData['base_path'], '/') . '/';
}
return escape($prefix ?? '') . escape(ltrim($bookmark->getUrl(), '/'));
}
return escape($bookmark->getUrl());
}

View file

@ -3,8 +3,8 @@
namespace Shaarli\Formatter;
use DateTime;
use Shaarli\Config\ConfigManager;
use Shaarli\Bookmark\Bookmark;
use Shaarli\Config\ConfigManager;
/**
* Class BookmarkFormatter
@ -80,6 +80,8 @@ public function format($bookmark)
public function addContextData($key, $value)
{
$this->contextData[$key] = $value;
return $this;
}
/**
@ -128,7 +130,7 @@ protected function formatUrl($bookmark)
*/
protected function formatRealUrl($bookmark)
{
return $bookmark->getUrl();
return $this->formatUrl($bookmark);
}
/**

View file

@ -114,7 +114,7 @@ function ($match) use ($allowedProtocols, $indexUrl) {
/**
* Replace hashtag in Markdown links format
* E.g. `#hashtag` becomes `[#hashtag](?addtag=hashtag)`
* E.g. `#hashtag` becomes `[#hashtag](./add-tag/hashtag)`
* It includes the index URL if specified.
*
* @param string $description
@ -133,7 +133,7 @@ protected function formatHashTags($description)
* \p{Mn} - any non marking space (accents, umlauts, etc)
*/
$regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui';
$replacement = '$1[#$2]('. $indexUrl .'?addtag=$2)';
$replacement = '$1[#$2]('. $indexUrl .'./add-tag/$2)';
$descriptionLines = explode(PHP_EOL, $description);
$descriptionOut = '';

View file

@ -38,7 +38,7 @@ public function __construct(ConfigManager $conf, bool $isLoggedIn)
*
* @return BookmarkFormatter instance.
*/
public function getFormatter(string $type = null)
public function getFormatter(string $type = null): BookmarkFormatter
{
$type = $type ? $type : $this->conf->get('formatter', 'default');
$className = '\\Shaarli\\Formatter\\Bookmark'. ucfirst($type) .'Formatter';

View file

@ -0,0 +1,27 @@
<?php
namespace Shaarli\Front;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Middleware used for controller requiring to be authenticated.
* It extends ShaarliMiddleware, and just make sure that the user is authenticated.
* Otherwise, it redirects to the login page.
*/
class ShaarliAdminMiddleware extends ShaarliMiddleware
{
public function __invoke(Request $request, Response $response, callable $next): Response
{
$this->initBasePath($request);
if (true !== $this->container->loginManager->isLoggedIn()) {
$returnUrl = urlencode($this->container->environment['REQUEST_URI']);
return $response->withRedirect($this->container->basePath . '/login?returnurl=' . $returnUrl);
}
return parent::__invoke($request, $response, $next);
}
}

View file

@ -3,7 +3,7 @@
namespace Shaarli\Front;
use Shaarli\Container\ShaarliContainer;
use Shaarli\Front\Exception\ShaarliException;
use Shaarli\Front\Exception\UnauthorizedException;
use Slim\Http\Request;
use Slim\Http\Response;
@ -24,6 +24,8 @@ public function __construct(ShaarliContainer $container)
/**
* Middleware execution:
* - run updates
* - if not logged in open shaarli, redirect to login
* - execute the controller
* - return the response
*
@ -35,23 +37,78 @@ public function __construct(ShaarliContainer $container)
*
* @return Response response.
*/
public function __invoke(Request $request, Response $response, callable $next)
public function __invoke(Request $request, Response $response, callable $next): Response
{
$this->initBasePath($request);
try {
$response = $next($request, $response);
} catch (ShaarliException $e) {
$this->container->pageBuilder->assign('message', $e->getMessage());
if ($this->container->conf->get('dev.debug', false)) {
$this->container->pageBuilder->assign(
'stacktrace',
nl2br(get_class($this) .': '. $e->getTraceAsString())
if (!is_file($this->container->conf->getConfigFileExt())
&& !in_array($next->getName(), ['displayInstall', 'saveInstall'], true)
) {
return $response->withRedirect($this->container->basePath . '/install');
}
$this->runUpdates();
$this->checkOpenShaarli($request, $response, $next);
return $next($request, $response);
} catch (UnauthorizedException $e) {
$returnUrl = urlencode($this->container->environment['REQUEST_URI']);
return $response->withRedirect($this->container->basePath . '/login?returnurl=' . $returnUrl);
}
// Other exceptions are handled by ErrorController
}
/**
* Run the updater for every requests processed while logged in.
*/
protected function runUpdates(): void
{
if ($this->container->loginManager->isLoggedIn() !== true) {
return;
}
$this->container->updater->setBasePath($this->container->basePath);
$newUpdates = $this->container->updater->update();
if (!empty($newUpdates)) {
$this->container->updater->writeUpdates(
$this->container->conf->get('resource.updates'),
$this->container->updater->getDoneUpdates()
);
$this->container->pageCacheManager->invalidateCaches();
}
}
$response = $response->withStatus($e->getCode());
$response = $response->write($this->container->pageBuilder->render('error'));
/**
* Access is denied to most pages with `hide_public_links` + `force_login` settings.
*/
protected function checkOpenShaarli(Request $request, Response $response, callable $next): bool
{
if (// if the user isn't logged in
!$this->container->loginManager->isLoggedIn()
// and Shaarli doesn't have public content...
&& $this->container->conf->get('privacy.hide_public_links')
// and is configured to enforce the login
&& $this->container->conf->get('privacy.force_login')
// and the current page isn't already the login page
// and the user is not requesting a feed (which would lead to a different content-type as expected)
&& !in_array($next->getName(), ['login', 'atom', 'rss'], true)
) {
throw new UnauthorizedException();
}
return $response;
return true;
}
/**
* Initialize the URL base path if it hasn't been defined yet.
*/
protected function initBasePath(Request $request): void
{
if (null === $this->container->basePath) {
$this->container->basePath = rtrim($request->getUri()->getBasePath(), '/');
}
}
}

View file

@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Admin;
use Shaarli\Languages;
use Shaarli\Render\TemplatePage;
use Shaarli\Render\ThemeUtils;
use Shaarli\Thumbnailer;
use Slim\Http\Request;
use Slim\Http\Response;
use Throwable;
/**
* Class ConfigureController
*
* Slim controller used to handle Shaarli configuration page (display + save new config).
*/
class ConfigureController extends ShaarliAdminController
{
/**
* GET /admin/configure - Displays the configuration page
*/
public function index(Request $request, Response $response): Response
{
$this->assignView('title', $this->container->conf->get('general.title', 'Shaarli'));
$this->assignView('theme', $this->container->conf->get('resource.theme'));
$this->assignView(
'theme_available',
ThemeUtils::getThemes($this->container->conf->get('resource.raintpl_tpl'))
);
$this->assignView('formatter_available', ['default', 'markdown']);
list($continents, $cities) = generateTimeZoneData(
timezone_identifiers_list(),
$this->container->conf->get('general.timezone')
);
$this->assignView('continents', $continents);
$this->assignView('cities', $cities);
$this->assignView('retrieve_description', $this->container->conf->get('general.retrieve_description', false));
$this->assignView('private_links_default', $this->container->conf->get('privacy.default_private_links', false));
$this->assignView(
'session_protection_disabled',
$this->container->conf->get('security.session_protection_disabled', false)
);
$this->assignView('enable_rss_permalinks', $this->container->conf->get('feed.rss_permalinks', false));
$this->assignView('enable_update_check', $this->container->conf->get('updates.check_updates', true));
$this->assignView('hide_public_links', $this->container->conf->get('privacy.hide_public_links', false));
$this->assignView('api_enabled', $this->container->conf->get('api.enabled', true));
$this->assignView('api_secret', $this->container->conf->get('api.secret'));
$this->assignView('languages', Languages::getAvailableLanguages());
$this->assignView('gd_enabled', extension_loaded('gd'));
$this->assignView('thumbnails_mode', $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE));
$this->assignView('pagetitle', t('Configure') .' - '. $this->container->conf->get('general.title', 'Shaarli'));
return $response->write($this->render(TemplatePage::CONFIGURE));
}
/**
* POST /admin/configure - Update Shaarli's configuration
*/
public function save(Request $request, Response $response): Response
{
$this->checkToken($request);
$continent = $request->getParam('continent');
$city = $request->getParam('city');
$tz = 'UTC';
if (null !== $continent && null !== $city && isTimeZoneValid($continent, $city)) {
$tz = $continent . '/' . $city;
}
$this->container->conf->set('general.timezone', $tz);
$this->container->conf->set('general.title', escape($request->getParam('title')));
$this->container->conf->set('general.header_link', escape($request->getParam('titleLink')));
$this->container->conf->set('general.retrieve_description', !empty($request->getParam('retrieveDescription')));
$this->container->conf->set('resource.theme', escape($request->getParam('theme')));
$this->container->conf->set(
'security.session_protection_disabled',
!empty($request->getParam('disablesessionprotection'))
);
$this->container->conf->set(
'privacy.default_private_links',
!empty($request->getParam('privateLinkByDefault'))
);
$this->container->conf->set('feed.rss_permalinks', !empty($request->getParam('enableRssPermalinks')));
$this->container->conf->set('updates.check_updates', !empty($request->getParam('updateCheck')));
$this->container->conf->set('privacy.hide_public_links', !empty($request->getParam('hidePublicLinks')));
$this->container->conf->set('api.enabled', !empty($request->getParam('enableApi')));
$this->container->conf->set('api.secret', escape($request->getParam('apiSecret')));
$this->container->conf->set('formatter', escape($request->getParam('formatter')));
if (!empty($request->getParam('language'))) {
$this->container->conf->set('translation.language', escape($request->getParam('language')));
}
$thumbnailsMode = extension_loaded('gd') ? $request->getParam('enableThumbnails') : Thumbnailer::MODE_NONE;
if ($thumbnailsMode !== Thumbnailer::MODE_NONE
&& $thumbnailsMode !== $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE)
) {
$this->saveWarningMessage(
t('You have enabled or changed thumbnails mode.') .
'<a href="'. $this->container->basePath .'/admin/thumbnails">' . t('Please synchronize them.') .'</a>'
);
}
$this->container->conf->set('thumbnails.mode', $thumbnailsMode);
try {
$this->container->conf->write($this->container->loginManager->isLoggedIn());
$this->container->history->updateSettings();
$this->container->pageCacheManager->invalidateCaches();
} catch (Throwable $e) {
$this->assignView('message', t('Error while writing config file after configuration update.'));
if ($this->container->conf->get('dev.debug', false)) {
$this->assignView('stacktrace', $e->getMessage() . PHP_EOL . $e->getTraceAsString());
}
return $response->write($this->render('error'));
}
$this->saveSuccessMessage(t('Configuration was saved.'));
return $this->redirect($response, '/admin/configure');
}
}

View file

@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Admin;
use DateTime;
use Shaarli\Bookmark\Bookmark;
use Shaarli\Render\TemplatePage;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Class ExportController
*
* Slim controller used to display Shaarli data export page,
* and process the bookmarks export as a Netscape Bookmarks file.
*/
class ExportController extends ShaarliAdminController
{
/**
* GET /admin/export - Display export page
*/
public function index(Request $request, Response $response): Response
{
$this->assignView('pagetitle', t('Export') .' - '. $this->container->conf->get('general.title', 'Shaarli'));
return $response->write($this->render(TemplatePage::EXPORT));
}
/**
* POST /admin/export - Process export, and serve download file named
* bookmarks_(all|private|public)_datetime.html
*/
public function export(Request $request, Response $response): Response
{
$this->checkToken($request);
$selection = $request->getParam('selection');
if (empty($selection)) {
$this->saveErrorMessage(t('Please select an export mode.'));
return $this->redirect($response, '/admin/export');
}
$prependNoteUrl = filter_var($request->getParam('prepend_note_url') ?? false, FILTER_VALIDATE_BOOLEAN);
try {
$formatter = $this->container->formatterFactory->getFormatter('raw');
$this->assignView(
'links',
$this->container->netscapeBookmarkUtils->filterAndFormat(
$formatter,
$selection,
$prependNoteUrl,
index_url($this->container->environment)
)
);
} catch (\Exception $exc) {
$this->saveErrorMessage($exc->getMessage());
return $this->redirect($response, '/admin/export');
}
$now = new DateTime();
$response = $response->withHeader('Content-Type', 'text/html; charset=utf-8');
$response = $response->withHeader(
'Content-disposition',
'attachment; filename=bookmarks_'.$selection.'_'.$now->format(Bookmark::LINK_DATE_FORMAT).'.html'
);
$this->assignView('date', $now->format(DateTime::RFC822));
$this->assignView('eol', PHP_EOL);
$this->assignView('selection', $selection);
return $response->write($this->render(TemplatePage::NETSCAPE_EXPORT_BOOKMARKS));
}
}

View file

@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Admin;
use Psr\Http\Message\UploadedFileInterface;
use Shaarli\Render\TemplatePage;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Class ImportController
*
* Slim controller used to display Shaarli data import page,
* and import bookmarks from Netscape Bookmarks file.
*/
class ImportController extends ShaarliAdminController
{
/**
* GET /admin/import - Display import page
*/
public function index(Request $request, Response $response): Response
{
$this->assignView(
'maxfilesize',
get_max_upload_size(
ini_get('post_max_size'),
ini_get('upload_max_filesize'),
false
)
);
$this->assignView(
'maxfilesizeHuman',
get_max_upload_size(
ini_get('post_max_size'),
ini_get('upload_max_filesize'),
true
)
);
$this->assignView('pagetitle', t('Import') .' - '. $this->container->conf->get('general.title', 'Shaarli'));
return $response->write($this->render(TemplatePage::IMPORT));
}
/**
* POST /admin/import - Process import file provided and create bookmarks
*/
public function import(Request $request, Response $response): Response
{
$this->checkToken($request);
$file = ($request->getUploadedFiles() ?? [])['filetoupload'] ?? null;
if (!$file instanceof UploadedFileInterface) {
$this->saveErrorMessage(t('No import file provided.'));
return $this->redirect($response, '/admin/import');
}
// Import bookmarks from an uploaded file
if (0 === $file->getSize()) {
// The file is too big or some form field may be missing.
$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'))
);
$this->saveErrorMessage($msg);
return $this->redirect($response, '/admin/import');
}
$status = $this->container->netscapeBookmarkUtils->import($request->getParams(), $file);
$this->saveSuccessMessage($status);
return $this->redirect($response, '/admin/import');
}
}

View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Admin;
use Shaarli\Security\CookieManager;
use Shaarli\Security\LoginManager;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Class LogoutController
*
* Slim controller used to logout the user.
* It invalidates page cache and terminate the user session. Then it redirects to the homepage.
*/
class LogoutController extends ShaarliAdminController
{
public function index(Request $request, Response $response): Response
{
$this->container->pageCacheManager->invalidateCaches();
$this->container->sessionManager->logout();
$this->container->cookieManager->setCookieParameter(
CookieManager::STAY_SIGNED_IN,
'false',
0,
$this->container->basePath . '/'
);
return $this->redirect($response, '/');
}
}

View file

@ -0,0 +1,371 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Admin;
use Shaarli\Bookmark\Bookmark;
use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
use Shaarli\Formatter\BookmarkMarkdownFormatter;
use Shaarli\Render\TemplatePage;
use Shaarli\Thumbnailer;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Class PostBookmarkController
*
* Slim controller used to handle Shaarli create or edit bookmarks.
*/
class ManageShaareController extends ShaarliAdminController
{
/**
* GET /admin/add-shaare - Displays the form used to create a new bookmark from an URL
*/
public function addShaare(Request $request, Response $response): Response
{
$this->assignView(
'pagetitle',
t('Shaare a new link') .' - '. $this->container->conf->get('general.title', 'Shaarli')
);
return $response->write($this->render(TemplatePage::ADDLINK));
}
/**
* GET /admin/shaare - Displays the bookmark form for creation.
* Note that if the URL is found in existing bookmarks, then it will be in edit mode.
*/
public function displayCreateForm(Request $request, Response $response): Response
{
$url = cleanup_url($request->getParam('post'));
$linkIsNew = false;
// Check if URL is not already in database (in this case, we will edit the existing link)
$bookmark = $this->container->bookmarkService->findByUrl($url);
if (null === $bookmark) {
$linkIsNew = true;
// Get shaare data if it was provided in URL (e.g.: by the bookmarklet).
$title = $request->getParam('title');
$description = $request->getParam('description');
$tags = $request->getParam('tags');
$private = filter_var($request->getParam('private'), FILTER_VALIDATE_BOOLEAN);
// 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) {
$retrieveDescription = $this->container->conf->get('general.retrieve_description');
// Short timeout to keep the application responsive
// The callback will fill $charset and $title with data from the downloaded page.
$this->container->httpAccess->getHttpResponse(
$url,
$this->container->conf->get('general.download_timeout', 30),
$this->container->conf->get('general.download_max_size', 4194304),
$this->container->httpAccess->getCurlDownloadCallback(
$charset,
$title,
$description,
$tags,
$retrieveDescription
)
);
if (! empty($title) && strtolower($charset) !== 'utf-8') {
$title = mb_convert_encoding($title, 'utf-8', $charset);
}
}
if (empty($url) && empty($title)) {
$title = $this->container->conf->get('general.default_note_title', t('Note: '));
}
$link = escape([
'title' => $title,
'url' => $url ?? '',
'description' => $description ?? '',
'tags' => $tags ?? '',
'private' => $private,
]);
} else {
$formatter = $this->container->formatterFactory->getFormatter('raw');
$link = $formatter->format($bookmark);
}
return $this->displayForm($link, $linkIsNew, $request, $response);
}
/**
* GET /admin/shaare/{id} - Displays the bookmark form in edition mode.
*/
public function displayEditForm(Request $request, Response $response, array $args): Response
{
$id = $args['id'] ?? '';
try {
if (false === ctype_digit($id)) {
throw new BookmarkNotFoundException();
}
$bookmark = $this->container->bookmarkService->get((int) $id); // Read database
} catch (BookmarkNotFoundException $e) {
$this->saveErrorMessage(sprintf(
t('Bookmark with identifier %s could not be found.'),
$id
));
return $this->redirect($response, '/');
}
$formatter = $this->container->formatterFactory->getFormatter('raw');
$link = $formatter->format($bookmark);
return $this->displayForm($link, false, $request, $response);
}
/**
* POST /admin/shaare
*/
public function save(Request $request, Response $response): Response
{
$this->checkToken($request);
// lf_id should only be present if the link exists.
$id = $request->getParam('lf_id') ? intval(escape($request->getParam('lf_id'))) : null;
if (null !== $id && true === $this->container->bookmarkService->exists($id)) {
// Edit
$bookmark = $this->container->bookmarkService->get($id);
} else {
// New link
$bookmark = new Bookmark();
}
$bookmark->setTitle($request->getParam('lf_title'));
$bookmark->setDescription($request->getParam('lf_description'));
$bookmark->setUrl($request->getParam('lf_url'), $this->container->conf->get('security.allowed_protocols', []));
$bookmark->setPrivate(filter_var($request->getParam('lf_private'), FILTER_VALIDATE_BOOLEAN));
$bookmark->setTagsString($request->getParam('lf_tags'));
if ($this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE
&& false === $bookmark->isNote()
) {
$bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl()));
}
$this->container->bookmarkService->addOrSet($bookmark, false);
// To preserve backward compatibility with 3rd parties, plugins still use arrays
$formatter = $this->container->formatterFactory->getFormatter('raw');
$data = $formatter->format($bookmark);
$this->executePageHooks('save_link', $data);
$bookmark->fromArray($data);
$this->container->bookmarkService->set($bookmark);
// If we are called from the bookmarklet, we must close the popup:
if ($request->getParam('source') === 'bookmarklet') {
return $response->write('<script>self.close();</script>');
}
if (!empty($request->getParam('returnurl'))) {
$this->container->environment['HTTP_REFERER'] = escape($request->getParam('returnurl'));
}
return $this->redirectFromReferer(
$request,
$response,
['add-shaare', 'shaare'], ['addlink', 'post', 'edit_link'],
$bookmark->getShortUrl()
);
}
/**
* GET /admin/shaare/delete - Delete one or multiple bookmarks (depending on `id` query parameter).
*/
public function deleteBookmark(Request $request, Response $response): Response
{
$this->checkToken($request);
$ids = escape(trim($request->getParam('id') ?? ''));
if (empty($ids) || strpos($ids, ' ') !== false) {
// multiple, space-separated ids provided
$ids = array_values(array_filter(preg_split('/\s+/', $ids), 'ctype_digit'));
} else {
$ids = [$ids];
}
// assert at least one id is given
if (0 === count($ids)) {
$this->saveErrorMessage(t('Invalid bookmark ID provided.'));
return $this->redirectFromReferer($request, $response, [], ['delete-shaare']);
}
$formatter = $this->container->formatterFactory->getFormatter('raw');
$count = 0;
foreach ($ids as $id) {
try {
$bookmark = $this->container->bookmarkService->get((int) $id);
} catch (BookmarkNotFoundException $e) {
$this->saveErrorMessage(sprintf(
t('Bookmark with identifier %s could not be found.'),
$id
));
continue;
}
$data = $formatter->format($bookmark);
$this->executePageHooks('delete_link', $data);
$this->container->bookmarkService->remove($bookmark, false);
++ $count;
}
if ($count > 0) {
$this->container->bookmarkService->save();
}
// If we are called from the bookmarklet, we must close the popup:
if ($request->getParam('source') === 'bookmarklet') {
return $response->write('<script>self.close();</script>');
}
// Don't redirect to where we were previously because the datastore has changed.
return $this->redirect($response, '/');
}
/**
* GET /admin/shaare/visibility
*
* Change visibility (public/private) of one or multiple bookmarks (depending on `id` query parameter).
*/
public function changeVisibility(Request $request, Response $response): Response
{
$this->checkToken($request);
$ids = trim(escape($request->getParam('id') ?? ''));
if (empty($ids) || strpos($ids, ' ') !== false) {
// multiple, space-separated ids provided
$ids = array_values(array_filter(preg_split('/\s+/', $ids), 'ctype_digit'));
} else {
// only a single id provided
$ids = [$ids];
}
// assert at least one id is given
if (0 === count($ids)) {
$this->saveErrorMessage(t('Invalid bookmark ID provided.'));
return $this->redirectFromReferer($request, $response, [], ['change_visibility']);
}
// assert that the visibility is valid
$visibility = $request->getParam('newVisibility');
if (null === $visibility || false === in_array($visibility, ['public', 'private'], true)) {
$this->saveErrorMessage(t('Invalid visibility provided.'));
return $this->redirectFromReferer($request, $response, [], ['change_visibility']);
} else {
$isPrivate = $visibility === 'private';
}
$formatter = $this->container->formatterFactory->getFormatter('raw');
$count = 0;
foreach ($ids as $id) {
try {
$bookmark = $this->container->bookmarkService->get((int) $id);
} catch (BookmarkNotFoundException $e) {
$this->saveErrorMessage(sprintf(
t('Bookmark with identifier %s could not be found.'),
$id
));
continue;
}
$bookmark->setPrivate($isPrivate);
// To preserve backward compatibility with 3rd parties, plugins still use arrays
$data = $formatter->format($bookmark);
$this->executePageHooks('save_link', $data);
$bookmark->fromArray($data);
$this->container->bookmarkService->set($bookmark, false);
++$count;
}
if ($count > 0) {
$this->container->bookmarkService->save();
}
return $this->redirectFromReferer($request, $response, ['/visibility'], ['change_visibility']);
}
/**
* GET /admin/shaare/{id}/pin - Pin or unpin a bookmark.
*/
public function pinBookmark(Request $request, Response $response, array $args): Response
{
$this->checkToken($request);
$id = $args['id'] ?? '';
try {
if (false === ctype_digit($id)) {
throw new BookmarkNotFoundException();
}
$bookmark = $this->container->bookmarkService->get((int) $id); // Read database
} catch (BookmarkNotFoundException $e) {
$this->saveErrorMessage(sprintf(
t('Bookmark with identifier %s could not be found.'),
$id
));
return $this->redirectFromReferer($request, $response, ['/pin'], ['pin']);
}
$formatter = $this->container->formatterFactory->getFormatter('raw');
$bookmark->setSticky(!$bookmark->isSticky());
// To preserve backward compatibility with 3rd parties, plugins still use arrays
$data = $formatter->format($bookmark);
$this->executePageHooks('save_link', $data);
$bookmark->fromArray($data);
$this->container->bookmarkService->set($bookmark);
return $this->redirectFromReferer($request, $response, ['/pin'], ['pin']);
}
/**
* Helper function used to display the shaare form whether it's a new or existing bookmark.
*
* @param array $link data used in template, either from parameters or from the data store
*/
protected function displayForm(array $link, bool $isNew, Request $request, Response $response): Response
{
$tags = $this->container->bookmarkService->bookmarksCountPerTag();
if ($this->container->conf->get('formatter') === 'markdown') {
$tags[BookmarkMarkdownFormatter::NO_MD_TAG] = 1;
}
$data = [
'link' => $link,
'link_is_new' => $isNew,
'http_referer' => escape($this->container->environment['HTTP_REFERER'] ?? ''),
'source' => $request->getParam('source') ?? '',
'tags' => $tags,
'default_private_links' => $this->container->conf->get('privacy.default_private_links', false),
];
$this->executePageHooks('render_editlink', $data, TemplatePage::EDIT_LINK);
foreach ($data as $key => $value) {
$this->assignView($key, $value);
}
$editLabel = false === $isNew ? t('Edit') .' ' : '';
$this->assignView(
'pagetitle',
$editLabel . t('Shaare') .' - '. $this->container->conf->get('general.title', 'Shaarli')
);
return $response->write($this->render(TemplatePage::EDIT_LINK));
}
}

View file

@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Admin;
use Shaarli\Bookmark\BookmarkFilter;
use Shaarli\Render\TemplatePage;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Class ManageTagController
*
* Slim controller used to handle Shaarli manage tags page (rename and delete tags).
*/
class ManageTagController extends ShaarliAdminController
{
/**
* GET /admin/tags - Displays the manage tags page
*/
public function index(Request $request, Response $response): Response
{
$fromTag = $request->getParam('fromtag') ?? '';
$this->assignView('fromtag', escape($fromTag));
$this->assignView(
'pagetitle',
t('Manage tags') .' - '. $this->container->conf->get('general.title', 'Shaarli')
);
return $response->write($this->render(TemplatePage::CHANGE_TAG));
}
/**
* POST /admin/tags - Update or delete provided tag
*/
public function save(Request $request, Response $response): Response
{
$this->checkToken($request);
$isDelete = null !== $request->getParam('deletetag') && null === $request->getParam('renametag');
$fromTag = escape(trim($request->getParam('fromtag') ?? ''));
$toTag = escape(trim($request->getParam('totag') ?? ''));
if (0 === strlen($fromTag) || false === $isDelete && 0 === strlen($toTag)) {
$this->saveWarningMessage(t('Invalid tags provided.'));
return $this->redirect($response, '/admin/tags');
}
// TODO: move this to bookmark service
$count = 0;
$bookmarks = $this->container->bookmarkService->search(['searchtags' => $fromTag], BookmarkFilter::$ALL, true);
foreach ($bookmarks as $bookmark) {
if (false === $isDelete) {
$bookmark->renameTag($fromTag, $toTag);
} else {
$bookmark->deleteTag($fromTag);
}
$this->container->bookmarkService->set($bookmark, false);
$this->container->history->updateLink($bookmark);
$count++;
}
$this->container->bookmarkService->save();
if (true === $isDelete) {
$alert = sprintf(
t('The tag was removed from %d bookmark.', 'The tag was removed from %d bookmarks.', $count),
$count
);
} else {
$alert = sprintf(
t('The tag was renamed in %d bookmark.', 'The tag was renamed in %d bookmarks.', $count),
$count
);
}
$this->saveSuccessMessage($alert);
$redirect = true === $isDelete ? '/admin/tags' : '/?searchtags='. urlencode($toTag);
return $this->redirect($response, $redirect);
}
}

View file

@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Admin;
use Shaarli\Container\ShaarliContainer;
use Shaarli\Front\Exception\OpenShaarliPasswordException;
use Shaarli\Front\Exception\ShaarliFrontException;
use Shaarli\Render\TemplatePage;
use Slim\Http\Request;
use Slim\Http\Response;
use Throwable;
/**
* Class PasswordController
*
* Slim controller used to handle passwords update.
*/
class PasswordController extends ShaarliAdminController
{
public function __construct(ShaarliContainer $container)
{
parent::__construct($container);
$this->assignView(
'pagetitle',
t('Change password') .' - '. $this->container->conf->get('general.title', 'Shaarli')
);
}
/**
* GET /admin/password - Displays the change password template
*/
public function index(Request $request, Response $response): Response
{
return $response->write($this->render(TemplatePage::CHANGE_PASSWORD));
}
/**
* POST /admin/password - Change admin password - existing and new passwords need to be provided.
*/
public function change(Request $request, Response $response): Response
{
$this->checkToken($request);
if ($this->container->conf->get('security.open_shaarli', false)) {
throw new OpenShaarliPasswordException();
}
$oldPassword = $request->getParam('oldpassword');
$newPassword = $request->getParam('setpassword');
if (empty($newPassword) || empty($oldPassword)) {
$this->saveErrorMessage(t('You must provide the current and new password to change it.'));
return $response
->withStatus(400)
->write($this->render(TemplatePage::CHANGE_PASSWORD))
;
}
// Make sure old password is correct.
$oldHash = sha1(
$oldPassword .
$this->container->conf->get('credentials.login') .
$this->container->conf->get('credentials.salt')
);
if ($oldHash !== $this->container->conf->get('credentials.hash')) {
$this->saveErrorMessage(t('The old password is not correct.'));
return $response
->withStatus(400)
->write($this->render(TemplatePage::CHANGE_PASSWORD))
;
}
// Save new password
// Salt renders rainbow-tables attacks useless.
$this->container->conf->set('credentials.salt', sha1(uniqid('', true) .'_'. mt_rand()));
$this->container->conf->set(
'credentials.hash',
sha1(
$newPassword
. $this->container->conf->get('credentials.login')
. $this->container->conf->get('credentials.salt')
)
);
try {
$this->container->conf->write($this->container->loginManager->isLoggedIn());
} catch (Throwable $e) {
throw new ShaarliFrontException($e->getMessage(), 500, $e);
}
$this->saveSuccessMessage(t('Your password has been changed'));
return $response->write($this->render(TemplatePage::CHANGE_PASSWORD));
}
}

View file

@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Admin;
use Exception;
use Shaarli\Render\TemplatePage;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Class PluginsController
*
* Slim controller used to handle Shaarli plugins configuration page (display + save new config).
*/
class PluginsController extends ShaarliAdminController
{
/**
* GET /admin/plugins - Displays the configuration page
*/
public function index(Request $request, Response $response): Response
{
$pluginMeta = $this->container->pluginManager->getPluginsMeta();
// Split plugins into 2 arrays: ordered enabled plugins and disabled.
$enabledPlugins = array_filter($pluginMeta, function ($v) {
return ($v['order'] ?? false) !== false;
});
$enabledPlugins = load_plugin_parameter_values($enabledPlugins, $this->container->conf->get('plugins', []));
uasort(
$enabledPlugins,
function ($a, $b) {
return $a['order'] - $b['order'];
}
);
$disabledPlugins = array_filter($pluginMeta, function ($v) {
return ($v['order'] ?? false) === false;
});
$this->assignView('enabledPlugins', $enabledPlugins);
$this->assignView('disabledPlugins', $disabledPlugins);
$this->assignView(
'pagetitle',
t('Plugin Administration') .' - '. $this->container->conf->get('general.title', 'Shaarli')
);
return $response->write($this->render(TemplatePage::PLUGINS_ADMIN));
}
/**
* POST /admin/plugins - Update Shaarli's configuration
*/
public function save(Request $request, Response $response): Response
{
$this->checkToken($request);
try {
$parameters = $request->getParams() ?? [];
$this->executePageHooks('save_plugin_parameters', $parameters);
if (isset($parameters['parameters_form'])) {
unset($parameters['parameters_form']);
foreach ($parameters as $param => $value) {
$this->container->conf->set('plugins.'. $param, escape($value));
}
} else {
$this->container->conf->set('general.enabled_plugins', save_plugin_config($parameters));
}
$this->container->conf->write($this->container->loginManager->isLoggedIn());
$this->container->history->updateSettings();
$this->saveSuccessMessage(t('Setting successfully saved.'));
} catch (Exception $e) {
$this->saveErrorMessage(
t('Error while saving plugin configuration: ') . PHP_EOL . $e->getMessage()
);
}
return $this->redirect($response, '/admin/plugins');
}
}

View file

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Admin;
use Shaarli\Bookmark\BookmarkFilter;
use Shaarli\Security\SessionManager;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Class SessionFilterController
*
* Slim controller used to handle filters stored in the user session, such as visibility, etc.
*/
class SessionFilterController extends ShaarliAdminController
{
/**
* GET /admin/visibility: allows to display only public or only private bookmarks in linklist
*/
public function visibility(Request $request, Response $response, array $args): Response
{
if (false === $this->container->loginManager->isLoggedIn()) {
return $this->redirectFromReferer($request, $response, ['visibility']);
}
$newVisibility = $args['visibility'] ?? null;
if (false === in_array($newVisibility, [BookmarkFilter::$PRIVATE, BookmarkFilter::$PUBLIC], true)) {
$newVisibility = null;
}
$currentVisibility = $this->container->sessionManager->getSessionParameter(SessionManager::KEY_VISIBILITY);
// Visibility not set or not already expected value, set expected value, otherwise reset it
if ($newVisibility !== null && (null === $currentVisibility || $currentVisibility !== $newVisibility)) {
// See only public bookmarks
$this->container->sessionManager->setSessionParameter(
SessionManager::KEY_VISIBILITY,
$newVisibility
);
} else {
$this->container->sessionManager->deleteSessionParameter(SessionManager::KEY_VISIBILITY);
}
return $this->redirectFromReferer($request, $response, ['visibility']);
}
}

View file

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Admin;
use Shaarli\Container\ShaarliContainer;
use Shaarli\Front\Controller\Visitor\ShaarliVisitorController;
use Shaarli\Front\Exception\UnauthorizedException;
use Shaarli\Front\Exception\WrongTokenException;
use Shaarli\Security\SessionManager;
use Slim\Http\Request;
/**
* Class ShaarliAdminController
*
* All admin controllers (for logged in users) MUST extend this abstract class.
* It makes sure that the user is properly logged in, and otherwise throw an exception
* which will redirect to the login page.
*
* @package Shaarli\Front\Controller\Admin
*/
abstract class ShaarliAdminController extends ShaarliVisitorController
{
/**
* Any persistent action to the config or data store must check the XSRF token validity.
*/
protected function checkToken(Request $request): bool
{
if (!$this->container->sessionManager->checkToken($request->getParam('token'))) {
throw new WrongTokenException();
}
return true;
}
/**
* Save a SUCCESS message in user session, which will be displayed on any template page.
*/
protected function saveSuccessMessage(string $message): void
{
$this->saveMessage(SessionManager::KEY_SUCCESS_MESSAGES, $message);
}
/**
* Save a WARNING message in user session, which will be displayed on any template page.
*/
protected function saveWarningMessage(string $message): void
{
$this->saveMessage(SessionManager::KEY_WARNING_MESSAGES, $message);
}
/**
* Save an ERROR message in user session, which will be displayed on any template page.
*/
protected function saveErrorMessage(string $message): void
{
$this->saveMessage(SessionManager::KEY_ERROR_MESSAGES, $message);
}
/**
* Use the sessionManager to save the provided message using the proper type.
*
* @param string $type successed/warnings/errors
*/
protected function saveMessage(string $type, string $message): void
{
$messages = $this->container->sessionManager->getSessionParameter($type) ?? [];
$messages[] = $message;
$this->container->sessionManager->setSessionParameter($type, $messages);
}
}

View file

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Admin;
use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
use Shaarli\Render\TemplatePage;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Class ToolsController
*
* Slim controller used to handle thumbnails update.
*/
class ThumbnailsController extends ShaarliAdminController
{
/**
* GET /admin/thumbnails - Display thumbnails update page
*/
public function index(Request $request, Response $response): Response
{
$ids = [];
foreach ($this->container->bookmarkService->search() as $bookmark) {
// A note or not HTTP(S)
if ($bookmark->isNote() || !startsWith(strtolower($bookmark->getUrl()), 'http')) {
continue;
}
$ids[] = $bookmark->getId();
}
$this->assignView('ids', $ids);
$this->assignView(
'pagetitle',
t('Thumbnails update') .' - '. $this->container->conf->get('general.title', 'Shaarli')
);
return $response->write($this->render(TemplatePage::THUMBNAILS));
}
/**
* PATCH /admin/shaare/{id}/thumbnail-update - Route for AJAX calls
*/
public function ajaxUpdate(Request $request, Response $response, array $args): Response
{
$id = $args['id'] ?? null;
if (false === ctype_digit($id)) {
return $response->withStatus(400);
}
try {
$bookmark = $this->container->bookmarkService->get($id);
} catch (BookmarkNotFoundException $e) {
return $response->withStatus(404);
}
$bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl()));
$this->container->bookmarkService->set($bookmark);
return $response->withJson($this->container->formatterFactory->getFormatter('raw')->format($bookmark));
}
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Admin;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Class TokenController
*
* Endpoint used to retrieve a XSRF token. Useful for AJAX requests.
*/
class TokenController extends ShaarliAdminController
{
/**
* GET /admin/token
*/
public function getToken(Request $request, Response $response): Response
{
$response = $response->withHeader('Content-Type', 'text/plain');
return $response->write($this->container->sessionManager->generateToken());
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Admin;
use Shaarli\Render\TemplatePage;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Class ToolsController
*
* Slim controller used to display the tools page.
*/
class ToolsController extends ShaarliAdminController
{
public function index(Request $request, Response $response): Response
{
$data = [
'pageabsaddr' => index_url($this->container->environment),
'sslenabled' => is_https($this->container->environment),
];
$this->executePageHooks('render_tools', $data, TemplatePage::TOOLS);
foreach ($data as $key => $value) {
$this->assignView($key, $value);
}
$this->assignView('pagetitle', t('Tools') .' - '. $this->container->conf->get('general.title', 'Shaarli'));
return $response->write($this->render(TemplatePage::TOOLS));
}
}

View file

@ -0,0 +1,240 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Visitor;
use Shaarli\Bookmark\Bookmark;
use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
use Shaarli\Legacy\LegacyController;
use Shaarli\Legacy\UnknowLegacyRouteException;
use Shaarli\Render\TemplatePage;
use Shaarli\Thumbnailer;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Class BookmarkListController
*
* Slim controller used to render the bookmark list, the home page of Shaarli.
* It also displays permalinks, and process legacy routes based on GET parameters.
*/
class BookmarkListController extends ShaarliVisitorController
{
/**
* GET / - Displays the bookmark list, with optional filter parameters.
*/
public function index(Request $request, Response $response): Response
{
$legacyResponse = $this->processLegacyController($request, $response);
if (null !== $legacyResponse) {
return $legacyResponse;
}
$formatter = $this->container->formatterFactory->getFormatter();
$formatter->addContextData('base_path', $this->container->basePath);
$searchTags = escape(normalize_spaces($request->getParam('searchtags') ?? ''));
$searchTerm = escape(normalize_spaces($request->getParam('searchterm') ?? ''));;
// Filter bookmarks according search parameters.
$visibility = $this->container->sessionManager->getSessionParameter('visibility');
$search = [
'searchtags' => $searchTags,
'searchterm' => $searchTerm,
];
$linksToDisplay = $this->container->bookmarkService->search(
$search,
$visibility,
false,
!!$this->container->sessionManager->getSessionParameter('untaggedonly')
) ?? [];
// ---- Handle paging.
$keys = [];
foreach ($linksToDisplay as $key => $value) {
$keys[] = $key;
}
$linksPerPage = $this->container->sessionManager->getSessionParameter('LINKS_PER_PAGE', 20) ?: 20;
// Select articles according to paging.
$pageCount = (int) ceil(count($keys) / $linksPerPage) ?: 1;
$page = (int) $request->getParam('page') ?? 1;
$page = $page < 1 ? 1 : $page;
$page = $page > $pageCount ? $pageCount : $page;
// Start index.
$i = ($page - 1) * $linksPerPage;
$end = $i + $linksPerPage;
$linkDisp = [];
$save = false;
while ($i < $end && $i < count($keys)) {
$save = $this->updateThumbnail($linksToDisplay[$keys[$i]], false) || $save;
$link = $formatter->format($linksToDisplay[$keys[$i]]);
$linkDisp[$keys[$i]] = $link;
$i++;
}
if ($save) {
$this->container->bookmarkService->save();
}
// Compute paging navigation
$searchtagsUrl = $searchTags === '' ? '' : '&searchtags=' . urlencode($searchTags);
$searchtermUrl = $searchTerm === '' ? '' : '&searchterm=' . urlencode($searchTerm);
$previous_page_url = '';
if ($i !== count($keys)) {
$previous_page_url = '?page=' . ($page + 1) . $searchtermUrl . $searchtagsUrl;
}
$next_page_url = '';
if ($page > 1) {
$next_page_url = '?page=' . ($page - 1) . $searchtermUrl . $searchtagsUrl;
}
// Fill all template fields.
$data = array_merge(
$this->initializeTemplateVars(),
[
'previous_page_url' => $previous_page_url,
'next_page_url' => $next_page_url,
'page_current' => $page,
'page_max' => $pageCount,
'result_count' => count($linksToDisplay),
'search_term' => $searchTerm,
'search_tags' => $searchTags,
'visibility' => $visibility,
'links' => $linkDisp,
]
);
if (!empty($searchTerm) || !empty($searchTags)) {
$data['pagetitle'] = t('Search: ');
$data['pagetitle'] .= ! empty($searchTerm) ? $searchTerm . ' ' : '';
$bracketWrap = function ($tag) {
return '[' . $tag . ']';
};
$data['pagetitle'] .= ! empty($searchTags)
? implode(' ', array_map($bracketWrap, preg_split('/\s+/', $searchTags))) . ' '
: '';
$data['pagetitle'] .= '- ';
}
$data['pagetitle'] = ($data['pagetitle'] ?? '') . $this->container->conf->get('general.title', 'Shaarli');
$this->executePageHooks('render_linklist', $data, TemplatePage::LINKLIST);
$this->assignAllView($data);
return $response->write($this->render(TemplatePage::LINKLIST));
}
/**
* GET /shaare/{hash} - Display a single shaare
*/
public function permalink(Request $request, Response $response, array $args): Response
{
try {
$bookmark = $this->container->bookmarkService->findByHash($args['hash']);
} catch (BookmarkNotFoundException $e) {
$this->assignView('error_message', $e->getMessage());
return $response->write($this->render(TemplatePage::ERROR_404));
}
$this->updateThumbnail($bookmark);
$formatter = $this->container->formatterFactory->getFormatter();
$formatter->addContextData('base_path', $this->container->basePath);
$data = array_merge(
$this->initializeTemplateVars(),
[
'pagetitle' => $bookmark->getTitle() .' - '. $this->container->conf->get('general.title', 'Shaarli'),
'links' => [$formatter->format($bookmark)],
]
);
$this->executePageHooks('render_linklist', $data, TemplatePage::LINKLIST);
$this->assignAllView($data);
return $response->write($this->render(TemplatePage::LINKLIST));
}
/**
* Update the thumbnail of a single bookmark if necessary.
*/
protected function updateThumbnail(Bookmark $bookmark, bool $writeDatastore = true): bool
{
// Logged in, thumbnails enabled, not a note, is HTTP
// and (never retrieved yet or no valid cache file)
if ($this->container->loginManager->isLoggedIn()
&& $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE
&& false !== $bookmark->getThumbnail()
&& !$bookmark->isNote()
&& (null === $bookmark->getThumbnail() || !is_file($bookmark->getThumbnail()))
&& startsWith(strtolower($bookmark->getUrl()), 'http')
) {
$bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl()));
$this->container->bookmarkService->set($bookmark, $writeDatastore);
return true;
}
return false;
}
/**
* @return string[] Default template variables without values.
*/
protected function initializeTemplateVars(): array
{
return [
'previous_page_url' => '',
'next_page_url' => '',
'page_max' => '',
'search_tags' => '',
'result_count' => '',
];
}
/**
* Process legacy routes if necessary. They used query parameters.
* If no legacy routes is passed, return null.
*/
protected function processLegacyController(Request $request, Response $response): ?Response
{
// Legacy smallhash filter
$queryString = $this->container->environment['QUERY_STRING'] ?? null;
if (null !== $queryString && 1 === preg_match('/^([a-zA-Z0-9-_@]{6})($|&|#)/', $queryString, $match)) {
return $this->redirect($response, '/shaare/' . $match[1]);
}
// Legacy controllers (mostly used for redirections)
if (null !== $request->getQueryParam('do')) {
$legacyController = new LegacyController($this->container);
try {
return $legacyController->process($request, $response, $request->getQueryParam('do'));
} catch (UnknowLegacyRouteException $e) {
// We ignore legacy 404
return null;
}
}
// Legacy GET admin routes
$legacyGetRoutes = array_intersect(
LegacyController::LEGACY_GET_ROUTES,
array_keys($request->getQueryParams() ?? [])
);
if (1 === count($legacyGetRoutes)) {
$legacyController = new LegacyController($this->container);
return $legacyController->process($request, $response, $legacyGetRoutes[0]);
}
return null;
}
}

View file

@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Visitor;
use DateTime;
use DateTimeImmutable;
use Shaarli\Bookmark\Bookmark;
use Shaarli\Render\TemplatePage;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Class DailyController
*
* Slim controller used to render the daily page.
*/
class DailyController extends ShaarliVisitorController
{
public static $DAILY_RSS_NB_DAYS = 8;
/**
* Controller displaying all bookmarks published in a single day.
* It take a `day` date query parameter (format YYYYMMDD).
*/
public function index(Request $request, Response $response): Response
{
$day = $request->getQueryParam('day') ?? date('Ymd');
$availableDates = $this->container->bookmarkService->days();
$nbAvailableDates = count($availableDates);
$index = array_search($day, $availableDates);
if ($index === false) {
// no bookmarks for day, but at least one day with bookmarks
$day = $availableDates[$nbAvailableDates - 1] ?? $day;
$previousDay = $availableDates[$nbAvailableDates - 2] ?? '';
} else {
$previousDay = $availableDates[$index - 1] ?? '';
$nextDay = $availableDates[$index + 1] ?? '';
}
if ($day === date('Ymd')) {
$this->assignView('dayDesc', t('Today'));
} elseif ($day === date('Ymd', strtotime('-1 days'))) {
$this->assignView('dayDesc', t('Yesterday'));
}
try {
$linksToDisplay = $this->container->bookmarkService->filterDay($day);
} catch (\Exception $exc) {
$linksToDisplay = [];
}
$formatter = $this->container->formatterFactory->getFormatter();
$formatter->addContextData('base_path', $this->container->basePath);
// We pre-format some fields for proper output.
foreach ($linksToDisplay as $key => $bookmark) {
$linksToDisplay[$key] = $formatter->format($bookmark);
// This page is a bit specific, we need raw description to calculate the length
$linksToDisplay[$key]['formatedDescription'] = $linksToDisplay[$key]['description'];
$linksToDisplay[$key]['description'] = $bookmark->getDescription();
}
$dayDate = DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, $day.'_000000');
$data = [
'linksToDisplay' => $linksToDisplay,
'day' => $dayDate->getTimestamp(),
'dayDate' => $dayDate,
'previousday' => $previousDay ?? '',
'nextday' => $nextDay ?? '',
];
// Hooks are called before column construction so that plugins don't have to deal with columns.
$this->executePageHooks('render_daily', $data, TemplatePage::DAILY);
$data['cols'] = $this->calculateColumns($data['linksToDisplay']);
$this->assignAllView($data);
$mainTitle = $this->container->conf->get('general.title', 'Shaarli');
$this->assignView(
'pagetitle',
t('Daily') .' - '. format_date($dayDate, false) . ' - ' . $mainTitle
);
return $response->write($this->render(TemplatePage::DAILY));
}
/**
* Daily RSS feed: 1 RSS entry per day giving all the bookmarks on that day.
* Gives the last 7 days (which have bookmarks).
* This RSS feed cannot be filtered and does not trigger plugins yet.
*/
public function rss(Request $request, Response $response): Response
{
$response = $response->withHeader('Content-Type', 'application/rss+xml; charset=utf-8');
$pageUrl = page_url($this->container->environment);
$cache = $this->container->pageCacheManager->getCachePage($pageUrl);
$cached = $cache->cachedVersion();
if (!empty($cached)) {
return $response->write($cached);
}
$days = [];
foreach ($this->container->bookmarkService->search() as $bookmark) {
$day = $bookmark->getCreated()->format('Ymd');
// Stop iterating after DAILY_RSS_NB_DAYS entries
if (count($days) === static::$DAILY_RSS_NB_DAYS && !isset($days[$day])) {
break;
}
$days[$day][] = $bookmark;
}
// Build the RSS feed.
$indexUrl = escape(index_url($this->container->environment));
$formatter = $this->container->formatterFactory->getFormatter();
$formatter->addContextData('index_url', $indexUrl);
$dataPerDay = [];
/** @var Bookmark[] $bookmarks */
foreach ($days as $day => $bookmarks) {
$dayDatetime = DateTimeImmutable::createFromFormat(Bookmark::LINK_DATE_FORMAT, $day.'_000000');
$dataPerDay[$day] = [
'date' => $dayDatetime,
'date_rss' => $dayDatetime->format(DateTime::RSS),
'date_human' => format_date($dayDatetime, false, true),
'absolute_url' => $indexUrl . '/daily?day=' . $day,
'links' => [],
];
foreach ($bookmarks as $key => $bookmark) {
$dataPerDay[$day]['links'][$key] = $formatter->format($bookmark);
// Make permalink URL absolute
if ($bookmark->isNote()) {
$dataPerDay[$day]['links'][$key]['url'] = $indexUrl . $bookmark->getUrl();
}
}
}
$this->assignView('title', $this->container->conf->get('general.title', 'Shaarli'));
$this->assignView('index_url', $indexUrl);
$this->assignView('page_url', $pageUrl);
$this->assignView('hide_timestamps', $this->container->conf->get('privacy.hide_timestamps', false));
$this->assignView('days', $dataPerDay);
$rssContent = $this->render(TemplatePage::DAILY_RSS);
$cache->cache($rssContent);
return $response->write($rssContent);
}
/**
* We need to spread the articles on 3 columns.
* did not want to use a JavaScript lib like http://masonry.desandro.com/
* so I manually spread entries with a simple method: I roughly evaluate the
* height of a div according to title and description length.
*/
protected function calculateColumns(array $links): array
{
// Entries to display, for each column.
$columns = [[], [], []];
// Rough estimate of columns fill.
$fill = [0, 0, 0];
foreach ($links as $link) {
// Roughly estimate length of entry (by counting characters)
// Title: 30 chars = 1 line. 1 line is 30 pixels height.
// Description: 836 characters gives roughly 342 pixel height.
// This is not perfect, but it's usually OK.
$length = strlen($link['title'] ?? '') + (342 * strlen($link['description'] ?? '')) / 836;
if (! empty($link['thumbnail'])) {
$length += 100; // 1 thumbnails roughly takes 100 pixels height.
}
// Then put in column which is the less filled:
$smallest = min($fill); // find smallest value in array.
$index = array_search($smallest, $fill); // find index of this smallest value.
array_push($columns[$index], $link); // Put entry in this column.
$fill[$index] += $length;
}
return $columns;
}
}

View file

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Visitor;
use Shaarli\Front\Exception\ShaarliFrontException;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Controller used to render the error page, with a provided exception.
* It is actually used as a Slim error handler.
*/
class ErrorController extends ShaarliVisitorController
{
public function __invoke(Request $request, Response $response, \Throwable $throwable): Response
{
// Unknown error encountered
$this->container->pageBuilder->reset();
if ($throwable instanceof ShaarliFrontException) {
// Functional error
$this->assignView('message', nl2br($throwable->getMessage()));
$response = $response->withStatus($throwable->getCode());
} else {
// Internal error (any other Throwable)
if ($this->container->conf->get('dev.debug', false)) {
$this->assignView('message', $throwable->getMessage());
$this->assignView(
'stacktrace',
nl2br(get_class($throwable) .': '. PHP_EOL . $throwable->getTraceAsString())
);
} else {
$this->assignView('message', t('An unexpected error occurred.'));
}
$response = $response->withStatus(500);
}
return $response->write($this->render('error'));
}
}

View file

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Visitor;
use Shaarli\Feed\FeedBuilder;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Class FeedController
*
* Slim controller handling ATOM and RSS feed.
*/
class FeedController extends ShaarliVisitorController
{
public function atom(Request $request, Response $response): Response
{
return $this->processRequest(FeedBuilder::$FEED_ATOM, $request, $response);
}
public function rss(Request $request, Response $response): Response
{
return $this->processRequest(FeedBuilder::$FEED_RSS, $request, $response);
}
protected function processRequest(string $feedType, Request $request, Response $response): Response
{
$response = $response->withHeader('Content-Type', 'application/'. $feedType .'+xml; charset=utf-8');
$pageUrl = page_url($this->container->environment);
$cache = $this->container->pageCacheManager->getCachePage($pageUrl);
$cached = $cache->cachedVersion();
if (!empty($cached)) {
return $response->write($cached);
}
// Generate data.
$this->container->feedBuilder->setLocale(strtolower(setlocale(LC_COLLATE, 0)));
$this->container->feedBuilder->setHideDates($this->container->conf->get('privacy.hide_timestamps', false));
$this->container->feedBuilder->setUsePermalinks(
null !== $request->getParam('permalinks') || !$this->container->conf->get('feed.rss_permalinks')
);
$data = $this->container->feedBuilder->buildData($feedType, $request->getParams());
$this->executePageHooks('render_feed', $data, $feedType);
$this->assignAllView($data);
$content = $this->render('feed.'. $feedType);
$cache->cache($content);
return $response->write($content);
}
}

View file

@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Visitor;
use Shaarli\ApplicationUtils;
use Shaarli\Container\ShaarliContainer;
use Shaarli\Front\Exception\AlreadyInstalledException;
use Shaarli\Front\Exception\ResourcePermissionException;
use Shaarli\Languages;
use Shaarli\Security\SessionManager;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Slim controller used to render install page, and create initial configuration file.
*/
class InstallController extends ShaarliVisitorController
{
public const SESSION_TEST_KEY = 'session_tested';
public const SESSION_TEST_VALUE = 'Working';
public function __construct(ShaarliContainer $container)
{
parent::__construct($container);
if (is_file($this->container->conf->getConfigFileExt())) {
throw new AlreadyInstalledException();
}
}
/**
* Display the install template page.
* Also test file permissions and sessions beforehand.
*/
public function index(Request $request, Response $response): Response
{
// Before installation, we'll make sure that permissions are set properly, and sessions are working.
$this->checkPermissions();
if (static::SESSION_TEST_VALUE
!== $this->container->sessionManager->getSessionParameter(static::SESSION_TEST_KEY)
) {
$this->container->sessionManager->setSessionParameter(static::SESSION_TEST_KEY, static::SESSION_TEST_VALUE);
return $this->redirect($response, '/install/session-test');
}
[$continents, $cities] = generateTimeZoneData(timezone_identifiers_list(), date_default_timezone_get());
$this->assignView('continents', $continents);
$this->assignView('cities', $cities);
$this->assignView('languages', Languages::getAvailableLanguages());
return $response->write($this->render('install'));
}
/**
* Route checking that the session parameter has been properly saved between two distinct requests.
* If the session parameter is preserved, redirect to install template page, otherwise displays error.
*/
public function sessionTest(Request $request, Response $response): Response
{
// This part makes sure sessions works correctly.
// (Because on some hosts, session.save_path may not be set correctly,
// or we may not have write access to it.)
if (static::SESSION_TEST_VALUE
!== $this->container->sessionManager->getSessionParameter(static::SESSION_TEST_KEY)
) {
// 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, $this->container->sessionManager->getSavePath());
$this->assignView('message', $msg);
return $response->write($this->render('error'));
}
return $this->redirect($response, '/install');
}
/**
* Save installation form and initialize config file and datastore if necessary.
*/
public function save(Request $request, Response $response): Response
{
$timezone = 'UTC';
if (!empty($request->getParam('continent'))
&& !empty($request->getParam('city'))
&& isTimeZoneValid($request->getParam('continent'), $request->getParam('city'))
) {
$timezone = $request->getParam('continent') . '/' . $request->getParam('city');
}
$this->container->conf->set('general.timezone', $timezone);
$login = $request->getParam('setlogin');
$this->container->conf->set('credentials.login', $login);
$salt = sha1(uniqid('', true) .'_'. mt_rand());
$this->container->conf->set('credentials.salt', $salt);
$this->container->conf->set('credentials.hash', sha1($request->getParam('setpassword') . $login . $salt));
if (!empty($request->getParam('title'))) {
$this->container->conf->set('general.title', escape($request->getParam('title')));
} else {
$this->container->conf->set(
'general.title',
'Shared bookmarks on '.escape(index_url($this->container->environment))
);
}
$this->container->conf->set('translation.language', escape($request->getParam('language')));
$this->container->conf->set('updates.check_updates', !empty($request->getParam('updateCheck')));
$this->container->conf->set('api.enabled', !empty($request->getParam('enableApi')));
$this->container->conf->set(
'api.secret',
generate_api_secret(
$this->container->conf->get('credentials.login'),
$this->container->conf->get('credentials.salt')
)
);
$this->container->conf->set('general.header_link', $this->container->basePath . '/');
try {
// Everything is ok, let's create config file.
$this->container->conf->write($this->container->loginManager->isLoggedIn());
} catch (\Exception $e) {
$this->assignView('message', t('Error while writing config file after configuration update.'));
$this->assignView('stacktrace', $e->getMessage() . PHP_EOL . $e->getTraceAsString());
return $response->write($this->render('error'));
}
$this->container->sessionManager->setSessionParameter(
SessionManager::KEY_SUCCESS_MESSAGES,
[t('Shaarli is now configured. Please login and start shaaring your bookmarks!')]
);
return $this->redirect($response, '/login');
}
protected function checkPermissions(): bool
{
// Ensure Shaarli has proper access to its resources
$errors = ApplicationUtils::checkResourcePermissions($this->container->conf);
if (empty($errors)) {
return true;
}
$message = t('Insufficient permissions:') . PHP_EOL;
foreach ($errors as $error) {
$message .= PHP_EOL . $error;
}
throw new ResourcePermissionException($message);
}
}

View file

@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Visitor;
use Shaarli\Front\Exception\CantLoginException;
use Shaarli\Front\Exception\LoginBannedException;
use Shaarli\Front\Exception\WrongTokenException;
use Shaarli\Render\TemplatePage;
use Shaarli\Security\CookieManager;
use Shaarli\Security\SessionManager;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Class LoginController
*
* Slim controller used to render the login page.
*
* The login page is not available if the user is banned
* or if open shaarli setting is enabled.
*/
class LoginController extends ShaarliVisitorController
{
/**
* GET /login - Display the login page.
*/
public function index(Request $request, Response $response): Response
{
try {
$this->checkLoginState();
} catch (CantLoginException $e) {
return $this->redirect($response, '/');
}
if ($request->getParam('login') !== null) {
$this->assignView('username', escape($request->getParam('login')));
}
$returnUrl = $request->getParam('returnurl') ?? $this->container->environment['HTTP_REFERER'] ?? null;
$this
->assignView('returnurl', escape($returnUrl))
->assignView('remember_user_default', $this->container->conf->get('privacy.remember_user_default', true))
->assignView('pagetitle', t('Login') .' - '. $this->container->conf->get('general.title', 'Shaarli'))
;
return $response->write($this->render(TemplatePage::LOGIN));
}
/**
* POST /login - Process login
*/
public function login(Request $request, Response $response): Response
{
if (!$this->container->sessionManager->checkToken($request->getParam('token'))) {
throw new WrongTokenException();
}
try {
$this->checkLoginState();
} catch (CantLoginException $e) {
return $this->redirect($response, '/');
}
if (!$this->container->loginManager->checkCredentials(
$this->container->environment['REMOTE_ADDR'],
client_ip_id($this->container->environment),
$request->getParam('login'),
$request->getParam('password')
)
) {
$this->container->loginManager->handleFailedLogin($this->container->environment);
$this->container->sessionManager->setSessionParameter(
SessionManager::KEY_ERROR_MESSAGES,
[t('Wrong login/password.')]
);
// Call controller directly instead of unnecessary redirection
return $this->index($request, $response);
}
$this->container->loginManager->handleSuccessfulLogin($this->container->environment);
$cookiePath = $this->container->basePath . '/';
$expirationTime = $this->saveLongLastingSession($request, $cookiePath);
$this->renewUserSession($cookiePath, $expirationTime);
// Force referer from given return URL
$this->container->environment['HTTP_REFERER'] = $request->getParam('returnurl');
return $this->redirectFromReferer($request, $response, ['login', 'install']);
}
/**
* Make sure that the user is allowed to login and/or displaying the login page:
* - not already logged in
* - not open shaarli
* - not banned
*/
protected function checkLoginState(): bool
{
if ($this->container->loginManager->isLoggedIn()
|| $this->container->conf->get('security.open_shaarli', false)
) {
throw new CantLoginException();
}
if (true !== $this->container->loginManager->canLogin($this->container->environment)) {
throw new LoginBannedException();
}
return true;
}
/**
* @return int Session duration in seconds
*/
protected function saveLongLastingSession(Request $request, string $cookiePath): int
{
if (empty($request->getParam('longlastingsession'))) {
// Standard session expiration (=when browser closes)
$expirationTime = 0;
} else {
// Keep the session cookie even after the browser closes
$this->container->sessionManager->setStaySignedIn(true);
$expirationTime = $this->container->sessionManager->extendSession();
}
$this->container->cookieManager->setCookieParameter(
CookieManager::STAY_SIGNED_IN,
$this->container->loginManager->getStaySignedInToken(),
$expirationTime,
$cookiePath
);
return $expirationTime;
}
protected function renewUserSession(string $cookiePath, int $expirationTime): void
{
// Send cookie with the new expiration date to the browser
$this->container->sessionManager->destroy();
$this->container->sessionManager->cookieParameters(
$expirationTime,
$cookiePath,
$this->container->environment['SERVER_NAME']
);
$this->container->sessionManager->start();
$this->container->sessionManager->regenerateId(true);
}
}

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Visitor;
use Shaarli\Render\TemplatePage;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Class OpenSearchController
*
* Slim controller used to render open search template.
* This allows to add Shaarli as a search engine within the browser.
*/
class OpenSearchController extends ShaarliVisitorController
{
public function index(Request $request, Response $response): Response
{
$response = $response->withHeader('Content-Type', 'application/opensearchdescription+xml; charset=utf-8');
$this->assignView('serverurl', index_url($this->container->environment));
return $response->write($this->render(TemplatePage::OPEN_SEARCH));
}
}

View file

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Visitor;
use Shaarli\Front\Exception\ThumbnailsDisabledException;
use Shaarli\Render\TemplatePage;
use Shaarli\Thumbnailer;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Class PicturesWallController
*
* Slim controller used to render the pictures wall page.
* If thumbnails mode is set to NONE, we just render the template without any image.
*/
class PictureWallController extends ShaarliVisitorController
{
public function index(Request $request, Response $response): Response
{
if ($this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) === Thumbnailer::MODE_NONE) {
throw new ThumbnailsDisabledException();
}
$this->assignView(
'pagetitle',
t('Picture wall') .' - '. $this->container->conf->get('general.title', 'Shaarli')
);
// Optionally filter the results:
$links = $this->container->bookmarkService->search($request->getQueryParams());
$linksToDisplay = [];
// Get only bookmarks which have a thumbnail.
// Note: we do not retrieve thumbnails here, the request is too heavy.
$formatter = $this->container->formatterFactory->getFormatter('raw');
foreach ($links as $key => $link) {
if (!empty($link->getThumbnail())) {
$linksToDisplay[] = $formatter->format($link);
}
}
$data = ['linksToDisplay' => $linksToDisplay];
$this->executePageHooks('render_picwall', $data, TemplatePage::PICTURE_WALL);
foreach ($data as $key => $value) {
$this->assignView($key, $value);
}
return $response->write($this->render(TemplatePage::PICTURE_WALL));
}
}

View file

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Visitor;
use Shaarli\Security\SessionManager;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Slim controller used to handle filters stored in the visitor session, links per page, etc.
*/
class PublicSessionFilterController extends ShaarliVisitorController
{
/**
* GET /links-per-page: set the number of bookmarks to display per page in homepage
*/
public function linksPerPage(Request $request, Response $response): Response
{
$linksPerPage = $request->getParam('nb') ?? null;
if (null === $linksPerPage || false === is_numeric($linksPerPage)) {
$linksPerPage = $this->container->conf->get('general.links_per_page', 20);
}
$this->container->sessionManager->setSessionParameter(
SessionManager::KEY_LINKS_PER_PAGE,
abs(intval($linksPerPage))
);
return $this->redirectFromReferer($request, $response, ['linksperpage'], ['nb']);
}
/**
* GET /untagged-only: allows to display only bookmarks without any tag
*/
public function untaggedOnly(Request $request, Response $response): Response
{
$this->container->sessionManager->setSessionParameter(
SessionManager::KEY_UNTAGGED_ONLY,
empty($this->container->sessionManager->getSessionParameter(SessionManager::KEY_UNTAGGED_ONLY))
);
return $this->redirectFromReferer($request, $response, ['untaggedonly', 'untagged-only']);
}
}

View file

@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Visitor;
use Shaarli\Bookmark\BookmarkFilter;
use Shaarli\Container\ShaarliContainer;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Class ShaarliVisitorController
*
* All controllers accessible by visitors (non logged in users) should extend this abstract class.
* Contains a few helper function for template rendering, plugins, etc.
*
* @package Shaarli\Front\Controller\Visitor
*/
abstract class ShaarliVisitorController
{
/** @var ShaarliContainer */
protected $container;
/** @param ShaarliContainer $container Slim container (extended for attribute completion). */
public function __construct(ShaarliContainer $container)
{
$this->container = $container;
}
/**
* Assign variables to RainTPL template through the PageBuilder.
*
* @param mixed $value Value to assign to the template
*/
protected function assignView(string $name, $value): self
{
$this->container->pageBuilder->assign($name, $value);
return $this;
}
/**
* Assign variables to RainTPL template through the PageBuilder.
*
* @param mixed $data Values to assign to the template and their keys
*/
protected function assignAllView(array $data): self
{
foreach ($data as $key => $value) {
$this->assignView($key, $value);
}
return $this;
}
protected function render(string $template): string
{
$this->assignView('linkcount', $this->container->bookmarkService->count(BookmarkFilter::$ALL));
$this->assignView('privateLinkcount', $this->container->bookmarkService->count(BookmarkFilter::$PRIVATE));
$this->assignView('plugin_errors', $this->container->pluginManager->getErrors());
$this->executeDefaultHooks($template);
return $this->container->pageBuilder->render($template, $this->container->basePath);
}
/**
* Call plugin hooks for header, footer and includes, specifying which page will be rendered.
* Then assign generated data to RainTPL.
*/
protected function executeDefaultHooks(string $template): void
{
$common_hooks = [
'includes',
'header',
'footer',
];
foreach ($common_hooks as $name) {
$pluginData = [];
$this->container->pluginManager->executeHooks(
'render_' . $name,
$pluginData,
[
'target' => $template,
'loggedin' => $this->container->loginManager->isLoggedIn(),
'basePath' => $this->container->basePath,
]
);
$this->assignView('plugins_' . $name, $pluginData);
}
}
protected function executePageHooks(string $hook, array &$data, string $template = null): void
{
$params = [
'target' => $template,
'loggedin' => $this->container->loginManager->isLoggedIn(),
'basePath' => $this->container->basePath,
];
$this->container->pluginManager->executeHooks(
$hook,
$data,
$params
);
}
/**
* Simple helper which prepend the base path to redirect path.
*
* @param Response $response
* @param string $path Absolute path, e.g.: `/`, or `/admin/shaare/123` regardless of install directory
*
* @return Response updated
*/
protected function redirect(Response $response, string $path): Response
{
return $response->withRedirect($this->container->basePath . $path);
}
/**
* Generates a redirection to the previous page, based on the HTTP_REFERER.
* It fails back to the home page.
*
* @param array $loopTerms Terms to remove from path and query string to prevent direction loop.
* @param array $clearParams List of parameter to remove from the query string of the referrer.
*/
protected function redirectFromReferer(
Request $request,
Response $response,
array $loopTerms = [],
array $clearParams = [],
string $anchor = null
): Response {
$defaultPath = $this->container->basePath . '/';
$referer = $this->container->environment['HTTP_REFERER'] ?? null;
if (null !== $referer) {
$currentUrl = parse_url($referer);
parse_str($currentUrl['query'] ?? '', $params);
$path = $currentUrl['path'] ?? $defaultPath;
} else {
$params = [];
$path = $defaultPath;
}
// Prevent redirection loop
if (isset($currentUrl)) {
foreach ($clearParams as $value) {
unset($params[$value]);
}
$checkQuery = implode('', array_keys($params));
foreach ($loopTerms as $value) {
if (strpos($path . $checkQuery, $value) !== false) {
$params = [];
$path = $defaultPath;
break;
}
}
}
$queryString = count($params) > 0 ? '?'. http_build_query($params) : '';
$anchor = $anchor ? '#' . $anchor : '';
return $response->withRedirect($path . $queryString . $anchor);
}
}

View file

@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Visitor;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Class TagCloud
*
* Slim controller used to render the tag cloud and tag list pages.
*/
class TagCloudController extends ShaarliVisitorController
{
protected const TYPE_CLOUD = 'cloud';
protected const TYPE_LIST = 'list';
/**
* Display the tag cloud through the template engine.
* This controller a few filters:
* - Visibility stored in the session for logged in users
* - `searchtags` query parameter: will return tags associated with filter in at least one bookmark
*/
public function cloud(Request $request, Response $response): Response
{
return $this->processRequest(static::TYPE_CLOUD, $request, $response);
}
/**
* Display the tag list through the template engine.
* This controller a few filters:
* - Visibility stored in the session for logged in users
* - `searchtags` query parameter: will return tags associated with filter in at least one bookmark
* - `sort` query parameters:
* + `usage` (default): most used tags first
* + `alpha`: alphabetical order
*/
public function list(Request $request, Response $response): Response
{
return $this->processRequest(static::TYPE_LIST, $request, $response);
}
/**
* Process the request for both tag cloud and tag list endpoints.
*/
protected function processRequest(string $type, Request $request, Response $response): Response
{
if ($this->container->loginManager->isLoggedIn() === true) {
$visibility = $this->container->sessionManager->getSessionParameter('visibility');
}
$sort = $request->getQueryParam('sort');
$searchTags = $request->getQueryParam('searchtags');
$filteringTags = $searchTags !== null ? explode(' ', $searchTags) : [];
$tags = $this->container->bookmarkService->bookmarksCountPerTag($filteringTags, $visibility ?? null);
if (static::TYPE_CLOUD === $type || 'alpha' === $sort) {
// TODO: the sorting should be handled by bookmarkService instead of the controller
alphabetical_sort($tags, false, true);
}
if (static::TYPE_CLOUD === $type) {
$tags = $this->formatTagsForCloud($tags);
}
$searchTags = implode(' ', escape($filteringTags));
$data = [
'search_tags' => $searchTags,
'tags' => $tags,
];
$this->executePageHooks('render_tag' . $type, $data, 'tag.' . $type);
$this->assignAllView($data);
$searchTags = !empty($searchTags) ? $searchTags .' - ' : '';
$this->assignView(
'pagetitle',
$searchTags . t('Tag '. $type) .' - '. $this->container->conf->get('general.title', 'Shaarli')
);
return $response->write($this->render('tag.' . $type));
}
/**
* Format the tags array for the tag cloud template.
*
* @param array<string, int> $tags List of tags as key with count as value
*
* @return mixed[] List of tags as key, with count and expected font size in a subarray
*/
protected function formatTagsForCloud(array $tags): array
{
// We sort tags alphabetically, then choose a font size according to count.
// First, find max value.
$maxCount = count($tags) > 0 ? max($tags) : 0;
$logMaxCount = $maxCount > 1 ? log($maxCount, 30) : 1;
$tagList = [];
foreach ($tags as $key => $value) {
// Tag font size scaling:
// default 15 and 30 logarithm bases affect scaling,
// 2.2 and 0.8 are arbitrary font sizes in em.
$size = log($value, 15) / $logMaxCount * 2.2 + 0.8;
$tagList[$key] = [
'count' => $value,
'size' => number_format($size, 2, '.', ''),
];
}
return $tagList;
}
}

View file

@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Visitor;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Class TagController
*
* Slim controller handle tags.
*/
class TagController extends ShaarliVisitorController
{
/**
* Add another tag in the current search through an HTTP redirection.
*
* @param array $args Should contain `newTag` key as tag to add to current search
*/
public function addTag(Request $request, Response $response, array $args): Response
{
$newTag = $args['newTag'] ?? null;
$referer = $this->container->environment['HTTP_REFERER'] ?? null;
// In case browser does not send HTTP_REFERER, we search a single tag
if (null === $referer) {
if (null !== $newTag) {
return $this->redirect($response, '/?searchtags='. urlencode($newTag));
}
return $this->redirect($response, '/');
}
$currentUrl = parse_url($referer);
parse_str($currentUrl['query'] ?? '', $params);
if (null === $newTag) {
return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params));
}
// Prevent redirection loop
if (isset($params['addtag'])) {
unset($params['addtag']);
}
// Check if this tag is already in the search query and ignore it if it is.
// Each tag is always separated by a space
$currentTags = isset($params['searchtags']) ? explode(' ', $params['searchtags']) : [];
$addtag = true;
foreach ($currentTags as $value) {
if ($value === $newTag) {
$addtag = false;
break;
}
}
// Append the tag if necessary
if (true === $addtag) {
$currentTags[] = trim($newTag);
}
$params['searchtags'] = trim(implode(' ', $currentTags));
// We also remove page (keeping the same page has no sense, since the results are different)
unset($params['page']);
return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params));
}
/**
* Remove a tag from the current search through an HTTP redirection.
*
* @param array $args Should contain `tag` key as tag to remove from current search
*/
public function removeTag(Request $request, Response $response, array $args): Response
{
$referer = $this->container->environment['HTTP_REFERER'] ?? null;
// If the referrer is not provided, we can update the search, so we failback on the bookmark list
if (empty($referer)) {
return $this->redirect($response, '/');
}
$tagToRemove = $args['tag'] ?? null;
$currentUrl = parse_url($referer);
parse_str($currentUrl['query'] ?? '', $params);
if (null === $tagToRemove) {
return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params));
}
// Prevent redirection loop
if (isset($params['removetag'])) {
unset($params['removetag']);
}
if (isset($params['searchtags'])) {
$tags = explode(' ', $params['searchtags']);
// Remove value from array $tags.
$tags = array_diff($tags, [$tagToRemove]);
$params['searchtags'] = implode(' ', $tags);
if (empty($params['searchtags'])) {
unset($params['searchtags']);
}
// We also remove page (keeping the same page has no sense, since the results are different)
unset($params['page']);
}
$queryParams = count($params) > 0 ? '?' . http_build_query($params) : '';
return $response->withRedirect(($currentUrl['path'] ?? './') . $queryParams);
}
}

View file

@ -1,48 +0,0 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller;
use Shaarli\Front\Exception\LoginBannedException;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Class LoginController
*
* Slim controller used to render the login page.
*
* The login page is not available if the user is banned
* or if open shaarli setting is enabled.
*
* @package Front\Controller
*/
class LoginController extends ShaarliController
{
public function index(Request $request, Response $response): Response
{
if ($this->container->loginManager->isLoggedIn()
|| $this->container->conf->get('security.open_shaarli', false)
) {
return $response->withRedirect('./');
}
$userCanLogin = $this->container->loginManager->canLogin($request->getServerParams());
if ($userCanLogin !== true) {
throw new LoginBannedException();
}
if ($request->getParam('username') !== null) {
$this->assignView('username', escape($request->getParam('username')));
}
$this
->assignView('returnurl', escape($request->getServerParam('HTTP_REFERER')))
->assignView('remember_user_default', $this->container->conf->get('privacy.remember_user_default', true))
->assignView('pagetitle', t('Login') .' - '. $this->container->conf->get('general.title', 'Shaarli'))
;
return $response->write($this->render('loginform'));
}
}

View file

@ -1,69 +0,0 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller;
use Shaarli\Bookmark\BookmarkFilter;
use Shaarli\Container\ShaarliContainer;
abstract class ShaarliController
{
/** @var ShaarliContainer */
protected $container;
/** @param ShaarliContainer $container Slim container (extended for attribute completion). */
public function __construct(ShaarliContainer $container)
{
$this->container = $container;
}
/**
* Assign variables to RainTPL template through the PageBuilder.
*
* @param mixed $value Value to assign to the template
*/
protected function assignView(string $name, $value): self
{
$this->container->pageBuilder->assign($name, $value);
return $this;
}
protected function render(string $template): string
{
$this->assignView('linkcount', $this->container->bookmarkService->count(BookmarkFilter::$ALL));
$this->assignView('privateLinkcount', $this->container->bookmarkService->count(BookmarkFilter::$PRIVATE));
$this->assignView('plugin_errors', $this->container->pluginManager->getErrors());
$this->executeDefaultHooks($template);
return $this->container->pageBuilder->render($template);
}
/**
* Call plugin hooks for header, footer and includes, specifying which page will be rendered.
* Then assign generated data to RainTPL.
*/
protected function executeDefaultHooks(string $template): void
{
$common_hooks = [
'includes',
'header',
'footer',
];
foreach ($common_hooks as $name) {
$plugin_data = [];
$this->container->pluginManager->executeHooks(
'render_' . $name,
$plugin_data,
[
'target' => $template,
'loggedin' => $this->container->loginManager->isLoggedIn()
]
);
$this->assignView('plugins_' . $name, $plugin_data);
}
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Exception;
class AlreadyInstalledException extends ShaarliFrontException
{
public function __construct()
{
$message = t('Shaarli has already been installed. Login to edit the configuration.');
parent::__construct($message, 401);
}
}

View file

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Exception;
class CantLoginException extends \Exception
{
}

View file

@ -4,7 +4,7 @@
namespace Shaarli\Front\Exception;
class LoginBannedException extends ShaarliException
class LoginBannedException extends ShaarliFrontException
{
public function __construct()
{

View file

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Exception;
/**
* Class OpenShaarliPasswordException
*
* Raised if the user tries to change the admin password on an open shaarli instance.
*/
class OpenShaarliPasswordException extends ShaarliFrontException
{
public function __construct()
{
parent::__construct(t('You are not supposed to change a password on an Open Shaarli.'), 403);
}
}

View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Exception;
class ResourcePermissionException extends ShaarliFrontException
{
public function __construct(string $message)
{
parent::__construct($message, 500);
}
}

View file

@ -9,11 +9,11 @@
/**
* Class ShaarliException
*
* Abstract exception class used to defined any custom exception thrown during front rendering.
* Exception class used to defined any custom exception thrown during front rendering.
*
* @package Front\Exception
*/
abstract class ShaarliException extends \Exception
class ShaarliFrontException extends \Exception
{
/** Override parent constructor to force $message and $httpCode parameters to be set. */
public function __construct(string $message, int $httpCode, Throwable $previous = null)

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Exception;
class ThumbnailsDisabledException extends ShaarliFrontException
{
public function __construct()
{
$message = t('Picture wall unavailable (thumbnails are disabled).');
parent::__construct($message, 400);
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Exception;
/**
* Class UnauthorizedException
*
* Exception raised if the user tries to access a ShaarliAdminController while logged out.
*/
class UnauthorizedException extends \Exception
{
}

View file

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Exception;
/**
* Class OpenShaarliPasswordException
*
* Raised if the user tries to perform an action with an invalid XSRF token.
*/
class WrongTokenException extends ShaarliFrontException
{
public function __construct()
{
parent::__construct(t('Wrong token.'), 403);
}
}

View file

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Shaarli\Http;
/**
* Class HttpAccess
*
* This is mostly an OOP wrapper for HTTP functions defined in `HttpUtils`.
* It is used as dependency injection in Shaarli's container.
*
* @package Shaarli\Http
*/
class HttpAccess
{
public function getHttpResponse($url, $timeout = 30, $maxBytes = 4194304, $curlWriteFunction = null)
{
return get_http_response($url, $timeout, $maxBytes, $curlWriteFunction);
}
public function getCurlDownloadCallback(
&$charset,
&$title,
&$description,
&$keywords,
$retrieveDescription,
$curlGetInfo = 'curl_getinfo'
) {
return get_curl_download_callback(
$charset,
$title,
$description,
$keywords,
$retrieveDescription,
$curlGetInfo
);
}
}

View file

@ -369,7 +369,7 @@ function server_url($server)
*/
function index_url($server)
{
$scriptname = $server['SCRIPT_NAME'];
$scriptname = $server['SCRIPT_NAME'] ?? '';
if (endsWith($scriptname, 'index.php')) {
$scriptname = substr($scriptname, 0, -9);
}
@ -377,7 +377,7 @@ function index_url($server)
}
/**
* Returns the absolute URL of the current script, with the query
* Returns the absolute URL of the current script, with current route and query
*
* If the resource is "index.php", then it is removed (for better-looking URLs)
*
@ -387,10 +387,17 @@ function index_url($server)
*/
function page_url($server)
{
if (! empty($server['QUERY_STRING'])) {
return index_url($server).'?'.$server['QUERY_STRING'];
$scriptname = $server['SCRIPT_NAME'] ?? '';
if (endsWith($scriptname, 'index.php')) {
$scriptname = substr($scriptname, 0, -9);
}
return index_url($server);
$route = ltrim($server['REQUEST_URI'] ?? '', $scriptname);
if (! empty($server['QUERY_STRING'])) {
return index_url($server) . $route . '?' . $server['QUERY_STRING'];
}
return index_url($server) . $route;
}
/**
@ -477,3 +484,109 @@ function is_https($server)
return ! empty($server['HTTPS']);
}
/**
* Get cURL callback function for CURLOPT_WRITEFUNCTION
*
* @param string $charset to extract from the downloaded page (reference)
* @param string $title to extract from the downloaded page (reference)
* @param string $description to extract from the downloaded page (reference)
* @param string $keywords to extract from the downloaded page (reference)
* @param bool $retrieveDescription Automatically tries to retrieve description and keywords from HTML content
* @param string $curlGetInfo Optionally overrides curl_getinfo function
*
* @return Closure
*/
function get_curl_download_callback(
&$charset,
&$title,
&$description,
&$keywords,
$retrieveDescription,
$curlGetInfo = 'curl_getinfo'
) {
$isRedirected = false;
$currentChunk = 0;
$foundChunk = null;
/**
* 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 (
$retrieveDescription,
$curlGetInfo,
&$charset,
&$title,
&$description,
&$keywords,
&$isRedirected,
&$currentChunk,
&$foundChunk
) {
$currentChunk++;
$responseCode = $curlGetInfo($ch, CURLINFO_RESPONSE_CODE);
if (!empty($responseCode) && in_array($responseCode, [301, 302])) {
$isRedirected = true;
return strlen($data);
}
if (!empty($responseCode) && $responseCode !== 200) {
return false;
}
// After a redirection, the content type will keep the previous request value
// until it finds the next content-type header.
if (! $isRedirected || strpos(strtolower($data), 'content-type') !== false) {
$contentType = $curlGetInfo($ch, CURLINFO_CONTENT_TYPE);
}
if (!empty($contentType) && strpos($contentType, 'text/html') === false) {
return false;
}
if (!empty($contentType) && empty($charset)) {
$charset = header_extract_charset($contentType);
}
if (empty($charset)) {
$charset = html_extract_charset($data);
}
if (empty($title)) {
$title = html_extract_title($data);
$foundChunk = ! empty($title) ? $currentChunk : $foundChunk;
}
if ($retrieveDescription && empty($description)) {
$description = html_extract_tag('description', $data);
$foundChunk = ! empty($description) ? $currentChunk : $foundChunk;
}
if ($retrieveDescription && empty($keywords)) {
$keywords = html_extract_tag('keywords', $data);
if (! empty($keywords)) {
$foundChunk = $currentChunk;
// Keywords use the format tag1, tag2 multiple words, tag
// So we format them to match Shaarli's separator and glue multiple words with '-'
$keywords = implode(' ', array_map(function($keyword) {
return implode('-', preg_split('/\s+/', trim($keyword)));
}, explode(',', $keywords)));
}
}
// We got everything we want, stop the download.
// If we already found either the title, description or keywords,
// it's highly unlikely that we'll found the other metas further than
// in the same chunk of data or the next one. So we also stop the download after that.
if ((!empty($responseCode) && !empty($contentType) && !empty($charset)) && $foundChunk !== null
&& (! $retrieveDescription
|| $foundChunk < $currentChunk
|| (!empty($title) && !empty($description) && !empty($keywords))
)
) {
return false;
}
return strlen($data);
};
}

View file

@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace Shaarli\Legacy;
use Shaarli\Feed\FeedBuilder;
use Shaarli\Front\Controller\Visitor\ShaarliVisitorController;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* We use this to maintain legacy routes, and redirect requests to the corresponding Slim route.
* Only public routes, and both `?addlink` and `?post` were kept here.
* Other routes will just display the linklist.
*
* @deprecated
*/
class LegacyController extends ShaarliVisitorController
{
/** @var string[] Both `?post` and `?addlink` do not use `?do=` format. */
public const LEGACY_GET_ROUTES = [
'post',
'addlink',
];
/**
* This method will call `$action` method, which will redirect to corresponding Slim route.
*/
public function process(Request $request, Response $response, string $action): Response
{
if (!method_exists($this, $action)) {
throw new UnknowLegacyRouteException();
}
return $this->{$action}($request, $response);
}
/** Legacy route: ?post= */
public function post(Request $request, Response $response): Response
{
$parameters = count($request->getQueryParams()) > 0 ? '?' . http_build_query($request->getQueryParams()) : '';
if (!$this->container->loginManager->isLoggedIn()) {
return $this->redirect($response, '/login' . $parameters);
}
return $this->redirect($response, '/admin/shaare' . $parameters);
}
/** Legacy route: ?addlink= */
protected function addlink(Request $request, Response $response): Response
{
if (!$this->container->loginManager->isLoggedIn()) {
return $this->redirect($response, '/login');
}
return $this->redirect($response, '/admin/add-shaare');
}
/** Legacy route: ?do=login */
protected function login(Request $request, Response $response): Response
{
return $this->redirect($response, '/login');
}
/** Legacy route: ?do=logout */
protected function logout(Request $request, Response $response): Response
{
return $this->redirect($response, '/admin/logout');
}
/** Legacy route: ?do=picwall */
protected function picwall(Request $request, Response $response): Response
{
return $this->redirect($response, '/picture-wall');
}
/** Legacy route: ?do=tagcloud */
protected function tagcloud(Request $request, Response $response): Response
{
return $this->redirect($response, '/tags/cloud');
}
/** Legacy route: ?do=taglist */
protected function taglist(Request $request, Response $response): Response
{
return $this->redirect($response, '/tags/list');
}
/** Legacy route: ?do=daily */
protected function daily(Request $request, Response $response): Response
{
$dayParam = !empty($request->getParam('day')) ? '?day=' . escape($request->getParam('day')) : '';
return $this->redirect($response, '/daily' . $dayParam);
}
/** Legacy route: ?do=rss */
protected function rss(Request $request, Response $response): Response
{
return $this->feed($request, $response, FeedBuilder::$FEED_RSS);
}
/** Legacy route: ?do=atom */
protected function atom(Request $request, Response $response): Response
{
return $this->feed($request, $response, FeedBuilder::$FEED_ATOM);
}
/** Legacy route: ?do=opensearch */
protected function opensearch(Request $request, Response $response): Response
{
return $this->redirect($response, '/open-search');
}
/** Legacy route: ?do=dailyrss */
protected function dailyrss(Request $request, Response $response): Response
{
return $this->redirect($response, '/daily-rss');
}
/** Legacy route: ?do=feed */
protected function feed(Request $request, Response $response, string $feedType): Response
{
$parameters = count($request->getQueryParams()) > 0 ? '?' . http_build_query($request->getQueryParams()) : '';
return $this->redirect($response, '/feed/' . $feedType . $parameters);
}
}

View file

@ -9,6 +9,7 @@
use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
use Shaarli\Exceptions\IOException;
use Shaarli\FileUtils;
use Shaarli\Render\PageCacheManager;
/**
* Data storage for bookmarks.
@ -352,7 +353,8 @@ public function save($pageCacheDir)
$this->write();
invalidateCaches($pageCacheDir);
$pageCacheManager = new PageCacheManager($pageCacheDir, $this->loggedIn);
$pageCacheManager->invalidateCaches();
}
/**

View file

@ -1,12 +1,15 @@
<?php
namespace Shaarli;
namespace Shaarli\Legacy;
/**
* Class Router
*
* (only displayable pages here)
*
* @deprecated
*/
class Router
class LegacyRouter
{
public static $AJAX_THUMB_UPDATE = 'ajax_thumb_update';

View file

@ -10,9 +10,9 @@
use Shaarli\ApplicationUtils;
use Shaarli\Bookmark\Bookmark;
use Shaarli\Bookmark\BookmarkArray;
use Shaarli\Bookmark\LinkDB;
use Shaarli\Bookmark\BookmarkFilter;
use Shaarli\Bookmark\BookmarkIO;
use Shaarli\Bookmark\LinkDB;
use Shaarli\Config\ConfigJson;
use Shaarli\Config\ConfigManager;
use Shaarli\Config\ConfigPhp;
@ -534,7 +534,8 @@ public function updateMethodWebThumbnailer()
if ($thumbnailsEnabled) {
$this->session['warnings'][] = t(
'You have enabled or changed thumbnails mode. <a href="?do=thumbs_update">Please synchronize them</a>.'
t('You have enabled or changed thumbnails mode.') .
'<a href="./admin/thumbnails">' . t('Please synchronize them.') . '</a>'
);
}

View file

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Shaarli\Legacy;
class UnknowLegacyRouteException extends \Exception
{
}

View file

@ -6,6 +6,7 @@
use DateTimeZone;
use Exception;
use Katzgrau\KLogger\Logger;
use Psr\Http\Message\UploadedFileInterface;
use Psr\Log\LogLevel;
use Shaarli\Bookmark\Bookmark;
use Shaarli\Bookmark\BookmarkServiceInterface;
@ -16,10 +17,24 @@
/**
* Utilities to import and export bookmarks using the Netscape format
* TODO: Not static, use a container.
*/
class NetscapeBookmarkUtils
{
/** @var BookmarkServiceInterface */
protected $bookmarkService;
/** @var ConfigManager */
protected $conf;
/** @var History */
protected $history;
public function __construct(BookmarkServiceInterface $bookmarkService, ConfigManager $conf, History $history)
{
$this->bookmarkService = $bookmarkService;
$this->conf = $conf;
$this->history = $history;
}
/**
* Filters bookmarks and adds Netscape-formatted fields
@ -28,18 +43,16 @@ class NetscapeBookmarkUtils
* - timestamp link addition date, using the Unix epoch format
* - taglist comma-separated tag list
*
* @param BookmarkServiceInterface $bookmarkService Link datastore
* @param BookmarkFormatter $formatter instance
* @param string $selection Which bookmarks to export: (all|private|public)
* @param bool $prependNoteUrl Prepend note permalinks with the server's URL
* @param string $indexUrl Absolute URL of the Shaarli index page
*
* @return array The bookmarks to be exported, with additional fields
*@throws Exception Invalid export selection
*
* @throws Exception Invalid export selection
*/
public static function filterAndFormat(
$bookmarkService,
public function filterAndFormat(
$formatter,
$selection,
$prependNoteUrl,
@ -51,11 +64,11 @@ public static function filterAndFormat(
}
$bookmarkLinks = array();
foreach ($bookmarkService->search([], $selection) as $bookmark) {
foreach ($this->bookmarkService->search([], $selection) as $bookmark) {
$link = $formatter->format($bookmark);
$link['taglist'] = implode(',', $bookmark->getTags());
if ($bookmark->isNote() && $prependNoteUrl) {
$link['url'] = $indexUrl . $link['url'];
$link['url'] = rtrim($indexUrl, '/') . '/' . ltrim($link['url'], '/');
}
$bookmarkLinks[] = $link;
@ -64,61 +77,23 @@ public static function filterAndFormat(
return $bookmarkLinks;
}
/**
* Generates an import status summary
*
* @param string $filename name of the file to import
* @param int $filesize size of the file to import
* @param int $importCount how many bookmarks were imported
* @param int $overwriteCount how many bookmarks were overwritten
* @param int $skipCount how many bookmarks were skipped
* @param int $duration how many seconds did the import take
*
* @return string Summary of the bookmark import status
*/
private static function importStatus(
$filename,
$filesize,
$importCount = 0,
$overwriteCount = 0,
$skipCount = 0,
$duration = 0
) {
$status = sprintf(t('File %s (%d bytes) '), $filename, $filesize);
if ($importCount == 0 && $overwriteCount == 0 && $skipCount == 0) {
$status .= t('has an unknown file format. Nothing was imported.');
} else {
$status .= vsprintf(
t(
'was successfully processed in %d seconds: '
. '%d bookmarks imported, %d bookmarks overwritten, %d bookmarks skipped.'
),
[$duration, $importCount, $overwriteCount, $skipCount]
);
}
return $status;
}
/**
* Imports Web bookmarks from an uploaded Netscape bookmark dump
*
* @param array $post Server $_POST parameters
* @param array $files Server $_FILES parameters
* @param BookmarkServiceInterface $bookmarkService Loaded LinkDB instance
* @param ConfigManager $conf instance
* @param History $history History instance
* @param UploadedFileInterface $file File in PSR-7 object format
*
* @return string Summary of the bookmark import status
*/
public static function import($post, $files, $bookmarkService, $conf, $history)
public function import($post, UploadedFileInterface $file)
{
$start = time();
$filename = $files['filetoupload']['name'];
$filesize = $files['filetoupload']['size'];
$data = file_get_contents($files['filetoupload']['tmp_name']);
$filename = $file->getClientFilename();
$filesize = $file->getSize();
$data = (string) $file->getStream();
if (preg_match('/<!DOCTYPE NETSCAPE-Bookmark-file-1>/i', $data) === 0) {
return self::importStatus($filename, $filesize);
return $this->importStatus($filename, $filesize);
}
// Overwrite existing bookmarks?
@ -141,11 +116,11 @@ public static function import($post, $files, $bookmarkService, $conf, $history)
true, // nested tag support
$defaultTags, // additional user-specified tags
strval(1 - $defaultPrivacy), // defaultPub = 1 - defaultPrivacy
$conf->get('resource.data_dir') // log path, will be overridden
$this->conf->get('resource.data_dir') // log path, will be overridden
);
$logger = new Logger(
$conf->get('resource.data_dir'),
!$conf->get('dev.debug') ? LogLevel::INFO : LogLevel::DEBUG,
$this->conf->get('resource.data_dir'),
!$this->conf->get('dev.debug') ? LogLevel::INFO : LogLevel::DEBUG,
[
'prefix' => 'import.',
'extension' => 'log',
@ -171,7 +146,7 @@ public static function import($post, $files, $bookmarkService, $conf, $history)
$private = 0;
}
$link = $bookmarkService->findByUrl($bkm['uri']);
$link = $this->bookmarkService->findByUrl($bkm['uri']);
$existingLink = $link !== null;
if (! $existingLink) {
$link = new Bookmark();
@ -193,20 +168,21 @@ public static function import($post, $files, $bookmarkService, $conf, $history)
}
$link->setTitle($bkm['title']);
$link->setUrl($bkm['uri'], $conf->get('security.allowed_protocols'));
$link->setUrl($bkm['uri'], $this->conf->get('security.allowed_protocols'));
$link->setDescription($bkm['note']);
$link->setPrivate($private);
$link->setTagsString($bkm['tags']);
$bookmarkService->addOrSet($link, false);
$this->bookmarkService->addOrSet($link, false);
$importCount++;
}
$bookmarkService->save();
$history->importLinks();
$this->bookmarkService->save();
$this->history->importLinks();
$duration = time() - $start;
return self::importStatus(
return $this->importStatus(
$filename,
$filesize,
$importCount,
@ -215,4 +191,39 @@ public static function import($post, $files, $bookmarkService, $conf, $history)
$duration
);
}
/**
* Generates an import status summary
*
* @param string $filename name of the file to import
* @param int $filesize size of the file to import
* @param int $importCount how many bookmarks were imported
* @param int $overwriteCount how many bookmarks were overwritten
* @param int $skipCount how many bookmarks were skipped
* @param int $duration how many seconds did the import take
*
* @return string Summary of the bookmark import status
*/
protected function importStatus(
$filename,
$filesize,
$importCount = 0,
$overwriteCount = 0,
$skipCount = 0,
$duration = 0
) {
$status = sprintf(t('File %s (%d bytes) '), $filename, $filesize);
if ($importCount == 0 && $overwriteCount == 0 && $skipCount == 0) {
$status .= t('has an unknown file format. Nothing was imported.');
} else {
$status .= vsprintf(
t(
'was successfully processed in %d seconds: '
. '%d bookmarks imported, %d bookmarks overwritten, %d bookmarks skipped.'
),
[$duration, $importCount, $overwriteCount, $skipCount]
);
}
return $status;
}
}

View file

@ -16,7 +16,7 @@ class PluginManager
*
* @var array $authorizedPlugins
*/
private $authorizedPlugins;
private $authorizedPlugins = [];
/**
* List of loaded plugins.
@ -108,6 +108,10 @@ public function executeHooks($hook, &$data, $params = array())
$data['_LOGGEDIN_'] = $params['loggedin'];
}
if (isset($params['basePath'])) {
$data['_BASE_PATH_'] = $params['basePath'];
}
foreach ($this->loadedPlugins as $plugin) {
$hookFunction = $this->buildHookName($hook, $plugin);

View file

@ -3,10 +3,12 @@
namespace Shaarli\Render;
use Exception;
use exceptions\MissingBasePathException;
use RainTPL;
use Shaarli\ApplicationUtils;
use Shaarli\Bookmark\BookmarkServiceInterface;
use Shaarli\Config\ConfigManager;
use Shaarli\Security\SessionManager;
use Shaarli\Thumbnailer;
/**
@ -68,6 +70,15 @@ public function __construct(&$conf, $session, $linkDB = null, $token = null, $is
$this->isLoggedIn = $isLoggedIn;
}
/**
* Reset current state of template rendering.
* Mostly useful for error handling. We remove everything, and display the error template.
*/
public function reset(): void
{
$this->tpl = false;
}
/**
* Initialize all default tpl tags.
*/
@ -136,17 +147,40 @@ private function initialize()
$this->tpl->assign('thumbnails_width', $this->conf->get('thumbnails.width'));
$this->tpl->assign('thumbnails_height', $this->conf->get('thumbnails.height'));
if (!empty($_SESSION['warnings'])) {
$this->tpl->assign('global_warnings', $_SESSION['warnings']);
unset($_SESSION['warnings']);
}
$this->tpl->assign('formatter', $this->conf->get('formatter', 'default'));
// To be removed with a proper theme configuration.
$this->tpl->assign('conf', $this->conf);
}
/**
* Affect variable after controller processing.
* Used for alert messages.
*/
protected function finalize(string $basePath): void
{
// TODO: use the SessionManager
$messageKeys = [
SessionManager::KEY_SUCCESS_MESSAGES,
SessionManager::KEY_WARNING_MESSAGES,
SessionManager::KEY_ERROR_MESSAGES
];
foreach ($messageKeys as $messageKey) {
if (!empty($_SESSION[$messageKey])) {
$this->tpl->assign('global_' . $messageKey, $_SESSION[$messageKey]);
unset($_SESSION[$messageKey]);
}
}
$this->assign('base_path', $basePath);
$this->assign(
'asset_path',
$basePath . '/' .
rtrim($this->conf->get('resource.raintpl_tpl', 'tpl'), '/') . '/' .
$this->conf->get('resource.theme', 'default')
);
}
/**
* The following assign() method is basically the same as RainTPL (except lazy loading)
*
@ -184,21 +218,6 @@ public function assignAll($data)
return true;
}
/**
* Render a specific page (using a template file).
* e.g. $pb->renderPage('picwall');
*
* @param string $page Template filename (without extension).
*/
public function renderPage($page)
{
if ($this->tpl === false) {
$this->initialize();
}
$this->tpl->draw($page);
}
/**
* Render a specific page as string (using a template file).
* e.g. $pb->render('picwall');
@ -207,28 +226,14 @@ public function renderPage($page)
*
* @return string Processed template content
*/
public function render(string $page): string
public function render(string $page, string $basePath): string
{
if ($this->tpl === false) {
$this->initialize();
}
$this->finalize($basePath);
return $this->tpl->draw($page, true);
}
/**
* Render a 404 page (uses the template : tpl/404.tpl)
* usage: $PAGE->render404('The link was deleted')
*
* @param string $message A message to display what is not found
*/
public function render404($message = '')
{
if (empty($message)) {
$message = t('The page you are trying to reach does not exist or has been deleted.');
}
header($_SERVER['SERVER_PROTOCOL'] . ' ' . t('404 Not Found'));
$this->tpl->assign('error_message', $message);
$this->renderPage('404');
}
}

View file

@ -0,0 +1,60 @@
<?php
namespace Shaarli\Render;
use Shaarli\Feed\CachedPage;
/**
* Cache utilities
*/
class PageCacheManager
{
/** @var string Cache directory */
protected $pageCacheDir;
/** @var bool */
protected $isLoggedIn;
public function __construct(string $pageCacheDir, bool $isLoggedIn)
{
$this->pageCacheDir = $pageCacheDir;
$this->isLoggedIn = $isLoggedIn;
}
/**
* Purges all cached pages
*
* @return string|null an error string if the directory is missing
*/
public function purgeCachedPages(): ?string
{
if (!is_dir($this->pageCacheDir)) {
$error = sprintf(t('Cannot purge %s: no directory'), $this->pageCacheDir);
error_log($error);
return $error;
}
array_map('unlink', glob($this->pageCacheDir . '/*.cache'));
return null;
}
/**
* Invalidates caches when the database is changed or the user logs out.
*/
public function invalidateCaches(): void
{
// Purge page cache shared by sessions.
$this->purgeCachedPages();
}
public function getCachePage(string $pageUrl): CachedPage
{
return new CachedPage(
$this->pageCacheDir,
$pageUrl,
false === $this->isLoggedIn
);
}
}

View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Shaarli\Render;
interface TemplatePage
{
public const ERROR_404 = '404';
public const ADDLINK = 'addlink';
public const CHANGE_PASSWORD = 'changepassword';
public const CHANGE_TAG = 'changetag';
public const CONFIGURE = 'configure';
public const DAILY = 'daily';
public const DAILY_RSS = 'dailyrss';
public const EDIT_LINK = 'editlink';
public const ERROR = 'error';
public const EXPORT = 'export';
public const NETSCAPE_EXPORT_BOOKMARKS = 'export.bookmarks';
public const FEED_ATOM = 'feed.atom';
public const FEED_RSS = 'feed.rss';
public const IMPORT = 'import';
public const INSTALL = 'install';
public const LINKLIST = 'linklist';
public const LOGIN = 'loginform';
public const OPEN_SEARCH = 'opensearch';
public const PICTURE_WALL = 'picwall';
public const PLUGINS_ADMIN = 'pluginsadmin';
public const TAG_CLOUD = 'tag.cloud';
public const TAG_LIST = 'tag.list';
public const THUMBNAILS = 'thumbnails';
public const TOOLS = 'tools';
}

View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Shaarli\Security;
class CookieManager
{
/** @var string Name of the cookie set after logging in **/
public const STAY_SIGNED_IN = 'shaarli_staySignedIn';
/** @var mixed $_COOKIE set by reference */
protected $cookies;
public function __construct(array &$cookies)
{
$this->cookies = $cookies;
}
public function setCookieParameter(string $key, string $value, int $expires, string $path): self
{
$this->cookies[$key] = $value;
setcookie($key, $value, $expires, $path);
return $this;
}
public function getCookieParameter(string $key, string $default = null): ?string
{
return $this->cookies[$key] ?? $default;
}
}

View file

@ -9,9 +9,6 @@
*/
class LoginManager
{
/** @var string Name of the cookie set after logging in **/
public static $STAY_SIGNED_IN_COOKIE = 'shaarli_staySignedIn';
/** @var array A reference to the $_GLOBALS array */
protected $globals = [];
@ -32,17 +29,21 @@ class LoginManager
/** @var string User sign-in token depending on remote IP and credentials */
protected $staySignedInToken = '';
/** @var CookieManager */
protected $cookieManager;
/**
* Constructor
*
* @param ConfigManager $configManager Configuration Manager instance
* @param SessionManager $sessionManager SessionManager instance
* @param CookieManager $cookieManager CookieManager instance
*/
public function __construct($configManager, $sessionManager)
public function __construct($configManager, $sessionManager, $cookieManager)
{
$this->configManager = $configManager;
$this->sessionManager = $sessionManager;
$this->cookieManager = $cookieManager;
$this->banManager = new BanManager(
$this->configManager->get('security.trusted_proxies', []),
$this->configManager->get('security.ban_after'),
@ -86,10 +87,9 @@ public function getStaySignedInToken()
/**
* Check user session state and validity (expiration)
*
* @param array $cookie The $_COOKIE array
* @param string $clientIpId Client IP address identifier
*/
public function checkLoginState($cookie, $clientIpId)
public function checkLoginState($clientIpId)
{
if (! $this->configManager->exists('credentials.login')) {
// Shaarli is not configured yet
@ -97,9 +97,7 @@ public function checkLoginState($cookie, $clientIpId)
return;
}
if (isset($cookie[self::$STAY_SIGNED_IN_COOKIE])
&& $cookie[self::$STAY_SIGNED_IN_COOKIE] === $this->staySignedInToken
) {
if ($this->staySignedInToken === $this->cookieManager->getCookieParameter(CookieManager::STAY_SIGNED_IN)) {
// The user client has a valid stay-signed-in cookie
// Session information is updated with the current client information
$this->sessionManager->storeLoginInfo($clientIpId);

View file

@ -8,6 +8,14 @@
*/
class SessionManager
{
public const KEY_LINKS_PER_PAGE = 'LINKS_PER_PAGE';
public const KEY_VISIBILITY = 'visibility';
public const KEY_UNTAGGED_ONLY = 'untaggedonly';
public const KEY_SUCCESS_MESSAGES = 'successes';
public const KEY_WARNING_MESSAGES = 'warnings';
public const KEY_ERROR_MESSAGES = 'errors';
/** @var int Session expiration timeout, in seconds */
public static $SHORT_TIMEOUT = 3600; // 1 hour
@ -23,16 +31,35 @@ class SessionManager
/** @var bool Whether the user should stay signed in (LONG_TIMEOUT) */
protected $staySignedIn = false;
/** @var string */
protected $savePath;
/**
* Constructor
*
* @param array $session The $_SESSION array (reference)
* @param ConfigManager $conf ConfigManager instance
* @param string $savePath Session save path returned by builtin function session_save_path()
*/
public function __construct(& $session, $conf)
public function __construct(&$session, $conf, string $savePath)
{
$this->session = &$session;
$this->conf = $conf;
$this->savePath = $savePath;
}
/**
* Initialize XSRF token and links per page session variables.
*/
public function initialize(): void
{
if (!isset($this->session['tokens'])) {
$this->session['tokens'] = [];
}
if (!isset($this->session['LINKS_PER_PAGE'])) {
$this->session['LINKS_PER_PAGE'] = $this->conf->get('general.links_per_page', 20);
}
}
/**
@ -202,4 +229,78 @@ public function getSession(): array
{
return $this->session;
}
/**
* @param mixed $default value which will be returned if the $key is undefined
*
* @return mixed Content stored in session
*/
public function getSessionParameter(string $key, $default = null)
{
return $this->session[$key] ?? $default;
}
/**
* Store a variable in user session.
*
* @param string $key Session key
* @param mixed $value Session value to store
*
* @return $this
*/
public function setSessionParameter(string $key, $value): self
{
$this->session[$key] = $value;
return $this;
}
/**
* Store a variable in user session.
*
* @param string $key Session key
*
* @return $this
*/
public function deleteSessionParameter(string $key): self
{
unset($this->session[$key]);
return $this;
}
public function getSavePath(): string
{
return $this->savePath;
}
/*
* Next public functions wrapping native PHP session API.
*/
public function destroy(): bool
{
$this->session = [];
return session_destroy();
}
public function start(): bool
{
if (session_status() === PHP_SESSION_ACTIVE) {
$this->destroy();
}
return session_start();
}
public function cookieParameters(int $lifeTime, string $path, string $domain): bool
{
return session_set_cookie_params($lifeTime, $path, $domain);
}
public function regenerateId(bool $deleteOldSession = false): bool
{
return session_regenerate_id($deleteOldSession);
}
}

View file

@ -2,8 +2,8 @@
namespace Shaarli\Updater;
use Shaarli\Config\ConfigManager;
use Shaarli\Bookmark\BookmarkServiceInterface;
use Shaarli\Config\ConfigManager;
use Shaarli\Updater\Exception\UpdaterException;
/**
@ -21,7 +21,7 @@ class Updater
/**
* @var BookmarkServiceInterface instance.
*/
protected $linkServices;
protected $bookmarkService;
/**
* @var ConfigManager $conf Configuration Manager instance.
@ -38,6 +38,11 @@ class Updater
*/
protected $methods;
/**
* @var string $basePath Shaarli root directory (from HTTP Request)
*/
protected $basePath = null;
/**
* Object constructor.
*
@ -49,7 +54,7 @@ class Updater
public function __construct($doneUpdates, $linkDB, $conf, $isLoggedIn)
{
$this->doneUpdates = $doneUpdates;
$this->linkServices = $linkDB;
$this->bookmarkService = $linkDB;
$this->conf = $conf;
$this->isLoggedIn = $isLoggedIn;
@ -62,13 +67,15 @@ public function __construct($doneUpdates, $linkDB, $conf, $isLoggedIn)
* Run all new updates.
* Update methods have to start with 'updateMethod' and return true (on success).
*
* @param string $basePath Shaarli root directory (from HTTP Request)
*
* @return array An array containing ran updates.
*
* @throws UpdaterException If something went wrong.
*/
public function update()
public function update(string $basePath = null)
{
$updatesRan = array();
$updatesRan = [];
// If the user isn't logged in, exit without updating.
if ($this->isLoggedIn !== true) {
@ -111,4 +118,62 @@ public function getDoneUpdates()
{
return $this->doneUpdates;
}
public function readUpdates(string $updatesFilepath): array
{
return UpdaterUtils::read_updates_file($updatesFilepath);
}
public function writeUpdates(string $updatesFilepath, array $updates): void
{
UpdaterUtils::write_updates_file($updatesFilepath, $updates);
}
/**
* With the Slim routing system, default header link should be `/subfolder/` instead of `?`.
* Otherwise you can not go back to the home page.
* Example: `/subfolder/picture-wall` -> `/subfolder/picture-wall?` instead of `/subfolder/`.
*/
public function updateMethodRelativeHomeLink(): bool
{
if ('?' === trim($this->conf->get('general.header_link'))) {
$this->conf->set('general.header_link', $this->basePath . '/', true, true);
}
return true;
}
/**
* With the Slim routing system, note bookmarks URL formatted `?abcdef`
* should be replaced with `/shaare/abcdef`
*/
public function updateMethodMigrateExistingNotesUrl(): bool
{
$updated = false;
foreach ($this->bookmarkService->search() as $bookmark) {
if ($bookmark->isNote()
&& startsWith($bookmark->getUrl(), '?')
&& 1 === preg_match('/^\?([a-zA-Z0-9-_@]{6})($|&|#)/', $bookmark->getUrl(), $match)
) {
$updated = true;
$bookmark = $bookmark->setUrl('/shaare/' . $match[1]);
$this->bookmarkService->set($bookmark, false);
}
}
if ($updated) {
$this->bookmarkService->save();
}
return true;
}
public function setBasePath(string $basePath): self
{
$this->basePath = $basePath;
return $this;
}
}

View file

@ -10,13 +10,14 @@
* It contains a recursive call to retrieve the thumb of the next link when it succeed.
* It also update the progress bar and other visual feedback elements.
*
* @param {string} basePath Shaarli subfolder for XHR requests
* @param {array} ids List of LinkID to update
* @param {int} i Current index in ids
* @param {object} elements List of DOM element to avoid retrieving them at each iteration
*/
function updateThumb(ids, i, elements) {
function updateThumb(basePath, ids, i, elements) {
const xhr = new XMLHttpRequest();
xhr.open('POST', '?do=ajax_thumb_update');
xhr.open('PATCH', `${basePath}/admin/shaare/${ids[i]}/update-thumbnail`);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.responseType = 'json';
xhr.onload = () => {
@ -29,17 +30,18 @@ function updateThumb(ids, i, elements) {
elements.current.innerHTML = i;
elements.title.innerHTML = response.title;
if (response.thumbnail !== false) {
elements.thumbnail.innerHTML = `<img src="${response.thumbnail}">`;
elements.thumbnail.innerHTML = `<img src="${basePath}/${response.thumbnail}">`;
}
if (i < ids.length) {
updateThumb(ids, i, elements);
updateThumb(basePath, ids, i, elements);
}
}
};
xhr.send(`id=${ids[i]}`);
xhr.send();
}
(() => {
const basePath = document.querySelector('input[name="js_base_path"]').value;
const ids = document.getElementsByName('ids')[0].value.split(',');
const elements = {
progressBar: document.querySelector('.progressbar > div'),
@ -47,5 +49,5 @@ function updateThumb(ids, i, elements) {
thumbnail: document.querySelector('.thumbnail-placeholder'),
title: document.querySelector('.thumbnail-link-title'),
};
updateThumb(ids, 0, elements);
updateThumb(basePath, ids, 0, elements);
})();

View file

@ -25,12 +25,16 @@ function findParent(element, tagName, attributes) {
/**
* Ajax request to refresh the CSRF token.
*/
function refreshToken() {
function refreshToken(basePath) {
console.log('refresh');
const xhr = new XMLHttpRequest();
xhr.open('GET', '?do=token');
xhr.open('GET', `${basePath}/admin/token`);
xhr.onload = () => {
const token = document.getElementById('token');
token.setAttribute('value', xhr.responseText);
const elements = document.querySelectorAll('input[name="token"]');
[...elements].forEach((element) => {
console.log(element);
element.setAttribute('value', xhr.responseText);
});
};
xhr.send();
}
@ -215,6 +219,8 @@ function init(description) {
}
(() => {
const basePath = document.querySelector('input[name="js_base_path"]').value;
/**
* Handle responsive menu.
* Source: http://purecss.io/layouts/tucked-menu-vertical/
@ -461,7 +467,7 @@ function init(description) {
});
if (window.confirm(message)) {
window.location = `?delete_link&lf_linkdate=${ids.join('+')}&token=${token.value}`;
window.location = `${basePath}/admin/shaare/delete?id=${ids.join('+')}&token=${token.value}`;
}
});
}
@ -483,7 +489,8 @@ function init(description) {
});
const ids = links.map(item => item.id);
window.location = `?change_visibility&token=${token.value}&newVisibility=${visibility}&ids=${ids.join('+')}`;
window.location =
`${basePath}/admin/shaare/visibility?token=${token.value}&newVisibility=${visibility}&id=${ids.join('+')}`;
});
});
}
@ -546,7 +553,7 @@ function init(description) {
const refreshedToken = document.getElementById('token').value;
const fromtag = block.getAttribute('data-tag');
const xhr = new XMLHttpRequest();
xhr.open('POST', '?do=changetag');
xhr.open('POST', `${basePath}/admin/tags`);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.onload = () => {
if (xhr.status !== 200) {
@ -558,8 +565,12 @@ function init(description) {
input.setAttribute('value', totag);
findParent(input, 'div', { class: 'rename-tag-form' }).style.display = 'none';
block.querySelector('a.tag-link').innerHTML = htmlEntities(totag);
block.querySelector('a.tag-link').setAttribute('href', `?searchtags=${encodeURIComponent(totag)}`);
block.querySelector('a.rename-tag').setAttribute('href', `?do=changetag&fromtag=${encodeURIComponent(totag)}`);
block
.querySelector('a.tag-link')
.setAttribute('href', `${basePath}/?searchtags=${encodeURIComponent(totag)}`);
block
.querySelector('a.rename-tag')
.setAttribute('href', `${basePath}/admin/tags?fromtag=${encodeURIComponent(totag)}`);
// Refresh awesomplete values
existingTags = existingTags.map(tag => (tag === fromtag ? totag : tag));
@ -567,7 +578,7 @@ function init(description) {
}
};
xhr.send(`renametag=1&fromtag=${encodeURIComponent(fromtag)}&totag=${encodeURIComponent(totag)}&token=${refreshedToken}`);
refreshToken();
refreshToken(basePath);
});
});
@ -593,13 +604,13 @@ function init(description) {
if (confirm(`Are you sure you want to delete the tag "${tag}"?`)) {
const xhr = new XMLHttpRequest();
xhr.open('POST', '?do=changetag');
xhr.open('POST', `${basePath}/admin/tags`);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.onload = () => {
block.remove();
};
xhr.send(encodeURI(`deletetag=1&fromtag=${tag}&token=${refreshedToken}`));
refreshToken();
refreshToken(basePath);
existingTags = existingTags.filter(tagItem => tagItem !== tag);
awesomepletes = updateAwesompleteList('.rename-tag-input', existingTags, awesomepletes);

View file

@ -490,6 +490,10 @@ body,
}
}
.header-alert-message {
text-align: center;
}
// CONTENT - GENERAL
.container {
position: relative;

View file

@ -53,7 +53,8 @@
"Shaarli\\Feed\\": "application/feed",
"Shaarli\\Formatter\\": "application/formatter",
"Shaarli\\Front\\": "application/front",
"Shaarli\\Front\\Controller\\": "application/front/controllers",
"Shaarli\\Front\\Controller\\Admin\\": "application/front/controller/admin",
"Shaarli\\Front\\Controller\\Visitor\\": "application/front/controller/visitor",
"Shaarli\\Front\\Exception\\": "application/front/exceptions",
"Shaarli\\Http\\": "application/http",
"Shaarli\\Legacy\\": "application/legacy",

211
composer.lock generated
View file

@ -508,16 +508,16 @@
},
{
"name": "psr/log",
"version": "1.1.2",
"version": "1.1.3",
"source": {
"type": "git",
"url": "https://github.com/php-fig/log.git",
"reference": "446d54b4cb6bf489fc9d75f55843658e6f25d801"
"reference": "0f73288fd15629204f9d42b7055f72dacbe811fc"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/log/zipball/446d54b4cb6bf489fc9d75f55843658e6f25d801",
"reference": "446d54b4cb6bf489fc9d75f55843658e6f25d801",
"url": "https://api.github.com/repos/php-fig/log/zipball/0f73288fd15629204f9d42b7055f72dacbe811fc",
"reference": "0f73288fd15629204f9d42b7055f72dacbe811fc",
"shasum": ""
},
"require": {
@ -551,7 +551,7 @@
"psr",
"psr-3"
],
"time": "2019-11-01T11:05:21+00:00"
"time": "2020-03-23T09:12:05+00:00"
},
{
"name": "pubsubhubbub/publisher",
@ -936,24 +936,21 @@
},
{
"name": "phpdocumentor/reflection-common",
"version": "2.0.0",
"version": "2.1.0",
"source": {
"type": "git",
"url": "https://github.com/phpDocumentor/ReflectionCommon.git",
"reference": "63a995caa1ca9e5590304cd845c15ad6d482a62a"
"reference": "6568f4687e5b41b054365f9ae03fcb1ed5f2069b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/63a995caa1ca9e5590304cd845c15ad6d482a62a",
"reference": "63a995caa1ca9e5590304cd845c15ad6d482a62a",
"url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/6568f4687e5b41b054365f9ae03fcb1ed5f2069b",
"reference": "6568f4687e5b41b054365f9ae03fcb1ed5f2069b",
"shasum": ""
},
"require": {
"php": ">=7.1"
},
"require-dev": {
"phpunit/phpunit": "~6"
},
"type": "library",
"extra": {
"branch-alias": {
@ -984,7 +981,7 @@
"reflection",
"static analysis"
],
"time": "2018-08-07T13:53:10+00:00"
"time": "2020-04-27T09:25:28+00:00"
},
{
"name": "phpdocumentor/reflection-docblock",
@ -1087,24 +1084,24 @@
},
{
"name": "phpspec/prophecy",
"version": "1.10.1",
"version": "v1.10.3",
"source": {
"type": "git",
"url": "https://github.com/phpspec/prophecy.git",
"reference": "cbe1df668b3fe136bcc909126a0f529a78d4cbbc"
"reference": "451c3cd1418cf640de218914901e51b064abb093"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpspec/prophecy/zipball/cbe1df668b3fe136bcc909126a0f529a78d4cbbc",
"reference": "cbe1df668b3fe136bcc909126a0f529a78d4cbbc",
"url": "https://api.github.com/repos/phpspec/prophecy/zipball/451c3cd1418cf640de218914901e51b064abb093",
"reference": "451c3cd1418cf640de218914901e51b064abb093",
"shasum": ""
},
"require": {
"doctrine/instantiator": "^1.0.2",
"php": "^5.3|^7.0",
"phpdocumentor/reflection-docblock": "^2.0|^3.0.2|^4.0|^5.0",
"sebastian/comparator": "^1.2.3|^2.0|^3.0",
"sebastian/recursion-context": "^1.0|^2.0|^3.0"
"sebastian/comparator": "^1.2.3|^2.0|^3.0|^4.0",
"sebastian/recursion-context": "^1.0|^2.0|^3.0|^4.0"
},
"require-dev": {
"phpspec/phpspec": "^2.5 || ^3.2",
@ -1146,7 +1143,7 @@
"spy",
"stub"
],
"time": "2019-12-22T21:05:45+00:00"
"time": "2020-03-05T15:02:03+00:00"
},
{
"name": "phpunit/php-code-coverage",
@ -1501,12 +1498,12 @@
"source": {
"type": "git",
"url": "https://github.com/Roave/SecurityAdvisories.git",
"reference": "67ac6ea8f4a078c3c9b7aec5d7ae70f098c37389"
"reference": "5a342e2dc0408d026b97ee3176b5b406e54e3766"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/67ac6ea8f4a078c3c9b7aec5d7ae70f098c37389",
"reference": "67ac6ea8f4a078c3c9b7aec5d7ae70f098c37389",
"url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/5a342e2dc0408d026b97ee3176b5b406e54e3766",
"reference": "5a342e2dc0408d026b97ee3176b5b406e54e3766",
"shasum": ""
},
"conflict": {
@ -1518,11 +1515,17 @@
"api-platform/core": ">=2.2,<2.2.10|>=2.3,<2.3.6",
"asymmetricrypt/asymmetricrypt": ">=0,<9.9.99",
"aws/aws-sdk-php": ">=3,<3.2.1",
"bagisto/bagisto": "<0.1.5",
"barrelstrength/sprout-base-email": "<3.9",
"bolt/bolt": "<3.6.10",
"brightlocal/phpwhois": "<=4.2.5",
"buddypress/buddypress": "<5.1.2",
"bugsnag/bugsnag-laravel": ">=2,<2.0.2",
"cakephp/cakephp": ">=1.3,<1.3.18|>=2,<2.4.99|>=2.5,<2.5.99|>=2.6,<2.6.12|>=2.7,<2.7.6|>=3,<3.5.18|>=3.6,<3.6.15|>=3.7,<3.7.7",
"cart2quote/module-quotation": ">=4.1.6,<=4.4.5|>=5,<5.4.4",
"cartalyst/sentry": "<=2.1.6",
"centreon/centreon": "<18.10.8|>=19,<19.4.5",
"cesnet/simplesamlphp-module-proxystatistics": "<3.1",
"codeigniter/framework": "<=3.0.6",
"composer/composer": "<=1-alpha.11",
"contao-components/mediaelement": ">=2.14.2,<2.21.1",
@ -1540,22 +1543,32 @@
"doctrine/mongodb-odm": ">=1,<1.0.2",
"doctrine/mongodb-odm-bundle": ">=2,<3.0.1",
"doctrine/orm": ">=2,<2.4.8|>=2.5,<2.5.1",
"dolibarr/dolibarr": "<=10.0.6",
"dompdf/dompdf": ">=0.6,<0.6.2",
"drupal/core": ">=7,<7.69|>=8,<8.7.11|>=8.8,<8.8.1",
"drupal/drupal": ">=7,<7.69|>=8,<8.7.11|>=8.8,<8.8.1",
"drupal/core": ">=7,<7.69|>=8,<8.7.12|>=8.8,<8.8.4",
"drupal/drupal": ">=7,<7.69|>=8,<8.7.12|>=8.8,<8.8.4",
"endroid/qr-code-bundle": "<3.4.2",
"enshrined/svg-sanitize": "<0.13.1",
"erusev/parsedown": "<1.7.2",
"ezsystems/ezplatform-admin-ui": ">=1.3,<1.3.5|>=1.4,<1.4.4",
"ezsystems/ezpublish-kernel": ">=5.3,<5.3.12.1|>=5.4,<5.4.13.1|>=6,<6.7.9.1|>=6.8,<6.13.5.1|>=7,<7.2.4.1|>=7.3,<7.3.2.1",
"ezsystems/ezpublish-legacy": ">=5.3,<5.3.12.6|>=5.4,<5.4.12.3|>=2011,<2017.12.4.3|>=2018.6,<2018.6.1.4|>=2018.9,<2018.9.1.3",
"ezsystems/demobundle": ">=5.4,<5.4.6.1",
"ezsystems/ezdemo-ls-extension": ">=5.4,<5.4.2.1",
"ezsystems/ezfind-ls": ">=5.3,<5.3.6.1|>=5.4,<5.4.11.1|>=2017.12,<2017.12.0.1",
"ezsystems/ezplatform": ">=1.7,<1.7.9.1|>=1.13,<1.13.5.1|>=2.5,<2.5.4",
"ezsystems/ezplatform-admin-ui": ">=1.3,<1.3.5|>=1.4,<1.4.6",
"ezsystems/ezplatform-admin-ui-assets": ">=4,<4.2",
"ezsystems/ezplatform-user": ">=1,<1.0.1",
"ezsystems/ezpublish-kernel": ">=5.3,<5.3.12.1|>=5.4,<5.4.14.1|>=6,<6.7.9.1|>=6.8,<6.13.6.2|>=7,<7.2.4.1|>=7.3,<7.3.2.1|>=7.5,<7.5.6.2",
"ezsystems/ezpublish-legacy": ">=5.3,<5.3.12.6|>=5.4,<5.4.14.1|>=2011,<2017.12.7.2|>=2018.6,<2018.6.1.4|>=2018.9,<2018.9.1.3|>=2019.3,<2019.3.4.2",
"ezsystems/repository-forms": ">=2.3,<2.3.2.1",
"ezyang/htmlpurifier": "<4.1.1",
"firebase/php-jwt": "<2",
"fooman/tcpdf": "<6.2.22",
"fossar/tcpdf-parser": "<6.2.22",
"friendsofsymfony/oauth2-php": "<1.3",
"friendsofsymfony/rest-bundle": ">=1.2,<1.2.2",
"friendsofsymfony/user-bundle": ">=1.2,<1.3.5",
"fuel/core": "<1.8.1",
"getgrav/grav": "<1.7-beta.8",
"gree/jose": "<=2.2",
"gregwar/rst": "<1.0.3",
"guzzlehttp/guzzle": ">=4-rc.2,<4.2.4|>=5,<5.3.1|>=6,<6.2.1",
@ -1563,6 +1576,7 @@
"illuminate/cookie": ">=4,<=4.0.11|>=4.1,<=4.1.31|>=4.2,<=4.2.22|>=5,<=5.0.35|>=5.1,<=5.1.46|>=5.2,<=5.2.45|>=5.3,<=5.3.31|>=5.4,<=5.4.36|>=5.5,<5.5.42|>=5.6,<5.6.30",
"illuminate/database": ">=4,<4.0.99|>=4.1,<4.1.29",
"illuminate/encryption": ">=4,<=4.0.11|>=4.1,<=4.1.31|>=4.2,<=4.2.22|>=5,<=5.0.35|>=5.1,<=5.1.46|>=5.2,<=5.2.45|>=5.3,<=5.3.31|>=5.4,<=5.4.36|>=5.5,<5.5.40|>=5.6,<5.6.15",
"illuminate/view": ">=7,<7.1.2",
"ivankristianto/phpwhois": "<=4.3",
"james-heinrich/getid3": "<1.9.9",
"joomla/session": "<1.3.1",
@ -1570,15 +1584,19 @@
"kazist/phpwhois": "<=4.2.6",
"kreait/firebase-php": ">=3.2,<3.8.1",
"la-haute-societe/tcpdf": "<6.2.22",
"laravel/framework": ">=4,<4.0.99|>=4.1,<=4.1.31|>=4.2,<=4.2.22|>=5,<=5.0.35|>=5.1,<=5.1.46|>=5.2,<=5.2.45|>=5.3,<=5.3.31|>=5.4,<=5.4.36|>=5.5,<5.5.42|>=5.6,<5.6.30",
"laravel/framework": ">=4,<4.0.99|>=4.1,<=4.1.31|>=4.2,<=4.2.22|>=5,<=5.0.35|>=5.1,<=5.1.46|>=5.2,<=5.2.45|>=5.3,<=5.3.31|>=5.4,<=5.4.36|>=5.5,<5.5.42|>=5.6,<5.6.30|>=7,<7.1.2",
"laravel/socialite": ">=1,<1.0.99|>=2,<2.0.10",
"league/commonmark": "<0.18.3",
"librenms/librenms": "<1.53",
"magento/community-edition": ">=2,<2.2.10|>=2.3,<2.3.3",
"magento/magento1ce": "<1.9.4.3",
"magento/magento1ee": ">=1,<1.14.4.3",
"magento/product-community-edition": ">=2,<2.2.10|>=2.3,<2.3.2-p.2",
"monolog/monolog": ">=1.8,<1.12",
"namshi/jose": "<2.2",
"nzo/url-encryptor-bundle": ">=4,<4.3.2|>=5,<5.0.1",
"onelogin/php-saml": "<2.10.4",
"oneup/uploader-bundle": "<1.9.3|>=2,<2.1.5",
"openid/php-openid": "<2.3",
"oro/crm": ">=1.7,<1.7.4",
"oro/platform": ">=1.7,<1.7.4",
@ -1587,49 +1605,67 @@
"paragonie/random_compat": "<2",
"paypal/merchant-sdk-php": "<3.12",
"pear/archive_tar": "<1.4.4",
"phpfastcache/phpfastcache": ">=5,<5.0.13",
"phpmailer/phpmailer": ">=5,<5.2.27|>=6,<6.0.6",
"phpoffice/phpexcel": "<=1.8.1",
"phpoffice/phpspreadsheet": "<=1.5",
"phpmyadmin/phpmyadmin": "<4.9.2",
"phpoffice/phpexcel": "<1.8.2",
"phpoffice/phpspreadsheet": "<1.8",
"phpunit/phpunit": ">=4.8.19,<4.8.28|>=5.0.10,<5.6.3",
"phpwhois/phpwhois": "<=4.2.5",
"phpxmlrpc/extras": "<0.6.1",
"pimcore/pimcore": "<6.3",
"prestashop/autoupgrade": ">=4,<4.10.1",
"prestashop/gamification": "<2.3.2",
"prestashop/ps_facetedsearch": "<3.4.1",
"privatebin/privatebin": "<1.2.2|>=1.3,<1.3.2",
"propel/propel": ">=2-alpha.1,<=2-alpha.7",
"propel/propel1": ">=1,<=1.7.1",
"pusher/pusher-php-server": "<2.2.1",
"robrichards/xmlseclibs": ">=1,<3.0.4",
"robrichards/xmlseclibs": "<3.0.4",
"sabre/dav": ">=1.6,<1.6.99|>=1.7,<1.7.11|>=1.8,<1.8.9",
"scheb/two-factor-bundle": ">=0,<3.26|>=4,<4.11",
"sensiolabs/connect": "<4.2.3",
"serluck/phpwhois": "<=4.2.6",
"shopware/shopware": "<5.3.7",
"silverstripe/cms": ">=3,<=3.0.11|>=3.1,<3.1.11",
"silverstripe/admin": ">=1.0.3,<1.0.4|>=1.1,<1.1.1",
"silverstripe/assets": ">=1,<1.4.7|>=1.5,<1.5.2",
"silverstripe/cms": "<4.3.6|>=4.4,<4.4.4",
"silverstripe/comments": ">=1.3,<1.9.99|>=2,<2.9.99|>=3,<3.1.1",
"silverstripe/forum": "<=0.6.1|>=0.7,<=0.7.3",
"silverstripe/framework": ">=3,<3.6.7|>=3.7,<3.7.3|>=4,<4.4",
"silverstripe/framework": "<4.4.5|>=4.5,<4.5.2",
"silverstripe/graphql": ">=2,<2.0.5|>=3,<3.1.2",
"silverstripe/registry": ">=2.1,<2.1.2|>=2.2,<2.2.1",
"silverstripe/restfulserver": ">=1,<1.0.9|>=2,<2.0.4",
"silverstripe/subsites": ">=2,<2.1.1",
"silverstripe/taxonomy": ">=1.3,<1.3.1|>=2,<2.0.1",
"silverstripe/userforms": "<3",
"simple-updates/phpwhois": "<=1",
"simplesamlphp/saml2": "<1.10.6|>=2,<2.3.8|>=3,<3.1.4",
"simplesamlphp/simplesamlphp": "<1.17.8",
"simplesamlphp/simplesamlphp": "<1.18.6",
"simplesamlphp/simplesamlphp-module-infocard": "<1.0.1",
"simplito/elliptic-php": "<1.0.6",
"slim/slim": "<2.6",
"smarty/smarty": "<3.1.33",
"socalnick/scn-social-auth": "<1.15.2",
"spoonity/tcpdf": "<6.2.22",
"squizlabs/php_codesniffer": ">=1,<2.8.1|>=3,<3.0.1",
"ssddanbrown/bookstack": "<0.29.2",
"stormpath/sdk": ">=0,<9.9.99",
"studio-42/elfinder": "<2.1.48",
"studio-42/elfinder": "<2.1.49",
"swiftmailer/swiftmailer": ">=4,<5.4.5",
"sylius/admin-bundle": ">=1,<1.0.17|>=1.1,<1.1.9|>=1.2,<1.2.2",
"sylius/grid": ">=1,<1.1.19|>=1.2,<1.2.18|>=1.3,<1.3.13|>=1.4,<1.4.5|>=1.5,<1.5.1",
"sylius/grid-bundle": ">=1,<1.1.19|>=1.2,<1.2.18|>=1.3,<1.3.13|>=1.4,<1.4.5|>=1.5,<1.5.1",
"sylius/sylius": ">=1,<1.1.18|>=1.2,<1.2.17|>=1.3,<1.3.12|>=1.4,<1.4.4",
"sylius/resource-bundle": "<1.3.13|>=1.4,<1.4.6|>=1.5,<1.5.1|>=1.6,<1.6.3",
"sylius/sylius": "<1.3.16|>=1.4,<1.4.12|>=1.5,<1.5.9|>=1.6,<1.6.5",
"symbiote/silverstripe-multivaluefield": ">=3,<3.0.99",
"symbiote/silverstripe-versionedfiles": "<=2.0.3",
"symfony/cache": ">=3.1,<3.4.35|>=4,<4.2.12|>=4.3,<4.3.8",
"symfony/dependency-injection": ">=2,<2.0.17|>=2.7,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7",
"symfony/error-handler": ">=4.4,<4.4.4|>=5,<5.0.4",
"symfony/form": ">=2.3,<2.3.35|>=2.4,<2.6.12|>=2.7,<2.7.50|>=2.8,<2.8.49|>=3,<3.4.20|>=4,<4.0.15|>=4.1,<4.1.9|>=4.2,<4.2.1",
"symfony/framework-bundle": ">=2,<2.3.18|>=2.4,<2.4.8|>=2.5,<2.5.2|>=2.7,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7",
"symfony/http-foundation": ">=2,<2.8.52|>=3,<3.4.35|>=4,<4.2.12|>=4.3,<4.3.8",
"symfony/http-foundation": ">=2,<2.8.52|>=3,<3.4.35|>=4,<4.2.12|>=4.3,<4.3.8|>=4.4,<4.4.7|>=5,<5.0.7",
"symfony/http-kernel": ">=2,<2.8.52|>=3,<3.4.35|>=4,<4.2.12|>=4.3,<4.3.8",
"symfony/intl": ">=2.7,<2.7.38|>=2.8,<2.8.31|>=3,<3.2.14|>=3.3,<3.3.13",
"symfony/mime": ">=4.3,<4.3.8",
@ -1638,14 +1674,14 @@
"symfony/polyfill-php55": ">=1,<1.10",
"symfony/proxy-manager-bridge": ">=2.7,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7",
"symfony/routing": ">=2,<2.0.19",
"symfony/security": ">=2,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7",
"symfony/security": ">=2,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7|>=4.4,<4.4.7|>=5,<5.0.7",
"symfony/security-bundle": ">=2,<2.7.48|>=2.8,<2.8.41|>=3,<3.3.17|>=3.4,<3.4.11|>=4,<4.0.11",
"symfony/security-core": ">=2.4,<2.6.13|>=2.7,<2.7.9|>=2.7.30,<2.7.32|>=2.8,<2.8.37|>=3,<3.3.17|>=3.4,<3.4.7|>=4,<4.0.7",
"symfony/security-csrf": ">=2.4,<2.7.48|>=2.8,<2.8.41|>=3,<3.3.17|>=3.4,<3.4.11|>=4,<4.0.11",
"symfony/security-guard": ">=2.8,<2.8.41|>=3,<3.3.17|>=3.4,<3.4.11|>=4,<4.0.11",
"symfony/security-http": ">=2.3,<2.3.41|>=2.4,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.2.12|>=4.3,<4.3.8",
"symfony/security-http": ">=2.3,<2.3.41|>=2.4,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.2.12|>=4.3,<4.3.8|>=4.4,<4.4.7|>=5,<5.0.7",
"symfony/serializer": ">=2,<2.0.11",
"symfony/symfony": ">=2,<2.8.52|>=3,<3.4.35|>=4,<4.2.12|>=4.3,<4.3.8",
"symfony/symfony": ">=2,<2.8.52|>=3,<3.4.35|>=4,<4.2.12|>=4.3,<4.3.8|>=4.4,<4.4.7|>=5,<5.0.7",
"symfony/translation": ">=2,<2.0.17",
"symfony/validator": ">=2,<2.0.24|>=2.1,<2.1.12|>=2.2,<2.2.5|>=2.3,<2.3.3",
"symfony/var-exporter": ">=4.2,<4.2.12|>=4.3,<4.3.8",
@ -1658,14 +1694,17 @@
"titon/framework": ">=0,<9.9.99",
"truckersmp/phpwhois": "<=4.3.1",
"twig/twig": "<1.38|>=2,<2.7",
"typo3/cms": ">=6.2,<6.2.30|>=7,<7.6.32|>=8,<8.7.30|>=9,<9.5.12|>=10,<10.2.1",
"typo3/cms-core": ">=8,<8.7.30|>=9,<9.5.12|>=10,<10.2.1",
"typo3/cms": ">=6.2,<6.2.30|>=7,<7.6.32|>=8,<8.7.30|>=9,<9.5.17|>=10,<10.4.2",
"typo3/cms-core": ">=8,<8.7.30|>=9,<9.5.17|>=10,<10.4.2",
"typo3/flow": ">=1,<1.0.4|>=1.1,<1.1.1|>=2,<2.0.1|>=2.3,<2.3.16|>=3,<3.0.10|>=3.1,<3.1.7|>=3.2,<3.2.7|>=3.3,<3.3.5",
"typo3/neos": ">=1.1,<1.1.3|>=1.2,<1.2.13|>=2,<2.0.4",
"typo3/phar-stream-wrapper": ">=1,<2.1.1|>=3,<3.1.1",
"ua-parser/uap-php": "<3.8",
"usmanhalalit/pixie": "<1.0.3|>=2,<2.0.2",
"verot/class.upload.php": "<=1.0.3|>=2,<=2.0.4",
"wallabag/tcpdf": "<6.2.22",
"willdurand/js-translation-bundle": "<2.1.1",
"yii2mod/yii2-cms": "<1.9.2",
"yiisoft/yii": ">=1.1.14,<1.1.15",
"yiisoft/yii2": "<2.0.15",
"yiisoft/yii2-bootstrap": "<2.0.4",
@ -1674,6 +1713,7 @@
"yiisoft/yii2-gii": "<2.0.4",
"yiisoft/yii2-jui": "<2.0.4",
"yiisoft/yii2-redis": "<2.0.8",
"yourls/yourls": "<1.7.4",
"zendframework/zend-cache": ">=2.4,<2.4.8|>=2.5,<2.5.3",
"zendframework/zend-captcha": ">=2,<2.4.9|>=2.5,<2.5.2",
"zendframework/zend-crypt": ">=2,<2.4.9|>=2.5,<2.5.2",
@ -1710,10 +1750,15 @@
"name": "Marco Pivetta",
"email": "ocramius@gmail.com",
"role": "maintainer"
},
{
"name": "Ilya Tribusean",
"email": "slash3b@gmail.com",
"role": "maintainer"
}
],
"description": "Prevents installation of composer packages with known security vulnerabilities: no API, simply require it",
"time": "2020-01-06T19:16:46+00:00"
"time": "2020-05-12T11:18:47+00:00"
},
{
"name": "sebastian/code-unit-reverse-lookup",
@ -2326,16 +2371,16 @@
},
{
"name": "squizlabs/php_codesniffer",
"version": "3.5.3",
"version": "3.5.5",
"source": {
"type": "git",
"url": "https://github.com/squizlabs/PHP_CodeSniffer.git",
"reference": "557a1fc7ac702c66b0bbfe16ab3d55839ef724cb"
"reference": "73e2e7f57d958e7228fce50dc0c61f58f017f9f6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/557a1fc7ac702c66b0bbfe16ab3d55839ef724cb",
"reference": "557a1fc7ac702c66b0bbfe16ab3d55839ef724cb",
"url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/73e2e7f57d958e7228fce50dc0c61f58f017f9f6",
"reference": "73e2e7f57d958e7228fce50dc0c61f58f017f9f6",
"shasum": ""
},
"require": {
@ -2373,20 +2418,20 @@
"phpcs",
"standards"
],
"time": "2019-12-04T04:46:47+00:00"
"time": "2020-04-17T01:09:41+00:00"
},
{
"name": "symfony/console",
"version": "v4.4.2",
"version": "v4.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
"reference": "82437719dab1e6bdd28726af14cb345c2ec816d0"
"reference": "10bb3ee3c97308869d53b3e3d03f6ac23ff985f7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/console/zipball/82437719dab1e6bdd28726af14cb345c2ec816d0",
"reference": "82437719dab1e6bdd28726af14cb345c2ec816d0",
"url": "https://api.github.com/repos/symfony/console/zipball/10bb3ee3c97308869d53b3e3d03f6ac23ff985f7",
"reference": "10bb3ee3c97308869d53b3e3d03f6ac23ff985f7",
"shasum": ""
},
"require": {
@ -2449,20 +2494,20 @@
],
"description": "Symfony Console Component",
"homepage": "https://symfony.com",
"time": "2019-12-17T10:32:23+00:00"
"time": "2020-03-30T11:41:10+00:00"
},
{
"name": "symfony/finder",
"version": "v4.4.2",
"version": "v4.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/finder.git",
"reference": "ce8743441da64c41e2a667b8eb66070444ed911e"
"reference": "5729f943f9854c5781984ed4907bbb817735776b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/finder/zipball/ce8743441da64c41e2a667b8eb66070444ed911e",
"reference": "ce8743441da64c41e2a667b8eb66070444ed911e",
"url": "https://api.github.com/repos/symfony/finder/zipball/5729f943f9854c5781984ed4907bbb817735776b",
"reference": "5729f943f9854c5781984ed4907bbb817735776b",
"shasum": ""
},
"require": {
@ -2498,20 +2543,20 @@
],
"description": "Symfony Finder Component",
"homepage": "https://symfony.com",
"time": "2019-11-17T21:56:56+00:00"
"time": "2020-03-27T16:54:36+00:00"
},
{
"name": "symfony/polyfill-ctype",
"version": "v1.13.1",
"version": "v1.17.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-ctype.git",
"reference": "f8f0b461be3385e56d6de3dbb5a0df24c0c275e3"
"reference": "e94c8b1bbe2bc77507a1056cdb06451c75b427f9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/f8f0b461be3385e56d6de3dbb5a0df24c0c275e3",
"reference": "f8f0b461be3385e56d6de3dbb5a0df24c0c275e3",
"url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/e94c8b1bbe2bc77507a1056cdb06451c75b427f9",
"reference": "e94c8b1bbe2bc77507a1056cdb06451c75b427f9",
"shasum": ""
},
"require": {
@ -2523,7 +2568,7 @@
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.13-dev"
"dev-master": "1.17-dev"
}
},
"autoload": {
@ -2556,20 +2601,20 @@
"polyfill",
"portable"
],
"time": "2019-11-27T13:56:44+00:00"
"time": "2020-05-12T16:14:59+00:00"
},
{
"name": "symfony/polyfill-mbstring",
"version": "v1.13.1",
"version": "v1.17.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-mbstring.git",
"reference": "7b4aab9743c30be783b73de055d24a39cf4b954f"
"reference": "fa79b11539418b02fc5e1897267673ba2c19419c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/7b4aab9743c30be783b73de055d24a39cf4b954f",
"reference": "7b4aab9743c30be783b73de055d24a39cf4b954f",
"url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/fa79b11539418b02fc5e1897267673ba2c19419c",
"reference": "fa79b11539418b02fc5e1897267673ba2c19419c",
"shasum": ""
},
"require": {
@ -2581,7 +2626,7 @@
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.13-dev"
"dev-master": "1.17-dev"
}
},
"autoload": {
@ -2615,20 +2660,20 @@
"portable",
"shim"
],
"time": "2019-11-27T14:18:11+00:00"
"time": "2020-05-12T16:47:27+00:00"
},
{
"name": "symfony/polyfill-php73",
"version": "v1.13.1",
"version": "v1.17.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php73.git",
"reference": "4b0e2222c55a25b4541305a053013d5647d3a25f"
"reference": "a760d8964ff79ab9bf057613a5808284ec852ccc"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/4b0e2222c55a25b4541305a053013d5647d3a25f",
"reference": "4b0e2222c55a25b4541305a053013d5647d3a25f",
"url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/a760d8964ff79ab9bf057613a5808284ec852ccc",
"reference": "a760d8964ff79ab9bf057613a5808284ec852ccc",
"shasum": ""
},
"require": {
@ -2637,7 +2682,7 @@
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.13-dev"
"dev-master": "1.17-dev"
}
},
"autoload": {
@ -2673,7 +2718,7 @@
"portable",
"shim"
],
"time": "2019-11-27T16:25:15+00:00"
"time": "2020-05-12T16:47:27+00:00"
},
{
"name": "symfony/service-contracts",
@ -2815,16 +2860,16 @@
},
{
"name": "webmozart/assert",
"version": "1.6.0",
"version": "1.8.0",
"source": {
"type": "git",
"url": "https://github.com/webmozart/assert.git",
"reference": "573381c0a64f155a0d9a23f4b0c797194805b925"
"reference": "ab2cb0b3b559010b75981b1bdce728da3ee90ad6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/webmozart/assert/zipball/573381c0a64f155a0d9a23f4b0c797194805b925",
"reference": "573381c0a64f155a0d9a23f4b0c797194805b925",
"url": "https://api.github.com/repos/webmozart/assert/zipball/ab2cb0b3b559010b75981b1bdce728da3ee90ad6",
"reference": "ab2cb0b3b559010b75981b1bdce728da3ee90ad6",
"shasum": ""
},
"require": {
@ -2832,7 +2877,7 @@
"symfony/polyfill-ctype": "^1.8"
},
"conflict": {
"vimeo/psalm": "<3.6.0"
"vimeo/psalm": "<3.9.1"
},
"require-dev": {
"phpunit/phpunit": "^4.8.36 || ^7.5.13"
@ -2859,7 +2904,7 @@
"check",
"validate"
],
"time": "2019-11-24T13:36:37+00:00"
"time": "2020-04-18T12:12:48+00:00"
}
],
"aliases": [],

View file

@ -537,7 +537,7 @@ At the end of the menu:
At the end of file, before clearing floating blocks:
{if="!empty($plugin_errors) && isLoggedIn()"}
{if="!empty($plugin_errors) && $is_logged_in"}
<ul class="errors">
{loop="plugin_errors"}
<li>{$value}</li>

View file

@ -1,14 +1,14 @@
### Feeds options
Feeds are available in ATOM with `?do=atom` and RSS with `do=RSS`.
Feeds are available in ATOM with `/feed/atom` and RSS with `/feed/rss`.
Options:
- You can use `permalinks` in the feed URL to get permalink to Shaares instead of direct link to shaared URL.
- E.G. `https://my.shaarli.domain/?do=atom&permalinks`.
- E.G. `https://my.shaarli.domain/feed/atom?permalinks`.
- You can use `nb` parameter in the feed URL to specify the number of Shaares you want in a feed (default if not specified: `50`). The keyword `all` is available if you want everything.
- `https://my.shaarli.domain/?do=atom&permalinks&nb=42`
- `https://my.shaarli.domain/?do=atom&permalinks&nb=all`
- `https://my.shaarli.domain/feed/atom?permalinks&nb=42`
- `https://my.shaarli.domain/feed/atom?permalinks&nb=all`
### RSS Feeds or Picture Wall for a specific search/tag
@ -23,6 +23,6 @@ For example, if you want to subscribe only to links tagged `photography`:
- The same method **also works for a full-text search** (_Search_ box) **and for the Picture Wall** (want to only see pictures about `nature`?)
- You can also build the URLs manually:
- `https://my.shaarli.domain/?do=rss&searchtags=nature`
- `https://my.shaarli.domain/links/?do=picwall&searchterm=poney`
- `https://my.shaarli.domain/links/picture-wall?searchterm=poney`
![](images/rss-filter-1.png) ![](images/rss-filter-2.png)

View file

@ -32,20 +32,20 @@ Here is a list :
```
http://<replace_domain>/
http://<replace_domain>/?nonope
http://<replace_domain>/?do=addlink
http://<replace_domain>/?do=changepasswd
http://<replace_domain>/?do=changetag
http://<replace_domain>/?do=configure
http://<replace_domain>/?do=tools
http://<replace_domain>/?do=daily
http://<replace_domain>/?post
http://<replace_domain>/?do=export
http://<replace_domain>/?do=import
http://<replace_domain>/admin/add-shaare
http://<replace_domain>/admin/password
http://<replace_domain>/admin/tags
http://<replace_domain>/admin/configure
http://<replace_domain>/admin/tools
http://<replace_domain>/daily
http://<replace_domain>/admin/shaare
http://<replace_domain>/admin/export
http://<replace_domain>/admin/import
http://<replace_domain>/login
http://<replace_domain>/?do=picwall
http://<replace_domain>/?do=pluginadmin
http://<replace_domain>/?do=tagcloud
http://<replace_domain>/?do=taglist
http://<replace_domain>/picture-wall
http://<replace_domain>/admin/plugins
http://<replace_domain>/tags/cloud
http://<replace_domain>/tags/list
```
#### Improve existing translation

File diff suppressed because it is too large Load diff

1951
index.php

File diff suppressed because it is too large Load diff

85
init.php Normal file
View file

@ -0,0 +1,85 @@
<?php
require_once __DIR__ . '/vendor/autoload.php';
use Shaarli\ApplicationUtils;
use Shaarli\Security\SessionManager;
// Set 'UTC' as the default timezone if it is not defined in php.ini
// See http://php.net/manual/en/datetime.configuration.php#ini.date.timezone
if (date_default_timezone_get() == '') {
date_default_timezone_set('UTC');
}
// High execution time in case of problematic imports/exports.
ini_set('max_input_time', '60');
// Try to set max upload file size and read
ini_set('memory_limit', '128M');
ini_set('post_max_size', '16M');
ini_set('upload_max_filesize', '16M');
// See all error except warnings
error_reporting(E_ALL^E_WARNING);
// 3rd-party libraries
if (! file_exists(__DIR__ . '/vendor/autoload.php')) {
header('Content-Type: text/plain; charset=utf-8');
echo "Error: missing Composer configuration\n\n"
."If you installed Shaarli through Git or using the development branch,\n"
."please refer to the installation documentation to install PHP"
." dependencies using Composer:\n"
."- https://shaarli.readthedocs.io/en/master/Server-configuration/\n"
."- https://shaarli.readthedocs.io/en/master/Download-and-Installation/";
exit;
}
// Ensure the PHP version is supported
try {
ApplicationUtils::checkPHPVersion('7.1', PHP_VERSION);
} catch (Exception $exc) {
header('Content-Type: text/plain; charset=utf-8');
echo $exc->getMessage();
exit;
}
// Force cookie path (but do not change lifetime)
$cookie = session_get_cookie_params();
$cookiedir = '';
if (dirname($_SERVER['SCRIPT_NAME']) != '/') {
$cookiedir = dirname($_SERVER["SCRIPT_NAME"]).'/';
}
// Set default cookie expiration and path.
session_set_cookie_params($cookie['lifetime'], $cookiedir, $_SERVER['SERVER_NAME']);
// Set session parameters on server side.
// Use cookies to store session.
ini_set('session.use_cookies', 1);
// Force cookies for session (phpsessionID forbidden in URL).
ini_set('session.use_only_cookies', 1);
// Prevent PHP form using sessionID in URL if cookies are disabled.
ini_set('session.use_trans_sid', false);
define('SHAARLI_VERSION', ApplicationUtils::getVersion(__DIR__ .'/'. ApplicationUtils::$VERSION_FILE));
session_name('shaarli');
// Start session if needed (Some server auto-start sessions).
if (session_status() == PHP_SESSION_NONE) {
session_start();
}
// Regenerate session ID if invalid or not defined in cookie.
if (isset($_COOKIE['shaarli']) && !SessionManager::checkId($_COOKIE['shaarli'])) {
session_regenerate_id(true);
$_COOKIE['shaarli'] = session_id();
}
// LC_MESSAGES isn't defined without php-intl, in this case use LC_COLLATE locale instead.
if (! defined('LC_MESSAGES')) {
define('LC_MESSAGES', LC_COLLATE);
}
// Prevent caching on client side or proxy: (yes, it's ugly)
header("Last-Modified: " . gmdate("D, d M Y H:i:s") . " GMT");
header("Cache-Control: no-store, no-cache, must-revalidate");
header("Cache-Control: post-check=0, pre-check=0", false);
header("Pragma: no-cache");

View file

@ -5,7 +5,7 @@
* Adds the addlink input on the linklist page.
*/
use Shaarli\Router;
use Shaarli\Render\TemplatePage;
/**
* When linklist is displayed, add play videos to header's toolbar.
@ -16,11 +16,11 @@
*/
function hook_addlink_toolbar_render_header($data)
{
if ($data['_PAGE_'] == Router::$PAGE_LINKLIST && $data['_LOGGEDIN_'] === true) {
if ($data['_PAGE_'] == TemplatePage::LINKLIST && $data['_LOGGEDIN_'] === true) {
$form = array(
'attr' => array(
'method' => 'GET',
'action' => '',
'action' => $data['_BASE_PATH_'] . '/admin/shaare',
'name' => 'addform',
'class' => 'addform',
),

View file

@ -1,5 +1,5 @@
<span>
<a href="https://web.archive.org/web/%s">
<img class="linklist-plugin-icon" src="plugins/archiveorg/internetarchive.png" title="%s" alt="archive.org" />
<img class="linklist-plugin-icon" src="%s/archiveorg/internetarchive.png" title="%s" alt="archive.org" />
</a>
</span>

View file

@ -17,12 +17,13 @@
function hook_archiveorg_render_linklist($data)
{
$archive_html = file_get_contents(PluginManager::$PLUGINS_PATH . '/archiveorg/archiveorg.html');
$path = ($data['_BASE_PATH_'] ?? '') . '/' . PluginManager::$PLUGINS_PATH;
foreach ($data['links'] as &$value) {
if ($value['private'] && preg_match('/^\?[a-zA-Z0-9-_@]{6}($|&|#)/', $value['real_url'])) {
continue;
}
$archive = sprintf($archive_html, $value['url'], t('View on archive.org'));
$archive = sprintf($archive_html, $value['url'], $path, t('View on archive.org'));
$value['link_plugin'][] = $archive;
}

View file

@ -16,7 +16,7 @@
use Shaarli\Config\ConfigManager;
use Shaarli\Plugin\PluginManager;
use Shaarli\Router;
use Shaarli\Render\TemplatePage;
/**
* In the footer hook, there is a working example of a translation extension for Shaarli.
@ -74,7 +74,7 @@ function demo_plugin_init($conf)
function hook_demo_plugin_render_header($data)
{
// Only execute when linklist is rendered.
if ($data['_PAGE_'] == Router::$PAGE_LINKLIST) {
if ($data['_PAGE_'] == TemplatePage::LINKLIST) {
// If loggedin
if ($data['_LOGGEDIN_'] === true) {
/*
@ -118,7 +118,7 @@ function hook_demo_plugin_render_header($data)
$form = array(
'attr' => array(
'method' => 'GET',
'action' => '?',
'action' => $data['_BASE_PATH_'] . '/',
'class' => 'addform',
),
'inputs' => array(
@ -441,9 +441,9 @@ function hook_demo_plugin_delete_link($data)
function hook_demo_plugin_render_feed($data)
{
foreach ($data['links'] as &$link) {
if ($data['_PAGE_'] == Router::$PAGE_FEED_ATOM) {
if ($data['_PAGE_'] == TemplatePage::FEED_ATOM) {
$link['description'] .= ' - ATOM Feed' ;
} elseif ($data['_PAGE_'] == Router::$PAGE_FEED_RSS) {
} elseif ($data['_PAGE_'] == TemplatePage::FEED_RSS) {
$link['description'] .= ' - RSS Feed';
}
}

View file

@ -6,7 +6,7 @@
use Shaarli\Config\ConfigManager;
use Shaarli\Plugin\PluginManager;
use Shaarli\Router;
use Shaarli\Render\TemplatePage;
/**
* Display an error everywhere if the plugin is enabled without configuration.
@ -76,7 +76,7 @@ function hook_isso_render_linklist($data, $conf)
*/
function hook_isso_render_includes($data)
{
if ($data['_PAGE_'] == Router::$PAGE_LINKLIST) {
if ($data['_PAGE_'] == TemplatePage::LINKLIST) {
$data['css_files'][] = PluginManager::$PLUGINS_PATH . '/isso/isso.css';
}

View file

@ -1,5 +0,0 @@
<span>
<a href="?%s#isso-thread">
<img class="linklist-plugin-icon" src="plugins/archiveorg/internetarchive.png" title="%s" alt="archive.org" />
</a>
</span>

View file

@ -7,7 +7,7 @@
*/
use Shaarli\Plugin\PluginManager;
use Shaarli\Router;
use Shaarli\Render\TemplatePage;
/**
* When linklist is displayed, add play videos to header's toolbar.
@ -18,7 +18,7 @@
*/
function hook_playvideos_render_header($data)
{
if ($data['_PAGE_'] == Router::$PAGE_LINKLIST) {
if ($data['_PAGE_'] == TemplatePage::LINKLIST) {
$playvideo = array(
'attr' => array(
'href' => '#',
@ -42,7 +42,7 @@ function hook_playvideos_render_header($data)
*/
function hook_playvideos_render_footer($data)
{
if ($data['_PAGE_'] == Router::$PAGE_LINKLIST) {
if ($data['_PAGE_'] == TemplatePage::LINKLIST) {
$data['js_files'][] = PluginManager::$PLUGINS_PATH . '/playvideos/jquery-1.11.2.min.js';
$data['js_files'][] = PluginManager::$PLUGINS_PATH . '/playvideos/youtube_playlist.js';
}

View file

@ -13,7 +13,7 @@
use Shaarli\Config\ConfigManager;
use Shaarli\Feed\FeedBuilder;
use Shaarli\Plugin\PluginManager;
use Shaarli\Router;
use Shaarli\Render\TemplatePage;
/**
* Plugin init function - set the hub to the default appspot one.
@ -41,7 +41,7 @@ function pubsubhubbub_init($conf)
*/
function hook_pubsubhubbub_render_feed($data, $conf)
{
$feedType = $data['_PAGE_'] == Router::$PAGE_FEED_RSS ? FeedBuilder::$FEED_RSS : FeedBuilder::$FEED_ATOM;
$feedType = $data['_PAGE_'] == TemplatePage::FEED_RSS ? FeedBuilder::$FEED_RSS : FeedBuilder::$FEED_ATOM;
$template = file_get_contents(PluginManager::$PLUGINS_PATH . '/pubsubhubbub/hub.'. $feedType .'.xml');
$data['feed_plugins_header'][] = sprintf($template, $conf->get('plugins.PUBSUBHUB_URL'));
@ -60,8 +60,8 @@ function hook_pubsubhubbub_render_feed($data, $conf)
function hook_pubsubhubbub_save_link($data, $conf)
{
$feeds = array(
index_url($_SERVER) .'?do=atom',
index_url($_SERVER) .'?do=rss',
index_url($_SERVER) .'feed/atom',
index_url($_SERVER) .'feed/rss',
);
$httpPost = function_exists('curl_version') ? false : 'nocurl_http_post';

View file

@ -6,7 +6,7 @@
*/
use Shaarli\Plugin\PluginManager;
use Shaarli\Router;
use Shaarli\Render\TemplatePage;
/**
* Add qrcode icon to link_plugin when rendering linklist.
@ -19,11 +19,12 @@ function hook_qrcode_render_linklist($data)
{
$qrcode_html = file_get_contents(PluginManager::$PLUGINS_PATH . '/qrcode/qrcode.html');
$path = ($data['_BASE_PATH_'] ?? '') . '/' . PluginManager::$PLUGINS_PATH;
foreach ($data['links'] as &$value) {
$qrcode = sprintf(
$qrcode_html,
$value['url'],
PluginManager::$PLUGINS_PATH
$path
);
$value['link_plugin'][] = $qrcode;
}
@ -40,7 +41,7 @@ function hook_qrcode_render_linklist($data)
*/
function hook_qrcode_render_footer($data)
{
if ($data['_PAGE_'] == Router::$PAGE_LINKLIST) {
if ($data['_PAGE_'] == TemplatePage::LINKLIST) {
$data['js_files'][] = PluginManager::$PLUGINS_PATH . '/qrcode/shaarli-qrcode.js';
}
@ -56,7 +57,7 @@ function hook_qrcode_render_footer($data)
*/
function hook_qrcode_render_includes($data)
{
if ($data['_PAGE_'] == Router::$PAGE_LINKLIST) {
if ($data['_PAGE_'] == TemplatePage::LINKLIST) {
$data['css_files'][] = PluginManager::$PLUGINS_PATH . '/qrcode/qrcode.css';
}

View file

@ -34,8 +34,9 @@ function showQrCode(caller,loading)
{
if (!loading) // If javascript lib is still loading, do not append script to body.
{
var basePath = document.querySelector('input[name="js_base_path"]').value;
var element = document.createElement("script");
element.src = "plugins/qrcode/qr-1.1.3.min.js";
element.src = basePath + "/plugins/qrcode/qr-1.1.3.min.js";
document.body.appendChild(element);
}
setTimeout(function() { showQrCode(caller,true);}, 200); // Retry in 200 milliseconds.

View file

@ -21,7 +21,7 @@ The directory structure should look like:
To enable the plugin, you can either:
* enable it in the plugins administration page (`?do=pluginadmin`).
* enable it in the plugins administration page (`/admin/plugins`).
* add `wallabag` to your list of enabled plugins in `data/config.json.php` (`general.enabled_plugins` section).
### Configuration

View file

@ -45,12 +45,14 @@ function hook_wallabag_render_linklist($data, $conf)
$wallabagHtml = file_get_contents(PluginManager::$PLUGINS_PATH . '/wallabag/wallabag.html');
$linkTitle = t('Save to wallabag');
$path = ($data['_BASE_PATH_'] ?? '') . '/' . PluginManager::$PLUGINS_PATH;
foreach ($data['links'] as &$value) {
$wallabag = sprintf(
$wallabagHtml,
$wallabagInstance->getWallabagUrl(),
urlencode($value['url']),
PluginManager::$PLUGINS_PATH,
$path,
$linkTitle
);
$value['link_plugin'][] = $wallabag;

Some files were not shown because too many files have changed in this diff Show more