Merge branch 'master' of github.com:Shaarli/Shaarli

This commit is contained in:
Keith Carangelo 2020-11-17 09:31:12 -05:00
commit b2eb77e1f7
189 changed files with 7325 additions and 1694 deletions

View file

@ -17,27 +17,13 @@ http {
index index.html index.php; index index.html index.php;
server { server {
listen 80; listen 80;
root /var/www/shaarli; root /var/www/shaarli;
access_log /var/log/nginx/shaarli.access.log; access_log /var/log/nginx/shaarli.access.log;
error_log /var/log/nginx/shaarli.error.log; error_log /var/log/nginx/shaarli.error.log;
location ~ /\. { location ~* \.(?:ico|css|js|gif|jpe?g|png|ttf|oet|woff2?)$ {
# deny access to dotfiles
access_log off;
log_not_found off;
deny all;
}
location ~ ~$ {
# deny access to temp editor files, e.g. "script.php~"
access_log off;
log_not_found off;
deny all;
}
location ~* \.(?:ico|css|js|gif|jpe?g|png)$ {
# cache static assets # cache static assets
expires max; expires max;
add_header Pragma public; add_header Pragma public;
@ -49,30 +35,25 @@ http {
alias /var/www/shaarli/images/favicon.ico; alias /var/www/shaarli/images/favicon.ico;
} }
location / { location /doc/html/ {
# Slim - rewrite URLs default_type "text/html";
try_files $uri /index.php$is_args$args; try_files $uri $uri/ $uri.html =404;
} }
location ~ (index)\.php$ { location / {
# Slim - rewrite URLs & do NOT serve static files through this location
try_files _ /index.php$is_args$args;
}
location ~ index\.php$ {
# Slim - split URL path into (script_filename, path_info) # Slim - split URL path into (script_filename, path_info)
try_files $uri =404; try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$; fastcgi_split_path_info ^(index.php)(/.+)$;
# filter and proxy PHP requests to PHP-FPM # filter and proxy PHP requests to PHP-FPM
fastcgi_pass unix:/var/run/php-fpm.sock; fastcgi_pass unix:/var/run/php-fpm.sock;
fastcgi_index index.php; fastcgi_index index.php;
include fastcgi.conf; include fastcgi.conf;
} }
location ~ /doc/ {
default_type "text/html";
try_files $uri $uri/ $uri.html =404;
}
location ~ \.php$ {
# deny access to all other PHP scripts
deny all;
}
} }
} }

View file

@ -2,8 +2,16 @@
.dev .dev
.git .git
.github .github
.gitattributes
.gitignore
.travis.yml
tests tests
# Docker related resources are not needed inside the container
.dockerignore
Dockerfile
Dockerfile.armhf
# Docker Compose resources # Docker Compose resources
docker-compose.yml docker-compose.yml
@ -13,6 +21,9 @@ data/*
pagecache/* pagecache/*
tmp/* tmp/*
# Shaarli's docs are created during the build
doc/html/
# Eclipse project files # Eclipse project files
.settings .settings
.buildpath .buildpath

View file

@ -13,7 +13,7 @@ RewriteRule .* - [e=HTTP_AUTHORIZATION:%1]
# Alternative (if the 2 lines above don't work) # Alternative (if the 2 lines above don't work)
# SetEnvIf Authorization .+ HTTP_AUTHORIZATION=$0 # SetEnvIf Authorization .+ HTTP_AUTHORIZATION=$0
# REST API # Slim URL Redirection
# Ionos Hosting needs RewriteBase / # Ionos Hosting needs RewriteBase /
# RewriteBase / # RewriteBase /
RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-f

View file

@ -49,6 +49,10 @@ cache:
directories: directories:
- $HOME/.composer/cache - $HOME/.composer/cache
before_install:
# Disable xdebug: it significantly speed up tests and linter, and we don't use coverage yet
- phpenv config-rm xdebug.ini || echo 'No xdebug config.'
install: install:
# install/update composer and php dependencies # install/update composer and php dependencies
- composer config --unset platform && composer config platform.php $TRAVIS_PHP_VERSION - composer config --unset platform && composer config platform.php $TRAVIS_PHP_VERSION
@ -60,4 +64,5 @@ before_script:
script: script:
- make clean - make clean
- make check_permissions - make check_permissions
- make code_sniffer
- make all_tests - make all_tests

View file

@ -1,4 +1,4 @@
991 ArthurHoaro <arthur@hoa.ro> 1097 ArthurHoaro <arthur@hoa.ro>
402 VirtualTam <virtualtam@flibidi.net> 402 VirtualTam <virtualtam@flibidi.net>
294 nodiscc <nodiscc@gmail.com> 294 nodiscc <nodiscc@gmail.com>
56 Sébastien Sauvage <sebsauvage@sebsauvage.net> 56 Sébastien Sauvage <sebsauvage@sebsauvage.net>
@ -25,6 +25,7 @@
2 Alexandre G.-Raymond <alex@ndre.gr> 2 Alexandre G.-Raymond <alex@ndre.gr>
2 Chris Kuethe <chris.kuethe@gmail.com> 2 Chris Kuethe <chris.kuethe@gmail.com>
2 Felix Bartels <felix@host-consultants.de> 2 Felix Bartels <felix@host-consultants.de>
2 Ganesh Kandu <kanduganesh@gmail.com>
2 Guillaume Virlet <github@virlet.org> 2 Guillaume Virlet <github@virlet.org>
2 Knah Tsaeb <Knah-Tsaeb@knah-tsaeb.org> 2 Knah Tsaeb <Knah-Tsaeb@knah-tsaeb.org>
2 Mathieu Chabanon <git@matchab.fr> 2 Mathieu Chabanon <git@matchab.fr>
@ -39,6 +40,7 @@
2 pips <pips@e5150.fr> 2 pips <pips@e5150.fr>
2 trailjeep <trailjeep@gmail.com> 2 trailjeep <trailjeep@gmail.com>
2 yude <yudesleepy@gmail.com> 2 yude <yudesleepy@gmail.com>
2 yudete <yu@yude.moe>
1 Adrien Oliva <adrien.oliva@yapbreak.fr> 1 Adrien Oliva <adrien.oliva@yapbreak.fr>
1 Adrien le Maire <adrien@alemaire.be> 1 Adrien le Maire <adrien@alemaire.be>
1 Alexis J <alexis@effingo.be> 1 Alexis J <alexis@effingo.be>
@ -65,6 +67,7 @@
1 Kevin Masson <kevin.masson@methodinthemadness.eu> 1 Kevin Masson <kevin.masson@methodinthemadness.eu>
1 Knah Tsaeb <knah-tsaeb@knah-tsaeb.org> 1 Knah Tsaeb <knah-tsaeb@knah-tsaeb.org>
1 Lionel Martin <renarddesmers@gmail.com> 1 Lionel Martin <renarddesmers@gmail.com>
1 Loïc Carr <zizou.xena@gmail.com>
1 Mark Gerarts <mark.gerarts@gmail.com> 1 Mark Gerarts <mark.gerarts@gmail.com>
1 Marsup <marsup@gmail.com> 1 Marsup <marsup@gmail.com>
1 Paul van den Burg <github@paulvandenburg.nl> 1 Paul van den Burg <github@paulvandenburg.nl>

View file

@ -4,7 +4,55 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/) The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/). and this project adheres to [Semantic Versioning](http://semver.org/).
## [v0.12.1]() - UNRELEASED ## [v0.12.2]() - UNRELEASED
## [v0.12.1](https://github.com/shaarli/Shaarli/releases/tag/v0.12.0) - 2020-11-12
> nginx ([#1628](https://github.com/shaarli/Shaarli/pull/1628)) and Apache ([#1630](https://github.com/shaarli/Shaarli/pull/1630)) configurations have been reviewed. It is recommended that you
> update yours using [the documentation](https://shaarli.readthedocs.io/en/master/Server-configuration/).
> Users using official Docker image will receive updated configuration automatically.
### Added
- Bulk creation of bookmarks
- Server administration tool page (and install page requirements)
- Support any tag separator, not just whitespaces
- Share a private bookmark using a URL with a token
- Add a setting to retrieve bookmark metadata asynchronously (enabled by default)
- Highlight fulltext search results
- Weekly and monthly view/RSS feed for daily page
- MarkdownExtra formatter
- Default formatter: add a setting to disable auto-linkification
- Add mutex on datastore I/O operations to prevent data loss
- PHP 8.0 support
- REST API: allow override of creation and update dates
- Add strict types for bookmarks management
### Changed
- Improve regex and performances to extract HTML metadata (title, description, etc.)
- Support using Shaarli without URL rewriting (prefix URL with `/index.php/`)
- Improve the "Manage tags" tools page
- Use PSR-3 logger for login attempts
- Move utils classes to Shaarli\Helper namespace and folder
- Include php-simplexml in Docker image
- Raise 404 error instead of 500 if permalink access is denied
- Display error details even with dev.debug set to false
- Reviewed nginx configuration
- Reviewed Apache configuration
- Replace vimeo link in demo bookmarks due to IP ban on the demo instance
- Apply PSR-12 on code base, and add CI check using PHPCS
### Fixed
- Compatiliby issue on login with PHP 7.1
- Japanese translations update
- Redirect to referrer after bookmark deletion
- Inject ROOT_PATH in plugin instead of regenerating it everywhere
- Wallabag plugin: minor improvements
- REST API postLink: change relative path to absolute path
- Webpack: fix vintage theme images include
- Docker-compose: fix SSL certificate + add parameter for Docker tag
### Removed
- `config.json.php` new lines in prefix/suffix to prevent issues with Windows PHP
## [v0.12.0](https://github.com/shaarli/Shaarli/releases/tag/v0.12.0) - 2020-10-13 ## [v0.12.0](https://github.com/shaarli/Shaarli/releases/tag/v0.12.0) - 2020-10-13

View file

@ -44,6 +44,7 @@ RUN apk --update --no-cache add \
php7-openssl \ php7-openssl \
php7-session \ php7-session \
php7-xml \ php7-xml \
php7-simplexml \
php7-zlib \ php7-zlib \
s6 s6

View file

@ -27,10 +27,6 @@ PHPCS := $(BIN)/phpcs
code_sniffer: code_sniffer:
@$(PHPCS) @$(PHPCS)
### - errors filtered by coding standard: PEAR, PSR1, PSR2, Zend...
PHPCS_%:
@$(PHPCS) --report-full --report-width=200 --standard=$*
### - errors by Git author ### - errors by Git author
code_sniffer_blame: code_sniffer_blame:
@$(PHPCS) --report-gitblame @$(PHPCS) --report-gitblame

View file

@ -9,7 +9,7 @@ _It is designed to be personal (single-user), fast and handy._
[![](https://img.shields.io/badge/stable-v0.11.1-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.11.1) [![](https://img.shields.io/badge/stable-v0.11.1-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.11.1)
[![](https://img.shields.io/travis/shaarli/Shaarli/stable.svg?label=stable)](https://travis-ci.org/shaarli/Shaarli) [![](https://img.shields.io/travis/shaarli/Shaarli/stable.svg?label=stable)](https://travis-ci.org/shaarli/Shaarli)
&bull; &bull;
[![](https://img.shields.io/badge/latest-v0.12.0-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.12.0) [![](https://img.shields.io/badge/latest-v0.12.1-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.12.1)
[![](https://img.shields.io/travis/shaarli/Shaarli/latest.svg?label=latest)](https://travis-ci.org/shaarli/Shaarli) [![](https://img.shields.io/travis/shaarli/Shaarli/latest.svg?label=latest)](https://travis-ci.org/shaarli/Shaarli)
&bull; &bull;
[![](https://img.shields.io/badge/master-v0.12.x-blue.svg)](https://github.com/shaarli/Shaarli) [![](https://img.shields.io/badge/master-v0.12.x-blue.svg)](https://github.com/shaarli/Shaarli)

View file

@ -1,9 +1,11 @@
<?php <?php
namespace Shaarli; namespace Shaarli;
use DateTime; use DateTime;
use Exception; use Exception;
use Shaarli\Bookmark\Bookmark; use Shaarli\Bookmark\Bookmark;
use Shaarli\Helper\FileUtils;
/** /**
* Class History * Class History
@ -30,27 +32,27 @@ class History
/** /**
* @var string Action key: a new link has been created. * @var string Action key: a new link has been created.
*/ */
const CREATED = 'CREATED'; public const CREATED = 'CREATED';
/** /**
* @var string Action key: a link has been updated. * @var string Action key: a link has been updated.
*/ */
const UPDATED = 'UPDATED'; public const UPDATED = 'UPDATED';
/** /**
* @var string Action key: a link has been deleted. * @var string Action key: a link has been deleted.
*/ */
const DELETED = 'DELETED'; public const DELETED = 'DELETED';
/** /**
* @var string Action key: settings have been updated. * @var string Action key: settings have been updated.
*/ */
const SETTINGS = 'SETTINGS'; public const SETTINGS = 'SETTINGS';
/** /**
* @var string Action key: a bulk import has been processed. * @var string Action key: a bulk import has been processed.
*/ */
const IMPORT = 'IMPORT'; public const IMPORT = 'IMPORT';
/** /**
* @var string History file path. * @var string History file path.

View file

@ -41,7 +41,7 @@ class Languages
/** /**
* Core translations domain * Core translations domain
*/ */
const DEFAULT_DOMAIN = 'shaarli'; public const DEFAULT_DOMAIN = 'shaarli';
/** /**
* @var TranslatorInterface * @var TranslatorInterface
@ -76,7 +76,8 @@ public function __construct($language, $conf)
$this->language = $confLanguage; $this->language = $confLanguage;
} }
if (! extension_loaded('gettext') if (
! extension_loaded('gettext')
|| in_array($this->conf->get('translation.mode', 'auto'), ['auto', 'php']) || in_array($this->conf->get('translation.mode', 'auto'), ['auto', 'php'])
) { ) {
$this->initPhpTranslator(); $this->initPhpTranslator();
@ -98,7 +99,7 @@ protected function initGettextTranslator()
$this->translator->loadDomain(self::DEFAULT_DOMAIN, 'inc/languages'); $this->translator->loadDomain(self::DEFAULT_DOMAIN, 'inc/languages');
// Default extension translation from the current theme // Default extension translation from the current theme
$themeTransFolder = rtrim($this->conf->get('raintpl_tpl'), '/') .'/'. $this->conf->get('theme') .'/language'; $themeTransFolder = rtrim($this->conf->get('raintpl_tpl'), '/') . '/' . $this->conf->get('theme') . '/language';
if (is_dir($themeTransFolder)) { if (is_dir($themeTransFolder)) {
$this->translator->loadDomain($this->conf->get('theme'), $themeTransFolder, false); $this->translator->loadDomain($this->conf->get('theme'), $themeTransFolder, false);
} }
@ -121,7 +122,9 @@ protected function initPhpTranslator()
$translations = new Translations(); $translations = new Translations();
// Core translations // Core translations
try { try {
$translations = $translations->addFromPoFile('inc/languages/'. $this->language .'/LC_MESSAGES/shaarli.po'); $translations = $translations->addFromPoFile(
'inc/languages/' . $this->language . '/LC_MESSAGES/shaarli.po'
);
$translations->setDomain('shaarli'); $translations->setDomain('shaarli');
$this->translator->loadTranslations($translations); $this->translator->loadTranslations($translations);
} catch (\InvalidArgumentException $e) { } catch (\InvalidArgumentException $e) {
@ -129,11 +132,11 @@ protected function initPhpTranslator()
// Default extension translation from the current theme // Default extension translation from the current theme
$theme = $this->conf->get('theme'); $theme = $this->conf->get('theme');
$themeTransFolder = rtrim($this->conf->get('raintpl_tpl'), '/') .'/'. $theme .'/language'; $themeTransFolder = rtrim($this->conf->get('raintpl_tpl'), '/') . '/' . $theme . '/language';
if (is_dir($themeTransFolder)) { if (is_dir($themeTransFolder)) {
try { try {
$translations = Translations::fromPoFile( $translations = Translations::fromPoFile(
$themeTransFolder .'/'. $this->language .'/LC_MESSAGES/'. $theme .'.po' $themeTransFolder . '/' . $this->language . '/LC_MESSAGES/' . $theme . '.po'
); );
$translations->setDomain($theme); $translations->setDomain($theme);
$this->translator->loadTranslations($translations); $this->translator->loadTranslations($translations);
@ -149,7 +152,7 @@ protected function initPhpTranslator()
try { try {
$extension = Translations::fromPoFile( $extension = Translations::fromPoFile(
$translationPath . $this->language .'/LC_MESSAGES/'. $domain .'.po' $translationPath . $this->language . '/LC_MESSAGES/' . $domain . '.po'
); );
$extension->setDomain($domain); $extension->setDomain($domain);
$this->translator->loadTranslations($extension); $this->translator->loadTranslations($extension);
@ -183,6 +186,7 @@ public static function getAvailableLanguages()
'en' => t('English'), 'en' => t('English'),
'fr' => t('French'), 'fr' => t('French'),
'jp' => t('Japanese'), 'jp' => t('Japanese'),
'ru' => t('Russian'),
]; ];
} }
} }

View file

@ -13,7 +13,7 @@
*/ */
class Thumbnailer class Thumbnailer
{ {
const COMMON_MEDIA_DOMAINS = [ protected const COMMON_MEDIA_DOMAINS = [
'imgur.com', 'imgur.com',
'flickr.com', 'flickr.com',
'youtube.com', 'youtube.com',
@ -31,9 +31,9 @@ class Thumbnailer
'deviantart.com', 'deviantart.com',
]; ];
const MODE_ALL = 'all'; public const MODE_ALL = 'all';
const MODE_COMMON = 'common'; public const MODE_COMMON = 'common';
const MODE_NONE = 'none'; public const MODE_NONE = 'none';
/** /**
* @var WebThumbnailer instance. * @var WebThumbnailer instance.
@ -60,7 +60,7 @@ public function __construct($conf)
// TODO: create a proper error handling system able to catch exceptions... // TODO: create a proper error handling system able to catch exceptions...
die(t( die(t(
'php-gd extension must be loaded to use thumbnails. ' 'php-gd extension must be loaded to use thumbnails. '
.'Thumbnails are now disabled. Please reload the page.' . 'Thumbnails are now disabled. Please reload the page.'
)); ));
} }
@ -81,7 +81,8 @@ public function __construct($conf)
*/ */
public function get($url) public function get($url)
{ {
if ($this->conf->get('thumbnails.mode') === self::MODE_COMMON if (
$this->conf->get('thumbnails.mode') === self::MODE_COMMON
&& ! $this->isCommonMediaOrImage($url) && ! $this->isCommonMediaOrImage($url)
) { ) {
return false; return false;

View file

@ -1,4 +1,5 @@
<?php <?php
/** /**
* Generates a list of available timezone continents and cities. * Generates a list of available timezone continents and cities.
* *
@ -43,7 +44,7 @@ function generateTimeZoneData($installedTimeZones, $preselectedTimezone = '')
// Try to split the provided timezone // Try to split the provided timezone
$spos = strpos($preselectedTimezone, '/'); $spos = strpos($preselectedTimezone, '/');
$pcontinent = substr($preselectedTimezone, 0, $spos); $pcontinent = substr($preselectedTimezone, 0, $spos);
$pcity = substr($preselectedTimezone, $spos+1); $pcity = substr($preselectedTimezone, $spos + 1);
} }
$continents = []; $continents = [];
@ -60,7 +61,7 @@ function generateTimeZoneData($installedTimeZones, $preselectedTimezone = '')
} }
$continent = substr($tz, 0, $spos); $continent = substr($tz, 0, $spos);
$city = substr($tz, $spos+1); $city = substr($tz, $spos + 1);
$cities[] = ['continent' => $continent, 'city' => $city]; $cities[] = ['continent' => $continent, 'city' => $city];
$continents[$continent] = true; $continents[$continent] = true;
} }
@ -85,7 +86,7 @@ function generateTimeZoneData($installedTimeZones, $preselectedTimezone = '')
function isTimeZoneValid($continent, $city) function isTimeZoneValid($continent, $city)
{ {
return in_array( return in_array(
$continent.'/'.$city, $continent . '/' . $city,
timezone_identifiers_list() timezone_identifiers_list()
); );
} }

View file

@ -1,24 +1,27 @@
<?php <?php
/** /**
* Shaarli utilities * Shaarli utilities
*/ */
/** /**
* Logs a message to a text file * Format log using provided data.
* *
* The log format is compatible with fail2ban. * @param string $message the message to log
* @param string|null $clientIp the client's remote IPv4/IPv6 address
* *
* @param string $logFile where to write the logs * @return string Formatted message to log
* @param string $clientIp the client's remote IPv4/IPv6 address
* @param string $message the message to log
*/ */
function logm($logFile, $clientIp, $message) function format_log(string $message, string $clientIp = null): string
{ {
file_put_contents( $out = $message;
$logFile,
date('Y/m/d H:i:s').' - '.$clientIp.' - '.strval($message).PHP_EOL, if (!empty($clientIp)) {
FILE_APPEND // Note: we keep the first dash to avoid breaking fail2ban configs
); $out = '- ' . $clientIp . ' - ' . $out;
}
return $out;
} }
/** /**
@ -100,7 +103,7 @@ function escape($input)
} }
if (is_array($input)) { if (is_array($input)) {
$out = array(); $out = [];
foreach ($input as $key => $value) { foreach ($input as $key => $value) {
$out[escape($key)] = escape($value); $out[escape($key)] = escape($value);
} }
@ -161,7 +164,7 @@ function checkDateFormat($format, $string)
* *
* @return string $referer - final referer. * @return string $referer - final referer.
*/ */
function generateLocation($referer, $host, $loopTerms = array()) function generateLocation($referer, $host, $loopTerms = [])
{ {
$finalReferer = './?'; $finalReferer = './?';
@ -194,7 +197,7 @@ function generateLocation($referer, $host, $loopTerms = array())
function autoLocale($headerLocale) function autoLocale($headerLocale)
{ {
// Default if browser does not send HTTP_ACCEPT_LANGUAGE // Default if browser does not send HTTP_ACCEPT_LANGUAGE
$locales = array('en_US', 'en_US.utf8', 'en_US.UTF-8'); $locales = ['en_US', 'en_US.utf8', 'en_US.UTF-8'];
if (! empty($headerLocale)) { if (! empty($headerLocale)) {
if (preg_match_all('/([a-z]{2,3})[-_]?([a-z]{2})?,?/i', $headerLocale, $matches, PREG_SET_ORDER)) { if (preg_match_all('/([a-z]{2,3})[-_]?([a-z]{2})?,?/i', $headerLocale, $matches, PREG_SET_ORDER)) {
$attempts = []; $attempts = [];
@ -324,6 +327,23 @@ function format_date($date, $time = true, $intl = true)
return $formatter->format($date); return $formatter->format($date);
} }
/**
* Format the date month according to the locale.
*
* @param DateTimeInterface $date to format.
*
* @return bool|string Formatted date, or false if the input is invalid.
*/
function format_month(DateTimeInterface $date)
{
if (! $date instanceof DateTimeInterface) {
return false;
}
return strftime('%B', $date->getTimestamp());
}
/** /**
* Check if the input is an integer, no matter its real type. * Check if the input is an integer, no matter its real type.
* *
@ -357,13 +377,15 @@ function return_bytes($val)
return $val; return $val;
} }
$val = trim($val); $val = trim($val);
$last = strtolower($val[strlen($val)-1]); $last = strtolower($val[strlen($val) - 1]);
$val = intval(substr($val, 0, -1)); $val = intval(substr($val, 0, -1));
switch ($last) { switch ($last) {
case 'g': case 'g':
$val *= 1024; $val *= 1024;
// do no break in order 1024^2 for each unit
case 'm': case 'm':
$val *= 1024; $val *= 1024;
// do no break in order 1024^2 for each unit
case 'k': case 'k':
$val *= 1024; $val *= 1024;
} }
@ -452,16 +474,22 @@ function alphabetical_sort(&$data, $reverse = false, $byKeys = false)
* Wrapper function for translation which match the API * Wrapper function for translation which match the API
* of gettext()/_() and ngettext(). * of gettext()/_() and ngettext().
* *
* @param string $text Text to translate. * @param string $text Text to translate.
* @param string $nText The plural message ID. * @param string $nText The plural message ID.
* @param int $nb The number of items for plural forms. * @param int $nb The number of items for plural forms.
* @param string $domain The domain where the translation is stored (default: shaarli). * @param string $domain The domain where the translation is stored (default: shaarli).
* @param array $variables Associative array of variables to replace in translated text.
* @param bool $fixCase Apply `ucfirst` on the translated string, might be useful for strings with variables.
* *
* @return string Text translated. * @return string Text translated.
*/ */
function t($text, $nText = '', $nb = 1, $domain = 'shaarli') function t($text, $nText = '', $nb = 1, $domain = 'shaarli', $variables = [], $fixCase = false)
{ {
return dn__($domain, $text, $nText, $nb); $postFunction = $fixCase ? 'ucfirst' : function ($input) {
return $input;
};
return $postFunction(dn__($domain, $text, $nText, $nb, $variables));
} }
/** /**
@ -471,4 +499,3 @@ function exception2text(Throwable $e): string
{ {
return $e->getMessage() . PHP_EOL . $e->getFile() . $e->getLine() . PHP_EOL . $e->getTraceAsString(); return $e->getMessage() . PHP_EOL . $e->getFile() . $e->getLine() . PHP_EOL . $e->getTraceAsString();
} }

View file

@ -1,4 +1,5 @@
<?php <?php
namespace Shaarli\Api; namespace Shaarli\Api;
use malkusch\lock\mutex\FlockMutex; use malkusch\lock\mutex\FlockMutex;
@ -108,7 +109,8 @@ protected function checkRequest($request)
*/ */
protected function checkToken($request) protected function checkToken($request)
{ {
if (!$request->hasHeader('Authorization') if (
!$request->hasHeader('Authorization')
&& !isset($this->container->environment['REDIRECT_HTTP_AUTHORIZATION']) && !isset($this->container->environment['REDIRECT_HTTP_AUTHORIZATION'])
) { ) {
throw new ApiAuthorizationException('JWT token not provided'); throw new ApiAuthorizationException('JWT token not provided');

View file

@ -1,4 +1,5 @@
<?php <?php
namespace Shaarli\Api; namespace Shaarli\Api;
use Shaarli\Api\Exceptions\ApiAuthorizationException; use Shaarli\Api\Exceptions\ApiAuthorizationException;
@ -27,7 +28,7 @@ public static function validateJwtToken($token, $secret)
throw new ApiAuthorizationException('Malformed JWT token'); throw new ApiAuthorizationException('Malformed JWT token');
} }
$genSign = Base64Url::encode(hash_hmac('sha512', $parts[0] .'.'. $parts[1], $secret, true)); $genSign = Base64Url::encode(hash_hmac('sha512', $parts[0] . '.' . $parts[1], $secret, true));
if ($parts[2] != $genSign) { if ($parts[2] != $genSign) {
throw new ApiAuthorizationException('Invalid JWT signature'); throw new ApiAuthorizationException('Invalid JWT signature');
} }
@ -42,7 +43,8 @@ public static function validateJwtToken($token, $secret)
throw new ApiAuthorizationException('Invalid JWT payload'); throw new ApiAuthorizationException('Invalid JWT payload');
} }
if (empty($payload->iat) if (
empty($payload->iat)
|| $payload->iat > time() || $payload->iat > time()
|| time() - $payload->iat > ApiMiddleware::$TOKEN_DURATION || time() - $payload->iat > ApiMiddleware::$TOKEN_DURATION
) { ) {

View file

@ -1,6 +1,5 @@
<?php <?php
namespace Shaarli\Api\Controllers; namespace Shaarli\Api\Controllers;
use Shaarli\Api\Exceptions\ApiBadParametersException; use Shaarli\Api\Exceptions\ApiBadParametersException;

View file

@ -29,13 +29,13 @@ public function getInfo($request, $response)
$info = [ $info = [
'global_counter' => $this->bookmarkService->count(), 'global_counter' => $this->bookmarkService->count(),
'private_counter' => $this->bookmarkService->count(BookmarkFilter::$PRIVATE), 'private_counter' => $this->bookmarkService->count(BookmarkFilter::$PRIVATE),
'settings' => array( 'settings' => [
'title' => $this->conf->get('general.title', 'Shaarli'), 'title' => $this->conf->get('general.title', 'Shaarli'),
'header_link' => $this->conf->get('general.header_link', '?'), 'header_link' => $this->conf->get('general.header_link', '?'),
'timezone' => $this->conf->get('general.timezone', 'UTC'), 'timezone' => $this->conf->get('general.timezone', 'UTC'),
'enabled_plugins' => $this->conf->get('general.enabled_plugins', []), 'enabled_plugins' => $this->conf->get('general.enabled_plugins', []),
'default_private_links' => $this->conf->get('privacy.default_private_links', false), 'default_private_links' => $this->conf->get('privacy.default_private_links', false),
), ],
]; ];
return $response->withJson($info, 200, $this->jsonStyle); return $response->withJson($info, 200, $this->jsonStyle);

View file

@ -119,7 +119,8 @@ public function postLink($request, $response)
$data = (array) ($request->getParsedBody() ?? []); $data = (array) ($request->getParsedBody() ?? []);
$bookmark = ApiUtils::buildBookmarkFromRequest($data, $this->conf->get('privacy.default_private_links')); $bookmark = ApiUtils::buildBookmarkFromRequest($data, $this->conf->get('privacy.default_private_links'));
// duplicate by URL, return 409 Conflict // duplicate by URL, return 409 Conflict
if (! empty($bookmark->getUrl()) if (
! empty($bookmark->getUrl())
&& ! empty($dup = $this->bookmarkService->findByUrl($bookmark->getUrl())) && ! empty($dup = $this->bookmarkService->findByUrl($bookmark->getUrl()))
) { ) {
return $response->withJson( return $response->withJson(
@ -131,7 +132,7 @@ public function postLink($request, $response)
$this->bookmarkService->add($bookmark); $this->bookmarkService->add($bookmark);
$out = ApiUtils::formatLink($bookmark, index_url($this->ci['environment'])); $out = ApiUtils::formatLink($bookmark, index_url($this->ci['environment']));
$redirect = $this->ci->router->relativePathFor('getLink', ['id' => $bookmark->getId()]); $redirect = $this->ci->router->pathFor('getLink', ['id' => $bookmark->getId()]);
return $response->withAddedHeader('Location', $redirect) return $response->withAddedHeader('Location', $redirect)
->withJson($out, 201, $this->jsonStyle); ->withJson($out, 201, $this->jsonStyle);
} }
@ -159,7 +160,8 @@ public function putLink($request, $response, $args)
$requestBookmark = ApiUtils::buildBookmarkFromRequest($data, $this->conf->get('privacy.default_private_links')); $requestBookmark = ApiUtils::buildBookmarkFromRequest($data, $this->conf->get('privacy.default_private_links'));
// duplicate URL on a different link, return 409 Conflict // duplicate URL on a different link, return 409 Conflict
if (! empty($requestBookmark->getUrl()) if (
! empty($requestBookmark->getUrl())
&& ! empty($dup = $this->bookmarkService->findByUrl($requestBookmark->getUrl())) && ! empty($dup = $this->bookmarkService->findByUrl($requestBookmark->getUrl()))
&& $dup->getId() != $id && $dup->getId() != $id
) { ) {

View file

@ -28,7 +28,7 @@ public function getApiResponse()
*/ */
public function setMessage($message) public function setMessage($message)
{ {
$original = $this->debug === true ? ': '. $this->getMessage() : ''; $original = $this->debug === true ? ': ' . $this->getMessage() : '';
$this->message = $message . $original; $this->message = $message . $original;
} }
} }

View file

@ -44,7 +44,7 @@ protected function getApiResponseBody()
} }
return [ return [
'message' => $this->getMessage(), 'message' => $this->getMessage(),
'stacktrace' => get_class($this) .': '. $this->getTraceAsString() 'stacktrace' => get_class($this) . ': ' . $this->getTraceAsString()
]; ];
} }

View file

@ -19,7 +19,7 @@
class Bookmark class Bookmark
{ {
/** @var string Date format used in string (former ID format) */ /** @var string Date format used in string (former ID format) */
const LINK_DATE_FORMAT = 'Ymd_His'; public const LINK_DATE_FORMAT = 'Ymd_His';
/** @var int Bookmark ID */ /** @var int Bookmark ID */
protected $id; protected $id;
@ -60,11 +60,13 @@ class Bookmark
/** /**
* Initialize a link from array data. Especially useful to create a Bookmark from former link storage format. * Initialize a link from array data. Especially useful to create a Bookmark from former link storage format.
* *
* @param array $data * @param array $data
* @param string $tagsSeparator Tags separator loaded from the config file.
* This is a context data, and it should *never* be stored in the Bookmark object.
* *
* @return $this * @return $this
*/ */
public function fromArray(array $data): Bookmark public function fromArray(array $data, string $tagsSeparator = ' '): Bookmark
{ {
$this->id = $data['id'] ?? null; $this->id = $data['id'] ?? null;
$this->shortUrl = $data['shorturl'] ?? null; $this->shortUrl = $data['shorturl'] ?? null;
@ -77,7 +79,7 @@ public function fromArray(array $data): Bookmark
if (is_array($data['tags'])) { if (is_array($data['tags'])) {
$this->tags = $data['tags']; $this->tags = $data['tags'];
} else { } else {
$this->tags = preg_split('/\s+/', $data['tags'] ?? '', -1, PREG_SPLIT_NO_EMPTY); $this->tags = tags_str2array($data['tags'] ?? '', $tagsSeparator);
} }
if (! empty($data['updated'])) { if (! empty($data['updated'])) {
$this->updated = $data['updated']; $this->updated = $data['updated'];
@ -104,7 +106,8 @@ public function fromArray(array $data): Bookmark
*/ */
public function validate(): void public function validate(): void
{ {
if ($this->id === null if (
$this->id === null
|| ! is_int($this->id) || ! is_int($this->id)
|| empty($this->shortUrl) || empty($this->shortUrl)
|| empty($this->created) || empty($this->created)
@ -112,7 +115,7 @@ public function validate(): void
throw new InvalidBookmarkException($this); throw new InvalidBookmarkException($this);
} }
if (empty($this->url)) { if (empty($this->url)) {
$this->url = '/shaare/'. $this->shortUrl; $this->url = '/shaare/' . $this->shortUrl;
} }
if (empty($this->title)) { if (empty($this->title)) {
$this->title = $this->url; $this->title = $this->url;
@ -348,7 +351,12 @@ public function getTags(): array
*/ */
public function setTags(?array $tags): Bookmark public function setTags(?array $tags): Bookmark
{ {
$this->setTagsString(implode(' ', $tags ?? [])); $this->tags = array_map(
function (string $tag): string {
return $tag[0] === '-' ? substr($tag, 1) : $tag;
},
tags_filter($tags, ' ')
);
return $this; return $this;
} }
@ -420,11 +428,13 @@ public function setSticky(?bool $sticky): Bookmark
} }
/** /**
* @return string Bookmark's tags as a string, separated by a space * @param string $separator Tags separator loaded from the config file.
*
* @return string Bookmark's tags as a string, separated by a separator
*/ */
public function getTagsString(): string public function getTagsString(string $separator = ' '): string
{ {
return implode(' ', $this->getTags()); return tags_array2str($this->getTags(), $separator);
} }
/** /**
@ -444,19 +454,13 @@ public function isNote(): bool
* - trailing dash in tags will be removed * - trailing dash in tags will be removed
* *
* @param string|null $tags * @param string|null $tags
* @param string $separator Tags separator loaded from the config file.
* *
* @return $this * @return $this
*/ */
public function setTagsString(?string $tags): Bookmark public function setTagsString(?string $tags, string $separator = ' '): Bookmark
{ {
// Remove first '-' char in tags. $this->setTags(tags_str2array($tags, $separator));
$tags = preg_replace('/(^| )\-/', '$1', $tags ?? '');
// Explode all tags separted by spaces or commas
$tags = preg_split('/[\s,]+/', $tags);
// Remove eventual empty values
$tags = array_values(array_filter($tags));
$this->tags = $tags;
return $this; return $this;
} }
@ -507,7 +511,7 @@ public function getAdditionalContentEntry(string $key, $default = null)
*/ */
public function renameTag(string $fromTag, string $toTag): void public function renameTag(string $fromTag, string $toTag): void
{ {
if (($pos = array_search($fromTag, $this->tags)) !== false) { if (($pos = array_search($fromTag, $this->tags ?? [])) !== false) {
$this->tags[$pos] = trim($toTag); $this->tags[$pos] = trim($toTag);
} }
} }
@ -519,7 +523,7 @@ public function renameTag(string $fromTag, string $toTag): void
*/ */
public function deleteTag(string $tag): void public function deleteTag(string $tag): void
{ {
if (($pos = array_search($tag, $this->tags)) !== false) { if (($pos = array_search($tag, $this->tags ?? [])) !== false) {
unset($this->tags[$pos]); unset($this->tags[$pos]);
$this->tags = array_values($this->tags); $this->tags = array_values($this->tags);
} }

View file

@ -72,7 +72,8 @@ public function count()
*/ */
public function offsetSet($offset, $value) public function offsetSet($offset, $value)
{ {
if (! $value instanceof Bookmark if (
! $value instanceof Bookmark
|| $value->getId() === null || empty($value->getUrl()) || $value->getId() === null || empty($value->getUrl())
|| ($offset !== null && ! is_int($offset)) || ! is_int($value->getId()) || ($offset !== null && ! is_int($offset)) || ! is_int($value->getId())
|| $offset !== null && $offset !== $value->getId() || $offset !== null && $offset !== $value->getId()
@ -222,7 +223,8 @@ public function getNextId(): int
*/ */
public function getByUrl(string $url): ?Bookmark public function getByUrl(string $url): ?Bookmark
{ {
if (! empty($url) if (
! empty($url)
&& isset($this->urls[$url]) && isset($this->urls[$url])
&& isset($this->bookmarks[$this->urls[$url]]) && isset($this->bookmarks[$this->urls[$url]])
) { ) {

View file

@ -69,7 +69,7 @@ public function __construct(ConfigManager $conf, History $history, Mutex $mutex,
} else { } else {
try { try {
$this->bookmarks = $this->bookmarksIO->read(); $this->bookmarks = $this->bookmarksIO->read();
} catch (EmptyDataStoreException|DatastoreNotInitializedException $e) { } catch (EmptyDataStoreException | DatastoreNotInitializedException $e) {
$this->bookmarks = new BookmarkArray(); $this->bookmarks = new BookmarkArray();
if ($this->isLoggedIn) { if ($this->isLoggedIn) {
@ -85,25 +85,29 @@ public function __construct(ConfigManager $conf, History $history, Mutex $mutex,
if (! $this->bookmarks instanceof BookmarkArray) { if (! $this->bookmarks instanceof BookmarkArray) {
$this->migrate(); $this->migrate();
exit( exit(
'Your data store has been migrated, please reload the page.'. PHP_EOL . 'Your data store has been migrated, please reload the page.' . PHP_EOL .
'If this message keeps showing up, please delete data/updates.txt file.' 'If this message keeps showing up, please delete data/updates.txt file.'
); );
} }
} }
$this->bookmarkFilter = new BookmarkFilter($this->bookmarks); $this->bookmarkFilter = new BookmarkFilter($this->bookmarks, $this->conf);
} }
/** /**
* @inheritDoc * @inheritDoc
*/ */
public function findByHash(string $hash): Bookmark public function findByHash(string $hash, string $privateKey = null): Bookmark
{ {
$bookmark = $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_HASH, $hash); $bookmark = $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_HASH, $hash);
// PHP 7.3 introduced array_key_first() to avoid this hack // PHP 7.3 introduced array_key_first() to avoid this hack
$first = reset($bookmark); $first = reset($bookmark);
if (! $this->isLoggedIn && $first->isPrivate()) { if (
throw new Exception('Not authorized'); !$this->isLoggedIn
&& $first->isPrivate()
&& (empty($privateKey) || $privateKey !== $first->getAdditionalContentEntry('private_key'))
) {
throw new BookmarkNotFoundException();
} }
return $first; return $first;
@ -162,7 +166,8 @@ public function get(int $id, string $visibility = null): Bookmark
} }
$bookmark = $this->bookmarks[$id]; $bookmark = $this->bookmarks[$id];
if (($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private') if (
($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private')
|| (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public') || (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public')
) { ) {
throw new Exception('Unauthorized'); throw new Exception('Unauthorized');
@ -262,7 +267,8 @@ public function exists(int $id, string $visibility = null): bool
} }
$bookmark = $this->bookmarks[$id]; $bookmark = $this->bookmarks[$id];
if (($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private') if (
($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private')
|| (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public') || (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public')
) { ) {
return false; return false;
@ -304,7 +310,8 @@ public function bookmarksCountPerTag(array $filteringTags = [], string $visibili
$caseMapping = []; $caseMapping = [];
foreach ($bookmarks as $bookmark) { foreach ($bookmarks as $bookmark) {
foreach ($bookmark->getTags() as $tag) { foreach ($bookmark->getTags() as $tag) {
if (empty($tag) if (
empty($tag)
|| (! $this->isLoggedIn && startsWith($tag, '.')) || (! $this->isLoggedIn && startsWith($tag, '.'))
|| $tag === BookmarkMarkdownFormatter::NO_MD_TAG || $tag === BookmarkMarkdownFormatter::NO_MD_TAG
|| in_array($tag, $filteringTags, true) || in_array($tag, $filteringTags, true)
@ -340,26 +347,42 @@ public function bookmarksCountPerTag(array $filteringTags = [], string $visibili
/** /**
* @inheritDoc * @inheritDoc
*/ */
public function days(): array public function findByDate(
{ \DateTimeInterface $from,
$bookmarkDays = []; \DateTimeInterface $to,
foreach ($this->search() as $bookmark) { ?\DateTimeInterface &$previous,
$bookmarkDays[$bookmark->getCreated()->format('Ymd')] = 0; ?\DateTimeInterface &$next
} ): array {
$bookmarkDays = array_keys($bookmarkDays); $out = [];
sort($bookmarkDays); $previous = null;
$next = null;
return array_map('strval', $bookmarkDays); foreach ($this->search([], null, false, false, true) as $bookmark) {
if ($to < $bookmark->getCreated()) {
$next = $bookmark->getCreated();
} elseif ($from < $bookmark->getCreated() && $to > $bookmark->getCreated()) {
$out[] = $bookmark;
} else {
if ($previous !== null) {
break;
}
$previous = $bookmark->getCreated();
}
}
return $out;
} }
/** /**
* @inheritDoc * @inheritDoc
*/ */
public function filterDay(string $request) public function getLatest(): ?Bookmark
{ {
$visibility = $this->isLoggedIn ? BookmarkFilter::$ALL : BookmarkFilter::$PUBLIC; foreach ($this->search([], null, false, false, true) as $bookmark) {
return $bookmark;
}
return $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_DAY, $request, false, $visibility); return null;
} }
/** /**
@ -386,14 +409,14 @@ protected function migrate(): void
false false
); );
$updater = new LegacyUpdater( $updater = new LegacyUpdater(
UpdaterUtils::read_updates_file($this->conf->get('resource.updates')), UpdaterUtils::readUpdatesFile($this->conf->get('resource.updates')),
$bookmarkDb, $bookmarkDb,
$this->conf, $this->conf,
true true
); );
$newUpdates = $updater->update(); $newUpdates = $updater->update();
if (! empty($newUpdates)) { if (! empty($newUpdates)) {
UpdaterUtils::write_updates_file( UpdaterUtils::writeUpdatesFile(
$this->conf->get('resource.updates'), $this->conf->get('resource.updates'),
$updater->getDoneUpdates() $updater->getDoneUpdates()
); );

View file

@ -6,6 +6,7 @@
use Exception; use Exception;
use Shaarli\Bookmark\Exception\BookmarkNotFoundException; use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
use Shaarli\Config\ConfigManager;
/** /**
* Class LinkFilter. * Class LinkFilter.
@ -58,12 +59,16 @@ class BookmarkFilter
*/ */
private $bookmarks; private $bookmarks;
/** @var ConfigManager */
protected $conf;
/** /**
* @param Bookmark[] $bookmarks initialization. * @param Bookmark[] $bookmarks initialization.
*/ */
public function __construct($bookmarks) public function __construct($bookmarks, ConfigManager $conf)
{ {
$this->bookmarks = $bookmarks; $this->bookmarks = $bookmarks;
$this->conf = $conf;
} }
/** /**
@ -107,10 +112,14 @@ public function filter(
$filtered = $this->bookmarks; $filtered = $this->bookmarks;
} }
if (!empty($request[0])) { if (!empty($request[0])) {
$filtered = (new BookmarkFilter($filtered))->filterTags($request[0], $casesensitive, $visibility); $filtered = (new BookmarkFilter($filtered, $this->conf))
->filterTags($request[0], $casesensitive, $visibility)
;
} }
if (!empty($request[1])) { if (!empty($request[1])) {
$filtered = (new BookmarkFilter($filtered))->filterFulltext($request[1], $visibility); $filtered = (new BookmarkFilter($filtered, $this->conf))
->filterFulltext($request[1], $visibility)
;
} }
return $filtered; return $filtered;
case self::$FILTER_TEXT: case self::$FILTER_TEXT:
@ -141,7 +150,7 @@ private function noFilter(string $visibility = 'all')
return $this->bookmarks; return $this->bookmarks;
} }
$out = array(); $out = [];
foreach ($this->bookmarks as $key => $value) { foreach ($this->bookmarks as $key => $value) {
if ($value->isPrivate() && $visibility === 'private') { if ($value->isPrivate() && $visibility === 'private') {
$out[$key] = $value; $out[$key] = $value;
@ -280,8 +289,9 @@ private function filterFulltext(string $searchterms, string $visibility = 'all')
* *
* @return string generated regex fragment * @return string generated regex fragment
*/ */
private static function tag2regex(string $tag): string protected function tag2regex(string $tag): string
{ {
$tagsSeparator = $this->conf->get('general.tags_separator', ' ');
$len = strlen($tag); $len = strlen($tag);
if (!$len || $tag === "-" || $tag === "*") { if (!$len || $tag === "-" || $tag === "*") {
// nothing to search, return empty regex // nothing to search, return empty regex
@ -295,12 +305,13 @@ private static function tag2regex(string $tag): string
$i = 0; // start at first character $i = 0; // start at first character
$regex = '(?='; // use positive lookahead $regex = '(?='; // use positive lookahead
} }
$regex .= '.*(?:^| )'; // before tag may only be a space or the beginning // before tag may only be the separator or the beginning
$regex .= '.*(?:^|' . $tagsSeparator . ')';
// iterate over string, separating it into placeholder and content // iterate over string, separating it into placeholder and content
for (; $i < $len; $i++) { for (; $i < $len; $i++) {
if ($tag[$i] === '*') { if ($tag[$i] === '*') {
// placeholder found // placeholder found
$regex .= '[^ ]*?'; $regex .= '[^' . $tagsSeparator . ']*?';
} else { } else {
// regular characters // regular characters
$offset = strpos($tag, '*', $i); $offset = strpos($tag, '*', $i);
@ -316,7 +327,8 @@ private static function tag2regex(string $tag): string
$i = $offset; $i = $offset;
} }
} }
$regex .= '(?:$| ))'; // after the tag may only be a space or the end // after the tag may only be the separator or the end
$regex .= '(?:$|' . $tagsSeparator . '))';
return $regex; return $regex;
} }
@ -334,14 +346,15 @@ private static function tag2regex(string $tag): string
*/ */
public function filterTags($tags, bool $casesensitive = false, string $visibility = 'all') public function filterTags($tags, bool $casesensitive = false, string $visibility = 'all')
{ {
$tagsSeparator = $this->conf->get('general.tags_separator', ' ');
// get single tags (we may get passed an array, even though the docs say different) // get single tags (we may get passed an array, even though the docs say different)
$inputTags = $tags; $inputTags = $tags;
if (!is_array($tags)) { if (!is_array($tags)) {
// we got an input string, split tags // we got an input string, split tags
$inputTags = preg_split('/(?:\s+)|,/', $inputTags, -1, PREG_SPLIT_NO_EMPTY); $inputTags = tags_str2array($inputTags, $tagsSeparator);
} }
if (!count($inputTags)) { if (count($inputTags) === 0) {
// no input tags // no input tags
return $this->noFilter($visibility); return $this->noFilter($visibility);
} }
@ -358,7 +371,7 @@ public function filterTags($tags, bool $casesensitive = false, string $visibilit
} }
// build regex from all tags // build regex from all tags
$re = '/^' . implode(array_map("self::tag2regex", $inputTags)) . '.*$/'; $re = '/^' . implode(array_map([$this, 'tag2regex'], $inputTags)) . '.*$/';
if (!$casesensitive) { if (!$casesensitive) {
// make regex case insensitive // make regex case insensitive
$re .= 'i'; $re .= 'i';
@ -378,10 +391,11 @@ public function filterTags($tags, bool $casesensitive = false, string $visibilit
continue; continue;
} }
} }
$search = $link->getTagsString(); // build search string, start with tags of current link // build search string, start with tags of current link
$search = $link->getTagsString($tagsSeparator);
if (strlen(trim($link->getDescription())) && strpos($link->getDescription(), '#') !== false) { if (strlen(trim($link->getDescription())) && strpos($link->getDescription(), '#') !== false) {
// description given and at least one possible tag found // description given and at least one possible tag found
$descTags = array(); $descTags = [];
// find all tags in the form of #tag in the description // find all tags in the form of #tag in the description
preg_match_all( preg_match_all(
'/(?<![' . self::$HASHTAG_CHARS . '])#([' . self::$HASHTAG_CHARS . ']+?)\b/sm', '/(?<![' . self::$HASHTAG_CHARS . '])#([' . self::$HASHTAG_CHARS . ']+?)\b/sm',
@ -390,9 +404,9 @@ public function filterTags($tags, bool $casesensitive = false, string $visibilit
); );
if (count($descTags[1])) { if (count($descTags[1])) {
// there were some tags in the description, add them to the search string // there were some tags in the description, add them to the search string
$search .= ' ' . implode(' ', $descTags[1]); $search .= $tagsSeparator . tags_array2str($descTags[1], $tagsSeparator);
} }
}; }
// match regular expression with search string // match regular expression with search string
if (!preg_match($re, $search)) { if (!preg_match($re, $search)) {
// this entry does _not_ match our regex // this entry does _not_ match our regex
@ -422,7 +436,7 @@ public function filterUntagged(string $visibility)
} }
} }
if (empty(trim($link->getTagsString()))) { if (empty($link->getTags())) {
$filtered[$key] = $link; $filtered[$key] = $link;
} }
} }
@ -537,10 +551,11 @@ protected function postProcessFoundPositions(array $fieldLengths, array $foundPo
*/ */
protected function buildFullTextSearchableLink(Bookmark $link, array &$lengths): string protected function buildFullTextSearchableLink(Bookmark $link, array &$lengths): string
{ {
$content = mb_convert_case($link->getTitle(), MB_CASE_LOWER, 'UTF-8') .'\\'; $tagString = $link->getTagsString($this->conf->get('general.tags_separator', ' '));
$content .= mb_convert_case($link->getDescription(), MB_CASE_LOWER, 'UTF-8') .'\\'; $content = mb_convert_case($link->getTitle(), MB_CASE_LOWER, 'UTF-8') . '\\';
$content .= mb_convert_case($link->getUrl(), MB_CASE_LOWER, 'UTF-8') .'\\'; $content .= mb_convert_case($link->getDescription(), MB_CASE_LOWER, 'UTF-8') . '\\';
$content .= mb_convert_case($link->getTagsString(), MB_CASE_LOWER, 'UTF-8') .'\\'; $content .= mb_convert_case($link->getUrl(), MB_CASE_LOWER, 'UTF-8') . '\\';
$content .= mb_convert_case($tagString, MB_CASE_LOWER, 'UTF-8') . '\\';
$lengths['title'] = ['start' => 0, 'end' => mb_strlen($link->getTitle())]; $lengths['title'] = ['start' => 0, 'end' => mb_strlen($link->getTitle())];
$nextField = $lengths['title']['end'] + 1; $nextField = $lengths['title']['end'] + 1;
@ -548,7 +563,7 @@ protected function buildFullTextSearchableLink(Bookmark $link, array &$lengths):
$nextField = $lengths['description']['end'] + 1; $nextField = $lengths['description']['end'] + 1;
$lengths['url'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($link->getUrl())]; $lengths['url'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($link->getUrl())];
$nextField = $lengths['url']['end'] + 1; $nextField = $lengths['url']['end'] + 1;
$lengths['tags'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($link->getTagsString())]; $lengths['tags'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($tagString)];
return $content; return $content;
} }

View file

@ -112,12 +112,12 @@ public function write($links)
if (is_file($this->datastore) && !is_writeable($this->datastore)) { if (is_file($this->datastore) && !is_writeable($this->datastore)) {
// The datastore exists but is not writeable // The datastore exists but is not writeable
throw new NotWritableDataStoreException($this->datastore); throw new NotWritableDataStoreException($this->datastore);
} else if (!is_file($this->datastore) && !is_writeable(dirname($this->datastore))) { } elseif (!is_file($this->datastore) && !is_writeable(dirname($this->datastore))) {
// The datastore does not exist and its parent directory is not writeable // The datastore does not exist and its parent directory is not writeable
throw new NotWritableDataStoreException(dirname($this->datastore)); throw new NotWritableDataStoreException(dirname($this->datastore));
} }
$data = self::$phpPrefix.base64_encode(gzdeflate(serialize($links))).self::$phpSuffix; $data = self::$phpPrefix . base64_encode(gzdeflate(serialize($links))) . self::$phpSuffix;
$this->mutex->synchronized(function () use ($data) { $this->mutex->synchronized(function () use ($data) {
file_put_contents( file_put_contents(

View file

@ -13,6 +13,9 @@
* To prevent data corruption, it does not overwrite existing bookmarks, * To prevent data corruption, it does not overwrite existing bookmarks,
* even though there should not be any. * even though there should not be any.
* *
* We disable this because otherwise it creates indentation issues, and heredoc is not supported by PHP gettext.
* @phpcs:disable Generic.Files.LineLength.TooLong
*
* @package Shaarli\Bookmark * @package Shaarli\Bookmark
*/ */
class BookmarkInitializer class BookmarkInitializer
@ -36,10 +39,10 @@ public function __construct(BookmarkServiceInterface $bookmarkService)
public function initialize(): void public function initialize(): void
{ {
$bookmark = new Bookmark(); $bookmark = new Bookmark();
$bookmark->setTitle('quicksilver (loop) on Vimeo ' . t('(private bookmark with thumbnail demo)')); $bookmark->setTitle('Calm Jazz Music - YouTube ' . t('(private bookmark with thumbnail demo)'));
$bookmark->setUrl('https://vimeo.com/153493904'); $bookmark->setUrl('https://www.youtube.com/watch?v=DVEUcbPkb-c');
$bookmark->setDescription(t( $bookmark->setDescription(t(
'Shaarli will automatically pick up the thumbnail for links to a variety of websites. 'Shaarli will automatically pick up the thumbnail for links to a variety of websites.
Explore your new Shaarli instance by trying out controls and menus. Explore your new Shaarli instance by trying out controls and menus.
Visit the project on [Github](https://github.com/shaarli/Shaarli) or [the documentation](https://shaarli.readthedocs.io/en/master/) to learn more about Shaarli. Visit the project on [Github](https://github.com/shaarli/Shaarli) or [the documentation](https://shaarli.readthedocs.io/en/master/) to learn more about Shaarli.
@ -54,7 +57,7 @@ public function initialize(): void
$bookmark = new Bookmark(); $bookmark = new Bookmark();
$bookmark->setTitle(t('Note: Shaare descriptions')); $bookmark->setTitle(t('Note: Shaare descriptions'));
$bookmark->setDescription(t( $bookmark->setDescription(t(
'Adding a shaare without entering a URL creates a text-only "note" post such as this one. 'Adding a shaare without entering a URL creates a text-only "note" post such as this one.
This note is private, so you are the only one able to see it while logged in. This note is private, so you are the only one able to see it while logged in.
You can use this to keep notes, post articles, code snippets, and much more. You can use this to keep notes, post articles, code snippets, and much more.
@ -91,7 +94,7 @@ public function initialize(): void
'Shaarli - ' . t('The personal, minimalist, super-fast, database free, bookmarking service') 'Shaarli - ' . t('The personal, minimalist, super-fast, database free, bookmarking service')
); );
$bookmark->setDescription(t( $bookmark->setDescription(t(
'Welcome to Shaarli! 'Welcome to Shaarli!
Shaarli allows you to bookmark your favorite pages, and share them with others or store them privately. Shaarli allows you to bookmark your favorite pages, and share them with others or store them privately.
You can add a description to your bookmarks, such as this one, and tag them. You can add a description to your bookmarks, such as this one, and tag them.

View file

@ -20,13 +20,14 @@ interface BookmarkServiceInterface
/** /**
* Find a bookmark by hash * Find a bookmark by hash
* *
* @param string $hash * @param string $hash Bookmark's hash
* @param string|null $privateKey Optional key used to access private links while logged out
* *
* @return Bookmark * @return Bookmark
* *
* @throws \Exception * @throws \Exception
*/ */
public function findByHash(string $hash): Bookmark; public function findByHash(string $hash, string $privateKey = null);
/** /**
* @param $url * @param $url
@ -155,22 +156,29 @@ public function save(): void;
public function bookmarksCountPerTag(array $filteringTags = [], ?string $visibility = null): array; public function bookmarksCountPerTag(array $filteringTags = [], ?string $visibility = null): array;
/** /**
* Returns the list of days containing articles (oldest first) * Return a list of bookmark matching provided period of time.
* It also update directly previous and next date outside of given period found in the datastore.
* *
* @return array containing days (in format YYYYMMDD). * @param \DateTimeInterface $from Starting date.
* @param \DateTimeInterface $to Ending date.
* @param \DateTimeInterface|null $previous (by reference) updated with first created date found before $from.
* @param \DateTimeInterface|null $next (by reference) updated with first created date found after $to.
*
* @return array List of bookmarks matching provided period of time.
*/ */
public function days(): array; public function findByDate(
\DateTimeInterface $from,
\DateTimeInterface $to,
?\DateTimeInterface &$previous,
?\DateTimeInterface &$next
): array;
/** /**
* Returns the list of articles for a given day. * Returns the latest bookmark by creation date.
* *
* @param string $request day to filter. Format: YYYYMMDD. * @return Bookmark|null Found Bookmark or null if the datastore is empty.
*
* @return Bookmark[] list of shaare found.
*
* @throws BookmarkNotFoundException
*/ */
public function filterDay(string $request); public function getLatest(): ?Bookmark;
/** /**
* Creates the default database after a fresh install. * Creates the default database after a fresh install.

View file

@ -67,17 +67,18 @@ function html_extract_tag($tag, $html)
$propertiesKey = ['property', 'name', 'itemprop']; $propertiesKey = ['property', 'name', 'itemprop'];
$properties = implode('|', $propertiesKey); $properties = implode('|', $propertiesKey);
// We need a OR here to accept either 'property=og:noquote' or 'property="og:unrelated og:my-tag"' // We need a OR here to accept either 'property=og:noquote' or 'property="og:unrelated og:my-tag"'
$orCondition = '["\']?(?:og:)?'. $tag .'["\']?|["\'][^\'"]*?(?:og:)?' . $tag . '[^\'"]*?[\'"]'; $orCondition = '["\']?(?:og:)?' . $tag . '["\']?|["\'][^\'"]*?(?:og:)?' . $tag . '[^\'"]*?[\'"]';
// Try to retrieve OpenGraph image. // Try to retrieve OpenGraph tag.
$ogRegex = '#<meta[^>]+(?:'. $properties .')=(?:'. $orCondition .')[^>]*content=["\'](.*?)["\'].*?>#'; $ogRegex = '#<meta[^>]+(?:' . $properties . ')=(?:' . $orCondition . ')[^>]*content=(["\'])([^\1]*?)\1.*?>#';
// If the attributes are not in the order property => content (e.g. Github) // If the attributes are not in the order property => content (e.g. Github)
// New regex to keep this readable... more or less. // New regex to keep this readable... more or less.
$ogRegexReverse = '#<meta[^>]+content=["\'](.*?)["\'][^>]+(?:'. $properties .')=(?:'. $orCondition .').*?>#'; $ogRegexReverse = '#<meta[^>]+content=(["\'])([^\1]*?)\1[^>]+(?:' . $properties . ')=(?:' . $orCondition . ').*?>#';
if (preg_match($ogRegex, $html, $matches) > 0 if (
preg_match($ogRegex, $html, $matches) > 0
|| preg_match($ogRegexReverse, $html, $matches) > 0 || preg_match($ogRegexReverse, $html, $matches) > 0
) { ) {
return $matches[1]; return $matches[2];
} }
return false; return false;
@ -116,7 +117,7 @@ function hashtag_autolink($description, $indexUrl = '')
* \p{Mn} - any non marking space (accents, umlauts, etc) * \p{Mn} - any non marking space (accents, umlauts, etc)
*/ */
$regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui'; $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui';
$replacement = '$1<a href="'. $indexUrl .'./add-tag/$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); return preg_replace($regex, $replacement, $description);
} }
@ -138,12 +139,17 @@ function space2nbsp($text)
* *
* @param string $description shaare's description. * @param string $description shaare's description.
* @param string $indexUrl URL to Shaarli's index. * @param string $indexUrl URL to Shaarli's index.
* @param bool $autolink Turn on/off automatic linkifications of URLs and hashtags
*
* @return string formatted description. * @return string formatted description.
*/ */
function format_description($description, $indexUrl = '') function format_description($description, $indexUrl = '', $autolink = true)
{ {
return nl2br(space2nbsp(hashtag_autolink(text2clickable($description), $indexUrl))); if ($autolink) {
$description = hashtag_autolink(text2clickable($description), $indexUrl);
}
return nl2br(space2nbsp($description));
} }
/** /**
@ -171,3 +177,49 @@ function is_note($linkUrl)
{ {
return isset($linkUrl[0]) && $linkUrl[0] === '?'; return isset($linkUrl[0]) && $linkUrl[0] === '?';
} }
/**
* Extract an array of tags from a given tag string, with provided separator.
*
* @param string|null $tags String containing a list of tags separated by $separator.
* @param string $separator Shaarli's default: ' ' (whitespace)
*
* @return array List of tags
*/
function tags_str2array(?string $tags, string $separator): array
{
// For whitespaces, we use the special \s regex character
$separator = $separator === ' ' ? '\s' : $separator;
return preg_split('/\s*' . $separator . '+\s*/', trim($tags) ?? '', -1, PREG_SPLIT_NO_EMPTY);
}
/**
* Return a tag string with provided separator from a list of tags.
* Note that given array is clean up by tags_filter().
*
* @param array|null $tags List of tags
* @param string $separator
*
* @return string
*/
function tags_array2str(?array $tags, string $separator): string
{
return implode($separator, tags_filter($tags, $separator));
}
/**
* Clean an array of tags: trim + remove empty entries
*
* @param array|null $tags List of tags
* @param string $separator
*
* @return array
*/
function tags_filter(?array $tags, string $separator): array
{
$trimDefault = " \t\n\r\0\x0B";
return array_values(array_filter(array_map(function (string $entry) use ($separator, $trimDefault): string {
return trim($entry, $trimDefault . $separator);
}, $tags ?? [])));
}

View file

@ -1,4 +1,5 @@
<?php <?php
namespace Shaarli\Bookmark\Exception; namespace Shaarli\Bookmark\Exception;
use Exception; use Exception;

View file

@ -1,7 +1,7 @@
<?php <?php
namespace Shaarli\Bookmark\Exception; namespace Shaarli\Bookmark\Exception;
class EmptyDataStoreException extends \Exception
class EmptyDataStoreException extends \Exception {} {
}

View file

@ -16,14 +16,14 @@ public function __construct($bookmark)
} else { } else {
$created = 'Not a DateTime object'; $created = 'Not a DateTime object';
} }
$this->message = 'This bookmark is not valid'. PHP_EOL; $this->message = 'This bookmark is not valid' . PHP_EOL;
$this->message .= ' - ID: '. $bookmark->getId() . PHP_EOL; $this->message .= ' - ID: ' . $bookmark->getId() . PHP_EOL;
$this->message .= ' - Title: '. $bookmark->getTitle() . PHP_EOL; $this->message .= ' - Title: ' . $bookmark->getTitle() . PHP_EOL;
$this->message .= ' - Url: '. $bookmark->getUrl() . PHP_EOL; $this->message .= ' - Url: ' . $bookmark->getUrl() . PHP_EOL;
$this->message .= ' - ShortUrl: '. $bookmark->getShortUrl() . PHP_EOL; $this->message .= ' - ShortUrl: ' . $bookmark->getShortUrl() . PHP_EOL;
$this->message .= ' - Created: '. $created . PHP_EOL; $this->message .= ' - Created: ' . $created . PHP_EOL;
} else { } else {
$this->message = 'The provided data is not a bookmark'. PHP_EOL; $this->message = 'The provided data is not a bookmark' . PHP_EOL;
$this->message .= var_export($bookmark, true); $this->message .= var_export($bookmark, true);
} }
} }

View file

@ -1,9 +1,7 @@
<?php <?php
namespace Shaarli\Bookmark\Exception; namespace Shaarli\Bookmark\Exception;
class NotWritableDataStoreException extends \Exception class NotWritableDataStoreException extends \Exception
{ {
/** /**
@ -13,7 +11,7 @@ class NotWritableDataStoreException extends \Exception
*/ */
public function __construct($dataStore) public function __construct($dataStore)
{ {
$this->message = 'Couldn\'t load data from the data store file "'. $dataStore .'". '. $this->message = 'Couldn\'t load data from the data store file "' . $dataStore . '". ' .
'Your data might be corrupted, or your file isn\'t readable.'; 'Your data might be corrupted, or your file isn\'t readable.';
} }
} }

View file

@ -1,4 +1,5 @@
<?php <?php
namespace Shaarli\Config; namespace Shaarli\Config;
/** /**

View file

@ -19,7 +19,7 @@ public function read($filepath)
$data = file_get_contents($filepath); $data = file_get_contents($filepath);
$data = str_replace(self::getPhpHeaders(), '', $data); $data = str_replace(self::getPhpHeaders(), '', $data);
$data = str_replace(self::getPhpSuffix(), '', $data); $data = str_replace(self::getPhpSuffix(), '', $data);
$data = json_decode($data, true); $data = json_decode(trim($data), true);
if ($data === null) { if ($data === null) {
$errorCode = json_last_error(); $errorCode = json_last_error();
$error = sprintf( $error = sprintf(
@ -73,7 +73,7 @@ public function getExtension()
*/ */
public static function getPhpHeaders() public static function getPhpHeaders()
{ {
return '<?php /*'. PHP_EOL; return '<?php /*';
} }
/** /**
@ -85,6 +85,6 @@ public static function getPhpHeaders()
*/ */
public static function getPhpSuffix() public static function getPhpSuffix()
{ {
return PHP_EOL . '*/ ?>'; return '*/ ?>';
} }
} }

View file

@ -1,4 +1,5 @@
<?php <?php
namespace Shaarli\Config; namespace Shaarli\Config;
use Shaarli\Config\Exception\MissingFieldConfigException; use Shaarli\Config\Exception\MissingFieldConfigException;
@ -20,7 +21,7 @@ class ConfigManager
*/ */
protected static $NOT_FOUND = 'NOT_FOUND'; protected static $NOT_FOUND = 'NOT_FOUND';
public static $DEFAULT_PLUGINS = array('qrcode'); public static $DEFAULT_PLUGINS = ['qrcode'];
/** /**
* @var string Config folder. * @var string Config folder.
@ -133,7 +134,7 @@ public function get($setting, $default = '')
public function set($setting, $value, $write = false, $isLoggedIn = false) public function set($setting, $value, $write = false, $isLoggedIn = false)
{ {
if (empty($setting) || ! is_string($setting)) { if (empty($setting) || ! is_string($setting)) {
throw new \Exception(t('Invalid setting key parameter. String expected, got: '). gettype($setting)); throw new \Exception(t('Invalid setting key parameter. String expected, got: ') . gettype($setting));
} }
// During the ConfigIO transition, map legacy settings to the new ones. // During the ConfigIO transition, map legacy settings to the new ones.
@ -160,7 +161,7 @@ public function set($setting, $value, $write = false, $isLoggedIn = false)
public function remove($setting, $write = false, $isLoggedIn = false) public function remove($setting, $write = false, $isLoggedIn = false)
{ {
if (empty($setting) || ! is_string($setting)) { if (empty($setting) || ! is_string($setting)) {
throw new \Exception(t('Invalid setting key parameter. String expected, got: '). gettype($setting)); throw new \Exception(t('Invalid setting key parameter. String expected, got: ') . gettype($setting));
} }
// During the ConfigIO transition, map legacy settings to the new ones. // During the ConfigIO transition, map legacy settings to the new ones.
@ -213,7 +214,7 @@ public function exists($setting)
public function write($isLoggedIn) public function write($isLoggedIn)
{ {
// These fields are required in configuration. // These fields are required in configuration.
$mandatoryFields = array( $mandatoryFields = [
'credentials.login', 'credentials.login',
'credentials.hash', 'credentials.hash',
'credentials.salt', 'credentials.salt',
@ -222,7 +223,7 @@ public function write($isLoggedIn)
'general.title', 'general.title',
'general.header_link', 'general.header_link',
'privacy.default_private_links', 'privacy.default_private_links',
); ];
// Only logged in user can alter config. // Only logged in user can alter config.
if (is_file($this->getConfigFileExt()) && !$isLoggedIn) { if (is_file($this->getConfigFileExt()) && !$isLoggedIn) {
@ -368,9 +369,10 @@ protected function setDefaultValues()
$this->setEmpty('general.default_note_title', 'Note: '); $this->setEmpty('general.default_note_title', 'Note: ');
$this->setEmpty('general.retrieve_description', true); $this->setEmpty('general.retrieve_description', true);
$this->setEmpty('general.enable_async_metadata', true); $this->setEmpty('general.enable_async_metadata', true);
$this->setEmpty('general.tags_separator', ' ');
$this->setEmpty('updates.check_updates', false); $this->setEmpty('updates.check_updates', true);
$this->setEmpty('updates.check_updates_branch', 'stable'); $this->setEmpty('updates.check_updates_branch', 'latest');
$this->setEmpty('updates.check_updates_interval', 86400); $this->setEmpty('updates.check_updates_interval', 86400);
$this->setEmpty('feed.rss_permalinks', true); $this->setEmpty('feed.rss_permalinks', true);
@ -391,7 +393,7 @@ protected function setDefaultValues()
$this->setEmpty('translation.mode', 'php'); $this->setEmpty('translation.mode', 'php');
$this->setEmpty('translation.extensions', []); $this->setEmpty('translation.extensions', []);
$this->setEmpty('plugins', array()); $this->setEmpty('plugins', []);
$this->setEmpty('formatter', 'markdown'); $this->setEmpty('formatter', 'markdown');
} }

View file

@ -1,4 +1,5 @@
<?php <?php
namespace Shaarli\Config; namespace Shaarli\Config;
/** /**
@ -12,7 +13,7 @@ class ConfigPhp implements ConfigIO
/** /**
* @var array List of config key without group. * @var array List of config key without group.
*/ */
public static $ROOT_KEYS = array( public static $ROOT_KEYS = [
'login', 'login',
'hash', 'hash',
'salt', 'salt',
@ -22,7 +23,7 @@ class ConfigPhp implements ConfigIO
'redirector', 'redirector',
'disablesessionprotection', 'disablesessionprotection',
'privateLinkByDefault', 'privateLinkByDefault',
); ];
/** /**
* Map legacy config keys with the new ones. * Map legacy config keys with the new ones.
@ -31,7 +32,7 @@ class ConfigPhp implements ConfigIO
* *
* @var array current key => legacy key. * @var array current key => legacy key.
*/ */
public static $LEGACY_KEYS_MAPPING = array( public static $LEGACY_KEYS_MAPPING = [
'credentials.login' => 'login', 'credentials.login' => 'login',
'credentials.hash' => 'hash', 'credentials.hash' => 'hash',
'credentials.salt' => 'salt', 'credentials.salt' => 'salt',
@ -68,7 +69,7 @@ class ConfigPhp implements ConfigIO
'privacy.hide_public_links' => 'config.HIDE_PUBLIC_LINKS', 'privacy.hide_public_links' => 'config.HIDE_PUBLIC_LINKS',
'privacy.hide_timestamps' => 'config.HIDE_TIMESTAMPS', 'privacy.hide_timestamps' => 'config.HIDE_TIMESTAMPS',
'security.open_shaarli' => 'config.OPEN_SHAARLI', 'security.open_shaarli' => 'config.OPEN_SHAARLI',
); ];
/** /**
* @inheritdoc * @inheritdoc
@ -76,12 +77,12 @@ class ConfigPhp implements ConfigIO
public function read($filepath) public function read($filepath)
{ {
if (! file_exists($filepath) || ! is_readable($filepath)) { if (! file_exists($filepath) || ! is_readable($filepath)) {
return array(); return [];
} }
include $filepath; include $filepath;
$out = array(); $out = [];
foreach (self::$ROOT_KEYS as $key) { foreach (self::$ROOT_KEYS as $key) {
$out[$key] = isset($GLOBALS[$key]) ? $GLOBALS[$key] : ''; $out[$key] = isset($GLOBALS[$key]) ? $GLOBALS[$key] : '';
} }
@ -95,7 +96,7 @@ public function read($filepath)
*/ */
public function write($filepath, $conf) public function write($filepath, $conf)
{ {
$configStr = '<?php '. PHP_EOL; $configStr = '<?php ' . PHP_EOL;
foreach (self::$ROOT_KEYS as $key) { foreach (self::$ROOT_KEYS as $key) {
if (isset($conf[$key])) { if (isset($conf[$key])) {
$configStr .= '$GLOBALS[\'' . $key . '\'] = ' . var_export($conf[$key], true) . ';' . PHP_EOL; $configStr .= '$GLOBALS[\'' . $key . '\'] = ' . var_export($conf[$key], true) . ';' . PHP_EOL;
@ -106,8 +107,8 @@ public function write($filepath, $conf)
foreach ($conf['config'] as $key => $value) { foreach ($conf['config'] as $key => $value) {
$configStr .= '$GLOBALS[\'config\'][\'' $configStr .= '$GLOBALS[\'config\'][\''
. $key . $key
.'\'] = ' . '\'] = '
.var_export($conf['config'][$key], true).';' . var_export($conf['config'][$key], true) . ';'
. PHP_EOL; . PHP_EOL;
} }
@ -115,18 +116,19 @@ public function write($filepath, $conf)
foreach ($conf['plugins'] as $key => $value) { foreach ($conf['plugins'] as $key => $value) {
$configStr .= '$GLOBALS[\'plugins\'][\'' $configStr .= '$GLOBALS[\'plugins\'][\''
. $key . $key
.'\'] = ' . '\'] = '
.var_export($conf['plugins'][$key], true).';' . var_export($conf['plugins'][$key], true) . ';'
. PHP_EOL; . PHP_EOL;
} }
} }
if (!file_put_contents($filepath, $configStr) if (
!file_put_contents($filepath, $configStr)
|| strcmp(file_get_contents($filepath), $configStr) != 0 || strcmp(file_get_contents($filepath), $configStr) != 0
) { ) {
throw new \Shaarli\Exceptions\IOException( throw new \Shaarli\Exceptions\IOException(
$filepath, $filepath,
t('Shaarli could not create the config file. '. t('Shaarli could not create the config file. ' .
'Please make sure Shaarli has the right to write in the folder is it installed in.') 'Please make sure Shaarli has the right to write in the folder is it installed in.')
); );
} }

View file

@ -39,8 +39,8 @@ function ($value, string $key) use ($directories) {
throw new PluginConfigOrderException(); throw new PluginConfigOrderException();
} }
$plugins = array(); $plugins = [];
$newEnabledPlugins = array(); $newEnabledPlugins = [];
foreach ($formData as $key => $data) { foreach ($formData as $key => $data) {
if (startsWith($key, 'order')) { if (startsWith($key, 'order')) {
continue; continue;
@ -62,7 +62,7 @@ function ($value, string $key) use ($directories) {
throw new PluginConfigOrderException(); throw new PluginConfigOrderException();
} }
$finalPlugins = array(); $finalPlugins = [];
// Make plugins order continuous. // Make plugins order continuous.
foreach ($plugins as $plugin) { foreach ($plugins as $plugin) {
$finalPlugins[] = $plugin; $finalPlugins[] = $plugin;
@ -81,7 +81,7 @@ function ($value, string $key) use ($directories) {
*/ */
function validate_plugin_order($formData) function validate_plugin_order($formData)
{ {
$orders = array(); $orders = [];
foreach ($formData as $key => $value) { foreach ($formData as $key => $value) {
// No duplicate order allowed. // No duplicate order allowed.
if (in_array($value, $orders, true)) { if (in_array($value, $orders, true)) {

View file

@ -1,6 +1,5 @@
<?php <?php
namespace Shaarli\Config\Exception; namespace Shaarli\Config\Exception;
/** /**

View file

@ -1,6 +1,5 @@
<?php <?php
namespace Shaarli\Config\Exception; namespace Shaarli\Config\Exception;
/** /**

View file

@ -5,6 +5,7 @@
namespace Shaarli\Container; namespace Shaarli\Container;
use malkusch\lock\mutex\FlockMutex; use malkusch\lock\mutex\FlockMutex;
use Psr\Log\LoggerInterface;
use Shaarli\Bookmark\BookmarkFileService; use Shaarli\Bookmark\BookmarkFileService;
use Shaarli\Bookmark\BookmarkServiceInterface; use Shaarli\Bookmark\BookmarkServiceInterface;
use Shaarli\Config\ConfigManager; use Shaarli\Config\ConfigManager;
@ -49,6 +50,9 @@ class ContainerBuilder
/** @var LoginManager */ /** @var LoginManager */
protected $login; protected $login;
/** @var LoggerInterface */
protected $logger;
/** @var string|null */ /** @var string|null */
protected $basePath = null; protected $basePath = null;
@ -56,12 +60,14 @@ public function __construct(
ConfigManager $conf, ConfigManager $conf,
SessionManager $session, SessionManager $session,
CookieManager $cookieManager, CookieManager $cookieManager,
LoginManager $login LoginManager $login,
LoggerInterface $logger
) { ) {
$this->conf = $conf; $this->conf = $conf;
$this->session = $session; $this->session = $session;
$this->login = $login; $this->login = $login;
$this->cookieManager = $cookieManager; $this->cookieManager = $cookieManager;
$this->logger = $logger;
} }
public function build(): ShaarliContainer public function build(): ShaarliContainer
@ -72,6 +78,7 @@ public function build(): ShaarliContainer
$container['sessionManager'] = $this->session; $container['sessionManager'] = $this->session;
$container['cookieManager'] = $this->cookieManager; $container['cookieManager'] = $this->cookieManager;
$container['loginManager'] = $this->login; $container['loginManager'] = $this->login;
$container['logger'] = $this->logger;
$container['basePath'] = $this->basePath; $container['basePath'] = $this->basePath;
$container['plugins'] = function (ShaarliContainer $container): PluginManager { $container['plugins'] = function (ShaarliContainer $container): PluginManager {
@ -99,6 +106,7 @@ public function build(): ShaarliContainer
return new PageBuilder( return new PageBuilder(
$container->conf, $container->conf,
$container->sessionManager->getSession(), $container->sessionManager->getSession(),
$container->logger,
$container->bookmarkService, $container->bookmarkService,
$container->sessionManager->generateToken(), $container->sessionManager->generateToken(),
$container->loginManager->isLoggedIn() $container->loginManager->isLoggedIn()
@ -150,7 +158,7 @@ public function build(): ShaarliContainer
$container['updater'] = function (ShaarliContainer $container): Updater { $container['updater'] = function (ShaarliContainer $container): Updater {
return new Updater( return new Updater(
UpdaterUtils::read_updates_file($container->conf->get('resource.updates')), UpdaterUtils::readUpdatesFile($container->conf->get('resource.updates')),
$container->bookmarkService, $container->bookmarkService,
$container->conf, $container->conf,
$container->loginManager->isLoggedIn() $container->loginManager->isLoggedIn()

View file

@ -4,6 +4,7 @@
namespace Shaarli\Container; namespace Shaarli\Container;
use Psr\Log\LoggerInterface;
use Shaarli\Bookmark\BookmarkServiceInterface; use Shaarli\Bookmark\BookmarkServiceInterface;
use Shaarli\Config\ConfigManager; use Shaarli\Config\ConfigManager;
use Shaarli\Feed\FeedBuilder; use Shaarli\Feed\FeedBuilder;
@ -36,6 +37,7 @@
* @property History $history * @property History $history
* @property HttpAccess $httpAccess * @property HttpAccess $httpAccess
* @property LoginManager $loginManager * @property LoginManager $loginManager
* @property LoggerInterface $logger
* @property MetadataRetriever $metadataRetriever * @property MetadataRetriever $metadataRetriever
* @property NetscapeBookmarkUtils $netscapeBookmarkUtils * @property NetscapeBookmarkUtils $netscapeBookmarkUtils
* @property callable $notFoundHandler Overrides default Slim exception display * @property callable $notFoundHandler Overrides default Slim exception display

View file

@ -1,4 +1,5 @@
<?php <?php
namespace Shaarli\Exceptions; namespace Shaarli\Exceptions;
use Exception; use Exception;

View file

@ -1,4 +1,5 @@
<?php <?php
namespace Shaarli\Feed; namespace Shaarli\Feed;
use DateTime; use DateTime;
@ -107,14 +108,14 @@ public function buildData(string $feedType, ?array $userInput)
$nblinksToDisplay = $this->getNbLinks(count($linksToDisplay), $userInput); $nblinksToDisplay = $this->getNbLinks(count($linksToDisplay), $userInput);
// Can't use array_keys() because $link is a LinkDB instance and not a real array. // Can't use array_keys() because $link is a LinkDB instance and not a real array.
$keys = array(); $keys = [];
foreach ($linksToDisplay as $key => $value) { foreach ($linksToDisplay as $key => $value) {
$keys[] = $key; $keys[] = $key;
} }
$pageaddr = escape(index_url($this->serverInfo)); $pageaddr = escape(index_url($this->serverInfo));
$this->formatter->addContextData('index_url', $pageaddr); $this->formatter->addContextData('index_url', $pageaddr);
$linkDisplayed = array(); $linkDisplayed = [];
for ($i = 0; $i < $nblinksToDisplay && $i < count($keys); $i++) { for ($i = 0; $i < $nblinksToDisplay && $i < count($keys); $i++) {
$linkDisplayed[$keys[$i]] = $this->buildItem($feedType, $linksToDisplay[$keys[$i]], $pageaddr); $linkDisplayed[$keys[$i]] = $this->buildItem($feedType, $linksToDisplay[$keys[$i]], $pageaddr);
} }
@ -176,9 +177,9 @@ protected function buildItem(string $feedType, $link, $pageaddr)
$data = $this->formatter->format($link); $data = $this->formatter->format($link);
$data['guid'] = rtrim($pageaddr, '/') . '/shaare/' . $data['shorturl']; $data['guid'] = rtrim($pageaddr, '/') . '/shaare/' . $data['shorturl'];
if ($this->usePermalinks === true) { if ($this->usePermalinks === true) {
$permalink = '<a href="'. $data['url'] .'" title="'. t('Direct link') .'">'. t('Direct link') .'</a>'; $permalink = '<a href="' . $data['url'] . '" title="' . t('Direct link') . '">' . t('Direct link') . '</a>';
} else { } else {
$permalink = '<a href="'. $data['guid'] .'" title="'. t('Permalink') .'">'. t('Permalink') .'</a>'; $permalink = '<a href="' . $data['guid'] . '" title="' . t('Permalink') . '">' . t('Permalink') . '</a>';
} }
$data['description'] .= PHP_EOL . PHP_EOL . '<br>&#8212; ' . $permalink; $data['description'] .= PHP_EOL . PHP_EOL . '<br>&#8212; ' . $permalink;

View file

@ -12,8 +12,8 @@
*/ */
class BookmarkDefaultFormatter extends BookmarkFormatter class BookmarkDefaultFormatter extends BookmarkFormatter
{ {
const SEARCH_HIGHLIGHT_OPEN = '|@@HIGHLIGHT'; protected const SEARCH_HIGHLIGHT_OPEN = '|@@HIGHLIGHT';
const SEARCH_HIGHLIGHT_CLOSE = 'HIGHLIGHT@@|'; protected const SEARCH_HIGHLIGHT_CLOSE = 'HIGHLIGHT@@|';
/** /**
* @inheritdoc * @inheritdoc
@ -46,8 +46,13 @@ protected function formatDescription($bookmark)
$bookmark->getDescription() ?? '', $bookmark->getDescription() ?? '',
$bookmark->getAdditionalContentEntry('search_highlight')['description'] ?? [] $bookmark->getAdditionalContentEntry('search_highlight')['description'] ?? []
); );
$description = format_description(
escape($description),
$indexUrl,
$this->conf->get('formatter_settings.autolink', true)
);
return $this->replaceTokens(format_description(escape($description), $indexUrl)); return $this->replaceTokens($description);
} }
/** /**
@ -63,15 +68,16 @@ protected function formatTagList($bookmark)
*/ */
protected function formatTagListHtml($bookmark) protected function formatTagListHtml($bookmark)
{ {
$tagsSeparator = $this->conf->get('general.tags_separator', ' ');
if (empty($bookmark->getAdditionalContentEntry('search_highlight')['tags'])) { if (empty($bookmark->getAdditionalContentEntry('search_highlight')['tags'])) {
return $this->formatTagList($bookmark); return $this->formatTagList($bookmark);
} }
$tags = $this->tokenizeSearchHighlightField( $tags = $this->tokenizeSearchHighlightField(
$bookmark->getTagsString(), $bookmark->getTagsString($tagsSeparator),
$bookmark->getAdditionalContentEntry('search_highlight')['tags'] $bookmark->getAdditionalContentEntry('search_highlight')['tags']
); );
$tags = $this->filterTagList(explode(' ', $tags)); $tags = $this->filterTagList(tags_str2array($tags, $tagsSeparator));
$tags = escape($tags); $tags = escape($tags);
$tags = $this->replaceTokensArray($tags); $tags = $this->replaceTokensArray($tags);
@ -83,7 +89,7 @@ protected function formatTagListHtml($bookmark)
*/ */
protected function formatTagString($bookmark) protected function formatTagString($bookmark)
{ {
return implode(' ', $this->formatTagList($bookmark)); return implode($this->conf->get('general.tags_separator'), $this->formatTagList($bookmark));
} }
/** /**

View file

@ -267,7 +267,7 @@ protected function formatTagListHtml($bookmark)
*/ */
protected function formatTagString($bookmark) protected function formatTagString($bookmark)
{ {
return implode(' ', $this->formatTagList($bookmark)); return implode($this->conf->get('general.tags_separator', ' '), $this->formatTagList($bookmark));
} }
/** /**
@ -351,6 +351,7 @@ protected function formatUpdatedTimestamp(Bookmark $bookmark)
/** /**
* Format tag list, e.g. remove private tags if the user is not logged in. * Format tag list, e.g. remove private tags if the user is not logged in.
* TODO: this method is called multiple time to format tags, the result should be cached.
* *
* @param array $tags * @param array $tags
* *

View file

@ -16,7 +16,7 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter
/** /**
* When this tag is present in a bookmark, its description should not be processed with Markdown * When this tag is present in a bookmark, its description should not be processed with Markdown
*/ */
const NO_MD_TAG = 'nomarkdown'; public const NO_MD_TAG = 'nomarkdown';
/** @var \Parsedown instance */ /** @var \Parsedown instance */
protected $parsedown; protected $parsedown;
@ -71,7 +71,7 @@ public function formatDescription($bookmark)
$processedDescription = $this->replaceTokens($processedDescription); $processedDescription = $this->replaceTokens($processedDescription);
if (!empty($processedDescription)) { if (!empty($processedDescription)) {
$processedDescription = '<div class="markdown">'. $processedDescription . '</div>'; $processedDescription = '<div class="markdown">' . $processedDescription . '</div>';
} }
return $processedDescription; return $processedDescription;
@ -110,7 +110,7 @@ protected function filterProtocols($description)
function ($match) use ($allowedProtocols, $indexUrl) { function ($match) use ($allowedProtocols, $indexUrl) {
$link = startsWith($match[1], '?') || startsWith($match[1], '/') ? $indexUrl : ''; $link = startsWith($match[1], '?') || startsWith($match[1], '/') ? $indexUrl : '';
$link .= whitelist_protocols($match[1], $allowedProtocols); $link .= whitelist_protocols($match[1], $allowedProtocols);
return ']('. $link.')'; return '](' . $link . ')';
}, },
$description $description
); );
@ -137,7 +137,7 @@ protected function formatHashTags($description)
* \p{Mn} - any non marking space (accents, umlauts, etc) * \p{Mn} - any non marking space (accents, umlauts, etc)
*/ */
$regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui'; $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui';
$replacement = '$1[#$2]('. $indexUrl .'./add-tag/$2)'; $replacement = '$1[#$2](' . $indexUrl . './add-tag/$2)';
$descriptionLines = explode(PHP_EOL, $description); $descriptionLines = explode(PHP_EOL, $description);
$descriptionOut = ''; $descriptionOut = '';
@ -178,17 +178,17 @@ protected function formatHashTags($description)
*/ */
protected function sanitizeHtml($description) protected function sanitizeHtml($description)
{ {
$escapeTags = array( $escapeTags = [
'script', 'script',
'style', 'style',
'link', 'link',
'iframe', 'iframe',
'frameset', 'frameset',
'frame', 'frame',
); ];
foreach ($escapeTags as $tag) { foreach ($escapeTags as $tag) {
$description = preg_replace_callback( $description = preg_replace_callback(
'#<\s*'. $tag .'[^>]*>(.*</\s*'. $tag .'[^>]*>)?#is', '#<\s*' . $tag . '[^>]*>(.*</\s*' . $tag . '[^>]*>)?#is',
function ($match) { function ($match) {
return escape($match[0]); return escape($match[0]);
}, },

View file

@ -10,4 +10,6 @@
* *
* @package Shaarli\Formatter * @package Shaarli\Formatter
*/ */
class BookmarkRawFormatter extends BookmarkFormatter {} class BookmarkRawFormatter extends BookmarkFormatter
{
}

View file

@ -41,7 +41,7 @@ public function __construct(ConfigManager $conf, bool $isLoggedIn)
public function getFormatter(string $type = null): BookmarkFormatter public function getFormatter(string $type = null): BookmarkFormatter
{ {
$type = $type ? $type : $this->conf->get('formatter', 'default'); $type = $type ? $type : $this->conf->get('formatter', 'default');
$className = '\\Shaarli\\Formatter\\Bookmark'. ucfirst($type) .'Formatter'; $className = '\\Shaarli\\Formatter\\Bookmark' . ucfirst($type) . 'Formatter';
if (!class_exists($className)) { if (!class_exists($className)) {
$className = '\\Shaarli\\Formatter\\BookmarkDefaultFormatter'; $className = '\\Shaarli\\Formatter\\BookmarkDefaultFormatter';
} }

View file

@ -42,7 +42,8 @@ public function __invoke(Request $request, Response $response, callable $next):
$this->initBasePath($request); $this->initBasePath($request);
try { try {
if (!is_file($this->container->conf->getConfigFileExt()) if (
!is_file($this->container->conf->getConfigFileExt())
&& !in_array($next->getName(), ['displayInstall', 'saveInstall'], true) && !in_array($next->getName(), ['displayInstall', 'saveInstall'], true)
) { ) {
return $response->withRedirect($this->container->basePath . '/install'); return $response->withRedirect($this->container->basePath . '/install');
@ -86,7 +87,8 @@ protected function runUpdates(): void
*/ */
protected function checkOpenShaarli(Request $request, Response $response, callable $next): bool protected function checkOpenShaarli(Request $request, Response $response, callable $next): bool
{ {
if (// if the user isn't logged in if (
// if the user isn't logged in
!$this->container->loginManager->isLoggedIn() !$this->container->loginManager->isLoggedIn()
// and Shaarli doesn't have public content... // and Shaarli doesn't have public content...
&& $this->container->conf->get('privacy.hide_public_links') && $this->container->conf->get('privacy.hide_public_links')

View file

@ -51,7 +51,10 @@ public function index(Request $request, Response $response): Response
$this->assignView('languages', Languages::getAvailableLanguages()); $this->assignView('languages', Languages::getAvailableLanguages());
$this->assignView('gd_enabled', extension_loaded('gd')); $this->assignView('gd_enabled', extension_loaded('gd'));
$this->assignView('thumbnails_mode', $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE)); $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')); $this->assignView(
'pagetitle',
t('Configure') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
);
return $response->write($this->render(TemplatePage::CONFIGURE)); return $response->write($this->render(TemplatePage::CONFIGURE));
} }
@ -95,12 +98,15 @@ public function save(Request $request, Response $response): Response
} }
$thumbnailsMode = extension_loaded('gd') ? $request->getParam('enableThumbnails') : Thumbnailer::MODE_NONE; $thumbnailsMode = extension_loaded('gd') ? $request->getParam('enableThumbnails') : Thumbnailer::MODE_NONE;
if ($thumbnailsMode !== Thumbnailer::MODE_NONE if (
$thumbnailsMode !== Thumbnailer::MODE_NONE
&& $thumbnailsMode !== $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) && $thumbnailsMode !== $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE)
) { ) {
$this->saveWarningMessage( $this->saveWarningMessage(
t('You have enabled or changed thumbnails mode.') . t('You have enabled or changed thumbnails mode.') .
'<a href="'. $this->container->basePath .'/admin/thumbnails">' . t('Please synchronize them.') .'</a>' '<a href="' . $this->container->basePath . '/admin/thumbnails">' .
t('Please synchronize them.') .
'</a>'
); );
} }
$this->container->conf->set('thumbnails.mode', $thumbnailsMode); $this->container->conf->set('thumbnails.mode', $thumbnailsMode);

View file

@ -23,7 +23,7 @@ class ExportController extends ShaarliAdminController
*/ */
public function index(Request $request, Response $response): Response public function index(Request $request, Response $response): Response
{ {
$this->assignView('pagetitle', t('Export') .' - '. $this->container->conf->get('general.title', 'Shaarli')); $this->assignView('pagetitle', t('Export') . ' - ' . $this->container->conf->get('general.title', 'Shaarli'));
return $response->write($this->render(TemplatePage::EXPORT)); return $response->write($this->render(TemplatePage::EXPORT));
} }
@ -68,7 +68,7 @@ public function export(Request $request, Response $response): Response
$response = $response->withHeader('Content-Type', 'text/html; charset=utf-8'); $response = $response->withHeader('Content-Type', 'text/html; charset=utf-8');
$response = $response->withHeader( $response = $response->withHeader(
'Content-disposition', 'Content-disposition',
'attachment; filename=bookmarks_'.$selection.'_'.$now->format(Bookmark::LINK_DATE_FORMAT).'.html' 'attachment; filename=bookmarks_' . $selection . '_' . $now->format(Bookmark::LINK_DATE_FORMAT) . '.html'
); );
$this->assignView('date', $now->format(DateTime::RFC822)); $this->assignView('date', $now->format(DateTime::RFC822));

View file

@ -38,7 +38,7 @@ public function index(Request $request, Response $response): Response
true true
) )
); );
$this->assignView('pagetitle', t('Import') .' - '. $this->container->conf->get('general.title', 'Shaarli')); $this->assignView('pagetitle', t('Import') . ' - ' . $this->container->conf->get('general.title', 'Shaarli'));
return $response->write($this->render(TemplatePage::IMPORT)); return $response->write($this->render(TemplatePage::IMPORT));
} }
@ -64,7 +64,7 @@ public function import(Request $request, Response $response): Response
$msg = sprintf( $msg = sprintf(
t( t(
'The file you are trying to upload is probably bigger than what this webserver can accept' 'The file you are trying to upload is probably bigger than what this webserver can accept'
.' (%s). Please upload in smaller chunks.' . ' (%s). Please upload in smaller chunks.'
), ),
get_max_upload_size(ini_get('post_max_size'), ini_get('upload_max_filesize')) get_max_upload_size(ini_get('post_max_size'), ini_get('upload_max_filesize'))
); );

View file

@ -1,360 +0,0 @@
<?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 (true !== $this->container->conf->get('general.enable_async_metadata', true)
&& empty($title)
&& strpos(get_url_scheme($url) ?: '', 'http') !== false
) {
$metadata = $this->container->metadataRetriever->retrieve($url);
}
if (empty($url)) {
$metadata['title'] = $this->container->conf->get('general.default_note_title', t('Note: '));
}
$link = [
'title' => $title ?? $metadata['title'] ?? '',
'url' => $url ?? '',
'description' => $description ?? $metadata['description'] ?? '',
'tags' => $tags ?? $metadata['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') !== null ? 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
&& true !== $this->container->conf->get('general.enable_async_metadata', true)
&& $bookmark->shouldUpdateThumbnail()
) {
$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,
['/admin/add-shaare', '/admin/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 = escape([
'link' => $link,
'link_is_new' => $isNew,
'http_referer' => $this->container->environment['HTTP_REFERER'] ?? '',
'source' => $request->getParam('source') ?? '',
'tags' => $tags,
'default_private_links' => $this->container->conf->get('privacy.default_private_links', false),
'async_metadata' => $this->container->conf->get('general.enable_async_metadata', true),
'retrieve_description' => $this->container->conf->get('general.retrieve_description', 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

@ -24,9 +24,15 @@ public function index(Request $request, Response $response): Response
$fromTag = $request->getParam('fromtag') ?? ''; $fromTag = $request->getParam('fromtag') ?? '';
$this->assignView('fromtag', escape($fromTag)); $this->assignView('fromtag', escape($fromTag));
$separator = escape($this->container->conf->get('general.tags_separator', ' '));
if ($separator === ' ') {
$separator = '&nbsp;';
$this->assignView('tags_separator_desc', t('whitespace'));
}
$this->assignView('tags_separator', $separator);
$this->assignView( $this->assignView(
'pagetitle', 'pagetitle',
t('Manage tags') .' - '. $this->container->conf->get('general.title', 'Shaarli') t('Manage tags') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
); );
return $response->write($this->render(TemplatePage::CHANGE_TAG)); return $response->write($this->render(TemplatePage::CHANGE_TAG));
@ -81,8 +87,35 @@ public function save(Request $request, Response $response): Response
$this->saveSuccessMessage($alert); $this->saveSuccessMessage($alert);
$redirect = true === $isDelete ? '/admin/tags' : '/?searchtags='. urlencode($toTag); $redirect = true === $isDelete ? '/admin/tags' : '/?searchtags=' . urlencode($toTag);
return $this->redirect($response, $redirect); return $this->redirect($response, $redirect);
} }
/**
* POST /admin/tags/change-separator - Change tag separator
*/
public function changeSeparator(Request $request, Response $response): Response
{
$this->checkToken($request);
$reservedCharacters = ['-', '.', '*'];
$newSeparator = $request->getParam('separator');
if ($newSeparator === null || mb_strlen($newSeparator) !== 1) {
$this->saveErrorMessage(t('Tags separator must be a single character.'));
} elseif (in_array($newSeparator, $reservedCharacters, true)) {
$reservedCharacters = implode(' ', array_map(function (string $character) {
return '<code>' . $character . '</code>';
}, $reservedCharacters));
$this->saveErrorMessage(
t('These characters are reserved and can\'t be used as tags separator: ') . $reservedCharacters
);
} else {
$this->container->conf->set('general.tags_separator', $newSeparator, true, true);
$this->saveSuccessMessage('Your tags separator setting has been updated!');
}
return $this->redirect($response, '/admin/tags');
}
} }

View file

@ -25,7 +25,7 @@ public function __construct(ShaarliContainer $container)
$this->assignView( $this->assignView(
'pagetitle', 'pagetitle',
t('Change password') .' - '. $this->container->conf->get('general.title', 'Shaarli') t('Change password') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
); );
} }
@ -78,7 +78,7 @@ public function change(Request $request, Response $response): Response
// Save new password // Save new password
// Salt renders rainbow-tables attacks useless. // Salt renders rainbow-tables attacks useless.
$this->container->conf->set('credentials.salt', sha1(uniqid('', true) .'_'. mt_rand())); $this->container->conf->set('credentials.salt', sha1(uniqid('', true) . '_' . mt_rand()));
$this->container->conf->set( $this->container->conf->set(
'credentials.hash', 'credentials.hash',
sha1( sha1(

View file

@ -42,7 +42,7 @@ function ($a, $b) {
$this->assignView('disabledPlugins', $disabledPlugins); $this->assignView('disabledPlugins', $disabledPlugins);
$this->assignView( $this->assignView(
'pagetitle', 'pagetitle',
t('Plugin Administration') .' - '. $this->container->conf->get('general.title', 'Shaarli') t('Plugin Administration') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
); );
return $response->write($this->render(TemplatePage::PLUGINS_ADMIN)); return $response->write($this->render(TemplatePage::PLUGINS_ADMIN));
@ -64,7 +64,7 @@ public function save(Request $request, Response $response): Response
unset($parameters['parameters_form']); unset($parameters['parameters_form']);
unset($parameters['token']); unset($parameters['token']);
foreach ($parameters as $param => $value) { foreach ($parameters as $param => $value) {
$this->container->conf->set('plugins.'. $param, escape($value)); $this->container->conf->set('plugins.' . $param, escape($value));
} }
} else { } else {
$this->container->conf->set('general.enabled_plugins', save_plugin_config($parameters)); $this->container->conf->set('general.enabled_plugins', save_plugin_config($parameters));

View file

@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Admin;
use Shaarli\Helper\ApplicationUtils;
use Shaarli\Helper\FileUtils;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Slim controller used to handle Server administration page, and actions.
*/
class ServerController extends ShaarliAdminController
{
/** @var string Cache type - main - by default pagecache/ and tmp/ */
protected const CACHE_MAIN = 'main';
/** @var string Cache type - thumbnails - by default cache/ */
protected const CACHE_THUMB = 'thumbnails';
/**
* GET /admin/server - Display page Server administration
*/
public function index(Request $request, Response $response): Response
{
$releaseUrl = ApplicationUtils::$GITHUB_URL . '/releases/';
if ($this->container->conf->get('updates.check_updates', true)) {
$latestVersion = 'v' . ApplicationUtils::getVersion(
ApplicationUtils::$GIT_RAW_URL . '/latest/' . ApplicationUtils::$VERSION_FILE
);
$releaseUrl .= 'tag/' . $latestVersion;
} else {
$latestVersion = t('Check disabled');
}
$currentVersion = ApplicationUtils::getVersion('./shaarli_version.php');
$currentVersion = $currentVersion === 'dev' ? $currentVersion : 'v' . $currentVersion;
$phpEol = new \DateTimeImmutable(ApplicationUtils::getPhpEol(PHP_VERSION));
$this->assignView('php_version', PHP_VERSION);
$this->assignView('php_eol', format_date($phpEol, false));
$this->assignView('php_has_reached_eol', $phpEol < new \DateTimeImmutable());
$this->assignView('php_extensions', ApplicationUtils::getPhpExtensionsRequirement());
$this->assignView('permissions', ApplicationUtils::checkResourcePermissions($this->container->conf));
$this->assignView('release_url', $releaseUrl);
$this->assignView('latest_version', $latestVersion);
$this->assignView('current_version', $currentVersion);
$this->assignView('thumbnails_mode', $this->container->conf->get('thumbnails.mode'));
$this->assignView('index_url', index_url($this->container->environment));
$this->assignView('client_ip', client_ip_id($this->container->environment));
$this->assignView('trusted_proxies', $this->container->conf->get('security.trusted_proxies', []));
$this->assignView(
'pagetitle',
t('Server administration') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
);
return $response->write($this->render('server'));
}
/**
* GET /admin/clear-cache?type={$type} - Action to trigger cache folder clearing (either main or thumbnails).
*/
public function clearCache(Request $request, Response $response): Response
{
$exclude = ['.htaccess'];
if ($request->getQueryParam('type') === static::CACHE_THUMB) {
$folders = [$this->container->conf->get('resource.thumbnails_cache')];
$this->saveWarningMessage(
t('Thumbnails cache has been cleared.') . ' ' .
'<a href="' . $this->container->basePath . '/admin/thumbnails">' .
t('Please synchronize them.') .
'</a>'
);
} else {
$folders = [
$this->container->conf->get('resource.page_cache'),
$this->container->conf->get('resource.raintpl_tmp'),
];
$this->saveSuccessMessage(t('Shaarli\'s cache folder has been cleared!'));
}
// Make sure that we don't delete root cache folder
$folders = array_map('realpath', array_values(array_filter(array_map('trim', $folders))));
foreach ($folders as $folder) {
FileUtils::clearFolder($folder, false, $exclude);
}
return $this->redirect($response, '/admin/server');
}
}

View file

@ -45,6 +45,4 @@ public function visibility(Request $request, Response $response, array $args): R
return $this->redirectFromReferer($request, $response, ['visibility']); return $this->redirectFromReferer($request, $response, ['visibility']);
} }
} }

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Admin;
use Shaarli\Formatter\BookmarkMarkdownFormatter;
use Shaarli\Render\TemplatePage;
use Slim\Http\Request;
use Slim\Http\Response;
class ShaareAddController 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
{
$tags = $this->container->bookmarkService->bookmarksCountPerTag();
if ($this->container->conf->get('formatter') === 'markdown') {
$tags[BookmarkMarkdownFormatter::NO_MD_TAG] = 1;
}
$this->assignView(
'pagetitle',
t('Shaare a new link') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
);
$this->assignView('tags', $tags);
$this->assignView('default_private_links', $this->container->conf->get('privacy.default_private_links', false));
$this->assignView('async_metadata', $this->container->conf->get('general.enable_async_metadata', true));
return $response->write($this->render(TemplatePage::ADDLINK));
}
}

View file

@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Admin;
use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Class PostBookmarkController
*
* Slim controller used to handle Shaarli create or edit bookmarks.
*/
class ShaareManageController extends ShaarliAdminController
{
/**
* 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 permalink after deletion.
return $this->redirectFromReferer($request, $response, ['shaare/']);
}
/**
* 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->conf->get('general.tags_separator', ' '));
$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->conf->get('general.tags_separator', ' '));
$this->container->bookmarkService->set($bookmark);
return $this->redirectFromReferer($request, $response, ['/pin'], ['pin']);
}
/**
* GET /admin/shaare/private/{hash} - Attach a private key to given bookmark, then redirect to the sharing URL.
*/
public function sharePrivate(Request $request, Response $response, array $args): Response
{
$this->checkToken($request);
$hash = $args['hash'] ?? '';
$bookmark = $this->container->bookmarkService->findByHash($hash);
if ($bookmark->isPrivate() !== true) {
return $this->redirect($response, '/shaare/' . $hash);
}
if (empty($bookmark->getAdditionalContentEntry('private_key'))) {
$privateKey = bin2hex(random_bytes(16));
$bookmark->addAdditionalContentEntry('private_key', $privateKey);
$this->container->bookmarkService->set($bookmark);
}
return $this->redirect(
$response,
'/shaare/' . $hash . '?key=' . $bookmark->getAdditionalContentEntry('private_key')
);
}
}

View file

@ -0,0 +1,274 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Admin;
use Shaarli\Bookmark\Bookmark;
use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
use Shaarli\Formatter\BookmarkFormatter;
use Shaarli\Formatter\BookmarkMarkdownFormatter;
use Shaarli\Render\TemplatePage;
use Shaarli\Thumbnailer;
use Slim\Http\Request;
use Slim\Http\Response;
class ShaarePublishController extends ShaarliAdminController
{
/**
* @var BookmarkFormatter[] Statically cached instances of formatters
*/
protected $formatters = [];
/**
* @var array Statically cached bookmark's tags counts
*/
protected $tags;
/**
* 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'));
$link = $this->buildLinkDataFromUrl($request, $url);
return $this->displayForm($link, $link['linkIsNew'], $request, $response);
}
/**
* POST /admin/shaare-batch - Displays multiple creation/edit forms from bulk add in add-link page.
*/
public function displayCreateBatchForms(Request $request, Response $response): Response
{
$urls = array_map('cleanup_url', explode(PHP_EOL, $request->getParam('urls')));
$links = [];
foreach ($urls as $url) {
if (empty($url)) {
continue;
}
$link = $this->buildLinkDataFromUrl($request, $url);
$data = $this->buildFormData($link, $link['linkIsNew'], $request);
$data['token'] = $this->container->sessionManager->generateToken();
$data['source'] = 'batch';
$this->executePageHooks('render_editlink', $data, TemplatePage::EDIT_LINK);
$links[] = $data;
}
$this->assignView('links', $links);
$this->assignView('batch_mode', true);
$this->assignView('async_metadata', $this->container->conf->get('general.enable_async_metadata', true));
return $response->write($this->render(TemplatePage::EDIT_LINK_BATCH));
}
/**
* 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->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') !== null ? 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'),
$this->container->conf->get('general.tags_separator', ' ')
);
if (
$this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE
&& true !== $this->container->conf->get('general.enable_async_metadata', true)
&& $bookmark->shouldUpdateThumbnail()
) {
$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->getFormatter('raw');
$data = $formatter->format($bookmark);
$this->executePageHooks('save_link', $data);
$bookmark->fromArray($data, $this->container->conf->get('general.tags_separator', ' '));
$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>');
} elseif ($request->getParam('source') === 'batch') {
return $response;
}
if (!empty($request->getParam('returnurl'))) {
$this->container->environment['HTTP_REFERER'] = $request->getParam('returnurl');
}
return $this->redirectFromReferer(
$request,
$response,
['/admin/add-shaare', '/admin/shaare'],
['addlink', 'post', 'edit_link'],
$bookmark->getShortUrl()
);
}
/**
* 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
{
$data = $this->buildFormData($link, $isNew, $request);
$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));
}
protected function buildLinkDataFromUrl(Request $request, string $url): array
{
// 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) {
// 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');
if ($request->getParam('private') !== null) {
$private = filter_var($request->getParam('private'), FILTER_VALIDATE_BOOLEAN);
} else {
$private = $this->container->conf->get('privacy.default_private_links', false);
}
// 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 (
true !== $this->container->conf->get('general.enable_async_metadata', true)
&& empty($title)
&& strpos(get_url_scheme($url) ?: '', 'http') !== false
) {
$metadata = $this->container->metadataRetriever->retrieve($url);
}
if (empty($url)) {
$metadata['title'] = $this->container->conf->get('general.default_note_title', t('Note: '));
}
return [
'title' => $title ?? $metadata['title'] ?? '',
'url' => $url ?? '',
'description' => $description ?? $metadata['description'] ?? '',
'tags' => $tags ?? $metadata['tags'] ?? '',
'private' => $private,
'linkIsNew' => true,
];
}
$formatter = $this->getFormatter('raw');
$link = $formatter->format($bookmark);
$link['linkIsNew'] = false;
return $link;
}
protected function buildFormData(array $link, bool $isNew, Request $request): array
{
$link['tags'] = strlen($link['tags']) > 0
? $link['tags'] . $this->container->conf->get('general.tags_separator', ' ')
: $link['tags']
;
return escape([
'link' => $link,
'link_is_new' => $isNew,
'http_referer' => $this->container->environment['HTTP_REFERER'] ?? '',
'source' => $request->getParam('source') ?? '',
'tags' => $this->getTags(),
'default_private_links' => $this->container->conf->get('privacy.default_private_links', false),
'async_metadata' => $this->container->conf->get('general.enable_async_metadata', true),
'retrieve_description' => $this->container->conf->get('general.retrieve_description', false),
]);
}
/**
* Memoize formatterFactory->getFormatter() calls.
*/
protected function getFormatter(string $type): BookmarkFormatter
{
if (!array_key_exists($type, $this->formatters) || $this->formatters[$type] === null) {
$this->formatters[$type] = $this->container->formatterFactory->getFormatter($type);
}
return $this->formatters[$type];
}
/**
* Memoize bookmarkService->bookmarksCountPerTag() calls.
*/
protected function getTags(): array
{
if ($this->tags === null) {
$this->tags = $this->container->bookmarkService->bookmarksCountPerTag();
if ($this->container->conf->get('formatter') === 'markdown') {
$this->tags[BookmarkMarkdownFormatter::NO_MD_TAG] = 1;
}
}
return $this->tags;
}
}

View file

@ -34,7 +34,7 @@ public function index(Request $request, Response $response): Response
$this->assignView('ids', $ids); $this->assignView('ids', $ids);
$this->assignView( $this->assignView(
'pagetitle', 'pagetitle',
t('Thumbnails update') .' - '. $this->container->conf->get('general.title', 'Shaarli') t('Thumbnails update') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
); );
return $response->write($this->render(TemplatePage::THUMBNAILS)); return $response->write($this->render(TemplatePage::THUMBNAILS));

View file

@ -28,7 +28,7 @@ public function index(Request $request, Response $response): Response
$this->assignView($key, $value); $this->assignView($key, $value);
} }
$this->assignView('pagetitle', t('Tools') .' - '. $this->container->conf->get('general.title', 'Shaarli')); $this->assignView('pagetitle', t('Tools') . ' - ' . $this->container->conf->get('general.title', 'Shaarli'));
return $response->write($this->render(TemplatePage::TOOLS)); return $response->write($this->render(TemplatePage::TOOLS));
} }

View file

@ -35,7 +35,8 @@ public function index(Request $request, Response $response): Response
$formatter->addContextData('base_path', $this->container->basePath); $formatter->addContextData('base_path', $this->container->basePath);
$searchTags = normalize_spaces($request->getParam('searchtags') ?? ''); $searchTags = normalize_spaces($request->getParam('searchtags') ?? '');
$searchTerm = escape(normalize_spaces($request->getParam('searchterm') ?? ''));; $searchTerm = escape(normalize_spaces($request->getParam('searchterm') ?? ''));
;
// Filter bookmarks according search parameters. // Filter bookmarks according search parameters.
$visibility = $this->container->sessionManager->getSessionParameter('visibility'); $visibility = $this->container->sessionManager->getSessionParameter('visibility');
@ -95,6 +96,10 @@ public function index(Request $request, Response $response): Response
$next_page_url = '?page=' . ($page - 1) . $searchtermUrl . $searchtagsUrl; $next_page_url = '?page=' . ($page - 1) . $searchtermUrl . $searchtagsUrl;
} }
$tagsSeparator = $this->container->conf->get('general.tags_separator', ' ');
$searchTagsUrlEncoded = array_map('urlencode', tags_str2array($searchTags, $tagsSeparator));
$searchTags = !empty($searchTags) ? trim($searchTags, $tagsSeparator) . $tagsSeparator : '';
// Fill all template fields. // Fill all template fields.
$data = array_merge( $data = array_merge(
$this->initializeTemplateVars(), $this->initializeTemplateVars(),
@ -106,7 +111,7 @@ public function index(Request $request, Response $response): Response
'result_count' => count($linksToDisplay), 'result_count' => count($linksToDisplay),
'search_term' => escape($searchTerm), 'search_term' => escape($searchTerm),
'search_tags' => escape($searchTags), 'search_tags' => escape($searchTags),
'search_tags_url' => array_map('urlencode', explode(' ', $searchTags)), 'search_tags_url' => $searchTagsUrlEncoded,
'visibility' => $visibility, 'visibility' => $visibility,
'links' => $linkDisp, 'links' => $linkDisp,
] ]
@ -119,8 +124,9 @@ public function index(Request $request, Response $response): Response
return '[' . $tag . ']'; return '[' . $tag . ']';
}; };
$data['pagetitle'] .= ! empty($searchTags) $data['pagetitle'] .= ! empty($searchTags)
? implode(' ', array_map($bracketWrap, preg_split('/\s+/', $searchTags))) . ' ' ? implode(' ', array_map($bracketWrap, tags_str2array($searchTags, $tagsSeparator))) . ' '
: ''; : ''
;
$data['pagetitle'] .= '- '; $data['pagetitle'] .= '- ';
} }
@ -137,8 +143,10 @@ public function index(Request $request, Response $response): Response
*/ */
public function permalink(Request $request, Response $response, array $args): Response public function permalink(Request $request, Response $response, array $args): Response
{ {
$privateKey = $request->getParam('key');
try { try {
$bookmark = $this->container->bookmarkService->findByHash($args['hash']); $bookmark = $this->container->bookmarkService->findByHash($args['hash'], $privateKey);
} catch (BookmarkNotFoundException $e) { } catch (BookmarkNotFoundException $e) {
$this->assignView('error_message', $e->getMessage()); $this->assignView('error_message', $e->getMessage());
@ -153,7 +161,7 @@ public function permalink(Request $request, Response $response, array $args): Re
$data = array_merge( $data = array_merge(
$this->initializeTemplateVars(), $this->initializeTemplateVars(),
[ [
'pagetitle' => $bookmark->getTitle() .' - '. $this->container->conf->get('general.title', 'Shaarli'), 'pagetitle' => $bookmark->getTitle() . ' - ' . $this->container->conf->get('general.title', 'Shaarli'),
'links' => [$formatter->format($bookmark)], 'links' => [$formatter->format($bookmark)],
] ]
); );
@ -169,16 +177,25 @@ public function permalink(Request $request, Response $response, array $args): Re
*/ */
protected function updateThumbnail(Bookmark $bookmark, bool $writeDatastore = true): bool protected function updateThumbnail(Bookmark $bookmark, bool $writeDatastore = true): bool
{ {
// Logged in, not async retrieval, thumbnails enabled, and thumbnail should be updated if (false === $this->container->loginManager->isLoggedIn()) {
if ($this->container->loginManager->isLoggedIn() return false;
&& true !== $this->container->conf->get('general.enable_async_metadata', true) }
&& $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE
&& $bookmark->shouldUpdateThumbnail()
) {
$bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl()));
$this->container->bookmarkService->set($bookmark, $writeDatastore);
return true; // If thumbnail should be updated, we reset it to null
if ($bookmark->shouldUpdateThumbnail()) {
$bookmark->setThumbnail(null);
// Requires an update, not async retrieval, thumbnails enabled
if (
$bookmark->shouldUpdateThumbnail()
&& true !== $this->container->conf->get('general.enable_async_metadata', true)
&& $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE
) {
$bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl()));
$this->container->bookmarkService->set($bookmark, $writeDatastore);
return true;
}
} }
return false; return false;

View file

@ -5,8 +5,8 @@
namespace Shaarli\Front\Controller\Visitor; namespace Shaarli\Front\Controller\Visitor;
use DateTime; use DateTime;
use DateTimeImmutable;
use Shaarli\Bookmark\Bookmark; use Shaarli\Bookmark\Bookmark;
use Shaarli\Helper\DailyPageHelper;
use Shaarli\Render\TemplatePage; use Shaarli\Render\TemplatePage;
use Slim\Http\Request; use Slim\Http\Request;
use Slim\Http\Response; use Slim\Http\Response;
@ -26,32 +26,20 @@ class DailyController extends ShaarliVisitorController
*/ */
public function index(Request $request, Response $response): Response public function index(Request $request, Response $response): Response
{ {
$day = $request->getQueryParam('day') ?? date('Ymd'); $type = DailyPageHelper::extractRequestedType($request);
$format = DailyPageHelper::getFormatByType($type);
$latestBookmark = $this->container->bookmarkService->getLatest();
$dateTime = DailyPageHelper::extractRequestedDateTime($type, $request->getQueryParam($type), $latestBookmark);
$start = DailyPageHelper::getStartDateTimeByType($type, $dateTime);
$end = DailyPageHelper::getEndDateTimeByType($type, $dateTime);
$dailyDesc = DailyPageHelper::getDescriptionByType($type, $dateTime);
$availableDates = $this->container->bookmarkService->days(); $linksToDisplay = $this->container->bookmarkService->findByDate(
$nbAvailableDates = count($availableDates); $start,
$index = array_search($day, $availableDates); $end,
$previousDay,
if ($index === false) { $nextDay
// 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 = $this->container->formatterFactory->getFormatter();
$formatter->addContextData('base_path', $this->container->basePath); $formatter->addContextData('base_path', $this->container->basePath);
@ -63,13 +51,15 @@ public function index(Request $request, Response $response): Response
$linksToDisplay[$key]['description'] = $bookmark->getDescription(); $linksToDisplay[$key]['description'] = $bookmark->getDescription();
} }
$dayDate = DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, $day.'_000000');
$data = [ $data = [
'linksToDisplay' => $linksToDisplay, 'linksToDisplay' => $linksToDisplay,
'day' => $dayDate->getTimestamp(), 'dayDate' => $start,
'dayDate' => $dayDate, 'day' => $start->getTimestamp(),
'previousday' => $previousDay ?? '', 'previousday' => $previousDay ? $previousDay->format($format) : '',
'nextday' => $nextDay ?? '', 'nextday' => $nextDay ? $nextDay->format($format) : '',
'dayDesc' => $dailyDesc,
'type' => $type,
'localizedType' => $this->translateType($type),
]; ];
// Hooks are called before column construction so that plugins don't have to deal with columns. // Hooks are called before column construction so that plugins don't have to deal with columns.
@ -82,7 +72,7 @@ public function index(Request $request, Response $response): Response
$mainTitle = $this->container->conf->get('general.title', 'Shaarli'); $mainTitle = $this->container->conf->get('general.title', 'Shaarli');
$this->assignView( $this->assignView(
'pagetitle', 'pagetitle',
t('Daily') .' - '. format_date($dayDate, false) . ' - ' . $mainTitle $data['localizedType'] . ' - ' . $data['dayDesc'] . ' - ' . $mainTitle
); );
return $response->write($this->render(TemplatePage::DAILY)); return $response->write($this->render(TemplatePage::DAILY));
@ -106,11 +96,14 @@ public function rss(Request $request, Response $response): Response
} }
$days = []; $days = [];
$type = DailyPageHelper::extractRequestedType($request);
$format = DailyPageHelper::getFormatByType($type);
$length = DailyPageHelper::getRssLengthByType($type);
foreach ($this->container->bookmarkService->search() as $bookmark) { foreach ($this->container->bookmarkService->search() as $bookmark) {
$day = $bookmark->getCreated()->format('Ymd'); $day = $bookmark->getCreated()->format($format);
// Stop iterating after DAILY_RSS_NB_DAYS entries // Stop iterating after DAILY_RSS_NB_DAYS entries
if (count($days) === static::$DAILY_RSS_NB_DAYS && !isset($days[$day])) { if (count($days) === $length && !isset($days[$day])) {
break; break;
} }
@ -127,12 +120,19 @@ public function rss(Request $request, Response $response): Response
/** @var Bookmark[] $bookmarks */ /** @var Bookmark[] $bookmarks */
foreach ($days as $day => $bookmarks) { foreach ($days as $day => $bookmarks) {
$dayDatetime = DateTimeImmutable::createFromFormat(Bookmark::LINK_DATE_FORMAT, $day.'_000000'); $dayDateTime = DailyPageHelper::extractRequestedDateTime($type, (string) $day);
$endDateTime = DailyPageHelper::getEndDateTimeByType($type, $dayDateTime);
// We only want the RSS entry to be published when the period is over.
if (new DateTime() < $endDateTime) {
continue;
}
$dataPerDay[$day] = [ $dataPerDay[$day] = [
'date' => $dayDatetime, 'date' => $endDateTime,
'date_rss' => $dayDatetime->format(DateTime::RSS), 'date_rss' => $endDateTime->format(DateTime::RSS),
'date_human' => format_date($dayDatetime, false, true), 'date_human' => DailyPageHelper::getDescriptionByType($type, $dayDateTime),
'absolute_url' => $indexUrl . 'daily?day=' . $day, 'absolute_url' => $indexUrl . 'daily?' . $type . '=' . $day,
'links' => [], 'links' => [],
]; ];
@ -141,16 +141,20 @@ public function rss(Request $request, Response $response): Response
// Make permalink URL absolute // Make permalink URL absolute
if ($bookmark->isNote()) { if ($bookmark->isNote()) {
$dataPerDay[$day]['links'][$key]['url'] = $indexUrl . $bookmark->getUrl(); $dataPerDay[$day]['links'][$key]['url'] = rtrim($indexUrl, '/') . $bookmark->getUrl();
} }
} }
} }
$this->assignView('title', $this->container->conf->get('general.title', 'Shaarli')); $this->assignAllView([
$this->assignView('index_url', $indexUrl); 'title' => $this->container->conf->get('general.title', 'Shaarli'),
$this->assignView('page_url', $pageUrl); 'index_url' => $indexUrl,
$this->assignView('hide_timestamps', $this->container->conf->get('privacy.hide_timestamps', false)); 'page_url' => $pageUrl,
$this->assignView('days', $dataPerDay); 'hide_timestamps' => $this->container->conf->get('privacy.hide_timestamps', false),
'days' => $dataPerDay,
'type' => $type,
'localizedType' => $this->translateType($type),
]);
$rssContent = $this->render(TemplatePage::DAILY_RSS); $rssContent = $this->render(TemplatePage::DAILY_RSS);
@ -189,4 +193,13 @@ protected function calculateColumns(array $links): array
return $columns; return $columns;
} }
protected function translateType($type): string
{
return [
t('day') => t('Daily'),
t('week') => t('Weekly'),
t('month') => t('Monthly'),
][t($type)] ?? t('Daily');
}
} }

View file

@ -26,8 +26,14 @@ public function __invoke(Request $request, Response $response, \Throwable $throw
$response = $response->withStatus($throwable->getCode()); $response = $response->withStatus($throwable->getCode());
} else { } else {
// Internal error (any other Throwable) // Internal error (any other Throwable)
if ($this->container->conf->get('dev.debug', false)) { if ($this->container->conf->get('dev.debug', false) || $this->container->loginManager->isLoggedIn()) {
$this->assignView('message', $throwable->getMessage()); $this->assignView('message', t('Error: ') . $throwable->getMessage());
$this->assignView(
'text',
'<a href="https://github.com/shaarli/Shaarli/issues/new">'
. t('Please report it on Github.')
. '</a>'
);
$this->assignView('stacktrace', exception2text($throwable)); $this->assignView('stacktrace', exception2text($throwable));
} else { } else {
$this->assignView('message', t('An unexpected error occurred.')); $this->assignView('message', t('An unexpected error occurred.'));
@ -36,7 +42,6 @@ public function __invoke(Request $request, Response $response, \Throwable $throw
$response = $response->withStatus(500); $response = $response->withStatus(500);
} }
return $response->write($this->render('error')); return $response->write($this->render('error'));
} }
} }

View file

@ -27,7 +27,7 @@ public function rss(Request $request, Response $response): Response
protected function processRequest(string $feedType, Request $request, Response $response): Response protected function processRequest(string $feedType, Request $request, Response $response): Response
{ {
$response = $response->withHeader('Content-Type', 'application/'. $feedType .'+xml; charset=utf-8'); $response = $response->withHeader('Content-Type', 'application/' . $feedType . '+xml; charset=utf-8');
$pageUrl = page_url($this->container->environment); $pageUrl = page_url($this->container->environment);
$cache = $this->container->pageCacheManager->getCachePage($pageUrl); $cache = $this->container->pageCacheManager->getCachePage($pageUrl);

View file

@ -4,10 +4,10 @@
namespace Shaarli\Front\Controller\Visitor; namespace Shaarli\Front\Controller\Visitor;
use Shaarli\ApplicationUtils;
use Shaarli\Container\ShaarliContainer; use Shaarli\Container\ShaarliContainer;
use Shaarli\Front\Exception\AlreadyInstalledException; use Shaarli\Front\Exception\AlreadyInstalledException;
use Shaarli\Front\Exception\ResourcePermissionException; use Shaarli\Front\Exception\ResourcePermissionException;
use Shaarli\Helper\ApplicationUtils;
use Shaarli\Languages; use Shaarli\Languages;
use Shaarli\Security\SessionManager; use Shaarli\Security\SessionManager;
use Slim\Http\Request; use Slim\Http\Request;
@ -39,7 +39,8 @@ public function index(Request $request, Response $response): Response
// Before installation, we'll make sure that permissions are set properly, and sessions are working. // Before installation, we'll make sure that permissions are set properly, and sessions are working.
$this->checkPermissions(); $this->checkPermissions();
if (static::SESSION_TEST_VALUE if (
static::SESSION_TEST_VALUE
!== $this->container->sessionManager->getSessionParameter(static::SESSION_TEST_KEY) !== $this->container->sessionManager->getSessionParameter(static::SESSION_TEST_KEY)
) { ) {
$this->container->sessionManager->setSessionParameter(static::SESSION_TEST_KEY, static::SESSION_TEST_VALUE); $this->container->sessionManager->setSessionParameter(static::SESSION_TEST_KEY, static::SESSION_TEST_VALUE);
@ -53,6 +54,16 @@ public function index(Request $request, Response $response): Response
$this->assignView('cities', $cities); $this->assignView('cities', $cities);
$this->assignView('languages', Languages::getAvailableLanguages()); $this->assignView('languages', Languages::getAvailableLanguages());
$phpEol = new \DateTimeImmutable(ApplicationUtils::getPhpEol(PHP_VERSION));
$this->assignView('php_version', PHP_VERSION);
$this->assignView('php_eol', format_date($phpEol, false));
$this->assignView('php_has_reached_eol', $phpEol < new \DateTimeImmutable());
$this->assignView('php_extensions', ApplicationUtils::getPhpExtensionsRequirement());
$this->assignView('permissions', ApplicationUtils::checkResourcePermissions($this->container->conf));
$this->assignView('pagetitle', t('Install Shaarli'));
return $response->write($this->render('install')); return $response->write($this->render('install'));
} }
@ -65,17 +76,18 @@ public function sessionTest(Request $request, Response $response): Response
// This part makes sure sessions works correctly. // This part makes sure sessions works correctly.
// (Because on some hosts, session.save_path may not be set correctly, // (Because on some hosts, session.save_path may not be set correctly,
// or we may not have write access to it.) // or we may not have write access to it.)
if (static::SESSION_TEST_VALUE if (
static::SESSION_TEST_VALUE
!== $this->container->sessionManager->getSessionParameter(static::SESSION_TEST_KEY) !== $this->container->sessionManager->getSessionParameter(static::SESSION_TEST_KEY)
) { ) {
// Step 2: Check if data in session is correct. // Step 2: Check if data in session is correct.
$msg = t( $msg = t(
'<pre>Sessions do not seem to work correctly on your server.<br>'. '<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, '. 'Make sure the variable "session.save_path" is set correctly in your PHP config, ' .
'and that you have write access to it.<br>'. 'and that you have write access to it.<br>' .
'It currently points to %s.<br>'. 'It currently points to %s.<br>' .
'On some browsers, accessing your server via a hostname like \'localhost\' '. 'On some browsers, accessing your server via a hostname like \'localhost\' ' .
'or any custom hostname without a dot causes cookie storage to fail. '. '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>' 'We recommend accessing your server via it\'s IP address or Fully Qualified Domain Name.<br>'
); );
$msg = sprintf($msg, $this->container->sessionManager->getSavePath()); $msg = sprintf($msg, $this->container->sessionManager->getSavePath());
@ -94,7 +106,8 @@ public function sessionTest(Request $request, Response $response): Response
public function save(Request $request, Response $response): Response public function save(Request $request, Response $response): Response
{ {
$timezone = 'UTC'; $timezone = 'UTC';
if (!empty($request->getParam('continent')) if (
!empty($request->getParam('continent'))
&& !empty($request->getParam('city')) && !empty($request->getParam('city'))
&& isTimeZoneValid($request->getParam('continent'), $request->getParam('city')) && isTimeZoneValid($request->getParam('continent'), $request->getParam('city'))
) { ) {
@ -104,7 +117,7 @@ public function save(Request $request, Response $response): Response
$login = $request->getParam('setlogin'); $login = $request->getParam('setlogin');
$this->container->conf->set('credentials.login', $login); $this->container->conf->set('credentials.login', $login);
$salt = sha1(uniqid('', true) .'_'. mt_rand()); $salt = sha1(uniqid('', true) . '_' . mt_rand());
$this->container->conf->set('credentials.salt', $salt); $this->container->conf->set('credentials.salt', $salt);
$this->container->conf->set('credentials.hash', sha1($request->getParam('setpassword') . $login . $salt)); $this->container->conf->set('credentials.hash', sha1($request->getParam('setpassword') . $login . $salt));
@ -113,7 +126,7 @@ public function save(Request $request, Response $response): Response
} else { } else {
$this->container->conf->set( $this->container->conf->set(
'general.title', 'general.title',
'Shared bookmarks on '.escape(index_url($this->container->environment)) 'Shared bookmarks on ' . escape(index_url($this->container->environment))
); );
} }
@ -150,7 +163,7 @@ public function save(Request $request, Response $response): Response
protected function checkPermissions(): bool protected function checkPermissions(): bool
{ {
// Ensure Shaarli has proper access to its resources // Ensure Shaarli has proper access to its resources
$errors = ApplicationUtils::checkResourcePermissions($this->container->conf); $errors = ApplicationUtils::checkResourcePermissions($this->container->conf, true);
if (empty($errors)) { if (empty($errors)) {
return true; return true;
} }

View file

@ -43,7 +43,7 @@ public function index(Request $request, Response $response): Response
$this $this
->assignView('returnurl', escape($returnUrl)) ->assignView('returnurl', escape($returnUrl))
->assignView('remember_user_default', $this->container->conf->get('privacy.remember_user_default', true)) ->assignView('remember_user_default', $this->container->conf->get('privacy.remember_user_default', true))
->assignView('pagetitle', t('Login') .' - '. $this->container->conf->get('general.title', 'Shaarli')) ->assignView('pagetitle', t('Login') . ' - ' . $this->container->conf->get('general.title', 'Shaarli'))
; ;
return $response->write($this->render(TemplatePage::LOGIN)); return $response->write($this->render(TemplatePage::LOGIN));
@ -64,8 +64,8 @@ public function login(Request $request, Response $response): Response
return $this->redirect($response, '/'); return $this->redirect($response, '/');
} }
if (!$this->container->loginManager->checkCredentials( if (
$this->container->environment['REMOTE_ADDR'], !$this->container->loginManager->checkCredentials(
client_ip_id($this->container->environment), client_ip_id($this->container->environment),
$request->getParam('login'), $request->getParam('login'),
$request->getParam('password') $request->getParam('password')
@ -102,7 +102,8 @@ public function login(Request $request, Response $response): Response
*/ */
protected function checkLoginState(): bool protected function checkLoginState(): bool
{ {
if ($this->container->loginManager->isLoggedIn() if (
$this->container->loginManager->isLoggedIn()
|| $this->container->conf->get('security.open_shaarli', false) || $this->container->conf->get('security.open_shaarli', false)
) { ) {
throw new CantLoginException(); throw new CantLoginException();

View file

@ -26,7 +26,7 @@ public function index(Request $request, Response $response): Response
$this->assignView( $this->assignView(
'pagetitle', 'pagetitle',
t('Picture wall') .' - '. $this->container->conf->get('general.title', 'Shaarli') t('Picture wall') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
); );
// Optionally filter the results: // Optionally filter the results:

View file

@ -144,7 +144,8 @@ protected function redirectFromReferer(
if (null !== $referer) { if (null !== $referer) {
$currentUrl = parse_url($referer); $currentUrl = parse_url($referer);
// If the referer is not related to Shaarli instance, redirect to default // If the referer is not related to Shaarli instance, redirect to default
if (isset($currentUrl['host']) if (
isset($currentUrl['host'])
&& strpos(index_url($this->container->environment), $currentUrl['host']) === false && strpos(index_url($this->container->environment), $currentUrl['host']) === false
) { ) {
return $response->withRedirect($defaultPath); return $response->withRedirect($defaultPath);
@ -173,7 +174,7 @@ protected function redirectFromReferer(
} }
} }
$queryString = count($params) > 0 ? '?'. http_build_query($params) : ''; $queryString = count($params) > 0 ? '?' . http_build_query($params) : '';
$anchor = $anchor ? '#' . $anchor : ''; $anchor = $anchor ? '#' . $anchor : '';
return $response->withRedirect($path . $queryString . $anchor); return $response->withRedirect($path . $queryString . $anchor);

View file

@ -47,13 +47,14 @@ public function list(Request $request, Response $response): Response
*/ */
protected function processRequest(string $type, Request $request, Response $response): Response protected function processRequest(string $type, Request $request, Response $response): Response
{ {
$tagsSeparator = $this->container->conf->get('general.tags_separator', ' ');
if ($this->container->loginManager->isLoggedIn() === true) { if ($this->container->loginManager->isLoggedIn() === true) {
$visibility = $this->container->sessionManager->getSessionParameter('visibility'); $visibility = $this->container->sessionManager->getSessionParameter('visibility');
} }
$sort = $request->getQueryParam('sort'); $sort = $request->getQueryParam('sort');
$searchTags = $request->getQueryParam('searchtags'); $searchTags = $request->getQueryParam('searchtags');
$filteringTags = $searchTags !== null ? explode(' ', $searchTags) : []; $filteringTags = $searchTags !== null ? explode($tagsSeparator, $searchTags) : [];
$tags = $this->container->bookmarkService->bookmarksCountPerTag($filteringTags, $visibility ?? null); $tags = $this->container->bookmarkService->bookmarksCountPerTag($filteringTags, $visibility ?? null);
@ -71,8 +72,9 @@ protected function processRequest(string $type, Request $request, Response $resp
$tagsUrl[escape($tag)] = urlencode((string) $tag); $tagsUrl[escape($tag)] = urlencode((string) $tag);
} }
$searchTags = implode(' ', escape($filteringTags)); $searchTags = tags_array2str($filteringTags, $tagsSeparator);
$searchTagsUrl = urlencode(implode(' ', $filteringTags)); $searchTags = !empty($searchTags) ? trim($searchTags, $tagsSeparator) . $tagsSeparator : '';
$searchTagsUrl = urlencode($searchTags);
$data = [ $data = [
'search_tags' => escape($searchTags), 'search_tags' => escape($searchTags),
'search_tags_url' => $searchTagsUrl, 'search_tags_url' => $searchTagsUrl,
@ -82,10 +84,10 @@ protected function processRequest(string $type, Request $request, Response $resp
$this->executePageHooks('render_tag' . $type, $data, 'tag.' . $type); $this->executePageHooks('render_tag' . $type, $data, 'tag.' . $type);
$this->assignAllView($data); $this->assignAllView($data);
$searchTags = !empty($searchTags) ? $searchTags .' - ' : ''; $searchTags = !empty($searchTags) ? trim(str_replace($tagsSeparator, ' ', $searchTags)) . ' - ' : '';
$this->assignView( $this->assignView(
'pagetitle', 'pagetitle',
$searchTags . t('Tag '. $type) .' - '. $this->container->conf->get('general.title', 'Shaarli') $searchTags . t('Tag ' . $type) . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
); );
return $response->write($this->render('tag.' . $type)); return $response->write($this->render('tag.' . $type));

View file

@ -27,7 +27,7 @@ public function addTag(Request $request, Response $response, array $args): Respo
// In case browser does not send HTTP_REFERER, we search a single tag // In case browser does not send HTTP_REFERER, we search a single tag
if (null === $referer) { if (null === $referer) {
if (null !== $newTag) { if (null !== $newTag) {
return $this->redirect($response, '/?searchtags='. urlencode($newTag)); return $this->redirect($response, '/?searchtags=' . urlencode($newTag));
} }
return $this->redirect($response, '/'); return $this->redirect($response, '/');
@ -37,7 +37,7 @@ public function addTag(Request $request, Response $response, array $args): Respo
parse_str($currentUrl['query'] ?? '', $params); parse_str($currentUrl['query'] ?? '', $params);
if (null === $newTag) { if (null === $newTag) {
return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params)); return $response->withRedirect(($currentUrl['path'] ?? './') . '?' . http_build_query($params));
} }
// Prevent redirection loop // Prevent redirection loop
@ -45,9 +45,10 @@ public function addTag(Request $request, Response $response, array $args): Respo
unset($params['addtag']); unset($params['addtag']);
} }
$tagsSeparator = $this->container->conf->get('general.tags_separator', ' ');
// Check if this tag is already in the search query and ignore it if it is. // Check if this tag is already in the search query and ignore it if it is.
// Each tag is always separated by a space // Each tag is always separated by a space
$currentTags = isset($params['searchtags']) ? explode(' ', $params['searchtags']) : []; $currentTags = tags_str2array($params['searchtags'] ?? '', $tagsSeparator);
$addtag = true; $addtag = true;
foreach ($currentTags as $value) { foreach ($currentTags as $value) {
@ -62,12 +63,12 @@ public function addTag(Request $request, Response $response, array $args): Respo
$currentTags[] = trim($newTag); $currentTags[] = trim($newTag);
} }
$params['searchtags'] = trim(implode(' ', $currentTags)); $params['searchtags'] = tags_array2str($currentTags, $tagsSeparator);
// We also remove page (keeping the same page has no sense, since the results are different) // We also remove page (keeping the same page has no sense, since the results are different)
unset($params['page']); unset($params['page']);
return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params)); return $response->withRedirect(($currentUrl['path'] ?? './') . '?' . http_build_query($params));
} }
/** /**
@ -89,7 +90,7 @@ public function removeTag(Request $request, Response $response, array $args): Re
parse_str($currentUrl['query'] ?? '', $params); parse_str($currentUrl['query'] ?? '', $params);
if (null === $tagToRemove) { if (null === $tagToRemove) {
return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params)); return $response->withRedirect(($currentUrl['path'] ?? './') . '?' . http_build_query($params));
} }
// Prevent redirection loop // Prevent redirection loop
@ -98,10 +99,11 @@ public function removeTag(Request $request, Response $response, array $args): Re
} }
if (isset($params['searchtags'])) { if (isset($params['searchtags'])) {
$tags = explode(' ', $params['searchtags']); $tagsSeparator = $this->container->conf->get('general.tags_separator', ' ');
$tags = tags_str2array($params['searchtags'] ?? '', $tagsSeparator);
// Remove value from array $tags. // Remove value from array $tags.
$tags = array_diff($tags, [$tagToRemove]); $tags = array_diff($tags, [$tagToRemove]);
$params['searchtags'] = implode(' ', $tags); $params['searchtags'] = tags_array2str($tags, $tagsSeparator);
if (empty($params['searchtags'])) { if (empty($params['searchtags'])) {
unset($params['searchtags']); unset($params['searchtags']);

View file

@ -1,5 +1,6 @@
<?php <?php
namespace Shaarli;
namespace Shaarli\Helper;
use Exception; use Exception;
use Shaarli\Config\ConfigManager; use Shaarli\Config\ConfigManager;
@ -14,8 +15,9 @@ class ApplicationUtils
*/ */
public static $VERSION_FILE = 'shaarli_version.php'; public static $VERSION_FILE = 'shaarli_version.php';
private static $GIT_URL = 'https://raw.githubusercontent.com/shaarli/Shaarli'; public static $GITHUB_URL = 'https://github.com/shaarli/Shaarli';
private static $GIT_BRANCHES = array('latest', 'stable'); public static $GIT_RAW_URL = 'https://raw.githubusercontent.com/shaarli/Shaarli';
public static $GIT_BRANCHES = ['latest', 'stable'];
private static $VERSION_START_TAG = '<?php /* '; private static $VERSION_START_TAG = '<?php /* ';
private static $VERSION_END_TAG = ' */ ?>'; private static $VERSION_END_TAG = ' */ ?>';
@ -63,8 +65,8 @@ public static function getVersion($remote, $timeout = 2)
} }
return str_replace( return str_replace(
array(self::$VERSION_START_TAG, self::$VERSION_END_TAG, PHP_EOL), [self::$VERSION_START_TAG, self::$VERSION_END_TAG, PHP_EOL],
array('', '', ''), ['', '', ''],
$data $data
); );
} }
@ -125,7 +127,7 @@ public static function checkUpdate(
// Late Static Binding allows overriding within tests // Late Static Binding allows overriding within tests
// See http://php.net/manual/en/language.oop5.late-static-bindings.php // See http://php.net/manual/en/language.oop5.late-static-bindings.php
$latestVersion = static::getVersion( $latestVersion = static::getVersion(
self::$GIT_URL . '/' . $branch . '/' . self::$VERSION_FILE self::$GIT_RAW_URL . '/' . $branch . '/' . self::$VERSION_FILE
); );
if (!$latestVersion) { if (!$latestVersion) {
@ -171,35 +173,47 @@ public static function checkPHPVersion($minVersion, $curVersion)
/** /**
* Checks Shaarli has the proper access permissions to its resources * Checks Shaarli has the proper access permissions to its resources
* *
* @param ConfigManager $conf Configuration Manager instance. * @param ConfigManager $conf Configuration Manager instance.
* @param bool $minimalMode In minimal mode we only check permissions to be able to display a template.
* Currently we only need to be able to read the theme and write in raintpl cache.
* *
* @return array A list of the detected configuration issues * @return array A list of the detected configuration issues
*/ */
public static function checkResourcePermissions($conf) public static function checkResourcePermissions(ConfigManager $conf, bool $minimalMode = false): array
{ {
$errors = array(); $errors = [];
$rainTplDir = rtrim($conf->get('resource.raintpl_tpl'), '/'); $rainTplDir = rtrim($conf->get('resource.raintpl_tpl'), '/');
// Check script and template directories are readable // Check script and template directories are readable
foreach (array( foreach (
'application', [
'inc', 'application',
'plugins', 'inc',
$rainTplDir, 'plugins',
$rainTplDir . '/' . $conf->get('resource.theme'), $rainTplDir,
) as $path) { $rainTplDir . '/' . $conf->get('resource.theme'),
] as $path
) {
if (!is_readable(realpath($path))) { if (!is_readable(realpath($path))) {
$errors[] = '"' . $path . '" ' . t('directory is not readable'); $errors[] = '"' . $path . '" ' . t('directory is not readable');
} }
} }
// Check cache and data directories are readable and writable // Check cache and data directories are readable and writable
foreach (array( if ($minimalMode) {
$conf->get('resource.thumbnails_cache'), $folders = [
$conf->get('resource.data_dir'), $conf->get('resource.raintpl_tmp'),
$conf->get('resource.page_cache'), ];
$conf->get('resource.raintpl_tmp'), } else {
) as $path) { $folders = [
$conf->get('resource.thumbnails_cache'),
$conf->get('resource.data_dir'),
$conf->get('resource.page_cache'),
$conf->get('resource.raintpl_tmp'),
];
}
foreach ($folders as $path) {
if (!is_readable(realpath($path))) { if (!is_readable(realpath($path))) {
$errors[] = '"' . $path . '" ' . t('directory is not readable'); $errors[] = '"' . $path . '" ' . t('directory is not readable');
} }
@ -208,14 +222,20 @@ public static function checkResourcePermissions($conf)
} }
} }
if ($minimalMode) {
return $errors;
}
// Check configuration files are readable and writable // Check configuration files are readable and writable
foreach (array( foreach (
$conf->getConfigFileExt(), [
$conf->get('resource.datastore'), $conf->getConfigFileExt(),
$conf->get('resource.ban_file'), $conf->get('resource.datastore'),
$conf->get('resource.log'), $conf->get('resource.ban_file'),
$conf->get('resource.update_check'), $conf->get('resource.log'),
) as $path) { $conf->get('resource.update_check'),
] as $path
) {
if (!is_file(realpath($path))) { if (!is_file(realpath($path))) {
# the file may not exist yet # the file may not exist yet
continue; continue;
@ -246,4 +266,54 @@ public static function getVersionHash($currentVersion, $salt)
{ {
return hash_hmac('sha256', $currentVersion, $salt); return hash_hmac('sha256', $currentVersion, $salt);
} }
/**
* Get a list of PHP extensions used by Shaarli.
*
* @return array[] List of extension with following keys:
* - name: extension name
* - required: whether the extension is required to use Shaarli
* - desc: short description of extension usage in Shaarli
* - loaded: whether the extension is properly loaded or not
*/
public static function getPhpExtensionsRequirement(): array
{
$extensions = [
['name' => 'json', 'required' => true, 'desc' => t('Configuration parsing')],
['name' => 'simplexml', 'required' => true, 'desc' => t('Slim Framework (routing, etc.)')],
['name' => 'mbstring', 'required' => true, 'desc' => t('Multibyte (Unicode) string support')],
['name' => 'gd', 'required' => false, 'desc' => t('Required to use thumbnails')],
['name' => 'intl', 'required' => false, 'desc' => t('Localized text sorting (e.g. e->è->f)')],
['name' => 'curl', 'required' => false, 'desc' => t('Better retrieval of bookmark metadata and thumbnail')],
['name' => 'gettext', 'required' => false, 'desc' => t('Use the translation system in gettext mode')],
['name' => 'ldap', 'required' => false, 'desc' => t('Login using LDAP server')],
];
foreach ($extensions as &$extension) {
$extension['loaded'] = extension_loaded($extension['name']);
}
return $extensions;
}
/**
* Return the EOL date of given PHP version. If the version is unknown,
* we return today + 2 years.
*
* @param string $fullVersion PHP version, e.g. 7.4.7
*
* @return string Date format: YYYY-MM-DD
*/
public static function getPhpEol(string $fullVersion): string
{
preg_match('/(\d+\.\d+)\.\d+/', $fullVersion, $matches);
return [
'7.1' => '2019-12-01',
'7.2' => '2020-11-30',
'7.3' => '2021-12-06',
'7.4' => '2022-11-28',
'8.0' => '2023-12-01',
][$matches[1]] ?? (new \DateTime('+2 year'))->format('Y-m-d');
}
} }

View file

@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace Shaarli\Helper;
use Shaarli\Bookmark\Bookmark;
use Slim\Http\Request;
class DailyPageHelper
{
public const MONTH = 'month';
public const WEEK = 'week';
public const DAY = 'day';
/**
* Extracts the type of the daily to display from the HTTP request parameters
*
* @param Request $request HTTP request
*
* @return string month/week/day
*/
public static function extractRequestedType(Request $request): string
{
if ($request->getQueryParam(static::MONTH) !== null) {
return static::MONTH;
} elseif ($request->getQueryParam(static::WEEK) !== null) {
return static::WEEK;
}
return static::DAY;
}
/**
* Extracts a DateTimeImmutable from provided HTTP request.
* If no parameter is provided, we rely on the creation date of the latest provided created bookmark.
* If the datastore is empty or no bookmark is provided, we use the current date.
*
* @param string $type month/week/day
* @param string|null $requestedDate Input string extracted from the request
* @param Bookmark|null $latestBookmark Latest bookmark found in the datastore (by date)
*
* @return \DateTimeImmutable from input or latest bookmark.
*
* @throws \Exception Type not supported.
*/
public static function extractRequestedDateTime(
string $type,
?string $requestedDate,
Bookmark $latestBookmark = null
): \DateTimeImmutable {
$format = static::getFormatByType($type);
if (empty($requestedDate)) {
return $latestBookmark instanceof Bookmark
? new \DateTimeImmutable($latestBookmark->getCreated()->format(\DateTime::ATOM))
: new \DateTimeImmutable()
;
}
// W is not supported by createFromFormat...
if ($type === static::WEEK) {
return (new \DateTimeImmutable())
->setISODate((int) substr($requestedDate, 0, 4), (int) substr($requestedDate, 4, 2))
;
}
return \DateTimeImmutable::createFromFormat($format, $requestedDate);
}
/**
* Get the DateTime format used by provided type
* Examples:
* - day: 20201016 (<year><month><day>)
* - week: 202041 (<year><week number>)
* - month: 202010 (<year><month>)
*
* @param string $type month/week/day
*
* @return string DateTime compatible format
*
* @see https://www.php.net/manual/en/datetime.format.php
*
* @throws \Exception Type not supported.
*/
public static function getFormatByType(string $type): string
{
switch ($type) {
case static::MONTH:
return 'Ym';
case static::WEEK:
return 'YW';
case static::DAY:
return 'Ymd';
default:
throw new \Exception('Unsupported daily format type');
}
}
/**
* Get the first DateTime of the time period depending on given datetime and type.
* Note: DateTimeImmutable is required because we rely heavily on DateTime->modify() syntax
* and we don't want to alter original datetime.
*
* @param string $type month/week/day
* @param \DateTimeImmutable $requested DateTime extracted from request input
* (should come from extractRequestedDateTime)
*
* @return \DateTimeInterface First DateTime of the time period
*
* @throws \Exception Type not supported.
*/
public static function getStartDateTimeByType(string $type, \DateTimeImmutable $requested): \DateTimeInterface
{
switch ($type) {
case static::MONTH:
return $requested->modify('first day of this month midnight');
case static::WEEK:
return $requested->modify('Monday this week midnight');
case static::DAY:
return $requested->modify('Today midnight');
default:
throw new \Exception('Unsupported daily format type');
}
}
/**
* Get the last DateTime of the time period depending on given datetime and type.
* Note: DateTimeImmutable is required because we rely heavily on DateTime->modify() syntax
* and we don't want to alter original datetime.
*
* @param string $type month/week/day
* @param \DateTimeImmutable $requested DateTime extracted from request input
* (should come from extractRequestedDateTime)
*
* @return \DateTimeInterface Last DateTime of the time period
*
* @throws \Exception Type not supported.
*/
public static function getEndDateTimeByType(string $type, \DateTimeImmutable $requested): \DateTimeInterface
{
switch ($type) {
case static::MONTH:
return $requested->modify('last day of this month 23:59:59');
case static::WEEK:
return $requested->modify('Sunday this week 23:59:59');
case static::DAY:
return $requested->modify('Today 23:59:59');
default:
throw new \Exception('Unsupported daily format type');
}
}
/**
* Get localized description of the time period depending on given datetime and type.
* Example: for a month period, it returns `October, 2020`.
*
* @param string $type month/week/day
* @param \DateTimeImmutable $requested DateTime extracted from request input
* (should come from extractRequestedDateTime)
*
* @return string Localized time period description
*
* @throws \Exception Type not supported.
*/
public static function getDescriptionByType(string $type, \DateTimeImmutable $requested): string
{
switch ($type) {
case static::MONTH:
return $requested->format('F') . ', ' . $requested->format('Y');
case static::WEEK:
$requested = $requested->modify('Monday this week');
return t('Week') . ' ' . $requested->format('W') . ' (' . format_date($requested, false) . ')';
case static::DAY:
$out = '';
if ($requested->format('Ymd') === date('Ymd')) {
$out = t('Today') . ' - ';
} elseif ($requested->format('Ymd') === date('Ymd', strtotime('-1 days'))) {
$out = t('Yesterday') . ' - ';
}
return $out . format_date($requested, false);
default:
throw new \Exception('Unsupported daily format type');
}
}
/**
* Get the number of items to display in the RSS feed depending on the given type.
*
* @param string $type month/week/day
*
* @return int number of elements
*
* @throws \Exception Type not supported.
*/
public static function getRssLengthByType(string $type): int
{
switch ($type) {
case static::MONTH:
return 12; // 1 year
case static::WEEK:
return 26; // ~6 months
case static::DAY:
return 30; // ~1 month
default:
throw new \Exception('Unsupported daily format type');
}
}
}

View file

@ -1,6 +1,6 @@
<?php <?php
namespace Shaarli; namespace Shaarli\Helper;
use Shaarli\Exceptions\IOException; use Shaarli\Exceptions\IOException;
@ -81,4 +81,60 @@ public static function readFlatDB($file, $default = null)
) )
); );
} }
/**
* Recursively deletes a folder content, and deletes itself optionally.
* If an excluded file is found, folders won't be deleted.
*
* Additional security: raise an exception if it tries to delete a folder outside of Shaarli directory.
*
* @param string $path
* @param bool $selfDelete Delete the provided folder if true, only its content if false.
* @param array $exclude
*/
public static function clearFolder(string $path, bool $selfDelete, array $exclude = []): bool
{
$skipped = false;
if (!is_dir($path)) {
throw new IOException(t('Provided path is not a directory.'));
}
if (!static::isPathInShaarliFolder($path)) {
throw new IOException(t('Trying to delete a folder outside of Shaarli path.'));
}
foreach (new \DirectoryIterator($path) as $file) {
if ($file->isDot()) {
continue;
}
if (in_array($file->getBasename(), $exclude, true)) {
$skipped = true;
continue;
}
if ($file->isFile()) {
unlink($file->getPathname());
} elseif ($file->isDir()) {
$skipped = static::clearFolder($file->getRealPath(), true, $exclude) || $skipped;
}
}
if ($selfDelete && !$skipped) {
rmdir($path);
}
return $skipped;
}
/**
* Checks that the given path is inside Shaarli directory.
*/
public static function isPathInShaarliFolder(string $path): bool
{
$rootDirectory = dirname(dirname(dirname(__FILE__)));
return strpos(realpath($path), $rootDirectory) !== false;
}
} }

View file

@ -29,14 +29,16 @@ public function getCurlDownloadCallback(
&$title, &$title,
&$description, &$description,
&$keywords, &$keywords,
$retrieveDescription $retrieveDescription,
$tagsSeparator
) { ) {
return get_curl_download_callback( return get_curl_download_callback(
$charset, $charset,
$title, $title,
$description, $description,
$keywords, $keywords,
$retrieveDescription $retrieveDescription,
$tagsSeparator
); );
} }

View file

@ -48,7 +48,7 @@ function get_http_response(
$cleanUrl = $urlObj->idnToAscii(); $cleanUrl = $urlObj->idnToAscii();
if (!filter_var($cleanUrl, FILTER_VALIDATE_URL) || !$urlObj->isHttp()) { if (!filter_var($cleanUrl, FILTER_VALIDATE_URL) || !$urlObj->isHttp()) {
return array(array(0 => 'Invalid HTTP UrlUtils'), false); return [[0 => 'Invalid HTTP UrlUtils'], false];
} }
$userAgent = $userAgent =
@ -71,7 +71,7 @@ function get_http_response(
$ch = curl_init($cleanUrl); $ch = curl_init($cleanUrl);
if ($ch === false) { if ($ch === false) {
return array(array(0 => 'curl_init() error'), false); return [[0 => 'curl_init() error'], false];
} }
// General cURL settings // General cURL settings
@ -82,7 +82,7 @@ function get_http_response(
curl_setopt( curl_setopt(
$ch, $ch,
CURLOPT_HTTPHEADER, CURLOPT_HTTPHEADER,
array('Accept-Language: ' . $acceptLanguage) ['Accept-Language: ' . $acceptLanguage]
); );
curl_setopt($ch, CURLOPT_MAXREDIRS, $maxRedirs); curl_setopt($ch, CURLOPT_MAXREDIRS, $maxRedirs);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
@ -90,7 +90,7 @@ function get_http_response(
curl_setopt($ch, CURLOPT_USERAGENT, $userAgent); curl_setopt($ch, CURLOPT_USERAGENT, $userAgent);
// Max download size management // Max download size management
curl_setopt($ch, CURLOPT_BUFFERSIZE, 1024*16); curl_setopt($ch, CURLOPT_BUFFERSIZE, 1024 * 16);
curl_setopt($ch, CURLOPT_NOPROGRESS, false); curl_setopt($ch, CURLOPT_NOPROGRESS, false);
if (is_callable($curlHeaderFunction)) { if (is_callable($curlHeaderFunction)) {
curl_setopt($ch, CURLOPT_HEADERFUNCTION, $curlHeaderFunction); curl_setopt($ch, CURLOPT_HEADERFUNCTION, $curlHeaderFunction);
@ -122,9 +122,9 @@ function ($arg0, $arg1, $arg2, $arg3, $arg4) use ($maxBytes) {
* Removing this would require updating * Removing this would require updating
* GetHttpUrlTest::testGetInvalidRemoteUrl() * GetHttpUrlTest::testGetInvalidRemoteUrl()
*/ */
return array(false, false); return [false, false];
} }
return array(array(0 => 'curl_exec() error: ' . $errorStr), false); return [[0 => 'curl_exec() error: ' . $errorStr], false];
} }
// Formatting output like the fallback method // Formatting output like the fallback method
@ -135,7 +135,7 @@ function ($arg0, $arg1, $arg2, $arg3, $arg4) use ($maxBytes) {
$rawHeadersLastRedir = end($rawHeadersArrayRedirs); $rawHeadersLastRedir = end($rawHeadersArrayRedirs);
$content = substr($response, $headSize); $content = substr($response, $headSize);
$headers = array(); $headers = [];
foreach (preg_split('~[\r\n]+~', $rawHeadersLastRedir) as $line) { foreach (preg_split('~[\r\n]+~', $rawHeadersLastRedir) as $line) {
if (empty($line) || ctype_space($line)) { if (empty($line) || ctype_space($line)) {
continue; continue;
@ -146,7 +146,7 @@ function ($arg0, $arg1, $arg2, $arg3, $arg4) use ($maxBytes) {
$value = $splitLine[1]; $value = $splitLine[1];
if (array_key_exists($key, $headers)) { if (array_key_exists($key, $headers)) {
if (!is_array($headers[$key])) { if (!is_array($headers[$key])) {
$headers[$key] = array(0 => $headers[$key]); $headers[$key] = [0 => $headers[$key]];
} }
$headers[$key][] = $value; $headers[$key][] = $value;
} else { } else {
@ -157,7 +157,7 @@ function ($arg0, $arg1, $arg2, $arg3, $arg4) use ($maxBytes) {
} }
} }
return array($headers, $content); return [$headers, $content];
} }
/** /**
@ -188,15 +188,15 @@ function get_http_response_fallback(
$acceptLanguage, $acceptLanguage,
$maxRedr $maxRedr
) { ) {
$options = array( $options = [
'http' => array( 'http' => [
'method' => 'GET', 'method' => 'GET',
'timeout' => $timeout, 'timeout' => $timeout,
'user_agent' => $userAgent, 'user_agent' => $userAgent,
'header' => "Accept: */*\r\n" 'header' => "Accept: */*\r\n"
. 'Accept-Language: ' . $acceptLanguage . 'Accept-Language: ' . $acceptLanguage
) ]
); ];
stream_context_set_default($options); stream_context_set_default($options);
list($headers, $finalUrl) = get_redirected_headers($cleanUrl, $maxRedr); list($headers, $finalUrl) = get_redirected_headers($cleanUrl, $maxRedr);
@ -207,7 +207,7 @@ function get_http_response_fallback(
} }
if (! $headers) { if (! $headers) {
return array($headers, false); return [$headers, false];
} }
try { try {
@ -215,10 +215,10 @@ function get_http_response_fallback(
$context = stream_context_create($options); $context = stream_context_create($options);
$content = file_get_contents($finalUrl, false, $context, -1, $maxBytes); $content = file_get_contents($finalUrl, false, $context, -1, $maxBytes);
} catch (Exception $exc) { } catch (Exception $exc) {
return array(array(0 => 'HTTP Error'), $exc->getMessage()); return [[0 => 'HTTP Error'], $exc->getMessage()];
} }
return array($headers, $content); return [$headers, $content];
} }
/** /**
@ -237,10 +237,12 @@ function get_redirected_headers($url, $redirectionLimit = 3)
} }
// Headers found, redirection found, and limit not reached. // Headers found, redirection found, and limit not reached.
if ($redirectionLimit-- > 0 if (
$redirectionLimit-- > 0
&& !empty($headers) && !empty($headers)
&& (strpos($headers[0], '301') !== false || strpos($headers[0], '302') !== false) && (strpos($headers[0], '301') !== false || strpos($headers[0], '302') !== false)
&& !empty($headers['Location'])) { && !empty($headers['Location'])
) {
$redirection = is_array($headers['Location']) ? end($headers['Location']) : $headers['Location']; $redirection = is_array($headers['Location']) ? end($headers['Location']) : $headers['Location'];
if ($redirection != $url) { if ($redirection != $url) {
$redirection = getAbsoluteUrl($url, $redirection); $redirection = getAbsoluteUrl($url, $redirection);
@ -248,7 +250,7 @@ function get_redirected_headers($url, $redirectionLimit = 3)
} }
} }
return array($headers, $url); return [$headers, $url];
} }
/** /**
@ -270,7 +272,7 @@ function getAbsoluteUrl($originalUrl, $newUrl)
} }
$parts = parse_url($originalUrl); $parts = parse_url($originalUrl);
$final = $parts['scheme'] .'://'. $parts['host']; $final = $parts['scheme'] . '://' . $parts['host'];
$final .= (!empty($parts['port'])) ? $parts['port'] : ''; $final .= (!empty($parts['port'])) ? $parts['port'] : '';
$final .= '/'; $final .= '/';
if ($newUrl[0] != '/') { if ($newUrl[0] != '/') {
@ -323,7 +325,8 @@ function server_url($server)
$scheme = 'https'; $scheme = 'https';
} }
if (($scheme == 'http' && $port != '80') if (
($scheme == 'http' && $port != '80')
|| ($scheme == 'https' && $port != '443') || ($scheme == 'https' && $port != '443')
) { ) {
$port = ':' . $port; $port = ':' . $port;
@ -344,22 +347,26 @@ function server_url($server)
$host = $server['SERVER_NAME']; $host = $server['SERVER_NAME'];
} }
return $scheme.'://'.$host.$port; return $scheme . '://' . $host . $port;
} }
// SSL detection // SSL detection
if ((! empty($server['HTTPS']) && strtolower($server['HTTPS']) == 'on') if (
|| (isset($server['SERVER_PORT']) && $server['SERVER_PORT'] == '443')) { (! empty($server['HTTPS']) && strtolower($server['HTTPS']) == 'on')
|| (isset($server['SERVER_PORT']) && $server['SERVER_PORT'] == '443')
) {
$scheme = 'https'; $scheme = 'https';
} }
// Do not append standard port values // Do not append standard port values
if (($scheme == 'http' && $server['SERVER_PORT'] != '80') if (
|| ($scheme == 'https' && $server['SERVER_PORT'] != '443')) { ($scheme == 'http' && $server['SERVER_PORT'] != '80')
$port = ':'.$server['SERVER_PORT']; || ($scheme == 'https' && $server['SERVER_PORT'] != '443')
) {
$port = ':' . $server['SERVER_PORT'];
} }
return $scheme.'://'.$server['SERVER_NAME'].$port; return $scheme . '://' . $server['SERVER_NAME'] . $port;
} }
/** /**
@ -550,7 +557,8 @@ function get_curl_download_callback(
&$title, &$title,
&$description, &$description,
&$keywords, &$keywords,
$retrieveDescription $retrieveDescription,
$tagsSeparator
) { ) {
$currentChunk = 0; $currentChunk = 0;
$foundChunk = null; $foundChunk = null;
@ -566,8 +574,12 @@ function get_curl_download_callback(
* *
* @return int|bool length of $data or false if we need to stop the download * @return int|bool length of $data or false if we need to stop the download
*/ */
return function ($ch, $data) use ( return function (
$ch,
$data
) use (
$retrieveDescription, $retrieveDescription,
$tagsSeparator,
&$charset, &$charset,
&$title, &$title,
&$description, &$description,
@ -598,10 +610,10 @@ function get_curl_download_callback(
if (! empty($keywords)) { if (! empty($keywords)) {
$foundChunk = $currentChunk; $foundChunk = $currentChunk;
// Keywords use the format tag1, tag2 multiple words, tag // Keywords use the format tag1, tag2 multiple words, tag
// So we format them to match Shaarli's separator and glue multiple words with '-' // So we split the result with `,`, then if a tag contains the separator we replace it by `-`.
$keywords = implode(' ', array_map(function($keyword) { $keywords = tags_array2str(array_map(function (string $keyword) use ($tagsSeparator): string {
return implode('-', preg_split('/\s+/', trim($keyword))); return tags_array2str(tags_str2array($keyword, $tagsSeparator), '-');
}, explode(',', $keywords))); }, tags_str2array($keywords, ',')), $tagsSeparator);
} }
} }
@ -609,7 +621,8 @@ function get_curl_download_callback(
// If we already found either the title, description or keywords, // If we already found either the title, description or keywords,
// it's highly unlikely that we'll found the other metas further than // 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. // 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 if (
(!empty($responseCode) && !empty($contentType) && !empty($charset)) && $foundChunk !== null
&& (! $retrieveDescription && (! $retrieveDescription
|| $foundChunk < $currentChunk || $foundChunk < $currentChunk
|| (!empty($title) && !empty($description) && !empty($keywords)) || (!empty($title) && !empty($description) && !empty($keywords))

View file

@ -38,7 +38,6 @@ public function retrieve(string $url): array
$title = null; $title = null;
$description = null; $description = null;
$tags = null; $tags = null;
$retrieveDescription = $this->conf->get('general.retrieve_description');
// Short timeout to keep the application responsive // Short timeout to keep the application responsive
// The callback will fill $charset and $title with data from the downloaded page. // The callback will fill $charset and $title with data from the downloaded page.
@ -52,7 +51,8 @@ public function retrieve(string $url): array
$title, $title,
$description, $description,
$tags, $tags,
$retrieveDescription $this->conf->get('general.retrieve_description'),
$this->conf->get('general.tags_separator', ' ')
) )
); );

View file

@ -17,7 +17,7 @@
*/ */
class Url class Url
{ {
private static $annoyingQueryParams = array( private static $annoyingQueryParams = [
// Facebook // Facebook
'action_object_map=', 'action_object_map=',
'action_ref_map=', 'action_ref_map=',
@ -37,15 +37,15 @@ class Url
// Other // Other
'campaign_' 'campaign_'
); ];
private static $annoyingFragments = array( private static $annoyingFragments = [
// ATInternet // ATInternet
'xtor=RSS-', 'xtor=RSS-',
// Misc. // Misc.
'tk.rss_all' 'tk.rss_all'
); ];
/* /*
* URL parts represented as an array * URL parts represented as an array
@ -120,7 +120,7 @@ protected function cleanupQuery()
foreach (self::$annoyingQueryParams as $annoying) { foreach (self::$annoyingQueryParams as $annoying) {
foreach ($queryParams as $param) { foreach ($queryParams as $param) {
if (startsWith($param, $annoying)) { if (startsWith($param, $annoying)) {
$queryParams = array_diff($queryParams, array($param)); $queryParams = array_diff($queryParams, [$param]);
continue; continue;
} }
} }

View file

@ -1,4 +1,5 @@
<?php <?php
/** /**
* Converts an array-represented URL to a string * Converts an array-represented URL to a string
* *
@ -12,15 +13,15 @@
*/ */
function unparse_url($parsedUrl) function unparse_url($parsedUrl)
{ {
$scheme = isset($parsedUrl['scheme']) ? $parsedUrl['scheme'].'://' : ''; $scheme = isset($parsedUrl['scheme']) ? $parsedUrl['scheme'] . '://' : '';
$host = isset($parsedUrl['host']) ? $parsedUrl['host'] : ''; $host = isset($parsedUrl['host']) ? $parsedUrl['host'] : '';
$port = isset($parsedUrl['port']) ? ':'.$parsedUrl['port'] : ''; $port = isset($parsedUrl['port']) ? ':' . $parsedUrl['port'] : '';
$user = isset($parsedUrl['user']) ? $parsedUrl['user'] : ''; $user = isset($parsedUrl['user']) ? $parsedUrl['user'] : '';
$pass = isset($parsedUrl['pass']) ? ':'.$parsedUrl['pass'] : ''; $pass = isset($parsedUrl['pass']) ? ':' . $parsedUrl['pass'] : '';
$pass = ($user || $pass) ? "$pass@" : ''; $pass = ($user || $pass) ? "$pass@" : '';
$path = isset($parsedUrl['path']) ? $parsedUrl['path'] : ''; $path = isset($parsedUrl['path']) ? $parsedUrl['path'] : '';
$query = isset($parsedUrl['query']) ? '?'.$parsedUrl['query'] : ''; $query = isset($parsedUrl['query']) ? '?' . $parsedUrl['query'] : '';
$fragment = isset($parsedUrl['fragment']) ? '#'.$parsedUrl['fragment'] : ''; $fragment = isset($parsedUrl['fragment']) ? '#' . $parsedUrl['fragment'] : '';
return "$scheme$user$pass$host$port$path$query$fragment"; return "$scheme$user$pass$host$port$path$query$fragment";
} }

View file

@ -51,7 +51,7 @@ public function post(Request $request, Response $response): Response
if (!$this->container->loginManager->isLoggedIn()) { if (!$this->container->loginManager->isLoggedIn()) {
$parameters = $buildParameters($request->getQueryParams(), true); $parameters = $buildParameters($request->getQueryParams(), true);
return $this->redirect($response, '/login?returnurl='. $this->getBasePath() . $route . $parameters); return $this->redirect($response, '/login?returnurl=' . $this->getBasePath() . $route . $parameters);
} }
$parameters = $buildParameters($request->getQueryParams(), false); $parameters = $buildParameters($request->getQueryParams(), false);

View file

@ -8,7 +8,7 @@
use Iterator; use Iterator;
use Shaarli\Bookmark\Exception\BookmarkNotFoundException; use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
use Shaarli\Exceptions\IOException; use Shaarli\Exceptions\IOException;
use Shaarli\FileUtils; use Shaarli\Helper\FileUtils;
use Shaarli\Render\PageCacheManager; use Shaarli\Render\PageCacheManager;
/** /**
@ -62,7 +62,7 @@ class LegacyLinkDB implements Iterator, Countable, ArrayAccess
private $datastore; private $datastore;
// Link date storage format // Link date storage format
const LINK_DATE_FORMAT = 'Ymd_His'; public const LINK_DATE_FORMAT = 'Ymd_His';
// List of bookmarks (associative array) // List of bookmarks (associative array)
// - key: link date (e.g. "20110823_124546"), // - key: link date (e.g. "20110823_124546"),
@ -240,8 +240,8 @@ private function check()
} }
// Create a dummy database for example // Create a dummy database for example
$this->links = array(); $this->links = [];
$link = array( $link = [
'id' => 1, 'id' => 1,
'title' => t('The personal, minimalist, super-fast, database free, bookmarking service'), 'title' => t('The personal, minimalist, super-fast, database free, bookmarking service'),
'url' => 'https://shaarli.readthedocs.io', 'url' => 'https://shaarli.readthedocs.io',
@ -257,11 +257,11 @@ private function check()
'created' => new DateTime(), 'created' => new DateTime(),
'tags' => 'opensource software', 'tags' => 'opensource software',
'sticky' => false, 'sticky' => false,
); ];
$link['shorturl'] = link_small_hash($link['created'], $link['id']); $link['shorturl'] = link_small_hash($link['created'], $link['id']);
$this->links[1] = $link; $this->links[1] = $link;
$link = array( $link = [
'id' => 0, 'id' => 0,
'title' => t('My secret stuff... - Pastebin.com'), 'title' => t('My secret stuff... - Pastebin.com'),
'url' => 'http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=', 'url' => 'http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=',
@ -270,7 +270,7 @@ private function check()
'created' => new DateTime('1 minute ago'), 'created' => new DateTime('1 minute ago'),
'tags' => 'secretstuff', 'tags' => 'secretstuff',
'sticky' => false, 'sticky' => false,
); ];
$link['shorturl'] = link_small_hash($link['created'], $link['id']); $link['shorturl'] = link_small_hash($link['created'], $link['id']);
$this->links[0] = $link; $this->links[0] = $link;
@ -285,7 +285,7 @@ private function read()
{ {
// Public bookmarks are hidden and user not logged in => nothing to show // Public bookmarks are hidden and user not logged in => nothing to show
if ($this->hidePublicLinks && !$this->loggedIn) { if ($this->hidePublicLinks && !$this->loggedIn) {
$this->links = array(); $this->links = [];
return; return;
} }
@ -293,7 +293,7 @@ private function read()
$this->ids = []; $this->ids = [];
$this->links = FileUtils::readFlatDB($this->datastore, []); $this->links = FileUtils::readFlatDB($this->datastore, []);
$toremove = array(); $toremove = [];
foreach ($this->links as $key => &$link) { foreach ($this->links as $key => &$link) {
if (!$this->loggedIn && $link['private'] != 0) { if (!$this->loggedIn && $link['private'] != 0) {
// Transition for not upgraded databases. // Transition for not upgraded databases.
@ -414,7 +414,7 @@ public function filterDay($request)
* @return array filtered bookmarks, all bookmarks if no suitable filter was provided. * @return array filtered bookmarks, all bookmarks if no suitable filter was provided.
*/ */
public function filterSearch( public function filterSearch(
$filterRequest = array(), $filterRequest = [],
$casesensitive = false, $casesensitive = false,
$visibility = 'all', $visibility = 'all',
$untaggedonly = false $untaggedonly = false
@ -512,7 +512,7 @@ public function renameTag($from, $to)
*/ */
public function days() public function days()
{ {
$linkDays = array(); $linkDays = [];
foreach ($this->links as $link) { foreach ($this->links as $link) {
$linkDays[$link['created']->format('Ymd')] = 0; $linkDays[$link['created']->format('Ymd')] = 0;
} }

View file

@ -120,7 +120,7 @@ private function noFilter($visibility = 'all')
return $this->links; return $this->links;
} }
$out = array(); $out = [];
foreach ($this->links as $key => $value) { foreach ($this->links as $key => $value) {
if ($value['private'] && $visibility === 'private') { if ($value['private'] && $visibility === 'private') {
$out[$key] = $value; $out[$key] = $value;
@ -143,7 +143,7 @@ private function noFilter($visibility = 'all')
*/ */
private function filterSmallHash($smallHash) private function filterSmallHash($smallHash)
{ {
$filtered = array(); $filtered = [];
foreach ($this->links as $key => $l) { foreach ($this->links as $key => $l) {
if ($smallHash == $l['shorturl']) { if ($smallHash == $l['shorturl']) {
// Yes, this is ugly and slow // Yes, this is ugly and slow
@ -186,7 +186,7 @@ private function filterFulltext($searchterms, $visibility = 'all')
return $this->noFilter($visibility); return $this->noFilter($visibility);
} }
$filtered = array(); $filtered = [];
$search = mb_convert_case(html_entity_decode($searchterms), MB_CASE_LOWER, 'UTF-8'); $search = mb_convert_case(html_entity_decode($searchterms), MB_CASE_LOWER, 'UTF-8');
$exactRegex = '/"([^"]+)"/'; $exactRegex = '/"([^"]+)"/';
// Retrieve exact search terms. // Retrieve exact search terms.
@ -198,8 +198,8 @@ private function filterFulltext($searchterms, $visibility = 'all')
$explodedSearchAnd = array_values(array_filter($explodedSearchAnd)); $explodedSearchAnd = array_values(array_filter($explodedSearchAnd));
// Filter excluding terms and update andSearch. // Filter excluding terms and update andSearch.
$excludeSearch = array(); $excludeSearch = [];
$andSearch = array(); $andSearch = [];
foreach ($explodedSearchAnd as $needle) { foreach ($explodedSearchAnd as $needle) {
if ($needle[0] == '-' && strlen($needle) > 1) { if ($needle[0] == '-' && strlen($needle) > 1) {
$excludeSearch[] = substr($needle, 1); $excludeSearch[] = substr($needle, 1);
@ -208,7 +208,7 @@ private function filterFulltext($searchterms, $visibility = 'all')
} }
} }
$keys = array('title', 'description', 'url', 'tags'); $keys = ['title', 'description', 'url', 'tags'];
// Iterate over every stored link. // Iterate over every stored link.
foreach ($this->links as $id => $link) { foreach ($this->links as $id => $link) {
@ -336,7 +336,7 @@ public function filterTags($tags, $casesensitive = false, $visibility = 'all')
} }
// create resulting array // create resulting array
$filtered = array(); $filtered = [];
// iterate over each link // iterate over each link
foreach ($this->links as $key => $link) { foreach ($this->links as $key => $link) {
@ -352,7 +352,7 @@ public function filterTags($tags, $casesensitive = false, $visibility = 'all')
$search = $link['tags']; // build search string, start with tags of current link $search = $link['tags']; // build search string, start with tags of current link
if (strlen(trim($link['description'])) && strpos($link['description'], '#') !== false) { if (strlen(trim($link['description'])) && strpos($link['description'], '#') !== false) {
// description given and at least one possible tag found // description given and at least one possible tag found
$descTags = array(); $descTags = [];
// find all tags in the form of #tag in the description // find all tags in the form of #tag in the description
preg_match_all( preg_match_all(
'/(?<![' . self::$HASHTAG_CHARS . '])#([' . self::$HASHTAG_CHARS . ']+?)\b/sm', '/(?<![' . self::$HASHTAG_CHARS . '])#([' . self::$HASHTAG_CHARS . ']+?)\b/sm',
@ -419,7 +419,7 @@ public function filterDay($day)
throw new Exception('Invalid date format'); throw new Exception('Invalid date format');
} }
$filtered = array(); $filtered = [];
foreach ($this->links as $key => $l) { foreach ($this->links as $key => $l) {
if ($l['created']->format('Ymd') == $day) { if ($l['created']->format('Ymd') == $day) {
$filtered[$key] = $l; $filtered[$key] = $l;

View file

@ -7,7 +7,6 @@
use ReflectionClass; use ReflectionClass;
use ReflectionException; use ReflectionException;
use ReflectionMethod; use ReflectionMethod;
use Shaarli\ApplicationUtils;
use Shaarli\Bookmark\Bookmark; use Shaarli\Bookmark\Bookmark;
use Shaarli\Bookmark\BookmarkArray; use Shaarli\Bookmark\BookmarkArray;
use Shaarli\Bookmark\BookmarkFilter; use Shaarli\Bookmark\BookmarkFilter;
@ -17,6 +16,7 @@
use Shaarli\Config\ConfigManager; use Shaarli\Config\ConfigManager;
use Shaarli\Config\ConfigPhp; use Shaarli\Config\ConfigPhp;
use Shaarli\Exceptions\IOException; use Shaarli\Exceptions\IOException;
use Shaarli\Helper\ApplicationUtils;
use Shaarli\Thumbnailer; use Shaarli\Thumbnailer;
use Shaarli\Updater\Exception\UpdaterException; use Shaarli\Updater\Exception\UpdaterException;
@ -93,7 +93,7 @@ public function __construct($doneUpdates, $linkDB, $conf, $isLoggedIn, &$session
*/ */
public function update() public function update()
{ {
$updatesRan = array(); $updatesRan = [];
// If the user isn't logged in, exit without updating. // If the user isn't logged in, exit without updating.
if ($this->isLoggedIn !== true) { if ($this->isLoggedIn !== true) {
@ -106,7 +106,8 @@ public function update()
foreach ($this->methods as $method) { foreach ($this->methods as $method) {
// Not an update method or already done, pass. // Not an update method or already done, pass.
if (!startsWith($method->getName(), 'updateMethod') if (
!startsWith($method->getName(), 'updateMethod')
|| in_array($method->getName(), $this->doneUpdates) || in_array($method->getName(), $this->doneUpdates)
) { ) {
continue; continue;
@ -189,7 +190,7 @@ public function updateMethodConfigToJson()
} }
// Set sub config keys (config and plugins) // Set sub config keys (config and plugins)
$subConfig = array('config', 'plugins'); $subConfig = ['config', 'plugins'];
foreach ($subConfig as $sub) { foreach ($subConfig as $sub) {
foreach ($oldConfig[$sub] as $key => $value) { foreach ($oldConfig[$sub] as $key => $value) {
if (isset($legacyMap[$sub . '.' . $key])) { if (isset($legacyMap[$sub . '.' . $key])) {
@ -259,7 +260,7 @@ public function updateMethodDatastoreIds()
$save = $this->conf->get('resource.data_dir') . '/datastore.' . date('YmdHis') . '.php'; $save = $this->conf->get('resource.data_dir') . '/datastore.' . date('YmdHis') . '.php';
copy($this->conf->get('resource.datastore'), $save); copy($this->conf->get('resource.datastore'), $save);
$links = array(); $links = [];
foreach ($this->linkDB as $offset => $value) { foreach ($this->linkDB as $offset => $value) {
$links[] = $value; $links[] = $value;
unset($this->linkDB[$offset]); unset($this->linkDB[$offset]);
@ -498,7 +499,8 @@ public function updateMethodVisibilitySession()
*/ */
public function updateMethodDownloadSizeAndTimeoutConf() public function updateMethodDownloadSizeAndTimeoutConf()
{ {
if ($this->conf->exists('general.download_max_size') if (
$this->conf->exists('general.download_max_size')
&& $this->conf->exists('general.download_timeout') && $this->conf->exists('general.download_timeout')
) { ) {
return true; return true;
@ -585,7 +587,7 @@ public function updateMethodMigrateDatabase()
$linksArray = new BookmarkArray(); $linksArray = new BookmarkArray();
foreach ($this->linkDB as $key => $link) { foreach ($this->linkDB as $key => $link) {
$linksArray[$key] = (new Bookmark())->fromArray($link); $linksArray[$key] = (new Bookmark())->fromArray($link, $this->conf->get('general.tags_separator', ' '));
} }
$linksIo = new BookmarkIO($this->conf); $linksIo = new BookmarkIO($this->conf);
$linksIo->write($linksArray); $linksIo->write($linksArray);

View file

@ -59,11 +59,11 @@ public function filterAndFormat(
$indexUrl $indexUrl
) { ) {
// see tpl/export.html for possible values // see tpl/export.html for possible values
if (!in_array($selection, array('all', 'public', 'private'))) { if (!in_array($selection, ['all', 'public', 'private'])) {
throw new Exception(t('Invalid export selection:') . ' "' . $selection . '"'); throw new Exception(t('Invalid export selection:') . ' "' . $selection . '"');
} }
$bookmarkLinks = array(); $bookmarkLinks = [];
foreach ($this->bookmarkService->search([], $selection) as $bookmark) { foreach ($this->bookmarkService->search([], $selection) as $bookmark) {
$link = $formatter->format($bookmark); $link = $formatter->format($bookmark);
$link['taglist'] = implode(',', $bookmark->getTags()); $link['taglist'] = implode(',', $bookmark->getTags());
@ -101,11 +101,11 @@ public function import($post, UploadedFileInterface $file)
// Add tags to all imported bookmarks? // Add tags to all imported bookmarks?
if (empty($post['default_tags'])) { if (empty($post['default_tags'])) {
$defaultTags = array(); $defaultTags = [];
} else { } else {
$defaultTags = preg_split( $defaultTags = tags_str2array(
'/[\s,]+/', escape($post['default_tags']),
escape($post['default_tags']) $this->conf->get('general.tags_separator', ' ')
); );
} }
@ -171,7 +171,7 @@ public function import($post, UploadedFileInterface $file)
$link->setUrl($bkm['uri'], $this->conf->get('security.allowed_protocols')); $link->setUrl($bkm['uri'], $this->conf->get('security.allowed_protocols'));
$link->setDescription($bkm['note']); $link->setDescription($bkm['note']);
$link->setPrivate($private); $link->setPrivate($private);
$link->setTagsString($bkm['tags']); $link->setTags($bkm['tags']);
$this->bookmarkService->addOrSet($link, false); $this->bookmarkService->addOrSet($link, false);
$importCount++; $importCount++;

View file

@ -1,4 +1,5 @@
<?php <?php
namespace Shaarli\Plugin; namespace Shaarli\Plugin;
use Shaarli\Config\ConfigManager; use Shaarli\Config\ConfigManager;
@ -23,7 +24,7 @@ class PluginManager
* *
* @var array $loadedPlugins * @var array $loadedPlugins
*/ */
private $loadedPlugins = array(); private $loadedPlugins = [];
/** /**
* @var ConfigManager Configuration Manager instance. * @var ConfigManager Configuration Manager instance.
@ -57,7 +58,7 @@ class PluginManager
public function __construct(&$conf) public function __construct(&$conf)
{ {
$this->conf = $conf; $this->conf = $conf;
$this->errors = array(); $this->errors = [];
} }
/** /**
@ -98,7 +99,7 @@ public function load($authorizedPlugins)
* *
* @return void * @return void
*/ */
public function executeHooks($hook, &$data, $params = array()) public function executeHooks($hook, &$data, $params = [])
{ {
$metadataParameters = [ $metadataParameters = [
'target' => '_PAGE_', 'target' => '_PAGE_',
@ -196,7 +197,7 @@ public function buildHookName($hook, $pluginName)
*/ */
public function getPluginsMeta() public function getPluginsMeta()
{ {
$metaData = array(); $metaData = [];
$dirs = glob(self::$PLUGINS_PATH . '/*', GLOB_ONLYDIR | GLOB_MARK); $dirs = glob(self::$PLUGINS_PATH . '/*', GLOB_ONLYDIR | GLOB_MARK);
// Browse all plugin directories. // Browse all plugin directories.
@ -217,9 +218,9 @@ public function getPluginsMeta()
if (isset($metaData[$plugin]['parameters'])) { if (isset($metaData[$plugin]['parameters'])) {
$params = explode(';', $metaData[$plugin]['parameters']); $params = explode(';', $metaData[$plugin]['parameters']);
} else { } else {
$params = array(); $params = [];
} }
$metaData[$plugin]['parameters'] = array(); $metaData[$plugin]['parameters'] = [];
foreach ($params as $param) { foreach ($params as $param) {
if (empty($param)) { if (empty($param)) {
continue; continue;

View file

@ -1,4 +1,5 @@
<?php <?php
namespace Shaarli\Plugin\Exception; namespace Shaarli\Plugin\Exception;
use Exception; use Exception;

View file

@ -3,11 +3,11 @@
namespace Shaarli\Render; namespace Shaarli\Render;
use Exception; use Exception;
use exceptions\MissingBasePathException; use Psr\Log\LoggerInterface;
use RainTPL; use RainTPL;
use Shaarli\ApplicationUtils;
use Shaarli\Bookmark\BookmarkServiceInterface; use Shaarli\Bookmark\BookmarkServiceInterface;
use Shaarli\Config\ConfigManager; use Shaarli\Config\ConfigManager;
use Shaarli\Helper\ApplicationUtils;
use Shaarli\Security\SessionManager; use Shaarli\Security\SessionManager;
use Shaarli\Thumbnailer; use Shaarli\Thumbnailer;
@ -35,6 +35,9 @@ class PageBuilder
*/ */
protected $session; protected $session;
/** @var LoggerInterface */
protected $logger;
/** /**
* @var BookmarkServiceInterface $bookmarkService instance. * @var BookmarkServiceInterface $bookmarkService instance.
*/ */
@ -54,17 +57,25 @@ class PageBuilder
* PageBuilder constructor. * PageBuilder constructor.
* $tpl is initialized at false for lazy loading. * $tpl is initialized at false for lazy loading.
* *
* @param ConfigManager $conf Configuration Manager instance (reference). * @param ConfigManager $conf Configuration Manager instance (reference).
* @param array $session $_SESSION array * @param array $session $_SESSION array
* @param BookmarkServiceInterface $linkDB instance. * @param LoggerInterface $logger
* @param string $token Session token * @param null $linkDB instance.
* @param bool $isLoggedIn * @param null $token Session token
* @param bool $isLoggedIn
*/ */
public function __construct(&$conf, $session, $linkDB = null, $token = null, $isLoggedIn = false) public function __construct(
{ ConfigManager &$conf,
array $session,
LoggerInterface $logger,
$linkDB = null,
$token = null,
$isLoggedIn = false
) {
$this->tpl = false; $this->tpl = false;
$this->conf = $conf; $this->conf = $conf;
$this->session = $session; $this->session = $session;
$this->logger = $logger;
$this->bookmarkService = $linkDB; $this->bookmarkService = $linkDB;
$this->token = $token; $this->token = $token;
$this->isLoggedIn = $isLoggedIn; $this->isLoggedIn = $isLoggedIn;
@ -98,7 +109,7 @@ private function initialize()
$this->tpl->assign('newVersion', escape($version)); $this->tpl->assign('newVersion', escape($version));
$this->tpl->assign('versionError', ''); $this->tpl->assign('versionError', '');
} catch (Exception $exc) { } catch (Exception $exc) {
logm($this->conf->get('resource.log'), $_SERVER['REMOTE_ADDR'], $exc->getMessage()); $this->logger->error(format_log('Error: ' . $exc->getMessage(), client_ip_id($_SERVER)));
$this->tpl->assign('newVersion', ''); $this->tpl->assign('newVersion', '');
$this->tpl->assign('versionError', escape($exc->getMessage())); $this->tpl->assign('versionError', escape($exc->getMessage()));
} }
@ -149,7 +160,8 @@ private function initialize()
$this->tpl->assign('formatter', $this->conf->get('formatter', 'default')); $this->tpl->assign('formatter', $this->conf->get('formatter', 'default'));
$this->tpl->assign('links_per_page', $this->session['LINKS_PER_PAGE']); $this->tpl->assign('links_per_page', $this->session['LINKS_PER_PAGE'] ?? 20);
$this->tpl->assign('tags_separator', $this->conf->get('general.tags_separator', ' '));
// To be removed with a proper theme configuration. // To be removed with a proper theme configuration.
$this->tpl->assign('conf', $this->conf); $this->tpl->assign('conf', $this->conf);

View file

@ -14,6 +14,7 @@ interface TemplatePage
public const DAILY = 'daily'; public const DAILY = 'daily';
public const DAILY_RSS = 'dailyrss'; public const DAILY_RSS = 'dailyrss';
public const EDIT_LINK = 'editlink'; public const EDIT_LINK = 'editlink';
public const EDIT_LINK_BATCH = 'editlink.batch';
public const ERROR = 'error'; public const ERROR = 'error';
public const EXPORT = 'export'; public const EXPORT = 'export';
public const NETSCAPE_EXPORT_BOOKMARKS = 'export.bookmarks'; public const NETSCAPE_EXPORT_BOOKMARKS = 'export.bookmarks';

View file

@ -23,10 +23,10 @@ class ThemeUtils
public static function getThemes($tplDir) public static function getThemes($tplDir)
{ {
$tplDir = rtrim($tplDir, '/'); $tplDir = rtrim($tplDir, '/');
$allTheme = glob($tplDir.'/*', GLOB_ONLYDIR); $allTheme = glob($tplDir . '/*', GLOB_ONLYDIR);
$themes = []; $themes = [];
foreach ($allTheme as $value) { foreach ($allTheme as $value) {
$themes[] = str_replace($tplDir.'/', '', $value); $themes[] = str_replace($tplDir . '/', '', $value);
} }
return $themes; return $themes;

View file

@ -1,9 +1,9 @@
<?php <?php
namespace Shaarli\Security; namespace Shaarli\Security;
use Shaarli\FileUtils; use Psr\Log\LoggerInterface;
use Shaarli\Helper\FileUtils;
/** /**
* Class BanManager * Class BanManager
@ -28,8 +28,8 @@ class BanManager
/** @var string Path to the file containing IP bans and failures */ /** @var string Path to the file containing IP bans and failures */
protected $banFile; protected $banFile;
/** @var string Path to the log file, used to log bans */ /** @var LoggerInterface Path to the log file, used to log bans */
protected $logFile; protected $logger;
/** @var array List of IP with their associated number of failed attempts */ /** @var array List of IP with their associated number of failed attempts */
protected $failures = []; protected $failures = [];
@ -40,18 +40,20 @@ class BanManager
/** /**
* BanManager constructor. * BanManager constructor.
* *
* @param array $trustedProxies List of allowed proxies IP * @param array $trustedProxies List of allowed proxies IP
* @param int $nbAttempts Number of allowed failed attempt before the ban * @param int $nbAttempts Number of allowed failed attempt before the ban
* @param int $banDuration Ban duration in seconds * @param int $banDuration Ban duration in seconds
* @param string $banFile Path to the file containing IP bans and failures * @param string $banFile Path to the file containing IP bans and failures
* @param string $logFile Path to the log file, used to log bans * @param LoggerInterface $logger PSR-3 logger to save login attempts in log directory
*/ */
public function __construct($trustedProxies, $nbAttempts, $banDuration, $banFile, $logFile) { public function __construct($trustedProxies, $nbAttempts, $banDuration, $banFile, LoggerInterface $logger)
{
$this->trustedProxies = $trustedProxies; $this->trustedProxies = $trustedProxies;
$this->nbAttempts = $nbAttempts; $this->nbAttempts = $nbAttempts;
$this->banDuration = $banDuration; $this->banDuration = $banDuration;
$this->banFile = $banFile; $this->banFile = $banFile;
$this->logFile = $logFile; $this->logger = $logger;
$this->readBanFile(); $this->readBanFile();
} }
@ -78,11 +80,7 @@ public function handleFailedAttempt($server)
if ($this->failures[$ip] >= $this->nbAttempts) { if ($this->failures[$ip] >= $this->nbAttempts) {
$this->bans[$ip] = time() + $this->banDuration; $this->bans[$ip] = time() + $this->banDuration;
logm( $this->logger->info(format_log('IP address banned from login: ' . $ip, $ip));
$this->logFile,
$server['REMOTE_ADDR'],
'IP address banned from login: '. $ip
);
} }
$this->writeBanFile(); $this->writeBanFile();
} }
@ -138,7 +136,7 @@ public function isBanned($server)
unset($this->failures[$ip]); unset($this->failures[$ip]);
} }
unset($this->bans[$ip]); unset($this->bans[$ip]);
logm($this->logFile, $server['REMOTE_ADDR'], 'Ban lifted for: '. $ip); $this->logger->info(format_log('Ban lifted for: ' . $ip, $ip));
$this->writeBanFile(); $this->writeBanFile();
return false; return false;

View file

@ -1,7 +1,9 @@
<?php <?php
namespace Shaarli\Security; namespace Shaarli\Security;
use Exception; use Exception;
use Psr\Log\LoggerInterface;
use Shaarli\Config\ConfigManager; use Shaarli\Config\ConfigManager;
/** /**
@ -31,26 +33,30 @@ class LoginManager
protected $staySignedInToken = ''; protected $staySignedInToken = '';
/** @var CookieManager */ /** @var CookieManager */
protected $cookieManager; protected $cookieManager;
/** @var LoggerInterface */
protected $logger;
/** /**
* Constructor * Constructor
* *
* @param ConfigManager $configManager Configuration Manager instance * @param ConfigManager $configManager Configuration Manager instance
* @param SessionManager $sessionManager SessionManager instance * @param SessionManager $sessionManager SessionManager instance
* @param CookieManager $cookieManager CookieManager instance * @param CookieManager $cookieManager CookieManager instance
* @param BanManager $banManager
* @param LoggerInterface $logger Used to log login attempts
*/ */
public function __construct($configManager, $sessionManager, $cookieManager) public function __construct(
{ ConfigManager $configManager,
SessionManager $sessionManager,
CookieManager $cookieManager,
BanManager $banManager,
LoggerInterface $logger
) {
$this->configManager = $configManager; $this->configManager = $configManager;
$this->sessionManager = $sessionManager; $this->sessionManager = $sessionManager;
$this->cookieManager = $cookieManager; $this->cookieManager = $cookieManager;
$this->banManager = new BanManager( $this->banManager = $banManager;
$this->configManager->get('security.trusted_proxies', []), $this->logger = $logger;
$this->configManager->get('security.ban_after'),
$this->configManager->get('security.ban_duration'),
$this->configManager->get('resource.ban_file', 'data/ipbans.php'),
$this->configManager->get('resource.log')
);
if ($this->configManager->get('security.open_shaarli') === true) { if ($this->configManager->get('security.open_shaarli') === true) {
$this->openShaarli = true; $this->openShaarli = true;
@ -101,7 +107,8 @@ public function checkLoginState($clientIpId)
// The user client has a valid stay-signed-in cookie // The user client has a valid stay-signed-in cookie
// Session information is updated with the current client information // Session information is updated with the current client information
$this->sessionManager->storeLoginInfo($clientIpId); $this->sessionManager->storeLoginInfo($clientIpId);
} elseif ($this->sessionManager->hasSessionExpired() } elseif (
$this->sessionManager->hasSessionExpired()
|| $this->sessionManager->hasClientIpChanged($clientIpId) || $this->sessionManager->hasClientIpChanged($clientIpId)
) { ) {
$this->sessionManager->logout(); $this->sessionManager->logout();
@ -129,48 +136,35 @@ public function isLoggedIn(): bool
/** /**
* Check user credentials are valid * Check user credentials are valid
* *
* @param string $remoteIp Remote client IP address
* @param string $clientIpId Client IP address identifier * @param string $clientIpId Client IP address identifier
* @param string $login Username * @param string $login Username
* @param string $password Password * @param string $password Password
* *
* @return bool true if the provided credentials are valid, false otherwise * @return bool true if the provided credentials are valid, false otherwise
*/ */
public function checkCredentials($remoteIp, $clientIpId, $login, $password) public function checkCredentials($clientIpId, $login, $password)
{ {
// Check login matches config
if ($login !== $this->configManager->get('credentials.login')) {
return false;
}
// Check credentials // Check credentials
try { try {
$useLdapLogin = !empty($this->configManager->get('ldap.host')); $useLdapLogin = !empty($this->configManager->get('ldap.host'));
if ((false === $useLdapLogin && $this->checkCredentialsFromLocalConfig($login, $password)) if (
|| (true === $useLdapLogin && $this->checkCredentialsFromLdap($login, $password)) $login === $this->configManager->get('credentials.login')
&& (
(false === $useLdapLogin && $this->checkCredentialsFromLocalConfig($login, $password))
|| (true === $useLdapLogin && $this->checkCredentialsFromLdap($login, $password))
)
) { ) {
$this->sessionManager->storeLoginInfo($clientIpId); $this->sessionManager->storeLoginInfo($clientIpId);
logm( $this->logger->info(format_log('Login successful', $clientIpId));
$this->configManager->get('resource.log'),
$remoteIp, return true;
'Login successful'
);
return true;
} }
} } catch (Exception $exception) {
catch(Exception $exception) { $this->logger->info(format_log('Exception while checking credentials: ' . $exception, $clientIpId));
logm(
$this->configManager->get('resource.log'),
$remoteIp,
'Exception while checking credentials: ' . $exception
);
} }
logm( $this->logger->info(format_log('Login failed for user ' . $login, $clientIpId));
$this->configManager->get('resource.log'),
$remoteIp,
'Login failed for user ' . $login
);
return false; return false;
} }
@ -183,7 +177,8 @@ public function checkCredentials($remoteIp, $clientIpId, $login, $password)
* *
* @return bool true if the provided credentials are valid, false otherwise * @return bool true if the provided credentials are valid, false otherwise
*/ */
public function checkCredentialsFromLocalConfig($login, $password) { public function checkCredentialsFromLocalConfig($login, $password)
{
$hash = sha1($password . $login . $this->configManager->get('credentials.salt')); $hash = sha1($password . $login . $this->configManager->get('credentials.salt'));
return $login == $this->configManager->get('credentials.login') return $login == $this->configManager->get('credentials.login')
@ -202,14 +197,14 @@ public function checkCredentialsFromLocalConfig($login, $password) {
*/ */
public function checkCredentialsFromLdap($login, $password, $connect = null, $bind = null) public function checkCredentialsFromLdap($login, $password, $connect = null, $bind = null)
{ {
$connect = $connect ?? function($host) { $connect = $connect ?? function ($host) {
$resource = ldap_connect($host); $resource = ldap_connect($host);
ldap_set_option($resource, LDAP_OPT_PROTOCOL_VERSION, 3); ldap_set_option($resource, LDAP_OPT_PROTOCOL_VERSION, 3);
return $resource; return $resource;
}; };
$bind = $bind ?? function($handle, $dn, $password) { $bind = $bind ?? function ($handle, $dn, $password) {
return ldap_bind($handle, $dn, $password); return ldap_bind($handle, $dn, $password);
}; };

View file

@ -1,4 +1,5 @@
<?php <?php
namespace Shaarli\Security; namespace Shaarli\Security;
use Shaarli\Config\ConfigManager; use Shaarli\Config\ConfigManager;
@ -79,7 +80,7 @@ public function setStaySignedIn($staySignedIn)
*/ */
public function generateToken() public function generateToken()
{ {
$token = sha1(uniqid('', true) .'_'. mt_rand() . $this->conf->get('credentials.salt')); $token = sha1(uniqid('', true) . '_' . mt_rand() . $this->conf->get('credentials.salt'));
$this->session['tokens'][$token] = 1; $this->session['tokens'][$token] = 1;
return $token; return $token;
} }
@ -293,9 +294,12 @@ public function start(): bool
return session_start(); return session_start();
} }
public function cookieParameters(int $lifeTime, string $path, string $domain): bool /**
* Be careful, return type of session_set_cookie_params() changed between PHP 7.1 and 7.2.
*/
public function cookieParameters(int $lifeTime, string $path, string $domain): void
{ {
return session_set_cookie_params($lifeTime, $path, $domain); session_set_cookie_params($lifeTime, $path, $domain);
} }
public function regenerateId(bool $deleteOldSession = false): bool public function regenerateId(bool $deleteOldSession = false): bool

View file

@ -88,7 +88,8 @@ public function update(string $basePath = null)
foreach ($this->methods as $method) { foreach ($this->methods as $method) {
// Not an update method or already done, pass. // Not an update method or already done, pass.
if (! startsWith($method->getName(), 'updateMethod') if (
! startsWith($method->getName(), 'updateMethod')
|| in_array($method->getName(), $this->doneUpdates) || in_array($method->getName(), $this->doneUpdates)
) { ) {
continue; continue;
@ -121,12 +122,12 @@ public function getDoneUpdates()
public function readUpdates(string $updatesFilepath): array public function readUpdates(string $updatesFilepath): array
{ {
return UpdaterUtils::read_updates_file($updatesFilepath); return UpdaterUtils::readUpdatesFile($updatesFilepath);
} }
public function writeUpdates(string $updatesFilepath, array $updates): void public function writeUpdates(string $updatesFilepath, array $updates): void
{ {
UpdaterUtils::write_updates_file($updatesFilepath, $updates); UpdaterUtils::writeUpdatesFile($updatesFilepath, $updates);
} }
/** /**
@ -152,7 +153,8 @@ public function updateMethodMigrateExistingNotesUrl(): bool
$updated = false; $updated = false;
foreach ($this->bookmarkService->search() as $bookmark) { foreach ($this->bookmarkService->search() as $bookmark) {
if ($bookmark->isNote() if (
$bookmark->isNote()
&& startsWith($bookmark->getUrl(), '?') && startsWith($bookmark->getUrl(), '?')
&& 1 === preg_match('/^\?([a-zA-Z0-9-_@]{6})($|&|#)/', $bookmark->getUrl(), $match) && 1 === preg_match('/^\?([a-zA-Z0-9-_@]{6})($|&|#)/', $bookmark->getUrl(), $match)
) { ) {

View file

@ -11,7 +11,7 @@ class UpdaterUtils
* *
* @return array Already done update methods. * @return array Already done update methods.
*/ */
public static function read_updates_file($updatesFilepath) public static function readUpdatesFile($updatesFilepath)
{ {
if (! empty($updatesFilepath) && is_file($updatesFilepath)) { if (! empty($updatesFilepath) && is_file($updatesFilepath)) {
$content = file_get_contents($updatesFilepath); $content = file_get_contents($updatesFilepath);
@ -19,7 +19,7 @@ public static function read_updates_file($updatesFilepath)
return explode(';', $content); return explode(';', $content);
} }
} }
return array(); return [];
} }
/** /**
@ -30,7 +30,7 @@ public static function read_updates_file($updatesFilepath)
* *
* @throws \Exception Couldn't write version number. * @throws \Exception Couldn't write version number.
*/ */
public static function write_updates_file($updatesFilepath, $updates) public static function writeUpdatesFile($updatesFilepath, $updates)
{ {
if (empty($updatesFilepath)) { if (empty($updatesFilepath)) {
throw new \Exception('Updates file path is not set, can\'t write updates.'); throw new \Exception('Updates file path is not set, can\'t write updates.');
@ -38,7 +38,7 @@ public static function write_updates_file($updatesFilepath, $updates)
$res = file_put_contents($updatesFilepath, implode(';', $updates)); $res = file_put_contents($updatesFilepath, implode(';', $updates));
if ($res === false) { if ($res === false) {
throw new \Exception('Unable to write updates in '. $updatesFilepath . '.'); throw new \Exception('Unable to write updates in ' . $updatesFilepath . '.');
} }
} }
} }

View file

@ -56,37 +56,41 @@ function updateThumb(basePath, divElement, id) {
(() => { (() => {
const basePath = document.querySelector('input[name="js_base_path"]').value; const basePath = document.querySelector('input[name="js_base_path"]').value;
const loaders = document.querySelectorAll('.loading-input');
/* /*
* METADATA FOR EDIT BOOKMARK PAGE * METADATA FOR EDIT BOOKMARK PAGE
*/ */
const inputTitle = document.querySelector('input[name="lf_title"]'); const inputTitles = document.querySelectorAll('input[name="lf_title"]');
if (inputTitle != null) { if (inputTitles != null) {
if (inputTitle.value.length > 0) { [...inputTitles].forEach((inputTitle) => {
clearLoaders(loaders); const form = inputTitle.closest('form[name="linkform"]');
return; const loaders = form.querySelectorAll('.loading-input');
}
const url = document.querySelector('input[name="lf_url"]').value; if (inputTitle.value.length > 0) {
clearLoaders(loaders);
return;
}
const xhr = new XMLHttpRequest(); const url = form.querySelector('input[name="lf_url"]').value;
xhr.open('GET', `${basePath}/admin/metadata?url=${encodeURI(url)}`, true);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); const xhr = new XMLHttpRequest();
xhr.onload = () => { xhr.open('GET', `${basePath}/admin/metadata?url=${encodeURI(url)}`, true);
const result = JSON.parse(xhr.response); xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
Object.keys(result).forEach((key) => { xhr.onload = () => {
if (result[key] !== null && result[key].length) { const result = JSON.parse(xhr.response);
const element = document.querySelector(`input[name="lf_${key}"], textarea[name="lf_${key}"]`); Object.keys(result).forEach((key) => {
if (element != null && element.value.length === 0) { if (result[key] !== null && result[key].length) {
element.value = he.decode(result[key]); const element = form.querySelector(`input[name="lf_${key}"], textarea[name="lf_${key}"]`);
if (element != null && element.value.length === 0) {
element.value = he.decode(result[key]);
}
} }
} });
}); clearLoaders(loaders);
clearLoaders(loaders); };
};
xhr.send(); xhr.send();
});
} }
/* /*

View file

@ -0,0 +1,121 @@
const sendBookmarkForm = (basePath, formElement) => {
const inputs = formElement
.querySelectorAll('input[type="text"], textarea, input[type="checkbox"], input[type="hidden"]');
const formData = new FormData();
[...inputs].forEach((input) => {
formData.append(input.getAttribute('name'), input.value);
});
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', `${basePath}/admin/shaare`);
xhr.onload = () => {
if (xhr.status !== 200) {
alert(`An error occurred. Return code: ${xhr.status}`);
reject();
} else {
formElement.closest('.edit-link-container').remove();
resolve();
}
};
xhr.send(formData);
});
};
const sendBookmarkDelete = (buttonElement, formElement) => (
new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', buttonElement.href);
xhr.onload = () => {
if (xhr.status !== 200) {
alert(`An error occurred. Return code: ${xhr.status}`);
reject();
} else {
formElement.closest('.edit-link-container').remove();
resolve();
}
};
xhr.send();
})
);
const redirectIfEmptyBatch = (basePath, formElements, path) => {
if (formElements == null || formElements.length === 0) {
window.location.href = `${basePath}${path}`;
}
};
(() => {
const basePath = document.querySelector('input[name="js_base_path"]').value;
const getForms = () => document.querySelectorAll('form[name="linkform"]');
const cancelButtons = document.querySelectorAll('[name="cancel-batch-link"]');
if (cancelButtons != null) {
[...cancelButtons].forEach((cancelButton) => {
cancelButton.addEventListener('click', (e) => {
e.preventDefault();
e.target.closest('form[name="linkform"]').remove();
redirectIfEmptyBatch(basePath, getForms(), '/admin/add-shaare');
});
});
}
const saveButtons = document.querySelectorAll('[name="save_edit"]');
if (saveButtons != null) {
[...saveButtons].forEach((saveButton) => {
saveButton.addEventListener('click', (e) => {
e.preventDefault();
const formElement = e.target.closest('form[name="linkform"]');
sendBookmarkForm(basePath, formElement)
.then(() => redirectIfEmptyBatch(basePath, getForms(), '/'));
});
});
}
const saveAllButtons = document.querySelectorAll('[name="save_edit_batch"]');
if (saveAllButtons != null) {
[...saveAllButtons].forEach((saveAllButton) => {
saveAllButton.addEventListener('click', (e) => {
e.preventDefault();
const forms = [...getForms()];
const nbForm = forms.length;
let current = 0;
const progressBar = document.querySelector('.progressbar > div');
const progressBarCurrent = document.querySelector('.progressbar-current');
document.querySelector('.dark-layer').style.display = 'block';
document.querySelector('.progressbar-max').innerHTML = nbForm;
progressBarCurrent.innerHTML = current;
const promises = [];
forms.forEach((formElement) => {
promises.push(sendBookmarkForm(basePath, formElement).then(() => {
current += 1;
progressBar.style.width = `${(current * 100) / nbForm}%`;
progressBarCurrent.innerHTML = current;
}));
});
Promise.all(promises).then(() => {
window.location.href = basePath || '/';
});
});
});
}
const deleteButtons = document.querySelectorAll('[name="delete_link"]');
if (deleteButtons != null) {
[...deleteButtons].forEach((deleteButton) => {
deleteButton.addEventListener('click', (e) => {
e.preventDefault();
const formElement = e.target.closest('form[name="linkform"]');
sendBookmarkDelete(e.target, formElement)
.then(() => redirectIfEmptyBatch(basePath, getForms(), '/'));
});
});
}
})();

View file

@ -42,19 +42,21 @@ function refreshToken(basePath, callback) {
xhr.send(); xhr.send();
} }
function createAwesompleteInstance(element, tags = []) { function createAwesompleteInstance(element, separator, tags = []) {
const awesome = new Awesomplete(Awesomplete.$(element)); const awesome = new Awesomplete(Awesomplete.$(element));
// Tags are separated by a space
awesome.filter = (text, input) => Awesomplete.FILTER_CONTAINS(text, input.match(/[^ ]*$/)[0]); // Tags are separated by separator
awesome.filter = (text, input) => Awesomplete.FILTER_CONTAINS(text, input.match(new RegExp(`[^${separator}]*$`))[0]);
// Insert new selected tag in the input // Insert new selected tag in the input
awesome.replace = (text) => { awesome.replace = (text) => {
const before = awesome.input.value.match(/^.+ \s*|/)[0]; const before = awesome.input.value.match(new RegExp(`^.+${separator}+|`))[0];
awesome.input.value = `${before}${text} `; awesome.input.value = `${before}${text}${separator}`;
}; };
// Highlight found items // Highlight found items
awesome.item = (text, input) => Awesomplete.ITEM(text, input.match(/[^ ]*$/)[0]); awesome.item = (text, input) => Awesomplete.ITEM(text, input.match(new RegExp(`[^${separator}]*$`))[0]);
// Don't display already selected items // Don't display already selected items
const reg = /(\w+) /g; // WARNING: pseudo classes does not seem to work with string litterals...
const reg = new RegExp(`([^${separator}]+)${separator}`, 'g');
let match; let match;
awesome.data = (item, input) => { awesome.data = (item, input) => {
while ((match = reg.exec(input))) { while ((match = reg.exec(input))) {
@ -78,13 +80,14 @@ function createAwesompleteInstance(element, tags = []) {
* @param selector CSS selector * @param selector CSS selector
* @param tags Array of tags * @param tags Array of tags
* @param instances List of existing awesomplete instances * @param instances List of existing awesomplete instances
* @param separator Tags separator character
*/ */
function updateAwesompleteList(selector, tags, instances) { function updateAwesompleteList(selector, tags, instances, separator) {
if (instances.length === 0) { if (instances.length === 0) {
// First load: create Awesomplete instances // First load: create Awesomplete instances
const elements = document.querySelectorAll(selector); const elements = document.querySelectorAll(selector);
[...elements].forEach((element) => { [...elements].forEach((element) => {
instances.push(createAwesompleteInstance(element, tags)); instances.push(createAwesompleteInstance(element, separator, tags));
}); });
} else { } else {
// Update awesomplete tag list // Update awesomplete tag list
@ -214,6 +217,8 @@ function init(description) {
(() => { (() => {
const basePath = document.querySelector('input[name="js_base_path"]').value; const basePath = document.querySelector('input[name="js_base_path"]').value;
const tagsSeparatorElement = document.querySelector('input[name="tags_separator"]');
const tagsSeparator = tagsSeparatorElement ? tagsSeparatorElement.value || ' ' : ' ';
/** /**
* Handle responsive menu. * Handle responsive menu.
@ -294,7 +299,8 @@ function init(description) {
const deleteLinks = document.querySelectorAll('.confirm-delete'); const deleteLinks = document.querySelectorAll('.confirm-delete');
[...deleteLinks].forEach((deleteLink) => { [...deleteLinks].forEach((deleteLink) => {
deleteLink.addEventListener('click', (event) => { deleteLink.addEventListener('click', (event) => {
if (!confirm(document.getElementById('translation-delete-tag').innerHTML)) { const type = event.currentTarget.getAttribute('data-type') || 'link';
if (!confirm(document.getElementById(`translation-delete-${type}`).innerHTML)) {
event.preventDefault(); event.preventDefault();
} }
}); });
@ -574,7 +580,7 @@ function init(description) {
// Refresh awesomplete values // Refresh awesomplete values
existingTags = existingTags.map((tag) => (tag === fromtag ? totag : tag)); existingTags = existingTags.map((tag) => (tag === fromtag ? totag : tag));
awesomepletes = updateAwesompleteList('.rename-tag-input', existingTags, awesomepletes); awesomepletes = updateAwesompleteList('.rename-tag-input', existingTags, awesomepletes, tagsSeparator);
} }
}; };
xhr.send(`renametag=1&fromtag=${fromtagUrl}&totag=${encodeURIComponent(totag)}&token=${refreshedToken}`); xhr.send(`renametag=1&fromtag=${fromtagUrl}&totag=${encodeURIComponent(totag)}&token=${refreshedToken}`);
@ -614,14 +620,14 @@ function init(description) {
refreshToken(basePath); refreshToken(basePath);
existingTags = existingTags.filter((tagItem) => tagItem !== tag); existingTags = existingTags.filter((tagItem) => tagItem !== tag);
awesomepletes = updateAwesompleteList('.rename-tag-input', existingTags, awesomepletes); awesomepletes = updateAwesompleteList('.rename-tag-input', existingTags, awesomepletes, tagsSeparator);
} }
}); });
}); });
const autocompleteFields = document.querySelectorAll('input[data-multiple]'); const autocompleteFields = document.querySelectorAll('input[data-multiple]');
[...autocompleteFields].forEach((autocompleteField) => { [...autocompleteFields].forEach((autocompleteField) => {
awesomepletes.push(createAwesompleteInstance(autocompleteField)); awesomepletes.push(createAwesompleteInstance(autocompleteField, tagsSeparator));
}); });
const exportForm = document.querySelector('#exportform'); const exportForm = document.querySelector('#exportform');
@ -634,4 +640,33 @@ function init(description) {
}); });
}); });
} }
const bulkCreationButton = document.querySelector('.addlink-batch-show-more-block');
if (bulkCreationButton != null) {
const toggleBulkCreationVisibility = (showMoreBlockElement, formElement) => {
if (bulkCreationButton.classList.contains('pure-u-0')) {
showMoreBlockElement.classList.remove('pure-u-0');
formElement.classList.add('pure-u-0');
} else {
showMoreBlockElement.classList.add('pure-u-0');
formElement.classList.remove('pure-u-0');
}
};
const bulkCreationForm = document.querySelector('.addlink-batch-form-block');
toggleBulkCreationVisibility(bulkCreationButton, bulkCreationForm);
bulkCreationButton.querySelector('a').addEventListener('click', (e) => {
e.preventDefault();
toggleBulkCreationVisibility(bulkCreationButton, bulkCreationForm);
});
// Force to send falsy value if the checkbox is not checked.
const privateButton = bulkCreationForm.querySelector('input[type="checkbox"][name="private"]');
const privateHiddenButton = bulkCreationForm.querySelector('input[type="hidden"][name="private"]');
privateButton.addEventListener('click', () => {
privateHiddenButton.disabled = !privateHiddenButton.disabled;
});
privateHiddenButton.disabled = privateButton.checked;
}
})(); })();

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