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

@ -17,27 +17,13 @@ http {
index index.html index.php;
server {
listen 80;
root /var/www/shaarli;
listen 80;
root /var/www/shaarli;
access_log /var/log/nginx/shaarli.access.log;
error_log /var/log/nginx/shaarli.error.log;
location ~ /\. {
# 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)$ {
location ~* \.(?:ico|css|js|gif|jpe?g|png|ttf|oet|woff2?)$ {
# cache static assets
expires max;
add_header Pragma public;
@ -49,25 +35,25 @@ http {
alias /var/www/shaarli/images/favicon.ico;
}
location / {
# Slim - rewrite URLs
try_files $uri /index.php$is_args$args;
location /doc/html/ {
default_type "text/html";
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)
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_split_path_info ^(index.php)(/.+)$;
# filter and proxy PHP requests to PHP-FPM
fastcgi_pass unix:/var/run/php-fpm.sock;
fastcgi_index index.php;
include fastcgi.conf;
}
location ~ \.php$ {
# deny access to all other PHP scripts
deny all;
}
}
}

View file

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

View file

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

View file

@ -49,6 +49,10 @@ cache:
directories:
- $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/update composer and php dependencies
- composer config --unset platform && composer config platform.php $TRAVIS_PHP_VERSION
@ -60,4 +64,5 @@ before_script:
script:
- make clean
- make check_permissions
- make code_sniffer
- make all_tests

View file

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

View file

@ -26,7 +26,7 @@ RUN cd shaarli \
# Stage 4:
# - Shaarli image
FROM alpine:3.8
FROM alpine:3.12
LABEL maintainer="Shaarli Community"
RUN apk --update --no-cache add \
@ -44,6 +44,7 @@ RUN apk --update --no-cache add \
php7-openssl \
php7-session \
php7-xml \
php7-simplexml \
php7-zlib \
s6

View file

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

View file

@ -27,10 +27,6 @@ PHPCS := $(BIN)/phpcs
code_sniffer:
@$(PHPCS)
### - errors filtered by coding standard: PEAR, PSR1, PSR2, Zend...
PHPCS_%:
@$(PHPCS) --report-full --report-width=200 --standard=$*
### - errors by Git author
code_sniffer_blame:
@$(PHPCS) --report-gitblame
@ -175,6 +171,7 @@ translate:
eslint:
@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/common/js/
### Run CSSLint check against Shaarli's SCSS files
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/travis/shaarli/Shaarli/stable.svg?label=stable)](https://travis-ci.org/shaarli/Shaarli)
&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)
&bull;
[![](https://img.shields.io/badge/master-v0.12.x-blue.svg)](https://github.com/shaarli/Shaarli)

View file

@ -1,9 +1,11 @@
<?php
namespace Shaarli;
use DateTime;
use Exception;
use Shaarli\Bookmark\Bookmark;
use Shaarli\Helper\FileUtils;
/**
* Class History
@ -30,27 +32,27 @@ class History
/**
* @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.
*/
const UPDATED = 'UPDATED';
public const UPDATED = 'UPDATED';
/**
* @var string Action key: a link has been deleted.
*/
const DELETED = 'DELETED';
public const DELETED = 'DELETED';
/**
* @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.
*/
const IMPORT = 'IMPORT';
public const IMPORT = 'IMPORT';
/**
* @var string History file path.

View file

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

View file

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

View file

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

View file

@ -1,24 +1,27 @@
<?php
/**
* Shaarli utilities
*/
/**
* Logs a message to a text file
* Format log using provided data.
*
* The log format is compatible with fail2ban.
* @param string $message the message to log
* @param string|null $clientIp the client's remote IPv4/IPv6 address
*
* @param string $logFile where to write the logs
* @param string $clientIp the client's remote IPv4/IPv6 address
* @param string $message the message to log
* @return string Formatted message to log
*/
function logm($logFile, $clientIp, $message)
function format_log(string $message, string $clientIp = null): string
{
file_put_contents(
$logFile,
date('Y/m/d H:i:s').' - '.$clientIp.' - '.strval($message).PHP_EOL,
FILE_APPEND
);
$out = $message;
if (!empty($clientIp)) {
// 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)) {
$out = array();
$out = [];
foreach ($input as $key => $value) {
$out[escape($key)] = escape($value);
}
@ -161,7 +164,7 @@ function checkDateFormat($format, $string)
*
* @return string $referer - final referer.
*/
function generateLocation($referer, $host, $loopTerms = array())
function generateLocation($referer, $host, $loopTerms = [])
{
$finalReferer = './?';
@ -194,7 +197,7 @@ function generateLocation($referer, $host, $loopTerms = array())
function autoLocale($headerLocale)
{
// 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 (preg_match_all('/([a-z]{2,3})[-_]?([a-z]{2})?,?/i', $headerLocale, $matches, PREG_SET_ORDER)) {
$attempts = [];
@ -324,6 +327,23 @@ function format_date($date, $time = true, $intl = true)
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.
*
@ -357,13 +377,15 @@ function return_bytes($val)
return $val;
}
$val = trim($val);
$last = strtolower($val[strlen($val)-1]);
$last = strtolower($val[strlen($val) - 1]);
$val = intval(substr($val, 0, -1));
switch ($last) {
case 'g':
$val *= 1024;
// do no break in order 1024^2 for each unit
case 'm':
$val *= 1024;
// do no break in order 1024^2 for each unit
case 'k':
$val *= 1024;
}
@ -452,14 +474,28 @@ function alphabetical_sort(&$data, $reverse = false, $byKeys = false)
* Wrapper function for translation which match the API
* of gettext()/_() and ngettext().
*
* @param string $text Text to translate.
* @param string $nText The plural message ID.
* @param int $nb The number of items for plural forms.
* @param string $domain The domain where the translation is stored (default: shaarli).
* @param string $text Text to translate.
* @param string $nText The plural message ID.
* @param int $nb The number of items for plural forms.
* @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.
*/
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
namespace Shaarli\Api;
use malkusch\lock\mutex\FlockMutex;
@ -108,7 +109,8 @@ class ApiMiddleware
*/
protected function checkToken($request)
{
if (!$request->hasHeader('Authorization')
if (
!$request->hasHeader('Authorization')
&& !isset($this->container->environment['REDIRECT_HTTP_AUTHORIZATION'])
) {
throw new ApiAuthorizationException('JWT token not provided');

View file

@ -1,4 +1,5 @@
<?php
namespace Shaarli\Api;
use Shaarli\Api\Exceptions\ApiAuthorizationException;
@ -27,7 +28,7 @@ class ApiUtils
throw new ApiAuthorizationException('Malformed JWT token');
}
$genSign = Base64Url::encode(hash_hmac('sha512', $parts[0] .'.'. $parts[1], $secret, true));
$genSign = Base64Url::encode(hash_hmac('sha512', $parts[0] . '.' . $parts[1], $secret, true));
if ($parts[2] != $genSign) {
throw new ApiAuthorizationException('Invalid JWT signature');
}
@ -42,7 +43,8 @@ class ApiUtils
throw new ApiAuthorizationException('Invalid JWT payload');
}
if (empty($payload->iat)
if (
empty($payload->iat)
|| $payload->iat > time()
|| time() - $payload->iat > ApiMiddleware::$TOKEN_DURATION
) {
@ -89,13 +91,17 @@ class ApiUtils
* If no URL is provided, it will generate a local note URL.
* If no title is provided, it will use the URL as title.
*
* @param array|null $input Request Link.
* @param bool $defaultPrivate Setting defined if a bookmark is private by default.
* @param array|null $input Request Link.
* @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.
*/
public static function buildBookmarkFromRequest(?array $input, bool $defaultPrivate): Bookmark
{
public static function buildBookmarkFromRequest(
?array $input,
bool $defaultPrivate,
string $tagsSeparator
): Bookmark {
$bookmark = new Bookmark();
$url = ! empty($input['url']) ? cleanup_url($input['url']) : '';
if (isset($input['private'])) {
@ -107,6 +113,15 @@ class ApiUtils
$bookmark->setTitle(! empty($input['title']) ? $input['title'] : '');
$bookmark->setUrl($url);
$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->setPrivate($private);

View file

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

View file

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

View file

@ -117,9 +117,14 @@ class Links extends ApiController
public function postLink($request, $response)
{
$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
if (! empty($bookmark->getUrl())
if (
! empty($bookmark->getUrl())
&& ! empty($dup = $this->bookmarkService->findByUrl($bookmark->getUrl()))
) {
return $response->withJson(
@ -131,7 +136,7 @@ class Links extends ApiController
$this->bookmarkService->add($bookmark);
$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)
->withJson($out, 201, $this->jsonStyle);
}
@ -157,9 +162,14 @@ class Links extends ApiController
$index = index_url($this->ci['environment']);
$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
if (! empty($requestBookmark->getUrl())
if (
! empty($requestBookmark->getUrl())
&& ! empty($dup = $this->bookmarkService->findByUrl($requestBookmark->getUrl()))
&& $dup->getId() != $id
) {

View file

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

View file

@ -44,7 +44,7 @@ abstract class ApiException extends \Exception
}
return [
'message' => $this->getMessage(),
'stacktrace' => get_class($this) .': '. $this->getTraceAsString()
'stacktrace' => get_class($this) . ': ' . $this->getTraceAsString()
];
}

View file

@ -19,7 +19,7 @@ use Shaarli\Bookmark\Exception\InvalidBookmarkException;
class Bookmark
{
/** @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 */
protected $id;
@ -60,11 +60,13 @@ class Bookmark
/**
* Initialize a link from array data. Especially useful to create a Bookmark from former link storage format.
*
* @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
*/
public function fromArray(array $data): Bookmark
public function fromArray(array $data, string $tagsSeparator = ' '): Bookmark
{
$this->id = $data['id'] ?? null;
$this->shortUrl = $data['shorturl'] ?? null;
@ -77,7 +79,7 @@ class Bookmark
if (is_array($data['tags'])) {
$this->tags = $data['tags'];
} else {
$this->tags = preg_split('/\s+/', $data['tags'] ?? '', -1, PREG_SPLIT_NO_EMPTY);
$this->tags = tags_str2array($data['tags'] ?? '', $tagsSeparator);
}
if (! empty($data['updated'])) {
$this->updated = $data['updated'];
@ -104,7 +106,8 @@ class Bookmark
*/
public function validate(): void
{
if ($this->id === null
if (
$this->id === null
|| ! is_int($this->id)
|| empty($this->shortUrl)
|| empty($this->created)
@ -112,7 +115,7 @@ class Bookmark
throw new InvalidBookmarkException($this);
}
if (empty($this->url)) {
$this->url = '/shaare/'. $this->shortUrl;
$this->url = '/shaare/' . $this->shortUrl;
}
if (empty($this->title)) {
$this->title = $this->url;
@ -348,7 +351,12 @@ class 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;
}
@ -377,6 +385,24 @@ class Bookmark
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.
*
@ -402,11 +428,13 @@ class 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 @@ class Bookmark
* - trailing dash in tags will be removed
*
* @param string|null $tags
* @param string $separator Tags separator loaded from the config file.
*
* @return $this
*/
public function setTagsString(?string $tags): Bookmark
public function setTagsString(?string $tags, string $separator = ' '): Bookmark
{
// Remove first '-' char in tags.
$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;
$this->setTags(tags_str2array($tags, $separator));
return $this;
}
@ -489,7 +511,7 @@ class Bookmark
*/
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);
}
}
@ -501,7 +523,7 @@ class Bookmark
*/
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]);
$this->tags = array_values($this->tags);
}

View file

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

View file

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

View file

@ -6,6 +6,7 @@ namespace Shaarli\Bookmark;
use Exception;
use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
use Shaarli\Config\ConfigManager;
/**
* Class LinkFilter.
@ -58,12 +59,16 @@ class BookmarkFilter
*/
private $bookmarks;
/** @var ConfigManager */
protected $conf;
/**
* @param Bookmark[] $bookmarks initialization.
*/
public function __construct($bookmarks)
public function __construct($bookmarks, ConfigManager $conf)
{
$this->bookmarks = $bookmarks;
$this->conf = $conf;
}
/**
@ -107,10 +112,14 @@ class BookmarkFilter
$filtered = $this->bookmarks;
}
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])) {
$filtered = (new BookmarkFilter($filtered))->filterFulltext($request[1], $visibility);
$filtered = (new BookmarkFilter($filtered, $this->conf))
->filterFulltext($request[1], $visibility)
;
}
return $filtered;
case self::$FILTER_TEXT:
@ -141,7 +150,7 @@ class BookmarkFilter
return $this->bookmarks;
}
$out = array();
$out = [];
foreach ($this->bookmarks as $key => $value) {
if ($value->isPrivate() && $visibility === 'private') {
$out[$key] = $value;
@ -280,8 +289,9 @@ class BookmarkFilter
*
* @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);
if (!$len || $tag === "-" || $tag === "*") {
// nothing to search, return empty regex
@ -295,12 +305,13 @@ class BookmarkFilter
$i = 0; // start at first character
$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
for (; $i < $len; $i++) {
if ($tag[$i] === '*') {
// placeholder found
$regex .= '[^ ]*?';
$regex .= '[^' . $tagsSeparator . ']*?';
} else {
// regular characters
$offset = strpos($tag, '*', $i);
@ -316,7 +327,8 @@ class BookmarkFilter
$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;
}
@ -334,14 +346,15 @@ class BookmarkFilter
*/
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)
$inputTags = $tags;
if (!is_array($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
return $this->noFilter($visibility);
}
@ -358,7 +371,7 @@ class BookmarkFilter
}
// build regex from all tags
$re = '/^' . implode(array_map("self::tag2regex", $inputTags)) . '.*$/';
$re = '/^' . implode(array_map([$this, 'tag2regex'], $inputTags)) . '.*$/';
if (!$casesensitive) {
// make regex case insensitive
$re .= 'i';
@ -378,10 +391,11 @@ class BookmarkFilter
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) {
// description given and at least one possible tag found
$descTags = array();
$descTags = [];
// find all tags in the form of #tag in the description
preg_match_all(
'/(?<![' . self::$HASHTAG_CHARS . '])#([' . self::$HASHTAG_CHARS . ']+?)\b/sm',
@ -390,9 +404,9 @@ class BookmarkFilter
);
if (count($descTags[1])) {
// 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
if (!preg_match($re, $search)) {
// this entry does _not_ match our regex
@ -422,7 +436,7 @@ class BookmarkFilter
}
}
if (empty(trim($link->getTagsString()))) {
if (empty($link->getTags())) {
$filtered[$key] = $link;
}
}
@ -537,10 +551,11 @@ class BookmarkFilter
*/
protected function buildFullTextSearchableLink(Bookmark $link, array &$lengths): string
{
$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->getUrl(), MB_CASE_LOWER, 'UTF-8') .'\\';
$content .= mb_convert_case($link->getTagsString(), MB_CASE_LOWER, 'UTF-8') .'\\';
$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->getDescription(), MB_CASE_LOWER, 'UTF-8') . '\\';
$content .= mb_convert_case($link->getUrl(), MB_CASE_LOWER, 'UTF-8') . '\\';
$content .= mb_convert_case($tagString, MB_CASE_LOWER, 'UTF-8') . '\\';
$lengths['title'] = ['start' => 0, 'end' => mb_strlen($link->getTitle())];
$nextField = $lengths['title']['end'] + 1;
@ -548,7 +563,7 @@ class BookmarkFilter
$nextField = $lengths['description']['end'] + 1;
$lengths['url'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($link->getUrl())];
$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;
}

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Shaarli\Bookmark;
use malkusch\lock\exception\LockAcquireException;
use malkusch\lock\mutex\Mutex;
use malkusch\lock\mutex\NoMutex;
use Shaarli\Bookmark\Exception\DatastoreNotInitializedException;
@ -80,7 +81,7 @@ class BookmarkIO
}
$content = null;
$this->mutex->synchronized(function () use (&$content) {
$this->synchronized(function () use (&$content) {
$content = file_get_contents($this->datastore);
});
@ -112,18 +113,35 @@ class BookmarkIO
if (is_file($this->datastore) && !is_writeable($this->datastore)) {
// The datastore exists but is not writeable
throw new NotWritableDataStoreException($this->datastore);
} else if (!is_file($this->datastore) && !is_writeable(dirname($this->datastore))) {
} elseif (!is_file($this->datastore) && !is_writeable(dirname($this->datastore))) {
// The datastore does not exist and its parent directory is not writeable
throw new NotWritableDataStoreException(dirname($this->datastore));
}
$data = self::$phpPrefix.base64_encode(gzdeflate(serialize($links))).self::$phpSuffix;
$data = self::$phpPrefix . base64_encode(gzdeflate(serialize($links))) . self::$phpSuffix;
$this->mutex->synchronized(function () use ($data) {
$this->synchronized(function () use ($data) {
file_put_contents(
$this->datastore,
$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 @@ namespace Shaarli\Bookmark;
* To prevent data corruption, it does not overwrite existing bookmarks,
* 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
*/
class BookmarkInitializer
@ -36,10 +39,10 @@ class BookmarkInitializer
public function initialize(): void
{
$bookmark = new Bookmark();
$bookmark->setTitle('quicksilver (loop) on Vimeo ' . t('(private bookmark with thumbnail demo)'));
$bookmark->setUrl('https://vimeo.com/153493904');
$bookmark->setTitle('Calm Jazz Music - YouTube ' . t('(private bookmark with thumbnail demo)'));
$bookmark->setUrl('https://www.youtube.com/watch?v=DVEUcbPkb-c');
$bookmark->setDescription(t(
'Shaarli will automatically pick up the thumbnail for links to a variety of websites.
'Shaarli will automatically pick up the thumbnail for links to a variety of websites.
Explore your new Shaarli instance by trying out controls and menus.
Visit the project on [Github](https://github.com/shaarli/Shaarli) or [the documentation](https://shaarli.readthedocs.io/en/master/) to learn more about Shaarli.
@ -54,7 +57,7 @@ Now you can edit or delete the default shaares.
$bookmark = new Bookmark();
$bookmark->setTitle(t('Note: Shaare descriptions'));
$bookmark->setDescription(t(
'Adding a shaare without entering a URL creates a text-only "note" post such as this one.
'Adding a shaare without entering a URL creates a text-only "note" post such as this one.
This note is private, so you are the only one able to see it while logged in.
You can use this to keep notes, post articles, code snippets, and much more.
@ -91,7 +94,7 @@ Markdown also supports tables:
'Shaarli - ' . t('The personal, minimalist, super-fast, database free, bookmarking service')
);
$bookmark->setDescription(t(
'Welcome to Shaarli!
'Welcome to Shaarli!
Shaarli allows you to bookmark your favorite pages, and share them with others or store them privately.
You can add a description to your bookmarks, such as this one, and tag them.

View file

@ -20,13 +20,14 @@ interface BookmarkServiceInterface
/**
* Find a bookmark by hash
*
* @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
*
* @throws \Exception
*/
public function findByHash(string $hash): Bookmark;
public function findByHash(string $hash, string $privateKey = null);
/**
* @param $url
@ -155,22 +156,29 @@ interface BookmarkServiceInterface
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[] list of shaare found.
*
* @throws BookmarkNotFoundException
* @return Bookmark|null Found Bookmark or null if the datastore is empty.
*/
public function filterDay(string $request);
public function getLatest(): ?Bookmark;
/**
* Creates the default database after a fresh install.

View file

@ -67,17 +67,20 @@ function html_extract_tag($tag, $html)
$propertiesKey = ['property', 'name', 'itemprop'];
$properties = implode('|', $propertiesKey);
// We need a OR here to accept either 'property=og:noquote' or 'property="og:unrelated og:my-tag"'
$orCondition = '["\']?(?:og:)?'. $tag .'["\']?|["\'][^\'"]*?(?:og:)?' . $tag . '[^\'"]*?[\'"]';
// Try to retrieve OpenGraph image.
$ogRegex = '#<meta[^>]+(?:'. $properties .')=(?:'. $orCondition .')[^>]*content=["\'](.*?)["\'].*?>#';
$orCondition = '["\']?(?:og:)?' . $tag . '["\']?|["\'][^\'"]*?(?:og:)?' . $tag . '[^\'"]*?[\'"]';
// Support quotes in double quoted content, and the other way around
$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)
// 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
) {
return $matches[1];
return $matches[2];
}
return false;
@ -116,7 +119,7 @@ function hashtag_autolink($description, $indexUrl = '')
* \p{Mn} - any non marking space (accents, umlauts, etc)
*/
$regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui';
$replacement = '$1<a href="'. $indexUrl .'./add-tag/$2" title="Hashtag $2">#$2</a>';
$replacement = '$1<a href="' . $indexUrl . './add-tag/$2" title="Hashtag $2">#$2</a>';
return preg_replace($regex, $replacement, $description);
}
@ -138,12 +141,17 @@ function space2nbsp($text)
*
* @param string $description shaare's description.
* @param string $indexUrl URL to Shaarli's index.
* @param bool $autolink Turn on/off automatic linkifications of URLs and hashtags
*
* @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] === '?';
}
/**
* 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
namespace Shaarli\Bookmark\Exception;
use Exception;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Shaarli\Container;
use malkusch\lock\mutex\FlockMutex;
use Psr\Log\LoggerInterface;
use Shaarli\Bookmark\BookmarkFileService;
use Shaarli\Bookmark\BookmarkServiceInterface;
use Shaarli\Config\ConfigManager;
@ -14,6 +15,7 @@ use Shaarli\Front\Controller\Visitor\ErrorController;
use Shaarli\Front\Controller\Visitor\ErrorNotFoundController;
use Shaarli\History;
use Shaarli\Http\HttpAccess;
use Shaarli\Http\MetadataRetriever;
use Shaarli\Netscape\NetscapeBookmarkUtils;
use Shaarli\Plugin\PluginManager;
use Shaarli\Render\PageBuilder;
@ -48,6 +50,12 @@ class ContainerBuilder
/** @var LoginManager */
protected $login;
/** @var PluginManager */
protected $pluginManager;
/** @var LoggerInterface */
protected $logger;
/** @var string|null */
protected $basePath = null;
@ -55,12 +63,16 @@ class ContainerBuilder
ConfigManager $conf,
SessionManager $session,
CookieManager $cookieManager,
LoginManager $login
LoginManager $login,
PluginManager $pluginManager,
LoggerInterface $logger
) {
$this->conf = $conf;
$this->session = $session;
$this->login = $login;
$this->cookieManager = $cookieManager;
$this->pluginManager = $pluginManager;
$this->logger = $logger;
}
public function build(): ShaarliContainer
@ -71,11 +83,10 @@ class ContainerBuilder
$container['sessionManager'] = $this->session;
$container['cookieManager'] = $this->cookieManager;
$container['loginManager'] = $this->login;
$container['pluginManager'] = $this->pluginManager;
$container['logger'] = $this->logger;
$container['basePath'] = $this->basePath;
$container['plugins'] = function (ShaarliContainer $container): PluginManager {
return new PluginManager($container->conf);
};
$container['history'] = function (ShaarliContainer $container): History {
return new History($container->conf->get('resource.history'));
@ -90,24 +101,21 @@ class ContainerBuilder
);
};
$container['metadataRetriever'] = function (ShaarliContainer $container): MetadataRetriever {
return new MetadataRetriever($container->conf, $container->httpAccess);
};
$container['pageBuilder'] = function (ShaarliContainer $container): PageBuilder {
return new PageBuilder(
$container->conf,
$container->sessionManager->getSession(),
$container->logger,
$container->bookmarkService,
$container->sessionManager->generateToken(),
$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 {
return new FormatterFactory(
$container->conf,
@ -145,7 +153,7 @@ class ContainerBuilder
$container['updater'] = function (ShaarliContainer $container): Updater {
return new Updater(
UpdaterUtils::read_updates_file($container->conf->get('resource.updates')),
UpdaterUtils::readUpdatesFile($container->conf->get('resource.updates')),
$container->bookmarkService,
$container->conf,
$container->loginManager->isLoggedIn()

View file

@ -4,12 +4,14 @@ declare(strict_types=1);
namespace Shaarli\Container;
use Psr\Log\LoggerInterface;
use Shaarli\Bookmark\BookmarkServiceInterface;
use Shaarli\Config\ConfigManager;
use Shaarli\Feed\FeedBuilder;
use Shaarli\Formatter\FormatterFactory;
use Shaarli\History;
use Shaarli\Http\HttpAccess;
use Shaarli\Http\MetadataRetriever;
use Shaarli\Netscape\NetscapeBookmarkUtils;
use Shaarli\Plugin\PluginManager;
use Shaarli\Render\PageBuilder;
@ -35,6 +37,8 @@ use Slim\Container;
* @property History $history
* @property HttpAccess $httpAccess
* @property LoginManager $loginManager
* @property LoggerInterface $logger
* @property MetadataRetriever $metadataRetriever
* @property NetscapeBookmarkUtils $netscapeBookmarkUtils
* @property callable $notFoundHandler Overrides default Slim exception display
* @property PageBuilder $pageBuilder

View file

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

View file

@ -1,34 +1,43 @@
<?php
declare(strict_types=1);
namespace Shaarli\Feed;
use DatePeriod;
/**
* Simple cache system, mainly for the RSS/ATOM feeds
*/
class CachedPage
{
// Directory containing page caches
private $cacheDir;
/** Directory containing page caches */
protected $cacheDir;
// Should this URL be cached (boolean)?
private $shouldBeCached;
/** Should this URL be cached (boolean)? */
protected $shouldBeCached;
// Name of the cache file for this URL
private $filename;
/** Name of the cache file for this URL */
protected $filename;
/** @var DatePeriod|null Optionally specify a period of time for cache validity */
protected $validityPeriod;
/**
* Creates a new CachedPage
*
* @param string $cacheDir page cache directory
* @param string $url page URL
* @param bool $shouldBeCached whether this page needs to be cached
* @param string $cacheDir page cache directory
* @param string $url page URL
* @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
$this->cacheDir = $cacheDir;
$this->filename = $this->cacheDir . '/' . sha1($url) . '.cache';
$this->shouldBeCached = $shouldBeCached;
$this->validityPeriod = $validityPeriod;
}
/**
@ -41,10 +50,20 @@ class CachedPage
if (!$this->shouldBeCached) {
return null;
}
if (is_file($this->filename)) {
return file_get_contents($this->filename);
if (!is_file($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);
}
/**

View file

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

View file

@ -12,8 +12,8 @@ namespace Shaarli\Formatter;
*/
class BookmarkDefaultFormatter extends BookmarkFormatter
{
const SEARCH_HIGHLIGHT_OPEN = '|@@HIGHLIGHT';
const SEARCH_HIGHLIGHT_CLOSE = 'HIGHLIGHT@@|';
protected const SEARCH_HIGHLIGHT_OPEN = '|@@HIGHLIGHT';
protected const SEARCH_HIGHLIGHT_CLOSE = 'HIGHLIGHT@@|';
/**
* @inheritdoc
@ -46,8 +46,13 @@ class BookmarkDefaultFormatter extends BookmarkFormatter
$bookmark->getDescription() ?? '',
$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 @@ class BookmarkDefaultFormatter extends BookmarkFormatter
*/
protected function formatTagListHtml($bookmark)
{
$tagsSeparator = $this->conf->get('general.tags_separator', ' ');
if (empty($bookmark->getAdditionalContentEntry('search_highlight')['tags'])) {
return $this->formatTagList($bookmark);
}
$tags = $this->tokenizeSearchHighlightField(
$bookmark->getTagsString(),
$bookmark->getTagsString($tagsSeparator),
$bookmark->getAdditionalContentEntry('search_highlight')['tags']
);
$tags = $this->filterTagList(explode(' ', $tags));
$tags = $this->filterTagList(tags_str2array($tags, $tagsSeparator));
$tags = escape($tags);
$tags = $this->replaceTokensArray($tags);
@ -83,7 +89,7 @@ class BookmarkDefaultFormatter extends BookmarkFormatter
*/
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 @@ abstract class BookmarkFormatter
*/
protected function formatTagString($bookmark)
{
return implode(' ', $this->formatTagList($bookmark));
return implode($this->conf->get('general.tags_separator', ' '), $this->formatTagList($bookmark));
}
/**
@ -351,6 +351,7 @@ abstract class BookmarkFormatter
/**
* 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
*

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

View file

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

View file

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

View file

@ -42,7 +42,8 @@ class ShaarliMiddleware
$this->initBasePath($request);
try {
if (!is_file($this->container->conf->getConfigFileExt())
if (
!is_file($this->container->conf->getConfigFileExt())
&& !in_array($next->getName(), ['displayInstall', 'saveInstall'], true)
) {
return $response->withRedirect($this->container->basePath . '/install');
@ -86,7 +87,8 @@ class ShaarliMiddleware
*/
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()
// and Shaarli doesn't have public content...
&& $this->container->conf->get('privacy.hide_public_links')

View file

@ -51,7 +51,10 @@ class ConfigureController extends ShaarliAdminController
$this->assignView('languages', Languages::getAvailableLanguages());
$this->assignView('gd_enabled', extension_loaded('gd'));
$this->assignView('thumbnails_mode', $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE));
$this->assignView('pagetitle', t('Configure') .' - '. $this->container->conf->get('general.title', 'Shaarli'));
$this->assignView(
'pagetitle',
t('Configure') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
);
return $response->write($this->render(TemplatePage::CONFIGURE));
}
@ -95,12 +98,15 @@ class ConfigureController extends ShaarliAdminController
}
$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)
) {
$this->saveWarningMessage(
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);

View file

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

View file

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

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,9 +24,15 @@ class ManageTagController extends ShaarliAdminController
$fromTag = $request->getParam('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(
'pagetitle',
t('Manage tags') .' - '. $this->container->conf->get('general.title', 'Shaarli')
t('Manage tags') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
);
return $response->write($this->render(TemplatePage::CHANGE_TAG));
@ -81,8 +87,35 @@ class ManageTagController extends ShaarliAdminController
$this->saveSuccessMessage($alert);
$redirect = true === $isDelete ? '/admin/tags' : '/?searchtags='. urlencode($toTag);
$redirect = true === $isDelete ? '/admin/tags' : '/?searchtags=' . urlencode($toTag);
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

@ -25,7 +25,7 @@ class PasswordController extends ShaarliAdminController
$this->assignView(
'pagetitle',
t('Change password') .' - '. $this->container->conf->get('general.title', 'Shaarli')
t('Change password') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
);
}
@ -78,7 +78,7 @@ class PasswordController extends ShaarliAdminController
// Save new password
// Salt renders rainbow-tables attacks useless.
$this->container->conf->set('credentials.salt', sha1(uniqid('', true) .'_'. mt_rand()));
$this->container->conf->set('credentials.salt', sha1(uniqid('', true) . '_' . mt_rand()));
$this->container->conf->set(
'credentials.hash',
sha1(

View file

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

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 @@ class SessionFilterController extends ShaarliAdminController
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

@ -34,7 +34,7 @@ class ThumbnailsController extends ShaarliAdminController
$this->assignView('ids', $ids);
$this->assignView(
'pagetitle',
t('Thumbnails update') .' - '. $this->container->conf->get('general.title', 'Shaarli')
t('Thumbnails update') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
);
return $response->write($this->render(TemplatePage::THUMBNAILS));

View file

@ -28,7 +28,7 @@ class ToolsController extends ShaarliAdminController
$this->assignView($key, $value);
}
$this->assignView('pagetitle', t('Tools') .' - '. $this->container->conf->get('general.title', 'Shaarli'));
$this->assignView('pagetitle', t('Tools') . ' - ' . $this->container->conf->get('general.title', 'Shaarli'));
return $response->write($this->render(TemplatePage::TOOLS));
}

View file

@ -35,7 +35,8 @@ class BookmarkListController extends ShaarliVisitorController
$formatter->addContextData('base_path', $this->container->basePath);
$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.
$visibility = $this->container->sessionManager->getSessionParameter('visibility');
@ -95,6 +96,10 @@ class BookmarkListController extends ShaarliVisitorController
$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.
$data = array_merge(
$this->initializeTemplateVars(),
@ -106,7 +111,7 @@ class BookmarkListController extends ShaarliVisitorController
'result_count' => count($linksToDisplay),
'search_term' => escape($searchTerm),
'search_tags' => escape($searchTags),
'search_tags_url' => array_map('urlencode', explode(' ', $searchTags)),
'search_tags_url' => $searchTagsUrlEncoded,
'visibility' => $visibility,
'links' => $linkDisp,
]
@ -119,8 +124,9 @@ class BookmarkListController extends ShaarliVisitorController
return '[' . $tag . ']';
};
$data['pagetitle'] .= ! empty($searchTags)
? implode(' ', array_map($bracketWrap, preg_split('/\s+/', $searchTags))) . ' '
: '';
? implode(' ', array_map($bracketWrap, tags_str2array($searchTags, $tagsSeparator))) . ' '
: ''
;
$data['pagetitle'] .= '- ';
}
@ -137,8 +143,10 @@ class BookmarkListController extends ShaarliVisitorController
*/
public function permalink(Request $request, Response $response, array $args): Response
{
$privateKey = $request->getParam('key');
try {
$bookmark = $this->container->bookmarkService->findByHash($args['hash']);
$bookmark = $this->container->bookmarkService->findByHash($args['hash'], $privateKey);
} catch (BookmarkNotFoundException $e) {
$this->assignView('error_message', $e->getMessage());
@ -153,7 +161,7 @@ class BookmarkListController extends ShaarliVisitorController
$data = array_merge(
$this->initializeTemplateVars(),
[
'pagetitle' => $bookmark->getTitle() .' - '. $this->container->conf->get('general.title', 'Shaarli'),
'pagetitle' => $bookmark->getTitle() . ' - ' . $this->container->conf->get('general.title', 'Shaarli'),
'links' => [$formatter->format($bookmark)],
]
);
@ -169,19 +177,25 @@ class BookmarkListController extends ShaarliVisitorController
*/
protected function updateThumbnail(Bookmark $bookmark, bool $writeDatastore = true): bool
{
// Logged in, thumbnails enabled, not a note, is HTTP
// and (never retrieved yet or no valid cache file)
if ($this->container->loginManager->isLoggedIn()
&& $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE
&& false !== $bookmark->getThumbnail()
&& !$bookmark->isNote()
&& (null === $bookmark->getThumbnail() || !is_file($bookmark->getThumbnail()))
&& startsWith(strtolower($bookmark->getUrl()), 'http')
) {
$bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl()));
$this->container->bookmarkService->set($bookmark, $writeDatastore);
if (false === $this->container->loginManager->isLoggedIn()) {
return false;
}
return true;
// If thumbnail should be updated, we reset it to null
if ($bookmark->shouldUpdateThumbnail()) {
$bookmark->setThumbnail(null);
// Requires an update, not async retrieval, thumbnails enabled
if (
$bookmark->shouldUpdateThumbnail()
&& true !== $this->container->conf->get('general.enable_async_metadata', true)
&& $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE
) {
$bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl()));
$this->container->bookmarkService->set($bookmark, $writeDatastore);
return true;
}
}
return false;
@ -198,6 +212,7 @@ class BookmarkListController extends ShaarliVisitorController
'page_max' => '',
'search_tags' => '',
'result_count' => '',
'async_metadata' => $this->container->conf->get('general.enable_async_metadata', true)
];
}

View file

@ -5,8 +5,8 @@ declare(strict_types=1);
namespace Shaarli\Front\Controller\Visitor;
use DateTime;
use DateTimeImmutable;
use Shaarli\Bookmark\Bookmark;
use Shaarli\Helper\DailyPageHelper;
use Shaarli\Render\TemplatePage;
use Slim\Http\Request;
use Slim\Http\Response;
@ -26,32 +26,20 @@ class DailyController extends ShaarliVisitorController
*/
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();
$nbAvailableDates = count($availableDates);
$index = array_search($day, $availableDates);
if ($index === false) {
// no bookmarks for day, but at least one day with bookmarks
$day = $availableDates[$nbAvailableDates - 1] ?? $day;
$previousDay = $availableDates[$nbAvailableDates - 2] ?? '';
} else {
$previousDay = $availableDates[$index - 1] ?? '';
$nextDay = $availableDates[$index + 1] ?? '';
}
if ($day === date('Ymd')) {
$this->assignView('dayDesc', t('Today'));
} elseif ($day === date('Ymd', strtotime('-1 days'))) {
$this->assignView('dayDesc', t('Yesterday'));
}
try {
$linksToDisplay = $this->container->bookmarkService->filterDay($day);
} catch (\Exception $exc) {
$linksToDisplay = [];
}
$linksToDisplay = $this->container->bookmarkService->findByDate(
$start,
$end,
$previousDay,
$nextDay
);
$formatter = $this->container->formatterFactory->getFormatter();
$formatter->addContextData('base_path', $this->container->basePath);
@ -63,13 +51,15 @@ class DailyController extends ShaarliVisitorController
$linksToDisplay[$key]['description'] = $bookmark->getDescription();
}
$dayDate = DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, $day.'_000000');
$data = [
'linksToDisplay' => $linksToDisplay,
'day' => $dayDate->getTimestamp(),
'dayDate' => $dayDate,
'previousday' => $previousDay ?? '',
'nextday' => $nextDay ?? '',
'dayDate' => $start,
'day' => $start->getTimestamp(),
'previousday' => $previousDay ? $previousDay->format($format) : '',
'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.
@ -82,7 +72,7 @@ class DailyController extends ShaarliVisitorController
$mainTitle = $this->container->conf->get('general.title', 'Shaarli');
$this->assignView(
'pagetitle',
t('Daily') .' - '. format_date($dayDate, false) . ' - ' . $mainTitle
$data['localizedType'] . ' - ' . $data['dayDesc'] . ' - ' . $mainTitle
);
return $response->write($this->render(TemplatePage::DAILY));
@ -96,9 +86,11 @@ class DailyController extends ShaarliVisitorController
public function rss(Request $request, Response $response): Response
{
$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);
$cache = $this->container->pageCacheManager->getCachePage($pageUrl);
$cache = $this->container->pageCacheManager->getCachePage($pageUrl, $cacheDuration);
$cached = $cache->cachedVersion();
if (!empty($cached)) {
@ -106,11 +98,13 @@ class DailyController extends ShaarliVisitorController
}
$days = [];
$format = DailyPageHelper::getFormatByType($type);
$length = DailyPageHelper::getRssLengthByType($type);
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
if (count($days) === static::$DAILY_RSS_NB_DAYS && !isset($days[$day])) {
if (count($days) === $length && !isset($days[$day])) {
break;
}
@ -127,12 +121,19 @@ class DailyController extends ShaarliVisitorController
/** @var Bookmark[] $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] = [
'date' => $dayDatetime,
'date_rss' => $dayDatetime->format(DateTime::RSS),
'date_human' => format_date($dayDatetime, false, true),
'absolute_url' => $indexUrl . 'daily?day=' . $day,
'date' => $endDateTime,
'date_rss' => $endDateTime->format(DateTime::RSS),
'date_human' => DailyPageHelper::getDescriptionByType($type, $dayDateTime, false),
'absolute_url' => $indexUrl . 'daily?' . $type . '=' . $day,
'links' => [],
];
@ -141,16 +142,20 @@ class DailyController extends ShaarliVisitorController
// Make permalink URL absolute
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->assignView('index_url', $indexUrl);
$this->assignView('page_url', $pageUrl);
$this->assignView('hide_timestamps', $this->container->conf->get('privacy.hide_timestamps', false));
$this->assignView('days', $dataPerDay);
$this->assignAllView([
'title' => $this->container->conf->get('general.title', 'Shaarli'),
'index_url' => $indexUrl,
'page_url' => $pageUrl,
'hide_timestamps' => $this->container->conf->get('privacy.hide_timestamps', false),
'days' => $dataPerDay,
'type' => $type,
'localizedType' => $this->translateType($type),
]);
$rssContent = $this->render(TemplatePage::DAILY_RSS);
@ -189,4 +194,13 @@ class DailyController extends ShaarliVisitorController
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 @@ class ErrorController extends ShaarliVisitorController
$response = $response->withStatus($throwable->getCode());
} else {
// Internal error (any other Throwable)
if ($this->container->conf->get('dev.debug', false)) {
$this->assignView('message', $throwable->getMessage());
if ($this->container->conf->get('dev.debug', false) || $this->container->loginManager->isLoggedIn()) {
$this->assignView('message', t('Error: ') . $throwable->getMessage());
$this->assignView(
'stacktrace',
nl2br(get_class($throwable) .': '. PHP_EOL . $throwable->getTraceAsString())
'text',
'<a href="https://github.com/shaarli/Shaarli/issues/new">'
. t('Please report it on Github.')
. '</a>'
);
$this->assignView('stacktrace', exception2text($throwable));
} else {
$this->assignView('message', t('An unexpected error occurred.'));
}
@ -39,7 +42,6 @@ class ErrorController extends ShaarliVisitorController
$response = $response->withStatus(500);
}
return $response->write($this->render('error'));
}
}

View file

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

View file

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

View file

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

View file

@ -26,7 +26,7 @@ class PictureWallController extends ShaarliVisitorController
$this->assignView(
'pagetitle',
t('Picture wall') .' - '. $this->container->conf->get('general.title', 'Shaarli')
t('Picture wall') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
);
// Optionally filter the results:

View file

@ -144,7 +144,8 @@ abstract class ShaarliVisitorController
if (null !== $referer) {
$currentUrl = parse_url($referer);
// 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
) {
return $response->withRedirect($defaultPath);
@ -173,7 +174,7 @@ abstract class ShaarliVisitorController
}
}
$queryString = count($params) > 0 ? '?'. http_build_query($params) : '';
$queryString = count($params) > 0 ? '?' . http_build_query($params) : '';
$anchor = $anchor ? '#' . $anchor : '';
return $response->withRedirect($path . $queryString . $anchor);

View file

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

View file

@ -27,7 +27,7 @@ class TagController extends ShaarliVisitorController
// In case browser does not send HTTP_REFERER, we search a single tag
if (null === $referer) {
if (null !== $newTag) {
return $this->redirect($response, '/?searchtags='. urlencode($newTag));
return $this->redirect($response, '/?searchtags=' . urlencode($newTag));
}
return $this->redirect($response, '/');
@ -37,7 +37,7 @@ class TagController extends ShaarliVisitorController
parse_str($currentUrl['query'] ?? '', $params);
if (null === $newTag) {
return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params));
return $response->withRedirect(($currentUrl['path'] ?? './') . '?' . http_build_query($params));
}
// Prevent redirection loop
@ -45,9 +45,10 @@ class TagController extends ShaarliVisitorController
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.
// Each tag is always separated by a space
$currentTags = isset($params['searchtags']) ? explode(' ', $params['searchtags']) : [];
$currentTags = tags_str2array($params['searchtags'] ?? '', $tagsSeparator);
$addtag = true;
foreach ($currentTags as $value) {
@ -62,12 +63,12 @@ class TagController extends ShaarliVisitorController
$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)
unset($params['page']);
return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params));
return $response->withRedirect(($currentUrl['path'] ?? './') . '?' . http_build_query($params));
}
/**
@ -89,7 +90,7 @@ class TagController extends ShaarliVisitorController
parse_str($currentUrl['query'] ?? '', $params);
if (null === $tagToRemove) {
return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params));
return $response->withRedirect(($currentUrl['path'] ?? './') . '?' . http_build_query($params));
}
// Prevent redirection loop
@ -98,10 +99,11 @@ class TagController extends ShaarliVisitorController
}
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.
$tags = array_diff($tags, [$tagToRemove]);
$params['searchtags'] = implode(' ', $tags);
$params['searchtags'] = tags_array2str($tags, $tagsSeparator);
if (empty($params['searchtags'])) {
unset($params['searchtags']);

View file

@ -1,7 +1,10 @@
<?php
namespace Shaarli;
namespace Shaarli\Helper;
use Exception;
use malkusch\lock\exception\LockAcquireException;
use malkusch\lock\mutex\FlockMutex;
use Shaarli\Config\ConfigManager;
/**
@ -14,8 +17,9 @@ class ApplicationUtils
*/
public static $VERSION_FILE = 'shaarli_version.php';
private static $GIT_URL = 'https://raw.githubusercontent.com/shaarli/Shaarli';
private static $GIT_BRANCHES = array('latest', 'stable');
public static $GITHUB_URL = 'https://github.com/shaarli/Shaarli';
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_END_TAG = ' */ ?>';
@ -63,8 +67,8 @@ class ApplicationUtils
}
return str_replace(
array(self::$VERSION_START_TAG, self::$VERSION_END_TAG, PHP_EOL),
array('', '', ''),
[self::$VERSION_START_TAG, self::$VERSION_END_TAG, PHP_EOL],
['', '', ''],
$data
);
}
@ -125,7 +129,7 @@ class ApplicationUtils
// Late Static Binding allows overriding within tests
// See http://php.net/manual/en/language.oop5.late-static-bindings.php
$latestVersion = static::getVersion(
self::$GIT_URL . '/' . $branch . '/' . self::$VERSION_FILE
self::$GIT_RAW_URL . '/' . $branch . '/' . self::$VERSION_FILE
);
if (!$latestVersion) {
@ -171,35 +175,47 @@ class ApplicationUtils
/**
* 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
*/
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'), '/');
// Check script and template directories are readable
foreach (array(
'application',
'inc',
'plugins',
$rainTplDir,
$rainTplDir . '/' . $conf->get('resource.theme'),
) as $path) {
foreach (
[
'application',
'inc',
'plugins',
$rainTplDir,
$rainTplDir . '/' . $conf->get('resource.theme'),
] as $path
) {
if (!is_readable(realpath($path))) {
$errors[] = '"' . $path . '" ' . t('directory is not readable');
}
}
// Check cache and data directories are readable and writable
foreach (array(
$conf->get('resource.thumbnails_cache'),
$conf->get('resource.data_dir'),
$conf->get('resource.page_cache'),
$conf->get('resource.raintpl_tmp'),
) as $path) {
if ($minimalMode) {
$folders = [
$conf->get('resource.raintpl_tmp'),
];
} else {
$folders = [
$conf->get('resource.thumbnails_cache'),
$conf->get('resource.data_dir'),
$conf->get('resource.page_cache'),
$conf->get('resource.raintpl_tmp'),
];
}
foreach ($folders as $path) {
if (!is_readable(realpath($path))) {
$errors[] = '"' . $path . '" ' . t('directory is not readable');
}
@ -208,14 +224,20 @@ class ApplicationUtils
}
}
if ($minimalMode) {
return $errors;
}
// Check configuration files are readable and writable
foreach (array(
$conf->getConfigFileExt(),
$conf->get('resource.datastore'),
$conf->get('resource.ban_file'),
$conf->get('resource.log'),
$conf->get('resource.update_check'),
) as $path) {
foreach (
[
$conf->getConfigFileExt(),
$conf->get('resource.datastore'),
$conf->get('resource.ban_file'),
$conf->get('resource.log'),
$conf->get('resource.update_check'),
] as $path
) {
if (!is_file(realpath($path))) {
# the file may not exist yet
continue;
@ -232,6 +254,20 @@ class ApplicationUtils
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.
*
@ -246,4 +282,54 @@ class ApplicationUtils
{
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
namespace Shaarli;
namespace Shaarli\Helper;
use Shaarli\Exceptions\IOException;
@ -81,4 +81,60 @@ class FileUtils
)
);
}
/**
* 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 @@ namespace Shaarli\Http;
*/
class HttpAccess
{
public function getHttpResponse($url, $timeout = 30, $maxBytes = 4194304, $curlWriteFunction = null)
{
return get_http_response($url, $timeout, $maxBytes, $curlWriteFunction);
public function getHttpResponse(
$url,
$timeout = 30,
$maxBytes = 4194304,
$curlHeaderFunction = null,
$curlWriteFunction = null
) {
return get_http_response($url, $timeout, $maxBytes, $curlHeaderFunction, $curlWriteFunction);
}
public function getCurlDownloadCallback(
@ -25,7 +30,7 @@ class HttpAccess
&$description,
&$keywords,
$retrieveDescription,
$curlGetInfo = 'curl_getinfo'
$tagsSeparator
) {
return get_curl_download_callback(
$charset,
@ -33,7 +38,12 @@ class HttpAccess
$description,
$keywords,
$retrieveDescription,
$curlGetInfo
$tagsSeparator
);
}
public function getCurlHeaderCallback(&$charset, $curlGetInfo = 'curl_getinfo')
{
return get_curl_header_callback($charset, $curlGetInfo);
}
}

View file

@ -6,12 +6,14 @@ use Shaarli\Http\Url;
* GET an HTTP URL to retrieve its content
* Uses the cURL library or a fallback method
*
* @param string $url URL to get (http://...)
* @param int $timeout network timeout (in seconds)
* @param int $maxBytes maximum downloaded bytes (default: 4 MiB)
* @param callable|string $curlWriteFunction Optional callback called during the download (cURL CURLOPT_WRITEFUNCTION).
* Can be used to add download conditions on the
* headers (response code, content type, etc.).
* @param string $url URL to get (http://...)
* @param int $timeout network timeout (in seconds)
* @param int $maxBytes maximum downloaded bytes (default: 4 MiB)
* @param callable|string $curlHeaderFunction Optional callback called during the download of headers
* (CURLOPT_HEADERFUNCTION)
* @param callable|string $curlWriteFunction Optional callback called during the download (cURL CURLOPT_WRITEFUNCTION).
* Can be used to add download conditions on the
* headers (response code, content type, etc.).
*
* @return array HTTP response headers, downloaded content
*
@ -35,13 +37,18 @@ use Shaarli\Http\Url;
* @see http://stackoverflow.com/q/9183178
* @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);
$cleanUrl = $urlObj->idnToAscii();
if (!filter_var($cleanUrl, FILTER_VALIDATE_URL) || !$urlObj->isHttp()) {
return array(array(0 => 'Invalid HTTP UrlUtils'), false);
return [[0 => 'Invalid HTTP UrlUtils'], false];
}
$userAgent =
@ -64,42 +71,39 @@ function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteF
$ch = curl_init($cleanUrl);
if ($ch === false) {
return array(array(0 => 'curl_init() error'), false);
return [[0 => 'curl_init() error'], false];
}
// General cURL settings
curl_setopt($ch, CURLOPT_AUTOREFERER, 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(
$ch,
CURLOPT_HTTPHEADER,
array('Accept-Language: ' . $acceptLanguage)
['Accept-Language: ' . $acceptLanguage]
);
curl_setopt($ch, CURLOPT_MAXREDIRS, $maxRedirs);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
curl_setopt($ch, CURLOPT_USERAGENT, $userAgent);
// Max download size management
curl_setopt($ch, CURLOPT_BUFFERSIZE, 1024 * 16);
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);
}
// Max download size management
curl_setopt($ch, CURLOPT_BUFFERSIZE, 1024*16);
curl_setopt($ch, CURLOPT_NOPROGRESS, false);
curl_setopt(
$ch,
CURLOPT_PROGRESSFUNCTION,
function ($arg0, $arg1, $arg2, $arg3, $arg4 = 0) use ($maxBytes) {
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;
}
function ($arg0, $arg1, $arg2, $arg3, $arg4) use ($maxBytes) {
$downloaded = $arg2;
// Non-zero return stops downloading
return ($downloaded > $maxBytes) ? 1 : 0;
}
@ -118,9 +122,9 @@ function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteF
* Removing this would require updating
* 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
@ -131,7 +135,7 @@ function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteF
$rawHeadersLastRedir = end($rawHeadersArrayRedirs);
$content = substr($response, $headSize);
$headers = array();
$headers = [];
foreach (preg_split('~[\r\n]+~', $rawHeadersLastRedir) as $line) {
if (empty($line) || ctype_space($line)) {
continue;
@ -142,7 +146,7 @@ function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteF
$value = $splitLine[1];
if (array_key_exists($key, $headers)) {
if (!is_array($headers[$key])) {
$headers[$key] = array(0 => $headers[$key]);
$headers[$key] = [0 => $headers[$key]];
}
$headers[$key][] = $value;
} else {
@ -153,7 +157,7 @@ function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteF
}
}
return array($headers, $content);
return [$headers, $content];
}
/**
@ -184,15 +188,15 @@ function get_http_response_fallback(
$acceptLanguage,
$maxRedr
) {
$options = array(
'http' => array(
$options = [
'http' => [
'method' => 'GET',
'timeout' => $timeout,
'user_agent' => $userAgent,
'header' => "Accept: */*\r\n"
. 'Accept-Language: ' . $acceptLanguage
)
);
]
];
stream_context_set_default($options);
list($headers, $finalUrl) = get_redirected_headers($cleanUrl, $maxRedr);
@ -203,7 +207,7 @@ function get_http_response_fallback(
}
if (! $headers) {
return array($headers, false);
return [$headers, false];
}
try {
@ -211,10 +215,10 @@ function get_http_response_fallback(
$context = stream_context_create($options);
$content = file_get_contents($finalUrl, false, $context, -1, $maxBytes);
} 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.
if ($redirectionLimit-- > 0
if (
$redirectionLimit-- > 0
&& !empty($headers)
&& (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'];
if ($redirection != $url) {
$redirection = getAbsoluteUrl($url, $redirection);
@ -244,7 +250,7 @@ function get_redirected_headers($url, $redirectionLimit = 3)
}
}
return array($headers, $url);
return [$headers, $url];
}
/**
@ -266,7 +272,7 @@ function getAbsoluteUrl($originalUrl, $newUrl)
}
$parts = parse_url($originalUrl);
$final = $parts['scheme'] .'://'. $parts['host'];
$final = $parts['scheme'] . '://' . $parts['host'];
$final .= (!empty($parts['port'])) ? $parts['port'] : '';
$final .= '/';
if ($newUrl[0] != '/') {
@ -319,7 +325,8 @@ function server_url($server)
$scheme = 'https';
}
if (($scheme == 'http' && $port != '80')
if (
($scheme == 'http' && $port != '80')
|| ($scheme == 'https' && $port != '443')
) {
$port = ':' . $port;
@ -340,22 +347,26 @@ function server_url($server)
$host = $server['SERVER_NAME'];
}
return $scheme.'://'.$host.$port;
return $scheme . '://' . $host . $port;
}
// SSL detection
if ((! empty($server['HTTPS']) && strtolower($server['HTTPS']) == 'on')
|| (isset($server['SERVER_PORT']) && $server['SERVER_PORT'] == '443')) {
if (
(! empty($server['HTTPS']) && strtolower($server['HTTPS']) == 'on')
|| (isset($server['SERVER_PORT']) && $server['SERVER_PORT'] == '443')
) {
$scheme = 'https';
}
// Do not append standard port values
if (($scheme == 'http' && $server['SERVER_PORT'] != '80')
|| ($scheme == 'https' && $server['SERVER_PORT'] != '443')) {
$port = ':'.$server['SERVER_PORT'];
if (
($scheme == 'http' && $server['SERVER_PORT'] != '80')
|| ($scheme == 'https' && $server['SERVER_PORT'] != '443')
) {
$port = ':' . $server['SERVER_PORT'];
}
return $scheme.'://'.$server['SERVER_NAME'].$port;
return $scheme . '://' . $server['SERVER_NAME'] . $port;
}
/**
@ -493,53 +504,22 @@ function is_https($server)
* 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(
function get_curl_header_callback(
&$charset,
&$title,
&$description,
&$keywords,
$retrieveDescription,
$curlGetInfo = 'curl_getinfo'
) {
$isRedirected = false;
$currentChunk = 0;
$foundChunk = null;
/**
* cURL callback function for CURLOPT_WRITEFUNCTION (called during the download).
*
* While downloading the remote page, we check that the HTTP code is 200 and content type is 'html/text'
* Then we extract the title and the charset and stop the download when it's done.
*
* @param resource $ch cURL resource
* @param string $data chunk of data being downloaded
*
* @return int|bool length of $data or false if we need to stop the download
*/
return function (&$ch, $data) use (
$retrieveDescription,
$curlGetInfo,
&$charset,
&$title,
&$description,
&$keywords,
&$isRedirected,
&$currentChunk,
&$foundChunk
) {
$currentChunk++;
return function ($ch, $data) use ($curlGetInfo, &$charset, &$isRedirected) {
$responseCode = $curlGetInfo($ch, CURLINFO_RESPONSE_CODE);
$chunkLength = strlen($data);
if (!empty($responseCode) && in_array($responseCode, [301, 302])) {
$isRedirected = true;
return strlen($data);
return $chunkLength;
}
if (!empty($responseCode) && $responseCode !== 200) {
return false;
@ -555,6 +535,61 @@ function get_curl_download_callback(
if (!empty($contentType) && empty($charset)) {
$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)) {
$charset = html_extract_charset($data);
}
@ -562,6 +597,10 @@ function get_curl_download_callback(
$title = html_extract_title($data);
$foundChunk = ! empty($title) ? $currentChunk : $foundChunk;
}
if (empty($title)) {
$title = html_extract_tag('title', $data);
$foundChunk = ! empty($title) ? $currentChunk : $foundChunk;
}
if ($retrieveDescription && empty($description)) {
$description = html_extract_tag('description', $data);
$foundChunk = ! empty($description) ? $currentChunk : $foundChunk;
@ -571,10 +610,10 @@ function get_curl_download_callback(
if (! empty($keywords)) {
$foundChunk = $currentChunk;
// Keywords use the format tag1, tag2 multiple words, tag
// So we format them to match Shaarli's separator and glue multiple words with '-'
$keywords = implode(' ', array_map(function($keyword) {
return implode('-', preg_split('/\s+/', trim($keyword)));
}, explode(',', $keywords)));
// So we split the result with `,`, then if a tag contains the separator we replace it by `-`.
$keywords = tags_array2str(array_map(function (string $keyword) use ($tagsSeparator): string {
return tags_array2str(tags_str2array($keyword, $tagsSeparator), '-');
}, tags_str2array($keywords, ',')), $tagsSeparator);
}
}
@ -582,7 +621,8 @@ function get_curl_download_callback(
// If we already found either the title, description or keywords,
// it's highly unlikely that we'll found the other metas further than
// in the same chunk of data or the next one. So we also stop the download after that.
if ((!empty($responseCode) && !empty($contentType) && !empty($charset)) && $foundChunk !== null
if (
(!empty($responseCode) && !empty($contentType) && !empty($charset)) && $foundChunk !== null
&& (! $retrieveDescription
|| $foundChunk < $currentChunk
|| (!empty($title) && !empty($description) && !empty($keywords))
@ -591,6 +631,6 @@ function get_curl_download_callback(
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 @@ namespace Shaarli\Http;
*/
class Url
{
private static $annoyingQueryParams = array(
private static $annoyingQueryParams = [
// Facebook
'action_object_map=',
'action_ref_map=',
@ -37,15 +37,15 @@ class Url
// Other
'campaign_'
);
];
private static $annoyingFragments = array(
private static $annoyingFragments = [
// ATInternet
'xtor=RSS-',
// Misc.
'tk.rss_all'
);
];
/*
* URL parts represented as an array
@ -120,7 +120,7 @@ class Url
foreach (self::$annoyingQueryParams as $annoying) {
foreach ($queryParams as $param) {
if (startsWith($param, $annoying)) {
$queryParams = array_diff($queryParams, array($param));
$queryParams = array_diff($queryParams, [$param]);
continue;
}
}

View file

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

View file

@ -51,7 +51,7 @@ class LegacyController extends ShaarliVisitorController
if (!$this->container->loginManager->isLoggedIn()) {
$parameters = $buildParameters($request->getQueryParams(), true);
return $this->redirect($response, '/login?returnurl='. $this->getBasePath() . $route . $parameters);
return $this->redirect($response, '/login?returnurl=' . $this->getBasePath() . $route . $parameters);
}
$parameters = $buildParameters($request->getQueryParams(), false);

View file

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

View file

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

View file

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

View file

@ -59,11 +59,11 @@ class NetscapeBookmarkUtils
$indexUrl
) {
// 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 . '"');
}
$bookmarkLinks = array();
$bookmarkLinks = [];
foreach ($this->bookmarkService->search([], $selection) as $bookmark) {
$link = $formatter->format($bookmark);
$link['taglist'] = implode(',', $bookmark->getTags());
@ -101,11 +101,11 @@ class NetscapeBookmarkUtils
// Add tags to all imported bookmarks?
if (empty($post['default_tags'])) {
$defaultTags = array();
$defaultTags = [];
} else {
$defaultTags = preg_split(
'/[\s,]+/',
escape($post['default_tags'])
$defaultTags = tags_str2array(
escape($post['default_tags']),
$this->conf->get('general.tags_separator', ' ')
);
}
@ -171,7 +171,7 @@ class NetscapeBookmarkUtils
$link->setUrl($bkm['uri'], $this->conf->get('security.allowed_protocols'));
$link->setDescription($bkm['note']);
$link->setPrivate($private);
$link->setTagsString($bkm['tags']);
$link->setTags($bkm['tags']);
$this->bookmarkService->addOrSet($link, false);
$importCount++;

View file

@ -1,8 +1,10 @@
<?php
namespace Shaarli\Plugin;
use Shaarli\Config\ConfigManager;
use Shaarli\Plugin\Exception\PluginFileNotFoundException;
use Shaarli\Plugin\Exception\PluginInvalidRouteException;
/**
* Class PluginManager
@ -23,7 +25,15 @@ class PluginManager
*
* @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.
@ -57,7 +67,7 @@ class PluginManager
public function __construct(&$conf)
{
$this->conf = $conf;
$this->errors = array();
$this->errors = [];
}
/**
@ -85,6 +95,9 @@ class PluginManager
$this->loadPlugin($dirs[$index], $plugin);
} catch (PluginFileNotFoundException $e) {
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 @@ class PluginManager
*
* @return void
*/
public function executeHooks($hook, &$data, $params = array())
public function executeHooks($hook, &$data, $params = [])
{
$metadataParameters = [
'target' => '_PAGE_',
@ -165,6 +178,22 @@ class PluginManager
}
}
$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;
}
@ -196,7 +225,7 @@ class PluginManager
*/
public function getPluginsMeta()
{
$metaData = array();
$metaData = [];
$dirs = glob(self::$PLUGINS_PATH . '/*', GLOB_ONLYDIR | GLOB_MARK);
// Browse all plugin directories.
@ -217,9 +246,9 @@ class PluginManager
if (isset($metaData[$plugin]['parameters'])) {
$params = explode(';', $metaData[$plugin]['parameters']);
} else {
$params = array();
$params = [];
}
$metaData[$plugin]['parameters'] = array();
$metaData[$plugin]['parameters'] = [];
foreach ($params as $param) {
if (empty($param)) {
continue;
@ -236,6 +265,14 @@ class PluginManager
return $metaData;
}
/**
* @return array List of registered custom routes by plugins.
*/
public function getRegisteredRoutes(): array
{
return $this->registeredRoutes;
}
/**
* Return the list of encountered errors.
*
@ -245,4 +282,32 @@ class PluginManager
{
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
namespace Shaarli\Plugin\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;
use Exception;
use exceptions\MissingBasePathException;
use Psr\Log\LoggerInterface;
use RainTPL;
use Shaarli\ApplicationUtils;
use Shaarli\Bookmark\BookmarkServiceInterface;
use Shaarli\Config\ConfigManager;
use Shaarli\Helper\ApplicationUtils;
use Shaarli\Security\SessionManager;
use Shaarli\Thumbnailer;
@ -35,6 +35,9 @@ class PageBuilder
*/
protected $session;
/** @var LoggerInterface */
protected $logger;
/**
* @var BookmarkServiceInterface $bookmarkService instance.
*/
@ -54,17 +57,25 @@ class PageBuilder
* PageBuilder constructor.
* $tpl is initialized at false for lazy loading.
*
* @param ConfigManager $conf Configuration Manager instance (reference).
* @param array $session $_SESSION array
* @param BookmarkServiceInterface $linkDB instance.
* @param string $token Session token
* @param bool $isLoggedIn
* @param ConfigManager $conf Configuration Manager instance (reference).
* @param array $session $_SESSION array
* @param LoggerInterface $logger
* @param null $linkDB instance.
* @param null $token Session token
* @param bool $isLoggedIn
*/
public function __construct(&$conf, $session, $linkDB = null, $token = null, $isLoggedIn = false)
{
public function __construct(
ConfigManager &$conf,
array $session,
LoggerInterface $logger,
$linkDB = null,
$token = null,
$isLoggedIn = false
) {
$this->tpl = false;
$this->conf = $conf;
$this->session = $session;
$this->logger = $logger;
$this->bookmarkService = $linkDB;
$this->token = $token;
$this->isLoggedIn = $isLoggedIn;
@ -98,7 +109,7 @@ class PageBuilder
$this->tpl->assign('newVersion', escape($version));
$this->tpl->assign('versionError', '');
} 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('versionError', escape($exc->getMessage()));
}
@ -149,7 +160,8 @@ class PageBuilder
$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.
$this->tpl->assign('conf', $this->conf);

View file

@ -2,6 +2,7 @@
namespace Shaarli\Render;
use DatePeriod;
use Shaarli\Feed\CachedPage;
/**
@ -49,12 +50,21 @@ class PageCacheManager
$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(
$this->pageCacheDir,
$pageUrl,
false === $this->isLoggedIn
false === $this->isLoggedIn,
$validityPeriod
);
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,5 @@
<?php
namespace Shaarli\Security;
use Shaarli\Config\ConfigManager;
@ -79,7 +80,7 @@ class SessionManager
*/
public function generateToken()
{
$token = sha1(uniqid('', true) .'_'. mt_rand() . $this->conf->get('credentials.salt'));
$token = sha1(uniqid('', true) . '_' . mt_rand() . $this->conf->get('credentials.salt'));
$this->session['tokens'][$token] = 1;
return $token;
}
@ -293,9 +294,12 @@ class SessionManager
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

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