Merge pull request #2 from shaarli/master

Merge fork source
This commit is contained in:
yude 2021-01-04 18:51:10 +09:00 committed by GitHub
commit e6754f2154
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
208 changed files with 8987 additions and 2009 deletions

View file

@ -23,21 +23,7 @@ http {
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,25 +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 ~ \.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

@ -26,7 +26,7 @@ RUN cd shaarli \
# Stage 4: # Stage 4:
# - Shaarli image # - Shaarli image
FROM alpine:3.8 FROM alpine:3.12
LABEL maintainer="Shaarli Community" LABEL maintainer="Shaarli Community"
RUN apk --update --no-cache add \ RUN apk --update --no-cache add \
@ -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

@ -1,7 +1,7 @@
# Stage 1: # Stage 1:
# - Copy Shaarli sources # - Copy Shaarli sources
# - Build documentation # - Build documentation
FROM arm32v6/alpine:3.8 as docs FROM arm32v6/alpine:3.10 as docs
ADD . /usr/src/app/shaarli ADD . /usr/src/app/shaarli
RUN apk --update --no-cache add py2-pip \ RUN apk --update --no-cache add py2-pip \
&& cd /usr/src/app/shaarli \ && cd /usr/src/app/shaarli \
@ -10,7 +10,7 @@ RUN apk --update --no-cache add py2-pip \
# Stage 2: # Stage 2:
# - Resolve PHP dependencies with Composer # - Resolve PHP dependencies with Composer
FROM arm32v6/alpine:3.8 as composer FROM arm32v6/alpine:3.10 as composer
COPY --from=docs /usr/src/app/shaarli /app/shaarli COPY --from=docs /usr/src/app/shaarli /app/shaarli
RUN apk --update --no-cache add php7-curl php7-mbstring php7-simplexml composer \ RUN apk --update --no-cache add php7-curl php7-mbstring php7-simplexml composer \
&& cd /app/shaarli \ && cd /app/shaarli \
@ -18,7 +18,7 @@ RUN apk --update --no-cache add php7-curl php7-mbstring php7-simplexml composer
# Stage 3: # Stage 3:
# - Frontend dependencies # - Frontend dependencies
FROM arm32v6/alpine:3.8 as node FROM arm32v6/alpine:3.10 as node
COPY --from=composer /app/shaarli /shaarli COPY --from=composer /app/shaarli /shaarli
RUN apk --update --no-cache add yarn nodejs-current python2 build-base \ RUN apk --update --no-cache add yarn nodejs-current python2 build-base \
&& cd /shaarli \ && cd /shaarli \
@ -28,7 +28,7 @@ RUN apk --update --no-cache add yarn nodejs-current python2 build-base \
# Stage 4: # Stage 4:
# - Shaarli image # - Shaarli image
FROM arm32v6/alpine:3.8 FROM arm32v6/alpine:3.10
LABEL maintainer="Shaarli Community" LABEL maintainer="Shaarli Community"
RUN apk --update --no-cache add \ RUN apk --update --no-cache add \

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
@ -175,6 +171,7 @@ translate:
eslint: eslint:
@yarn run eslint -c .dev/.eslintrc.js assets/vintage/js/ @yarn run eslint -c .dev/.eslintrc.js assets/vintage/js/
@yarn run eslint -c .dev/.eslintrc.js assets/default/js/ @yarn run eslint -c .dev/.eslintrc.js assets/default/js/
@yarn run eslint -c .dev/.eslintrc.js assets/common/js/
### Run CSSLint check against Shaarli's SCSS files ### Run CSSLint check against Shaarli's SCSS files
sasslint: sasslint:

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();
@ -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) {
@ -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.
@ -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.
* *

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 $logFile where to write the logs
* @param string $clientIp the client's remote IPv4/IPv6 address
* @param string $message the message to log * @param string $message the message to log
* @param string|null $clientIp the client's remote IPv4/IPv6 address
*
* @return string Formatted 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.
* *
@ -362,8 +382,10 @@ function return_bytes($val)
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;
} }
@ -456,10 +478,24 @@ function alphabetical_sort(&$data, $reverse = false, $byKeys = false)
* @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));
}
/**
* Converts an exception into a printable stack trace string.
*/
function exception2text(Throwable $e): string
{
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;
@ -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
) { ) {
@ -91,11 +93,15 @@ public static function formatLink($bookmark, $indexUrl)
* *
* @param array|null $input Request Link. * @param array|null $input Request Link.
* @param bool $defaultPrivate Setting defined if a bookmark is private by default. * @param bool $defaultPrivate Setting defined if a bookmark is private by default.
* @param string $tagsSeparator Tags separator loaded from the config file.
* *
* @return Bookmark instance. * @return Bookmark instance.
*/ */
public static function buildBookmarkFromRequest(?array $input, bool $defaultPrivate): Bookmark public static function buildBookmarkFromRequest(
{ ?array $input,
bool $defaultPrivate,
string $tagsSeparator
): Bookmark {
$bookmark = new Bookmark(); $bookmark = new Bookmark();
$url = ! empty($input['url']) ? cleanup_url($input['url']) : ''; $url = ! empty($input['url']) ? cleanup_url($input['url']) : '';
if (isset($input['private'])) { if (isset($input['private'])) {
@ -107,6 +113,15 @@ public static function buildBookmarkFromRequest(?array $input, bool $defaultPriv
$bookmark->setTitle(! empty($input['title']) ? $input['title'] : ''); $bookmark->setTitle(! empty($input['title']) ? $input['title'] : '');
$bookmark->setUrl($url); $bookmark->setUrl($url);
$bookmark->setDescription(! empty($input['description']) ? $input['description'] : ''); $bookmark->setDescription(! empty($input['description']) ? $input['description'] : '');
// Be permissive with provided tags format
if (is_string($input['tags'] ?? null)) {
$input['tags'] = tags_str2array($input['tags'], $tagsSeparator);
}
if (is_array($input['tags'] ?? null) && count($input['tags']) === 1 && is_string($input['tags'][0])) {
$input['tags'] = tags_str2array($input['tags'][0], $tagsSeparator);
}
$bookmark->setTags(! empty($input['tags']) ? $input['tags'] : []); $bookmark->setTags(! empty($input['tags']) ? $input['tags'] : []);
$bookmark->setPrivate($private); $bookmark->setPrivate($private);

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

@ -117,9 +117,14 @@ public function getLink($request, $response, $args)
public function postLink($request, $response) 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'),
$this->conf->get('general.tags_separator', ' ')
);
// 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 +136,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);
} }
@ -157,9 +162,14 @@ public function putLink($request, $response, $args)
$index = index_url($this->ci['environment']); $index = index_url($this->ci['environment']);
$data = $request->getParsedBody(); $data = $request->getParsedBody();
$requestBookmark = ApiUtils::buildBookmarkFromRequest($data, $this->conf->get('privacy.default_private_links')); $requestBookmark = ApiUtils::buildBookmarkFromRequest(
$data,
$this->conf->get('privacy.default_private_links'),
$this->conf->get('general.tags_separator', ' ')
);
// 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

@ -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;
@ -61,10 +61,12 @@ 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)
@ -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;
} }
@ -377,6 +385,24 @@ public function setThumbnail($thumbnail): Bookmark
return $this; return $this;
} }
/**
* Return true if:
* - the bookmark's thumbnail is not already set to false (= not found)
* - it's not a note
* - it's an HTTP(S) link
* - the thumbnail has not yet be retrieved (null) or its associated cache file doesn't exist anymore
*
* @return bool True if the bookmark's thumbnail needs to be retrieved.
*/
public function shouldUpdateThumbnail(): bool
{
return $this->thumbnail !== false
&& !$this->isNote()
&& startsWith(strtolower($this->url), 'http')
&& (null === $this->thumbnail || !is_file($this->thumbnail))
;
}
/** /**
* Get the Sticky. * Get the Sticky.
* *
@ -402,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);
} }
/** /**
@ -426,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;
} }
@ -489,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);
} }
} }
@ -501,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

@ -91,19 +91,23 @@ public function __construct(ConfigManager $conf, History $history, Mutex $mutex,
} }
} }
$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
{ {
$tagString = $link->getTagsString($this->conf->get('general.tags_separator', ' '));
$content = mb_convert_case($link->getTitle(), MB_CASE_LOWER, 'UTF-8') . '\\'; $content = mb_convert_case($link->getTitle(), MB_CASE_LOWER, 'UTF-8') . '\\';
$content .= mb_convert_case($link->getDescription(), MB_CASE_LOWER, 'UTF-8') . '\\'; $content .= mb_convert_case($link->getDescription(), MB_CASE_LOWER, 'UTF-8') . '\\';
$content .= mb_convert_case($link->getUrl(), MB_CASE_LOWER, 'UTF-8') . '\\'; $content .= mb_convert_case($link->getUrl(), MB_CASE_LOWER, 'UTF-8') . '\\';
$content .= mb_convert_case($link->getTagsString(), 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

@ -4,6 +4,7 @@
namespace Shaarli\Bookmark; namespace Shaarli\Bookmark;
use malkusch\lock\exception\LockAcquireException;
use malkusch\lock\mutex\Mutex; use malkusch\lock\mutex\Mutex;
use malkusch\lock\mutex\NoMutex; use malkusch\lock\mutex\NoMutex;
use Shaarli\Bookmark\Exception\DatastoreNotInitializedException; use Shaarli\Bookmark\Exception\DatastoreNotInitializedException;
@ -80,7 +81,7 @@ public function read()
} }
$content = null; $content = null;
$this->mutex->synchronized(function () use (&$content) { $this->synchronized(function () use (&$content) {
$content = file_get_contents($this->datastore); $content = file_get_contents($this->datastore);
}); });
@ -119,11 +120,28 @@ public function write($links)
$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->synchronized(function () use ($data) {
file_put_contents( file_put_contents(
$this->datastore, $this->datastore,
$data $data
); );
}); });
} }
/**
* Wrapper applying mutex to provided function.
* If the lock can't be acquired (e.g. some shared hosting provider), we execute the function without mutex.
*
* @see https://github.com/shaarli/Shaarli/issues/1650
*
* @param callable $function
*/
protected function synchronized(callable $function): void
{
try {
$this->mutex->synchronized($function);
} catch (LockAcquireException $exception) {
$function();
}
}
} }

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,8 +39,8 @@ 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.

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

@ -68,16 +68,19 @@ function html_extract_tag($tag, $html)
$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. // Support quotes in double quoted content, and the other way around
$ogRegex = '#<meta[^>]+(?:'. $properties .')=(?:'. $orCondition .')[^>]*content=["\'](.*?)["\'].*?>#'; $content = 'content=(["\'])((?:(?!\1).)*)\1';
// Try to retrieve OpenGraph tag.
$ogRegex = '#<meta[^>]+(?:' . $properties . ')=(?:' . $orCondition . ')[^>]*' . $content . '.*?>#';
// 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 . '[^>]+(?:' . $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;
@ -138,12 +141,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 +179,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

@ -1,9 +1,7 @@
<?php <?php
namespace Shaarli\Bookmark\Exception; namespace Shaarli\Bookmark\Exception;
class NotWritableDataStoreException extends \Exception class NotWritableDataStoreException extends \Exception
{ {
/** /**

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.
@ -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) {
@ -366,10 +367,12 @@ protected function setDefaultValues()
$this->setEmpty('general.links_per_page', 20); $this->setEmpty('general.links_per_page', 20);
$this->setEmpty('general.enabled_plugins', self::$DEFAULT_PLUGINS); $this->setEmpty('general.enabled_plugins', self::$DEFAULT_PLUGINS);
$this->setEmpty('general.default_note_title', 'Note: '); $this->setEmpty('general.default_note_title', 'Note: ');
$this->setEmpty('general.retrieve_description', false); $this->setEmpty('general.retrieve_description', 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);
@ -390,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] : '';
} }
@ -121,7 +122,8 @@ public function write($filepath, $conf)
} }
} }
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(

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;
@ -14,6 +15,7 @@
use Shaarli\Front\Controller\Visitor\ErrorNotFoundController; use Shaarli\Front\Controller\Visitor\ErrorNotFoundController;
use Shaarli\History; use Shaarli\History;
use Shaarli\Http\HttpAccess; use Shaarli\Http\HttpAccess;
use Shaarli\Http\MetadataRetriever;
use Shaarli\Netscape\NetscapeBookmarkUtils; use Shaarli\Netscape\NetscapeBookmarkUtils;
use Shaarli\Plugin\PluginManager; use Shaarli\Plugin\PluginManager;
use Shaarli\Render\PageBuilder; use Shaarli\Render\PageBuilder;
@ -48,6 +50,12 @@ class ContainerBuilder
/** @var LoginManager */ /** @var LoginManager */
protected $login; protected $login;
/** @var PluginManager */
protected $pluginManager;
/** @var LoggerInterface */
protected $logger;
/** @var string|null */ /** @var string|null */
protected $basePath = null; protected $basePath = null;
@ -55,12 +63,16 @@ public function __construct(
ConfigManager $conf, ConfigManager $conf,
SessionManager $session, SessionManager $session,
CookieManager $cookieManager, CookieManager $cookieManager,
LoginManager $login LoginManager $login,
PluginManager $pluginManager,
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->pluginManager = $pluginManager;
$this->logger = $logger;
} }
public function build(): ShaarliContainer public function build(): ShaarliContainer
@ -71,11 +83,10 @@ 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['pluginManager'] = $this->pluginManager;
$container['logger'] = $this->logger;
$container['basePath'] = $this->basePath; $container['basePath'] = $this->basePath;
$container['plugins'] = function (ShaarliContainer $container): PluginManager {
return new PluginManager($container->conf);
};
$container['history'] = function (ShaarliContainer $container): History { $container['history'] = function (ShaarliContainer $container): History {
return new History($container->conf->get('resource.history')); return new History($container->conf->get('resource.history'));
@ -90,24 +101,21 @@ public function build(): ShaarliContainer
); );
}; };
$container['metadataRetriever'] = function (ShaarliContainer $container): MetadataRetriever {
return new MetadataRetriever($container->conf, $container->httpAccess);
};
$container['pageBuilder'] = function (ShaarliContainer $container): PageBuilder { $container['pageBuilder'] = function (ShaarliContainer $container): PageBuilder {
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()
); );
}; };
$container['pluginManager'] = function (ShaarliContainer $container): PluginManager {
$pluginManager = new PluginManager($container->conf);
$pluginManager->load($container->conf->get('general.enabled_plugins'));
return $pluginManager;
};
$container['formatterFactory'] = function (ShaarliContainer $container): FormatterFactory { $container['formatterFactory'] = function (ShaarliContainer $container): FormatterFactory {
return new FormatterFactory( return new FormatterFactory(
$container->conf, $container->conf,
@ -145,7 +153,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,12 +4,14 @@
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;
use Shaarli\Formatter\FormatterFactory; use Shaarli\Formatter\FormatterFactory;
use Shaarli\History; use Shaarli\History;
use Shaarli\Http\HttpAccess; use Shaarli\Http\HttpAccess;
use Shaarli\Http\MetadataRetriever;
use Shaarli\Netscape\NetscapeBookmarkUtils; use Shaarli\Netscape\NetscapeBookmarkUtils;
use Shaarli\Plugin\PluginManager; use Shaarli\Plugin\PluginManager;
use Shaarli\Render\PageBuilder; use Shaarli\Render\PageBuilder;
@ -35,6 +37,8 @@
* @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 NetscapeBookmarkUtils $netscapeBookmarkUtils * @property NetscapeBookmarkUtils $netscapeBookmarkUtils
* @property callable $notFoundHandler Overrides default Slim exception display * @property callable $notFoundHandler Overrides default Slim exception display
* @property PageBuilder $pageBuilder * @property PageBuilder $pageBuilder

View file

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

View file

@ -1,20 +1,27 @@
<?php <?php
declare(strict_types=1);
namespace Shaarli\Feed; namespace Shaarli\Feed;
use DatePeriod;
/** /**
* Simple cache system, mainly for the RSS/ATOM feeds * Simple cache system, mainly for the RSS/ATOM feeds
*/ */
class CachedPage class CachedPage
{ {
// Directory containing page caches /** Directory containing page caches */
private $cacheDir; protected $cacheDir;
// Should this URL be cached (boolean)? /** Should this URL be cached (boolean)? */
private $shouldBeCached; protected $shouldBeCached;
// Name of the cache file for this URL /** Name of the cache file for this URL */
private $filename; protected $filename;
/** @var DatePeriod|null Optionally specify a period of time for cache validity */
protected $validityPeriod;
/** /**
* Creates a new CachedPage * Creates a new CachedPage
@ -22,13 +29,15 @@ class CachedPage
* @param string $cacheDir page cache directory * @param string $cacheDir page cache directory
* @param string $url page URL * @param string $url page URL
* @param bool $shouldBeCached whether this page needs to be cached * @param bool $shouldBeCached whether this page needs to be cached
* @param ?DatePeriod $validityPeriod Optionally specify a time limit on requested cache
*/ */
public function __construct($cacheDir, $url, $shouldBeCached) public function __construct($cacheDir, $url, $shouldBeCached, ?DatePeriod $validityPeriod)
{ {
// TODO: check write access to the cache directory // TODO: check write access to the cache directory
$this->cacheDir = $cacheDir; $this->cacheDir = $cacheDir;
$this->filename = $this->cacheDir . '/' . sha1($url) . '.cache'; $this->filename = $this->cacheDir . '/' . sha1($url) . '.cache';
$this->shouldBeCached = $shouldBeCached; $this->shouldBeCached = $shouldBeCached;
$this->validityPeriod = $validityPeriod;
} }
/** /**
@ -41,11 +50,21 @@ public function cachedVersion()
if (!$this->shouldBeCached) { if (!$this->shouldBeCached) {
return null; return null;
} }
if (is_file($this->filename)) { if (!is_file($this->filename)) {
return file_get_contents($this->filename);
}
return null; return null;
} }
if ($this->validityPeriod !== null) {
$cacheDate = \DateTime::createFromFormat('U', (string) filemtime($this->filename));
if (
$cacheDate < $this->validityPeriod->getStartDate()
|| $cacheDate > $this->validityPeriod->getEndDate()
) {
return null;
}
}
return file_get_contents($this->filename);
}
/** /**
* Puts a page in the cache * Puts a page in the cache

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);
} }

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;
@ -178,14 +178,14 @@ 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',

View file

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

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

@ -1,371 +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 (empty($title) && strpos(get_url_scheme($url) ?: '', 'http') !== false) {
$retrieveDescription = $this->container->conf->get('general.retrieve_description');
// Short timeout to keep the application responsive
// The callback will fill $charset and $title with data from the downloaded page.
$this->container->httpAccess->getHttpResponse(
$url,
$this->container->conf->get('general.download_timeout', 30),
$this->container->conf->get('general.download_max_size', 4194304),
$this->container->httpAccess->getCurlDownloadCallback(
$charset,
$title,
$description,
$tags,
$retrieveDescription
)
);
if (! empty($title) && strtolower($charset) !== 'utf-8' && mb_check_encoding($charset)) {
$title = mb_convert_encoding($title, 'utf-8', $charset);
}
}
if (empty($url) && empty($title)) {
$title = $this->container->conf->get('general.default_note_title', t('Note: '));
}
$link = [
'title' => $title,
'url' => $url ?? '',
'description' => $description ?? '',
'tags' => $tags ?? '',
'private' => $private,
];
} else {
$formatter = $this->container->formatterFactory->getFormatter('raw');
$link = $formatter->format($bookmark);
}
return $this->displayForm($link, $linkIsNew, $request, $response);
}
/**
* GET /admin/shaare/{id} - Displays the bookmark form in edition mode.
*/
public function displayEditForm(Request $request, Response $response, array $args): Response
{
$id = $args['id'] ?? '';
try {
if (false === ctype_digit($id)) {
throw new BookmarkNotFoundException();
}
$bookmark = $this->container->bookmarkService->get((int) $id); // Read database
} catch (BookmarkNotFoundException $e) {
$this->saveErrorMessage(sprintf(
t('Bookmark with identifier %s could not be found.'),
$id
));
return $this->redirect($response, '/');
}
$formatter = $this->container->formatterFactory->getFormatter('raw');
$link = $formatter->format($bookmark);
return $this->displayForm($link, false, $request, $response);
}
/**
* POST /admin/shaare
*/
public function save(Request $request, Response $response): Response
{
$this->checkToken($request);
// lf_id should only be present if the link exists.
$id = $request->getParam('lf_id') !== 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
&& false === $bookmark->isNote()
) {
$bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl()));
}
$this->container->bookmarkService->addOrSet($bookmark, false);
// To preserve backward compatibility with 3rd parties, plugins still use arrays
$formatter = $this->container->formatterFactory->getFormatter('raw');
$data = $formatter->format($bookmark);
$this->executePageHooks('save_link', $data);
$bookmark->fromArray($data);
$this->container->bookmarkService->set($bookmark);
// If we are called from the bookmarklet, we must close the popup:
if ($request->getParam('source') === 'bookmarklet') {
return $response->write('<script>self.close();</script>');
}
if (!empty($request->getParam('returnurl'))) {
$this->container->environment['HTTP_REFERER'] = escape($request->getParam('returnurl'));
}
return $this->redirectFromReferer(
$request,
$response,
['/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),
]);
$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,6 +24,12 @@ 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')
@ -85,4 +91,31 @@ public function save(Request $request, Response $response): Response
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

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Admin;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Controller used to retrieve/update bookmark's metadata.
*/
class MetadataController extends ShaarliAdminController
{
/**
* GET /admin/metadata/{url} - Attempt to retrieve the bookmark title from provided URL.
*/
public function ajaxRetrieveTitle(Request $request, Response $response): Response
{
$url = $request->getParam('url');
// Only try to extract metadata from URL with HTTP(s) scheme
if (!empty($url) && strpos(get_url_scheme($url) ?: '', 'http') !== false) {
return $response->withJson($this->container->metadataRetriever->retrieve($url));
}
return $response->withJson([]);
}
}

View file

@ -0,0 +1,101 @@
<?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));
$permissions = array_merge(
ApplicationUtils::checkResourcePermissions($this->container->conf),
ApplicationUtils::checkDatastoreMutex()
);
$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', $permissions);
$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'] = $link['tags'] !== null && 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

@ -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());
@ -169,20 +177,26 @@ 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, thumbnails enabled, not a note, is HTTP if (false === $this->container->loginManager->isLoggedIn()) {
// and (never retrieved yet or no valid cache file) return false;
if ($this->container->loginManager->isLoggedIn() }
// 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 && $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE
&& false !== $bookmark->getThumbnail()
&& !$bookmark->isNote()
&& (null === $bookmark->getThumbnail() || !is_file($bookmark->getThumbnail()))
&& startsWith(strtolower($bookmark->getUrl()), 'http')
) { ) {
$bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl())); $bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl()));
$this->container->bookmarkService->set($bookmark, $writeDatastore); $this->container->bookmarkService->set($bookmark, $writeDatastore);
return true; return true;
} }
}
return false; return false;
} }
@ -198,6 +212,7 @@ protected function initializeTemplateVars(): array
'page_max' => '', 'page_max' => '',
'search_tags' => '', 'search_tags' => '',
'result_count' => '', 'result_count' => '',
'async_metadata' => $this->container->conf->get('general.enable_async_metadata', true)
]; ];
} }

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));
@ -96,9 +86,11 @@ public function index(Request $request, Response $response): Response
public function rss(Request $request, Response $response): Response public function rss(Request $request, Response $response): Response
{ {
$response = $response->withHeader('Content-Type', 'application/rss+xml; charset=utf-8'); $response = $response->withHeader('Content-Type', 'application/rss+xml; charset=utf-8');
$type = DailyPageHelper::extractRequestedType($request);
$cacheDuration = DailyPageHelper::getCacheDatePeriodByType($type);
$pageUrl = page_url($this->container->environment); $pageUrl = page_url($this->container->environment);
$cache = $this->container->pageCacheManager->getCachePage($pageUrl); $cache = $this->container->pageCacheManager->getCachePage($pageUrl, $cacheDuration);
$cached = $cache->cachedVersion(); $cached = $cache->cachedVersion();
if (!empty($cached)) { if (!empty($cached)) {
@ -106,11 +98,13 @@ public function rss(Request $request, Response $response): Response
} }
$days = []; $days = [];
$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 +121,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, false),
'absolute_url' => $indexUrl . 'daily?day=' . $day, 'absolute_url' => $indexUrl . 'daily?' . $type . '=' . $day,
'links' => [], 'links' => [],
]; ];
@ -141,16 +142,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 +194,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,12 +26,15 @@ 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( $this->assignView(
'stacktrace', 'text',
nl2br(get_class($throwable) .': '. PHP_EOL . $throwable->getTraceAsString()) '<a href="https://github.com/shaarli/Shaarli/issues/new">'
. t('Please report it on Github.')
. '</a>'
); );
$this->assignView('stacktrace', exception2text($throwable));
} else { } else {
$this->assignView('message', t('An unexpected error occurred.')); $this->assignView('message', t('An unexpected error occurred.'));
} }
@ -39,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

@ -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,21 @@ 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));
$permissions = array_merge(
ApplicationUtils::checkResourcePermissions($this->container->conf),
ApplicationUtils::checkDatastoreMutex()
);
$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', $permissions);
$this->assignView('pagetitle', t('Install Shaarli'));
return $response->write($this->render('install')); return $response->write($this->render('install'));
} }
@ -65,7 +81,8 @@ 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.
@ -94,7 +111,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'))
) { ) {
@ -150,7 +168,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

@ -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

@ -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);

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,7 +84,7 @@ 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')

View file

@ -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,7 +63,7 @@ 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']);
@ -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,7 +1,10 @@
<?php <?php
namespace Shaarli;
namespace Shaarli\Helper;
use Exception; use Exception;
use malkusch\lock\exception\LockAcquireException;
use malkusch\lock\mutex\FlockMutex;
use Shaarli\Config\ConfigManager; use Shaarli\Config\ConfigManager;
/** /**
@ -14,8 +17,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 +67,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 +129,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) {
@ -172,34 +176,46 @@ 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', 'application',
'inc', 'inc',
'plugins', 'plugins',
$rainTplDir, $rainTplDir,
$rainTplDir . '/' . $conf->get('resource.theme'), $rainTplDir . '/' . $conf->get('resource.theme'),
) as $path) { ] 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) {
$folders = [
$conf->get('resource.raintpl_tmp'),
];
} else {
$folders = [
$conf->get('resource.thumbnails_cache'), $conf->get('resource.thumbnails_cache'),
$conf->get('resource.data_dir'), $conf->get('resource.data_dir'),
$conf->get('resource.page_cache'), $conf->get('resource.page_cache'),
$conf->get('resource.raintpl_tmp'), $conf->get('resource.raintpl_tmp'),
) as $path) { ];
}
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 +224,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->getConfigFileExt(),
$conf->get('resource.datastore'), $conf->get('resource.datastore'),
$conf->get('resource.ban_file'), $conf->get('resource.ban_file'),
$conf->get('resource.log'), $conf->get('resource.log'),
$conf->get('resource.update_check'), $conf->get('resource.update_check'),
) as $path) { ] 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;
@ -232,6 +254,20 @@ public static function checkResourcePermissions($conf)
return $errors; return $errors;
} }
public static function checkDatastoreMutex(): array
{
$mutex = new FlockMutex(fopen(SHAARLI_MUTEX_FILE, 'r'), 2);
try {
$mutex->synchronized(function () {
return true;
});
} catch (LockAcquireException $e) {
$errors[] = t('Lock can not be acquired on the datastore. You might encounter concurrent access issues.');
}
return $errors ?? [];
}
/** /**
* Returns a salted hash representing the current Shaarli version. * Returns a salted hash representing the current Shaarli version.
* *
@ -246,4 +282,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,236 @@
<?php
declare(strict_types=1);
namespace Shaarli\Helper;
use DatePeriod;
use DateTimeImmutable;
use Exception;
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)
* @param bool $includeRelative Include relative date description (today, yesterday, etc.)
*
* @return string Localized time period description
*
* @throws Exception Type not supported.
*/
public static function getDescriptionByType(
string $type,
\DateTimeImmutable $requested,
bool $includeRelative = true
): 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 ($includeRelative && $requested->format('Ymd') === date('Ymd')) {
$out = t('Today') . ' - ';
} elseif ($includeRelative && $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');
}
}
/**
* Get the number of items to display in the RSS feed depending on the given type.
*
* @param string $type month/week/day
* @param ?DateTimeImmutable $requested Currently only used for UT
*
* @return DatePeriod number of elements
*
* @throws Exception Type not supported.
*/
public static function getCacheDatePeriodByType(string $type, DateTimeImmutable $requested = null): DatePeriod
{
$requested = $requested ?? new DateTimeImmutable();
return new DatePeriod(
static::getStartDateTimeByType($type, $requested),
new \DateInterval('P1D'),
static::getEndDateTimeByType($type, $requested)
);
}
}

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

@ -14,9 +14,14 @@
*/ */
class HttpAccess class HttpAccess
{ {
public function getHttpResponse($url, $timeout = 30, $maxBytes = 4194304, $curlWriteFunction = null) public function getHttpResponse(
{ $url,
return get_http_response($url, $timeout, $maxBytes, $curlWriteFunction); $timeout = 30,
$maxBytes = 4194304,
$curlHeaderFunction = null,
$curlWriteFunction = null
) {
return get_http_response($url, $timeout, $maxBytes, $curlHeaderFunction, $curlWriteFunction);
} }
public function getCurlDownloadCallback( public function getCurlDownloadCallback(
@ -25,7 +30,7 @@ public function getCurlDownloadCallback(
&$description, &$description,
&$keywords, &$keywords,
$retrieveDescription, $retrieveDescription,
$curlGetInfo = 'curl_getinfo' $tagsSeparator
) { ) {
return get_curl_download_callback( return get_curl_download_callback(
$charset, $charset,
@ -33,7 +38,12 @@ public function getCurlDownloadCallback(
$description, $description,
$keywords, $keywords,
$retrieveDescription, $retrieveDescription,
$curlGetInfo $tagsSeparator
); );
} }
public function getCurlHeaderCallback(&$charset, $curlGetInfo = 'curl_getinfo')
{
return get_curl_header_callback($charset, $curlGetInfo);
}
} }

View file

@ -9,6 +9,8 @@
* @param string $url URL to get (http://...) * @param string $url URL to get (http://...)
* @param int $timeout network timeout (in seconds) * @param int $timeout network timeout (in seconds)
* @param int $maxBytes maximum downloaded bytes (default: 4 MiB) * @param int $maxBytes maximum downloaded bytes (default: 4 MiB)
* @param callable|string $curlHeaderFunction Optional callback called during the download of headers
* (CURLOPT_HEADERFUNCTION)
* @param callable|string $curlWriteFunction Optional callback called during the download (cURL CURLOPT_WRITEFUNCTION). * @param callable|string $curlWriteFunction Optional callback called during the download (cURL CURLOPT_WRITEFUNCTION).
* Can be used to add download conditions on the * Can be used to add download conditions on the
* headers (response code, content type, etc.). * headers (response code, content type, etc.).
@ -35,13 +37,18 @@
* @see http://stackoverflow.com/q/9183178 * @see http://stackoverflow.com/q/9183178
* @see http://stackoverflow.com/q/1462720 * @see http://stackoverflow.com/q/1462720
*/ */
function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteFunction = null) function get_http_response(
{ $url,
$timeout = 30,
$maxBytes = 4194304,
$curlHeaderFunction = null,
$curlWriteFunction = null
) {
$urlObj = new Url($url); $urlObj = new Url($url);
$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 =
@ -64,42 +71,39 @@ function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteF
$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
curl_setopt($ch, CURLOPT_AUTOREFERER, true); curl_setopt($ch, CURLOPT_AUTOREFERER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_HEADER, true); // Default header download if the $curlHeaderFunction is not defined
curl_setopt($ch, CURLOPT_HEADER, !is_callable($curlHeaderFunction));
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);
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
curl_setopt($ch, CURLOPT_USERAGENT, $userAgent); curl_setopt($ch, CURLOPT_USERAGENT, $userAgent);
if (is_callable($curlWriteFunction)) {
curl_setopt($ch, CURLOPT_WRITEFUNCTION, $curlWriteFunction);
}
// 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)) {
curl_setopt($ch, CURLOPT_HEADERFUNCTION, $curlHeaderFunction);
}
if (is_callable($curlWriteFunction)) {
curl_setopt($ch, CURLOPT_WRITEFUNCTION, $curlWriteFunction);
}
curl_setopt( curl_setopt(
$ch, $ch,
CURLOPT_PROGRESSFUNCTION, CURLOPT_PROGRESSFUNCTION,
function ($arg0, $arg1, $arg2, $arg3, $arg4 = 0) use ($maxBytes) { function ($arg0, $arg1, $arg2, $arg3, $arg4) use ($maxBytes) {
if (version_compare(phpversion(), '5.5', '<')) {
// PHP version lower than 5.5
// Callback has 4 arguments
$downloaded = $arg1;
} else {
// Callback has 5 arguments
$downloaded = $arg2; $downloaded = $arg2;
}
// Non-zero return stops downloading // Non-zero return stops downloading
return ($downloaded > $maxBytes) ? 1 : 0; return ($downloaded > $maxBytes) ? 1 : 0;
} }
@ -118,9 +122,9 @@ function ($arg0, $arg1, $arg2, $arg3, $arg4 = 0) 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
@ -131,7 +135,7 @@ function ($arg0, $arg1, $arg2, $arg3, $arg4 = 0) 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;
@ -142,7 +146,7 @@ function ($arg0, $arg1, $arg2, $arg3, $arg4 = 0) 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 {
@ -153,7 +157,7 @@ function ($arg0, $arg1, $arg2, $arg3, $arg4 = 0) use ($maxBytes) {
} }
} }
return array($headers, $content); return [$headers, $content];
} }
/** /**
@ -184,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);
@ -203,7 +207,7 @@ function get_http_response_fallback(
} }
if (! $headers) { if (! $headers) {
return array($headers, false); return [$headers, false];
} }
try { try {
@ -211,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];
} }
/** /**
@ -233,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);
@ -244,7 +250,7 @@ function get_redirected_headers($url, $redirectionLimit = 3)
} }
} }
return array($headers, $url); return [$headers, $url];
} }
/** /**
@ -319,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,14 +351,18 @@ function server_url($server)
} }
// 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')
|| ($scheme == 'https' && $server['SERVER_PORT'] != '443')
) {
$port = ':' . $server['SERVER_PORT']; $port = ':' . $server['SERVER_PORT'];
} }
@ -493,53 +504,22 @@ function is_https($server)
* Get cURL callback function for CURLOPT_WRITEFUNCTION * Get cURL callback function for CURLOPT_WRITEFUNCTION
* *
* @param string $charset to extract from the downloaded page (reference) * @param string $charset to extract from the downloaded page (reference)
* @param string $title to extract from the downloaded page (reference)
* @param string $description to extract from the downloaded page (reference)
* @param string $keywords to extract from the downloaded page (reference)
* @param bool $retrieveDescription Automatically tries to retrieve description and keywords from HTML content
* @param string $curlGetInfo Optionally overrides curl_getinfo function * @param string $curlGetInfo Optionally overrides curl_getinfo function
* *
* @return Closure * @return Closure
*/ */
function get_curl_download_callback( function get_curl_header_callback(
&$charset, &$charset,
&$title,
&$description,
&$keywords,
$retrieveDescription,
$curlGetInfo = 'curl_getinfo' $curlGetInfo = 'curl_getinfo'
) { ) {
$isRedirected = false; $isRedirected = false;
$currentChunk = 0;
$foundChunk = null;
/** return function ($ch, $data) use ($curlGetInfo, &$charset, &$isRedirected) {
* cURL callback function for CURLOPT_WRITEFUNCTION (called during the download).
*
* While downloading the remote page, we check that the HTTP code is 200 and content type is 'html/text'
* Then we extract the title and the charset and stop the download when it's done.
*
* @param resource $ch cURL resource
* @param string $data chunk of data being downloaded
*
* @return int|bool length of $data or false if we need to stop the download
*/
return function (&$ch, $data) use (
$retrieveDescription,
$curlGetInfo,
&$charset,
&$title,
&$description,
&$keywords,
&$isRedirected,
&$currentChunk,
&$foundChunk
) {
$currentChunk++;
$responseCode = $curlGetInfo($ch, CURLINFO_RESPONSE_CODE); $responseCode = $curlGetInfo($ch, CURLINFO_RESPONSE_CODE);
$chunkLength = strlen($data);
if (!empty($responseCode) && in_array($responseCode, [301, 302])) { if (!empty($responseCode) && in_array($responseCode, [301, 302])) {
$isRedirected = true; $isRedirected = true;
return strlen($data); return $chunkLength;
} }
if (!empty($responseCode) && $responseCode !== 200) { if (!empty($responseCode) && $responseCode !== 200) {
return false; return false;
@ -555,6 +535,61 @@ function get_curl_download_callback(
if (!empty($contentType) && empty($charset)) { if (!empty($contentType) && empty($charset)) {
$charset = header_extract_charset($contentType); $charset = header_extract_charset($contentType);
} }
return $chunkLength;
};
}
/**
* Get cURL callback function for CURLOPT_WRITEFUNCTION
*
* @param string $charset to extract from the downloaded page (reference)
* @param string $title to extract from the downloaded page (reference)
* @param string $description to extract from the downloaded page (reference)
* @param string $keywords to extract from the downloaded page (reference)
* @param bool $retrieveDescription Automatically tries to retrieve description and keywords from HTML content
* @param string $curlGetInfo Optionally overrides curl_getinfo function
*
* @return Closure
*/
function get_curl_download_callback(
&$charset,
&$title,
&$description,
&$keywords,
$retrieveDescription,
$tagsSeparator
) {
$currentChunk = 0;
$foundChunk = null;
/**
* cURL callback function for CURLOPT_WRITEFUNCTION (called during the download).
*
* While downloading the remote page, we check that the HTTP code is 200 and content type is 'html/text'
* Then we extract the title and the charset and stop the download when it's done.
*
* @param resource $ch cURL resource
* @param string $data chunk of data being downloaded
*
* @return int|bool length of $data or false if we need to stop the download
*/
return function (
$ch,
$data
) use (
$retrieveDescription,
$tagsSeparator,
&$charset,
&$title,
&$description,
&$keywords,
&$currentChunk,
&$foundChunk
) {
$chunkLength = strlen($data);
$currentChunk++;
if (empty($charset)) { if (empty($charset)) {
$charset = html_extract_charset($data); $charset = html_extract_charset($data);
} }
@ -562,6 +597,10 @@ function get_curl_download_callback(
$title = html_extract_title($data); $title = html_extract_title($data);
$foundChunk = ! empty($title) ? $currentChunk : $foundChunk; $foundChunk = ! empty($title) ? $currentChunk : $foundChunk;
} }
if (empty($title)) {
$title = html_extract_tag('title', $data);
$foundChunk = ! empty($title) ? $currentChunk : $foundChunk;
}
if ($retrieveDescription && empty($description)) { if ($retrieveDescription && empty($description)) {
$description = html_extract_tag('description', $data); $description = html_extract_tag('description', $data);
$foundChunk = ! empty($description) ? $currentChunk : $foundChunk; $foundChunk = ! empty($description) ? $currentChunk : $foundChunk;
@ -571,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);
} }
} }
@ -582,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))
@ -591,6 +631,6 @@ function get_curl_download_callback(
return false; return false;
} }
return strlen($data); return $chunkLength;
}; };
} }

View file

@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Shaarli\Http;
use Shaarli\Config\ConfigManager;
/**
* HTTP Tool used to extract metadata from external URL (title, description, etc.).
*/
class MetadataRetriever
{
/** @var ConfigManager */
protected $conf;
/** @var HttpAccess */
protected $httpAccess;
public function __construct(ConfigManager $conf, HttpAccess $httpAccess)
{
$this->conf = $conf;
$this->httpAccess = $httpAccess;
}
/**
* Retrieve metadata for given URL.
*
* @return array [
* 'title' => <remote title>,
* 'description' => <remote description>,
* 'tags' => <remote keywords>,
* ]
*/
public function retrieve(string $url): array
{
$charset = null;
$title = null;
$description = null;
$tags = null;
// Short timeout to keep the application responsive
// The callback will fill $charset and $title with data from the downloaded page.
$this->httpAccess->getHttpResponse(
$url,
$this->conf->get('general.download_timeout', 30),
$this->conf->get('general.download_max_size', 4194304),
$this->httpAccess->getCurlHeaderCallback($charset),
$this->httpAccess->getCurlDownloadCallback(
$charset,
$title,
$description,
$tags,
$this->conf->get('general.retrieve_description'),
$this->conf->get('general.tags_separator', ' ')
)
);
if (!empty($title) && strtolower($charset) !== 'utf-8') {
$title = mb_convert_encoding($title, 'utf-8', $charset);
}
return array_map([$this, 'cleanMetadata'], [
'title' => $title,
'description' => $description,
'tags' => $tags,
]);
}
protected function cleanMetadata($data): ?string
{
return !is_string($data) || empty(trim($data)) ? null : trim($data);
}
}

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
* *

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,8 +1,10 @@
<?php <?php
namespace Shaarli\Plugin; namespace Shaarli\Plugin;
use Shaarli\Config\ConfigManager; use Shaarli\Config\ConfigManager;
use Shaarli\Plugin\Exception\PluginFileNotFoundException; use Shaarli\Plugin\Exception\PluginFileNotFoundException;
use Shaarli\Plugin\Exception\PluginInvalidRouteException;
/** /**
* Class PluginManager * Class PluginManager
@ -23,7 +25,15 @@ class PluginManager
* *
* @var array $loadedPlugins * @var array $loadedPlugins
*/ */
private $loadedPlugins = array(); private $loadedPlugins = [];
/** @var array List of registered routes. Contains keys:
* - `method`: HTTP method, GET/POST/PUT/PATCH/DELETE
* - `route` (path): without prefix, e.g. `/up/{variable}`
* It will be later prefixed by `/plugin/<plugin name>/`.
* - `callable` string, function name or FQN class's method, e.g. `demo_plugin_custom_controller`.
*/
protected $registeredRoutes = [];
/** /**
* @var ConfigManager Configuration Manager instance. * @var ConfigManager Configuration Manager instance.
@ -57,7 +67,7 @@ class PluginManager
public function __construct(&$conf) public function __construct(&$conf)
{ {
$this->conf = $conf; $this->conf = $conf;
$this->errors = array(); $this->errors = [];
} }
/** /**
@ -85,6 +95,9 @@ public function load($authorizedPlugins)
$this->loadPlugin($dirs[$index], $plugin); $this->loadPlugin($dirs[$index], $plugin);
} catch (PluginFileNotFoundException $e) { } catch (PluginFileNotFoundException $e) {
error_log($e->getMessage()); error_log($e->getMessage());
} catch (\Throwable $e) {
$error = $plugin . t(' [plugin incompatibility]: ') . $e->getMessage();
$this->errors = array_unique(array_merge($this->errors, [$error]));
} }
} }
} }
@ -98,7 +111,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_',
@ -165,6 +178,22 @@ private function loadPlugin($dir, $pluginName)
} }
} }
$registerRouteFunction = $pluginName . '_register_routes';
$routes = null;
if (function_exists($registerRouteFunction)) {
$routes = call_user_func($registerRouteFunction);
}
if ($routes !== null) {
foreach ($routes as $route) {
if (static::validateRouteRegistration($route)) {
$this->registeredRoutes[$pluginName][] = $route;
} else {
throw new PluginInvalidRouteException($pluginName);
}
}
}
$this->loadedPlugins[] = $pluginName; $this->loadedPlugins[] = $pluginName;
} }
@ -196,7 +225,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 +246,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;
@ -236,6 +265,14 @@ public function getPluginsMeta()
return $metaData; return $metaData;
} }
/**
* @return array List of registered custom routes by plugins.
*/
public function getRegisteredRoutes(): array
{
return $this->registeredRoutes;
}
/** /**
* Return the list of encountered errors. * Return the list of encountered errors.
* *
@ -245,4 +282,32 @@ public function getErrors()
{ {
return $this->errors; return $this->errors;
} }
/**
* Checks whether provided input is valid to register a new route.
* It must contain keys `method`, `route`, `callable` (all strings).
*
* @param string[] $input
*
* @return bool
*/
protected static function validateRouteRegistration(array $input): bool
{
if (
!array_key_exists('method', $input)
|| !in_array(strtoupper($input['method']), ['GET', 'PUT', 'PATCH', 'POST', 'DELETE'])
) {
return false;
}
if (!array_key_exists('route', $input) || !preg_match('#^[a-z\d/\.\-_]+$#', $input['route'])) {
return false;
}
if (!array_key_exists('callable', $input)) {
return false;
}
return true;
}
} }

View file

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

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Shaarli\Plugin\Exception;
use Exception;
/**
* Class PluginFileNotFoundException
*
* Raise when plugin files can't be found.
*/
class PluginInvalidRouteException extends Exception
{
/**
* Construct exception with plugin name.
* Generate message.
*
* @param string $pluginName name of the plugin not found
*/
public function __construct()
{
$this->message = 'trying to register invalid route.';
}
}

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.
*/ */
@ -56,15 +59,23 @@ class PageBuilder
* *
* @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 null $token Session token
* @param bool $isLoggedIn * @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

@ -2,6 +2,7 @@
namespace Shaarli\Render; namespace Shaarli\Render;
use DatePeriod;
use Shaarli\Feed\CachedPage; use Shaarli\Feed\CachedPage;
/** /**
@ -49,12 +50,21 @@ public function invalidateCaches(): void
$this->purgeCachedPages(); $this->purgeCachedPages();
} }
public function getCachePage(string $pageUrl): CachedPage /**
* Get CachedPage instance for provided URL.
*
* @param string $pageUrl
* @param ?DatePeriod $validityPeriod Optionally specify a time limit on requested cache
*
* @return CachedPage
*/
public function getCachePage(string $pageUrl, DatePeriod $validityPeriod = null): CachedPage
{ {
return new CachedPage( return new CachedPage(
$this->pageCacheDir, $this->pageCacheDir,
$pageUrl, $pageUrl,
false === $this->isLoggedIn false === $this->isLoggedIn,
$validityPeriod
); );
} }
} }

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

@ -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 = [];
@ -44,14 +44,16 @@ class BanManager
* @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,6 +33,8 @@ class LoginManager
protected $staySignedInToken = ''; protected $staySignedInToken = '';
/** @var CookieManager */ /** @var CookieManager */
protected $cookieManager; protected $cookieManager;
/** @var LoggerInterface */
protected $logger;
/** /**
* Constructor * Constructor
@ -38,19 +42,21 @@ class LoginManager
* @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 (
$login === $this->configManager->get('credentials.login')
&& (
(false === $useLdapLogin && $this->checkCredentialsFromLocalConfig($login, $password))
|| (true === $useLdapLogin && $this->checkCredentialsFromLdap($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,
'Login successful'
);
return true; 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')

View file

@ -1,4 +1,5 @@
<?php <?php
namespace Shaarli\Security; namespace Shaarli\Security;
use Shaarli\Config\ConfigManager; use Shaarli\Config\ConfigManager;
@ -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.');

View file

@ -0,0 +1,107 @@
import he from 'he';
/**
* This script is used to retrieve bookmarks metadata asynchronously:
* - title, description and keywords while creating a new bookmark
* - thumbnails while visiting the bookmark list
*
* Note: it should only be included if the user is logged in
* and the setting general.enable_async_metadata is enabled.
*/
/**
* Removes given input loaders - used in edit link template.
*
* @param {object} loaders List of input DOM element that need to be cleared
*/
function clearLoaders(loaders) {
if (loaders != null && loaders.length > 0) {
[...loaders].forEach((loader) => {
loader.classList.remove('loading-input');
});
}
}
/**
* AJAX request to update the thumbnail of a bookmark with the provided ID.
* If a thumbnail is retrieved, it updates the divElement with the image src, and displays it.
*
* @param {string} basePath Shaarli subfolder for XHR requests
* @param {object} divElement Main <div> DOM element containing the thumbnail placeholder
* @param {int} id Bookmark ID to update
*/
function updateThumb(basePath, divElement, id) {
const xhr = new XMLHttpRequest();
xhr.open('PATCH', `${basePath}/admin/shaare/${id}/update-thumbnail`);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.responseType = 'json';
xhr.onload = () => {
if (xhr.status !== 200) {
alert(`An error occurred. Return code: ${xhr.status}`);
} else {
const { response } = xhr;
if (response.thumbnail !== false) {
const imgElement = divElement.querySelector('img');
imgElement.src = response.thumbnail;
imgElement.dataset.src = response.thumbnail;
imgElement.style.opacity = '1';
divElement.classList.remove('hidden');
}
}
};
xhr.send();
}
(() => {
const basePath = document.querySelector('input[name="js_base_path"]').value;
/*
* METADATA FOR EDIT BOOKMARK PAGE
*/
const inputTitles = document.querySelectorAll('input[name="lf_title"]');
if (inputTitles != null) {
[...inputTitles].forEach((inputTitle) => {
const form = inputTitle.closest('form[name="linkform"]');
const loaders = form.querySelectorAll('.loading-input');
if (inputTitle.value.length > 0) {
clearLoaders(loaders);
return;
}
const url = form.querySelector('input[name="lf_url"]').value;
const xhr = new XMLHttpRequest();
xhr.open('GET', `${basePath}/admin/metadata?url=${encodeURI(url)}`, true);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.onload = () => {
const result = JSON.parse(xhr.response);
Object.keys(result).forEach((key) => {
if (result[key] !== null && result[key].length) {
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);
};
xhr.send();
});
}
/*
* METADATA FOR THUMBNAIL RETRIEVAL
*/
const thumbsToLoad = document.querySelectorAll('div[data-async-thumbnail]');
if (thumbsToLoad != null) {
[...thumbsToLoad].forEach((divElement) => {
const { id } = divElement.closest('[data-id]').dataset;
updateThumb(basePath, divElement, id);
});
}
})();

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

@ -1,4 +1,5 @@
import Awesomplete from 'awesomplete'; import Awesomplete from 'awesomplete';
import he from 'he';
/** /**
* Find a parent element according to its tag and its attributes * Find a parent element according to its tag and its attributes
@ -41,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))) {
@ -77,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
@ -95,15 +99,6 @@ function updateAwesompleteList(selector, tags, instances) {
return instances; return instances;
} }
/**
* html_entities in JS
*
* @see http://stackoverflow.com/questions/18749591/encode-html-entities-in-javascript
*/
function htmlEntities(str) {
return str.replace(/[\u00A0-\u9999<>&]/gim, (i) => `&#${i.charCodeAt(0)};`);
}
/** /**
* Add the class 'hidden' to city options not attached to the current selected continent. * Add the class 'hidden' to city options not attached to the current selected continent.
* *
@ -222,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.
@ -302,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();
} }
}); });
@ -569,7 +567,7 @@ function init(description) {
input.setAttribute('name', totag); input.setAttribute('name', totag);
input.setAttribute('value', totag); input.setAttribute('value', totag);
findParent(input, 'div', { class: 'rename-tag-form' }).style.display = 'none'; findParent(input, 'div', { class: 'rename-tag-form' }).style.display = 'none';
block.querySelector('a.tag-link').innerHTML = htmlEntities(totag); block.querySelector('a.tag-link').innerHTML = he.encode(totag);
block block
.querySelector('a.tag-link') .querySelector('a.tag-link')
.setAttribute('href', `${basePath}/?searchtags=${encodeURIComponent(totag)}`); .setAttribute('href', `${basePath}/?searchtags=${encodeURIComponent(totag)}`);
@ -582,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}`);
@ -622,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');
@ -642,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;
}
})(); })();

View file

@ -139,6 +139,16 @@ body,
} }
} }
.page-form,
.pure-alert {
code {
display: inline-block;
padding: 0 2px;
color: $dark-grey;
background-color: var(--background-color);
}
}
// Make pure-extras alert closable. // Make pure-extras alert closable.
.pure-alert-closable { .pure-alert-closable {
.fa-times { .fa-times {
@ -1023,6 +1033,10 @@ body,
&.button-red { &.button-red {
background: $red; background: $red;
} }
&.button-grey {
background: $light-grey;
}
} }
.submit-buttons { .submit-buttons {
@ -1047,7 +1061,7 @@ body,
} }
table { table {
margin: auto; margin: 10px auto 25px auto;
width: 90%; width: 90%;
.order { .order {
@ -1083,6 +1097,11 @@ body,
position: absolute; position: absolute;
right: 5%; right: 5%;
} }
&.button-grey {
position: absolute;
left: 5%;
}
} }
} }
} }
@ -1257,11 +1276,15 @@ form {
margin: 70px 0 25px; margin: 70px 0 25px;
} }
a {
color: var(--main-color);
}
pre { pre {
margin: 0 20%; margin: 0 20%;
padding: 20px 0; padding: 20px 0;
text-align: left; text-align: left;
line-height: .7em; line-height: 1em;
} }
} }
@ -1273,6 +1296,57 @@ form {
} }
} }
.loading-input {
position: relative;
@keyframes around {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.icon-container {
position: absolute;
right: 60px;
top: calc(50% - 10px);
}
.loader {
position: relative;
height: 20px;
width: 20px;
display: inline-block;
animation: around 5.4s infinite;
&::after,
&::before {
content: "";
background: $form-input-background;
position: absolute;
display: inline-block;
width: 100%;
height: 100%;
border-width: 2px;
border-color: #333 #333 transparent transparent;
border-style: solid;
border-radius: 20px;
box-sizing: border-box;
top: 0;
left: 0;
animation: around 0.7s ease-in-out infinite;
}
&::after {
animation: around 0.7s ease-in-out 0.1s infinite;
background: transparent;
}
}
}
// LOGIN // LOGIN
.login-form-container { .login-form-container {
.remember-me { .remember-me {
@ -1645,6 +1719,123 @@ form {
} }
} }
// SERVER PAGE
.server-tables-page,
.server-tables {
.window-subtitle {
&::before {
display: block;
margin: 8px auto;
background: linear-gradient(to right, var(--background-color), $dark-grey, var(--background-color));
width: 50%;
height: 1px;
content: '';
}
}
.server-row {
p {
height: 25px;
padding: 0 10px;
}
}
.server-label {
text-align: right;
font-weight: bold;
}
i {
&.fa-color-green {
color: $main-green;
}
&.fa-color-orange {
color: $orange;
}
&.fa-color-red {
color: $red;
}
}
@media screen and (max-width: 64em) {
.server-label {
text-align: center;
}
.server-row {
p {
text-align: center;
}
}
}
}
// Batch creation
input[name='save_edit_batch'] {
@extend %page-form-button;
}
.addlink-batch-show-more {
display: flex;
align-items: center;
margin: 20px 0 8px;
a {
color: var(--main-color);
text-decoration: none;
}
&::before,
&::after {
content: "";
flex-grow: 1;
background: rgba(0, 0, 0, 0.35);
height: 1px;
font-size: 0;
line-height: 0;
}
&::before {
margin: 0 16px 0 0;
}
&::after {
margin: 0 0 0 16px;
}
}
.dark-layer {
display: none;
position: fixed;
height: 100%;
width: 100%;
z-index: 998;
background-color: rgba(0, 0, 0, .75);
color: #fff;
.screen-center {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
min-height: 100vh;
}
.progressbar {
width: 33%;
}
}
.addlink-batch-form-block {
.pure-alert {
margin: 25px 0 0 0;
}
}
// Print rules // Print rules
@media print { @media print {
.shaarli-menu { .shaarli-menu {

View file

@ -1122,6 +1122,16 @@ ul.errors {
float: left; float: left;
} }
ul.warnings {
color: orange;
float: left;
}
ul.successes {
color: green;
float: left;
}
#pluginsadmin { #pluginsadmin {
width: 80%; width: 80%;
padding: 20px 0 0 20px; padding: 20px 0 0 20px;
@ -1248,3 +1258,54 @@ ul.errors {
width: 0%; width: 0%;
height: 10px; height: 10px;
} }
.loading-input {
position: relative;
}
@keyframes around {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.loading-input .icon-container {
position: absolute;
right: 60px;
top: calc(50% - 10px);
}
.loading-input .loader {
position: relative;
height: 20px;
width: 20px;
display: inline-block;
animation: around 5.4s infinite;
}
.loading-input .loader::after,
.loading-input .loader::before {
content: "";
background: #eee;
position: absolute;
display: inline-block;
width: 100%;
height: 100%;
border-width: 2px;
border-color: #333 #333 transparent transparent;
border-style: solid;
border-radius: 20px;
box-sizing: border-box;
top: 0;
left: 0;
animation: around 0.7s ease-in-out infinite;
}
.loading-input .loader::after {
animation: around 0.7s ease-in-out 0.1s infinite;
background: transparent;
}

View file

@ -2,29 +2,38 @@ import Awesomplete from 'awesomplete';
import 'awesomplete/awesomplete.css'; import 'awesomplete/awesomplete.css';
(() => { (() => {
const awp = Awesomplete.$;
const autocompleteFields = document.querySelectorAll('input[data-multiple]'); const autocompleteFields = document.querySelectorAll('input[data-multiple]');
const tagsSeparatorElement = document.querySelector('input[name="tags_separator"]');
const tagsSeparator = tagsSeparatorElement ? tagsSeparatorElement.value || ' ' : ' ';
[...autocompleteFields].forEach((autocompleteField) => { [...autocompleteFields].forEach((autocompleteField) => {
const awesomplete = new Awesomplete(awp(autocompleteField)); const awesome = new Awesomplete(Awesomplete.$(autocompleteField));
awesomplete.filter = (text, input) => Awesomplete.FILTER_CONTAINS(text, input.match(/[^ ]*$/)[0]);
awesomplete.replace = (text) => { // Tags are separated by separator
const before = awesomplete.input.value.match(/^.+ \s*|/)[0]; awesome.filter = (text, input) => Awesomplete.FILTER_CONTAINS(
awesomplete.input.value = `${before}${text} `; text,
input.match(new RegExp(`[^${tagsSeparator}]*$`))[0],
);
// Insert new selected tag in the input
awesome.replace = (text) => {
const before = awesome.input.value.match(new RegExp(`^.+${tagsSeparator}+|`))[0];
awesome.input.value = `${before}${text}${tagsSeparator}`;
}; };
awesomplete.minChars = 1; // Highlight found items
awesome.item = (text, input) => Awesomplete.ITEM(text, input.match(new RegExp(`[^${tagsSeparator}]*$`))[0]);
autocompleteField.addEventListener('input', () => { // Don't display already selected items
const proposedTags = autocompleteField.getAttribute('data-list').replace(/,/g, '').split(' '); // WARNING: pseudo classes does not seem to work with string litterals...
const reg = /(\w+) /g; const reg = new RegExp(`([^${tagsSeparator}]+)${tagsSeparator}`, 'g');
let match; let match;
while ((match = reg.exec(autocompleteField.value)) !== null) { awesome.data = (item, input) => {
const id = proposedTags.indexOf(match[1]); while ((match = reg.exec(input))) {
if (id !== -1) { if (item === match[1]) {
proposedTags.splice(id, 1); return '';
} }
} }
return item;
awesomplete.list = proposedTags; };
}); awesome.minChars = 1;
}); });
})(); })();

View file

@ -23,9 +23,10 @@
"erusev/parsedown": "^1.6", "erusev/parsedown": "^1.6",
"erusev/parsedown-extra": "^0.8.1", "erusev/parsedown-extra": "^0.8.1",
"gettext/gettext": "^4.4", "gettext/gettext": "^4.4",
"katzgrau/klogger": "^1.2",
"malkusch/lock": "^2.1", "malkusch/lock": "^2.1",
"pubsubhubbub/publisher": "dev-master", "pubsubhubbub/publisher": "dev-master",
"shaarli/netscape-bookmark-parser": "^2.1", "shaarli/netscape-bookmark-parser": "^3.0",
"slim/slim": "^3.0" "slim/slim": "^3.0"
}, },
"require-dev": { "require-dev": {
@ -58,6 +59,7 @@
"Shaarli\\Front\\Controller\\Admin\\": "application/front/controller/admin", "Shaarli\\Front\\Controller\\Admin\\": "application/front/controller/admin",
"Shaarli\\Front\\Controller\\Visitor\\": "application/front/controller/visitor", "Shaarli\\Front\\Controller\\Visitor\\": "application/front/controller/visitor",
"Shaarli\\Front\\Exception\\": "application/front/exceptions", "Shaarli\\Front\\Exception\\": "application/front/exceptions",
"Shaarli\\Helper\\": "application/helper",
"Shaarli\\Http\\": "application/http", "Shaarli\\Http\\": "application/http",
"Shaarli\\Legacy\\": "application/legacy", "Shaarli\\Legacy\\": "application/legacy",
"Shaarli\\Netscape\\": "application/netscape", "Shaarli\\Netscape\\": "application/netscape",

65
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "932b191006135ff8be495aa0b4ba7e09", "content-hash": "83852dec81e299a117a81206a5091472",
"packages": [ "packages": [
{ {
"name": "arthurhoaro/web-thumbnailer", "name": "arthurhoaro/web-thumbnailer",
@ -786,24 +786,25 @@
}, },
{ {
"name": "shaarli/netscape-bookmark-parser", "name": "shaarli/netscape-bookmark-parser",
"version": "v2.2.0", "version": "v3.0.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/shaarli/netscape-bookmark-parser.git", "url": "https://github.com/shaarli/netscape-bookmark-parser.git",
"reference": "432a010af2bb1832d6fbc4763e6b0100b980a1df" "reference": "d2321f30413944b2d0a9844bf8cc588c71ae6305"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/shaarli/netscape-bookmark-parser/zipball/432a010af2bb1832d6fbc4763e6b0100b980a1df", "url": "https://api.github.com/repos/shaarli/netscape-bookmark-parser/zipball/d2321f30413944b2d0a9844bf8cc588c71ae6305",
"reference": "432a010af2bb1832d6fbc4763e6b0100b980a1df", "reference": "d2321f30413944b2d0a9844bf8cc588c71ae6305",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"katzgrau/klogger": "~1.0", "katzgrau/klogger": "~1.0",
"php": ">=5.6" "php": ">=7.1"
}, },
"require-dev": { "require-dev": {
"phpunit/phpunit": "^5.0" "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
"squizlabs/php_codesniffer": "^3.5"
}, },
"type": "library", "type": "library",
"autoload": { "autoload": {
@ -839,9 +840,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/shaarli/netscape-bookmark-parser/issues", "issues": "https://github.com/shaarli/netscape-bookmark-parser/issues",
"source": "https://github.com/shaarli/netscape-bookmark-parser/tree/v2.2.0" "source": "https://github.com/shaarli/netscape-bookmark-parser/tree/v3.0.1"
}, },
"time": "2020-06-06T15:53:53+00:00" "time": "2020-11-03T12:27:58+00:00"
}, },
{ {
"name": "slim/slim", "name": "slim/slim",
@ -1713,12 +1714,12 @@
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/Roave/SecurityAdvisories.git", "url": "https://github.com/Roave/SecurityAdvisories.git",
"reference": "ba5d234b3a1559321b816b64aafc2ce6728799ff" "reference": "065a018d3b5c2c84a53db3347cca4e1b7fa362a6"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/ba5d234b3a1559321b816b64aafc2ce6728799ff", "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/065a018d3b5c2c84a53db3347cca4e1b7fa362a6",
"reference": "ba5d234b3a1559321b816b64aafc2ce6728799ff", "reference": "065a018d3b5c2c84a53db3347cca4e1b7fa362a6",
"shasum": "" "shasum": ""
}, },
"conflict": { "conflict": {
@ -1734,7 +1735,7 @@
"bagisto/bagisto": "<0.1.5", "bagisto/bagisto": "<0.1.5",
"barrelstrength/sprout-base-email": "<1.2.7", "barrelstrength/sprout-base-email": "<1.2.7",
"barrelstrength/sprout-forms": "<3.9", "barrelstrength/sprout-forms": "<3.9",
"baserproject/basercms": ">=4,<=4.3.6", "baserproject/basercms": ">=4,<=4.3.6|>=4.4,<4.4.1",
"bolt/bolt": "<3.7.1", "bolt/bolt": "<3.7.1",
"brightlocal/phpwhois": "<=4.2.5", "brightlocal/phpwhois": "<=4.2.5",
"buddypress/buddypress": "<5.1.2", "buddypress/buddypress": "<5.1.2",
@ -1818,6 +1819,7 @@
"magento/magento1ee": ">=1,<1.14.4.3", "magento/magento1ee": ">=1,<1.14.4.3",
"magento/product-community-edition": ">=2,<2.2.10|>=2.3,<2.3.2-p.2", "magento/product-community-edition": ">=2,<2.2.10|>=2.3,<2.3.2-p.2",
"marcwillmann/turn": "<0.3.3", "marcwillmann/turn": "<0.3.3",
"mediawiki/core": ">=1.31,<1.31.9|>=1.32,<1.32.4|>=1.33,<1.33.3|>=1.34,<1.34.3|>=1.34.99,<1.35",
"mittwald/typo3_forum": "<1.2.1", "mittwald/typo3_forum": "<1.2.1",
"monolog/monolog": ">=1.8,<1.12", "monolog/monolog": ">=1.8,<1.12",
"namshi/jose": "<2.2", "namshi/jose": "<2.2",
@ -1832,7 +1834,8 @@
"onelogin/php-saml": "<2.10.4", "onelogin/php-saml": "<2.10.4",
"oneup/uploader-bundle": "<1.9.3|>=2,<2.1.5", "oneup/uploader-bundle": "<1.9.3|>=2,<2.1.5",
"openid/php-openid": "<2.3", "openid/php-openid": "<2.3",
"openmage/magento-lts": "<19.4.6|>=20,<20.0.2", "openmage/magento-lts": "<19.4.8|>=20,<20.0.4",
"orchid/platform": ">=9,<9.4.4",
"oro/crm": ">=1.7,<1.7.4", "oro/crm": ">=1.7,<1.7.4",
"oro/platform": ">=1.7,<1.7.4", "oro/platform": ">=1.7,<1.7.4",
"padraic/humbug_get_contents": "<1.1.2", "padraic/humbug_get_contents": "<1.1.2",
@ -1867,8 +1870,8 @@
"scheb/two-factor-bundle": ">=0,<3.26|>=4,<4.11", "scheb/two-factor-bundle": ">=0,<3.26|>=4,<4.11",
"sensiolabs/connect": "<4.2.3", "sensiolabs/connect": "<4.2.3",
"serluck/phpwhois": "<=4.2.6", "serluck/phpwhois": "<=4.2.6",
"shopware/core": "<=6.3.1", "shopware/core": "<=6.3.2",
"shopware/platform": "<=6.3.1", "shopware/platform": "<=6.3.2",
"shopware/shopware": "<5.3.7", "shopware/shopware": "<5.3.7",
"silverstripe/admin": ">=1.0.3,<1.0.4|>=1.1,<1.1.1", "silverstripe/admin": ">=1.0.3,<1.0.4|>=1.1,<1.1.1",
"silverstripe/assets": ">=1,<1.4.7|>=1.5,<1.5.2", "silverstripe/assets": ">=1,<1.4.7|>=1.5,<1.5.2",
@ -1901,7 +1904,7 @@
"sylius/grid": ">=1,<1.1.19|>=1.2,<1.2.18|>=1.3,<1.3.13|>=1.4,<1.4.5|>=1.5,<1.5.1", "sylius/grid": ">=1,<1.1.19|>=1.2,<1.2.18|>=1.3,<1.3.13|>=1.4,<1.4.5|>=1.5,<1.5.1",
"sylius/grid-bundle": ">=1,<1.1.19|>=1.2,<1.2.18|>=1.3,<1.3.13|>=1.4,<1.4.5|>=1.5,<1.5.1", "sylius/grid-bundle": ">=1,<1.1.19|>=1.2,<1.2.18|>=1.3,<1.3.13|>=1.4,<1.4.5|>=1.5,<1.5.1",
"sylius/resource-bundle": "<1.3.14|>=1.4,<1.4.7|>=1.5,<1.5.2|>=1.6,<1.6.4", "sylius/resource-bundle": "<1.3.14|>=1.4,<1.4.7|>=1.5,<1.5.2|>=1.6,<1.6.4",
"sylius/sylius": "<1.3.16|>=1.4,<1.4.12|>=1.5,<1.5.9|>=1.6,<1.6.5", "sylius/sylius": "<1.6.9|>=1.7,<1.7.9|>=1.8,<1.8.3",
"symbiote/silverstripe-multivaluefield": ">=3,<3.0.99", "symbiote/silverstripe-multivaluefield": ">=3,<3.0.99",
"symbiote/silverstripe-versionedfiles": "<=2.0.3", "symbiote/silverstripe-versionedfiles": "<=2.0.3",
"symfony/cache": ">=3.1,<3.4.35|>=4,<4.2.12|>=4.3,<4.3.8", "symfony/cache": ">=3.1,<3.4.35|>=4,<4.2.12|>=4.3,<4.3.8",
@ -2018,7 +2021,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2020-10-08T21:02:27+00:00" "time": "2020-11-01T20:01:47+00:00"
}, },
{ {
"name": "sebastian/code-unit-reverse-lookup", "name": "sebastian/code-unit-reverse-lookup",
@ -2632,16 +2635,16 @@
}, },
{ {
"name": "squizlabs/php_codesniffer", "name": "squizlabs/php_codesniffer",
"version": "3.5.6", "version": "3.5.8",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/squizlabs/PHP_CodeSniffer.git", "url": "https://github.com/squizlabs/PHP_CodeSniffer.git",
"reference": "e97627871a7eab2f70e59166072a6b767d5834e0" "reference": "9d583721a7157ee997f235f327de038e7ea6dac4"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/e97627871a7eab2f70e59166072a6b767d5834e0", "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/9d583721a7157ee997f235f327de038e7ea6dac4",
"reference": "e97627871a7eab2f70e59166072a6b767d5834e0", "reference": "9d583721a7157ee997f235f327de038e7ea6dac4",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -2684,24 +2687,24 @@
"source": "https://github.com/squizlabs/PHP_CodeSniffer", "source": "https://github.com/squizlabs/PHP_CodeSniffer",
"wiki": "https://github.com/squizlabs/PHP_CodeSniffer/wiki" "wiki": "https://github.com/squizlabs/PHP_CodeSniffer/wiki"
}, },
"time": "2020-08-10T04:50:15+00:00" "time": "2020-10-23T02:01:07+00:00"
}, },
{ {
"name": "symfony/polyfill-ctype", "name": "symfony/polyfill-ctype",
"version": "v1.18.1", "version": "v1.20.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/polyfill-ctype.git", "url": "https://github.com/symfony/polyfill-ctype.git",
"reference": "1c302646f6efc070cd46856e600e5e0684d6b454" "reference": "f4ba089a5b6366e453971d3aad5fe8e897b37f41"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/1c302646f6efc070cd46856e600e5e0684d6b454", "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/f4ba089a5b6366e453971d3aad5fe8e897b37f41",
"reference": "1c302646f6efc070cd46856e600e5e0684d6b454", "reference": "f4ba089a5b6366e453971d3aad5fe8e897b37f41",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": ">=5.3.3" "php": ">=7.1"
}, },
"suggest": { "suggest": {
"ext-ctype": "For best performance" "ext-ctype": "For best performance"
@ -2709,7 +2712,7 @@
"type": "library", "type": "library",
"extra": { "extra": {
"branch-alias": { "branch-alias": {
"dev-master": "1.18-dev" "dev-main": "1.20-dev"
}, },
"thanks": { "thanks": {
"name": "symfony/polyfill", "name": "symfony/polyfill",
@ -2747,7 +2750,7 @@
"portable" "portable"
], ],
"support": { "support": {
"source": "https://github.com/symfony/polyfill-ctype/tree/v1.18.0" "source": "https://github.com/symfony/polyfill-ctype/tree/v1.20.0"
}, },
"funding": [ "funding": [
{ {
@ -2763,7 +2766,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2020-07-14T12:35:20+00:00" "time": "2020-10-23T14:02:19+00:00"
}, },
{ {
"name": "theseer/tokenizer", "name": "theseer/tokenizer",

View file

@ -1,3 +1,4 @@
# Docker # Docker
[Docker](https://docs.docker.com/get-started/overview/) is an open platform for developing, shipping, and running applications [Docker](https://docs.docker.com/get-started/overview/) is an open platform for developing, shipping, and running applications
@ -113,9 +114,11 @@ $ mkdir shaarli && cd shaarli
# Download the latest version of Shaarli's docker-compose.yml # Download the latest version of Shaarli's docker-compose.yml
$ curl -L https://raw.githubusercontent.com/shaarli/Shaarli/latest/docker-compose.yml -o docker-compose.yml $ curl -L https://raw.githubusercontent.com/shaarli/Shaarli/latest/docker-compose.yml -o docker-compose.yml
# Create the .env file and fill in your VPS and domain information # Create the .env file and fill in your VPS and domain information
# (replace <MY_SHAARLI_DOMAIN> and <MY_CONTACT_EMAIL> with your actual information) # (replace <shaarli.mydomain.org>, <admin@mydomain.org> and <latest> with your actual information)
$ echo 'SHAARLI_VIRTUAL_HOST=shaarli.mydomain.org' > .env $ echo 'SHAARLI_VIRTUAL_HOST=shaarli.mydomain.org' > .env
$ echo 'SHAARLI_LETSENCRYPT_EMAIL=admin@mydomain.org' >> .env $ echo 'SHAARLI_LETSENCRYPT_EMAIL=admin@mydomain.org' >> .env
# Available Docker tags can be found at https://hub.docker.com/r/shaarli/shaarli/tags
$ echo 'SHAARLI_DOCKER_TAG=latest' >> .env
# Pull the Docker images # Pull the Docker images
$ docker-compose pull $ docker-compose pull
# Run! # Run!

View file

@ -73,7 +73,7 @@ var_dump(getInfo($baseUrl, $secret));
### Authentication ### Authentication
- All requests to Shaarli's API must include a **JWT token** to verify their authenticity. - All requests to Shaarli's API must include a **JWT token** to verify their authenticity.
- This token must be included as an HTTP header called `Authentication: Bearer <jwt token>`. - This token must be included as an HTTP header called `Authorization: Bearer <jwt token>`.
- JWT tokens are composed by three parts, separated by a dot `.` and encoded in base64: - JWT tokens are composed by three parts, separated by a dot `.` and encoded in base64:
``` ```

View file

@ -193,19 +193,24 @@ sudo nano /etc/apache2/sites-available/shaarli.mydomain.org.conf
Require all granted Require all granted
</Directory> </Directory>
<LocationMatch "/\."> # BE CAREFUL: directives order matter!
# Prevent accessing dotfiles
RedirectMatch 404 ".*"
</LocationMatch>
<LocationMatch "\.(?:ico|css|js|gif|jpe?g|png)$"> <FilesMatch ".*\.(?!(ico|css|js|gif|jpe?g|png|ttf|oet|woff2?)$)[^\.]*$">
Require all denied
</FilesMatch>
<Files "index.php">
Require all granted
</Files>
<FilesMatch "\.(?:ico|css|js|gif|jpe?g|png|ttf|oet|woff2)$">
# allow client-side caching of static files # allow client-side caching of static files
Header set Cache-Control "max-age=2628000, public, must-revalidate, proxy-revalidate" Header set Cache-Control "max-age=2628000, public, must-revalidate, proxy-revalidate"
</LocationMatch> </FilesMatch>
# serve the Shaarli favicon from its custom location # serve the Shaarli favicon from its custom location
Alias favicon.ico /var/www/shaarli.mydomain.org/images/favicon.ico Alias favicon.ico /var/www/shaarli.mydomain.org/images/favicon.ico
</VirtualHost> </VirtualHost>
``` ```
@ -296,7 +301,7 @@ server {
location / { location / {
# default index file when no file URI is requested # default index file when no file URI is requested
index index.php; index index.php;
try_files $uri /index.php$is_args$args; try_files _ /index.php$is_args$args;
} }
location ~ (index)\.php$ { location ~ (index)\.php$ {
@ -309,20 +314,9 @@ server {
include fastcgi.conf; include fastcgi.conf;
} }
location ~ \.php$ { location ~ /doc/html/ {
# deny access to all other PHP scripts default_type "text/html";
# disable this if you host other PHP applications on the same virtualhost try_files $uri $uri/ $uri.html =404;
deny all;
}
location ~ /\. {
# deny access to dotfiles
deny all;
}
location ~ ~$ {
# deny access to temp editor files, e.g. "script.php~"
deny all;
} }
location = /favicon.ico { location = /favicon.ico {
@ -331,13 +325,12 @@ server {
} }
# allow client-side caching of static files # allow client-side caching of static files
location ~* \.(?:ico|css|js|gif|jpe?g|png)$ { location ~* \.(?:ico|css|js|gif|jpe?g|png|ttf|oet|woff2?)$ {
expires max; expires max;
add_header Cache-Control "public, must-revalidate, proxy-revalidate"; add_header Cache-Control "public, must-revalidate, proxy-revalidate";
# HTTP 1.0 compatibility # HTTP 1.0 compatibility
add_header Pragma public; add_header Pragma public;
} }
} }
``` ```

View file

@ -74,6 +74,7 @@ Some settings can be configured directly from a web browser by accesing the `Too
"timezone": "Europe\/Paris", "timezone": "Europe\/Paris",
"title": "My Shaarli", "title": "My Shaarli",
"header_link": "?" "header_link": "?"
"tags_separator": " "
}, },
"dev": { "dev": {
"debug": false, "debug": false,
@ -150,8 +151,10 @@ _These settings should not be edited_
- **timezone**: See [the list of supported timezones](http://php.net/manual/en/timezones.php). - **timezone**: See [the list of supported timezones](http://php.net/manual/en/timezones.php).
- **enabled_plugins**: List of enabled plugins. - **enabled_plugins**: List of enabled plugins.
- **default_note_title**: Default title of a new note. - **default_note_title**: Default title of a new note.
- **enable_async_metadata** (boolean): Retrieve external bookmark metadata asynchronously to prevent bookmark creation slowdown.
- **retrieve_description** (boolean): If set to true, for every new Shaare Shaarli will try to retrieve the description and keywords from the HTML meta tags. - **retrieve_description** (boolean): If set to true, for every new Shaare Shaarli will try to retrieve the description and keywords from the HTML meta tags.
- **root_url**: Overrides automatic discovery of Shaarli instance's URL (e.g.) `https://sub.domain.tld/shaarli-folder/`. - **root_url**: Overrides automatic discovery of Shaarli instance's URL (e.g.) `https://sub.domain.tld/shaarli-folder/`.
- **tags_separator**: Defines your tags separator (default: whitespace).
### Security ### Security
@ -163,6 +166,22 @@ _These settings should not be edited_
- **trusted_proxies**: List of trusted IP which won't be banned after failed login attemps. Useful if Shaarli is behind a reverse proxy. - **trusted_proxies**: List of trusted IP which won't be banned after failed login attemps. Useful if Shaarli is behind a reverse proxy.
- **allowed_protocols**: List of allowed protocols in shaare URLs or markdown-rendered descriptions. Useful if you want to store `javascript:` links (bookmarklets) in Shaarli (default: `["ftp", "ftps", "magnet"]`). - **allowed_protocols**: List of allowed protocols in shaare URLs or markdown-rendered descriptions. Useful if you want to store `javascript:` links (bookmarklets) in Shaarli (default: `["ftp", "ftps", "magnet"]`).
### Formatter
Single string value. Default available:
- `default`: supports line breaks, URL and hashtag auto-links.
- `markdown`: supports [Markdown](https://daringfireball.net/projects/markdown/syntax).
- `markdownExtra`: adds [extra](https://michelf.ca/projects/php-markdown/extra/) flavor to Markdown.
### Formatter Settings
Additional settings applied to formatters.
#### default
- **autolink**: boolean to enable or disable automatic linkification of URL and hashtags.
### Resources ### Resources
- **data_dir**: Data directory. - **data_dir**: Data directory.

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