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

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

View file

@ -17,27 +17,13 @@ http {
index index.html index.php;
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,30 +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 ~ /doc/ {
default_type "text/html";
try_files $uri $uri/ $uri.html =404;
}
location ~ \.php$ {
# deny access to all other PHP scripts
deny all;
}
}
}

View file

@ -2,8 +2,16 @@
.dev
.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

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

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

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,16 +474,22 @@ 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));
}
/**
@ -471,4 +499,3 @@ 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
) {

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

@ -119,7 +119,8 @@ class Links extends ApiController
$data = (array) ($request->getParsedBody() ?? []);
$bookmark = ApiUtils::buildBookmarkFromRequest($data, $this->conf->get('privacy.default_private_links'));
// 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 +132,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);
}
@ -159,7 +160,8 @@ class Links extends ApiController
$requestBookmark = ApiUtils::buildBookmarkFromRequest($data, $this->conf->get('privacy.default_private_links'));
// 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;
}
@ -420,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);
}
/**
@ -444,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;
}
@ -507,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);
}
}
@ -519,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

@ -112,12 +112,12 @@ 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) {
file_put_contents(

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,18 @@ 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 . '[^\'"]*?[\'"]';
// Try to retrieve OpenGraph tag.
$ogRegex = '#<meta[^>]+(?:' . $properties . ')=(?:' . $orCondition . ')[^>]*content=(["\'])([^\1]*?)\1.*?>#';
// 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=(["\'])([^\1]*?)\1[^>]+(?:' . $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 +117,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 +139,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 +177,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) {
@ -368,9 +369,10 @@ class ConfigManager
$this->setEmpty('general.default_note_title', 'Note: ');
$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);
@ -391,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;
@ -49,6 +50,9 @@ class ContainerBuilder
/** @var LoginManager */
protected $login;
/** @var LoggerInterface */
protected $logger;
/** @var string|null */
protected $basePath = null;
@ -56,12 +60,14 @@ class ContainerBuilder
ConfigManager $conf,
SessionManager $session,
CookieManager $cookieManager,
LoginManager $login
LoginManager $login,
LoggerInterface $logger
) {
$this->conf = $conf;
$this->session = $session;
$this->login = $login;
$this->cookieManager = $cookieManager;
$this->logger = $logger;
}
public function build(): ShaarliContainer
@ -72,6 +78,7 @@ class ContainerBuilder
$container['sessionManager'] = $this->session;
$container['cookieManager'] = $this->cookieManager;
$container['loginManager'] = $this->login;
$container['logger'] = $this->logger;
$container['basePath'] = $this->basePath;
$container['plugins'] = function (ShaarliContainer $container): PluginManager {
@ -99,6 +106,7 @@ class ContainerBuilder
return new PageBuilder(
$container->conf,
$container->sessionManager->getSession(),
$container->logger,
$container->bookmarkService,
$container->sessionManager->generateToken(),
$container->loginManager->isLoggedIn()
@ -150,7 +158,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,6 +4,7 @@ declare(strict_types=1);
namespace Shaarli\Container;
use Psr\Log\LoggerInterface;
use Shaarli\Bookmark\BookmarkServiceInterface;
use Shaarli\Config\ConfigManager;
use Shaarli\Feed\FeedBuilder;
@ -36,6 +37,7 @@ 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

View file

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

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

View file

@ -24,9 +24,15 @@ 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

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

View file

@ -45,6 +45,4 @@ 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'] = 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,16 +177,25 @@ class BookmarkListController extends ShaarliVisitorController
*/
protected function updateThumbnail(Bookmark $bookmark, bool $writeDatastore = true): bool
{
// Logged in, not async retrieval, thumbnails enabled, and thumbnail should be updated
if ($this->container->loginManager->isLoggedIn()
&& true !== $this->container->conf->get('general.enable_async_metadata', true)
&& $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE
&& $bookmark->shouldUpdateThumbnail()
) {
$bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl()));
$this->container->bookmarkService->set($bookmark, $writeDatastore);
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;

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));
@ -106,11 +96,14 @@ class DailyController extends ShaarliVisitorController
}
$days = [];
$type = DailyPageHelper::extractRequestedType($request);
$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 +120,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),
'absolute_url' => $indexUrl . 'daily?' . $type . '=' . $day,
'links' => [],
];
@ -141,16 +141,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 +193,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,8 +26,14 @@ 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(
'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.'));
@ -36,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,16 @@ class InstallController extends ShaarliVisitorController
$this->assignView('cities', $cities);
$this->assignView('languages', Languages::getAvailableLanguages());
$phpEol = new \DateTimeImmutable(ApplicationUtils::getPhpEol(PHP_VERSION));
$this->assignView('php_version', PHP_VERSION);
$this->assignView('php_eol', format_date($phpEol, false));
$this->assignView('php_has_reached_eol', $phpEol < new \DateTimeImmutable());
$this->assignView('php_extensions', ApplicationUtils::getPhpExtensionsRequirement());
$this->assignView('permissions', ApplicationUtils::checkResourcePermissions($this->container->conf));
$this->assignView('pagetitle', t('Install Shaarli'));
return $response->write($this->render('install'));
}
@ -65,17 +76,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 +106,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 +117,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 +126,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 +163,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,5 +1,6 @@
<?php
namespace Shaarli;
namespace Shaarli\Helper;
use Exception;
use Shaarli\Config\ConfigManager;
@ -14,8 +15,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 +65,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 +127,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 +173,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 +222,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;
@ -246,4 +266,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,208 @@
<?php
declare(strict_types=1);
namespace Shaarli\Helper;
use Shaarli\Bookmark\Bookmark;
use Slim\Http\Request;
class DailyPageHelper
{
public const MONTH = 'month';
public const WEEK = 'week';
public const DAY = 'day';
/**
* Extracts the type of the daily to display from the HTTP request parameters
*
* @param Request $request HTTP request
*
* @return string month/week/day
*/
public static function extractRequestedType(Request $request): string
{
if ($request->getQueryParam(static::MONTH) !== null) {
return static::MONTH;
} elseif ($request->getQueryParam(static::WEEK) !== null) {
return static::WEEK;
}
return static::DAY;
}
/**
* Extracts a DateTimeImmutable from provided HTTP request.
* If no parameter is provided, we rely on the creation date of the latest provided created bookmark.
* If the datastore is empty or no bookmark is provided, we use the current date.
*
* @param string $type month/week/day
* @param string|null $requestedDate Input string extracted from the request
* @param Bookmark|null $latestBookmark Latest bookmark found in the datastore (by date)
*
* @return \DateTimeImmutable from input or latest bookmark.
*
* @throws \Exception Type not supported.
*/
public static function extractRequestedDateTime(
string $type,
?string $requestedDate,
Bookmark $latestBookmark = null
): \DateTimeImmutable {
$format = static::getFormatByType($type);
if (empty($requestedDate)) {
return $latestBookmark instanceof Bookmark
? new \DateTimeImmutable($latestBookmark->getCreated()->format(\DateTime::ATOM))
: new \DateTimeImmutable()
;
}
// W is not supported by createFromFormat...
if ($type === static::WEEK) {
return (new \DateTimeImmutable())
->setISODate((int) substr($requestedDate, 0, 4), (int) substr($requestedDate, 4, 2))
;
}
return \DateTimeImmutable::createFromFormat($format, $requestedDate);
}
/**
* Get the DateTime format used by provided type
* Examples:
* - day: 20201016 (<year><month><day>)
* - week: 202041 (<year><week number>)
* - month: 202010 (<year><month>)
*
* @param string $type month/week/day
*
* @return string DateTime compatible format
*
* @see https://www.php.net/manual/en/datetime.format.php
*
* @throws \Exception Type not supported.
*/
public static function getFormatByType(string $type): string
{
switch ($type) {
case static::MONTH:
return 'Ym';
case static::WEEK:
return 'YW';
case static::DAY:
return 'Ymd';
default:
throw new \Exception('Unsupported daily format type');
}
}
/**
* Get the first DateTime of the time period depending on given datetime and type.
* Note: DateTimeImmutable is required because we rely heavily on DateTime->modify() syntax
* and we don't want to alter original datetime.
*
* @param string $type month/week/day
* @param \DateTimeImmutable $requested DateTime extracted from request input
* (should come from extractRequestedDateTime)
*
* @return \DateTimeInterface First DateTime of the time period
*
* @throws \Exception Type not supported.
*/
public static function getStartDateTimeByType(string $type, \DateTimeImmutable $requested): \DateTimeInterface
{
switch ($type) {
case static::MONTH:
return $requested->modify('first day of this month midnight');
case static::WEEK:
return $requested->modify('Monday this week midnight');
case static::DAY:
return $requested->modify('Today midnight');
default:
throw new \Exception('Unsupported daily format type');
}
}
/**
* Get the last DateTime of the time period depending on given datetime and type.
* Note: DateTimeImmutable is required because we rely heavily on DateTime->modify() syntax
* and we don't want to alter original datetime.
*
* @param string $type month/week/day
* @param \DateTimeImmutable $requested DateTime extracted from request input
* (should come from extractRequestedDateTime)
*
* @return \DateTimeInterface Last DateTime of the time period
*
* @throws \Exception Type not supported.
*/
public static function getEndDateTimeByType(string $type, \DateTimeImmutable $requested): \DateTimeInterface
{
switch ($type) {
case static::MONTH:
return $requested->modify('last day of this month 23:59:59');
case static::WEEK:
return $requested->modify('Sunday this week 23:59:59');
case static::DAY:
return $requested->modify('Today 23:59:59');
default:
throw new \Exception('Unsupported daily format type');
}
}
/**
* Get localized description of the time period depending on given datetime and type.
* Example: for a month period, it returns `October, 2020`.
*
* @param string $type month/week/day
* @param \DateTimeImmutable $requested DateTime extracted from request input
* (should come from extractRequestedDateTime)
*
* @return string Localized time period description
*
* @throws \Exception Type not supported.
*/
public static function getDescriptionByType(string $type, \DateTimeImmutable $requested): string
{
switch ($type) {
case static::MONTH:
return $requested->format('F') . ', ' . $requested->format('Y');
case static::WEEK:
$requested = $requested->modify('Monday this week');
return t('Week') . ' ' . $requested->format('W') . ' (' . format_date($requested, false) . ')';
case static::DAY:
$out = '';
if ($requested->format('Ymd') === date('Ymd')) {
$out = t('Today') . ' - ';
} elseif ($requested->format('Ymd') === date('Ymd', strtotime('-1 days'))) {
$out = t('Yesterday') . ' - ';
}
return $out . format_date($requested, false);
default:
throw new \Exception('Unsupported daily format type');
}
}
/**
* Get the number of items to display in the RSS feed depending on the given type.
*
* @param string $type month/week/day
*
* @return int number of elements
*
* @throws \Exception Type not supported.
*/
public static function getRssLengthByType(string $type): int
{
switch ($type) {
case static::MONTH:
return 12; // 1 year
case static::WEEK:
return 26; // ~6 months
case static::DAY:
return 30; // ~1 month
default:
throw new \Exception('Unsupported daily format type');
}
}
}

View file

@ -1,6 +1,6 @@
<?php
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

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

View file

@ -48,7 +48,7 @@ function get_http_response(
$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 =
@ -71,7 +71,7 @@ function get_http_response(
$ch = curl_init($cleanUrl);
if ($ch === false) {
return array(array(0 => 'curl_init() error'), false);
return [[0 => 'curl_init() error'], false];
}
// General cURL settings
@ -82,7 +82,7 @@ function get_http_response(
curl_setopt(
$ch,
CURLOPT_HTTPHEADER,
array('Accept-Language: ' . $acceptLanguage)
['Accept-Language: ' . $acceptLanguage]
);
curl_setopt($ch, CURLOPT_MAXREDIRS, $maxRedirs);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
@ -90,7 +90,7 @@ function get_http_response(
curl_setopt($ch, CURLOPT_USERAGENT, $userAgent);
// Max download size management
curl_setopt($ch, CURLOPT_BUFFERSIZE, 1024*16);
curl_setopt($ch, CURLOPT_BUFFERSIZE, 1024 * 16);
curl_setopt($ch, CURLOPT_NOPROGRESS, false);
if (is_callable($curlHeaderFunction)) {
curl_setopt($ch, CURLOPT_HEADERFUNCTION, $curlHeaderFunction);
@ -122,9 +122,9 @@ function get_http_response(
* 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
@ -135,7 +135,7 @@ function get_http_response(
$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;
@ -146,7 +146,7 @@ function get_http_response(
$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 {
@ -157,7 +157,7 @@ function get_http_response(
}
}
return array($headers, $content);
return [$headers, $content];
}
/**
@ -188,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);
@ -207,7 +207,7 @@ function get_http_response_fallback(
}
if (! $headers) {
return array($headers, false);
return [$headers, false];
}
try {
@ -215,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];
}
/**
@ -237,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);
@ -248,7 +250,7 @@ function get_redirected_headers($url, $redirectionLimit = 3)
}
}
return array($headers, $url);
return [$headers, $url];
}
/**
@ -270,7 +272,7 @@ function getAbsoluteUrl($originalUrl, $newUrl)
}
$parts = parse_url($originalUrl);
$final = $parts['scheme'] .'://'. $parts['host'];
$final = $parts['scheme'] . '://' . $parts['host'];
$final .= (!empty($parts['port'])) ? $parts['port'] : '';
$final .= '/';
if ($newUrl[0] != '/') {
@ -323,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;
@ -344,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;
}
/**
@ -550,7 +557,8 @@ function get_curl_download_callback(
&$title,
&$description,
&$keywords,
$retrieveDescription
$retrieveDescription,
$tagsSeparator
) {
$currentChunk = 0;
$foundChunk = null;
@ -566,8 +574,12 @@ function get_curl_download_callback(
*
* @return int|bool length of $data or false if we need to stop the download
*/
return function ($ch, $data) use (
return function (
$ch,
$data
) use (
$retrieveDescription,
$tagsSeparator,
&$charset,
&$title,
&$description,
@ -598,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);
}
}
@ -609,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))

View file

@ -38,7 +38,6 @@ class MetadataRetriever
$title = null;
$description = null;
$tags = null;
$retrieveDescription = $this->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.
@ -52,7 +51,8 @@ class MetadataRetriever
$title,
$description,
$tags,
$retrieveDescription
$this->conf->get('general.retrieve_description'),
$this->conf->get('general.tags_separator', ' ')
)
);

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

View file

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

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

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

View file

@ -88,7 +88,8 @@ class Updater
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;
@ -121,12 +122,12 @@ class Updater
public function readUpdates(string $updatesFilepath): array
{
return UpdaterUtils::read_updates_file($updatesFilepath);
return UpdaterUtils::readUpdatesFile($updatesFilepath);
}
public function writeUpdates(string $updatesFilepath, array $updates): void
{
UpdaterUtils::write_updates_file($updatesFilepath, $updates);
UpdaterUtils::writeUpdatesFile($updatesFilepath, $updates);
}
/**
@ -152,7 +153,8 @@ class Updater
$updated = false;
foreach ($this->bookmarkService->search() as $bookmark) {
if ($bookmark->isNote()
if (
$bookmark->isNote()
&& startsWith($bookmark->getUrl(), '?')
&& 1 === preg_match('/^\?([a-zA-Z0-9-_@]{6})($|&|#)/', $bookmark->getUrl(), $match)
) {

View file

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

View file

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

View file

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

View file

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

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