Compare commits

..

5 commits

Author SHA1 Message Date
0f473eedfc Fix unit test 2016-12-08 11:02:38 +01:00
a197ef5e02 Restore custum tpl dir 2016-12-08 10:03:46 +01:00
81b9c01366 Rename Default to default 2016-12-08 09:46:34 +01:00
057fb6839c Reset doc file 2016-12-08 09:38:42 +01:00
d33763a409 #502 Change templates set through administration UI 2016-12-07 11:58:25 +01:00
753 changed files with 24350 additions and 90210 deletions

View file

@ -1,12 +0,0 @@
module.exports = {
"extends": "airbnb-base",
"env": {
"browser": true,
},
"rules": {
"no-param-reassign": 0, // manipulate DOM style properties
"no-restricted-globals": 0, // currently Shaarli uses alert/confirm, could be be improved later
"no-alert": 0, // currently Shaarli uses alert/confirm, could be be improved later
"no-cond-assign": [2, "except-parens"], // assignment in while loops is readable and avoid assignment duplication
}
};

View file

@ -1,15 +0,0 @@
module.exports = {
extends: 'stylelint-config-standard',
plugins: [
"stylelint-scss"
],
rules: {
"indentation": [2],
"number-leading-zero": null,
// Replace CSS @ with SASS ones
"at-rule-no-unknown": null,
"scss/at-rule-no-unknown": true,
// not compatible with SASS apparently
"no-descending-specificity": null
},
}

View file

@ -1,16 +0,0 @@
[global]
daemonize = no
[www]
user = nginx
group = nginx
listen.owner = nginx
listen.group = nginx
catch_workers_output = yes
listen = /var/run/php-fpm.sock
pm = dynamic
pm.max_children = 20
pm.start_servers = 1
pm.min_spare_servers = 1
pm.max_spare_servers = 3
pm.max_requests = 2048

View file

@ -1,2 +0,0 @@
#!/bin/sh
/bin/true

View file

@ -1,2 +0,0 @@
#!/bin/execlineb -P
nginx

View file

@ -1,2 +0,0 @@
#!/bin/execlineb -P
php-fpm8 -F

View file

@ -1,64 +0,0 @@
# Docker-ignore
.dev
.git
.github
.gitattributes
.gitignore
tests
# Docker related resources are not needed inside the container
.dockerignore
Dockerfile
Dockerfile.armhf
# Docker Compose resources
docker-compose.yml
# Shaarli runtime resources
cache/*
data/*
pagecache/*
tmp/*
# Shaarli's docs are created during the build
doc/html/
# Eclipse project files
.settings
.buildpath
.project
# Raintpl generated pages
*.rtpl.php
# 3rd-party dependencies
vendor/
# Release archives
*.tar.gz
*.zip
inc/languages/*/LC_MESSAGES/shaarli.mo
# Development and test resources
coverage
doxygen
sandbox
phpmd.html
# User plugin configuration
plugins/*/config.php
# 3rd party themes
tpl/*
!tpl/default
!tpl/vintage
# Front end
node_modules
tpl/default/js
tpl/default/css
tpl/default/fonts
tpl/default/img
tpl/vintage/js
tpl/vintage/css
tpl/vintage/img

View file

@ -1,23 +0,0 @@
# EditorConfig: http://EditorConfig.org
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 4
[*.{htaccess,html,scss,js,json,xml,yml}]
indent_size = 2
[*.php]
max_line_length = 120
[Dockerfile]
max_line_length = 80
[Makefile]
indent_style = tab

21
.gitattributes vendored
View file

@ -10,38 +10,21 @@
*.php text diff=php *.php text diff=php
Dockerfile text Dockerfile text
# Do not alter images nor minified scripts nor fonts # Do not alter images nor minified scripts
*.ico binary *.ico binary
*.jpg binary *.jpg binary
*.png binary *.png binary
*.svg binary
*.otf binary
*.eot binary
*.woff binary
*.woff2 binary
*.ttf binary
*.min.css binary *.min.css binary
*.min.js binary *.min.js binary
*.mo binary
# Exclude from Git archives # Exclude from Git archives
.editorconfig export-ignore
.dev export-ignore
.gitattributes export-ignore .gitattributes export-ignore
.github export-ignore
.gitignore export-ignore .gitignore export-ignore
.travis.yml export-ignore .travis.yml export-ignore
doc/**/*.json export-ignore doc/**/*.json export-ignore
doc/**/*.md export-ignore doc/**/*.md export-ignore
.docker/ export-ignore docker/ export-ignore
.dockerignore export-ignore
docker-compose.* export-ignore
Dockerfile* export-ignore
Doxyfile export-ignore Doxyfile export-ignore
Makefile export-ignore Makefile export-ignore
node_modules/ export-ignore
doc/conf.py export-ignore
doc/requirements.txt export-ignore
doc/html/.doctrees/ export-ignore
phpunit.xml export-ignore phpunit.xml export-ignore
tests/ export-ignore tests/ export-ignore

22
.github/mailmap vendored
View file

@ -1,22 +0,0 @@
ArthurHoaro <arthur@hoa.ro> <arthur.hoareau@wizacha.com>
ArthurHoaro <arthur@hoa.ro> Arthur
Florian Eula <eula.florian@gmail.com> feula
Florian Eula <eula.florian@gmail.com> <mr.pikzen@gmail.com>
Immánuel Fodor <immanuelfactor+github@gmail.com>
Immánuel Fodor <immanuelfactor+github@gmail.com> Immánuel! <21174107+immanuelfodor@users.noreply.github.com>
kalvn <kalvnthereal@gmail.com> <kalvn@users.noreply.github.com>
kalvn <kalvnthereal@gmail.com> <kalvn@pm.me>
Neros <contact@neros.fr> <NerosTie@users.noreply.github.com>
Nicolas Danelon <hi@nicolasmd.com.ar> nicolasm
Nicolas Danelon <hi@nicolasmd.com.ar> <nda@3818.com.ar>
Nicolas Danelon <hi@nicolasmd.com.ar> <nicolasdanelon@gmail.com>
Nicolas Danelon <hi@nicolasmd.com.ar> <nicolasdanelon@users.noreply.github.com>
Sébastien Sauvage <sebsauvage@sebsauvage.net>
Sébastien NOBILI <code@pipoprods.org> <s-code-github@pipoprods.org>
Timo Van Neerden <fire@lehollandaisvolant.net>
Timo Van Neerden <fire@lehollandaisvolant.net> lehollandaisvolant <levoltigeurhollandais@gmail.com>
VirtualTam <virtualtam@flibidi.net> <tamisier.aurelien@gmail.com>
VirtualTam <virtualtam@flibidi.net> <virtualtam+github@flibidi.net>
VirtualTam <virtualtam@flibidi.net> <virtualtam@flibidi.org>
Willi Eggeling <thewilli@gmail.com> <mail@wje-online.de>
Willi Eggeling <thewilli@gmail.com> <thewilli@users.noreply.github.com>

View file

@ -1,106 +0,0 @@
name: Shaarli CI
on: [push, pull_request]
jobs:
php:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php-versions: ['7.4', '8.0', '8.1', '8.2']
name: PHP ${{ matrix.php-versions }}
steps:
- name: Set locales
run: |
sudo locale-gen de_DE.utf8 && \
sudo locale-gen en_US.utf8 && \
sudo locale-gen fr_FR.utf8 && \
sudo dpkg-reconfigure --frontend=noninteractive locales
- name: Install Gettext
run: sudo apt-get install gettext
- name: Checkout
uses: actions/checkout@v3
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-versions }}
extensions: gd, xml, curl, mbstring, intl, gettext
tools: composer:v2
- name: Check PHP version
run: php -v
- name: Setup Composer from PHP version + update
run: composer config --unset platform && composer config platform.php ${{ matrix.php-versions }}
- name: Update dependencies for PHP 8.x
if: ${{ matrix.php-versions == '8.0' || matrix.php-versions == '8.1' }}
run: |
composer update && \
composer remove --dev phpunit/phpunit && \
composer require --dev phpunit/php-text-template ^2.0 && \
composer require --dev phpunit/phpunit ^9.0
- name: Update dependencies for PHP 7.x
if: ${{ matrix.php-versions != '8.0' && matrix.php-versions != '8.1' }}
run: composer update
- name: Clean up
run: make clean
- name: Check permissions
run: make check_permissions
- name: Run PHPCS
run: make code_sniffer
- name: Run tests
run: make all_tests
node:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '14.x'
- name: Yarn install
run: yarnpkg install
- name: Verify successful frontend builds
run: yarnpkg run build
- name: JS static analysis
run: make eslint
- name: Linter for SASS syntax
run: make sasslint
python:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: 3.8
- name: Build documentation
run: make htmldoc
trivy-repo:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Run trivy scanner on repository (non-blocking)
run: make test_trivy_repo TRIVY_EXIT_CODE=0

View file

@ -1,45 +0,0 @@
name: Build/push Docker image (master/latest)
on:
push:
branches: [ master ]
jobs:
docker-build:
runs-on: ubuntu-latest
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Checkout
uses: actions/checkout@v3
- name: Set shaarli version to the latest commit hash
run: sed -i "s/dev/$(git rev-parse --short HEAD)/" shaarli_version.php
- name: Build and push
id: docker_build
uses: docker/build-push-action@v4
with:
context: .
push: true
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: |
${{ secrets.DOCKER_IMAGE }}:latest
ghcr.io/${{ secrets.DOCKER_IMAGE }}:latest
- name: Image digest
run: echo ${{ steps.docker_build.outputs.digest }}
- name: Run trivy scanner on latest docker image
run: make test_trivy_docker TRIVY_TARGET_DOCKER_IMAGE=ghcr.io/${{ secrets.DOCKER_IMAGE }}:latest

View file

@ -1,21 +0,0 @@
name: Build Docker image (Pull Request)
on:
pull_request:
branches: [ master ]
jobs:
docker-build:
runs-on: ubuntu-latest
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Build Docker image
id: docker_build
uses: docker/build-push-action@v2
with:
push: false
tags: shaarli/shaarli:pr-${{ github.event.number }}
- name: Image digest
run: echo ${{ steps.docker_build.outputs.digest }}

View file

@ -1,43 +0,0 @@
name: Build/push Docker image (tags/releases)
on:
push:
tags:
- "v*.*.*"
branches:
- "v*.*"
- release
jobs:
docker-build:
runs-on: ubuntu-latest
steps:
- name: Get the tag name
run: echo "REF=${GITHUB_REF##*/}" >> $GITHUB_ENV
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
id: docker_build
uses: docker/build-push-action@v3
with:
push: true
platforms: linux/amd64,linux/arm/v7
tags: |
${{ secrets.DOCKER_IMAGE }}:${{ env.REF }}
ghcr.io/${{ secrets.DOCKER_IMAGE }}:${{ env.REF }}
- name: Image digest
run: echo ${{ steps.docker_build.outputs.digest }}

49
.gitignore vendored
View file

@ -13,61 +13,18 @@ pagecache
*.rtpl.php *.rtpl.php
# 3rd-party dependencies # 3rd-party dependencies
composer.lock
vendor/ vendor/
# Release archives # Release archives
*.tar.gz *.tar
*.zip *.zip
inc/languages/*/LC_MESSAGES/shaarli.mo
# Development and test resources # Development and test resources
coverage coverage
doxygen
sandbox sandbox
phpmd.html phpmd.html
phpdoc.xml
.phpunit.result.cache
trivy
# User plugin configuration # User plugin configuration
plugins/*
!addlink_toolbar
!archiveorg
!default_colors
!demo_plugin
!isso
!myShaarli
!piwik
!playvideos
!pubsubhubbub
!qrcode
!wallabag
plugins/*/config.php plugins/*/config.php
plugins/default_colors/default_colors.css
# HTML documentation
doc/html/
doc/phpdoc/
# 3rd party themes
tpl/*
!tpl/default
!tpl/vintage
!tpl/myShaarli
contact.php
formStyle.css
# Front end
node_modules
tpl/default/js
tpl/default/css
tpl/default/fonts
tpl/default/img
tpl/vintage/js
tpl/vintage/css
tpl/vintage/img
.composer.lock
# Documented scripts
generate_templates.php

View file

@ -1,37 +0,0 @@
# Disable directory listing
Options -Indexes
RewriteEngine On
# Prevent accessing subdirectories not managed by SCM
RewriteRule ^(.git|doxygen|vendor) - [F]
# Forward the "Authorization" HTTP header
# fixes JWT token not correctly forwarded on some Apache/FastCGI setups
RewriteCond %{HTTP:Authorization} ^(.*)
RewriteRule .* - [e=HTTP_AUTHORIZATION:%1]
# Alternative (if the 2 lines above don't work)
# SetEnvIf Authorization .+ HTTP_AUTHORIZATION=$0
# Slim URL Redirection
# Ionos Hosting needs RewriteBase /
# RewriteBase /
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L]
<LimitExcept GET POST PUT DELETE PATCH OPTIONS>
<IfModule version_module>
<IfVersion >= 2.4>
Require all denied
</IfVersion>
<IfVersion < 2.4>
Allow from none
Deny from all
</IfVersion>
</IfModule>
<IfModule !version_module>
Require all denied
</IfModule>
</LimitExcept>

View file

@ -1,23 +0,0 @@
# .readthedocs.yml
# Read the Docs configuration file
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
# Required
version: 2
# Build documentation in the "docs/" directory with Sphinx
sphinx:
configuration: doc/conf.py
builder: html
build:
os: ubuntu-22.04
tools:
python: "3.11"
commands:
- pip install sphinx==7.1.0 furo==2023.7.26 myst-parser sphinx-design
- sphinx-build -b html -c doc/ doc/md/ _readthedocs/html/
python:
install:
- requirements: doc/requirements.txt

18
.travis.yml Normal file
View file

@ -0,0 +1,18 @@
sudo: false
language: php
cache:
directories:
- $HOME/.composer/cache
php:
- 7.0
- 5.6
- 5.5
- 5.4
- 5.3
install:
- composer self-update
- composer install --prefer-dist
script:
- make clean
- make check_permissions
- make test

123
AUTHORS
View file

@ -1,123 +0,0 @@
1216 ArthurHoaro <arthur@hoa.ro>
456 nodiscc <nodiscc@gmail.com>
405 VirtualTam <virtualtam@flibidi.net>
56 Sébastien Sauvage <sebsauvage@sebsauvage.net>
27 dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
19 Keith Carangelo <mail@kcaran.com>
16 Luce Carević <lcarevic@access42.net>
15 Florian Eula <eula.florian@gmail.com>
14 Emilien Klein <emilien@klein.st>
12 Nicolas Danelon <hi@nicolasmd.com.ar>
9 Lucas Cimon <lucas.cimon@gmail.com>
9 Willi Eggeling <thewilli@gmail.com>
8 Christophe HENRY <christophe.henry@sbgodin.fr>
6 Immánuel Fodor <immanuelfactor+github@gmail.com>
6 YFdyh000 <yfdyh000@gmail.com>
6 kalvn <kalvnthereal@gmail.com>
6 B. van Berkum <dev@dotmpe.com>
6 Immánuel Fodor <immanuelfactor+github@gmail.com>
6 YFdyh000 <yfdyh000@gmail.com>
6 kalvn <kalvnthereal@gmail.com>
6 llune <llune@users.noreply.github.com>
5 Mark Schmitz <kramred@gmail.com>
5 Sébastien NOBILI <code@pipoprods.org>
4 Alexandre Alapetite <alexandre@alapetite.fr>
4 yude <yudesleepy@gmail.com>
4 David Sferruzza <david.sferruzza@gmail.com>
4 yude <yudesleepy@gmail.com>
3 Agurato <mail.vmonot@gmail.com>
3 Christoph Stoettner <christoph.stoettner@stoeps.de>
3 Olivier <bourreauolivier@gmail.com>
3 Teromene <teromene@teromene.fr>
3 yudete <yu@yude.moe>
2 Alexander Railean <alexandr.railean@arculus.de>
2 Alexandre G.-Raymond <alex@ndre.gr>
2 Chris Kuethe <chris.kuethe@gmail.com>
2 Doug Breaux <25640850+dougbreaux@users.noreply.github.com>
2 Felix Bartels <felix@host-consultants.de>
2 Ganesh Kandu <kanduganesh@gmail.com>
2 Gregory <gregory@nosheep.fr>
2 Guillaume Virlet <github@virlet.org>
2 Knah Tsaeb <Knah-Tsaeb@knah-tsaeb.org>
2 Mathieu Chabanon <git@matchab.fr>
2 Miloš Jovanović <mjovanovic@gmail.com>
2 Neros <contact@neros.fr>
2 Qwerty <champlywood@free.fr>
2 Sebastien Wains <sebw@users.noreply.github.com>
2 Stephen Muth <smuth4@gmail.com>
2 Timo Van Neerden <fire@lehollandaisvolant.net>
2 flow.gunso <flow.gunso@gmail.com>
2 julienCXX <software@chmodplusx.eu>
2 Knah Tsaeb <Knah-Tsaeb@knah-tsaeb.org>
2 philipp-r <philipp-r@users.noreply.github.com>
2 pips <pips@e5150.fr>
2 prog-it <pash.vld@gmail.com>
2 trailjeep <trailjeep@gmail.com>
1 leyrer <gitlab@leyrer.priv.at>
1 locness3 <37651007+locness3@users.noreply.github.com>
1 owen bell <66233223+xfnw@users.noreply.github.com>
1 philipp <philipp@philipp.PC.Ubuntu>
1 rfolo9li <50079896+rfolo9li@users.noreply.github.com>
1 sprak3000 <sprak3000+github@gmail.com>
1 yudejp <i@yude.jp>
1 Rajat Hans <rajathans9@gmail.com>
1 Adrien le Maire <adrien@alemaire.be>
1 Ajabep <ajabep@users.noreply.github.com>
1 Alexis J <alexis@effingo.be>
1 Alistair Young <avatar@arkane-systems.net>
1 Amadeous <amadeous@users.noreply.github.com>
1 Angristan <angristan@users.noreply.github.com>
1 Bish Erbas <42714627+bisherbas@users.noreply.github.com>
1 BoboTiG <bobotig@gmail.com>
1 Brendan M. Sleight <bms.git@barwap.com>
1 Bronco <bronco@warriordudimanche.net>
1 Buster One <37770318+buster-one@users.noreply.github.com>
1 D Low <daniellowtw@gmail.com>
1 Daniel Jakots <vigdis@chown.me>
1 David <dajare@gmail.com>
1 David Foucher <dev@tyjak.net>
1 Denis Renning <denis@devtty.de>
1 Dennis Verspuij <dennisverspuij@users.noreply.github.com>
1 Dimtion <zizou.xena@gmail.com>
1 Fanch <fanch-github@qth.fr>
1 Felix Kästner <github.com-fpunktk@fpunktk.de>
1 Florian Voigt <flvoigt@me.com>
1 Franck Kerbiriou <FranckKe@users.noreply.github.com>
1 Gary Marigliano <gmarigliano93@gmail.com>
1 Hazhar Galeh <78073762+hazhargaleh@users.noreply.github.com>
1 Hg <dev@indigo.re>
1 Jens Kubieziel <github@kubieziel.de>
1 Jonathan Amiez <jonathan.amiez@gmail.com>
1 Jonathan Druart <jonathan.druart@gmail.com>
1 Julien Pivotto <roidelapluie@inuits.eu>
1 Kevin Canévet <kevin@streamroot.io>
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 Nicolas Friedli <nicolas@theologique.ch>
1 Nicolas Le Gaillart <nicolas@legaillart.fr>
1 Paul van den Burg <github@paulvandenburg.nl>
1 Rajat Hans <rajathans9@gmail.com>
1 Sbgodin <Sbgodin@users.noreply.github.com>
1 ToM <tom@leloop.org>
1 TsT <tst2005@gmail.com>
1 agentcobra <agentcobra@free.fr>
1 aguy <aguytech@users.noreply.github.com>
1 bschwede <bschwede@users.noreply.github.com>
1 bschwede <gummibando@gmx.net>
1 clach04 <clach04@gmail.com>
1 dimtion <zizou.xena@gmail.com>
1 durcheinandr <jochen@durcheinandr.de>
1 heimpogo <hypertexthome@googlemail.com>
1 jalr <mail@jalr.de>
1 lapineige <lapineige@users.noreply.github.com>
1 leyrer <gitlab@leyrer.priv.at>
1 locness3 <37651007+locness3@users.noreply.github.com>
1 owen bell <66233223+xfnw@users.noreply.github.com>
1 philipp <philipp@philipp.PC.Ubuntu>
1 rfolo9li <50079896+rfolo9li@users.noreply.github.com>
1 sprak3000 <sprak3000+github@gmail.com>
1 yudejp <i@yude.jp>

View file

@ -4,705 +4,8 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/) The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/). and this project adheres to [Semantic Versioning](http://semver.org/).
## [v0.13.0](https://github.com/shaarli/Shaarli/releases/tag/v0.12.3) - 2023-11-22
> Major changes:
> - Security: Fix XSS vulnerability in tag search
> - Drop support for PHP 7.1, 7.2 and 7.3
### Added
* Docker build: add ARM64 platform and bump Github action version by @ArthurHoaro in https://github.com/shaarli/Shaarli/pull/1965
* github actions: build OCI images that contain both amd64 and armv7 by @nodiscc in https://github.com/shaarli/Shaarli/pull/1962
* Expose tags_separator config through /info API by @amadeous in https://github.com/shaarli/Shaarli/pull/1997
* tools: github actions: build docker images on pull requests by @nodiscc in https://github.com/shaarli/Shaarli/pull/2014
* doc: server configuration: add PHP 8.2 to PHP compatibility table by @nodiscc in https://github.com/shaarli/Shaarli/pull/2021
* Add shaarli-stack theme to Community-and-related-software.md by @dajare in https://github.com/shaarli/Shaarli/pull/2028
* doc: document general.download_max_size/timeout configuration settings by @nodiscc in https://github.com/shaarli/Shaarli/pull/2036
* doc: troubleshooting: automatic title retrieval fails when it is set by javascript by @nodiscc in https://github.com/shaarli/Shaarli/pull/2037
### Changed
* doc: update release procedure (merge the latest release to the release branch) + use the release branch for latest release version detection by @nodiscc in https://github.com/shaarli/Shaarli/pull/1960
* Update german translation by @bschwede in https://github.com/shaarli/Shaarli/pull/1969
* Update Server-configuration.md by @reinboldg in https://github.com/shaarli/Shaarli/pull/1973
* Update Community-and-related-software.md by @nlegaillart in https://github.com/shaarli/Shaarli/pull/1984
* doc: improve docs on usage of OR operator in tags search by @nodiscc in https://github.com/shaarli/Shaarli/pull/1987
* docker: nginx: listen on IPv6 in addition to IPv4 by @cerebrate in https://github.com/shaarli/Shaarli/pull/1983
* Doc update, WebSub (formerly PubSubHubbub) plugin by @clach04 in https://github.com/shaarli/Shaarli/pull/2008
* doc: community/related software/integration with other platforms: add link to shaarli debian package by @nodiscc in https://github.com/shaarli/Shaarli/pull/2018
* replace mkdocs with sphinx/myst-parser for HTML documentation generation, documentation improvements by @nodiscc in https://github.com/shaarli/Shaarli/pull/2025
### Fixed
* Makefile: Use GNU tar if available by @ArthurHoaro in https://github.com/shaarli/Shaarli/pull/1957
* Support: ignore disk_free_space if the function is unavailable by @ArthurHoaro in https://github.com/shaarli/Shaarli/pull/1970
* Documentation: fix broken link to 3rd party plugins by @ArthurHoaro in https://github.com/shaarli/Shaarli/pull/1975
* Fix autofocus: load bulk action input on linklist only by @ArthurHoaro in https://github.com/shaarli/Shaarli/pull/1976
* doc: fix mkdocs build warnings/relative links by @nodiscc in https://github.com/shaarli/Shaarli/pull/2015
* correct usage of hyphens in all occurences of 'super fast, database-free' by @nodiscc in https://github.com/shaarli/Shaarli/pull/2003
### Removed
* Drop support for PHP 7.1, 7.2 and 7.3 by @ArthurHoaro in https://github.com/shaarli/Shaarli/pull/1958
* doc: themes: remove unmaintained themes by @nodiscc in https://github.com/shaarli/Shaarli/pull/2030
* doc: remove bountysource badge by @nodiscc in https://github.com/shaarli/Shaarli/pull/2035
### Security
* Fix XSS vulnerability in tag search by @ArthurHoaro in https://github.com/shaarli/Shaarli/pull/2039
* tools: run trivy vulnerability scanner on the 'latest' docker image by @nodiscc in https://github.com/shaarli/Shaarli/pull/1980
* github actions: fix value of TRIVY_TARGET_DOCKER_IMAGE by @nodiscc in https://github.com/shaarli/Shaarli/pull/1989
* tools/CI: scan repository with trivy security scanner (yarn.lock, composer.lock) by @nodiscc in https://github.com/shaarli/Shaarli/pull/1998
* tools/tests: update trivy to v0.44.0 by @nodiscc in https://github.com/shaarli/Shaarli/pull/2012
* docker: update base alpine docker image to 3.16.7 by @nodiscc in https://github.com/shaarli/Shaarli/pull/2024
**Full Changelog**: https://github.com/shaarli/Shaarli/compare/v0.12.2...v0.12.3
## [v0.12.2](https://github.com/shaarli/Shaarli/releases/tag/v0.12.2) - 2023-03-18
> Docker: use `ghcr.io/shaarli/shaarli` as Docker image instead of `shaarli/shaarli`.
> The `:master` Docker image has been removed, please use `:latest` instead.
> The `:stable` Docker image has been removed, please use `:release` instead.
## Added
- Bulk action: add or delete tag to multiple bookmarks
- New Core Plugin: ReadItLater
- Plugin system: allow plugins to provide custom routes
- Support search highlights when matching URL content
- Support for OR (~) and optional AND (+) operators for tag search
- Russian translation
- Chinese translation
- Export:
- Export: set a bookmark's LAST_MODIFIED attribute to its update timestamp
- Export: set a bookmark's PRIVATE attribute using an integer value
- Add an additional free disk space check before saving the datastore
- curl: support HTTP/2 response code header
- CI:
- Build and push Docker images through Github Actions
- push container images to github registry in addition to dockerhub
- Documentation:
- Add '206 not acceptable' to the Troubleshooting section
- Add mention to Shaarli Archiver
- doc: add note to adjust proxy timeouts or PHP max execution time
- doc: shaarli configuration: mention file:/// URIs
- add "formatter" key to example config.json.php
## Changed
- docker latest: replace dev in shaarli_version.php with the latest commit
- Daily RSS Cache: invalidate cache base on the date
- Update Japanese translations
- Update German translations
- Templates: Inject current template name
- format_date: include timezone in IntlDateFormatter object
- Handle pagination through BookmarkService
- autocapitalize off for username input
- More intuitive label for plugin checkboxes
- Simple and uniform localized website title
- Use rewrited version of Netscape Bookmark Parser
- tests/makefile: rewrite translate target to be compatible with busybox
- PubSubHub Plugin: make 1 external call per request
- Docker:
- newer alpine (for newer PHP) and apk upgrade
- Dockerfile.armhf: upgrade python2 -> python3
- Dockerfile: add php8-gettext package
- update s6 service definition to use php-fpm8
- install php8-ldap in Docker images
- CI:
- use Github Action instead of Travis CI
- use the yarnpkg command instead of yarn
- tools: github actions: fix PHP 8.0 tests
- github actions: add tests for PHP 8.2
- Documentation:
- apache: explicitely ste index.php as DirectoryIndex
- bookmarklet is now working on github.com
- LDAP login support, update php requirements list
- installation/tests: clarify build tools installation procedure
- doc: PHP extensions are also required for development
- doc: move OCI images hosting to ghcr.io
## Fixed
- Error handling if the datastore mutex is not working
- Synchronous metadata retrieval is failing in strict mode
- Improve metadata extraction
- Typo: 'Authentication' ->
- default_colors plugin: update CSS file on color change
- API: POST/PUT Link - properly parse tags string
- Error when using bulk shaare with a single URL
- Bulk Shaare:
- use unique HTML ID
- error with a single URL
- redirection with ending slash
- Bug when trying to access ATOM feed without bookmarks
- Documentation build
- pubsubhubbub hub link in RSS / Atom.
- Monthly views previous/next month links during month
- Resolve PHP 8.1 deprecation warnings
- Fix PHP 8 incompatibility with debug mode enabled
- Fixed Roboto-Regular and Roboto-Bold font declarations
- template/vintage: fix typo in visibility selection link
- Do not display deprecated warnings by default
- Fix a bug when using '/' as a tag separator
- Fix Logger exception: gracefully handle permission issue
- Documentation:
- plugins.md: fix link casing
## Removed
- Daily RSS: Remove relative description (today, yesterday)
- Documentation:
- remove the markdown plugin from the plugins list
- remove duplicate "general" key in example config.php.json
## [v0.12.1](https://github.com/shaarli/Shaarli/releases/tag/v0.12.1) - 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
**Save you `data/` folder before updating!**
### Added
- Thumbnailer: add soundcloud.com to list of common media domains
- Markdown rendering is now integrated into Shaarli core
- Add autofocus on tag cloud filter input
- Japanese translations
- Japanese translation: add language to admin configuration page
- Support for PHP 8.0
- Support for local anchor URL (starting with `#`)
- LDAP authentication
- Encapsulated PageCacheManager
- Docs:
- add screenshots of all pages
- section about mkdocs
- Ulauncher extension
- CI: run against PHP 7.4
- Added $links_per_page variable to template and display on default
- Inject BookmarkServiceInterface in plugins data
- Add manual configuration for root URL
- Added PATCH to the allowed Apache request methods.
- REST API: compatibility with ionos Apache's headers
### Changed
- Introduce Bookmark object and Service layer
- Save bookmark as objects in the datastore
- Handle bookmark as objects across the whole codebase (except templates and plugins)
- Process all Shaarli page through Slim controller, with proper URL rewriting (see #1516)
- Docs: the entire documentation has been reviewed, updated and improved, thanks to @nodiscc!
- ATOM feed: use instance name as author name instead of URL
- Updated French translation
- Default colors plugin: generate CSS file during initialization
- Improve default bookmarks after install
- Upgrade all front end dependencies and webpack build
- Default theme: Make tag cloud/list views buttons more obvious
### Fixed
- Undefined index: thumbnail in daily page
- Undefined index: thumbnail on OpenGraph headers
- Undefined index: updated on linklist
- Make sure that bookmark sort is consistent, even with equal timestamps
- Code PHP version check as requirement bumped to PHP 7.1
- Thumbnail images lazy loading
- Markdown plugin: fix RSS feed direct link reverse
- Fix RSS permalink included in Markdown bloc
- Demo plugin: multiple typos
- Makefile target for releases
- Makefile target for html documentation
- Session cookie setting being set while session is active
- Deprecated use of implode
- Division by zero in tag cloud
- CI: deprecated linux distribution and sudo directive
- Docker build: gcc is no longer included in python alpine image
- Default template: display pin button in mobile view
- Pinned bookmarks are not longer displayed first in ATOM/RSS feeds
- Docs:
- Outdated Docker documentation for stable branch
- Outdated links
- Plugin description in meta files
- docker-compose.yml: pin traefik image to 1.7-alpine
### Removed
- Markdown plugin
- Docs:
- emojione & twemoji removed
- Makefile: remove static_analysis_summary from all: target
- doc/Makefile: remove references to composer update
## [v0.11.1](https://github.com/shaarli/Shaarli/releases/tag/v0.11.1) - 2019-08-03
Release to fix broken Docker build on the latest version.
### Fixed
- Fixed Docker build
- Fixed a few documentation broken links
- Fixed broken label in configuration page
### Added
- More accessibility improvements
## [v0.11.0](https://github.com/shaarli/Shaarli/releases/tag/v0.11.0) - 2019-07-27
**Shaarli no longer officially support PHP 5.6 and PHP 7.0 as they've reached end of life.**
**Shaarli classes now use namespace, third party plugins need to update.**
### Added
- Add optional PHP extension to composer suggestions.
- composer: enforce PHP security advisories
- phpDocumentor configuration and make target
- Run unit tests against PHP 7.3
- Bunch of accessibility improvements to the default template, thanks to @llune
- Bulk actions: set visibility
- Display sticky label in linklist
- Add print CSS rules to the default template
- New setting to automatically retrieve description for new bookmarks
- Plugin to override default template colors
### Changed
- Shaarli now uses namespaces for its classes.
- Rewrite IP ban management
- Default template: slightly lighten visited link color
- Hide select all button on mobile view
- Switch from FontAwesome v4.x to ForkAwesome
- Daily - display the current day instead of the previous one
### Fixed
- Do not check the IP address with session protection disabled
- API: update test regexes to comply with PCRE2
- Optimize and cleanup imports
- ensure HTML tags are stripped from OpenGraph description
- Documentation invalid links
- Thumbnails disabling if PHP GD is not installed
- Warning if links sticky status isn't set
- Fix button overlapping on mobile in linklist
- Do not try to retrieve thumbnails for internal link
- Update node-sass to fix a vulnerability in node tar dependency
- armhf Dockerfile
- Default template: Responsive issue with delete button fix
- Persist sticky status on bookmark update
### Removed
- Doxygen configuration
- redirector setting
- QRCode link to an external service
## [v0.10.4](https://github.com/shaarli/Shaarli/releases/tag/v0.10.4) - 2019-04-16
### Fixed
- Fix thumbnails disabling if PHP GD is not installed
- Fix a warning if links sticky status isn't set
## [v0.10.3](https://github.com/shaarli/Shaarli/releases/tag/v0.10.3) - 2019-02-23
### Added
- Add OpenGraph metadata tags on permalink page
- Add CORS headers to REST API reponses
- Add a button to toggle checkboxes of displayed links
- Add an icon to the link list when the Isso plugin is enabled
- Add noindex, nofollow to documentation pages
- Document usage of robots.txt
- Add a button to set links as sticky
### Changed
- Update French translation
- Refactor the documentation homepage
- Bump netscape-bookmark-parser
- Update session_start condition
- Improve accessibility
- Cleanup and refactor lint tooling
### Fixed
- Fix input size for dropdown search form
- Fix history for bulk link deletion
- Fix thumbnail requests
- Fix hashtag rendering when markdown escaping is enabled
- Fix AJAX tag deletion
- Fix lint errors and improve PSR-1 and PSR-2 compliance
### Removed
- Remove Firefox Share documentation
## [v0.10.2](https://github.com/shaarli/Shaarli/releases/tag/v0.10.2) - 2018-08-11
### Fixed
- Docker build
## [v0.10.1](https://github.com/shaarli/Shaarli/releases/tag/v0.10.1) - 2018-08-11
### Changed
- Accessibility:
- Remove alt text on the logo
- Remove redundant title in tools page
### Fixed
- Fixed an error on the daily page and daily RSS
- Fixed an issue causing 'You are not authorized to add a link' error while logged out
- Fixed thumbnail path when Shaarli's path uses symbolic links
- Add a `mod_version` check in Shaarli's root `.htaccess` file for Apache 2.2 syntax
- Include assets in the release Makefile target
### Removed
- Firefox Social API shaare has been removed
## [v0.10.0](https://github.com/shaarli/Shaarli/releases/tag/v0.10.0) - 2018-07-28
**PHP 5.5 compatibility has been dropped.** Shaarli now requires at least PHP 5.6.
### Added
- Add a filter to display public links only
- Add PHP 7.2 support
- Add German translation
- Resolve front-end dependencies from NPM
- Build front-end bundles with Yarn and Webpack
- Lint Javascript code with ESLint
- Lint SASS code with SASSLint
- Support redirection in cURL download callback
- Introduce multi-stage builds for Docker images
- Use Travis matrix and stages to run Javascript tests in a dedicated environment
- Add tag endpoint in the REST API
- Build the documentation in Travis builds
- Provide a Docker Compose example
### Changed
- Use web-thumbnailer to retrieve thumbnails (see #687)
- Use a specific page title in all pages
- Daily: run hooks before creating the columns
- Load theme translations files automatically
- Make max download size and timeout configurable
- Make Nginx logs accessible as stdout/stderr for Docker images
- Update buttons used to toggle link visibility filters
- Rewrite Javascript code for ES6 compliance
- Refactor IP ban management
- Refactor user login management
- Refactor server-side session management
- Update Doxygen configuration
- Update Parsedown
- Improve documentation
- Docker: build the images from the local sources
- Docker: bump alpine version to 3.7
- Docker: expose a volume for the thumbnail cache
### Removed
- Drop support for PHP 5.5
- Remove vendored front-end libraries
- Remove environment specific .gitignore entries
### Fixed
- Ignore the case while checking DOCTYPE during the file import
- Fix removal of on=... attributes from html generated from Markdown
- httpd: always forward the 'Authorization' header
- Ensure user-specific CSS file is loaded
- Fix feed permalink rendering when Markdown escaping is enabled
- Fix order of tags with the same number of occurrences
- Fixed the referrer meta tag in default template
- Disable MkDocs' strict mode for ReadTheDocs builds to pass
- fix and simplify Dockerfile for armhf
### Security
- Update `.htaccess` to prevent accessing Git metadata when using a Git-based installation
## [v0.9.7](https://github.com/shaarli/Shaarli/releases/tag/v0.9.7) - 2018-06-20
### Changed
- Build the Docker images from the local Git sources
## [v0.9.6](https://github.com/shaarli/Shaarli/releases/tag/v0.9.6) - 2018-03-25
### Changed
- htaccess: prevent accessing resources not managed by SCM
- htaccess: always forward the 'Authorization' HTTP header
## [v0.9.5](https://github.com/shaarli/Shaarli/releases/tag/v0.9.5) - 2018-02-02
### Fixed
- Fix a warning happening when `php-intl` is not installed on the system
- Fix warnings happening when updating from legacy SebSauvage version
## [v0.9.4](https://github.com/shaarli/Shaarli/releases/tag/v0.9.4) - 2018-01-30
### Added
- Enable translations: Shaarli is now also available in French. Other language translations are welcome!
- Add EditorConfig configuration
- Add favicons for mobile devices
- Add Alpine Linux arm32v7 Dockerfiles (master, latest)
### Changed
- Do not write bookmark edition history during file imports (performance)
- Migrate Docker images (master, latest) to Alpine Linux
- Improve unitary tests and code coverage
- Improve thumbnail display
- Improve theme ergonomics
- Improve messages if there is no plugin or parameter available in the admin page
- Increase buffer size for cURL download
- Force HTTPS if the original port is 443 behind a reverse proxy (workaround)
- Improve page title retrieval performances
### Removed
- Remove redirector setting from Configure page
### Fixed
- Fix broken links in the documentation
- Enable access to `data/user.css` (Apache 2.2 & 2.4)
- Don't URL encode description links if parameter `redirector.encode_url` is set to false
- Fix an issue preventing the Save button to appear for plugin parameters
## [v0.9.3](https://github.com/shaarli/Shaarli/releases/tag/v0.9.3) - 2018-01-04
**XSS vulnerability fixed. Please update.**
## Security
- Fix an XSS (cross-site-scripting) vulnerability in `index.php` -
[CVE-2018-5249](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2018-5249)
## [v0.9.2](https://github.com/shaarli/Shaarli/releases/tag/v0.9.2) - 2017-10-07
**Major security issue fixed. Please update.**
### Added
- Tag search now supports wildcards `*`
- New setting `privacy.force_login` which can be used with `privacy.hide_public_links` to redirect anonymous users to the login page.
- New setting `general.default_note_title` used to override default `Note:` title prefix for notes.
- Add a version hash for asset loading to prevent browser's cache issue
### Changed
- The "Remember me" checkbox is unchecked by default
- The default value of the "Remember me" checkbox can be configured under `data/config.json.php`
### Removed
- Remove obsolete PHP magic quote support
### Fixed
- Generates a permalink URL if the URL is set to blank
- Replace links to the old GitHub wiki with ReadTheDocs URIs
- Use single quotes in the note bookmarklet
- Daily page if there is no link
- Bulk link deletion with a single link
- HTTPS detection behind a reverse proxy
- Travis tests environment and localization
- Improve template paths robustness (trailing slash)
- Robustness: safer gzinflate/zlib usage
- Description links parsing with parenthesis (without Markdown)
- Templates:
- Sort the tag cloud alphabetically
- Firefox social title
- Improved visited link color
- Fix jumpy textarea with long content in post edit
### Security
- Fixed reflected XSS vulnerability introduced in v0.9.1, discovered by @chb9 ([CVE-2017-15215](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2017-15215)).
## [v0.9.1](https://github.com/shaarli/Shaarli/releases/tag/v0.9.1) - 2017-08-23
The documentation has been migrated to ReadTheDocs:
- https://shaarli.readthedocs.io/
- edits are submitted as pull requests
### Added
- Allow bulk link deletion
- Display subtags in the tag cloud
- Add an endpoint to refresh the token
- Add a token on every page
- Add a tag list view for management
- Add Note bookmarklet
- Add creation date when editing a link
### Changed
- Documentation:
- Generate static HTML documentation with [mkdocs](http://www.mkdocs.org/)
- Host documentation on [ReadTheDocs](http://www.mkdocs.org/)
- Update documentation structure
- Update Makefile targets to:
- Build the docs locally
- Include the generated docs in the release archives
- Theme:
- Use the new theme as the default
- Rename the tag cloud template to `tag.cloud.html`
- Display visited links in grey
- Use only one search form in `linklist.html`
- Hide the "search links with these tags" option when an empty `searchtags` is passed to `tag.list.html`
- Improve HTTP header handling when hosting Shaarli with Docker behind a reverse proxy
- Searching for tags with an empty value returns untagged links only
- Set Travis environment to `precise` until the new `trusty` environment is ready
### Removed
- Remove dead Pubsubhubbub code
- Disable the GitHub wiki (see changed/documentation)
- Remove Docker `dev` image and resources
- Theme:
- Remove the bottom "Sort by" menu in `tag.list.html`
### Fixed
- Fix file existence check for `user.css`
- Limit selection to 2k characters when using the bookmarklet
- Fix JS error `uncaught type error`
- Fix Firefox Social button
- Use pinned PHP dependencies when generating release archives
- Make sure that the tag exists before altering/removing it
### Security
- Add a whitelist for protocols for URLs
## [v0.9.0](https://github.com/shaarli/Shaarli/releases/tag/v0.9.0) - 2017-05-07
This release introduces the REST API, and requires updating HTTP server
configuration to enable URL rewriting, see:
- https://shaarli.github.io/api-documentation/
- https://shaarli.readthedocs.io/en/master/Server-configuration/
**WARNING**: Shaarli now requires PHP 5.5+.
### Added
- REST API v1
- [Slim](https://www.slimframework.com/) framework
- [JSON Web Token](https://jwt.io/introduction/) (JWT) authentication
- versioned API endpoints:
- `/api/v1/info`: get general information on the Shaarli instance
- `/api/v1/links`: get a list of shaared links
- `/api/v1/history`: get a list of latest actions
- Theming:
- Introduce a new theme
- Allow selecting themes/templates from the configuration page
- New/Edit link form can be submitted using CTRL+Enter in the textarea
- Shaarli version is displayed in the footer when logged in
- Add plugin placeholders to Atom/RSS feed templates
- Add OpenSearch to feed templates
- Add `campaign_` to the URL cleanup pattern list
- Add an AUTHORS file and Makefile target to list authors from Git commit data
- Link imports are now logged in `data/` folder, and can be debug using `dev.debug=true` setting.
- `composer.lock` is now included in git file to allow proper `composer install`
- History mechanism which logs link addition/modification/deletion
### Changed
- Docker: enable nginx URL rewriting for the REST API
- Theming:
- Move `user.css` to the `data` folder
- Move default template files to a subfolder (`default`)
- Rename the legacy theme to `vintage`
- Private only filter is now displayed as a search parameter
- Autocomplete: pre-select the first element
- Display daily date in the page title (browser title)
- Timezone lists are now passed as an array instead of raw HTML
- Move PubSubHub to a dedicated plugin
- Coding style:
- explicit method visibility
- safe boolean comparisons
- remove unused variables
- The updater now keeps custom theme preferences
- Simplify the COPYING information
- Improved client locale detection
- Improved date time display depending on the locale
- Partial namespace support for Shaarli classes
- Shaarli version is now only present in `shaarli_version.php`
- Human readable maximum file size upload
### Removed
- PHP < 5.5 compatibility
- ReadItYourself plugin
### Fixed
- Ignore generated release tarballs
- Hide default port when behind a reverse proxy
- Fix a typo in the Markdown plugin description
- Fix the presence of empty tags for private tags and in search results
- Fix a fatal error during the install
- Fix permalink image alignment in daily page
- Fix the delete button in `editlink`
- Fix redirection after link deletion
- Do not access LinkDB links by ID before the Updater applies migrations
- Remove extra spaces in the bookmarklet's name
- Piwik plugin: Piwik URL protocol can now be set (http or https)
- All inline JS has been moved to dedicated JS files
- Keep tags after login redirection
### Security
- Markdown plugin: escape HTML entities by default
## [v0.8.7](https://github.com/shaarli/Shaarli/releases/tag/v0.8.7) - 2018-06-20
### Changed
- Build the Docker image from the local Git sources
### Removed
- Disable PHP 5.3 Travis build (unsupported)
## [v0.8.6](https://github.com/shaarli/Shaarli/releases/tag/v0.8.6) - 2018-02-19
### Changed
- Run version check tests against the 'stable' branch
## [v0.8.5](https://github.com/shaarli/Shaarli/releases/tag/v0.8.5) - 2018-01-04
**XSS vulnerability fixed. Please update.**
## Security
- Fix an XSS (cross-site-scripting) vulnerability in `index.php` -
[CVE-2018-5249](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2018-5249)
## [v0.8.4](https://github.com/shaarli/Shaarli/releases/tag/v0.8.4) - 2017-03-04
### Security
- Markdown plugin: escape HTML entities by default
## [v0.8.3](https://github.com/shaarli/Shaarli/releases/tag/v0.8.3) - 2017-01-20
### Fixed
- PHP 7.1 compatibility: add ConfigManager parameter to anti-bruteforce function call in login template.
## [v0.8.2](https://github.com/shaarli/Shaarli/releases/tag/v0.8.2) - 2016-12-15
### Fixed
- Editing a link created before the new ID system would change its permalink.
## [v0.8.1](https://github.com/shaarli/Shaarli/releases/tag/v0.8.1) - 2016-12-12
> Note: this version will create an automatic backup of your database if anything goes wrong.
## [v0.8.1](https://github.com/shaarli/Shaarli/releases/tag/v0.8.1) - UNPUBLISHED
### Added ### Added
- Add CHANGELOG.md to track the whole project's history - Add CHANGELOG.md to track the whole project's history
- Enable Composer cache for Travis builds - Enable Composer cache for Travis builds
@ -715,14 +18,7 @@ configuration to enable URL rewriting, see:
- Meta tag to *not* send the referrer to external resources. - Meta tag to *not* send the referrer to external resources.
### Changed ### Changed
- Link ID complete refactoring: - Cleanup `{loop}` declarations in templates
- Links now have a numeric ID instead of dates
- Short URLs are now created once and can't change over time (previous URL are kept)
- Templates:
- Changed placeholder behaviour for: `buttons_toolbar`, `fields_toolbar` and `action_plugin`
- Cleanup `{loop}` declarations in templates
- Tools: hide Firefox Social button when not in HTTPS
- Firefox Social: show Shaarli's title when shaaring using Firefox Social
- Release archives now have the same structure as GitHub-generated archives: - Release archives now have the same structure as GitHub-generated archives:
- archives contain a `Shaarli` directory, itself containing sources + dependencies - archives contain a `Shaarli` directory, itself containing sources + dependencies
- the tarball is now gzipped - the tarball is now gzipped
@ -730,6 +26,8 @@ configuration to enable URL rewriting, see:
- Markdown: Parsedown library is now imported through Composer - Markdown: Parsedown library is now imported through Composer
- Minor code cleanup: PHPDoc, spelling, unused variables, etc. - Minor code cleanup: PHPDoc, spelling, unused variables, etc.
- Docker: explicitly set the maximum file upload size to 10 MiB - Docker: explicitly set the maximum file upload size to 10 MiB
- Tools: hide Firefox Social button when not in HTTPS
- Firefox Social: show Shaarli's title when shaaring using Firefox Social
### Fixed ### Fixed
- Fix the server `<self>` value in Atom/RSS feeds - Fix the server `<self>` value in Atom/RSS feeds
@ -742,7 +40,6 @@ configuration to enable URL rewriting, see:
- W3C compliance - W3C compliance
- Use absolute URL for hashtags in RSS and ATOM feeds - Use absolute URL for hashtags in RSS and ATOM feeds
- Docker: specify the location of the favicon - Docker: specify the location of the favicon
- ATOM feed: remove new line between content tag and data
### Security ### Security
- Allow whitelisting trusted IPs, else continue banning clients upon login failure - Allow whitelisting trusted IPs, else continue banning clients upon login failure
@ -784,10 +81,6 @@ Please use our release archives, or follow the
- XSRF token now generated each time a page is rendered - XSRF token now generated each time a page is rendered
## [v0.7.1](https://github.com/shaarli/Shaarli/releases/tag/v0.7.1) - 2017-03-08
### Security
- Markdown plugin: escape HTML entities by default
## [v0.7.0](https://github.com/shaarli/Shaarli/releases/tag/v0.7.0) - 2016-05-14 ## [v0.7.0](https://github.com/shaarli/Shaarli/releases/tag/v0.7.0) - 2016-05-14
### Added ### Added
- Adds an option to encode redirector URL parameter - Adds an option to encode redirector URL parameter

View file

@ -17,10 +17,14 @@ Check the [milestones](https://github.com/shaarli/Shaarli/milestones) to see wha
* You can also join instant discussion at https://gitter.im/shaarli/Shaarli, or via IRC as described [here](https://github.com/shaarli/Shaarli/issues/44#issuecomment-77745105) * You can also join instant discussion at https://gitter.im/shaarli/Shaarli, or via IRC as described [here](https://github.com/shaarli/Shaarli/issues/44#issuecomment-77745105)
### Documentation ### Documentation
**the [wiki](https://github.com/shaarli/Shaarli/wiki) is world-writable** - anyone can edit or add chapters and pages.
The [official documentation](http://shaarli.readthedocs.io/en/rtfd/) is generated from [Markdown](https://daringfireball.net/projects/markdown/syntax) documents in the `doc/md/` directory. HTML documentation is generated using [Mkdocs](http://www.mkdocs.org/). [Read the Docs](https://readthedocs.org/) provides hosting for the online documentation. * Large changes should preferably be discussed in [General discussion](https://github.com/shaarli/Shaarli/issues/44) beforehand (you can post a draft there and edit it).
* If you create a new page, please link it from the new page (eg from the [Other links](https://github.com/shaarli/Shaarli/wiki#other-links) section.
* The wiki is a general documentation about Shaarli: usage, development, hacks, usage tricks, related links, projects. Try to keep it organized.
* The wiki will be synced to Shaarli's `doc/` directory on each release. Keep that in mind when reviewing the quality of your edits.
To edit the documentation, please edit the appropriate `doc/md/*.md` files (and optionally `make htmlpages` to preview changes to HTML files). Then submit your changes as a Pull Request. Have a look at the MkDocs documentation and configuration file `mkdocs.yml` if you need to add/remove/rename/reorder pages. You can make the project known by publishing blog posts/articles/videos about it and adding them to the links section in the wiki.
### Translations ### Translations
Currently Shaarli has no translation/internationalization/localization system available and is single-language. You can help by proposing an i18n system (issue https://github.com/shaarli/Shaarli/issues/121) Currently Shaarli has no translation/internationalization/localization system available and is single-language. You can help by proposing an i18n system (issue https://github.com/shaarli/Shaarli/issues/121)
@ -54,7 +58,7 @@ Please report any problem you might find.
* starting from branch ` master`, switch to a new branch (eg. `git checkout -b my-awesome-feature`) * starting from branch ` master`, switch to a new branch (eg. `git checkout -b my-awesome-feature`)
* edit the required files (from the Github web interface or your text editor) * edit the required files (from the Github web interface or your text editor)
* add and commit your changes with a meaningful commit message (eg `Cool new feature, fixes issue #1001`) * add and commit your changes with a meaningful commit message (eg `Cool new feature, fixes issue #1001`)
* run unit tests against your patched version, see [Running unit tests](https://shaarli.readthedocs.io/en/master/Unit-tests/#run-unit-tests) * run unit tests against your patched version, see [Running unit tests](https://github.com/shaarli/Shaarli/wiki/Running-unit-tests)
* Open your fork in the Github web interface and click the "Compare and Pull Request" button, enter required info and submit your Pull Request. * Open your fork in the Github web interface and click the "Compare and Pull Request" button, enter required info and submit your Pull Request.
All changes you will do on the `my-awesome-feature` in the future will be added to your Pull Request. Don't work directly on the master branch, don't do unrelated work on your `my-awesome-feature` branch. All changes you will do on the `my-awesome-feature` in the future will be added to your Pull Request. Don't work directly on the master branch, don't do unrelated work on your `my-awesome-feature` branch.

60
COPYING
View file

@ -1,52 +1,72 @@
Files: * Files: *
License: zlib/libpng License: zlib/libpng
Copyright: (c) 2011-2015 Sébastien SAUVAGE <sebsauvage@sebsauvage.net> Copyright: (c) 2011-2015 Sébastien SAUVAGE <sebsauvage@sebsauvage.net>
(c) 2011-2018 The Shaarli Community, see AUTHORS (c) 2011-2015 Alexandre Alapetite <alexandre@alapetite.fr>
(c) 2011-2015 David Sferruzza <david.sferruzza@gmail.com>
(c) 2011-2015 Christophe HENRY <christophe.henry@sbgodin.fr>
(c) 2011-2015 Mathieu Chabanon <git@matchab.fr>
(c) 2011-2015 BoboTiG <bobotig@gmail.com>
(c) 2011-2015 Bronco <bronco@warriordudimanche.net>
(c) 2011-2015 Emilien Klein <emilien@klein.st>
(c) 2011-2015 Knah Tsaeb <knah-tsaeb@knah-tsaeb.org>
(c) 2011-2015 Lionel Martin <renarddesmers@gmail.com>
(c) 2011-2015 lehollandaisvolant <levoltigeurhollandais@gmail.com>
(c) 2011-2015 timo van neerden <fire@lehollandaisvolant.net>
(c) 2011-2015 nodiscc <nodiscc@gmail.com>
(c) 2011-2015 Florian Eula <mr.pikzen@gmail.com>
(c) 2011-2015 Arthur Hoaro <arthur@hoa.ro>
(c) 2011-2015 Aurélien "VirtualTam" Tamisier <virtualtam@flibidi.net>
(c) 2011-2015 qwertygc <champlywood@free.fr>
(c) 2011-2015 idleman <idleman@idleman.fr>
(c) 2015 Alexis Ju <alexis@effingo.be>
(c) 2015 dimtion <zizou.xena@gmail.com>
(c) 2015 Fanch <fanch-github@qth.fr>
(c) 2015 Guillaume Virlet <github@virlet.org>
(c) 2015 Felix Bartels <felix@host-consultants.de>
(c) 2015 Marsup <marsup@gmail.com>
(c) 2015 Miloš Jovanović <mjovanovic@gmail.com>
(c) 2015 Nicolás Danelón <hola@nicolasdanelon.com.ar>
(c) 2015 TsT <tst2005@gmail.com>
Files: assets/vintage/css/reset.css
Files: inc/reset.css
License: BSD (http://opensource.org/licenses/BSD-3-Clause) License: BSD (http://opensource.org/licenses/BSD-3-Clause)
Copyright: (c) 2010, Yahoo! Inc. Copyright: (c) 2010, Yahoo! Inc.
Files: assets/vintage/img/calendar.png Files: images/calendar.png, images/edit_icon.png, images/feed-icon-14x14.png, images/private.png, images/private_16x16.png, images/private_16x16_active.png, images/tag_blue.png
assets/vintage/img/edit_icon.png
assets/vintage/img/feed-icon-14x14.png
assets/vintage/img/private.png
assets/vintage/img/private_16x16.png
assets/vintage/img/private_16x16_active.png
assets/vintage/img/tag_blue.png
License: CC-BY (http://creativecommons.org/licenses/by/3.0/) License: CC-BY (http://creativecommons.org/licenses/by/3.0/)
Copyright: (c) 2014 Yusuke Kamiyamane Copyright: (c) 2014 Yusuke Kamiyamane
Source: http://p.yusukekamiyamane.com/ Source: http://p.yusukekamiyamane.com/
Files: assets/vintage/img/delete_icon.png Files: images/delete_icon.png
License: CC-BY (http://creativecommons.org/licenses/by/3.0/) License: CC-BY (http://creativecommons.org/licenses/by/3.0/)
Copyright: (c) 2014 Designmodo Copyright: (c) 2014 Designmodo
Source: http://designmodo.com/linecons-free/ Source: http://designmodo.com/linecons-free/
Files: assets/vintage/img/floral_left.png Files: images/floral_left.png, images/floral_right.png, images/squiggle.png, images/squiggle2.png, images/squiggle_closing.png
assets/vintage/img/floral_right.png
assets/vintage/img/squiggle.png
assets/vintage/img/squiggle_closing.png
Licence: Public Domain Licence: Public Domain
Source: https://openclipart.org/people/j4p4n/j4p4n_ornimental_bookend_-_left.svg Source: https://openclipart.org/people/j4p4n/j4p4n_ornimental_bookend_-_left.svg
Files: assets/vintage/img/Paper_texture_v5_by_bashcorpo_w1000.jpg Files: images/Paper_texture_v5_by_bashcorpo_w1000.jpg
Licence: Public Domain Licence: Public Domain
Source: http://bashcorpo.deviantart.com/art/Grungy-paper-texture-v-5-22966998 Source: http://bashcorpo.deviantart.com/art/Grungy-paper-texture-v-5-22966998
Files: assets/vintage/img/logo.png Files: images/logo.png
assets/vintage/img/logo.png
License: zlib/libpng License: zlib/libpng
Copyright: (c) 2011-2014 idleman idleman@idleman.fr Copyright: (c) 2011-2014 idleman idleman@idleman.fr
Files: assets/default/img/sad_star.png Files: inc/blazy*.js
License: MIT License (http://opensource.org/licenses/MIT) License: MIT License (http://opensource.org/licenses/MIT)
Copyright: (C) 2015 kalvn - https://github.com/kalvn/Shaarli-Material Copyright: (C) Bjoern Klinggaard - @bklinggaard - http://dinbror.dk/blazy
Files: inc/rain.tpl.class.php Files: inc/rain.tpl.class.php
License: LGPL-3+ (https://www.gnu.org/licenses/lgpl-3.0.txt)
Copyright: 2011-2012, Federico Ulfo <rainelemental@gmail.com> Copyright: 2011-2012, Federico Ulfo <rainelemental@gmail.com>
2011-2012, The Rain Team <hello@raintm.com> 2011-2012, The Rain Team <hello@raintm.com>
License: LGPL-3+ (https://www.gnu.org/licenses/lgpl-3.0.txt)
Files: inc/awesomplete*
License: MIT License (http://opensource.org/licenses/MIT)
Copyright: (C) 2015 Lea Verou - https://github.com/LeaVerou/awesomplete
Files: plugins/wallabag/wallabag.png Files: plugins/wallabag/wallabag.png
License: MIT License (http://opensource.org/licenses/MIT) License: MIT License (http://opensource.org/licenses/MIT)

View file

@ -1,74 +0,0 @@
# Stage 1:
# - Copy Shaarli sources
# - Build documentation
FROM python:3-alpine as docs
ADD . /usr/src/app/shaarli
RUN cd /usr/src/app/shaarli \
&& apk add --no-cache gcc musl-dev make bash \
&& make htmldoc
# Stage 2:
# - Resolve PHP dependencies with Composer
FROM composer:latest as composer
COPY --from=docs /usr/src/app/shaarli /app/shaarli
RUN cd shaarli \
&& composer --prefer-dist --no-dev install
# Stage 3:
# - Frontend dependencies
FROM node:12-alpine as node
COPY --from=composer /app/shaarli shaarli
RUN cd shaarli \
&& yarnpkg install \
&& yarnpkg run build \
&& rm -rf node_modules
# Stage 4:
# - Shaarli image
FROM alpine:3.16.7
LABEL maintainer="Shaarli Community"
RUN apk --update --no-cache add \
ca-certificates \
nginx \
php8 \
php8-ctype \
php8-curl \
php8-fpm \
php8-gd \
php8-gettext \
php8-iconv \
php8-intl \
php8-json \
php8-ldap \
php8-mbstring \
php8-openssl \
php8-session \
php8-xml \
php8-simplexml \
php8-zlib \
s6
COPY .docker/nginx.conf /etc/nginx/nginx.conf
COPY .docker/php-fpm.conf /etc/php8/php-fpm.conf
COPY .docker/services.d /etc/services.d
RUN rm -rf /etc/php8/php-fpm.d/www.conf \
&& sed -i 's/post_max_size.*/post_max_size = 10M/' /etc/php8/php.ini \
&& sed -i 's/upload_max_filesize.*/upload_max_filesize = 10M/' /etc/php8/php.ini
WORKDIR /var/www
COPY --from=node /shaarli shaarli
RUN chown -R nginx:nginx . \
&& ln -sf /dev/stdout /var/log/nginx/shaarli.access.log \
&& ln -sf /dev/stderr /var/log/nginx/shaarli.error.log
VOLUME /var/www/shaarli/cache
VOLUME /var/www/shaarli/data
EXPOSE 80
ENTRYPOINT ["/bin/s6-svscan", "/etc/services.d"]
CMD []

2376
Doxyfile Normal file

File diff suppressed because it is too large Load diff

250
Makefile
View file

@ -1,19 +1,30 @@
# The personal, minimalist, super fast, database-free, bookmarking service. # The personal, minimalist, super-fast, database free, bookmarking service.
# Makefile for PHP code analysis & testing, documentation and release generation # Makefile for PHP code analysis & testing, documentation and release generation
# Prerequisites:
# - install Composer, either:
# - from your distro's package manager;
# - from the official website (https://getcomposer.org/download/);
# - install/update test dependencies:
# $ composer install # 1st setup
# $ composer update
# - install Xdebug for PHPUnit code coverage reports:
# - see http://xdebug.org/docs/install
# - enable in php.ini
BIN = vendor/bin BIN = vendor/bin
PHP_SOURCE = index.php application tests plugins
PHP_COMMA_SOURCE = index.php,application,tests,plugins
all: check_permissions test all: static_analysis_summary check_permissions test
## ##
# Docker test adapter # Concise status of the project
# # These targets are non-blocking: || exit 0
# Shaarli sources and vendored libraries are copied from a shared volume
# to a user-owned directory to enable running tests as a non-root user.
## ##
docker_%:
rsync -az /shaarli/ ~/shaarli/ static_analysis_summary: code_sniffer_source copy_paste mess_detector_summary
cd ~/shaarli && make $* @echo
## ##
# PHP_CodeSniffer # PHP_CodeSniffer
@ -22,29 +33,70 @@ docker_%:
# - http://pear.php.net/manual/en/package.php.php-codesniffer.usage.php # - http://pear.php.net/manual/en/package.php.php-codesniffer.usage.php
# - http://pear.php.net/manual/en/package.php.php-codesniffer.reporting.php # - http://pear.php.net/manual/en/package.php.php-codesniffer.reporting.php
## ##
PHPCS := $(BIN)/phpcs
# Use GNU Tar where available code_sniffer: code_sniffer_full
ifneq (, $(shell which gtar))
TAR := gtar
else
TAR := tar
endif
code_sniffer: ### - errors filtered by coding standard: PEAR, PSR1, PSR2, Zend...
@$(PHPCS) PHPCS_%:
@$(BIN)/phpcs $(PHP_SOURCE) --report-full --report-width=200 --standard=$*
### - errors by Git author ### - errors by Git author
code_sniffer_blame: code_sniffer_blame:
@$(PHPCS) --report-gitblame @$(BIN)/phpcs $(PHP_SOURCE) --report-gitblame
### - all errors/warnings ### - all errors/warnings
code_sniffer_full: code_sniffer_full:
@$(PHPCS) --report-full --report-width=200 @$(BIN)/phpcs $(PHP_SOURCE) --report-full --report-width=200
### - errors grouped by kind ### - errors grouped by kind
code_sniffer_source: code_sniffer_source:
@$(PHPCS) --report-source || exit 0 @$(BIN)/phpcs $(PHP_SOURCE) --report-source || exit 0
##
# PHP Copy/Paste Detector
# Detects code redundancy
# Documentation: https://github.com/sebastianbergmann/phpcpd
##
copy_paste:
@echo "-----------------------"
@echo "PHP COPY/PASTE DETECTOR"
@echo "-----------------------"
@$(BIN)/phpcpd $(PHP_SOURCE) || exit 0
@echo
##
# PHP Mess Detector
# Detects PHP syntax errors, sorted by category
# Rules documentation: http://phpmd.org/rules/index.html
##
MESS_DETECTOR_RULES = cleancode,codesize,controversial,design,naming,unusedcode
mess_title:
@echo "-----------------"
@echo "PHP MESS DETECTOR"
@echo "-----------------"
### - all warnings
mess_detector: mess_title
@$(BIN)/phpmd $(PHP_COMMA_SOURCE) text $(MESS_DETECTOR_RULES) | sed 's_.*\/__'
### - all warnings + HTML output contains links to PHPMD's documentation
mess_detector_html:
@$(BIN)/phpmd $(PHP_COMMA_SOURCE) html $(MESS_DETECTOR_RULES) \
--reportfile phpmd.html || exit 0
### - warnings grouped by message, sorted by descending frequency order
mess_detector_grouped: mess_title
@$(BIN)/phpmd $(PHP_SOURCE) text $(MESS_DETECTOR_RULES) \
| cut -f 2 | sort | uniq -c | sort -nr
### - summary: number of warnings by rule set
mess_detector_summary: mess_title
@for rule in $$(echo $(MESS_DETECTOR_RULES) | tr ',' ' '); do \
warnings=$$($(BIN)/phpmd $(PHP_COMMA_SOURCE) text $$rule | wc -l); \
printf "$$warnings\t$$rule\n"; \
done;
## ##
# Checks source file & script permissions # Checks source file & script permissions
@ -53,7 +105,7 @@ check_permissions:
@echo "----------------------" @echo "----------------------"
@echo "Check file permissions" @echo "Check file permissions"
@echo "----------------------" @echo "----------------------"
@for file in `git ls-files | grep -v docker`; do \ @for file in `git ls-files`; do \
if [ -x $$file ]; then \ if [ -x $$file ]; then \
errors=true; \ errors=true; \
echo "$${file} is executable"; \ echo "$${file} is executable"; \
@ -68,29 +120,12 @@ check_permissions:
# See phpunit.xml for configuration # See phpunit.xml for configuration
# https://phpunit.de/manual/current/en/appendixes.configuration.html # https://phpunit.de/manual/current/en/appendixes.configuration.html
## ##
test: translate test:
@echo "-------" @echo "-------"
@echo "PHPUNIT" @echo "PHPUNIT"
@echo "-------" @echo "-------"
@mkdir -p sandbox coverage @mkdir -p sandbox
@$(BIN)/phpunit --coverage-php coverage/main.cov --bootstrap tests/bootstrap.php --testsuite unit-tests @$(BIN)/phpunit tests
locale_test_%:
@UT_LOCALE=$*.utf8 \
$(BIN)/phpunit \
--coverage-php coverage/$(firstword $(subst _, ,$*)).cov \
--bootstrap tests/languages/bootstrap.php \
--testsuite language-$(firstword $(subst _, ,$*))
all_tests: test locale_test_de_DE locale_test_en_US locale_test_fr_FR
@# --The current version is not compatible with PHP 7.2
@#$(BIN)/phpcov merge --html coverage coverage
@# --text doesn't work with phpunit 4.* (v5 requires PHP 5.6)
@#$(BIN)/phpcov merge --text coverage/txt coverage
### download 3rd-party PHP libraries, including dev dependencies
composer_dependencies_dev: clean
composer install --prefer-dist
## ##
# Custom release archive generation # Custom release archive generation
@ -108,36 +143,21 @@ release_archive: release_tar release_zip
### download 3rd-party PHP libraries ### download 3rd-party PHP libraries
composer_dependencies: clean composer_dependencies: clean
composer install --no-dev --prefer-dist composer update --no-dev
find vendor/ -name ".git" -type d -exec rm -rf {} + find vendor/ -name ".git" -type d -exec rm -rf {} +
### download 3rd-party frontend libraries ### generate a release tarball and include 3rd-party dependencies
frontend_dependencies: release_tar: composer_dependencies
yarnpkg install
### Build frontend dependencies
build_frontend: frontend_dependencies
yarnpkg run build
### generate a release tarball and include 3rd-party dependencies and translations
release_tar: composer_dependencies htmldoc translate build_frontend
git archive --prefix=$(ARCHIVE_PREFIX) -o $(ARCHIVE_VERSION).tar HEAD git archive --prefix=$(ARCHIVE_PREFIX) -o $(ARCHIVE_VERSION).tar HEAD
$(TAR) rvf $(ARCHIVE_VERSION).tar --transform "s|^vendor|$(ARCHIVE_PREFIX)vendor|" vendor/ tar rvf $(ARCHIVE_VERSION).tar --transform "s|^vendor|$(ARCHIVE_PREFIX)vendor|" vendor/
$(TAR) rvf $(ARCHIVE_VERSION).tar --transform "s|^doc/html|$(ARCHIVE_PREFIX)doc/html|" doc/html/
$(TAR) rvf $(ARCHIVE_VERSION).tar --transform "s|^tpl|$(ARCHIVE_PREFIX)tpl|" tpl/
gzip $(ARCHIVE_VERSION).tar gzip $(ARCHIVE_VERSION).tar
### generate a release zip and include 3rd-party dependencies and translations ### generate a release zip and include 3rd-party dependencies
release_zip: composer_dependencies htmldoc translate build_frontend release_zip: composer_dependencies
git archive --prefix=$(ARCHIVE_PREFIX) -o $(ARCHIVE_VERSION).zip -9 HEAD git archive --prefix=$(ARCHIVE_PREFIX) -o $(ARCHIVE_VERSION).zip -9 HEAD
mkdir -p $(ARCHIVE_PREFIX)/doc mkdir $(ARCHIVE_PREFIX)
mkdir -p $(ARCHIVE_PREFIX)/vendor
rsync -a doc/html/ $(ARCHIVE_PREFIX)doc/html/
zip -r $(ARCHIVE_VERSION).zip $(ARCHIVE_PREFIX)doc/
rsync -a vendor/ $(ARCHIVE_PREFIX)vendor/ rsync -a vendor/ $(ARCHIVE_PREFIX)vendor/
zip -r $(ARCHIVE_VERSION).zip $(ARCHIVE_PREFIX)vendor/ zip -r $(ARCHIVE_VERSION).zip $(ARCHIVE_PREFIX)vendor/
rsync -a tpl/ $(ARCHIVE_PREFIX)tpl/
zip -r $(ARCHIVE_VERSION).zip $(ARCHIVE_PREFIX)tpl/
rm -rf $(ARCHIVE_PREFIX) rm -rf $(ARCHIVE_PREFIX)
## ##
@ -147,69 +167,51 @@ release_zip: composer_dependencies htmldoc translate build_frontend
### remove all unversioned files ### remove all unversioned files
clean: clean:
@git clean -df @git clean -df
@rm -rf sandbox trivy* @rm -rf sandbox
### generate the AUTHORS file from Git commit information ### generate Doxygen documentation
generate_authors: doxygen: clean
@cp .github/mailmap .mailmap @rm -rf doxygen
@git shortlog -sne > AUTHORS @( cat Doxyfile ; echo "PROJECT_NUMBER=`git describe`" ) | doxygen -
@rm .mailmap
### generate phpDocumentor documentation ### update the local copy of the documentation
phpdoc: clean doc: clean
@docker run --rm -v $(PWD):/data -u `id -u`:`id -g` phpdoc/phpdoc @rm -rf doc
@git clone https://github.com/shaarli/Shaarli.wiki.git doc
@rm -rf doc/.git
### generate HTML documentation from Markdown pages with Sphinx ### Generate a custom sidebar
htmldoc: #
python3 -m venv venv/ # Sidebar content:
bash -c 'source venv/bin/activate; \ # - convert GitHub-flavoured relative links to standard Markdown
pip install wheel; \ # - trim HTML, only keep the list (<ul>[...]</ul>) part
pip install sphinx==7.1.0 furo==2023.7.26 myst-parser sphinx-design; \ htmlsidebar:
sphinx-build -b html -c doc/ doc/md/ doc/html/' @echo '<div id="local-sidebar">' > doc/sidebar.html
find doc/html/ -type f -exec chmod a-x '{}' \; @awk 'BEGIN { FS = "[\\[\\]]{2}" }'\
rm -r venv 'm = /\[/ { t=$$2; gsub(/ /, "-", $$2); print $$1"["t"]("$$2".html)"$$3 }'\
'!m { print $$0 }' doc/_Sidebar.md > doc/tmp.md
@pandoc -f markdown -t html5 -s doc/tmp.md | awk '/(ul>|li>)/' >> doc/sidebar.html
@echo '</div>' >> doc/sidebar.html
@rm doc/tmp.md
### Generate Shaarli's translation compiled file (.mo) ### Convert local markdown documentation to HTML
translate: #
@echo "----------------------" # For all pages:
@echo "Compile translation files" # - infer title from the file name
@echo "----------------------" # - convert GitHub-flavoured relative links to standard Markdown
@for pofile in `find inc/languages/ -name shaarli.po`; do \ # - insert the sidebar menu
echo "Compiling $$pofile"; \ htmlpages:
msgfmt -v "$$pofile" -o "`dirname "$$pofile"`/`basename "$$pofile" .po`.mo"; \ @for file in `find doc/ -maxdepth 1 -name "*.md"`; do \
base=`basename $$file .md`; \
sed -i "1i #$${base//-/ }" $$file; \
awk 'BEGIN { FS = "[\\[\\]]{2}" }'\
'm = /\[/ { t=$$2; gsub(/ /, "-", $$2); print $$1"["t"]("$$2".html)"$$3 }'\
'!m { print $$0 }' $$file > doc/tmp.md; \
mv doc/tmp.md $$file; \
pandoc -f markdown_github -t html5 -s \
-c "github-markdown.css" \
-T Shaarli -M pagetitle:"$${base//-/ }" -B doc/sidebar.html \
-o doc/$$base.html $$file; \
done; done;
### Run ESLint check against Shaarli's JS files htmldoc: doc htmlsidebar htmlpages
eslint:
@yarnpkg run eslint -c .dev/.eslintrc.js assets/vintage/js/
@yarnpkg run eslint -c .dev/.eslintrc.js assets/default/js/
@yarnpkg run eslint -c .dev/.eslintrc.js assets/common/js/
### Run CSSLint check against Shaarli's SCSS files
sasslint:
@yarnpkg run stylelint --config .dev/.stylelintrc.js 'assets/default/scss/*.scss'
##
# Security scans
##
# trivy version (https://github.com/aquasecurity/trivy/releases)
TRIVY_VERSION=0.44.0
# default trivy exit code when vulnerabilities are found
TRIVY_EXIT_CODE=1
# default docker image to scan with trivy
TRIVY_TARGET_DOCKER_IMAGE=ghcr.io/shaarli/shaarli:latest
### download trivy vulneravbility scanner
download_trivy:
wget --quiet --continue -O trivy_$(TRIVY_VERSION)_Linux-64bit.tar.gz https://github.com/aquasecurity/trivy/releases/download/v$(TRIVY_VERSION)/trivy_$(TRIVY_VERSION)_Linux-64bit.tar.gz
tar -z -x trivy -f trivy_$(TRIVY_VERSION)_Linux-64bit.tar.gz
### run trivy vulnerability scanner on docker image
test_trivy_docker: download_trivy
./trivy --exit-code $(TRIVY_EXIT_CODE) image $(TRIVY_TARGET_DOCKER_IMAGE)
### run trivy vulnerability scanner on composer/yarn dependency trees
test_trivy_repo: download_trivy
./trivy --exit-code $(TRIVY_EXIT_CODE) fs composer.lock
./trivy --exit-code $(TRIVY_EXIT_CODE) fs yarn.lock

110
README.md
View file

@ -1,31 +1,115 @@
![Shaarli logo](doc/md/images/doc-logo.png) ![Shaarli logo](doc/images/doc-logo.png)
The personal, minimalist, super fast, database-free, bookmarking service. The personal, minimalist, super-fast, database free, bookmarking service.
_Do you want to share the links you discover?_ _Do you want to share the links you discover?_
_Shaarli is a minimalist link sharing service that you can install on your own server._ _Shaarli is a minimalist delicious clone that you can install on your own server._
_It is designed to be personal (single-user), fast and handy._ _It is designed to be personal (single-user), fast and handy._
[![](https://img.shields.io/badge/stable-v0.12.2-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.11.1) [![](https://img.shields.io/travis/shaarli/Shaarli.svg?label=master)](https://travis-ci.org/shaarli/Shaarli)
[![](https://img.shields.io/badge/latest-v0.13.0-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.12.2) [![](https://img.shields.io/travis/shaarli/Shaarli/stable.svg?label=stable)](https://travis-ci.org/shaarli/Shaarli)
[![](https://img.shields.io/badge/master-v0.13.x-blue.svg)](https://github.com/shaarli/Shaarli) [![](https://img.shields.io/github/release/shaarli/shaarli.svg)](https://github.com/shaarli/Shaarli/releases/latest/)
[![](https://github.com/shaarli/Shaarli/actions/workflows/ci.yml/badge.svg)](https://github.com/shaarli/Shaarli/actions) [![Docker repository](https://img.shields.io/docker/pulls/shaarli/shaarli.svg)](https://hub.docker.com/r/shaarli/shaarli/)
[![Join the chat at https://gitter.im/shaarli/Shaarli](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/shaarli/Shaarli) [![Join the chat at https://gitter.im/shaarli/Shaarli](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/shaarli/Shaarli)
[![Docker repository](https://img.shields.io/docker/pulls/shaarli/shaarli.svg)](https://github.com/shaarli/Shaarli/pkgs/container/shaarli) [![Bountysource](https://www.bountysource.com/badge/team?team_id=19583&style=bounties_received)](https://www.bountysource.com/teams/shaarli/issues)
## Quickstart ## Quickstart
- [Wiki/documentation](https://github.com/shaarli/Shaarli/wiki)
- [Documentation](https://shaarli.readthedocs.io)
- [Change log](CHANGELOG.md) - [Change log](CHANGELOG.md)
- [Bugs/Feature requests/Discussion](https://github.com/shaarli/Shaarli/issues/) - [Bugs/Feature requests/Discussion](https://github.com/shaarli/Shaarli/issues/)
### Demo ### Demo
You can use this [public demo instance of Shaarli](http://shaarlidemo.tuxfamily.org/Shaarli).
You can use this [public demo instance of Shaarli](https://demo.shaarli.org).
It runs the latest development version of Shaarli and is updated/reset daily. It runs the latest development version of Shaarli and is updated/reset daily.
Login: `demo`; Password: `demo` Login: `demo`; Password: `demo`
### License ### Installation & upgrade
- [Download and installation](https://github.com/shaarli/Shaarli/wiki/Download-and-Installation)
- [Upgrade and migration](https://github.com/shaarli/Shaarli/wiki/Upgrade-and-migration)
- [Server requirements](https://github.com/shaarli/Shaarli/wiki/Server-requirements)
- [Server configuration](https://github.com/shaarli/Shaarli/wiki/Server-configuration)
- [Shaarli configuration](https://github.com/shaarli/Shaarli/wiki/Shaarli-configuration)
## Features
### Interface
- minimalist design (simple is beautiful)
- FAST
- ATOM and RSS feeds
- views:
- paginated link list
- tag cloud
- picture wall: image and video thumbnails
- daily: newspaper-like daily digest
- daily RSS feed
- permalinks for easy reference
- links can be public or private
- extensible through [plugins](https://github.com/shaarli/Shaarli/wiki/Plugins#plugin-usage)
### Tag, view and search your links!
- add a custom title and description to archived links
- add tags to classify and search links
- features tag autocompletion, renaming, merging and deletion
- full-text and tag search
### Easy setup
- dead-simple installation: drop the files, open the page
- links are stored in a file
- compact storage
- no database required
- easy backup: simply copy the datastore file
- import and export links as Netscape bookmarks
### Accessibility
- Firefox bookmarlet to share links in one click
- support for mobile browsers
- works with Javascript disabled
- easy page customization through HTML/CSS/RainTPL
### Security
- bruteforce-proof login form
- protected against [XSRF](http://en.wikipedia.org/wiki/Cross-site_request_forgery)
and session cookie hijacking
### Goodies
- thumbnail generation for images and video services:
dailymotion, flickr, imageshack, imgur, vimeo, xkcd, youtube...
- lazy-loading with [bLazy](http://dinbror.dk/blazy/)
- [PubSubHubbub](https://code.google.com/p/pubsubhubbub/) protocol support
- URL cleanup: automatic removal of `?utm_source=...`, `fb=...`
- discreet pop-up notification when a new release is available
### Other usages
Though Shaarli is primarily a bookmarking application, it can serve other purposes
(see [usage examples](https://github.com/shaarli/Shaarli/wiki#usage-examples)):
- micro-blogging
- pastebin
- online notepad
- snippet archive
## About
### Shaarli community fork
This friendly fork is maintained by the Shaarli community at https://github.com/shaarli/Shaarli
This is a community fork of the original [Shaarli](https://github.com/sebsauvage/Shaarli/) project by [Sébastien Sauvage](http://sebsauvage.net/).
The original project is currently unmaintained, and the developer [has informed us](https://github.com/sebsauvage/Shaarli/issues/191)
that he would have no time to work on Shaarli in the near future.
The Shaarli community has carried on the work to provide
[many patches](https://github.com/shaarli/Shaarli/compare/sebsauvage:master...master)
for [bug fixes and enhancements](https://github.com/shaarli/Shaarli/issues?q=is%3Aclosed+)
in this repository, and will keep maintaining the project for the foreseeable future, while keeping Shaarli simple and efficient.
### Contributing
If you'd like to help, please:
- have a look at the open [issues](https://github.com/shaarli/Shaarli/issues)
and [pull requests](https://github.com/shaarli/Shaarli/pulls)
- feel free to report bugs (feedback is much appreciated)
- suggest new features and improvements to both code and [documentation](https://github.com/shaarli/Shaarli/wiki)
- propose solutions to existing problems
- submit pull requests :-)
### License
Shaarli is [Free Software](http://en.wikipedia.org/wiki/Free_software). See [COPYING](COPYING) for a detail of the contributors and licenses for each individual component. Shaarli is [Free Software](http://en.wikipedia.org/wiki/Free_software). See [COPYING](COPYING) for a detail of the contributors and licenses for each individual component.

View file

@ -0,0 +1,198 @@
<?php
/**
* Shaarli (application) utilities
*/
class ApplicationUtils
{
private static $GIT_URL = 'https://raw.githubusercontent.com/shaarli/Shaarli';
private static $GIT_BRANCHES = array('master', 'stable');
private static $VERSION_FILE = 'shaarli_version.php';
private static $VERSION_START_TAG = '<?php /* ';
private static $VERSION_END_TAG = ' */ ?>';
/**
* Gets the latest version code from the Git repository
*
* The code is read from the raw content of the version file on the Git server.
*
* @param string $url URL to reach to get the latest version.
* @param int $timeout Timeout to check the URL (in seconds).
*
* @return mixed the version code from the repository if available, else 'false'
*/
public static function getLatestGitVersionCode($url, $timeout=2)
{
list($headers, $data) = get_http_response($url, $timeout);
if (strpos($headers[0], '200 OK') === false) {
error_log('Failed to retrieve ' . $url);
return false;
}
return str_replace(
array(self::$VERSION_START_TAG, self::$VERSION_END_TAG, PHP_EOL),
array('', '', ''),
$data
);
}
/**
* Checks if a new Shaarli version has been published on the Git repository
*
* Updates checks are run periodically, according to the following criteria:
* - the update checks are enabled (install, global config);
* - the user is logged in (or this is an open instance);
* - the last check is older than a given interval;
* - the check is non-blocking if the HTTPS connection to Git fails;
* - in case of failure, the update file's modification date is updated,
* to avoid intempestive connection attempts.
*
* @param string $currentVersion the current version code
* @param string $updateFile the file where to store the latest version code
* @param int $checkInterval the minimum interval between update checks (in seconds
* @param bool $enableCheck whether to check for new versions
* @param bool $isLoggedIn whether the user is logged in
* @param string $branch check update for the given branch
*
* @throws Exception an invalid branch has been set for update checks
*
* @return mixed the new version code if available and greater, else 'false'
*/
public static function checkUpdate($currentVersion,
$updateFile,
$checkInterval,
$enableCheck,
$isLoggedIn,
$branch='stable')
{
if (! $isLoggedIn) {
// Do not check versions for visitors
return false;
}
if (empty($enableCheck)) {
// Do not check if the user doesn't want to
return false;
}
if (is_file($updateFile) && (filemtime($updateFile) > time() - $checkInterval)) {
// Shaarli has checked for updates recently - skip HTTP query
$latestKnownVersion = file_get_contents($updateFile);
if (version_compare($latestKnownVersion, $currentVersion) == 1) {
return $latestKnownVersion;
}
return false;
}
if (! in_array($branch, self::$GIT_BRANCHES)) {
throw new Exception(
'Invalid branch selected for updates: "' . $branch . '"'
);
}
// Late Static Binding allows overriding within tests
// See http://php.net/manual/en/language.oop5.late-static-bindings.php
$latestVersion = static::getLatestGitVersionCode(
self::$GIT_URL . '/' . $branch . '/' . self::$VERSION_FILE
);
if (! $latestVersion) {
// Only update the file's modification date
file_put_contents($updateFile, $currentVersion);
return false;
}
// Update the file's content and modification date
file_put_contents($updateFile, $latestVersion);
if (version_compare($latestVersion, $currentVersion) == 1) {
return $latestVersion;
}
return false;
}
/**
* Checks the PHP version to ensure Shaarli can run
*
* @param string $minVersion minimum PHP required version
* @param string $curVersion current PHP version (use PHP_VERSION)
*
* @throws Exception the PHP version is not supported
*/
public static function checkPHPVersion($minVersion, $curVersion)
{
if (version_compare($curVersion, $minVersion) < 0) {
throw new Exception(
'Your PHP version is obsolete!'
.' Shaarli requires at least PHP '.$minVersion.', and thus cannot run.'
.' Your PHP version has known security vulnerabilities and should be'
.' updated as soon as possible.'
);
}
}
/**
* Checks Shaarli has the proper access permissions to its resources
*
* @param ConfigManager $conf Configuration Manager instance.
*
* @return array A list of the detected configuration issues
*/
public static function checkResourcePermissions($conf)
{
$errors = array();
// Check script and template directories are readable
foreach (array(
'application',
'inc',
'plugins',
$conf->get('resource.raintpl_tpl'),
$conf->get('resource.raintpl_tpl').'/'.$conf->get('resource.theme'),
) as $path) {
if (! is_readable(realpath($path))) {
$errors[] = '"'.$path.'" 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 (! is_readable(realpath($path))) {
$errors[] = '"'.$path.'" directory is not readable';
}
if (! is_writable(realpath($path))) {
$errors[] = '"'.$path.'" directory is not writable';
}
}
// 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) {
if (! is_file(realpath($path))) {
# the file may not exist yet
continue;
}
if (! is_readable(realpath($path))) {
$errors[] = '"'.$path.'" file is not readable';
}
if (! is_writable(realpath($path))) {
$errors[] = '"'.$path.'" file is not writable';
}
}
return $errors;
}
}

38
application/Cache.php Normal file
View file

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

View file

@ -0,0 +1,63 @@
<?php
/**
* Simple cache system, mainly for the RSS/ATOM feeds
*/
class CachedPage
{
// Directory containing page caches
private $cacheDir;
// Full URL of the page to cache -typically the value returned by pageUrl()
private $url;
// Should this URL be cached (boolean)?
private $shouldBeCached;
// Name of the cache file for this URL
private $filename;
/**
* Creates a new CachedPage
*
* @param string $cacheDir page cache directory
* @param string $url page URL
* @param bool $shouldBeCached whether this page needs to be cached
*/
public function __construct($cacheDir, $url, $shouldBeCached)
{
// TODO: check write access to the cache directory
$this->cacheDir = $cacheDir;
$this->url = $url;
$this->filename = $this->cacheDir.'/'.sha1($url).'.cache';
$this->shouldBeCached = $shouldBeCached;
}
/**
* Returns the cached version of a page, if it exists and should be cached
*
* @return string a cached version of the page if it exists, null otherwise
*/
public function cachedVersion()
{
if (!$this->shouldBeCached) {
return null;
}
if (is_file($this->filename)) {
return file_get_contents($this->filename);
}
return null;
}
/**
* Puts a page in the cache
*
* @param string $pageContent XML content to cache
*/
public function cache($pageContent)
{
if (!$this->shouldBeCached) {
return;
}
file_put_contents($this->filename, $pageContent);
}
}

307
application/FeedBuilder.php Normal file
View file

@ -0,0 +1,307 @@
<?php
/**
* FeedBuilder class.
*
* Used to build ATOM and RSS feeds data.
*/
class FeedBuilder
{
/**
* @var string Constant: RSS feed type.
*/
public static $FEED_RSS = 'rss';
/**
* @var string Constant: ATOM feed type.
*/
public static $FEED_ATOM = 'atom';
/**
* @var string Default language if the locale isn't set.
*/
public static $DEFAULT_LANGUAGE = 'en-en';
/**
* @var int Number of links to display in a feed by default.
*/
public static $DEFAULT_NB_LINKS = 50;
/**
* @var LinkDB instance.
*/
protected $linkDB;
/**
* @var string RSS or ATOM feed.
*/
protected $feedType;
/**
* @var array $_SERVER.
*/
protected $serverInfo;
/**
* @var array $_GET.
*/
protected $userInput;
/**
* @var boolean True if the user is currently logged in, false otherwise.
*/
protected $isLoggedIn;
/**
* @var boolean Use permalinks instead of direct links if true.
*/
protected $usePermalinks;
/**
* @var boolean true to hide dates in feeds.
*/
protected $hideDates;
/**
* @var string PubSub hub URL.
*/
protected $pubsubhubUrl;
/**
* @var string server locale.
*/
protected $locale;
/**
* @var DateTime Latest item date.
*/
protected $latestDate;
/**
* Feed constructor.
*
* @param LinkDB $linkDB LinkDB instance.
* @param string $feedType Type of feed.
* @param array $serverInfo $_SERVER.
* @param array $userInput $_GET.
* @param boolean $isLoggedIn True if the user is currently logged in, false otherwise.
*/
public function __construct($linkDB, $feedType, $serverInfo, $userInput, $isLoggedIn)
{
$this->linkDB = $linkDB;
$this->feedType = $feedType;
$this->serverInfo = $serverInfo;
$this->userInput = $userInput;
$this->isLoggedIn = $isLoggedIn;
}
/**
* Build data for feed templates.
*
* @return array Formatted data for feeds templates.
*/
public function buildData()
{
// Optionally filter the results:
$linksToDisplay = $this->linkDB->filterSearch($this->userInput);
$nblinksToDisplay = $this->getNbLinks(count($linksToDisplay));
// Can't use array_keys() because $link is a LinkDB instance and not a real array.
$keys = array();
foreach ($linksToDisplay as $key => $value) {
$keys[] = $key;
}
$pageaddr = escape(index_url($this->serverInfo));
$linkDisplayed = array();
for ($i = 0; $i < $nblinksToDisplay && $i < count($keys); $i++) {
$linkDisplayed[$keys[$i]] = $this->buildItem($linksToDisplay[$keys[$i]], $pageaddr);
}
$data['language'] = $this->getTypeLanguage();
$data['pubsubhub_url'] = $this->pubsubhubUrl;
$data['last_update'] = $this->getLatestDateFormatted();
$data['show_dates'] = !$this->hideDates || $this->isLoggedIn;
// Remove leading slash from REQUEST_URI.
$data['self_link'] = escape(server_url($this->serverInfo))
. escape($this->serverInfo['REQUEST_URI']);
$data['index_url'] = $pageaddr;
$data['usepermalinks'] = $this->usePermalinks === true;
$data['links'] = $linkDisplayed;
return $data;
}
/**
* Build a feed item (one per shaare).
*
* @param array $link Single link array extracted from LinkDB.
* @param string $pageaddr Index URL.
*
* @return array Link array with feed attributes.
*/
protected function buildItem($link, $pageaddr)
{
$link['guid'] = $pageaddr .'?'. smallHash($link['linkdate']);
// Check for both signs of a note: starting with ? and 7 chars long.
if ($link['url'][0] === '?' && strlen($link['url']) === 7) {
$link['url'] = $pageaddr . $link['url'];
}
if ($this->usePermalinks === true) {
$permalink = '<a href="'. $link['url'] .'" title="Direct link">Direct link</a>';
} else {
$permalink = '<a href="'. $link['guid'] .'" title="Permalink">Permalink</a>';
}
$link['description'] = format_description($link['description'], '', $pageaddr);
$link['description'] .= PHP_EOL .'<br>&#8212; '. $permalink;
$pubDate = DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, $link['linkdate']);
$link['pub_iso_date'] = $this->getIsoDate($pubDate);
// atom:entry elements MUST contain exactly one atom:updated element.
if (!empty($link['updated'])) {
$upDate = DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, $link['updated']);
$link['up_iso_date'] = $this->getIsoDate($upDate, DateTime::ATOM);
} else {
$link['up_iso_date'] = $this->getIsoDate($pubDate, DateTime::ATOM);;
}
// Save the more recent item.
if (empty($this->latestDate) || $this->latestDate < $pubDate) {
$this->latestDate = $pubDate;
}
if (!empty($upDate) && $this->latestDate < $upDate) {
$this->latestDate = $upDate;
}
$taglist = array_filter(explode(' ', $link['tags']), 'strlen');
uasort($taglist, 'strcasecmp');
$link['taglist'] = $taglist;
return $link;
}
/**
* Assign PubSub hub URL.
*
* @param string $pubsubhubUrl PubSub hub url.
*/
public function setPubsubhubUrl($pubsubhubUrl)
{
$this->pubsubhubUrl = $pubsubhubUrl;
}
/**
* Set this to true to use permalinks instead of direct links.
*
* @param boolean $usePermalinks true to force permalinks.
*/
public function setUsePermalinks($usePermalinks)
{
$this->usePermalinks = $usePermalinks;
}
/**
* Set this to true to hide timestamps in feeds.
*
* @param boolean $hideDates true to enable.
*/
public function setHideDates($hideDates)
{
$this->hideDates = $hideDates;
}
/**
* Set the locale. Used to show feed language.
*
* @param string $locale The locale (eg. 'fr_FR.UTF8').
*/
public function setLocale($locale)
{
$this->locale = strtolower($locale);
}
/**
* Get the language according to the feed type, based on the locale:
*
* - RSS format: en-us (default: 'en-en').
* - ATOM format: fr (default: 'en').
*
* @return string The language.
*/
public function getTypeLanguage()
{
// Use the locale do define the language, if available.
if (! empty($this->locale) && preg_match('/^\w{2}[_\-]\w{2}/', $this->locale)) {
$length = ($this->feedType == self::$FEED_RSS) ? 5 : 2;
return str_replace('_', '-', substr($this->locale, 0, $length));
}
return ($this->feedType == self::$FEED_RSS) ? 'en-en' : 'en';
}
/**
* Format the latest item date found according to the feed type.
*
* Return an empty string if invalid DateTime is passed.
*
* @return string Formatted date.
*/
protected function getLatestDateFormatted()
{
if (empty($this->latestDate) || !$this->latestDate instanceof DateTime) {
return '';
}
$type = ($this->feedType == self::$FEED_RSS) ? DateTime::RSS : DateTime::ATOM;
return $this->latestDate->format($type);
}
/**
* Get ISO date from DateTime according to feed type.
*
* @param DateTime $date Date to format.
* @param string|bool $format Force format.
*
* @return string Formatted date.
*/
protected function getIsoDate(DateTime $date, $format = false)
{
if ($format !== false) {
return $date->format($format);
}
if ($this->feedType == self::$FEED_RSS) {
return $date->format(DateTime::RSS);
}
return $date->format(DateTime::ATOM);
}
/**
* Returns the number of link to display according to 'nb' user input parameter.
*
* If 'nb' not set or invalid, default value: $DEFAULT_NB_LINKS.
* If 'nb' is set to 'all', display all filtered links (max parameter).
*
* @param int $max maximum number of links to display.
*
* @return int number of links to display.
*/
public function getNbLinks($max)
{
if (empty($this->userInput['nb'])) {
return self::$DEFAULT_NB_LINKS;
}
if ($this->userInput['nb'] == 'all') {
return $max;
}
$intNb = intval($this->userInput['nb']);
if (! is_int($intNb) || $intNb == 0) {
return self::$DEFAULT_NB_LINKS;
}
return $intNb;
}
}

View file

@ -1,9 +1,4 @@
<?php <?php
namespace Shaarli\Exceptions;
use Exception;
/** /**
* Exception class thrown when a filesystem access failure happens * Exception class thrown when a filesystem access failure happens
*/ */
@ -20,7 +15,7 @@ class IOException extends Exception
public function __construct($path, $message = '') public function __construct($path, $message = '')
{ {
$this->path = $path; $this->path = $path;
$this->message = empty($message) ? t('Error accessing') : $message; $this->message = empty($message) ? 'Error accessing' : $message;
$this->message .= ' "' . $this->path . '"'; $this->message .= PHP_EOL . $this->path;
} }
} }

View file

@ -1,223 +0,0 @@
<?php
namespace Shaarli;
use DateTime;
use Exception;
use Shaarli\Bookmark\Bookmark;
use Shaarli\Helper\FileUtils;
/**
* Class History
*
* Handle the history file tracing events in Shaarli.
* The history is stored as JSON in a file set by 'resource.history' setting.
*
* Available data:
* - event: event key
* - datetime: event date, in ISO8601 format.
* - id: event item identifier (currently only link IDs).
*
* Available event keys:
* - CREATED: new link
* - UPDATED: link updated
* - DELETED: link deleted
* - SETTINGS: the settings have been updated through the UI.
* - IMPORT: bulk bookmarks import
*
* Note: new events are put at the beginning of the file and history array.
*/
class History
{
/**
* @var string Action key: a new link has been created.
*/
public const CREATED = 'CREATED';
/**
* @var string Action key: a link has been updated.
*/
public const UPDATED = 'UPDATED';
/**
* @var string Action key: a link has been deleted.
*/
public const DELETED = 'DELETED';
/**
* @var string Action key: settings have been updated.
*/
public const SETTINGS = 'SETTINGS';
/**
* @var string Action key: a bulk import has been processed.
*/
public const IMPORT = 'IMPORT';
/**
* @var string History file path.
*/
protected $historyFilePath;
/**
* @var array History data.
*/
protected $history;
/**
* @var int History retention time in seconds (1 month).
*/
protected $retentionTime = 2678400;
/**
* History constructor.
*
* @param string $historyFilePath History file path.
* @param int $retentionTime History content retention time in seconds.
*
* @throws Exception if something goes wrong.
*/
public function __construct($historyFilePath, $retentionTime = null)
{
$this->historyFilePath = $historyFilePath;
if ($retentionTime !== null) {
$this->retentionTime = $retentionTime;
}
}
/**
* Initialize: read history file.
*
* Allow lazy loading (don't read the file if it isn't necessary).
*/
protected function initialize()
{
$this->check();
$this->read();
}
/**
* Add Event: new link.
*
* @param Bookmark $link Link data.
*/
public function addLink($link)
{
$this->addEvent(self::CREATED, $link->getId());
}
/**
* Add Event: update existing link.
*
* @param Bookmark $link Link data.
*/
public function updateLink($link)
{
$this->addEvent(self::UPDATED, $link->getId());
}
/**
* Add Event: delete existing link.
*
* @param Bookmark $link Link data.
*/
public function deleteLink($link)
{
$this->addEvent(self::DELETED, $link->getId());
}
/**
* Add Event: settings updated.
*/
public function updateSettings()
{
$this->addEvent(self::SETTINGS);
}
/**
* Add Event: bulk import.
*
* Note: we don't store bookmarks add/update one by one since it can have a huge impact on performances.
*/
public function importLinks()
{
$this->addEvent(self::IMPORT);
}
/**
* Save a new event and write it in the history file.
*
* @param string $status Event key, should be defined as constant.
* @param mixed $id Event item identifier (e.g. link ID).
*/
protected function addEvent($status, $id = null)
{
if ($this->history === null) {
$this->initialize();
}
$item = [
'event' => $status,
'datetime' => new DateTime(),
'id' => $id !== null ? $id : '',
];
$this->history = array_merge([$item], $this->history);
$this->write();
}
/**
* Check that the history file is writable.
* Create the file if it doesn't exist.
*
* @throws Exception if it isn't writable.
*/
protected function check()
{
if (!is_file($this->historyFilePath)) {
FileUtils::writeFlatDB($this->historyFilePath, []);
}
if (!is_writable($this->historyFilePath)) {
throw new Exception(t('History file isn\'t readable or writable'));
}
}
/**
* Read JSON history file.
*/
protected function read()
{
$this->history = FileUtils::readFlatDB($this->historyFilePath, []);
if ($this->history === false) {
throw new Exception(t('Could not parse history file'));
}
}
/**
* Write JSON history file and delete old entries.
*/
protected function write()
{
$comparaison = new DateTime('-' . $this->retentionTime . ' seconds');
foreach ($this->history as $key => $value) {
if ($value['datetime'] < $comparaison) {
unset($this->history[$key]);
}
}
FileUtils::writeFlatDB($this->historyFilePath, array_values($this->history));
}
/**
* Get the History.
*
* @return array
*/
public function getHistory()
{
if ($this->history === null) {
$this->initialize();
}
return $this->history;
}
}

383
application/HttpUtils.php Normal file
View file

@ -0,0 +1,383 @@
<?php
/**
* GET an HTTP URL to retrieve its content
* Uses the cURL library or a fallback method
*
* @param string $url URL to get (http://...)
* @param int $timeout network timeout (in seconds)
* @param int $maxBytes maximum downloaded bytes (default: 4 MiB)
*
* @return array HTTP response headers, downloaded content
*
* Output format:
* [0] = associative array containing HTTP response headers
* [1] = URL content (downloaded data)
*
* Example:
* list($headers, $data) = get_http_response('http://sebauvage.net/');
* if (strpos($headers[0], '200 OK') !== false) {
* echo 'Data type: '.htmlspecialchars($headers['Content-Type']);
* } else {
* echo 'There was an error: '.htmlspecialchars($headers[0]);
* }
*
* @see https://secure.php.net/manual/en/ref.curl.php
* @see https://secure.php.net/manual/en/functions.anonymous.php
* @see https://secure.php.net/manual/en/function.preg-split.php
* @see https://secure.php.net/manual/en/function.explode.php
* @see http://stackoverflow.com/q/17641073
* @see http://stackoverflow.com/q/9183178
* @see http://stackoverflow.com/q/1462720
*/
function get_http_response($url, $timeout = 30, $maxBytes = 4194304)
{
$urlObj = new Url($url);
$cleanUrl = $urlObj->idnToAscii();
if (!filter_var($cleanUrl, FILTER_VALIDATE_URL) || !$urlObj->isHttp()) {
return array(array(0 => 'Invalid HTTP Url'), false);
}
$userAgent =
'Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:45.0)'
. ' Gecko/20100101 Firefox/45.0';
$acceptLanguage =
substr(setlocale(LC_COLLATE, 0), 0, 2) . ',en-US;q=0.7,en;q=0.3';
$maxRedirs = 3;
if (!function_exists('curl_init')) {
return get_http_response_fallback(
$cleanUrl,
$timeout,
$maxBytes,
$userAgent,
$acceptLanguage,
$maxRedirs
);
}
$ch = curl_init($cleanUrl);
if ($ch === false) {
return array(array(0 => 'curl_init() error'), false);
}
// General cURL settings
curl_setopt($ch, CURLOPT_AUTOREFERER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_HEADER, true);
curl_setopt(
$ch,
CURLOPT_HTTPHEADER,
array('Accept-Language: ' . $acceptLanguage)
);
curl_setopt($ch, CURLOPT_MAXREDIRS, $maxRedirs);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
curl_setopt($ch, CURLOPT_USERAGENT, $userAgent);
// Max download size management
curl_setopt($ch, CURLOPT_BUFFERSIZE, 1024);
curl_setopt($ch, CURLOPT_NOPROGRESS, false);
curl_setopt($ch, CURLOPT_PROGRESSFUNCTION,
function($arg0, $arg1, $arg2, $arg3, $arg4 = 0) use ($maxBytes)
{
if (version_compare(phpversion(), '5.5', '<')) {
// PHP version lower than 5.5
// Callback has 4 arguments
$downloaded = $arg1;
} else {
// Callback has 5 arguments
$downloaded = $arg2;
}
// Non-zero return stops downloading
return ($downloaded > $maxBytes) ? 1 : 0;
}
);
$response = curl_exec($ch);
$errorNo = curl_errno($ch);
$errorStr = curl_error($ch);
$headSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
curl_close($ch);
if ($response === false) {
if ($errorNo == CURLE_COULDNT_RESOLVE_HOST) {
/*
* Workaround to match fallback method behaviour
* Removing this would require updating
* GetHttpUrlTest::testGetInvalidRemoteUrl()
*/
return array(false, false);
}
return array(array(0 => 'curl_exec() error: ' . $errorStr), false);
}
// Formatting output like the fallback method
$rawHeaders = substr($response, 0, $headSize);
// Keep only headers from latest redirection
$rawHeadersArrayRedirs = explode("\r\n\r\n", trim($rawHeaders));
$rawHeadersLastRedir = end($rawHeadersArrayRedirs);
$content = substr($response, $headSize);
$headers = array();
foreach (preg_split('~[\r\n]+~', $rawHeadersLastRedir) as $line) {
if (empty($line) or ctype_space($line)) {
continue;
}
$splitLine = explode(': ', $line, 2);
if (count($splitLine) > 1) {
$key = $splitLine[0];
$value = $splitLine[1];
if (array_key_exists($key, $headers)) {
if (!is_array($headers[$key])) {
$headers[$key] = array(0 => $headers[$key]);
}
$headers[$key][] = $value;
} else {
$headers[$key] = $value;
}
} else {
$headers[] = $splitLine[0];
}
}
return array($headers, $content);
}
/**
* GET an HTTP URL to retrieve its content (fallback method)
*
* @param string $cleanUrl URL to get (http://... valid and in ASCII form)
* @param int $timeout network timeout (in seconds)
* @param int $maxBytes maximum downloaded bytes
* @param string $userAgent "User-Agent" header
* @param string $acceptLanguage "Accept-Language" header
* @param int $maxRedr maximum amount of redirections followed
*
* @return array HTTP response headers, downloaded content
*
* Output format:
* [0] = associative array containing HTTP response headers
* [1] = URL content (downloaded data)
*
* @see http://php.net/manual/en/function.file-get-contents.php
* @see http://php.net/manual/en/function.stream-context-create.php
* @see http://php.net/manual/en/function.get-headers.php
*/
function get_http_response_fallback(
$cleanUrl,
$timeout,
$maxBytes,
$userAgent,
$acceptLanguage,
$maxRedr
) {
$options = array(
'http' => array(
'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);
if (! $headers || strpos($headers[0], '200 OK') === false) {
$options['http']['request_fulluri'] = true;
stream_context_set_default($options);
list($headers, $finalUrl) = get_redirected_headers($cleanUrl, $maxRedr);
}
if (! $headers) {
return array($headers, false);
}
try {
// TODO: catch Exception in calling code (thumbnailer)
$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 array($headers, $content);
}
/**
* Retrieve HTTP headers, following n redirections (temporary and permanent ones).
*
* @param string $url initial URL to reach.
* @param int $redirectionLimit max redirection follow.
*
* @return array HTTP headers, or false if it failed.
*/
function get_redirected_headers($url, $redirectionLimit = 3)
{
$headers = get_headers($url, 1);
if (!empty($headers['location']) && empty($headers['Location'])) {
$headers['Location'] = $headers['location'];
}
// Headers found, redirection found, and limit not reached.
if ($redirectionLimit-- > 0
&& !empty($headers)
&& (strpos($headers[0], '301') !== false || strpos($headers[0], '302') !== false)
&& !empty($headers['Location'])) {
$redirection = is_array($headers['Location']) ? end($headers['Location']) : $headers['Location'];
if ($redirection != $url) {
$redirection = getAbsoluteUrl($url, $redirection);
return get_redirected_headers($redirection, $redirectionLimit);
}
}
return array($headers, $url);
}
/**
* Get an absolute URL from a complete one, and another absolute/relative URL.
*
* @param string $originalUrl The original complete URL.
* @param string $newUrl The new one, absolute or relative.
*
* @return string Final URL:
* - $newUrl if it was already an absolute URL.
* - if it was relative, absolute URL from $originalUrl path.
*/
function getAbsoluteUrl($originalUrl, $newUrl)
{
$newScheme = parse_url($newUrl, PHP_URL_SCHEME);
// Already an absolute URL.
if (!empty($newScheme)) {
return $newUrl;
}
$parts = parse_url($originalUrl);
$final = $parts['scheme'] .'://'. $parts['host'];
$final .= (!empty($parts['port'])) ? $parts['port'] : '';
$final .= '/';
if ($newUrl[0] != '/') {
$final .= substr(ltrim($parts['path'], '/'), 0, strrpos($parts['path'], '/'));
}
$final .= ltrim($newUrl, '/');
return $final;
}
/**
* Returns the server's base URL: scheme://domain.tld[:port]
*
* @param array $server the $_SERVER array
*
* @return string the server's base URL
*
* @see http://www.ietf.org/rfc/rfc7239.txt
* @see http://www.ietf.org/rfc/rfc6648.txt
* @see http://stackoverflow.com/a/3561399
* @see http://stackoverflow.com/q/452375
*/
function server_url($server)
{
$scheme = 'http';
$port = '';
// Shaarli is served behind a proxy
if (isset($server['HTTP_X_FORWARDED_PROTO'])) {
// Keep forwarded scheme
if (strpos($server['HTTP_X_FORWARDED_PROTO'], ',') !== false) {
$schemes = explode(',', $server['HTTP_X_FORWARDED_PROTO']);
$scheme = trim($schemes[0]);
} else {
$scheme = $server['HTTP_X_FORWARDED_PROTO'];
}
if (isset($server['HTTP_X_FORWARDED_PORT'])) {
// Keep forwarded port
if (strpos($server['HTTP_X_FORWARDED_PORT'], ',') !== false) {
$ports = explode(',', $server['HTTP_X_FORWARDED_PORT']);
$port = ':' . trim($ports[0]);
} else {
$port = ':' . $server['HTTP_X_FORWARDED_PORT'];
}
}
return $scheme.'://'.$server['SERVER_NAME'].$port;
}
// SSL detection
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'];
}
return $scheme.'://'.$server['SERVER_NAME'].$port;
}
/**
* Returns the absolute URL of the current script, without the query
*
* If the resource is "index.php", then it is removed (for better-looking URLs)
*
* @param array $server the $_SERVER array
*
* @return string the absolute URL of the current script, without the query
*/
function index_url($server)
{
$scriptname = $server['SCRIPT_NAME'];
if (endsWith($scriptname, 'index.php')) {
$scriptname = substr($scriptname, 0, -9);
}
return server_url($server) . $scriptname;
}
/**
* Returns the absolute URL of the current script, with the query
*
* If the resource is "index.php", then it is removed (for better-looking URLs)
*
* @param array $server the $_SERVER array
*
* @return string the absolute URL of the current script, with the query
*/
function page_url($server)
{
if (! empty($server['QUERY_STRING'])) {
return index_url($server).'?'.$server['QUERY_STRING'];
}
return index_url($server);
}
/**
* Retrieve the initial IP forwarded by the reverse proxy.
*
* Inspired from: https://github.com/zendframework/zend-http/blob/master/src/PhpEnvironment/RemoteAddress.php
*
* @param array $server $_SERVER array which contains HTTP headers.
* @param array $trustedIps List of trusted IP from the configuration.
*
* @return string|bool The forwarded IP, or false if none could be extracted.
*/
function getIpAddressFromProxy($server, $trustedIps)
{
$forwardedIpHeader = 'HTTP_X_FORWARDED_FOR';
if (empty($server[$forwardedIpHeader])) {
return false;
}
$ips = preg_split('/\s*,\s*/', $server[$forwardedIpHeader]);
$ips = array_diff($ips, $trustedIps);
if (empty($ips)) {
return false;
}
return array_pop($ips);
}

View file

@ -1,193 +1,21 @@
<?php <?php
namespace Shaarli;
use Gettext\GettextTranslator;
use Gettext\Translations;
use Gettext\Translator;
use Gettext\TranslatorInterface;
use Shaarli\Config\ConfigManager;
/** /**
* Class Languages * Wrapper function for translation which match the API
* of gettext()/_() and ngettext().
* *
* Load Shaarli translations using 'gettext/gettext'. * Not doing translation for now.
* This class allows to either use PHP gettext extension, or a PHP implementation of gettext,
* with a fixed language, or dynamically using autoLocale().
* *
* Translation files PO/MO files follow gettext standard and must be placed under: * @param string $text Text to translate.
* <translation path>/<language>/LC_MESSAGES/<domain>.[po|mo] * @param string $nText The plural message ID.
* @param int $nb The number of items for plural forms.
* *
* Pros/cons: * @return String Text translated.
* - gettext extension is faster
* - gettext is very system dependent (PHP extension, the locale must be installed, and web server reloaded)
*
* Settings:
* - translation.mode:
* - auto: use default setting (PHP implementation)
* - php: use PHP implementation
* - gettext: use gettext wrapper
* - translation.language:
* - auto: use autoLocale() and the language change according to user HTTP headers
* - fixed language: e.g. 'fr'
* - translation.extensions:
* - domain => translation_path: allow plugins and themes to extend the defaut extension
* The domain must be unique, and translation path must be relative, and contains the tree mentioned above.
*
* @package Shaarli
*/ */
class Languages function t($text, $nText = '', $nb = 0) {
{ if (empty($nText)) {
/** return $text;
* Core translations domain
*/
public const DEFAULT_DOMAIN = 'shaarli';
/**
* @var TranslatorInterface
*/
protected $translator;
/**
* @var string
*/
protected $language;
/**
* @var ConfigManager
*/
protected $conf;
/**
* Languages constructor.
*
* @param string $language lang determined by autoLocale(), can be overridden.
* @param ConfigManager $conf instance.
*/
public function __construct($language, $conf)
{
$this->conf = $conf;
$confLanguage = $this->conf->get('translation.language', 'auto');
// Auto mode or invalid parameter, use the detected language.
// If the detected language is invalid, it doesn't matter, it will use English.
if ($confLanguage === 'auto' || ! $this->isValidLanguage($confLanguage)) {
$this->language = substr($language, 0, 5);
} else {
$this->language = $confLanguage;
}
if (
! extension_loaded('gettext')
|| in_array($this->conf->get('translation.mode', 'auto'), ['auto', 'php'])
) {
$this->initPhpTranslator();
} else {
$this->initGettextTranslator();
}
// Register default functions (e.g. '__()') to use our Translator
$this->translator->register();
}
/**
* Initialize the translator using php gettext extension (gettext dependency act as a wrapper).
*/
protected function initGettextTranslator()
{
$this->translator = new GettextTranslator();
$this->translator->setLanguage($this->language);
$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';
if (is_dir($themeTransFolder)) {
$this->translator->loadDomain($this->conf->get('theme'), $themeTransFolder, false);
}
foreach ($this->conf->get('translation.extensions', []) as $domain => $translationPath) {
if ($domain !== self::DEFAULT_DOMAIN) {
$this->translator->loadDomain($domain, $translationPath, false);
}
}
}
/**
* Initialize the translator using a PHP implementation of gettext.
*
* Note that if language po file doesn't exist, errors are ignored (e.g. not installed language).
*/
protected function initPhpTranslator()
{
$this->translator = new Translator();
$translations = new Translations();
// Core translations
try {
$translations = $translations->addFromPoFile(
'inc/languages/' . $this->language . '/LC_MESSAGES/shaarli.po'
);
$translations->setDomain('shaarli');
$this->translator->loadTranslations($translations);
} catch (\InvalidArgumentException $e) {
}
// Default extension translation from the current theme
$theme = $this->conf->get('theme');
$themeTransFolder = rtrim($this->conf->get('raintpl_tpl'), '/') . '/' . $theme . '/language';
if (is_dir($themeTransFolder)) {
try {
$translations = Translations::fromPoFile(
$themeTransFolder . '/' . $this->language . '/LC_MESSAGES/' . $theme . '.po'
);
$translations->setDomain($theme);
$this->translator->loadTranslations($translations);
} catch (\InvalidArgumentException $e) {
}
}
// Extension translations (plugins, themes, etc.).
foreach ($this->conf->get('translation.extensions', []) as $domain => $translationPath) {
if ($domain === self::DEFAULT_DOMAIN) {
continue;
}
try {
$extension = Translations::fromPoFile(
$translationPath . $this->language . '/LC_MESSAGES/' . $domain . '.po'
);
$extension->setDomain($domain);
$this->translator->loadTranslations($extension);
} catch (\InvalidArgumentException $e) {
}
}
}
/**
* Checks if a language string is valid.
*
* @param string $language e.g. 'fr' or 'en_US'
*
* @return bool true if valid, false otherwise
*/
protected function isValidLanguage($language)
{
return preg_match('/^[a-z]{2}(_[A-Z]{2})?/', $language) === 1;
}
/**
* Get the list of available languages for Shaarli.
*
* @return array List of available languages, with their label.
*/
public static function getAvailableLanguages()
{
return [
'auto' => t('Automatic'),
'de' => t('German'),
'en' => t('English'),
'fr' => t('French'),
'jp' => t('Japanese'),
'ru' => t('Russian'),
'zh_CN' => t('Chinese (Simplified)'),
];
} }
$actualForm = $nb > 1 ? $nText : $text;
return sprintf($actualForm, $nb);
} }

478
application/LinkDB.php Normal file
View file

@ -0,0 +1,478 @@
<?php
/**
* Data storage for links.
*
* This object behaves like an associative array.
*
* Example:
* $myLinks = new LinkDB();
* echo $myLinks['20110826_161819']['title'];
* foreach ($myLinks as $link)
* echo $link['title'].' at url '.$link['url'].'; description:'.$link['description'];
*
* Available keys:
* - description: description of the entry
* - linkdate: creation date of this entry, format: YYYYMMDD_HHMMSS
* (e.g.'20110914_192317')
* - updated: last modification date of this entry, format: YYYYMMDD_HHMMSS
* - private: Is this link private? 0=no, other value=yes
* - tags: tags attached to this entry (separated by spaces)
* - title Title of the link
* - url URL of the link. Used for displayable links (no redirector, relative, etc.).
* Can be absolute or relative.
* Relative URLs are permalinks (e.g.'?m-ukcw')
* - real_url Absolute processed URL.
*
* Implements 3 interfaces:
* - ArrayAccess: behaves like an associative array;
* - Countable: there is a count() method;
* - Iterator: usable in foreach () loops.
*/
class LinkDB implements Iterator, Countable, ArrayAccess
{
// Links are stored as a PHP serialized string
private $datastore;
// Link date storage format
const LINK_DATE_FORMAT = 'Ymd_His';
// Datastore PHP prefix
protected static $phpPrefix = '<?php /* ';
// Datastore PHP suffix
protected static $phpSuffix = ' */ ?>';
// List of links (associative array)
// - key: link date (e.g. "20110823_124546"),
// - value: associative array (keys: title, description...)
private $links;
// List of all recorded URLs (key=url, value=linkdate)
// for fast reserve search (url-->linkdate)
private $urls;
// List of linkdate keys (for the Iterator interface implementation)
private $keys;
// Position in the $this->keys array (for the Iterator interface)
private $position;
// Is the user logged in? (used to filter private links)
private $loggedIn;
// Hide public links
private $hidePublicLinks;
// link redirector set in user settings.
private $redirector;
/**
* Set this to `true` to urlencode link behind redirector link, `false` to leave it untouched.
*
* Example:
* anonym.to needs clean URL while dereferer.org needs urlencoded URL.
*
* @var boolean $redirectorEncode parameter: true or false
*/
private $redirectorEncode;
/**
* Creates a new LinkDB
*
* Checks if the datastore exists; else, attempts to create a dummy one.
*
* @param string $datastore datastore file path.
* @param boolean $isLoggedIn is the user logged in?
* @param boolean $hidePublicLinks if true all links are private.
* @param string $redirector link redirector set in user settings.
* @param boolean $redirectorEncode Enable urlencode on redirected urls (default: true).
*/
public function __construct(
$datastore,
$isLoggedIn,
$hidePublicLinks,
$redirector = '',
$redirectorEncode = true
)
{
$this->datastore = $datastore;
$this->loggedIn = $isLoggedIn;
$this->hidePublicLinks = $hidePublicLinks;
$this->redirector = $redirector;
$this->redirectorEncode = $redirectorEncode === true;
$this->check();
$this->read();
}
/**
* Countable - Counts elements of an object
*/
public function count()
{
return count($this->links);
}
/**
* ArrayAccess - Assigns a value to the specified offset
*/
public function offsetSet($offset, $value)
{
// TODO: use exceptions instead of "die"
if (!$this->loggedIn) {
die('You are not authorized to add a link.');
}
if (empty($value['linkdate']) || empty($value['url'])) {
die('Internal Error: A link should always have a linkdate and URL.');
}
if (empty($offset)) {
die('You must specify a key.');
}
$this->links[$offset] = $value;
$this->urls[$value['url']]=$offset;
}
/**
* ArrayAccess - Whether or not an offset exists
*/
public function offsetExists($offset)
{
return array_key_exists($offset, $this->links);
}
/**
* ArrayAccess - Unsets an offset
*/
public function offsetUnset($offset)
{
if (!$this->loggedIn) {
// TODO: raise an exception
die('You are not authorized to delete a link.');
}
$url = $this->links[$offset]['url'];
unset($this->urls[$url]);
unset($this->links[$offset]);
}
/**
* ArrayAccess - Returns the value at specified offset
*/
public function offsetGet($offset)
{
return isset($this->links[$offset]) ? $this->links[$offset] : null;
}
/**
* Iterator - Returns the current element
*/
public function current()
{
return $this->links[$this->keys[$this->position]];
}
/**
* Iterator - Returns the key of the current element
*/
public function key()
{
return $this->keys[$this->position];
}
/**
* Iterator - Moves forward to next element
*/
public function next()
{
++$this->position;
}
/**
* Iterator - Rewinds the Iterator to the first element
*
* Entries are sorted by date (latest first)
*/
public function rewind()
{
$this->keys = array_keys($this->links);
rsort($this->keys);
$this->position = 0;
}
/**
* Iterator - Checks if current position is valid
*/
public function valid()
{
return isset($this->keys[$this->position]);
}
/**
* Checks if the DB directory and file exist
*
* If no DB file is found, creates a dummy DB.
*/
private function check()
{
if (file_exists($this->datastore)) {
return;
}
// Create a dummy database for example
$this->links = array();
$link = array(
'title'=>' Shaarli: the personal, minimalist, super-fast, no-database delicious clone',
'url'=>'https://github.com/shaarli/Shaarli/wiki',
'description'=>'Welcome to Shaarli! This is your first public bookmark. To edit or delete me, you must first login.
To learn how to use Shaarli, consult the link "Help/documentation" at the bottom of this page.
You use the community supported version of the original Shaarli project, by Sebastien Sauvage.',
'private'=>0,
'linkdate'=> date('Ymd_His'),
'tags'=>'opensource software'
);
$this->links[$link['linkdate']] = $link;
$link = array(
'title'=>'My secret stuff... - Pastebin.com',
'url'=>'http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=',
'description'=>'Shhhh! I\'m a private link only YOU can see. You can delete me too.',
'private'=>1,
'linkdate'=> date('Ymd_His', strtotime('-1 minute')),
'tags'=>'secretstuff'
);
$this->links[$link['linkdate']] = $link;
// Write database to disk
$this->write();
}
/**
* Reads database from disk to memory
*/
private function read()
{
// Public links are hidden and user not logged in => nothing to show
if ($this->hidePublicLinks && !$this->loggedIn) {
$this->links = array();
return;
}
// Read data
// Note that gzinflate is faster than gzuncompress.
// See: http://www.php.net/manual/en/function.gzdeflate.php#96439
$this->links = array();
if (file_exists($this->datastore)) {
$this->links = unserialize(gzinflate(base64_decode(
substr(file_get_contents($this->datastore),
strlen(self::$phpPrefix), -strlen(self::$phpSuffix)))));
}
// If user is not logged in, filter private links.
if (!$this->loggedIn) {
$toremove = array();
foreach ($this->links as $link) {
if ($link['private'] != 0) {
$toremove[] = $link['linkdate'];
}
}
foreach ($toremove as $linkdate) {
unset($this->links[$linkdate]);
}
}
$this->urls = array();
foreach ($this->links as &$link) {
// Keep the list of the mapping URLs-->linkdate up-to-date.
$this->urls[$link['url']] = $link['linkdate'];
// Sanitize data fields.
sanitizeLink($link);
// Remove private tags if the user is not logged in.
if (! $this->loggedIn) {
$link['tags'] = preg_replace('/(^|\s+)\.[^($|\s)]+\s*/', ' ', $link['tags']);
}
// Do not use the redirector for internal links (Shaarli note URL starting with a '?').
if (!empty($this->redirector) && !startsWith($link['url'], '?')) {
$link['real_url'] = $this->redirector;
if ($this->redirectorEncode) {
$link['real_url'] .= urlencode(unescape($link['url']));
} else {
$link['real_url'] .= $link['url'];
}
}
else {
$link['real_url'] = $link['url'];
}
}
}
/**
* Saves the database from memory to disk
*
* @throws IOException the datastore is not writable
*/
private function write()
{
if (is_file($this->datastore) && !is_writeable($this->datastore)) {
// The datastore exists but is not writeable
throw new IOException($this->datastore);
} else if (!is_file($this->datastore) && !is_writeable(dirname($this->datastore))) {
// The datastore does not exist and its parent directory is not writeable
throw new IOException(dirname($this->datastore));
}
file_put_contents(
$this->datastore,
self::$phpPrefix.base64_encode(gzdeflate(serialize($this->links))).self::$phpSuffix
);
}
/**
* Saves the database from memory to disk
*
* @param string $pageCacheDir page cache directory
*/
public function save($pageCacheDir)
{
if (!$this->loggedIn) {
// TODO: raise an Exception instead
die('You are not authorized to change the database.');
}
$this->write();
invalidateCaches($pageCacheDir);
}
/**
* Returns the link for a given URL, or False if it does not exist.
*
* @param string $url URL to search for
*
* @return mixed the existing link if it exists, else 'false'
*/
public function getLinkFromUrl($url)
{
if (isset($this->urls[$url])) {
return $this->links[$this->urls[$url]];
}
return false;
}
/**
* Returns the shaare corresponding to a smallHash.
*
* @param string $request QUERY_STRING server parameter.
*
* @return array $filtered array containing permalink data.
*
* @throws LinkNotFoundException if the smallhash is malformed or doesn't match any link.
*/
public function filterHash($request)
{
$request = substr($request, 0, 6);
$linkFilter = new LinkFilter($this->links);
return $linkFilter->filter(LinkFilter::$FILTER_HASH, $request);
}
/**
* Returns the list of articles for a given day.
*
* @param string $request day to filter. Format: YYYYMMDD.
*
* @return array list of shaare found.
*/
public function filterDay($request) {
$linkFilter = new LinkFilter($this->links);
return $linkFilter->filter(LinkFilter::$FILTER_DAY, $request);
}
/**
* Filter links according to search parameters.
*
* @param array $filterRequest Search request content. Supported keys:
* - searchtags: list of tags
* - searchterm: term search
* @param bool $casesensitive Optional: Perform case sensitive filter
* @param bool $privateonly Optional: Returns private links only if true.
*
* @return array filtered links, all links if no suitable filter was provided.
*/
public function filterSearch($filterRequest = array(), $casesensitive = false, $privateonly = false)
{
// Filter link database according to parameters.
$searchtags = !empty($filterRequest['searchtags']) ? escape($filterRequest['searchtags']) : '';
$searchterm = !empty($filterRequest['searchterm']) ? escape($filterRequest['searchterm']) : '';
// Search tags + fullsearch.
if (! empty($searchtags) && ! empty($searchterm)) {
$type = LinkFilter::$FILTER_TAG | LinkFilter::$FILTER_TEXT;
$request = array($searchtags, $searchterm);
}
// Search by tags.
elseif (! empty($searchtags)) {
$type = LinkFilter::$FILTER_TAG;
$request = $searchtags;
}
// Fulltext search.
elseif (! empty($searchterm)) {
$type = LinkFilter::$FILTER_TEXT;
$request = $searchterm;
}
// Otherwise, display without filtering.
else {
$type = '';
$request = '';
}
$linkFilter = new LinkFilter($this->links);
return $linkFilter->filter($type, $request, $casesensitive, $privateonly);
}
/**
* Returns the list of all tags
* Output: associative array key=tags, value=0
*/
public function allTags()
{
$tags = array();
$caseMapping = array();
foreach ($this->links as $link) {
foreach (preg_split('/\s+/', $link['tags'], 0, PREG_SPLIT_NO_EMPTY) as $tag) {
if (empty($tag)) {
continue;
}
// The first case found will be displayed.
if (!isset($caseMapping[strtolower($tag)])) {
$caseMapping[strtolower($tag)] = $tag;
$tags[$caseMapping[strtolower($tag)]] = 0;
}
$tags[$caseMapping[strtolower($tag)]]++;
}
}
// Sort tags by usage (most used tag first)
arsort($tags);
return $tags;
}
/**
* Returns the list of days containing articles (oldest first)
* Output: An array containing days (in format YYYYMMDD).
*/
public function days()
{
$linkDays = array();
foreach (array_keys($this->links) as $day) {
$linkDays[substr($day, 0, 8)] = 0;
}
$linkDays = array_keys($linkDays);
sort($linkDays);
return $linkDays;
}
}

361
application/LinkFilter.php Normal file
View file

@ -0,0 +1,361 @@
<?php
/**
* Class LinkFilter.
*
* Perform search and filter operation on link data list.
*/
class LinkFilter
{
/**
* @var string permalinks.
*/
public static $FILTER_HASH = 'permalink';
/**
* @var string text search.
*/
public static $FILTER_TEXT = 'fulltext';
/**
* @var string tag filter.
*/
public static $FILTER_TAG = 'tags';
/**
* @var string filter by day.
*/
public static $FILTER_DAY = 'FILTER_DAY';
/**
* @var string Allowed characters for hashtags (regex syntax).
*/
public static $HASHTAG_CHARS = '\p{Pc}\p{N}\p{L}\p{Mn}';
/**
* @var array all available links.
*/
private $links;
/**
* @param array $links initialization.
*/
public function __construct($links)
{
$this->links = $links;
}
/**
* Filter links according to parameters.
*
* @param string $type Type of filter (eg. tags, permalink, etc.).
* @param mixed $request Filter content.
* @param bool $casesensitive Optional: Perform case sensitive filter if true.
* @param bool $privateonly Optional: Only returns private links if true.
*
* @return array filtered link list.
*/
public function filter($type, $request, $casesensitive = false, $privateonly = false)
{
switch($type) {
case self::$FILTER_HASH:
return $this->filterSmallHash($request);
case self::$FILTER_TAG | self::$FILTER_TEXT:
if (!empty($request)) {
$filtered = $this->links;
if (isset($request[0])) {
$filtered = $this->filterTags($request[0], $casesensitive, $privateonly);
}
if (isset($request[1])) {
$lf = new LinkFilter($filtered);
$filtered = $lf->filterFulltext($request[1], $privateonly);
}
return $filtered;
}
return $this->noFilter($privateonly);
case self::$FILTER_TEXT:
return $this->filterFulltext($request, $privateonly);
case self::$FILTER_TAG:
return $this->filterTags($request, $casesensitive, $privateonly);
case self::$FILTER_DAY:
return $this->filterDay($request);
default:
return $this->noFilter($privateonly);
}
}
/**
* Unknown filter, but handle private only.
*
* @param bool $privateonly returns private link only if true.
*
* @return array filtered links.
*/
private function noFilter($privateonly = false)
{
if (! $privateonly) {
krsort($this->links);
return $this->links;
}
$out = array();
foreach ($this->links as $value) {
if ($value['private']) {
$out[$value['linkdate']] = $value;
}
}
krsort($out);
return $out;
}
/**
* Returns the shaare corresponding to a smallHash.
*
* @param string $smallHash permalink hash.
*
* @return array $filtered array containing permalink data.
*
* @throws LinkNotFoundException if the smallhash doesn't match any link.
*/
private function filterSmallHash($smallHash)
{
$filtered = array();
foreach ($this->links as $l) {
if ($smallHash == smallHash($l['linkdate'])) {
// Yes, this is ugly and slow
$filtered[$l['linkdate']] = $l;
return $filtered;
}
}
if (empty($filtered)) {
throw new LinkNotFoundException();
}
return $filtered;
}
/**
* Returns the list of links corresponding to a full-text search
*
* Searches:
* - in the URLs, title and description;
* - are case-insensitive;
* - terms surrounded by quotes " are exact terms search.
* - terms starting with a dash - are excluded (except exact terms).
*
* Example:
* print_r($mydb->filterFulltext('hollandais'));
*
* mb_convert_case($val, MB_CASE_LOWER, 'UTF-8')
* - allows to perform searches on Unicode text
* - see https://github.com/shaarli/Shaarli/issues/75 for examples
*
* @param string $searchterms search query.
* @param bool $privateonly return only private links if true.
*
* @return array search results.
*/
private function filterFulltext($searchterms, $privateonly = false)
{
if (empty($searchterms)) {
return $this->links;
}
$filtered = array();
$search = mb_convert_case(html_entity_decode($searchterms), MB_CASE_LOWER, 'UTF-8');
$exactRegex = '/"([^"]+)"/';
// Retrieve exact search terms.
preg_match_all($exactRegex, $search, $exactSearch);
$exactSearch = array_values(array_filter($exactSearch[1]));
// Remove exact search terms to get AND terms search.
$explodedSearchAnd = explode(' ', trim(preg_replace($exactRegex, '', $search)));
$explodedSearchAnd = array_values(array_filter($explodedSearchAnd));
// Filter excluding terms and update andSearch.
$excludeSearch = array();
$andSearch = array();
foreach ($explodedSearchAnd as $needle) {
if ($needle[0] == '-' && strlen($needle) > 1) {
$excludeSearch[] = substr($needle, 1);
} else {
$andSearch[] = $needle;
}
}
$keys = array('title', 'description', 'url', 'tags');
// Iterate over every stored link.
foreach ($this->links as $link) {
// ignore non private links when 'privatonly' is on.
if (! $link['private'] && $privateonly === true) {
continue;
}
// Concatenate link fields to search across fields.
// Adds a '\' separator for exact search terms.
$content = '';
foreach ($keys as $key) {
$content .= mb_convert_case($link[$key], MB_CASE_LOWER, 'UTF-8') . '\\';
}
// Be optimistic
$found = true;
// First, we look for exact term search
for ($i = 0; $i < count($exactSearch) && $found; $i++) {
$found = strpos($content, $exactSearch[$i]) !== false;
}
// Iterate over keywords, if keyword is not found,
// no need to check for the others. We want all or nothing.
for ($i = 0; $i < count($andSearch) && $found; $i++) {
$found = strpos($content, $andSearch[$i]) !== false;
}
// Exclude terms.
for ($i = 0; $i < count($excludeSearch) && $found; $i++) {
$found = strpos($content, $excludeSearch[$i]) === false;
}
if ($found) {
$filtered[$link['linkdate']] = $link;
}
}
krsort($filtered);
return $filtered;
}
/**
* Returns the list of links associated with a given list of tags
*
* You can specify one or more tags, separated by space or a comma, e.g.
* print_r($mydb->filterTags('linux programming'));
*
* @param string $tags list of tags separated by commas or blank spaces.
* @param bool $casesensitive ignore case if false.
* @param bool $privateonly returns private links only.
*
* @return array filtered links.
*/
public function filterTags($tags, $casesensitive = false, $privateonly = false)
{
// Implode if array for clean up.
$tags = is_array($tags) ? trim(implode(' ', $tags)) : $tags;
if (empty($tags)) {
return $this->links;
}
$searchtags = self::tagsStrToArray($tags, $casesensitive);
$filtered = array();
if (empty($searchtags)) {
return $filtered;
}
foreach ($this->links as $link) {
// ignore non private links when 'privatonly' is on.
if (! $link['private'] && $privateonly === true) {
continue;
}
$linktags = self::tagsStrToArray($link['tags'], $casesensitive);
$found = true;
for ($i = 0 ; $i < count($searchtags) && $found; $i++) {
// Exclusive search, quit if tag found.
// Or, tag not found in the link, quit.
if (($searchtags[$i][0] == '-'
&& $this->searchTagAndHashTag(substr($searchtags[$i], 1), $linktags, $link['description']))
|| ($searchtags[$i][0] != '-')
&& ! $this->searchTagAndHashTag($searchtags[$i], $linktags, $link['description'])
) {
$found = false;
}
}
if ($found) {
$filtered[$link['linkdate']] = $link;
}
}
krsort($filtered);
return $filtered;
}
/**
* Returns the list of articles for a given day, chronologically sorted
*
* Day must be in the form 'YYYYMMDD' (e.g. '20120125'), e.g.
* print_r($mydb->filterDay('20120125'));
*
* @param string $day day to filter.
*
* @return array all link matching given day.
*
* @throws Exception if date format is invalid.
*/
public function filterDay($day)
{
if (! checkDateFormat('Ymd', $day)) {
throw new Exception('Invalid date format');
}
$filtered = array();
foreach ($this->links as $l) {
if (startsWith($l['linkdate'], $day)) {
$filtered[$l['linkdate']] = $l;
}
}
ksort($filtered);
return $filtered;
}
/**
* Check if a tag is found in the taglist, or as an hashtag in the link description.
*
* @param string $tag Tag to search.
* @param array $taglist List of tags for the current link.
* @param string $description Link description.
*
* @return bool True if found, false otherwise.
*/
protected function searchTagAndHashTag($tag, $taglist, $description)
{
if (in_array($tag, $taglist)) {
return true;
}
if (preg_match('/(^| )#'. $tag .'([^'. self::$HASHTAG_CHARS .']|$)/mui', $description) > 0) {
return true;
}
return false;
}
/**
* Convert a list of tags (str) to an array. Also
* - handle case sensitivity.
* - accepts spaces commas as separator.
*
* @param string $tags string containing a list of tags.
* @param bool $casesensitive will convert everything to lowercase if false.
*
* @return array filtered tags string.
*/
public static function tagsStrToArray($tags, $casesensitive)
{
// We use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek)
$tagsOut = $casesensitive ? $tags : mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8');
$tagsOut = str_replace(',', ' ', $tagsOut);
return array_values(array_filter(explode(' ', trim($tagsOut)), 'strlen'));
}
}
class LinkNotFoundException extends Exception
{
protected $message = 'The link you are trying to reach does not exist or has been deleted.';
}

171
application/LinkUtils.php Normal file
View file

@ -0,0 +1,171 @@
<?php
/**
* Extract title from an HTML document.
*
* @param string $html HTML content where to look for a title.
*
* @return bool|string Extracted title if found, false otherwise.
*/
function html_extract_title($html)
{
if (preg_match('!<title.*?>(.*?)</title>!is', $html, $matches)) {
return trim(str_replace("\n", '', $matches[1]));
}
return false;
}
/**
* Determine charset from downloaded page.
* Priority:
* 1. HTTP headers (Content type).
* 2. HTML content page (tag <meta charset>).
* 3. Use a default charset (default: UTF-8).
*
* @param array $headers HTTP headers array.
* @param string $htmlContent HTML content where to look for charset.
* @param string $defaultCharset Default charset to apply if other methods failed.
*
* @return string Determined charset.
*/
function get_charset($headers, $htmlContent, $defaultCharset = 'utf-8')
{
if ($charset = headers_extract_charset($headers)) {
return $charset;
}
if ($charset = html_extract_charset($htmlContent)) {
return $charset;
}
return $defaultCharset;
}
/**
* Extract charset from HTTP headers if it's defined.
*
* @param array $headers HTTP headers array.
*
* @return bool|string Charset string if found (lowercase), false otherwise.
*/
function headers_extract_charset($headers)
{
if (! empty($headers['Content-Type']) && strpos($headers['Content-Type'], 'charset=') !== false) {
preg_match('/charset="?([^; ]+)/i', $headers['Content-Type'], $match);
if (! empty($match[1])) {
return strtolower(trim($match[1]));
}
}
return false;
}
/**
* Extract charset HTML content (tag <meta charset>).
*
* @param string $html HTML content where to look for charset.
*
* @return bool|string Charset string if found, false otherwise.
*/
function html_extract_charset($html)
{
// Get encoding specified in HTML header.
preg_match('#<meta .*charset=["\']?([^";\'>/]+)["\']? */?>#Usi', $html, $enc);
if (!empty($enc[1])) {
return strtolower($enc[1]);
}
return false;
}
/**
* Count private links in given linklist.
*
* @param array|Countable $links Linklist.
*
* @return int Number of private links.
*/
function count_private($links)
{
$cpt = 0;
foreach ($links as $link) {
$cpt = $link['private'] == true ? $cpt + 1 : $cpt;
}
return $cpt;
}
/**
* In a string, converts URLs to clickable links.
*
* @param string $text input string.
* @param string $redirector if a redirector is set, use it to gerenate links.
*
* @return string returns $text with all links converted to HTML links.
*
* @see Function inspired from http://www.php.net/manual/en/function.preg-replace.php#85722
*/
function text2clickable($text, $redirector = '')
{
$regex = '!(((?:https?|ftp|file)://|apt:|magnet:)\S+[[:alnum:]]/?)!si';
if (empty($redirector)) {
return preg_replace($regex, '<a href="$1">$1</a>', $text);
}
// Redirector is set, urlencode the final URL.
return preg_replace_callback(
$regex,
function ($matches) use ($redirector) {
return '<a href="' . $redirector . urlencode($matches[1]) .'">'. $matches[1] .'</a>';
},
$text
);
}
/**
* Auto-link hashtags.
*
* @param string $description Given description.
* @param string $indexUrl Root URL.
*
* @return string Description with auto-linked hashtags.
*/
function hashtag_autolink($description, $indexUrl = '')
{
/*
* To support unicode: http://stackoverflow.com/a/35498078/1484919
* \p{Pc} - to match underscore
* \p{N} - numeric character in any script
* \p{L} - letter from any language
* \p{Mn} - any non marking space (accents, umlauts, etc)
*/
$regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui';
$replacement = '$1<a href="'. $indexUrl .'?addtag=$2" title="Hashtag $2">#$2</a>';
return preg_replace($regex, $replacement, $description);
}
/**
* This function inserts &nbsp; where relevant so that multiple spaces are properly displayed in HTML
* even in the absence of <pre> (This is used in description to keep text formatting).
*
* @param string $text input text.
*
* @return string formatted text.
*/
function space2nbsp($text)
{
return preg_replace('/(^| ) /m', '$1&nbsp;', $text);
}
/**
* Format Shaarli's description
*
* @param string $description shaare's description.
* @param string $redirector if a redirector is set, use it to gerenate links.
* @param string $indexUrl URL to Shaarli's index.
*
* @return string formatted description.
*/
function format_description($description, $redirector = '', $indexUrl = '') {
return nl2br(space2nbsp(hashtag_autolink(text2clickable($description, $redirector), $indexUrl)));
}

View file

@ -0,0 +1,195 @@
<?php
/**
* Utilities to import and export bookmarks using the Netscape format
*/
class NetscapeBookmarkUtils
{
/**
* Filters links and adds Netscape-formatted fields
*
* Added fields:
* - timestamp link addition date, using the Unix epoch format
* - taglist comma-separated tag list
*
* @param LinkDB $linkDb Link datastore
* @param string $selection Which links to export: (all|private|public)
* @param bool $prependNoteUrl Prepend note permalinks with the server's URL
* @param string $indexUrl Absolute URL of the Shaarli index page
*
* @throws Exception Invalid export selection
*
* @return array The links to be exported, with additional fields
*/
public static function filterAndFormat($linkDb, $selection, $prependNoteUrl, $indexUrl)
{
// see tpl/export.html for possible values
if (! in_array($selection, array('all', 'public', 'private'))) {
throw new Exception('Invalid export selection: "'.$selection.'"');
}
$bookmarkLinks = array();
foreach ($linkDb as $link) {
if ($link['private'] != 0 && $selection == 'public') {
continue;
}
if ($link['private'] == 0 && $selection == 'private') {
continue;
}
$date = DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, $link['linkdate']);
$link['timestamp'] = $date->getTimestamp();
$link['taglist'] = str_replace(' ', ',', $link['tags']);
if (startsWith($link['url'], '?') && $prependNoteUrl) {
$link['url'] = $indexUrl . $link['url'];
}
$bookmarkLinks[] = $link;
}
return $bookmarkLinks;
}
/**
* Generates an import status summary
*
* @param string $filename name of the file to import
* @param int $filesize size of the file to import
* @param int $importCount how many links were imported
* @param int $overwriteCount how many links were overwritten
* @param int $skipCount how many links were skipped
*
* @return string Summary of the bookmark import status
*/
private static function importStatus(
$filename,
$filesize,
$importCount=0,
$overwriteCount=0,
$skipCount=0
)
{
$status = 'File '.$filename.' ('.$filesize.' bytes) ';
if ($importCount == 0 && $overwriteCount == 0 && $skipCount == 0) {
$status .= 'has an unknown file format. Nothing was imported.';
} else {
$status .= 'was successfully processed: '.$importCount.' links imported, ';
$status .= $overwriteCount.' links overwritten, ';
$status .= $skipCount.' links skipped.';
}
return $status;
}
/**
* Imports Web bookmarks from an uploaded Netscape bookmark dump
*
* @param array $post Server $_POST parameters
* @param array $files Server $_FILES parameters
* @param LinkDB $linkDb Loaded LinkDB instance
* @param string $pagecache Page cache
*
* @return string Summary of the bookmark import status
*/
public static function import($post, $files, $linkDb, $pagecache)
{
$filename = $files['filetoupload']['name'];
$filesize = $files['filetoupload']['size'];
$data = file_get_contents($files['filetoupload']['tmp_name']);
if (strpos($data, '<!DOCTYPE NETSCAPE-Bookmark-file-1>') === false) {
return self::importStatus($filename, $filesize);
}
// Overwrite existing links?
$overwrite = ! empty($post['overwrite']);
// Add tags to all imported links?
if (empty($post['default_tags'])) {
$defaultTags = array();
} else {
$defaultTags = preg_split(
'/[\s,]+/',
escape($post['default_tags'])
);
}
// links are imported as public by default
$defaultPrivacy = 0;
$parser = new NetscapeBookmarkParser(
true, // nested tag support
$defaultTags, // additional user-specified tags
strval(1 - $defaultPrivacy) // defaultPub = 1 - defaultPrivacy
);
$bookmarks = $parser->parseString($data);
$importCount = 0;
$overwriteCount = 0;
$skipCount = 0;
foreach ($bookmarks as $bkm) {
$private = $defaultPrivacy;
if (empty($post['privacy']) || $post['privacy'] == 'default') {
// use value from the imported file
$private = $bkm['pub'] == '1' ? 0 : 1;
} else if ($post['privacy'] == 'private') {
// all imported links are private
$private = 1;
} else if ($post['privacy'] == 'public') {
// all imported links are public
$private = 0;
}
$newLink = array(
'title' => $bkm['title'],
'url' => $bkm['uri'],
'description' => $bkm['note'],
'private' => $private,
'linkdate'=> '',
'tags' => $bkm['tags']
);
$existingLink = $linkDb->getLinkFromUrl($bkm['uri']);
if ($existingLink !== false) {
if ($overwrite === false) {
// Do not overwrite an existing link
$skipCount++;
continue;
}
// Overwrite an existing link, keep its date
$newLink['linkdate'] = $existingLink['linkdate'];
$linkDb[$existingLink['linkdate']] = $newLink;
$importCount++;
$overwriteCount++;
continue;
}
// Add a new link
$newLinkDate = new DateTime('@'.strval($bkm['time']));
while (!empty($linkDb[$newLinkDate->format(LinkDB::LINK_DATE_FORMAT)])) {
// Ensure the date/time is not already used
// - this hack is necessary as the date/time acts as a primary key
// - apply 1 second increments until an unused index is found
// See https://github.com/shaarli/Shaarli/issues/351
$newLinkDate->add(new DateInterval('PT1S'));
}
$linkDbDate = $newLinkDate->format(LinkDB::LINK_DATE_FORMAT);
$newLink['linkdate'] = $linkDbDate;
$linkDb[$linkDbDate] = $newLink;
$importCount++;
}
$linkDb->save($pagecache);
return self::importStatus(
$filename,
$filesize,
$importCount,
$overwriteCount,
$skipCount
);
}
}

149
application/PageBuilder.php Normal file
View file

@ -0,0 +1,149 @@
<?php
/**
* This class is in charge of building the final page.
* (This is basically a wrapper around RainTPL which pre-fills some fields.)
* $p = new PageBuilder();
* $p->assign('myfield','myvalue');
* $p->renderPage('mytemplate');
*/
class PageBuilder
{
/**
* @var RainTPL RainTPL instance.
*/
private $tpl;
/**
* @var ConfigManager $conf Configuration Manager instance.
*/
protected $conf;
/**
* PageBuilder constructor.
* $tpl is initialized at false for lazy loading.
*
* @param ConfigManager $conf Configuration Manager instance (reference).
*/
function __construct(&$conf)
{
$this->tpl = false;
$this->conf = $conf;
}
/**
* Initialize all default tpl tags.
*/
private function initialize()
{
$this->tpl = new RainTPL();
try {
$version = ApplicationUtils::checkUpdate(
shaarli_version,
$this->conf->get('resource.update_check'),
$this->conf->get('updates.check_updates_interval'),
$this->conf->get('updates.check_updates'),
isLoggedIn(),
$this->conf->get('updates.check_updates_branch')
);
$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->tpl->assign('newVersion', '');
$this->tpl->assign('versionError', escape($exc->getMessage()));
}
$this->tpl->assign('feedurl', escape(index_url($_SERVER)));
$searchcrits = ''; // Search criteria
if (!empty($_GET['searchtags'])) {
$searchcrits .= '&searchtags=' . urlencode($_GET['searchtags']);
}
if (!empty($_GET['searchterm'])) {
$searchcrits .= '&searchterm=' . urlencode($_GET['searchterm']);
}
$this->tpl->assign('searchcrits', $searchcrits);
$this->tpl->assign('source', index_url($_SERVER));
$this->tpl->assign('version', shaarli_version);
$this->tpl->assign('scripturl', index_url($_SERVER));
$this->tpl->assign('privateonly', !empty($_SESSION['privateonly'])); // Show only private links?
$this->tpl->assign('pagetitle', $this->conf->get('general.title', 'Shaarli'));
if ($this->conf->exists('general.header_link')) {
$this->tpl->assign('titleLink', $this->conf->get('general.header_link'));
}
$this->tpl->assign('shaarlititle', $this->conf->get('general.title', 'Shaarli'));
$this->tpl->assign('openshaarli', $this->conf->get('security.open_shaarli', false));
$this->tpl->assign('showatom', $this->conf->get('feed.show_atom', false));
$this->tpl->assign('hide_timestamps', $this->conf->get('privacy.hide_timestamps', false));
$this->tpl->assign('token', getToken($this->conf));
// To be removed with a proper theme configuration.
$this->tpl->assign('conf', $this->conf->get('resource.theme', 'default'));
}
/**
* The following assign() method is basically the same as RainTPL (except lazy loading)
*
* @param string $placeholder Template placeholder.
* @param mixed $value Value to assign.
*/
public function assign($placeholder, $value)
{
if ($this->tpl === false) {
$this->initialize();
}
$this->tpl->assign($placeholder, $value);
}
/**
* Assign an array of data to the template builder.
*
* @param array $data Data to assign.
*
* @return false if invalid data.
*/
public function assignAll($data)
{
if ($this->tpl === false) {
$this->initialize();
}
if (empty($data) || !is_array($data)){
return false;
}
foreach ($data as $key => $value) {
$this->assign($key, $value);
}
return true;
}
/**
* Render a specific page (using a template file).
* e.g. $pb->renderPage('picwall');
*
* @param string $page Template filename (without extension).
*/
public function renderPage($page)
{
if ($this->tpl === false) {
$this->initialize();
}
$this->tpl->draw($page);
}
/**
* Render a 404 page (uses the template : tpl/404.tpl)
* usage : $PAGE->render404('The link was deleted')
*
* @param string $message A messate to display what is not found
*/
public function render404($message = 'The page you are trying to reach does not exist or has been deleted.')
{
header($_SERVER['SERVER_PROTOCOL'] . ' 404 Not Found');
$this->tpl->assign('error_message', $message);
$this->renderPage('404');
}
}

View file

@ -0,0 +1,242 @@
<?php
/**
* Class PluginManager
*
* Use to manage, load and execute plugins.
*/
class PluginManager
{
/**
* List of authorized plugins from configuration file.
* @var array $authorizedPlugins
*/
private $authorizedPlugins;
/**
* List of loaded plugins.
* @var array $loadedPlugins
*/
private $loadedPlugins = array();
/**
* @var ConfigManager Configuration Manager instance.
*/
protected $conf;
/**
* @var array List of plugin errors.
*/
protected $errors;
/**
* Plugins subdirectory.
* @var string $PLUGINS_PATH
*/
public static $PLUGINS_PATH = 'plugins';
/**
* Plugins meta files extension.
* @var string $META_EXT
*/
public static $META_EXT = 'meta';
/**
* Constructor.
*
* @param ConfigManager $conf Configuration Manager instance.
*/
public function __construct(&$conf)
{
$this->conf = $conf;
$this->errors = array();
}
/**
* Load plugins listed in $authorizedPlugins.
*
* @param array $authorizedPlugins Names of plugin authorized to be loaded.
*
* @return void
*/
public function load($authorizedPlugins)
{
$this->authorizedPlugins = $authorizedPlugins;
$dirs = glob(self::$PLUGINS_PATH . '/*', GLOB_ONLYDIR);
$dirnames = array_map('basename', $dirs);
foreach ($this->authorizedPlugins as $plugin) {
$index = array_search($plugin, $dirnames);
// plugin authorized, but its folder isn't listed
if ($index === false) {
continue;
}
try {
$this->loadPlugin($dirs[$index], $plugin);
}
catch (PluginFileNotFoundException $e) {
error_log($e->getMessage());
}
}
}
/**
* Execute all plugins registered hook.
*
* @param string $hook name of the hook to trigger.
* @param array $data list of data to manipulate passed by reference.
* @param array $params additional parameters such as page target.
*
* @return void
*/
public function executeHooks($hook, &$data, $params = array())
{
if (!empty($params['target'])) {
$data['_PAGE_'] = $params['target'];
}
if (isset($params['loggedin'])) {
$data['_LOGGEDIN_'] = $params['loggedin'];
}
foreach ($this->loadedPlugins as $plugin) {
$hookFunction = $this->buildHookName($hook, $plugin);
if (function_exists($hookFunction)) {
$data = call_user_func($hookFunction, $data, $this->conf);
}
}
}
/**
* Load a single plugin from its files.
* Call the init function if it exists, and collect errors.
* Add them in $loadedPlugins if successful.
*
* @param string $dir plugin's directory.
* @param string $pluginName plugin's name.
*
* @return void
* @throws PluginFileNotFoundException - plugin files not found.
*/
private function loadPlugin($dir, $pluginName)
{
if (!is_dir($dir)) {
throw new PluginFileNotFoundException($pluginName);
}
$pluginFilePath = $dir . '/' . $pluginName . '.php';
if (!is_file($pluginFilePath)) {
throw new PluginFileNotFoundException($pluginName);
}
$conf = $this->conf;
include_once $pluginFilePath;
$initFunction = $pluginName . '_init';
if (function_exists($initFunction)) {
$errors = call_user_func($initFunction, $this->conf);
if (!empty($errors)) {
$this->errors = array_merge($this->errors, $errors);
}
}
$this->loadedPlugins[] = $pluginName;
}
/**
* Construct normalize hook name for a specific plugin.
*
* Format:
* hook_<plugin_name>_<hook_name>
*
* @param string $hook hook name.
* @param string $pluginName plugin name.
*
* @return string - plugin's hook name.
*/
public function buildHookName($hook, $pluginName)
{
return 'hook_' . $pluginName . '_' . $hook;
}
/**
* Retrieve plugins metadata from *.meta (INI) files into an array.
* Metadata contains:
* - plugin description [description]
* - parameters split with ';' [parameters]
*
* Respects plugins order from settings.
*
* @return array plugins metadata.
*/
public function getPluginsMeta()
{
$metaData = array();
$dirs = glob(self::$PLUGINS_PATH . '/*', GLOB_ONLYDIR | GLOB_MARK);
// Browse all plugin directories.
foreach ($dirs as $pluginDir) {
$plugin = basename($pluginDir);
$metaFile = $pluginDir . $plugin . '.' . self::$META_EXT;
if (!is_file($metaFile) || !is_readable($metaFile)) {
continue;
}
$metaData[$plugin] = parse_ini_file($metaFile);
$metaData[$plugin]['order'] = array_search($plugin, $this->authorizedPlugins);
// Read parameters and format them into an array.
if (isset($metaData[$plugin]['parameters'])) {
$params = explode(';', $metaData[$plugin]['parameters']);
} else {
$params = array();
}
$metaData[$plugin]['parameters'] = array();
foreach ($params as $param) {
if (empty($param)) {
continue;
}
$metaData[$plugin]['parameters'][$param]['value'] = '';
// Optional parameter description in parameter.PARAM_NAME=
if (isset($metaData[$plugin]['parameter.'. $param])) {
$metaData[$plugin]['parameters'][$param]['desc'] = $metaData[$plugin]['parameter.'. $param];
}
}
}
return $metaData;
}
/**
* Return the list of encountered errors.
*
* @return array List of errors (empty array if none exists).
*/
public function getErrors()
{
return $this->errors;
}
}
/**
* Class PluginFileNotFoundException
*
* Raise when plugin files can't be found.
*/
class PluginFileNotFoundException extends Exception
{
/**
* Construct exception with plugin name.
* Generate message.
*
* @param string $pluginName name of the plugin not found
*/
public function __construct($pluginName)
{
$this->message = 'Plugin "'. $pluginName .'" files not found.';
}
}

141
application/Router.php Normal file
View file

@ -0,0 +1,141 @@
<?php
/**
* Class Router
*
* (only displayable pages here)
*/
class Router
{
public static $PAGE_LOGIN = 'login';
public static $PAGE_PICWALL = 'picwall';
public static $PAGE_TAGCLOUD = 'tagcloud';
public static $PAGE_DAILY = 'daily';
public static $PAGE_FEED_ATOM = 'atom';
public static $PAGE_FEED_RSS = 'rss';
public static $PAGE_TOOLS = 'tools';
public static $PAGE_CHANGEPASSWORD = 'changepasswd';
public static $PAGE_CONFIGURE = 'configure';
public static $PAGE_CHANGETAG = 'changetag';
public static $PAGE_ADDLINK = 'addlink';
public static $PAGE_EDITLINK = 'edit_link';
public static $PAGE_EXPORT = 'export';
public static $PAGE_IMPORT = 'import';
public static $PAGE_OPENSEARCH = 'opensearch';
public static $PAGE_LINKLIST = 'linklist';
public static $PAGE_PLUGINSADMIN = 'pluginadmin';
public static $PAGE_SAVE_PLUGINSADMIN = 'save_pluginadmin';
/**
* Reproducing renderPage() if hell, to avoid regression.
*
* This highlights how bad this needs to be rewrite,
* but let's focus on plugins for now.
*
* @param string $query $_SERVER['QUERY_STRING'].
* @param array $get $_SERVER['GET'].
* @param bool $loggedIn true if authenticated user.
*
* @return string page found.
*/
public static function findPage($query, $get, $loggedIn)
{
$loggedIn = ($loggedIn === true) ? true : false;
if (empty($query) && !isset($get['edit_link']) && !isset($get['post'])) {
return self::$PAGE_LINKLIST;
}
if (startsWith($query, 'do='. self::$PAGE_LOGIN) && $loggedIn === false) {
return self::$PAGE_LOGIN;
}
if (startsWith($query, 'do='. self::$PAGE_PICWALL)) {
return self::$PAGE_PICWALL;
}
if (startsWith($query, 'do='. self::$PAGE_TAGCLOUD)) {
return self::$PAGE_TAGCLOUD;
}
if (startsWith($query, 'do='. self::$PAGE_OPENSEARCH)) {
return self::$PAGE_OPENSEARCH;
}
if (startsWith($query, 'do='. self::$PAGE_DAILY)) {
return self::$PAGE_DAILY;
}
if (startsWith($query, 'do='. self::$PAGE_FEED_ATOM)) {
return self::$PAGE_FEED_ATOM;
}
if (startsWith($query, 'do='. self::$PAGE_FEED_RSS)) {
return self::$PAGE_FEED_RSS;
}
// At this point, only loggedin pages.
if (!$loggedIn) {
return self::$PAGE_LINKLIST;
}
if (startsWith($query, 'do='. self::$PAGE_TOOLS)) {
return self::$PAGE_TOOLS;
}
if (startsWith($query, 'do='. self::$PAGE_CHANGEPASSWORD)) {
return self::$PAGE_CHANGEPASSWORD;
}
if (startsWith($query, 'do='. self::$PAGE_CONFIGURE)) {
return self::$PAGE_CONFIGURE;
}
if (startsWith($query, 'do='. self::$PAGE_CHANGETAG)) {
return self::$PAGE_CHANGETAG;
}
if (startsWith($query, 'do='. self::$PAGE_ADDLINK)) {
return self::$PAGE_ADDLINK;
}
if (isset($get['edit_link']) || isset($get['post'])) {
return self::$PAGE_EDITLINK;
}
if (startsWith($query, 'do='. self::$PAGE_EXPORT)) {
return self::$PAGE_EXPORT;
}
if (startsWith($query, 'do='. self::$PAGE_IMPORT)) {
return self::$PAGE_IMPORT;
}
if (startsWith($query, 'do='. self::$PAGE_PLUGINSADMIN)) {
return self::$PAGE_PLUGINSADMIN;
}
if (startsWith($query, 'do='. self::$PAGE_SAVE_PLUGINSADMIN)) {
return self::$PAGE_SAVE_PLUGINSADMIN;
}
return self::$PAGE_LINKLIST;
}
}

View file

@ -1,131 +0,0 @@
<?php
namespace Shaarli;
use Shaarli\Config\ConfigManager;
use WebThumbnailer\Application\ConfigManager as WTConfigManager;
use WebThumbnailer\WebThumbnailer;
/**
* Class Thumbnailer
*
* Utility class used to retrieve thumbnails using web-thumbnailer dependency.
*/
class Thumbnailer
{
protected const COMMON_MEDIA_DOMAINS = [
'imgur.com',
'flickr.com',
'youtube.com',
'wikimedia.org',
'redd.it',
'gfycat.com',
'media.giphy.com',
'twitter.com',
'twimg.com',
'instagram.com',
'pinterest.com',
'pinterest.fr',
'soundcloud.com',
'tumblr.com',
'deviantart.com',
];
public const MODE_ALL = 'all';
public const MODE_COMMON = 'common';
public const MODE_NONE = 'none';
/**
* @var WebThumbnailer instance.
*/
protected $wt;
/**
* @var ConfigManager instance.
*/
protected $conf;
/**
* Thumbnailer constructor.
*
* @param ConfigManager $conf instance.
*/
public function __construct($conf)
{
$this->conf = $conf;
if (! $this->checkRequirements()) {
$this->conf->set('thumbnails.mode', Thumbnailer::MODE_NONE);
$this->conf->write(true);
// 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.'
));
}
$this->wt = new WebThumbnailer();
WTConfigManager::addFile('inc/web-thumbnailer.json');
$this->wt->maxWidth($this->conf->get('thumbnails.width'))
->maxHeight($this->conf->get('thumbnails.height'))
->crop(true)
->debug($this->conf->get('dev.debug', false));
}
/**
* Retrieve a thumbnail for given URL
*
* @param string $url where to look for a thumbnail.
*
* @return bool|string The thumbnail relative cache file path, or false if none has been found.
*/
public function get($url)
{
if (
$this->conf->get('thumbnails.mode') === self::MODE_COMMON
&& ! $this->isCommonMediaOrImage($url)
) {
return false;
}
try {
return $this->wt->thumbnail($url);
} catch (\Throwable $e) {
// Exceptions are only thrown in debug mode.
error_log(get_class($e) . ': ' . $e->getMessage());
}
return false;
}
/**
* We check weather the given URL is from a common media domain,
* or if the file extension is an image.
*
* @param string $url to check
*
* @return bool true if it's an image or from a common media domain, false otherwise.
*/
public function isCommonMediaOrImage($url)
{
foreach (self::COMMON_MEDIA_DOMAINS as $domain) {
if (strpos($url, $domain) !== false) {
return true;
}
}
if (endsWith($url, '.jpg') || endsWith($url, '.png') || endsWith($url, '.jpeg')) {
return true;
}
return false;
}
/**
* Make sure that requirements are match to use thumbnails:
* - php-gd is loaded
*/
protected function checkRequirements()
{
return extension_loaded('gd');
}
}

View file

@ -1,76 +1,88 @@
<?php <?php
/** /**
* Generates a list of available timezone continents and cities. * Generates the timezone selection form and JavaScript.
* *
* Two distinct array based on available timezones * Note: 'UTC/UTC' is mapped to 'UTC' to form a valid option
* and the one selected in the settings:
* - (0) continents:
* + list of available continents
* + special key 'selected' containing the value of the selected timezone's continent
* - (1) cities:
* + list of available cities associated with their continent
* + special key 'selected' containing the value of the selected timezone's city (without the continent)
* *
* Example: * Example: preselect Europe/Paris
* [ * list($htmlform, $js) = generateTimeZoneForm('Europe/Paris');
* [
* 'America',
* 'Europe',
* 'selected' => 'Europe',
* ],
* [
* ['continent' => 'America', 'city' => 'Toronto'],
* ['continent' => 'Europe', 'city' => 'Paris'],
* 'selected' => 'Paris',
* ],
* ];
* *
* Notes:
* - 'UTC/UTC' is mapped to 'UTC' to form a valid option
* - a few timezone cities includes the country/state, such as Argentina/Buenos_Aires
* - these arrays are designed to build timezone selects in template files with any HTML structure
*
* @param array $installedTimeZones List of installed timezones as string
* @param string $preselectedTimezone preselected timezone (optional) * @param string $preselectedTimezone preselected timezone (optional)
* *
* @return array[] continents and cities * @return array containing the generated HTML form and Javascript code
**/ **/
function generateTimeZoneData($installedTimeZones, $preselectedTimezone = '') function generateTimeZoneForm($preselectedTimezone='')
{ {
// Select the server timezone
if ($preselectedTimezone == '') {
$preselectedTimezone = date_default_timezone_get();
}
if ($preselectedTimezone == 'UTC') { if ($preselectedTimezone == 'UTC') {
$pcity = $pcontinent = 'UTC'; $pcity = $pcontinent = 'UTC';
} else { } else {
// Try to split the provided timezone // Try to split the provided timezone
$spos = strpos($preselectedTimezone, '/'); $spos = strpos($preselectedTimezone, '/');
$pcontinent = substr($preselectedTimezone, 0, $spos); $pcontinent = substr($preselectedTimezone, 0, $spos);
$pcity = substr($preselectedTimezone, $spos + 1); $pcity = substr($preselectedTimezone, $spos+1);
} }
$continents = []; // The list is in the form 'Europe/Paris', 'America/Argentina/Buenos_Aires'
$cities = []; // We split the list in continents/cities.
foreach ($installedTimeZones as $tz) { $continents = array();
$cities = array();
// TODO: use a template to generate the HTML/Javascript form
foreach (timezone_identifiers_list() as $tz) {
if ($tz == 'UTC') { if ($tz == 'UTC') {
$tz = 'UTC/UTC'; $tz = 'UTC/UTC';
} }
$spos = strpos($tz, '/'); $spos = strpos($tz, '/');
// Ignore invalid timezones if ($spos !== false) {
if ($spos === false) {
continue;
}
$continent = substr($tz, 0, $spos); $continent = substr($tz, 0, $spos);
$city = substr($tz, $spos + 1); $city = substr($tz, $spos+1);
$cities[] = ['continent' => $continent, 'city' => $city]; $continents[$continent] = 1;
$continents[$continent] = true;
if (!isset($cities[$continent])) {
$cities[$continent] = '';
}
$cities[$continent] .= '<option value="'.$city.'"';
if ($pcity == $city) {
$cities[$continent] .= ' selected="selected"';
}
$cities[$continent] .= '>'.$city.'</option>';
}
} }
$continentsHtml = '';
$continents = array_keys($continents); $continents = array_keys($continents);
$continents['selected'] = $pcontinent;
$cities['selected'] = $pcity;
return [$continents, $cities]; foreach ($continents as $continent) {
$continentsHtml .= '<option value="'.$continent.'"';
if ($pcontinent == $continent) {
$continentsHtml .= ' selected="selected"';
}
$continentsHtml .= '>'.$continent.'</option>';
}
// Timezone selection form
$timezoneForm = 'Continent:';
$timezoneForm .= '<select name="continent" id="continent" onChange="onChangecontinent();">';
$timezoneForm .= $continentsHtml.'</select>';
$timezoneForm .= '&nbsp;&nbsp;&nbsp;&nbsp;City:';
$timezoneForm .= '<select name="city" id="city">'.$cities[$pcontinent].'</select><br />';
// Javascript handler - updates the city list when the user selects a continent
$timezoneJs = '<script>';
$timezoneJs .= 'function onChangecontinent() {';
$timezoneJs .= 'document.getElementById("city").innerHTML =';
$timezoneJs .= ' citiescontinent[document.getElementById("continent").value]; }';
$timezoneJs .= 'var citiescontinent = '.json_encode($cities).';';
$timezoneJs .= '</script>';
return array($timezoneForm, $timezoneJs);
} }
/** /**
@ -86,7 +98,7 @@ function generateTimeZoneData($installedTimeZones, $preselectedTimezone = '')
function isTimeZoneValid($continent, $city) function isTimeZoneValid($continent, $city)
{ {
return in_array( return in_array(
$continent . '/' . $city, $continent.'/'.$city,
timezone_identifiers_list() timezone_identifiers_list()
); );
} }

311
application/Updater.php Normal file
View file

@ -0,0 +1,311 @@
<?php
/**
* Class Updater.
* Used to update stuff when a new Shaarli's version is reached.
* Update methods are ran only once, and the stored in a JSON file.
*/
class Updater
{
/**
* @var array Updates which are already done.
*/
protected $doneUpdates;
/**
* @var LinkDB instance.
*/
protected $linkDB;
/**
* @var ConfigManager $conf Configuration Manager instance.
*/
protected $conf;
/**
* @var bool True if the user is logged in, false otherwise.
*/
protected $isLoggedIn;
/**
* @var ReflectionMethod[] List of current class methods.
*/
protected $methods;
/**
* Object constructor.
*
* @param array $doneUpdates Updates which are already done.
* @param LinkDB $linkDB LinkDB instance.
* @param ConfigManager $conf Configuration Manager instance.
* @param boolean $isLoggedIn True if the user is logged in.
*/
public function __construct($doneUpdates, $linkDB, $conf, $isLoggedIn)
{
$this->doneUpdates = $doneUpdates;
$this->linkDB = $linkDB;
$this->conf = $conf;
$this->isLoggedIn = $isLoggedIn;
// Retrieve all update methods.
$class = new ReflectionClass($this);
$this->methods = $class->getMethods();
}
/**
* Run all new updates.
* Update methods have to start with 'updateMethod' and return true (on success).
*
* @return array An array containing ran updates.
*
* @throws UpdaterException If something went wrong.
*/
public function update()
{
$updatesRan = array();
// If the user isn't logged in, exit without updating.
if ($this->isLoggedIn !== true) {
return $updatesRan;
}
if ($this->methods == null) {
throw new UpdaterException('Couldn\'t retrieve Updater class methods.');
}
foreach ($this->methods as $method) {
// Not an update method or already done, pass.
if (! startsWith($method->getName(), 'updateMethod')
|| in_array($method->getName(), $this->doneUpdates)
) {
continue;
}
try {
$method->setAccessible(true);
$res = $method->invoke($this);
// Update method must return true to be considered processed.
if ($res === true) {
$updatesRan[] = $method->getName();
}
} catch (Exception $e) {
throw new UpdaterException($method, $e);
}
}
$this->doneUpdates = array_merge($this->doneUpdates, $updatesRan);
return $updatesRan;
}
/**
* @return array Updates methods already processed.
*/
public function getDoneUpdates()
{
return $this->doneUpdates;
}
/**
* Move deprecated options.php to config.php.
*
* Milestone 0.9 (old versioning) - shaarli/Shaarli#41:
* options.php is not supported anymore.
*/
public function updateMethodMergeDeprecatedConfigFile()
{
if (is_file($this->conf->get('resource.data_dir') . '/options.php')) {
include $this->conf->get('resource.data_dir') . '/options.php';
// Load GLOBALS into config
$allowedKeys = array_merge(ConfigPhp::$ROOT_KEYS);
$allowedKeys[] = 'config';
foreach ($GLOBALS as $key => $value) {
if (in_array($key, $allowedKeys)) {
$this->conf->set($key, $value);
}
}
$this->conf->write($this->isLoggedIn);
unlink($this->conf->get('resource.data_dir').'/options.php');
}
return true;
}
/**
* Rename tags starting with a '-' to work with tag exclusion search.
*/
public function updateMethodRenameDashTags()
{
$linklist = $this->linkDB->filterSearch();
foreach ($linklist as $link) {
$link['tags'] = preg_replace('/(^| )\-/', '$1', $link['tags']);
$link['tags'] = implode(' ', array_unique(LinkFilter::tagsStrToArray($link['tags'], true)));
$this->linkDB[$link['linkdate']] = $link;
}
$this->linkDB->save($this->conf->get('resource.page_cache'));
return true;
}
/**
* Move old configuration in PHP to the new config system in JSON format.
*
* Will rename 'config.php' into 'config.save.php' and create 'config.json.php'.
* It will also convert legacy setting keys to the new ones.
*/
public function updateMethodConfigToJson()
{
// JSON config already exists, nothing to do.
if ($this->conf->getConfigIO() instanceof ConfigJson) {
return true;
}
$configPhp = new ConfigPhp();
$configJson = new ConfigJson();
$oldConfig = $configPhp->read($this->conf->getConfigFile() . '.php');
rename($this->conf->getConfigFileExt(), $this->conf->getConfigFile() . '.save.php');
$this->conf->setConfigIO($configJson);
$this->conf->reload();
$legacyMap = array_flip(ConfigPhp::$LEGACY_KEYS_MAPPING);
foreach (ConfigPhp::$ROOT_KEYS as $key) {
$this->conf->set($legacyMap[$key], $oldConfig[$key]);
}
// Set sub config keys (config and plugins)
$subConfig = array('config', 'plugins');
foreach ($subConfig as $sub) {
foreach ($oldConfig[$sub] as $key => $value) {
if (isset($legacyMap[$sub .'.'. $key])) {
$configKey = $legacyMap[$sub .'.'. $key];
} else {
$configKey = $sub .'.'. $key;
}
$this->conf->set($configKey, $value);
}
}
try{
$this->conf->write($this->isLoggedIn);
return true;
} catch (IOException $e) {
error_log($e->getMessage());
return false;
}
}
/**
* Escape settings which have been manually escaped in every request in previous versions:
* - general.title
* - general.header_link
* - redirector.url
*
* @return bool true if the update is successful, false otherwise.
*/
public function updateMethodEscapeUnescapedConfig()
{
try {
$this->conf->set('general.title', escape($this->conf->get('general.title')));
$this->conf->set('general.header_link', escape($this->conf->get('general.header_link')));
$this->conf->set('redirector.url', escape($this->conf->get('redirector.url')));
$this->conf->write($this->isLoggedIn);
} catch (Exception $e) {
error_log($e->getMessage());
return false;
}
return true;
}
}
/**
* Class UpdaterException.
*/
class UpdaterException extends Exception
{
/**
* @var string Method where the error occurred.
*/
protected $method;
/**
* @var Exception The parent exception.
*/
protected $previous;
/**
* Constructor.
*
* @param string $message Force the error message if set.
* @param string $method Method where the error occurred.
* @param Exception|bool $previous Parent exception.
*/
public function __construct($message = '', $method = '', $previous = false)
{
$this->method = $method;
$this->previous = $previous;
$this->message = $this->buildMessage($message);
}
/**
* Build the exception error message.
*
* @param string $message Optional given error message.
*
* @return string The built error message.
*/
private function buildMessage($message)
{
$out = '';
if (! empty($message)) {
$out .= $message . PHP_EOL;
}
if (! empty($this->method)) {
$out .= 'An error occurred while running the update '. $this->method . PHP_EOL;
}
if (! empty($this->previous)) {
$out .= ' '. $this->previous->getMessage();
}
return $out;
}
}
/**
* Read the updates file, and return already done updates.
*
* @param string $updatesFilepath Updates file path.
*
* @return array Already done update methods.
*/
function read_updates_file($updatesFilepath)
{
if (! empty($updatesFilepath) && is_file($updatesFilepath)) {
$content = file_get_contents($updatesFilepath);
if (! empty($content)) {
return explode(';', $content);
}
}
return array();
}
/**
* Write updates file.
*
* @param string $updatesFilepath Updates file path.
* @param array $updates Updates array to write.
*
* @throws Exception Couldn't write version number.
*/
function write_updates_file($updatesFilepath, $updates)
{
if (empty($updatesFilepath)) {
throw new Exception('Updates file path is not set, can\'t write updates.');
}
$res = file_put_contents($updatesFilepath, implode(';', $updates));
if ($res === false) {
throw new Exception('Unable to write updates in '. $updatesFilepath . '.');
}
}

View file

@ -1,6 +1,67 @@
<?php <?php
/**
* Converts an array-represented URL to a string
*
* Source: http://php.net/manual/en/function.parse-url.php#106731
*
* @see http://php.net/manual/en/function.parse-url.php
*
* @param array $parsedUrl an array-represented URL
*
* @return string the string representation of the URL
*/
function unparse_url($parsedUrl)
{
$scheme = isset($parsedUrl['scheme']) ? $parsedUrl['scheme'].'://' : '';
$host = isset($parsedUrl['host']) ? $parsedUrl['host'] : '';
$port = isset($parsedUrl['port']) ? ':'.$parsedUrl['port'] : '';
$user = isset($parsedUrl['user']) ? $parsedUrl['user'] : '';
$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'] : '';
namespace Shaarli\Http; return "$scheme$user$pass$host$port$path$query$fragment";
}
/**
* Removes undesired query parameters and fragments
*
* @param string url Url to be cleaned
*
* @return string the string representation of this URL after cleanup
*/
function cleanup_url($url)
{
$obj_url = new Url($url);
return $obj_url->cleanup();
}
/**
* Get URL scheme.
*
* @param string url Url for which the scheme is requested
*
* @return mixed the URL scheme or false if none is provided.
*/
function get_url_scheme($url)
{
$obj_url = new Url($url);
return $obj_url->getScheme();
}
/**
* Adds a trailing slash at the end of URL if necessary.
*
* @param string $url URL to check/edit.
*
* @return string $url URL with a end trailing slash.
*/
function add_trailing_slash($url)
{
return $url . (!endsWith($url, '/') ? '/' : '');
}
/** /**
* URL representation and cleanup utilities * URL representation and cleanup utilities
@ -17,7 +78,7 @@
*/ */
class Url class Url
{ {
private static $annoyingQueryParams = [ private static $annoyingQueryParams = array(
// Facebook // Facebook
'action_object_map=', 'action_object_map=',
'action_ref_map=', 'action_ref_map=',
@ -33,19 +94,16 @@ class Url
'utm_', 'utm_',
// ATInternet // ATInternet
'xtor=', 'xtor='
);
// Other private static $annoyingFragments = array(
'campaign_'
];
private static $annoyingFragments = [
// ATInternet // ATInternet
'xtor=RSS-', 'xtor=RSS-',
// Misc. // Misc.
'tk.rss_all' 'tk.rss_all'
]; );
/* /*
* URL parts represented as an array * URL parts represented as an array
@ -61,7 +119,6 @@ class Url
*/ */
public function __construct($url) public function __construct($url)
{ {
$url = $url ?? '';
$url = self::cleanupUnparsedUrl(trim($url)); $url = self::cleanupUnparsedUrl(trim($url));
$this->parts = parse_url($url); $this->parts = parse_url($url);
@ -112,7 +169,7 @@ public function toString()
*/ */
protected function cleanupQuery() protected function cleanupQuery()
{ {
if (!isset($this->parts['query'])) { if (! isset($this->parts['query'])) {
return; return;
} }
@ -121,7 +178,7 @@ protected function cleanupQuery()
foreach (self::$annoyingQueryParams as $annoying) { foreach (self::$annoyingQueryParams as $annoying) {
foreach ($queryParams as $param) { foreach ($queryParams as $param) {
if (startsWith($param, $annoying)) { if (startsWith($param, $annoying)) {
$queryParams = array_diff($queryParams, [$param]); $queryParams = array_diff($queryParams, array($param));
continue; continue;
} }
} }
@ -140,7 +197,7 @@ protected function cleanupQuery()
*/ */
protected function cleanupFragment() protected function cleanupFragment()
{ {
if (!isset($this->parts['fragment'])) { if (! isset($this->parts['fragment'])) {
return; return;
} }
@ -173,10 +230,10 @@ public function cleanup()
public function idnToAscii() public function idnToAscii()
{ {
$out = $this->cleanup(); $out = $this->cleanup();
if (!function_exists('idn_to_ascii') || !isset($this->parts['host'])) { if (! function_exists('idn_to_ascii') || ! isset($this->parts['host'])) {
return $out; return $out;
} }
$asciiHost = idn_to_ascii($this->parts['host'], 0, INTL_IDNA_VARIANT_UTS46); $asciiHost = idn_to_ascii($this->parts['host']);
return str_replace($this->parts['host'], $asciiHost, $out); return str_replace($this->parts['host'], $asciiHost, $out);
} }
@ -185,8 +242,7 @@ public function idnToAscii()
* *
* @return string the URL scheme or false if none is provided. * @return string the URL scheme or false if none is provided.
*/ */
public function getScheme() public function getScheme() {
{
if (!isset($this->parts['scheme'])) { if (!isset($this->parts['scheme'])) {
return false; return false;
} }
@ -198,8 +254,7 @@ public function getScheme()
* *
* @return string the URL host or false if none is provided. * @return string the URL host or false if none is provided.
*/ */
public function getHost() public function getHost() {
{
if (empty($this->parts['host'])) { if (empty($this->parts['host'])) {
return false; return false;
} }
@ -207,12 +262,11 @@ public function getHost()
} }
/** /**
* Test if the UrlUtils is an HTTP one. * Test if the Url is an HTTP one.
* *
* @return true is HTTP, false otherwise. * @return true is HTTP, false otherwise.
*/ */
public function isHttp() public function isHttp() {
{
return strpos(strtolower($this->parts['scheme']), 'http') !== false; return strpos(strtolower($this->parts['scheme']), 'http') !== false;
} }
} }

View file

@ -1,27 +1,24 @@
<?php <?php
/** /**
* Shaarli utilities * Shaarli utilities
*/ */
/** /**
* Format log using provided data. * Logs a message to a text file
* *
* The log format is compatible with fail2ban.
*
* @param string $logFile where to write the logs
* @param string $clientIp the client's remote IPv4/IPv6 address
* @param string $message the message to log * @param string $message the message to log
* @param string|null $clientIp the client's remote IPv4/IPv6 address
*
* @return string Formatted message to log
*/ */
function format_log(string $message, string $clientIp = null): string function logm($logFile, $clientIp, $message)
{ {
$out = $message; file_put_contents(
$logFile,
if (!empty($clientIp)) { date('Y/m/d H:i:s').' - '.$clientIp.' - '.strval($message).PHP_EOL,
// Note: we keep the first dash to avoid breaking fail2ban configs FILE_APPEND
$out = '- ' . $clientIp . ' - ' . $out; );
}
return $out;
} }
/** /**
@ -34,11 +31,7 @@ function format_log(string $message, string $clientIp = null): string
* - are NOT cryptographically secure (they CAN be forged) * - are NOT cryptographically secure (they CAN be forged)
* *
* In Shaarli, they are used as a tinyurl-like link to individual entries, * In Shaarli, they are used as a tinyurl-like link to individual entries,
* built once with the combination of the date and item ID. * e.g. smallHash('20111006_131924') --> yZH23w
* e.g. smallHash('20111006_131924' . 142) --> eaWxtQ
*
* @warning before v0.8.1, smallhashes were built only with the date,
* and their value has been preserved.
* *
* @param string $text Create a hash from this text. * @param string $text Create a hash from this text.
* *
@ -61,7 +54,6 @@ function smallHash($text)
*/ */
function startsWith($haystack, $needle, $case = true) function startsWith($haystack, $needle, $case = true)
{ {
$needle = $needle ?? '';
if ($case) { if ($case) {
return (strcmp(substr($haystack, 0, strlen($needle)), $needle) === 0); return (strcmp(substr($haystack, 0, strlen($needle)), $needle) === 0);
} }
@ -91,22 +83,14 @@ function endsWith($haystack, $needle, $case = true)
* *
* @param mixed $input Data to escape: a single string or an array of strings. * @param mixed $input Data to escape: a single string or an array of strings.
* *
* @return string|array escaped. * @return string escaped.
*/ */
function escape($input) function escape($input)
{ {
if (null === $input) {
return null;
}
if (is_bool($input) || is_int($input) || is_float($input) || $input instanceof DateTimeInterface) {
return $input;
}
if (is_array($input)) { if (is_array($input)) {
$out = []; $out = array();
foreach ($input as $key => $value) { foreach($input as $key => $value) {
$out[escape($key)] = escape($value); $out[$key] = escape($value);
} }
return $out; return $out;
} }
@ -165,12 +149,12 @@ function checkDateFormat($format, $string)
* *
* @return string $referer - final referer. * @return string $referer - final referer.
*/ */
function generateLocation($referer, $host, $loopTerms = []) function generateLocation($referer, $host, $loopTerms = array())
{ {
$finalReferer = './?'; $finalReferer = '?';
// No referer if it contains any value in $loopCriteria. // No referer if it contains any value in $loopCriteria.
foreach (array_filter($loopTerms) as $value) { foreach ($loopTerms as $value) {
if (strpos($referer, $value) !== false) { if (strpos($referer, $value) !== false) {
return $finalReferer; return $finalReferer;
} }
@ -181,7 +165,7 @@ function generateLocation($referer, $host, $loopTerms = [])
$host = substr($host, 0, $pos); $host = substr($host, 0, $pos);
} }
$refererHost = parse_url($referer, PHP_URL_HOST) ?? ''; $refererHost = parse_url($referer, PHP_URL_HOST);
if (!empty($referer) && (strpos($refererHost, $host) !== false || startsWith('?', $refererHost))) { if (!empty($referer) && (strpos($refererHost, $host) !== false || startsWith('?', $refererHost))) {
$finalReferer = $referer; $finalReferer = $referer;
} }
@ -189,6 +173,36 @@ function generateLocation($referer, $host, $loopTerms = [])
return $finalReferer; return $finalReferer;
} }
/**
* Validate session ID to prevent Full Path Disclosure.
*
* See #298.
* The session ID's format depends on the hash algorithm set in PHP settings
*
* @param string $sessionId Session ID
*
* @return true if valid, false otherwise.
*
* @see http://php.net/manual/en/function.hash-algos.php
* @see http://php.net/manual/en/session.configuration.php
*/
function is_session_id_valid($sessionId)
{
if (empty($sessionId)) {
return false;
}
if (!$sessionId) {
return false;
}
if (!preg_match('/^[a-zA-Z0-9,-]{2,128}$/', $sessionId)) {
return false;
}
return true;
}
/** /**
* Sniff browser language to set the locale automatically. * Sniff browser language to set the locale automatically.
* Note that is may not work on your server if the corresponding locale is not installed. * Note that is may not work on your server if the corresponding locale is not installed.
@ -198,308 +212,28 @@ function generateLocation($referer, $host, $loopTerms = [])
function autoLocale($headerLocale) function autoLocale($headerLocale)
{ {
// Default if browser does not send HTTP_ACCEPT_LANGUAGE // Default if browser does not send HTTP_ACCEPT_LANGUAGE
$locales = ['en_US.UTF-8', 'en_US.utf8', 'en_US']; $attempts = array('en_US');
if (! empty($headerLocale)) { if (isset($headerLocale)) {
if (preg_match_all('/([a-z]{2,3})[-_]?([a-z]{2})?,?/i', $headerLocale, $matches, PREG_SET_ORDER)) { // (It's a bit crude, but it works very well. Preferred language is always presented first.)
$attempts = []; if (preg_match('/([a-z]{2})-?([a-z]{2})?/i', $headerLocale, $matches)) {
foreach ($matches as $match) { $loc = $matches[1] . (!empty($matches[2]) ? '_' . strtoupper($matches[2]) : '');
$first = [strtolower($match[1]), strtoupper($match[1])]; $attempts = array(
$separators = ['_', '-']; $loc.'.UTF-8', $loc, str_replace('_', '-', $loc).'.UTF-8', str_replace('_', '-', $loc),
$encodings = ['utf8', 'UTF-8']; $loc . '_' . strtoupper($loc).'.UTF-8', $loc . '_' . strtoupper($loc),
if (!empty($match[2])) { $loc . '_' . $loc.'.UTF-8', $loc . '_' . $loc, $loc . '-' . strtoupper($loc).'.UTF-8',
$second = [strtoupper($match[2]), strtolower($match[2])]; $loc . '-' . strtoupper($loc), $loc . '-' . $loc.'.UTF-8', $loc . '-' . $loc
$items = [$first, $separators, $second, ['.'], $encodings];
} else {
$items = [$first, $separators, $first, ['.'], $encodings];
}
$attempts = array_merge($attempts, iterator_to_array(cartesian_product_generator($items)));
}
if (! empty($attempts)) {
$locales = array_merge(array_map('implode', $attempts), $locales);
}
}
}
setlocale(LC_ALL, $locales);
}
/**
* Build a Generator object representing the cartesian product from given $items.
*
* Example:
* [['a'], ['b', 'c']]
* will generate:
* [
* ['a', 'b'],
* ['a', 'c'],
* ]
*
* @param array $items array of array of string
*
* @return Generator representing the cartesian product of given array.
*
* @see https://en.wikipedia.org/wiki/Cartesian_product
*/
function cartesian_product_generator($items)
{
if (empty($items)) {
yield [];
}
$subArray = array_pop($items);
if (empty($subArray)) {
return;
}
foreach (cartesian_product_generator($items) as $item) {
foreach ($subArray as $value) {
yield $item + [count($item) => $value];
}
}
}
/**
* Generates a default API secret.
*
* Note that the random-ish methods used in this function are predictable,
* which makes them NOT suitable for crypto.
* BUT the random string is salted with the salt and hashed with the username.
* It makes the generated API secret secured enough for Shaarli.
*
* PHP 7 provides random_int(), designed for cryptography.
* More info: http://stackoverflow.com/questions/4356289/php-random-string-generator
* @param string $username Shaarli login username
* @param string $salt Shaarli password hash salt
*
* @return string|bool Generated API secret, 12 char length.
* Or false if invalid parameters are provided (which will make the API unusable).
*/
function generate_api_secret($username, $salt)
{
if (empty($username) || empty($salt)) {
return false;
}
return str_shuffle(substr(hash_hmac('sha512', uniqid($salt), $username), 10, 12));
}
/**
* Trim string, replace sequences of whitespaces by a single space.
* PHP equivalent to `normalize-space` XSLT function.
*
* @param string $string Input string.
*
* @return mixed Normalized string.
*/
function normalize_spaces($string)
{
return preg_replace('/\s{2,}/', ' ', trim($string ?? ''));
}
/**
* Format the date according to the locale.
*
* Requires php-intl to display international datetimes,
* otherwise default format '%c' will be returned.
*
* @param DateTimeInterface $date to format.
* @param bool $time Displays time if true.
* @param bool $intl Use international format if true.
*
* @return bool|string Formatted date, or false if the input is invalid.
*/
function format_date($date, $time = true, $intl = true)
{
if (! $date instanceof DateTimeInterface) {
return false;
}
if (! $intl || ! class_exists('IntlDateFormatter')) {
$format = 'F j, Y';
if ($time) {
$format .= ' h:i:s A \G\M\TP';
}
return $date->format($format);
}
$formatter = new IntlDateFormatter(
setlocale(LC_TIME, 0),
IntlDateFormatter::LONG,
$time ? IntlDateFormatter::LONG : IntlDateFormatter::NONE
); );
$formatter->setTimeZone($date->getTimezone()); }
}
return $formatter->format($date); setlocale(LC_ALL, $attempts);
} }
/** function getAllTheme()
* 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) { $allTheme = glob('tpl/*', GLOB_ONLYDIR);
return false; foreach ($allTheme as $value) {
$themes[] = str_replace('tpl/', '', $value);
} }
return strftime('%B', $date->getTimestamp()); return $themes;
}
/**
* Check if the input is an integer, no matter its real type.
*
* PHP is a bit messy regarding this:
* - is_int returns false if the input is a string
* - ctype_digit returns false if the input is an integer or negative
*
* @param mixed $input value
*
* @return bool true if the input is an integer, false otherwise
*/
function is_integer_mixed($input)
{
if (is_array($input) || is_bool($input) || is_object($input)) {
return false;
}
$input = strval($input);
return ctype_digit($input) || (startsWith($input, '-') && ctype_digit(substr($input, 1)));
}
/**
* Convert post_max_size/upload_max_filesize (e.g. '16M') parameters to bytes.
*
* @param string $val Size expressed in string.
*
* @return int Size expressed in bytes.
*/
function return_bytes($val)
{
if (is_integer_mixed($val) || $val === '0' || empty($val)) {
return $val;
}
$val = trim($val);
$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;
}
return $val;
}
/**
* Return a human readable size from bytes.
*
* @param int $bytes value
*
* @return string Human readable size
*/
function human_bytes($bytes)
{
if ($bytes === '') {
return t('Setting not set');
}
if (! is_integer_mixed($bytes)) {
return $bytes;
}
$bytes = intval($bytes);
if ($bytes === 0) {
return t('Unlimited');
}
$units = [t('B'), t('kiB'), t('MiB'), t('GiB')];
for ($i = 0; $i < count($units) && $bytes >= 1024; ++$i) {
$bytes /= 1024;
}
return round($bytes) . $units[$i];
}
/**
* Try to determine max file size for uploads (POST).
* Returns an integer (in bytes) or formatted depending on $format.
*
* @param mixed $limitPost post_max_size PHP setting
* @param mixed $limitUpload upload_max_filesize PHP setting
* @param bool $format Format max upload size to human readable size
*
* @return int|string max upload file size
*/
function get_max_upload_size($limitPost, $limitUpload, $format = true)
{
$size1 = return_bytes($limitPost);
$size2 = return_bytes($limitUpload);
// Return the smaller of two:
$maxsize = min($size1, $size2);
return $format ? human_bytes($maxsize) : $maxsize;
}
/**
* Sort the given array alphabetically using php-intl if available.
* Case sensitive.
*
* Note: doesn't support multidimensional arrays
*
* @param array $data Input array, passed by reference
* @param bool $reverse Reverse sort if set to true
* @param bool $byKeys Sort the array by keys if set to true, by value otherwise.
*/
function alphabetical_sort(&$data, $reverse = false, $byKeys = false)
{
$callback = function ($a, $b) use ($reverse) {
// Collator is part of PHP intl.
if (class_exists('Collator')) {
$collator = new Collator(setlocale(LC_COLLATE, 0));
if (!intl_is_failure(intl_get_error_code())) {
return $collator->compare($a, $b) * ($reverse ? -1 : 1);
}
}
return strcasecmp($a, $b) * ($reverse ? -1 : 1);
};
if ($byKeys) {
uksort($data, $callback);
} else {
usort($data, $callback);
}
}
/**
* 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 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', $variables = [], $fixCase = false)
{
$postFunction = $fixCase ? 'ucfirst' : function ($input) {
return $input;
};
return $postFunction(dn__($domain, $text, $nText, $nb, $variables));
}
/**
* Converts an exception into a printable stack trace string.
*/
function exception2text(Throwable $e): string
{
return $e->getMessage() . PHP_EOL . $e->getFile() . $e->getLine() . PHP_EOL . $e->getTraceAsString();
} }

View file

@ -1,155 +0,0 @@
<?php
namespace Shaarli\Api;
use malkusch\lock\mutex\FlockMutex;
use Shaarli\Api\Exceptions\ApiAuthorizationException;
use Shaarli\Api\Exceptions\ApiException;
use Shaarli\Bookmark\BookmarkFileService;
use Shaarli\Config\ConfigManager;
use Slim\Container;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Class ApiMiddleware
*
* This will be called before accessing any API Controller.
* Its role is to make sure that the API is enabled, configured, and to validate the JWT token.
*
* If the request is validated, the controller is called, otherwise a JSON error response is returned.
*
* @package Api
*/
class ApiMiddleware
{
/**
* @var int JWT token validity in seconds (9 min).
*/
public static $TOKEN_DURATION = 540;
/**
* @var Container: contains conf, plugins, etc.
*/
protected $container;
/**
* @var ConfigManager instance.
*/
protected $conf;
/**
* ApiMiddleware constructor.
*
* @param Container $container instance.
*/
public function __construct($container)
{
$this->container = $container;
$this->conf = $this->container->get('conf');
$this->setLinkDb($this->conf);
}
/**
* Middleware execution:
* - check the API request
* - execute the controller
* - return the response
*
* @param Request $request Slim request
* @param Response $response Slim response
* @param callable $next Next action
*
* @return Response response.
*/
public function __invoke($request, $response, $next)
{
try {
$this->checkRequest($request);
$response = $next($request, $response);
} catch (ApiException $e) {
$e->setResponse($response);
$e->setDebug($this->conf->get('dev.debug', false));
$response = $e->getApiResponse();
}
return $response
->withHeader('Access-Control-Allow-Origin', '*')
->withHeader(
'Access-Control-Allow-Headers',
'X-Requested-With, Content-Type, Accept, Origin, Authorization'
)
->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
;
}
/**
* Check the request validity (HTTP method, request value, etc.),
* that the API is enabled, and the JWT token validity.
*
* @param Request $request Slim request
*
* @throws ApiAuthorizationException The API is disabled or the token is invalid.
*/
protected function checkRequest($request)
{
if (! $this->conf->get('api.enabled', true)) {
throw new ApiAuthorizationException('API is disabled');
}
$this->checkToken($request);
}
/**
* Check that the JWT token is set and valid.
* The API secret setting must be set.
*
* @param Request $request Slim request
*
* @throws ApiAuthorizationException The token couldn't be validated.
*/
protected function checkToken($request)
{
if (
!$request->hasHeader('Authorization')
&& !isset($this->container->environment['REDIRECT_HTTP_AUTHORIZATION'])
) {
throw new ApiAuthorizationException('JWT token not provided');
}
if (empty($this->conf->get('api.secret'))) {
throw new ApiAuthorizationException('Token secret must be set in Shaarli\'s administration');
}
if (isset($this->container->environment['REDIRECT_HTTP_AUTHORIZATION'])) {
$authorization = $this->container->environment['REDIRECT_HTTP_AUTHORIZATION'];
} else {
$authorization = $request->getHeaderLine('Authorization');
}
if (! preg_match('/^Bearer (.*)/i', $authorization, $matches)) {
throw new ApiAuthorizationException('Invalid JWT header');
}
ApiUtils::validateJwtToken($matches[1], $this->conf->get('api.secret'));
}
/**
* Instantiate a new LinkDB including private bookmarks,
* and load in the Slim container.
*
* FIXME! LinkDB could use a refactoring to avoid this trick.
*
* @param ConfigManager $conf instance.
*/
protected function setLinkDb($conf)
{
$linkDb = new BookmarkFileService(
$conf,
$this->container->get('pluginManager'),
$this->container->get('history'),
new FlockMutex(fopen(SHAARLI_MUTEX_FILE, 'r'), 2),
true
);
$this->container['db'] = $linkDb;
}
}

View file

@ -1,174 +0,0 @@
<?php
namespace Shaarli\Api;
use Shaarli\Api\Exceptions\ApiAuthorizationException;
use Shaarli\Bookmark\Bookmark;
use Shaarli\Http\Base64Url;
/**
* REST API utilities
*/
class ApiUtils
{
/**
* Validates a JWT token authenticity.
*
* @param string $token JWT token extracted from the headers.
* @param string $secret API secret set in the settings.
*
* @return bool true on success
*
* @throws ApiAuthorizationException the token is not valid.
*/
public static function validateJwtToken($token, $secret)
{
$parts = explode('.', $token);
if (count($parts) != 3 || strlen($parts[0]) == 0 || strlen($parts[1]) == 0) {
throw new ApiAuthorizationException('Malformed JWT token');
}
$genSign = Base64Url::encode(hash_hmac('sha512', $parts[0] . '.' . $parts[1], $secret, true));
if ($parts[2] != $genSign) {
throw new ApiAuthorizationException('Invalid JWT signature');
}
$header = json_decode(Base64Url::decode($parts[0]));
if ($header === null) {
throw new ApiAuthorizationException('Invalid JWT header');
}
$payload = json_decode(Base64Url::decode($parts[1]));
if ($payload === null) {
throw new ApiAuthorizationException('Invalid JWT payload');
}
if (
empty($payload->iat)
|| $payload->iat > time()
|| time() - $payload->iat > ApiMiddleware::$TOKEN_DURATION
) {
throw new ApiAuthorizationException('Invalid JWT issued time');
}
return true;
}
/**
* Format a Link for the REST API.
*
* @param Bookmark $bookmark Bookmark data read from the datastore.
* @param string $indexUrl Shaarli's index URL (used for relative URL).
*
* @return array Link data formatted for the REST API.
*/
public static function formatLink($bookmark, $indexUrl)
{
$out['id'] = $bookmark->getId();
// Not an internal link
if (! $bookmark->isNote()) {
$out['url'] = $bookmark->getUrl();
} else {
$out['url'] = rtrim($indexUrl, '/') . '/' . ltrim($bookmark->getUrl(), '/');
}
$out['shorturl'] = $bookmark->getShortUrl();
$out['title'] = $bookmark->getTitle();
$out['description'] = $bookmark->getDescription();
$out['tags'] = $bookmark->getTags();
$out['private'] = $bookmark->isPrivate();
$out['created'] = $bookmark->getCreated()->format(\DateTime::ATOM);
if (! empty($bookmark->getUpdated())) {
$out['updated'] = $bookmark->getUpdated()->format(\DateTime::ATOM);
} else {
$out['updated'] = '';
}
return $out;
}
/**
* Convert a link given through a request, to a valid Bookmark for the datastore.
*
* If no URL is provided, it will generate a local note URL.
* If no title is provided, it will use the URL as title.
*
* @param array|null $input Request Link.
* @param bool $defaultPrivate Setting defined if a bookmark is private by default.
* @param string $tagsSeparator Tags separator loaded from the config file.
*
* @return Bookmark instance.
*/
public static function buildBookmarkFromRequest(
?array $input,
bool $defaultPrivate,
string $tagsSeparator
): Bookmark {
$bookmark = new Bookmark();
$url = ! empty($input['url']) ? cleanup_url($input['url']) : '';
if (isset($input['private'])) {
$private = filter_var($input['private'], FILTER_VALIDATE_BOOLEAN);
} else {
$private = $defaultPrivate;
}
$bookmark->setTitle(! empty($input['title']) ? $input['title'] : '');
$bookmark->setUrl($url);
$bookmark->setDescription(! empty($input['description']) ? $input['description'] : '');
// Be permissive with provided tags format
if (is_string($input['tags'] ?? null)) {
$input['tags'] = tags_str2array($input['tags'], $tagsSeparator);
}
if (is_array($input['tags'] ?? null) && count($input['tags']) === 1 && is_string($input['tags'][0])) {
$input['tags'] = tags_str2array($input['tags'][0], $tagsSeparator);
}
$bookmark->setTags(! empty($input['tags']) ? $input['tags'] : []);
$bookmark->setPrivate($private);
$created = \DateTime::createFromFormat(\DateTime::ATOM, $input['created'] ?? '');
if ($created instanceof \DateTimeInterface) {
$bookmark->setCreated($created);
}
$updated = \DateTime::createFromFormat(\DateTime::ATOM, $input['updated'] ?? '');
if ($updated instanceof \DateTimeInterface) {
$bookmark->setUpdated($updated);
}
return $bookmark;
}
/**
* Update link fields using an updated link object.
*
* @param Bookmark $oldLink data
* @param Bookmark $newLink data
*
* @return Bookmark $oldLink updated with $newLink values
*/
public static function updateLink($oldLink, $newLink)
{
$oldLink->setTitle($newLink->getTitle());
$oldLink->setUrl($newLink->getUrl());
$oldLink->setDescription($newLink->getDescription());
$oldLink->setTags($newLink->getTags());
$oldLink->setPrivate($newLink->isPrivate());
return $oldLink;
}
/**
* Format a Tag for the REST API.
*
* @param string $tag Tag name
* @param int $occurrences Number of bookmarks using this tag
*
* @return array Link data formatted for the REST API.
*/
public static function formatTag($tag, $occurences)
{
return [
'name' => $tag,
'occurrences' => $occurences,
];
}
}

View file

@ -1,73 +0,0 @@
<?php
namespace Shaarli\Api\Controllers;
use Shaarli\Bookmark\BookmarkServiceInterface;
use Shaarli\Config\ConfigManager;
use Shaarli\History;
use Slim\Container;
/**
* Abstract Class ApiController
*
* Defines REST API Controller dependencies injected from the container.
*
* @package Api\Controllers
*/
abstract class ApiController
{
/**
* @var Container
*/
protected $ci;
/**
* @var ConfigManager
*/
protected $conf;
/**
* @var BookmarkServiceInterface
*/
protected $bookmarkService;
/**
* @var History
*/
protected $history;
/**
* @var int|null JSON style option.
*/
protected $jsonStyle;
/**
* ApiController constructor.
*
* Note: enabling debug mode displays JSON with readable formatting.
*
* @param Container $ci Slim container.
*/
public function __construct(Container $ci)
{
$this->ci = $ci;
$this->conf = $ci->get('conf');
$this->bookmarkService = $ci->get('db');
$this->history = $ci->get('history');
if ($this->conf->get('dev.debug', false)) {
$this->jsonStyle = JSON_PRETTY_PRINT;
} else {
$this->jsonStyle = null;
}
}
/**
* Get the container.
*
* @return Container
*/
public function getCi()
{
return $this->ci;
}
}

View file

@ -1,68 +0,0 @@
<?php
namespace Shaarli\Api\Controllers;
use Shaarli\Api\Exceptions\ApiBadParametersException;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Class History
*
* REST API Controller: /history
*
* @package Shaarli\Api\Controllers
*/
class HistoryController extends ApiController
{
/**
* Service providing operation regarding Shaarli datastore and settings.
*
* @param Request $request Slim request.
* @param Response $response Slim response.
*
* @return Response response.
*
* @throws ApiBadParametersException Invalid parameters.
*/
public function getHistory($request, $response)
{
$history = $this->history->getHistory();
// Return history operations from the {offset}th, starting from {since}.
$since = \DateTime::createFromFormat(\DateTime::ATOM, $request->getParam('since', ''));
$offset = $request->getParam('offset');
if (empty($offset)) {
$offset = 0;
} elseif (ctype_digit($offset)) {
$offset = (int) $offset;
} else {
throw new ApiBadParametersException('Invalid offset');
}
// limit parameter is either a number of bookmarks or 'all' for everything.
$limit = $request->getParam('limit');
if (empty($limit)) {
$limit = count($history);
} elseif (ctype_digit($limit)) {
$limit = (int) $limit;
} else {
throw new ApiBadParametersException('Invalid limit');
}
$out = [];
$i = 0;
foreach ($history as $entry) {
if ((! empty($since) && $entry['datetime'] <= $since) || count($out) >= $limit) {
break;
}
if (++$i > $offset) {
$out[$i] = $entry;
$out[$i]['datetime'] = $out[$i]['datetime']->format(\DateTime::ATOM);
}
}
$out = array_values($out);
return $response->withJson($out, 200, $this->jsonStyle);
}
}

View file

@ -1,43 +0,0 @@
<?php
namespace Shaarli\Api\Controllers;
use Shaarli\Bookmark\BookmarkFilter;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Class Info
*
* REST API Controller: /info
*
* @package Api\Controllers
* @see http://shaarli.github.io/api-documentation/#links-instance-information-get
*/
class Info extends ApiController
{
/**
* Service providing various information about Shaarli instance.
*
* @param Request $request Slim request.
* @param Response $response Slim response.
*
* @return Response response.
*/
public function getInfo($request, $response)
{
$info = [
'global_counter' => $this->bookmarkService->count(),
'private_counter' => $this->bookmarkService->count(BookmarkFilter::$PRIVATE),
'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

@ -1,213 +0,0 @@
<?php
namespace Shaarli\Api\Controllers;
use Shaarli\Api\ApiUtils;
use Shaarli\Api\Exceptions\ApiBadParametersException;
use Shaarli\Api\Exceptions\ApiLinkNotFoundException;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Class Links
*
* REST API Controller: all services related to bookmarks collection.
*
* @package Api\Controllers
* @see http://shaarli.github.io/api-documentation/#links-links-collection
*/
class Links extends ApiController
{
/**
* @var int Number of bookmarks returned if no limit is provided.
*/
public static $DEFAULT_LIMIT = 20;
/**
* Retrieve a list of bookmarks, allowing different filters.
*
* @param Request $request Slim request.
* @param Response $response Slim response.
*
* @return Response response.
*
* @throws ApiBadParametersException Invalid parameters.
*/
public function getLinks($request, $response)
{
$private = $request->getParam('visibility');
// Return bookmarks from the {offset}th link, starting from 0.
$offset = $request->getParam('offset');
if (! empty($offset) && ! ctype_digit($offset)) {
throw new ApiBadParametersException('Invalid offset');
}
$offset = ! empty($offset) ? intval($offset) : 0;
// limit parameter is either a number of bookmarks or 'all' for everything.
$limit = $request->getParam('limit');
if (empty($limit)) {
$limit = self::$DEFAULT_LIMIT;
} elseif (ctype_digit($limit)) {
$limit = intval($limit);
} elseif ($limit === 'all') {
$limit = null;
} else {
throw new ApiBadParametersException('Invalid limit');
}
$searchResult = $this->bookmarkService->search(
[
'searchtags' => $request->getParam('searchtags', ''),
'searchterm' => $request->getParam('searchterm', ''),
],
$private,
false,
false,
false,
[
'limit' => $limit,
'offset' => $offset,
'allowOutOfBounds' => true,
]
);
// 'environment' is set by Slim and encapsulate $_SERVER.
$indexUrl = index_url($this->ci['environment']);
$out = [];
foreach ($searchResult->getBookmarks() as $bookmark) {
$out[] = ApiUtils::formatLink($bookmark, $indexUrl);
}
return $response->withJson($out, 200, $this->jsonStyle);
}
/**
* Return a single formatted link by its ID.
*
* @param Request $request Slim request.
* @param Response $response Slim response.
* @param array $args Path parameters. including the ID.
*
* @return Response containing the link array.
*
* @throws ApiLinkNotFoundException generating a 404 error.
*/
public function getLink($request, $response, $args)
{
$id = is_integer_mixed($args['id']) ? (int) $args['id'] : null;
if ($id === null || ! $this->bookmarkService->exists($id)) {
throw new ApiLinkNotFoundException();
}
$index = index_url($this->ci['environment']);
$out = ApiUtils::formatLink($this->bookmarkService->get($id), $index);
return $response->withJson($out, 200, $this->jsonStyle);
}
/**
* Creates a new link from posted request body.
*
* @param Request $request Slim request.
* @param Response $response Slim response.
*
* @return Response response.
*/
public function postLink($request, $response)
{
$data = (array) ($request->getParsedBody() ?? []);
$bookmark = ApiUtils::buildBookmarkFromRequest(
$data,
$this->conf->get('privacy.default_private_links'),
$this->conf->get('general.tags_separator', ' ')
);
// duplicate by URL, return 409 Conflict
if (
! empty($bookmark->getUrl())
&& ! empty($dup = $this->bookmarkService->findByUrl($bookmark->getUrl()))
) {
return $response->withJson(
ApiUtils::formatLink($dup, index_url($this->ci['environment'])),
409,
$this->jsonStyle
);
}
$this->bookmarkService->add($bookmark);
$out = ApiUtils::formatLink($bookmark, index_url($this->ci['environment']));
$redirect = $this->ci->router->pathFor('getLink', ['id' => $bookmark->getId()]);
return $response->withAddedHeader('Location', $redirect)
->withJson($out, 201, $this->jsonStyle);
}
/**
* Updates an existing link from posted request body.
*
* @param Request $request Slim request.
* @param Response $response Slim response.
* @param array $args Path parameters. including the ID.
*
* @return Response response.
*
* @throws ApiLinkNotFoundException generating a 404 error.
*/
public function putLink($request, $response, $args)
{
$id = is_integer_mixed($args['id']) ? (int) $args['id'] : null;
if ($id === null || !$this->bookmarkService->exists($id)) {
throw new ApiLinkNotFoundException();
}
$index = index_url($this->ci['environment']);
$data = $request->getParsedBody();
$requestBookmark = ApiUtils::buildBookmarkFromRequest(
$data,
$this->conf->get('privacy.default_private_links'),
$this->conf->get('general.tags_separator', ' ')
);
// duplicate URL on a different link, return 409 Conflict
if (
! empty($requestBookmark->getUrl())
&& ! empty($dup = $this->bookmarkService->findByUrl($requestBookmark->getUrl()))
&& $dup->getId() != $id
) {
return $response->withJson(
ApiUtils::formatLink($dup, $index),
409,
$this->jsonStyle
);
}
$responseBookmark = $this->bookmarkService->get($id);
$responseBookmark = ApiUtils::updateLink($responseBookmark, $requestBookmark);
$this->bookmarkService->set($responseBookmark);
$out = ApiUtils::formatLink($responseBookmark, $index);
return $response->withJson($out, 200, $this->jsonStyle);
}
/**
* Delete an existing link by its ID.
*
* @param Request $request Slim request.
* @param Response $response Slim response.
* @param array $args Path parameters. including the ID.
*
* @return Response response.
*
* @throws ApiLinkNotFoundException generating a 404 error.
*/
public function deleteLink($request, $response, $args)
{
$id = is_integer_mixed($args['id']) ? (int) $args['id'] : null;
if ($id === null || !$this->bookmarkService->exists($id)) {
throw new ApiLinkNotFoundException();
}
$bookmark = $this->bookmarkService->get($id);
$this->bookmarkService->remove($bookmark);
return $response->withStatus(204);
}
}

View file

@ -1,174 +0,0 @@
<?php
namespace Shaarli\Api\Controllers;
use Shaarli\Api\ApiUtils;
use Shaarli\Api\Exceptions\ApiBadParametersException;
use Shaarli\Api\Exceptions\ApiTagNotFoundException;
use Shaarli\Bookmark\BookmarkFilter;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Class Tags
*
* REST API Controller: all services related to tags collection.
*
* @package Api\Controllers
*/
class Tags extends ApiController
{
/**
* @var int Number of bookmarks returned if no limit is provided.
*/
public static $DEFAULT_LIMIT = 'all';
/**
* Retrieve a list of tags, allowing different filters.
*
* @param Request $request Slim request.
* @param Response $response Slim response.
*
* @return Response response.
*
* @throws ApiBadParametersException Invalid parameters.
*/
public function getTags($request, $response)
{
$visibility = $request->getParam('visibility');
$tags = $this->bookmarkService->bookmarksCountPerTag([], $visibility);
// Return tags from the {offset}th tag, starting from 0.
$offset = $request->getParam('offset');
if (! empty($offset) && ! ctype_digit($offset)) {
throw new ApiBadParametersException('Invalid offset');
}
$offset = ! empty($offset) ? intval($offset) : 0;
if ($offset > count($tags)) {
return $response->withJson([], 200, $this->jsonStyle);
}
// limit parameter is either a number of bookmarks or 'all' for everything.
$limit = $request->getParam('limit');
if (empty($limit)) {
$limit = self::$DEFAULT_LIMIT;
}
if (ctype_digit($limit)) {
$limit = intval($limit);
} elseif ($limit === 'all') {
$limit = count($tags);
} else {
throw new ApiBadParametersException('Invalid limit');
}
$out = [];
$index = 0;
foreach ($tags as $tag => $occurrences) {
if (count($out) >= $limit) {
break;
}
if ($index++ >= $offset) {
$out[] = ApiUtils::formatTag($tag, $occurrences);
}
}
return $response->withJson($out, 200, $this->jsonStyle);
}
/**
* Return a single formatted tag by its name.
*
* @param Request $request Slim request.
* @param Response $response Slim response.
* @param array $args Path parameters. including the tag name.
*
* @return Response containing the link array.
*
* @throws ApiTagNotFoundException generating a 404 error.
*/
public function getTag($request, $response, $args)
{
$tags = $this->bookmarkService->bookmarksCountPerTag();
if (!isset($tags[$args['tagName']])) {
throw new ApiTagNotFoundException();
}
$out = ApiUtils::formatTag($args['tagName'], $tags[$args['tagName']]);
return $response->withJson($out, 200, $this->jsonStyle);
}
/**
* Rename a tag from the given name.
* If the new name provided matches an existing tag, they will be merged.
*
* @param Request $request Slim request.
* @param Response $response Slim response.
* @param array $args Path parameters. including the tag name.
*
* @return Response response.
*
* @throws ApiTagNotFoundException generating a 404 error.
* @throws ApiBadParametersException new tag name not provided
*/
public function putTag($request, $response, $args)
{
$tags = $this->bookmarkService->bookmarksCountPerTag();
if (! isset($tags[$args['tagName']])) {
throw new ApiTagNotFoundException();
}
$data = $request->getParsedBody();
if (empty($data['name'])) {
throw new ApiBadParametersException('New tag name is required in the request body');
}
$searchResult = $this->bookmarkService->search(
['searchtags' => $args['tagName']],
BookmarkFilter::$ALL,
true
);
foreach ($searchResult->getBookmarks() as $bookmark) {
$bookmark->renameTag($args['tagName'], $data['name']);
$this->bookmarkService->set($bookmark, false);
$this->history->updateLink($bookmark);
}
$this->bookmarkService->save();
$tags = $this->bookmarkService->bookmarksCountPerTag();
$out = ApiUtils::formatTag($data['name'], $tags[$data['name']]);
return $response->withJson($out, 200, $this->jsonStyle);
}
/**
* Delete an existing tag by its name.
*
* @param Request $request Slim request.
* @param Response $response Slim response.
* @param array $args Path parameters. including the tag name.
*
* @return Response response.
*
* @throws ApiTagNotFoundException generating a 404 error.
*/
public function deleteTag($request, $response, $args)
{
$tags = $this->bookmarkService->bookmarksCountPerTag();
if (! isset($tags[$args['tagName']])) {
throw new ApiTagNotFoundException();
}
$searchResult = $this->bookmarkService->search(
['searchtags' => $args['tagName']],
BookmarkFilter::$ALL,
true
);
foreach ($searchResult->getBookmarks() as $bookmark) {
$bookmark->deleteTag($args['tagName']);
$this->bookmarkService->set($bookmark, false);
$this->history->updateLink($bookmark);
}
$this->bookmarkService->save();
return $response->withStatus(204);
}
}

View file

@ -1,34 +0,0 @@
<?php
namespace Shaarli\Api\Exceptions;
/**
* Class ApiAuthorizationException
*
* Request not authorized, return a 401 HTTP code.
*/
class ApiAuthorizationException extends ApiException
{
/**
* {@inheritdoc}
*/
public function getApiResponse()
{
$this->setMessage('Not authorized');
return $this->buildApiResponse(401);
}
/**
* Set the exception message.
*
* We only return a generic error message in production mode to avoid giving
* to much security information.
*
* @param $message string the exception message.
*/
public function setMessage($message)
{
$original = $this->debug === true ? ': ' . $this->getMessage() : '';
$this->message = $message . $original;
}
}

View file

@ -1,19 +0,0 @@
<?php
namespace Shaarli\Api\Exceptions;
/**
* Class ApiBadParametersException
*
* Invalid request exception, return a 400 HTTP code.
*/
class ApiBadParametersException extends ApiException
{
/**
* {@inheritdoc}
*/
public function getApiResponse()
{
return $this->buildApiResponse(400);
}
}

View file

@ -1,78 +0,0 @@
<?php
namespace Shaarli\Api\Exceptions;
use Slim\Http\Response;
/**
* Abstract class ApiException
*
* Parent Exception related to the API, able to generate a valid Response (ResponseInterface).
* Also can include various information in debug mode.
*/
abstract class ApiException extends \Exception
{
/**
* @var Response instance from Slim.
*/
protected $response;
/**
* @var bool Debug mode enabled/disabled.
*/
protected $debug;
/**
* Build the final response.
*
* @return Response Final response to give.
*/
abstract public function getApiResponse();
/**
* Creates ApiResponse body.
* In production mode, it will only return the exception message,
* but in dev mode, it includes additional information in an array.
*
* @return array|string response body
*/
protected function getApiResponseBody()
{
if ($this->debug !== true) {
return $this->getMessage();
}
return [
'message' => $this->getMessage(),
'stacktrace' => get_class($this) . ': ' . $this->getTraceAsString()
];
}
/**
* Build the Response object to return.
*
* @param int $code HTTP status.
*
* @return Response with status + body.
*/
protected function buildApiResponse($code)
{
$style = $this->debug ? JSON_PRETTY_PRINT : null;
return $this->response->withJson($this->getApiResponseBody(), $code, $style);
}
/**
* @param Response $response
*/
public function setResponse($response)
{
$this->response = $response;
}
/**
* @param bool $debug
*/
public function setDebug($debug)
{
$this->debug = $debug;
}
}

View file

@ -1,19 +0,0 @@
<?php
namespace Shaarli\Api\Exceptions;
/**
* Class ApiInternalException
*
* Generic exception, return a 500 HTTP code.
*/
class ApiInternalException extends ApiException
{
/**
* @inheritdoc
*/
public function getApiResponse()
{
return $this->buildApiResponse(500);
}
}

View file

@ -1,29 +0,0 @@
<?php
namespace Shaarli\Api\Exceptions;
/**
* Class ApiLinkNotFoundException
*
* Link selected by ID couldn't be found, results in a 404 error.
*
* @package Shaarli\Api\Exceptions
*/
class ApiLinkNotFoundException extends ApiException
{
/**
* ApiLinkNotFoundException constructor.
*/
public function __construct()
{
$this->message = 'Link not found';
}
/**
* {@inheritdoc}
*/
public function getApiResponse()
{
return $this->buildApiResponse(404);
}
}

View file

@ -1,29 +0,0 @@
<?php
namespace Shaarli\Api\Exceptions;
/**
* Class ApiTagNotFoundException
*
* Tag selected by name couldn't be found in the datastore, results in a 404 error.
*
* @package Shaarli\Api\Exceptions
*/
class ApiTagNotFoundException extends ApiException
{
/**
* ApiLinkNotFoundException constructor.
*/
public function __construct()
{
$this->message = 'Tag not found';
}
/**
* {@inheritdoc}
*/
public function getApiResponse()
{
return $this->buildApiResponse(404);
}
}

View file

@ -1,542 +0,0 @@
<?php
declare(strict_types=1);
namespace Shaarli\Bookmark;
use DateTime;
use DateTimeInterface;
use Shaarli\Bookmark\Exception\InvalidBookmarkException;
/**
* Class Bookmark
*
* This class represent a single Bookmark with all its attributes.
* Every bookmark should manipulated using this, before being formatted.
*
* @package Shaarli\Bookmark
*/
class Bookmark
{
/** @var string Date format used in string (former ID format) */
public const LINK_DATE_FORMAT = 'Ymd_His';
/** @var int Bookmark ID */
protected $id;
/** @var string Permalink identifier */
protected $shortUrl;
/** @var string Bookmark's URL - $shortUrl prefixed with `?` for notes */
protected $url;
/** @var string Bookmark's title */
protected $title;
/** @var string Raw bookmark's description */
protected $description;
/** @var array List of bookmark's tags */
protected $tags;
/** @var string|bool|null Thumbnail's URL - initialized at null, false if no thumbnail could be found */
protected $thumbnail;
/** @var bool Set to true if the bookmark is set as sticky */
protected $sticky;
/** @var DateTimeInterface Creation datetime */
protected $created;
/** @var DateTimeInterface datetime */
protected $updated;
/** @var bool True if the bookmark can only be seen while logged in */
protected $private;
/** @var mixed[] Available to store any additional content for a bookmark. Currently used for search highlight. */
protected $additionalContent = [];
/**
* Initialize a link from array data. Especially useful to create a Bookmark from former link storage format.
*
* @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, string $tagsSeparator = ' '): Bookmark
{
$this->id = $data['id'] ?? null;
$this->shortUrl = $data['shorturl'] ?? null;
$this->url = $data['url'] ?? null;
$this->title = $data['title'] ?? null;
$this->description = $data['description'] ?? null;
$this->thumbnail = $data['thumbnail'] ?? null;
$this->sticky = $data['sticky'] ?? false;
$this->created = $data['created'] ?? null;
if (is_array($data['tags'])) {
$this->tags = $data['tags'];
} else {
$this->tags = tags_str2array($data['tags'] ?? '', $tagsSeparator);
}
if (! empty($data['updated'])) {
$this->updated = $data['updated'];
}
$this->private = ($data['private'] ?? false) ? true : false;
$this->additionalContent = $data['additional_content'] ?? [];
return $this;
}
/**
* Make sure that the current instance of Bookmark is valid and can be saved into the data store.
* A valid link requires:
* - an integer ID
* - a short URL (for permalinks)
* - a creation date
*
* This function also initialize optional empty fields:
* - the URL with the permalink
* - the title with the URL
*
* Also make sure that we do not save search highlights in the datastore.
*
* @throws InvalidBookmarkException
*/
public function validate(): void
{
if (
$this->id === null
|| ! is_int($this->id)
|| empty($this->shortUrl)
|| empty($this->created)
) {
throw new InvalidBookmarkException($this);
}
if (empty($this->url)) {
$this->url = '/shaare/' . $this->shortUrl;
}
if (empty($this->title)) {
$this->title = $this->url;
}
if (array_key_exists('search_highlight', $this->additionalContent)) {
unset($this->additionalContent['search_highlight']);
}
}
/**
* Set the Id.
* If they're not already initialized, this function also set:
* - created: with the current datetime
* - shortUrl: with a generated small hash from the date and the given ID
*
* @param int|null $id
*
* @return Bookmark
*/
public function setId(?int $id): Bookmark
{
$this->id = $id;
if (empty($this->created)) {
$this->created = new DateTime();
}
if (empty($this->shortUrl)) {
$this->shortUrl = link_small_hash($this->created, $this->id);
}
return $this;
}
/**
* Get the Id.
*
* @return int|null
*/
public function getId(): ?int
{
return $this->id;
}
/**
* Get the ShortUrl.
*
* @return string|null
*/
public function getShortUrl(): ?string
{
return $this->shortUrl;
}
/**
* Get the Url.
*
* @return string|null
*/
public function getUrl(): ?string
{
return $this->url;
}
/**
* Get the Title.
*
* @return string
*/
public function getTitle(): ?string
{
return $this->title;
}
/**
* Get the Description.
*
* @return string
*/
public function getDescription(): string
{
return ! empty($this->description) ? $this->description : '';
}
/**
* Get the Created.
*
* @return DateTimeInterface
*/
public function getCreated(): ?DateTimeInterface
{
return $this->created;
}
/**
* Get the Updated.
*
* @return DateTimeInterface
*/
public function getUpdated(): ?DateTimeInterface
{
return $this->updated;
}
/**
* Set the ShortUrl.
*
* @param string|null $shortUrl
*
* @return Bookmark
*/
public function setShortUrl(?string $shortUrl): Bookmark
{
$this->shortUrl = $shortUrl;
return $this;
}
/**
* Set the Url.
*
* @param string|null $url
* @param string[] $allowedProtocols
*
* @return Bookmark
*/
public function setUrl(?string $url, array $allowedProtocols = []): Bookmark
{
$url = $url !== null ? trim($url) : '';
if (! empty($url)) {
$url = whitelist_protocols($url, $allowedProtocols);
}
$this->url = $url;
return $this;
}
/**
* Set the Title.
*
* @param string|null $title
*
* @return Bookmark
*/
public function setTitle(?string $title): Bookmark
{
$this->title = $title !== null ? trim($title) : '';
return $this;
}
/**
* Set the Description.
*
* @param string|null $description
*
* @return Bookmark
*/
public function setDescription(?string $description): Bookmark
{
$this->description = $description;
return $this;
}
/**
* Set the Created.
* Note: you shouldn't set this manually except for special cases (like bookmark import)
*
* @param DateTimeInterface|null $created
*
* @return Bookmark
*/
public function setCreated(?DateTimeInterface $created): Bookmark
{
$this->created = $created;
return $this;
}
/**
* Set the Updated.
*
* @param DateTimeInterface|null $updated
*
* @return Bookmark
*/
public function setUpdated(?DateTimeInterface $updated): Bookmark
{
$this->updated = $updated;
return $this;
}
/**
* Get the Private.
*
* @return bool
*/
public function isPrivate(): bool
{
return $this->private ? true : false;
}
/**
* Set the Private.
*
* @param bool|null $private
*
* @return Bookmark
*/
public function setPrivate(?bool $private): Bookmark
{
$this->private = $private ? true : false;
return $this;
}
/**
* Get the Tags.
*
* @return string[]
*/
public function getTags(): array
{
return is_array($this->tags) ? $this->tags : [];
}
/**
* Set the Tags.
*
* @param string[]|null $tags
*
* @return Bookmark
*/
public function setTags(?array $tags): Bookmark
{
$this->tags = array_map(
function (string $tag): string {
return $tag[0] === '-' ? substr($tag, 1) : $tag;
},
tags_filter($tags, ' ')
);
return $this;
}
/**
* Get the Thumbnail.
*
* @return string|bool|null Thumbnail's URL - initialized at null, false if no thumbnail could be found
*/
public function getThumbnail()
{
return !$this->isNote() ? $this->thumbnail : false;
}
/**
* Set the Thumbnail.
*
* @param string|bool|null $thumbnail Thumbnail's URL - false if no thumbnail could be found
*
* @return Bookmark
*/
public function setThumbnail($thumbnail): Bookmark
{
$this->thumbnail = $thumbnail;
return $this;
}
/**
* Return true if:
* - the bookmark's thumbnail is not already set to false (= not found)
* - it's not a note
* - it's an HTTP(S) link
* - the thumbnail has not yet be retrieved (null) or its associated cache file doesn't exist anymore
*
* @return bool True if the bookmark's thumbnail needs to be retrieved.
*/
public function shouldUpdateThumbnail(): bool
{
return $this->thumbnail !== false
&& !$this->isNote()
&& startsWith(strtolower($this->url), 'http')
&& (null === $this->thumbnail || !is_file($this->thumbnail))
;
}
/**
* Get the Sticky.
*
* @return bool
*/
public function isSticky(): bool
{
return $this->sticky ? true : false;
}
/**
* Set the Sticky.
*
* @param bool|null $sticky
*
* @return Bookmark
*/
public function setSticky(?bool $sticky): Bookmark
{
$this->sticky = $sticky ? true : false;
return $this;
}
/**
* @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 $separator = ' '): string
{
return tags_array2str($this->getTags(), $separator);
}
/**
* @return bool
*/
public function isNote(): bool
{
// We check empty value to get a valid result if the link has not been saved yet
return empty($this->url) || startsWith($this->url, '/shaare/') || $this->url[0] === '?';
}
/**
* Set tags from a string.
* Note:
* - tags must be separated whether by a space or a comma
* - multiple spaces will be removed
* - 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, string $separator = ' '): Bookmark
{
$this->setTags(tags_str2array($tags, $separator));
return $this;
}
/**
* Get entire additionalContent array.
*
* @return mixed[]
*/
public function getAdditionalContent(): array
{
return $this->additionalContent;
}
/**
* Set a single entry in additionalContent, by key.
*
* @param string $key
* @param mixed|null $value Any type of value can be set.
*
* @return $this
*/
public function setAdditionalContentEntry(string $key, $value): self
{
$this->additionalContent[$key] = $value;
return $this;
}
/**
* Get a single entry in additionalContent, by key.
*
* @param string $key
* @param mixed|null $default
*
* @return mixed|null can be any type or even null.
*/
public function getAdditionalContentEntry(string $key, $default = null)
{
return array_key_exists($key, $this->additionalContent) ? $this->additionalContent[$key] : $default;
}
/**
* Rename a tag in tags list.
*
* @param string $fromTag
* @param string $toTag
*/
public function renameTag(string $fromTag, string $toTag): void
{
if (($pos = array_search($fromTag, $this->tags ?? [])) !== false) {
$this->tags[$pos] = trim($toTag);
}
}
/**
* Add a tag in tags list.
*
* @param string $tag
*/
public function addTag(string $tag): self
{
return $this->setTags(array_unique(array_merge($this->getTags(), [$tag])));
}
/**
* Delete a tag from tags list.
*
* @param string $tag
*/
public function deleteTag(string $tag): void
{
while (($pos = array_search($tag, $this->tags ?? [])) !== false) {
unset($this->tags[$pos]);
$this->tags = array_values($this->tags);
}
}
}

View file

@ -1,264 +0,0 @@
<?php
declare(strict_types=1);
namespace Shaarli\Bookmark;
use Shaarli\Bookmark\Exception\InvalidBookmarkException;
/**
* Class BookmarkArray
*
* Implementing ArrayAccess, this allows us to use the bookmark list
* as an array and iterate over it.
*
* @package Shaarli\Bookmark
*/
class BookmarkArray implements \Iterator, \Countable, \ArrayAccess
{
/**
* @var Bookmark[]
*/
protected $bookmarks;
/**
* @var array List of all bookmarks IDS mapped with their array offset.
* Map: id->offset.
*/
protected $ids;
/**
* @var int Position in the $this->keys array (for the Iterator interface)
*/
protected $position;
/**
* @var array List of offset keys (for the Iterator interface implementation)
*/
protected $keys;
/**
* @var array List of all recorded URLs (key=url, value=bookmark offset)
* for fast reserve search (url-->bookmark offset)
*/
protected $urls;
public function __construct()
{
$this->ids = [];
$this->bookmarks = [];
$this->keys = [];
$this->urls = [];
$this->position = 0;
}
/**
* Countable - Counts elements of an object
*
* @return int Number of bookmarks
*/
public function count(): int
{
return count($this->bookmarks);
}
/**
* ArrayAccess - Assigns a value to the specified offset
*
* @param int $offset Bookmark ID
* @param Bookmark $value instance
*
* @throws InvalidBookmarkException
*/
public function offsetSet($offset, $value): void
{
if (
! $value instanceof Bookmark
|| $value->getId() === null || empty($value->getUrl())
|| ($offset !== null && ! is_int($offset)) || ! is_int($value->getId())
|| $offset !== null && $offset !== $value->getId()
) {
throw new InvalidBookmarkException($value);
}
// If the bookmark exists, we reuse the real offset, otherwise new entry
if ($offset !== null) {
$existing = $this->getBookmarkOffset($offset);
} else {
$existing = $this->getBookmarkOffset($value->getId());
}
if ($existing !== null) {
$offset = $existing;
} else {
$offset = count($this->bookmarks);
}
$this->bookmarks[$offset] = $value;
$this->urls[$value->getUrl()] = $offset;
$this->ids[$value->getId()] = $offset;
}
/**
* ArrayAccess - Whether or not an offset exists
*
* @param int $offset Bookmark ID
*
* @return bool true if it exists, false otherwise
*/
public function offsetExists($offset): bool
{
return array_key_exists($this->getBookmarkOffset($offset), $this->bookmarks);
}
/**
* ArrayAccess - Unsets an offset
*
* @param int $offset Bookmark ID
*/
public function offsetUnset($offset): void
{
$realOffset = $this->getBookmarkOffset($offset);
$url = $this->bookmarks[$realOffset]->getUrl();
unset($this->urls[$url]);
unset($this->ids[$offset]);
unset($this->bookmarks[$realOffset]);
}
/**
* ArrayAccess - Returns the value at specified offset
*
* @param int $offset Bookmark ID
*
* @return Bookmark|null The Bookmark if found, null otherwise
*/
public function offsetGet($offset): ?Bookmark
{
$realOffset = $this->getBookmarkOffset($offset);
return isset($this->bookmarks[$realOffset]) ? $this->bookmarks[$realOffset] : null;
}
/**
* Iterator - Returns the current element
*
* @return Bookmark corresponding to the current position
*/
public function current(): Bookmark
{
return $this[$this->keys[$this->position]];
}
/**
* Iterator - Returns the key of the current element
*
* @return int Bookmark ID corresponding to the current position
*/
public function key(): int
{
return $this->keys[$this->position];
}
/**
* Iterator - Moves forward to next element
*/
public function next(): void
{
++$this->position;
}
/**
* Iterator - Rewinds the Iterator to the first element
*
* Entries are sorted by date (latest first)
*/
public function rewind(): void
{
$this->keys = array_keys($this->ids);
$this->position = 0;
}
/**
* Iterator - Checks if current position is valid
*
* @return bool true if the current Bookmark ID exists, false otherwise
*/
public function valid(): bool
{
return isset($this->keys[$this->position]);
}
/**
* Returns a bookmark offset in bookmarks array from its unique ID.
*
* @param int|null $id Persistent ID of a bookmark.
*
* @return int Real offset in local array, or null if doesn't exist.
*/
protected function getBookmarkOffset(?int $id): ?int
{
if ($id !== null && isset($this->ids[$id])) {
return $this->ids[$id];
}
return null;
}
/**
* Return the next key for bookmark creation.
* E.g. If the last ID is 597, the next will be 598.
*
* @return int next ID.
*/
public function getNextId(): int
{
if (!empty($this->ids)) {
return max(array_keys($this->ids)) + 1;
}
return 0;
}
/**
* @param string $url
*
* @return Bookmark|null
*/
public function getByUrl(string $url): ?Bookmark
{
if (
! empty($url)
&& isset($this->urls[$url])
&& isset($this->bookmarks[$this->urls[$url]])
) {
return $this->bookmarks[$this->urls[$url]];
}
return null;
}
/**
* Reorder links by creation date (newest first).
*
* Also update the urls and ids mapping arrays.
*
* @param string $order ASC|DESC
* @param bool $ignoreSticky If set to true, sticky bookmarks won't be first
*/
public function reorder(string $order = 'DESC', bool $ignoreSticky = false): void
{
$order = $order === 'ASC' ? -1 : 1;
// Reorder array by dates.
usort($this->bookmarks, function ($a, $b) use ($order, $ignoreSticky) {
/** @var $a Bookmark */
/** @var $b Bookmark */
if (false === $ignoreSticky && $a->isSticky() !== $b->isSticky()) {
return $a->isSticky() ? -1 : 1;
}
return $a->getCreated() < $b->getCreated() ? 1 * $order : -1 * $order;
});
$this->urls = [];
$this->ids = [];
foreach ($this->bookmarks as $key => $bookmark) {
$this->urls[$bookmark->getUrl()] = $key;
$this->ids[$bookmark->getId()] = $key;
}
}
}

View file

@ -1,443 +0,0 @@
<?php
declare(strict_types=1);
namespace Shaarli\Bookmark;
use DateTime;
use Exception;
use malkusch\lock\mutex\Mutex;
use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
use Shaarli\Bookmark\Exception\DatastoreNotInitializedException;
use Shaarli\Bookmark\Exception\EmptyDataStoreException;
use Shaarli\Config\ConfigManager;
use Shaarli\Formatter\BookmarkMarkdownFormatter;
use Shaarli\History;
use Shaarli\Legacy\LegacyLinkDB;
use Shaarli\Legacy\LegacyUpdater;
use Shaarli\Plugin\PluginManager;
use Shaarli\Render\PageCacheManager;
use Shaarli\Updater\UpdaterUtils;
/**
* Class BookmarksService
*
* This is the entry point to manipulate the bookmark DB.
* It manipulates loads links from a file data store containing all bookmarks.
*
* It also triggers the legacy format (bookmarks as arrays) migration.
*/
class BookmarkFileService implements BookmarkServiceInterface
{
/** @var Bookmark[] instance */
protected $bookmarks;
/** @var BookmarkIO instance */
protected $bookmarksIO;
/** @var BookmarkFilter */
protected $bookmarkFilter;
/** @var ConfigManager instance */
protected $conf;
/** @var PluginManager */
protected $pluginManager;
/** @var History instance */
protected $history;
/** @var PageCacheManager instance */
protected $pageCacheManager;
/** @var bool true for logged in users. Default value to retrieve private bookmarks. */
protected $isLoggedIn;
/** @var Mutex */
protected $mutex;
/**
* @inheritDoc
*/
public function __construct(
ConfigManager $conf,
PluginManager $pluginManager,
History $history,
Mutex $mutex,
bool $isLoggedIn
) {
$this->conf = $conf;
$this->history = $history;
$this->mutex = $mutex;
$this->pageCacheManager = new PageCacheManager($this->conf->get('resource.page_cache'), $isLoggedIn);
$this->bookmarksIO = new BookmarkIO($this->conf, $this->mutex);
$this->isLoggedIn = $isLoggedIn;
if (!$this->isLoggedIn && $this->conf->get('privacy.hide_public_links', false)) {
$this->bookmarks = new BookmarkArray();
} else {
try {
$this->bookmarks = $this->bookmarksIO->read();
} catch (EmptyDataStoreException | DatastoreNotInitializedException $e) {
$this->bookmarks = new BookmarkArray();
if ($this->isLoggedIn) {
// Datastore file does not exists, we initialize it with default bookmarks.
if ($e instanceof DatastoreNotInitializedException) {
$this->initialize();
} else {
$this->save();
}
}
}
if (! $this->bookmarks instanceof BookmarkArray) {
$this->migrate();
exit(
'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->pluginManager = $pluginManager;
$this->bookmarkFilter = new BookmarkFilter($this->bookmarks, $this->conf, $this->pluginManager);
}
/**
* @inheritDoc
*/
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()
&& (empty($privateKey) || $privateKey !== $first->getAdditionalContentEntry('private_key'))
) {
throw new BookmarkNotFoundException();
}
return $first;
}
/**
* @inheritDoc
*/
public function findByUrl(string $url): ?Bookmark
{
return $this->bookmarks->getByUrl($url);
}
/**
* @inheritDoc
*/
public function search(
array $request = [],
string $visibility = null,
bool $caseSensitive = false,
bool $untaggedOnly = false,
bool $ignoreSticky = false,
array $pagination = []
): SearchResult {
if ($visibility === null) {
$visibility = $this->isLoggedIn ? BookmarkFilter::$ALL : BookmarkFilter::$PUBLIC;
}
// Filter bookmark database according to parameters.
$searchTags = isset($request['searchtags']) ? $request['searchtags'] : '';
$searchTerm = isset($request['searchterm']) ? $request['searchterm'] : '';
if ($ignoreSticky) {
$this->bookmarks->reorder('DESC', true);
}
$bookmarks = $this->bookmarkFilter->filter(
BookmarkFilter::$FILTER_TAG | BookmarkFilter::$FILTER_TEXT,
[$searchTags, $searchTerm],
$caseSensitive,
$visibility,
$untaggedOnly
);
return SearchResult::getSearchResult(
$bookmarks,
$pagination['offset'] ?? 0,
$pagination['limit'] ?? null,
$pagination['allowOutOfBounds'] ?? false
);
}
/**
* @inheritDoc
*/
public function get(int $id, string $visibility = null): Bookmark
{
if (! isset($this->bookmarks[$id])) {
throw new BookmarkNotFoundException();
}
if ($visibility === null) {
$visibility = $this->isLoggedIn ? BookmarkFilter::$ALL : BookmarkFilter::$PUBLIC;
}
$bookmark = $this->bookmarks[$id];
if (
($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private')
|| (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public')
) {
throw new Exception('Unauthorized');
}
return $bookmark;
}
/**
* @inheritDoc
*/
public function set(Bookmark $bookmark, bool $save = true): Bookmark
{
if (true !== $this->isLoggedIn) {
throw new Exception(t('You\'re not authorized to alter the datastore'));
}
if (! isset($this->bookmarks[$bookmark->getId()])) {
throw new BookmarkNotFoundException();
}
$bookmark->validate();
$bookmark->setUpdated(new DateTime());
$this->bookmarks[$bookmark->getId()] = $bookmark;
if ($save === true) {
$this->save();
$this->history->updateLink($bookmark);
}
return $this->bookmarks[$bookmark->getId()];
}
/**
* @inheritDoc
*/
public function add(Bookmark $bookmark, bool $save = true): Bookmark
{
if (true !== $this->isLoggedIn) {
throw new Exception(t('You\'re not authorized to alter the datastore'));
}
if (!empty($bookmark->getId())) {
throw new Exception(t('This bookmarks already exists'));
}
$bookmark->setId($this->bookmarks->getNextId());
$bookmark->validate();
$this->bookmarks[$bookmark->getId()] = $bookmark;
if ($save === true) {
$this->save();
$this->history->addLink($bookmark);
}
return $this->bookmarks[$bookmark->getId()];
}
/**
* @inheritDoc
*/
public function addOrSet(Bookmark $bookmark, bool $save = true): Bookmark
{
if (true !== $this->isLoggedIn) {
throw new Exception(t('You\'re not authorized to alter the datastore'));
}
if ($bookmark->getId() === null) {
return $this->add($bookmark, $save);
}
return $this->set($bookmark, $save);
}
/**
* @inheritDoc
*/
public function remove(Bookmark $bookmark, bool $save = true): void
{
if (true !== $this->isLoggedIn) {
throw new Exception(t('You\'re not authorized to alter the datastore'));
}
if (! isset($this->bookmarks[$bookmark->getId()])) {
throw new BookmarkNotFoundException();
}
unset($this->bookmarks[$bookmark->getId()]);
if ($save === true) {
$this->save();
$this->history->deleteLink($bookmark);
}
}
/**
* @inheritDoc
*/
public function exists(int $id, string $visibility = null): bool
{
if (! isset($this->bookmarks[$id])) {
return false;
}
if ($visibility === null) {
$visibility = $this->isLoggedIn ? 'all' : 'public';
}
$bookmark = $this->bookmarks[$id];
if (
($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private')
|| (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public')
) {
return false;
}
return true;
}
/**
* @inheritDoc
*/
public function count(string $visibility = null): int
{
return $this->search([], $visibility)->getResultCount();
}
/**
* @inheritDoc
*/
public function save(): void
{
if (true !== $this->isLoggedIn) {
// TODO: raise an Exception instead
die('You are not authorized to change the database.');
}
$this->bookmarks->reorder();
$this->bookmarksIO->write($this->bookmarks);
$this->pageCacheManager->invalidateCaches();
}
/**
* @inheritDoc
*/
public function bookmarksCountPerTag(array $filteringTags = [], string $visibility = null): array
{
$searchResult = $this->search(['searchtags' => $filteringTags], $visibility);
$tags = [];
$caseMapping = [];
foreach ($searchResult->getBookmarks() as $bookmark) {
foreach ($bookmark->getTags() as $tag) {
if (
empty($tag)
|| (! $this->isLoggedIn && startsWith($tag, '.'))
|| $tag === BookmarkMarkdownFormatter::NO_MD_TAG
|| in_array($tag, $filteringTags, true)
) {
continue;
}
// The first case found will be displayed.
if (!isset($caseMapping[strtolower($tag)])) {
$caseMapping[strtolower($tag)] = $tag;
$tags[$caseMapping[strtolower($tag)]] = 0;
}
$tags[$caseMapping[strtolower($tag)]]++;
}
}
/*
* Formerly used arsort(), which doesn't define the sort behaviour for equal values.
* Also, this function doesn't produce the same result between PHP 5.6 and 7.
*
* So we now use array_multisort() to sort tags by DESC occurrences,
* then ASC alphabetically for equal values.
*
* @see https://github.com/shaarli/Shaarli/issues/1142
*/
$keys = array_keys($tags);
$tmpTags = array_combine($keys, $keys);
array_multisort($tags, SORT_DESC, $tmpTags, SORT_ASC, $tags);
return $tags;
}
/**
* @inheritDoc
*/
public function findByDate(
\DateTimeInterface $from,
\DateTimeInterface $to,
?\DateTimeInterface &$previous,
?\DateTimeInterface &$next
): array {
$out = [];
$previous = null;
$next = null;
foreach ($this->search([], null, false, false, true)->getBookmarks() 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 getLatest(): ?Bookmark
{
foreach ($this->search([], null, false, false, true)->getBookmarks() as $bookmark) {
return $bookmark;
}
return null;
}
/**
* @inheritDoc
*/
public function initialize(): void
{
$initializer = new BookmarkInitializer($this);
$initializer->initialize();
if (true === $this->isLoggedIn) {
$this->save();
}
}
/**
* Handles migration to the new database format (BookmarksArray).
*/
protected function migrate(): void
{
$bookmarkDb = new LegacyLinkDB(
$this->conf->get('resource.datastore'),
true,
false
);
$updater = new LegacyUpdater(
UpdaterUtils::readUpdatesFile($this->conf->get('resource.updates')),
$bookmarkDb,
$this->conf,
true
);
$newUpdates = $updater->update();
if (! empty($newUpdates)) {
UpdaterUtils::writeUpdatesFile(
$this->conf->get('resource.updates'),
$updater->getDoneUpdates()
);
}
}
}

View file

@ -1,635 +0,0 @@
<?php
declare(strict_types=1);
namespace Shaarli\Bookmark;
use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
use Shaarli\Config\ConfigManager;
use Shaarli\Plugin\PluginManager;
/**
* Class LinkFilter.
*
* Perform search and filter operation on link data list.
*/
class BookmarkFilter
{
/**
* @var string permalinks.
*/
public static $FILTER_HASH = 'permalink';
/**
* @var string text search.
*/
public static $FILTER_TEXT = 'fulltext';
/**
* @var string tag filter.
*/
public static $FILTER_TAG = 'tags';
/**
* @var string filter by day.
*/
public static $DEFAULT = 'NO_FILTER';
/** @var string Visibility: all */
public static $ALL = 'all';
/** @var string Visibility: public */
public static $PUBLIC = 'public';
/** @var string Visibility: private */
public static $PRIVATE = 'private';
/**
* @var string Allowed characters for hashtags (regex syntax).
*/
public static $HASHTAG_CHARS = '\p{Pc}\p{N}\p{L}\p{Mn}';
/**
* @var Bookmark[] all available bookmarks.
*/
private $bookmarks;
/** @var ConfigManager */
protected $conf;
/** @var PluginManager */
protected $pluginManager;
/**
* @param Bookmark[] $bookmarks initialization.
*/
public function __construct($bookmarks, ConfigManager $conf, PluginManager $pluginManager)
{
$this->bookmarks = $bookmarks;
$this->conf = $conf;
$this->pluginManager = $pluginManager;
}
/**
* Filter bookmarks according to parameters.
*
* @param string $type Type of filter (eg. tags, permalink, etc.).
* @param mixed $request Filter content.
* @param bool $casesensitive Optional: Perform case sensitive filter if true.
* @param string $visibility Optional: return only all/private/public bookmarks
* @param bool $untaggedonly Optional: return only untagged bookmarks. Applies only if $type includes FILTER_TAG
*
* @return Bookmark[] filtered bookmark list.
*
* @throws BookmarkNotFoundException
*/
public function filter(
string $type,
$request,
bool $casesensitive = false,
string $visibility = 'all',
bool $untaggedonly = false
) {
if (!in_array($visibility, ['all', 'public', 'private'])) {
$visibility = 'all';
}
switch ($type) {
case self::$FILTER_HASH:
return $this->filterSmallHash($request);
case self::$FILTER_TAG | self::$FILTER_TEXT: // == "vuotext"
$noRequest = empty($request) || (empty($request[0]) && empty($request[1]));
if ($noRequest) {
if ($untaggedonly) {
return $this->filterUntagged($visibility);
}
return $this->noFilter($visibility);
}
if ($untaggedonly) {
$filtered = $this->filterUntagged($visibility);
} else {
$filtered = $this->bookmarks;
}
if (!empty($request[0])) {
$filtered = (new BookmarkFilter($filtered, $this->conf, $this->pluginManager))
->filterTags($request[0], $casesensitive, $visibility)
;
}
if (!empty($request[1])) {
$filtered = (new BookmarkFilter($filtered, $this->conf, $this->pluginManager))
->filterFulltext($request[1], $visibility)
;
}
return $filtered;
case self::$FILTER_TEXT:
return $this->filterFulltext($request, $visibility);
case self::$FILTER_TAG:
if ($untaggedonly) {
return $this->filterUntagged($visibility);
} else {
return $this->filterTags($request, $casesensitive, $visibility);
}
default:
return $this->noFilter($visibility);
}
}
/**
* Unknown filter, but handle private only.
*
* @param string $visibility Optional: return only all/private/public bookmarks
*
* @return Bookmark[] filtered bookmarks.
*/
private function noFilter(string $visibility = 'all')
{
$out = [];
foreach ($this->bookmarks as $key => $value) {
if (
!$this->pluginManager->filterSearchEntry(
$value,
['source' => 'no_filter', 'visibility' => $visibility]
)
) {
continue;
}
if ($visibility === 'all') {
$out[$key] = $value;
} elseif ($value->isPrivate() && $visibility === 'private') {
$out[$key] = $value;
} elseif (!$value->isPrivate() && $visibility === 'public') {
$out[$key] = $value;
}
}
return $out;
}
/**
* Returns the shaare corresponding to a smallHash.
*
* @param string $smallHash permalink hash.
*
* @return Bookmark[] $filtered array containing permalink data.
*
* @throws BookmarkNotFoundException if the smallhash doesn't match any link.
*/
private function filterSmallHash(string $smallHash)
{
foreach ($this->bookmarks as $key => $l) {
if ($smallHash == $l->getShortUrl()) {
// Yes, this is ugly and slow
return [$key => $l];
}
}
throw new BookmarkNotFoundException();
}
/**
* Returns the list of bookmarks corresponding to a full-text search
*
* Searches:
* - in the URLs, title and description;
* - are case-insensitive;
* - terms surrounded by quotes " are exact terms search.
* - terms starting with a dash - are excluded (except exact terms).
*
* Example:
* print_r($mydb->filterFulltext('hollandais'));
*
* mb_convert_case($val, MB_CASE_LOWER, 'UTF-8')
* - allows to perform searches on Unicode text
* - see https://github.com/shaarli/Shaarli/issues/75 for examples
*
* @param string $searchterms search query.
* @param string $visibility Optional: return only all/private/public bookmarks.
*
* @return Bookmark[] search results.
*/
private function filterFulltext(string $searchterms, string $visibility = 'all')
{
if (empty($searchterms)) {
return $this->noFilter($visibility);
}
$filtered = [];
$search = mb_convert_case(html_entity_decode($searchterms), MB_CASE_LOWER, 'UTF-8');
$exactRegex = '/"([^"]+)"/';
// Retrieve exact search terms.
preg_match_all($exactRegex, $search, $exactSearch);
$exactSearch = array_values(array_filter($exactSearch[1]));
// Remove exact search terms to get AND terms search.
$explodedSearchAnd = explode(' ', trim(preg_replace($exactRegex, '', $search)));
$explodedSearchAnd = array_values(array_filter($explodedSearchAnd));
// Filter excluding terms and update andSearch.
$excludeSearch = [];
$andSearch = [];
foreach ($explodedSearchAnd as $needle) {
if ($needle[0] == '-' && strlen($needle) > 1) {
$excludeSearch[] = substr($needle, 1);
} else {
$andSearch[] = $needle;
}
}
// Iterate over every stored link.
foreach ($this->bookmarks as $id => $bookmark) {
if (
!$this->pluginManager->filterSearchEntry(
$bookmark,
[
'source' => 'fulltext',
'searchterms' => $searchterms,
'andSearch' => $andSearch,
'exactSearch' => $exactSearch,
'excludeSearch' => $excludeSearch,
'visibility' => $visibility
]
)
) {
continue;
}
// ignore non private bookmarks when 'privatonly' is on.
if ($visibility !== 'all') {
if (!$bookmark->isPrivate() && $visibility === 'private') {
continue;
} elseif ($bookmark->isPrivate() && $visibility === 'public') {
continue;
}
}
$lengths = [];
$content = $this->buildFullTextSearchableLink($bookmark, $lengths);
// Be optimistic
$found = true;
$foundPositions = [];
// First, we look for exact term search
// Then iterate over keywords, if keyword is not found,
// no need to check for the others. We want all or nothing.
foreach ([$exactSearch, $andSearch] as $search) {
for ($i = 0; $i < count($search) && $found !== false; $i++) {
$found = mb_strpos($content, $search[$i]);
if ($found === false) {
break;
}
$foundPositions[] = ['start' => $found, 'end' => $found + mb_strlen($search[$i])];
}
}
// Exclude terms.
for ($i = 0; $i < count($excludeSearch) && $found !== false; $i++) {
$found = strpos($content, $excludeSearch[$i]) === false;
}
if ($found !== false) {
$bookmark->setAdditionalContentEntry(
'search_highlight',
$this->postProcessFoundPositions($lengths, $foundPositions)
);
$filtered[$id] = $bookmark;
}
}
return $filtered;
}
/**
* Returns the list of bookmarks associated with a given list of tags
*
* You can specify one or more tags, separated by space or a comma, e.g.
* print_r($mydb->filterTags('linux programming'));
*
* @param string|array $tags list of tags, separated by commas or blank spaces if passed as string.
* @param bool $casesensitive ignore case if false.
* @param string $visibility Optional: return only all/private/public bookmarks.
*
* @return Bookmark[] filtered bookmarks.
*/
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 = tags_str2array($inputTags, $tagsSeparator);
}
if (count($inputTags) === 0) {
// no input tags
return $this->noFilter($visibility);
}
// If we only have public visibility, we can't look for hidden tags
if ($visibility === self::$PUBLIC) {
$inputTags = array_values(array_filter($inputTags, function ($tag) {
return ! startsWith($tag, '.');
}));
if (empty($inputTags)) {
return [];
}
}
// build regex from all tags
$re_and = implode(array_map([$this, 'tag2regex'], $inputTags));
$re = '/^' . $re_and;
$orTags = array_filter(array_map(function ($tag) {
return startsWith($tag, '~') ? substr($tag, 1) : null;
}, $inputTags));
$re_or = implode('|', array_map([$this, 'tag2matchterm'], $orTags));
if ($re_or) {
$re_or = '(' . $re_or . ')';
$re .= $this->term2match($re_or, false);
}
$re .= '.*$/';
if (!$casesensitive) {
// make regex case insensitive
$re .= 'i';
}
// create resulting array
$filtered = [];
// iterate over each link
foreach ($this->bookmarks as $key => $bookmark) {
if (
!$this->pluginManager->filterSearchEntry(
$bookmark,
[
'source' => 'tags',
'tags' => $tags,
'casesensitive' => $casesensitive,
'visibility' => $visibility
]
)
) {
continue;
}
// check level of visibility
// ignore non private bookmarks when 'privateonly' is on.
if ($visibility !== 'all') {
if (!$bookmark->isPrivate() && $visibility === 'private') {
continue;
} elseif ($bookmark->isPrivate() && $visibility === 'public') {
continue;
}
}
// build search string, start with tags of current link
$search = $bookmark->getTagsString($tagsSeparator);
if (strlen(trim($bookmark->getDescription())) && strpos($bookmark->getDescription(), '#') !== false) {
// description given and at least one possible tag found
$descTags = [];
// find all tags in the form of #tag in the description
preg_match_all(
'/(?<![' . self::$HASHTAG_CHARS . '])#([' . self::$HASHTAG_CHARS . ']+?)\b/sm',
$bookmark->getDescription(),
$descTags
);
if (count($descTags[1])) {
// there were some tags in the description, add them to the search string
$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
continue;
}
$filtered[$key] = $bookmark;
}
return $filtered;
}
/**
* Return only bookmarks without any tag.
*
* @param string $visibility return only all/private/public bookmarks.
*
* @return Bookmark[] filtered bookmarks.
*/
public function filterUntagged(string $visibility)
{
$filtered = [];
foreach ($this->bookmarks as $key => $bookmark) {
if (
!$this->pluginManager->filterSearchEntry(
$bookmark,
['source' => 'untagged', 'visibility' => $visibility]
)
) {
continue;
}
if ($visibility !== 'all') {
if (!$bookmark->isPrivate() && $visibility === 'private') {
continue;
} elseif ($bookmark->isPrivate() && $visibility === 'public') {
continue;
}
}
if (empty($bookmark->getTags())) {
$filtered[$key] = $bookmark;
}
}
return $filtered;
}
/**
* Convert a list of tags (str) to an array. Also
* - handle case sensitivity.
* - accepts spaces commas as separator.
*
* @param string $tags string containing a list of tags.
* @param bool $casesensitive will convert everything to lowercase if false.
*
* @return string[] filtered tags string.
*/
public static function tagsStrToArray(string $tags, bool $casesensitive): array
{
// We use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek)
$tagsOut = $casesensitive ? $tags : mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8');
$tagsOut = str_replace(',', ' ', $tagsOut);
return preg_split('/\s+/', $tagsOut, -1, PREG_SPLIT_NO_EMPTY);
}
/**
* generate a regex fragment out of a tag
*
* @param string $tag to generate regexs from. may start with '-'
* to negate, contain '*' as wildcard. Tags starting with '~' are
* treated separately as an 'OR' clause.
*
* @return string generated regex fragment
*/
protected function tag2regex(string $tag): string
{
$tagsSeparator = $this->conf->get('general.tags_separator', ' ');
if (!$tag || $tag === "-" || $tag === "*" || $tag[0] === "~") {
// nothing to search, return empty regex
return '';
}
$negate = false;
if ($tag[0] === "+" && $tag[1]) {
$tag = substr($tag, 1); // use offset to start after '+' character
}
if ($tag[0] === "-") {
// query is negated
$tag = substr($tag, 1); // use offset to start after '-' character
$negate = true;
}
$term = $this->tag2matchterm($tag);
return $this->term2match($term, $negate);
}
/**
* generate a regex match term fragment out of a tag
*
* @param string $tag to to generate regexs from. This function
* assumes any leading flags ('-', '~') have been stripped. The
* wildcard flag '*' is expanded by this function and any other
* regex characters are escaped.
*
* @return string generated regex match term fragment
*/
protected function tag2matchterm(string $tag): string
{
$tagsSeparator = $this->conf->get('general.tags_separator', ' ');
$len = strlen($tag);
$term = '';
// iterate over string, separating it into placeholder and content
$i = 0; // start at first character
for (; $i < $len; $i++) {
if ($tag[$i] === '*') {
// placeholder found
$term .= '[^' . $tagsSeparator . ']*?';
} else {
// regular characters
$offset = strpos($tag, '*', $i);
if ($offset === false) {
// no placeholder found, set offset to end of string
$offset = $len;
}
// subtract one, as we want to get before the placeholder or end of string
$offset -= 1;
// we got a tag name that we want to search for. escape any regex characters to prevent conflicts.
$term .= preg_quote(substr($tag, $i, $offset - $i + 1), '/');
// move $i on
$i = $offset;
}
}
return $term;
}
/**
* generate a regex fragment out of a match term
*
* @param string $term is the match term already generated by tag2matchterm
* @param bool $negate if true create a negative lookahead
*
* @return string generated regex fragment
*/
protected function term2match(string $term, bool $negate): string
{
$tagsSeparator = $this->conf->get('general.tags_separator', ' ');
$regex = $negate ? '(?!' : '(?='; // use negative or positive lookahead
// before tag may only be the separator or the beginning
$regex .= '.*(?:^|' . $tagsSeparator . ')';
$regex .= $term;
// after the tag may only be the separator or the end
$regex .= '(?:$|' . $tagsSeparator . '))';
return $regex;
}
/**
* This method finalize the content of the foundPositions array,
* by associated all search results to their associated bookmark field,
* making sure that there is no overlapping results, etc.
*
* @param array $fieldLengths Start and end positions of every bookmark fields in the aggregated bookmark content.
* @param array $foundPositions Positions where the search results were found in the aggregated content.
*
* @return array Updated $foundPositions, by bookmark field.
*/
protected function postProcessFoundPositions(array $fieldLengths, array $foundPositions): array
{
// Sort results by starting position ASC.
usort($foundPositions, function (array $entryA, array $entryB): int {
return $entryA['start'] > $entryB['start'] ? 1 : -1;
});
$out = [];
$currentMax = -1;
foreach ($foundPositions as $foundPosition) {
// we do not allow overlapping highlights
if ($foundPosition['start'] < $currentMax) {
continue;
}
$currentMax = $foundPosition['end'];
foreach ($fieldLengths as $part => $length) {
if ($foundPosition['start'] < $length['start'] || $foundPosition['start'] > $length['end']) {
continue;
}
$out[$part][] = [
'start' => $foundPosition['start'] - $length['start'],
'end' => $foundPosition['end'] - $length['start'],
];
break;
}
}
return $out;
}
/**
* Concatenate link fields to search across fields. Adds a '\' separator for exact search terms.
* Also populate $length array with starting and ending positions of every bookmark field
* inside concatenated content.
*
* @param Bookmark $link
* @param array $lengths (by reference)
*
* @return string Lowercase concatenated fields content.
*/
protected function buildFullTextSearchableLink(Bookmark $link, array &$lengths): string
{
$tagString = $link->getTagsString($this->conf->get('general.tags_separator', ' '));
$content = mb_convert_case($link->getTitle(), MB_CASE_LOWER, 'UTF-8') . '\\';
$content .= mb_convert_case($link->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;
$lengths['description'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($link->getDescription())];
$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($tagString)];
return $content;
}
}

View file

@ -1,177 +0,0 @@
<?php
declare(strict_types=1);
namespace Shaarli\Bookmark;
use malkusch\lock\exception\LockAcquireException;
use malkusch\lock\mutex\Mutex;
use malkusch\lock\mutex\NoMutex;
use Shaarli\Bookmark\Exception\DatastoreNotInitializedException;
use Shaarli\Bookmark\Exception\EmptyDataStoreException;
use Shaarli\Bookmark\Exception\InvalidWritableDataException;
use Shaarli\Bookmark\Exception\NotEnoughSpaceException;
use Shaarli\Bookmark\Exception\NotWritableDataStoreException;
use Shaarli\Config\ConfigManager;
/**
* Class BookmarkIO
*
* This class performs read/write operation to the file data store.
* Used by BookmarkFileService.
*
* @package Shaarli\Bookmark
*/
class BookmarkIO
{
/**
* @var string Datastore file path
*/
protected $datastore;
/**
* @var ConfigManager instance
*/
protected $conf;
/** @var Mutex */
protected $mutex;
/**
* string Datastore PHP prefix
*/
protected static $phpPrefix = '<?php /* ';
/**
* string Datastore PHP suffix
*/
protected static $phpSuffix = ' */ ?>';
/**
* LinksIO constructor.
*
* @param ConfigManager $conf instance
*/
public function __construct(ConfigManager $conf, Mutex $mutex = null)
{
if ($mutex === null) {
// This should only happen with legacy classes
$mutex = new NoMutex();
}
$this->conf = $conf;
$this->datastore = $conf->get('resource.datastore');
$this->mutex = $mutex;
}
/**
* Reads database from disk to memory
*
* @return Bookmark[]
*
* @throws NotWritableDataStoreException Data couldn't be loaded
* @throws EmptyDataStoreException Datastore file exists but does not contain any bookmark
* @throws DatastoreNotInitializedException File does not exists
*/
public function read()
{
if (! file_exists($this->datastore)) {
throw new DatastoreNotInitializedException();
}
if (!is_writable($this->datastore)) {
throw new NotWritableDataStoreException($this->datastore);
}
$content = null;
$this->synchronized(function () use (&$content) {
$content = file_get_contents($this->datastore);
});
// Note that gzinflate is faster than gzuncompress.
// See: http://www.php.net/manual/en/function.gzdeflate.php#96439
$links = unserialize(gzinflate(base64_decode(
substr($content, strlen(self::$phpPrefix), -strlen(self::$phpSuffix))
)));
if (empty($links)) {
if (filesize($this->datastore) > 100) {
throw new NotWritableDataStoreException($this->datastore);
}
throw new EmptyDataStoreException();
}
return $links;
}
/**
* Saves the database from memory to disk
*
* @param Bookmark[] $links
*
* @throws NotWritableDataStoreException the datastore is not writable
* @throws InvalidWritableDataException
*/
public function write($links)
{
if (is_file($this->datastore) && !is_writeable($this->datastore)) {
// The datastore exists but is not writeable
throw new NotWritableDataStoreException($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 = base64_encode(gzdeflate(serialize($links)));
if (empty($data)) {
throw new InvalidWritableDataException();
}
$data = self::$phpPrefix . $data . self::$phpSuffix;
$this->synchronized(function () use ($data) {
if (!$this->checkDiskSpace($data)) {
throw new NotEnoughSpaceException();
}
file_put_contents(
$this->datastore,
$data
);
});
}
/**
* Wrapper applying mutex to provided function.
* If the lock can't be acquired (e.g. some shared hosting provider), we execute the function without mutex.
*
* @see https://github.com/shaarli/Shaarli/issues/1650
*
* @param callable $function
*/
protected function synchronized(callable $function): void
{
try {
$this->mutex->synchronized($function);
} catch (LockAcquireException $exception) {
$function();
}
}
/**
* Make sure that there is enough disk space available to save the current data store.
* We add an arbitrary margin of 500kB.
*
* @param string $data to be saved
*
* @return bool True if data can safely be saved
*/
public function checkDiskSpace(string $data): bool
{
if (function_exists('disk_free_space') === false) {
return true;
}
return disk_free_space(dirname($this->datastore)) > (strlen($data) + 1024 * 500);
}
}

View file

@ -1,115 +0,0 @@
<?php
declare(strict_types=1);
namespace Shaarli\Bookmark;
/**
* Class BookmarkInitializer
*
* This class is used to initialized default bookmarks after a fresh install of Shaarli.
* It should be only called if the datastore file does not exist(users might want to delete the default bookmarks).
*
* To prevent data corruption, it does not overwrite existing bookmarks,
* even though there should not be any.
*
* 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
{
/** @var BookmarkServiceInterface */
protected $bookmarkService;
/**
* BookmarkInitializer constructor.
*
* @param BookmarkServiceInterface $bookmarkService
*/
public function __construct(BookmarkServiceInterface $bookmarkService)
{
$this->bookmarkService = $bookmarkService;
}
/**
* Initialize the data store with default bookmarks
*/
public function initialize(): void
{
$bookmark = new Bookmark();
$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.
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.
Now you can edit or delete the default shaares.
'
));
$bookmark->setTagsString('shaarli help thumbnail');
$bookmark->setPrivate(true);
$this->bookmarkService->add($bookmark, false);
$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.
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.
The Markdown formatting setting allows you to format your notes and bookmark description:
### Title headings
#### Multiple headings levels
* bullet lists
* _italic_ text
* **bold** text
* ~~strike through~~ text
* `code` blocks
* images
* [links](https://en.wikipedia.org/wiki/Markdown)
Markdown also supports tables:
| Name | Type | Color | Qty |
| ------- | --------- | ------ | ----- |
| Orange | Fruit | Orange | 126 |
| Apple | Fruit | Any | 62 |
| Lemon | Fruit | Yellow | 30 |
| Carrot | Vegetable | Red | 14 |
'
));
$bookmark->setTagsString('shaarli help');
$bookmark->setPrivate(true);
$this->bookmarkService->add($bookmark, false);
$bookmark = new Bookmark();
$bookmark->setTitle(
'Shaarli - ' . t('The personal, minimalist, super fast, database-free, bookmarking service')
);
$bookmark->setDescription(t(
'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.
Create a new shaare by clicking the `+Shaare` button, or using any of the recommended tools (browser extension, mobile app, bookmarklet, REST API, etc.).
You can easily retrieve your links, even with thousands of them, using the internal search engine, or search through tags (e.g. this Shaare is tagged with `shaarli` and `help`).
Hashtags such as #shaarli #help are also supported.
You can also filter the available [RSS feed](/feed/atom) and picture wall by tag or plaintext search.
We hope that you will enjoy using Shaarli, maintained with ❤️ by the community!
Feel free to open [an issue](https://github.com/shaarli/Shaarli/issues) if you have a suggestion or encounter an issue.
'
));
$bookmark->setTagsString('shaarli help');
$this->bookmarkService->add($bookmark, false);
}
}

View file

@ -1,189 +0,0 @@
<?php
declare(strict_types=1);
namespace Shaarli\Bookmark;
use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
use Shaarli\Bookmark\Exception\NotWritableDataStoreException;
/**
* Class BookmarksService
*
* This is the entry point to manipulate the bookmark DB.
*
* Regarding return types of a list of bookmarks, it can either be an array or an ArrayAccess implementation,
* so until PHP 8.0 is the minimal supported version with union return types it cannot be explicitly added.
*/
interface BookmarkServiceInterface
{
/**
* Find a bookmark by 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, string $privateKey = null);
/**
* @param $url
*
* @return Bookmark|null
*/
public function findByUrl(string $url): ?Bookmark;
/**
* Search bookmarks
*
* @param array $request
* @param ?string $visibility
* @param bool $caseSensitive
* @param bool $untaggedOnly
* @param bool $ignoreSticky
* @param array $pagination This array can contain the following keys for pagination: limit, offset.
*
* @return SearchResult
*/
public function search(
array $request = [],
string $visibility = null,
bool $caseSensitive = false,
bool $untaggedOnly = false,
bool $ignoreSticky = false,
array $pagination = []
): SearchResult;
/**
* Get a single bookmark by its ID.
*
* @param int $id Bookmark ID
* @param ?string $visibility all|public|private e.g. with public, accessing a private bookmark will throw an
* exception
*
* @return Bookmark
*
* @throws BookmarkNotFoundException
* @throws \Exception
*/
public function get(int $id, string $visibility = null);
/**
* Updates an existing bookmark (depending on its ID).
*
* @param Bookmark $bookmark
* @param bool $save Writes to the datastore if set to true
*
* @return Bookmark Updated bookmark
*
* @throws BookmarkNotFoundException
* @throws \Exception
*/
public function set(Bookmark $bookmark, bool $save = true): Bookmark;
/**
* Adds a new bookmark (the ID must be empty).
*
* @param Bookmark $bookmark
* @param bool $save Writes to the datastore if set to true
*
* @return Bookmark new bookmark
*
* @throws \Exception
*/
public function add(Bookmark $bookmark, bool $save = true): Bookmark;
/**
* Adds or updates a bookmark depending on its ID:
* - a Bookmark without ID will be added
* - a Bookmark with an existing ID will be updated
*
* @param Bookmark $bookmark
* @param bool $save
*
* @return Bookmark
*
* @throws \Exception
*/
public function addOrSet(Bookmark $bookmark, bool $save = true): Bookmark;
/**
* Deletes a bookmark.
*
* @param Bookmark $bookmark
* @param bool $save
*
* @throws \Exception
*/
public function remove(Bookmark $bookmark, bool $save = true): void;
/**
* Get a single bookmark by its ID.
*
* @param int $id Bookmark ID
* @param ?string $visibility all|public|private e.g. with public, accessing a private bookmark will throw an
* exception
*
* @return bool
*/
public function exists(int $id, string $visibility = null): bool;
/**
* Return the number of available bookmarks for given visibility.
*
* @param ?string $visibility public|private|all
*
* @return int Number of bookmarks
*/
public function count(string $visibility = null): int;
/**
* Write the datastore.
*
* @throws NotWritableDataStoreException
*/
public function save(): void;
/**
* Returns the list tags appearing in the bookmarks with the given tags
*
* @param array|null $filteringTags tags selecting the bookmarks to consider
* @param string|null $visibility process only all/private/public bookmarks
*
* @return array tag => bookmarksCount
*/
public function bookmarksCountPerTag(array $filteringTags = [], ?string $visibility = null): array;
/**
* 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.
*
* @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 findByDate(
\DateTimeInterface $from,
\DateTimeInterface $to,
?\DateTimeInterface &$previous,
?\DateTimeInterface &$next
): array;
/**
* Returns the latest bookmark by creation date.
*
* @return Bookmark|null Found Bookmark or null if the datastore is empty.
*/
public function getLatest(): ?Bookmark;
/**
* Creates the default database after a fresh install.
*/
public function initialize(): void;
}

View file

@ -1,253 +0,0 @@
<?php
use Shaarli\Bookmark\Bookmark;
use Shaarli\Formatter\BookmarkDefaultFormatter;
/**
* Extract title from an HTML document.
*
* @param string $html HTML content where to look for a title.
*
* @return bool|string Extracted title if found, false otherwise.
*/
function html_extract_title($html)
{
if (preg_match('!<title.*?>(.*?)</title>!is', $html, $matches)) {
return trim(str_replace("\n", '', $matches[1]));
}
return false;
}
/**
* Extract charset from HTTP header if it's defined.
*
* @param string $header HTTP header Content-Type line.
*
* @return bool|string Charset string if found (lowercase), false otherwise.
*/
function header_extract_charset($header)
{
preg_match('/charset=["\']?([^; "\']+)/i', $header, $match);
if (! empty($match[1])) {
return strtolower(trim($match[1]));
}
return false;
}
/**
* Extract charset HTML content (tag <meta charset>).
*
* @param string $html HTML content where to look for charset.
*
* @return bool|string Charset string if found, false otherwise.
*/
function html_extract_charset($html)
{
// Get encoding specified in HTML header.
preg_match('#<meta .*charset=["\']?([^";\'>/]+)["\']? */?>#Usi', $html, $enc);
if (!empty($enc[1])) {
return strtolower($enc[1]);
}
return false;
}
/**
* Extract meta tag from HTML content in either:
* - OpenGraph: <meta property="og:[tag]" ...>
* - Meta tag: <meta name="[tag]" ...>
*
* @param string $tag Name of the tag to retrieve.
* @param string $html HTML content where to look for charset.
*
* @return bool|string Charset string if found, false otherwise.
*/
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 . '[^\'"]*?[\'"]';
// Support quotes in double quoted content, and the other way around
$content = 'content=(["\'])((?:(?!\1).)*)\1';
// Try to retrieve OpenGraph tag.
$ogRegex = '#<meta[^>]+(?:' . $properties . ')=(?:' . $orCondition . ')[^>]*' . $content . '.*?>#';
// If the attributes are not in the order property => content (e.g. Github)
// New regex to keep this readable... more or less.
$ogRegexReverse = '#<meta[^>]+' . $content . '[^>]+(?:' . $properties . ')=(?:' . $orCondition . ').*?>#';
if (
preg_match($ogRegex, $html, $matches) > 0
|| preg_match($ogRegexReverse, $html, $matches) > 0
) {
return $matches[2];
}
return false;
}
/**
* In a string, converts URLs to clickable bookmarks.
*
* @param string $text input string.
*
* @return string returns $text with all bookmarks converted to HTML bookmarks.
*
* @see Function inspired from http://www.php.net/manual/en/function.preg-replace.php#85722
*/
function text2clickable($text)
{
$regex = '!(((?:https?|ftp|file)://|apt:|magnet:)\S+[a-z0-9\(\)]/?)!si';
$format = function (array $match): string {
return '<a href="' .
str_replace(
BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_OPEN,
'',
str_replace(BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_CLOSE, '', $match[1])
) .
'">' . $match[1] . '</a>'
;
};
return preg_replace_callback($regex, $format, $text);
}
/**
* Auto-link hashtags.
*
* @param string $description Given description.
* @param string $indexUrl Root URL.
*
* @return string Description with auto-linked hashtags.
*/
function hashtag_autolink($description, $indexUrl = '')
{
$tokens = '(?:' . BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_OPEN . ')' .
'(?:' . BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_CLOSE . ')'
;
/*
* To support unicode: http://stackoverflow.com/a/35498078/1484919
* \p{Pc} - to match underscore
* \p{N} - numeric character in any script
* \p{L} - letter from any language
* \p{Mn} - any non marking space (accents, umlauts, etc)
*/
$regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}' . $tokens . ']+)/mui';
$format = function (array $match) use ($indexUrl): string {
$cleanMatch = str_replace(
BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_OPEN,
'',
str_replace(BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_CLOSE, '', $match[2])
);
return $match[1] . '<a href="' . $indexUrl . './add-tag/' . $cleanMatch . '"' .
' title="Hashtag ' . $cleanMatch . '">' .
'#' . $match[2] .
'</a>';
};
return preg_replace_callback($regex, $format, $description);
}
/**
* This function inserts &nbsp; where relevant so that multiple spaces are properly displayed in HTML
* even in the absence of <pre> (This is used in description to keep text formatting).
*
* @param string $text input text.
*
* @return string formatted text.
*/
function space2nbsp($text)
{
return preg_replace('/(^| ) /m', '$1&nbsp;', $text);
}
/**
* Format Shaarli's description
*
* @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 = '', $autolink = true)
{
if ($autolink) {
$description = hashtag_autolink(text2clickable($description), $indexUrl);
}
return nl2br(space2nbsp($description));
}
/**
* Generate a small hash for a link.
*
* @param DateTime $date Link creation date.
* @param int $id Link ID.
*
* @return string the small hash generated from link data.
*/
function link_small_hash($date, $id)
{
return smallHash($date->format(Bookmark::LINK_DATE_FORMAT) . $id);
}
/**
* Returns whether or not the link is an internal note.
* Its URL starts by `?` because it's actually a permalink.
*
* @param string $linkUrl
*
* @return bool true if internal note, false otherwise.
*/
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 = str_replace([' ', '/'], ['\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,136 +0,0 @@
<?php
declare(strict_types=1);
namespace Shaarli\Bookmark;
/**
* Read-only class used to represent search result, including pagination.
*/
class SearchResult
{
/** @var Bookmark[] List of result bookmarks with pagination applied */
protected $bookmarks;
/** @var int number of Bookmarks found, with pagination applied */
protected $resultCount;
/** @var int total number of result found */
protected $totalCount;
/** @var int pagination: limit number of result bookmarks */
protected $limit;
/** @var int pagination: offset to apply to complete result list */
protected $offset;
public function __construct(array $bookmarks, int $totalCount, int $offset, ?int $limit)
{
$this->bookmarks = $bookmarks;
$this->resultCount = count($bookmarks);
$this->totalCount = $totalCount;
$this->limit = $limit;
$this->offset = $offset;
}
/**
* Build a SearchResult from provided full result set and pagination settings.
*
* @param Bookmark[] $bookmarks Full set of result which will be filtered
* @param int $offset Start recording results from $offset
* @param int|null $limit End recording results after $limit bookmarks is reached
* @param bool $allowOutOfBounds Set to false to display the last page if the offset is out of bound,
* return empty result set otherwise (default: false)
*
* @return SearchResult
*/
public static function getSearchResult(
$bookmarks,
int $offset = 0,
?int $limit = null,
bool $allowOutOfBounds = false
): self {
$totalCount = count($bookmarks);
if (!$allowOutOfBounds && $offset > $totalCount) {
$offset = $limit === null ? 0 : $limit * -1;
}
if ($bookmarks instanceof BookmarkArray) {
$buffer = [];
foreach ($bookmarks as $key => $value) {
$buffer[$key] = $value;
}
$bookmarks = $buffer;
}
return new static(
array_slice($bookmarks, $offset, $limit, true),
$totalCount,
$offset,
$limit
);
}
/** @return Bookmark[] List of result bookmarks with pagination applied */
public function getBookmarks(): array
{
return $this->bookmarks;
}
/** @return int number of Bookmarks found, with pagination applied */
public function getResultCount(): int
{
return $this->resultCount;
}
/** @return int total number of result found */
public function getTotalCount(): int
{
return $this->totalCount;
}
/** @return int pagination: limit number of result bookmarks */
public function getLimit(): ?int
{
return $this->limit;
}
/** @return int pagination: offset to apply to complete result list */
public function getOffset(): int
{
return $this->offset;
}
/** @return int Current page of result set in complete results */
public function getPage(): int
{
if (empty($this->limit)) {
return $this->offset === 0 ? 1 : 2;
}
$base = $this->offset >= 0 ? $this->offset : $this->totalCount + $this->offset;
return (int) ceil($base / $this->limit) + 1;
}
/** @return int Get the # of the last page */
public function getLastPage(): int
{
if (empty($this->limit)) {
return $this->offset === 0 ? 1 : 2;
}
return (int) ceil($this->totalCount / $this->limit);
}
/** @return bool Either the current page is the last one or not */
public function isLastPage(): bool
{
return $this->getPage() === $this->getLastPage();
}
/** @return bool Either the current page is the first one or not */
public function isFirstPage(): bool
{
return $this->offset === 0;
}
}

View file

@ -1,16 +0,0 @@
<?php
namespace Shaarli\Bookmark\Exception;
use Exception;
class BookmarkNotFoundException extends Exception
{
/**
* LinkNotFoundException constructor.
*/
public function __construct()
{
$this->message = t('The link you are trying to reach does not exist or has been deleted.');
}
}

View file

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

View file

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

View file

@ -1,30 +0,0 @@
<?php
namespace Shaarli\Bookmark\Exception;
use Shaarli\Bookmark\Bookmark;
class InvalidBookmarkException extends \Exception
{
public function __construct($bookmark)
{
if ($bookmark instanceof Bookmark) {
if ($bookmark->getCreated() instanceof \DateTime) {
$created = $bookmark->getCreated()->format(\DateTime::ATOM);
} elseif (empty($bookmark->getCreated())) {
$created = '';
} 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;
} else {
$this->message = 'The provided data is not a bookmark' . PHP_EOL;
$this->message .= var_export($bookmark, true);
}
}
}

View file

@ -1,14 +0,0 @@
<?php
namespace Shaarli\Bookmark\Exception;
class InvalidWritableDataException extends \Exception
{
/**
* InvalidWritableDataException constructor.
*/
public function __construct()
{
$this->message = 'Couldn\'t generate bookmark data to store in the datastore. Skipping file writing.';
}
}

View file

@ -1,14 +0,0 @@
<?php
namespace Shaarli\Bookmark\Exception;
class NotEnoughSpaceException extends \Exception
{
/**
* NotEnoughSpaceException constructor.
*/
public function __construct()
{
$this->message = 'Not enough available disk space to save the datastore.';
}
}

View file

@ -1,17 +0,0 @@
<?php
namespace Shaarli\Bookmark\Exception;
class NotWritableDataStoreException extends \Exception
{
/**
* NotReadableDataStore constructor.
*
* @param string $dataStore file path
*/
public function __construct($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,7 +1,5 @@
<?php <?php
namespace Shaarli\Config;
/** /**
* Interface ConfigIO * Interface ConfigIO
* *
@ -16,7 +14,7 @@ interface ConfigIO
* *
* @return array All configuration in an array. * @return array All configuration in an array.
*/ */
public function read($filepath); function read($filepath);
/** /**
* Write configuration. * Write configuration.
@ -24,12 +22,12 @@ public function read($filepath);
* @param string $filepath Config file absolute path. * @param string $filepath Config file absolute path.
* @param array $conf All configuration in an array. * @param array $conf All configuration in an array.
*/ */
public function write($filepath, $conf); function write($filepath, $conf);
/** /**
* Get config file extension according to config type. * Get config file extension according to config type.
* *
* @return string Config file extension. * @return string Config file extension.
*/ */
public function getExtension(); function getExtension();
} }

View file

@ -1,5 +1,4 @@
<?php <?php
namespace Shaarli\Config;
/** /**
* Class ConfigJson (ConfigIO implementation) * Class ConfigJson (ConfigIO implementation)
@ -11,7 +10,7 @@ class ConfigJson implements ConfigIO
/** /**
* @inheritdoc * @inheritdoc
*/ */
public function read($filepath) function read($filepath)
{ {
if (! is_readable($filepath)) { if (! is_readable($filepath)) {
return array(); return array();
@ -19,21 +18,10 @@ public function read($filepath)
$data = file_get_contents($filepath); $data = file_get_contents($filepath);
$data = str_replace(self::getPhpHeaders(), '', $data); $data = str_replace(self::getPhpHeaders(), '', $data);
$data = str_replace(self::getPhpSuffix(), '', $data); $data = str_replace(self::getPhpSuffix(), '', $data);
$data = json_decode(trim($data), true); $data = json_decode($data, true);
if ($data === null) { if ($data === null) {
$errorCode = json_last_error(); $error = json_last_error();
$error = sprintf( throw new Exception('An error occurred while parsing JSON file: error code #'. $error);
'An error occurred while parsing JSON configuration file (%s): error code #%d',
$filepath,
$errorCode
);
$error .= '<br>➜ <code>' . json_last_error_msg() .'</code>';
if ($errorCode === JSON_ERROR_SYNTAX) {
$error .= '<br>';
$error .= 'Please check your JSON syntax (without PHP comment tags) using a JSON lint tool such as ';
$error .= '<a href="http://jsonlint.com/">jsonlint.com</a>.';
}
throw new \Exception($error);
} }
return $data; return $data;
} }
@ -41,16 +29,16 @@ public function read($filepath)
/** /**
* @inheritdoc * @inheritdoc
*/ */
public function write($filepath, $conf) function write($filepath, $conf)
{ {
// JSON_PRETTY_PRINT is available from PHP 5.4. // JSON_PRETTY_PRINT is available from PHP 5.4.
$print = defined('JSON_PRETTY_PRINT') ? JSON_PRETTY_PRINT : 0; $print = defined('JSON_PRETTY_PRINT') ? JSON_PRETTY_PRINT : 0;
$data = self::getPhpHeaders() . json_encode($conf, $print) . self::getPhpSuffix(); $data = self::getPhpHeaders() . json_encode($conf, $print) . self::getPhpSuffix();
if (empty($filepath) || !file_put_contents($filepath, $data)) { if (!file_put_contents($filepath, $data)) {
throw new \Shaarli\Exceptions\IOException( throw new IOException(
$filepath, $filepath,
t('Shaarli could not create the config file. '. 'Shaarli could not create the config file.
'Please make sure Shaarli has the right to write in the folder is it installed in.') Please make sure Shaarli has the right to write in the folder is it installed in.'
); );
} }
} }
@ -58,7 +46,7 @@ public function write($filepath, $conf)
/** /**
* @inheritdoc * @inheritdoc
*/ */
public function getExtension() function getExtension()
{ {
return '.json.php'; return '.json.php';
} }
@ -73,7 +61,7 @@ public function getExtension()
*/ */
public static function getPhpHeaders() public static function getPhpHeaders()
{ {
return '<?php /*'; return '<?php /*'. PHP_EOL;
} }
/** /**
@ -85,6 +73,6 @@ public static function getPhpHeaders()
*/ */
public static function getPhpSuffix() public static function getPhpSuffix()
{ {
return '*/ ?>'; return PHP_EOL . '*/ ?>';
} }
} }

View file

@ -1,18 +1,17 @@
<?php <?php
namespace Shaarli\Config; // FIXME! Namespaces...
require_once 'ConfigIO.php';
use Shaarli\Config\Exception\MissingFieldConfigException; require_once 'ConfigJson.php';
use Shaarli\Config\Exception\UnauthorizedConfigException; require_once 'ConfigPhp.php';
use Shaarli\Thumbnailer;
/** /**
* Class ConfigManager * Class ConfigManager
* *
* Manages all Shaarli's settings. * Manages all Shaarli's settings.
* See the documentation for more information on settings: * See the documentation for more information on settings:
* - doc/md/Shaarli-configuration.md * - doc/Shaarli-configuration.html
* - https://shaarli.readthedocs.io/en/master/Shaarli-configuration/#configuration * - https://github.com/shaarli/Shaarli/wiki/Shaarli-configuration
*/ */
class ConfigManager class ConfigManager
{ {
@ -21,8 +20,6 @@ class ConfigManager
*/ */
protected static $NOT_FOUND = 'NOT_FOUND'; protected static $NOT_FOUND = 'NOT_FOUND';
public static $DEFAULT_PLUGINS = ['qrcode'];
/** /**
* @var string Config folder. * @var string Config folder.
*/ */
@ -83,11 +80,7 @@ protected function initialize()
*/ */
protected function load() protected function load()
{ {
try {
$this->loadedConfig = $this->configIO->read($this->getConfigFileExt()); $this->loadedConfig = $this->configIO->read($this->getConfigFileExt());
} catch (\Exception $e) {
die($e->getMessage());
}
$this->setDefaultValues(); $this->setDefaultValues();
} }
@ -125,16 +118,16 @@ public function get($setting, $default = '')
* Supports nested settings with dot separated keys. * Supports nested settings with dot separated keys.
* *
* @param string $setting Asked setting, keys separated with dots. * @param string $setting Asked setting, keys separated with dots.
* @param mixed $value Value to set. * @param string $value Value to set.
* @param bool $write Write the new setting in the config file, default false. * @param bool $write Write the new setting in the config file, default false.
* @param bool $isLoggedIn User login state, default false. * @param bool $isLoggedIn User login state, default false.
* *
* @throws \Exception Invalid * @throws Exception Invalid
*/ */
public function set($setting, $value, $write = false, $isLoggedIn = false) public function set($setting, $value, $write = false, $isLoggedIn = false)
{ {
if (empty($setting) || ! is_string($setting)) { if (empty($setting) || ! is_string($setting)) {
throw new \Exception(t('Invalid setting key parameter. String expected, got: ') . gettype($setting)); throw new Exception('Invalid setting key parameter. String expected, got: '. gettype($setting));
} }
// During the ConfigIO transition, map legacy settings to the new ones. // During the ConfigIO transition, map legacy settings to the new ones.
@ -149,33 +142,6 @@ public function set($setting, $value, $write = false, $isLoggedIn = false)
} }
} }
/**
* Remove a config element from the config file.
*
* @param string $setting Asked setting, keys separated with dots.
* @param bool $write Write the new setting in the config file, default false.
* @param bool $isLoggedIn User login state, default false.
*
* @throws \Exception Invalid
*/
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));
}
// During the ConfigIO transition, map legacy settings to the new ones.
if ($this->configIO instanceof ConfigPhp && isset(ConfigPhp::$LEGACY_KEYS_MAPPING[$setting])) {
$setting = ConfigPhp::$LEGACY_KEYS_MAPPING[$setting];
}
$settings = explode('.', $setting);
self::removeConfig($settings, $this->loadedConfig);
if ($write) {
$this->write($isLoggedIn);
}
}
/** /**
* Check if a settings exists. * Check if a settings exists.
* *
@ -209,12 +175,12 @@ public function exists($setting)
* *
* @throws MissingFieldConfigException: a mandatory field has not been provided in $conf. * @throws MissingFieldConfigException: a mandatory field has not been provided in $conf.
* @throws UnauthorizedConfigException: user is not authorize to change configuration. * @throws UnauthorizedConfigException: user is not authorize to change configuration.
* @throws \Shaarli\Exceptions\IOException: an error occurred while writing the new config file. * @throws IOException: an error occurred while writing the new config file.
*/ */
public function write($isLoggedIn) public function write($isLoggedIn)
{ {
// These fields are required in configuration. // These fields are required in configuration.
$mandatoryFields = [ $mandatoryFields = array(
'credentials.login', 'credentials.login',
'credentials.hash', 'credentials.hash',
'credentials.salt', 'credentials.salt',
@ -223,7 +189,8 @@ public function write($isLoggedIn)
'general.title', 'general.title',
'general.header_link', 'general.header_link',
'privacy.default_private_links', 'privacy.default_private_links',
]; 'redirector.url',
);
// Only logged in user can alter config. // Only logged in user can alter config.
if (is_file($this->getConfigFileExt()) && !$isLoggedIn) { if (is_file($this->getConfigFileExt()) && !$isLoggedIn) {
@ -317,27 +284,6 @@ protected static function setConfig($settings, $value, &$conf)
$conf[$setting] = $value; $conf[$setting] = $value;
} }
/**
* Recursive function which find asked setting in the loaded config and deletes it.
*
* @param array $settings Ordered array which contains keys to find.
* @param array $conf Loaded settings, then sub-array.
*
* @return mixed Found setting or NOT_FOUND flag.
*/
protected static function removeConfig($settings, &$conf)
{
if (!is_array($settings) || count($settings) == 0) {
return self::$NOT_FOUND;
}
$setting = array_shift($settings);
if (count($settings) > 0) {
return self::removeConfig($settings, $conf[$setting]);
}
unset($conf[$setting]);
}
/** /**
* Set a bunch of default values allowing Shaarli to start without a config file. * Set a bunch of default values allowing Shaarli to start without a config file.
*/ */
@ -350,7 +296,6 @@ protected function setDefaultValues()
$this->setEmpty('resource.updates', 'data/updates.txt'); $this->setEmpty('resource.updates', 'data/updates.txt');
$this->setEmpty('resource.log', 'data/log.txt'); $this->setEmpty('resource.log', 'data/log.txt');
$this->setEmpty('resource.update_check', 'data/lastupdatecheck.txt'); $this->setEmpty('resource.update_check', 'data/lastupdatecheck.txt');
$this->setEmpty('resource.history', 'data/history.php');
$this->setEmpty('resource.raintpl_tpl', 'tpl/'); $this->setEmpty('resource.raintpl_tpl', 'tpl/');
$this->setEmpty('resource.theme', 'default'); $this->setEmpty('resource.theme', 'default');
$this->setEmpty('resource.raintpl_tmp', 'tmp/'); $this->setEmpty('resource.raintpl_tmp', 'tmp/');
@ -361,41 +306,29 @@ protected function setDefaultValues()
$this->setEmpty('security.ban_duration', 1800); $this->setEmpty('security.ban_duration', 1800);
$this->setEmpty('security.session_protection_disabled', false); $this->setEmpty('security.session_protection_disabled', false);
$this->setEmpty('security.open_shaarli', false); $this->setEmpty('security.open_shaarli', false);
$this->setEmpty('security.allowed_protocols', ['ftp', 'ftps', 'magnet']);
$this->setEmpty('general.header_link', '/'); $this->setEmpty('general.header_link', '?');
$this->setEmpty('general.links_per_page', 20); $this->setEmpty('general.links_per_page', 20);
$this->setEmpty('general.enabled_plugins', self::$DEFAULT_PLUGINS); $this->setEmpty('general.enabled_plugins', array('qrcode'));
$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', true); $this->setEmpty('updates.check_updates', false);
$this->setEmpty('updates.check_updates_branch', 'latest'); $this->setEmpty('updates.check_updates_branch', 'stable');
$this->setEmpty('updates.check_updates_interval', 86400); $this->setEmpty('updates.check_updates_interval', 86400);
$this->setEmpty('feed.rss_permalinks', true); $this->setEmpty('feed.rss_permalinks', true);
$this->setEmpty('feed.show_atom', true); $this->setEmpty('feed.show_atom', false);
$this->setEmpty('privacy.default_private_links', false); $this->setEmpty('privacy.default_private_links', false);
$this->setEmpty('privacy.hide_public_links', false); $this->setEmpty('privacy.hide_public_links', false);
$this->setEmpty('privacy.force_login', false);
$this->setEmpty('privacy.hide_timestamps', false); $this->setEmpty('privacy.hide_timestamps', false);
// default state of the 'remember me' checkbox of the login form
$this->setEmpty('privacy.remember_user_default', true);
$this->setEmpty('thumbnails.mode', Thumbnailer::MODE_ALL); $this->setEmpty('thumbnail.enable_thumbnails', true);
$this->setEmpty('thumbnails.width', '125'); $this->setEmpty('thumbnail.enable_localcache', true);
$this->setEmpty('thumbnails.height', '90');
$this->setEmpty('translation.language', 'auto'); $this->setEmpty('redirector.url', '');
$this->setEmpty('translation.mode', 'php'); $this->setEmpty('redirector.encode_url', true);
$this->setEmpty('translation.extensions', []);
$this->setEmpty('plugins', []); $this->setEmpty('plugins', array());
$this->setEmpty('formatter', 'markdown');
} }
/** /**
@ -427,3 +360,36 @@ public function setConfigIO($configIO)
$this->configIO = $configIO; $this->configIO = $configIO;
} }
} }
/**
* Exception used if a mandatory field is missing in given configuration.
*/
class MissingFieldConfigException extends Exception
{
public $field;
/**
* Construct exception.
*
* @param string $field field name missing.
*/
public function __construct($field)
{
$this->field = $field;
$this->message = 'Configuration value is required for '. $this->field;
}
}
/**
* Exception used if an unauthorized attempt to edit configuration has been made.
*/
class UnauthorizedConfigException extends Exception
{
/**
* Construct exception.
*/
public function __construct()
{
$this->message = 'You are not authorized to alter config.';
}
}

View file

@ -1,7 +1,5 @@
<?php <?php
namespace Shaarli\Config;
/** /**
* Class ConfigPhp (ConfigIO implementation) * Class ConfigPhp (ConfigIO implementation)
* *
@ -13,7 +11,7 @@ class ConfigPhp implements ConfigIO
/** /**
* @var array List of config key without group. * @var array List of config key without group.
*/ */
public static $ROOT_KEYS = [ public static $ROOT_KEYS = array(
'login', 'login',
'hash', 'hash',
'salt', 'salt',
@ -23,16 +21,16 @@ class ConfigPhp implements ConfigIO
'redirector', 'redirector',
'disablesessionprotection', 'disablesessionprotection',
'privateLinkByDefault', 'privateLinkByDefault',
]; );
/** /**
* Map legacy config keys with the new ones. * Map legacy config keys with the new ones.
* If ConfigPhp is used, getting <newkey> will actually look for <legacykey>. * If ConfigPhp is used, getting <newkey> will actually look for <legacykey>.
* The updater will use this array to transform keys when switching to JSON. * The Updater will use this array to transform keys when switching to JSON.
* *
* @var array current key => legacy key. * @var array current key => legacy key.
*/ */
public static $LEGACY_KEYS_MAPPING = [ public static $LEGACY_KEYS_MAPPING = array(
'credentials.login' => 'login', 'credentials.login' => 'login',
'credentials.hash' => 'hash', 'credentials.hash' => 'hash',
'credentials.salt' => 'salt', 'credentials.salt' => 'salt',
@ -69,34 +67,34 @@ class ConfigPhp implements ConfigIO
'privacy.hide_public_links' => 'config.HIDE_PUBLIC_LINKS', 'privacy.hide_public_links' => 'config.HIDE_PUBLIC_LINKS',
'privacy.hide_timestamps' => 'config.HIDE_TIMESTAMPS', 'privacy.hide_timestamps' => 'config.HIDE_TIMESTAMPS',
'security.open_shaarli' => 'config.OPEN_SHAARLI', 'security.open_shaarli' => 'config.OPEN_SHAARLI',
]; );
/** /**
* @inheritdoc * @inheritdoc
*/ */
public function read($filepath) function read($filepath)
{ {
if (! file_exists($filepath) || ! is_readable($filepath)) { if (! file_exists($filepath) || ! is_readable($filepath)) {
return []; return array();
} }
include $filepath; include $filepath;
$out = []; $out = array();
foreach (self::$ROOT_KEYS as $key) { foreach (self::$ROOT_KEYS as $key) {
$out[$key] = isset($GLOBALS[$key]) ? $GLOBALS[$key] : ''; $out[$key] = $GLOBALS[$key];
} }
$out['config'] = isset($GLOBALS['config']) ? $GLOBALS['config'] : []; $out['config'] = $GLOBALS['config'];
$out['plugins'] = isset($GLOBALS['plugins']) ? $GLOBALS['plugins'] : []; $out['plugins'] = !empty($GLOBALS['plugins']) ? $GLOBALS['plugins'] : array();
return $out; return $out;
} }
/** /**
* @inheritdoc * @inheritdoc
*/ */
public function write($filepath, $conf) function write($filepath, $conf)
{ {
$configStr = '<?php ' . PHP_EOL; $configStr = '<?php '. PHP_EOL;
foreach (self::$ROOT_KEYS as $key) { foreach (self::$ROOT_KEYS as $key) {
if (isset($conf[$key])) { if (isset($conf[$key])) {
$configStr .= '$GLOBALS[\'' . $key . '\'] = ' . var_export($conf[$key], true) . ';' . PHP_EOL; $configStr .= '$GLOBALS[\'' . $key . '\'] = ' . var_export($conf[$key], true) . ';' . PHP_EOL;
@ -105,31 +103,22 @@ public function write($filepath, $conf)
// Store all $conf['config'] // Store all $conf['config']
foreach ($conf['config'] as $key => $value) { foreach ($conf['config'] as $key => $value) {
$configStr .= '$GLOBALS[\'config\'][\'' $configStr .= '$GLOBALS[\'config\'][\''. $key .'\'] = '.var_export($conf['config'][$key], true).';'. PHP_EOL;
. $key
. '\'] = '
. var_export($conf['config'][$key], true) . ';'
. PHP_EOL;
} }
if (isset($conf['plugins'])) { if (isset($conf['plugins'])) {
foreach ($conf['plugins'] as $key => $value) { foreach ($conf['plugins'] as $key => $value) {
$configStr .= '$GLOBALS[\'plugins\'][\'' $configStr .= '$GLOBALS[\'plugins\'][\''. $key .'\'] = '.var_export($conf['plugins'][$key], true).';'. PHP_EOL;
. $key
. '\'] = '
. var_export($conf['plugins'][$key], true) . ';'
. PHP_EOL;
} }
} }
if ( if (!file_put_contents($filepath, $configStr)
!file_put_contents($filepath, $configStr)
|| strcmp(file_get_contents($filepath), $configStr) != 0 || strcmp(file_get_contents($filepath), $configStr) != 0
) { ) {
throw new \Shaarli\Exceptions\IOException( throw new IOException(
$filepath, $filepath,
t('Shaarli could not create the config file. ' . 'Shaarli could not create the config file.
'Please make sure Shaarli has the right to write in the folder is it installed in.') Please make sure Shaarli has the right to write in the folder is it installed in.'
); );
} }
} }
@ -137,7 +126,7 @@ public function write($filepath, $conf)
/** /**
* @inheritdoc * @inheritdoc
*/ */
public function getExtension() function getExtension()
{ {
return '.php'; return '.php';
} }

View file

@ -1,8 +1,4 @@
<?php <?php
use Shaarli\Config\Exception\PluginConfigOrderException;
use Shaarli\Plugin\PluginManager;
/** /**
* Plugin configuration helper functions. * Plugin configuration helper functions.
* *
@ -20,27 +16,13 @@
*/ */
function save_plugin_config($formData) function save_plugin_config($formData)
{ {
// We can only save existing plugins
$directories = str_replace(
PluginManager::$PLUGINS_PATH . '/',
'',
glob(PluginManager::$PLUGINS_PATH . '/*')
);
$formData = array_filter(
$formData,
function ($value, string $key) use ($directories) {
return startsWith($key, 'order') || in_array($key, $directories);
},
ARRAY_FILTER_USE_BOTH
);
// Make sure there are no duplicates in orders. // Make sure there are no duplicates in orders.
if (!validate_plugin_order($formData)) { if (!validate_plugin_order($formData)) {
throw new PluginConfigOrderException(); throw new PluginConfigOrderException();
} }
$plugins = []; $plugins = array();
$newEnabledPlugins = []; $newEnabledPlugins = array();
foreach ($formData as $key => $data) { foreach ($formData as $key => $data) {
if (startsWith($key, 'order')) { if (startsWith($key, 'order')) {
continue; continue;
@ -49,7 +31,8 @@ function ($value, string $key) use ($directories) {
// If there is no order, it means a disabled plugin has been enabled. // If there is no order, it means a disabled plugin has been enabled.
if (isset($formData['order_' . $key])) { if (isset($formData['order_' . $key])) {
$plugins[(int) $formData['order_' . $key]] = $key; $plugins[(int) $formData['order_' . $key]] = $key;
} else { }
else {
$newEnabledPlugins[] = $key; $newEnabledPlugins[] = $key;
} }
} }
@ -62,7 +45,7 @@ function ($value, string $key) use ($directories) {
throw new PluginConfigOrderException(); throw new PluginConfigOrderException();
} }
$finalPlugins = []; $finalPlugins = array();
// Make plugins order continuous. // Make plugins order continuous.
foreach ($plugins as $plugin) { foreach ($plugins as $plugin) {
$finalPlugins[] = $plugin; $finalPlugins[] = $plugin;
@ -81,10 +64,10 @@ function ($value, string $key) use ($directories) {
*/ */
function validate_plugin_order($formData) function validate_plugin_order($formData)
{ {
$orders = []; $orders = array();
foreach ($formData as $key => $value) { foreach ($formData as $key => $value) {
// No duplicate order allowed. // No duplicate order allowed.
if (in_array($value, $orders, true)) { if (in_array($value, $orders)) {
return false; return false;
} }
@ -125,3 +108,17 @@ function load_plugin_parameter_values($plugins, $conf)
return $out; return $out;
} }
/**
* Exception used if an error occur while saving plugin configuration.
*/
class PluginConfigOrderException extends Exception
{
/**
* Construct exception.
*/
public function __construct()
{
$this->message = 'An error occurred while trying to save plugins loading order.';
}
}

View file

@ -1,22 +0,0 @@
<?php
namespace Shaarli\Config\Exception;
/**
* Exception used if a mandatory field is missing in given configuration.
*/
class MissingFieldConfigException extends \Exception
{
public $field;
/**
* Construct exception.
*
* @param string $field field name missing.
*/
public function __construct($field)
{
$this->field = $field;
$this->message = sprintf(t('Configuration value is required for %s'), $this->field);
}
}

View file

@ -1,17 +0,0 @@
<?php
namespace Shaarli\Config\Exception;
/**
* Exception used if an error occur while saving plugin configuration.
*/
class PluginConfigOrderException extends \Exception
{
/**
* Construct exception.
*/
public function __construct()
{
$this->message = t('An error occurred while trying to save plugins loading order.');
}
}

View file

@ -1,17 +0,0 @@
<?php
namespace Shaarli\Config\Exception;
/**
* Exception used if an unauthorized attempt to edit configuration has been made.
*/
class UnauthorizedConfigException extends \Exception
{
/**
* Construct exception.
*/
public function __construct()
{
$this->message = t('You are not authorized to alter config.');
}
}

View file

@ -1,176 +0,0 @@
<?php
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;
use Shaarli\Feed\FeedBuilder;
use Shaarli\Formatter\FormatterFactory;
use Shaarli\Front\Controller\Visitor\ErrorController;
use Shaarli\Front\Controller\Visitor\ErrorNotFoundController;
use Shaarli\History;
use Shaarli\Http\HttpAccess;
use Shaarli\Http\MetadataRetriever;
use Shaarli\Netscape\NetscapeBookmarkUtils;
use Shaarli\Plugin\PluginManager;
use Shaarli\Render\PageBuilder;
use Shaarli\Render\PageCacheManager;
use Shaarli\Security\CookieManager;
use Shaarli\Security\LoginManager;
use Shaarli\Security\SessionManager;
use Shaarli\Thumbnailer;
use Shaarli\Updater\Updater;
use Shaarli\Updater\UpdaterUtils;
/**
* Class ContainerBuilder
*
* Helper used to build a Slim container instance with Shaarli's object dependencies.
* Note that most injected objects MUST be added as closures, to let the container instantiate
* only the objects it requires during the execution.
*
* @package Container
*/
class ContainerBuilder
{
/** @var ConfigManager */
protected $conf;
/** @var SessionManager */
protected $session;
/** @var CookieManager */
protected $cookieManager;
/** @var LoginManager */
protected $login;
/** @var PluginManager */
protected $pluginManager;
/** @var LoggerInterface */
protected $logger;
/** @var string|null */
protected $basePath = null;
public function __construct(
ConfigManager $conf,
SessionManager $session,
CookieManager $cookieManager,
LoginManager $login,
PluginManager $pluginManager,
LoggerInterface $logger
) {
$this->conf = $conf;
$this->session = $session;
$this->login = $login;
$this->cookieManager = $cookieManager;
$this->pluginManager = $pluginManager;
$this->logger = $logger;
}
public function build(): ShaarliContainer
{
$container = new ShaarliContainer();
$container['conf'] = $this->conf;
$container['sessionManager'] = $this->session;
$container['cookieManager'] = $this->cookieManager;
$container['loginManager'] = $this->login;
$container['pluginManager'] = $this->pluginManager;
$container['logger'] = $this->logger;
$container['basePath'] = $this->basePath;
$container['history'] = function (ShaarliContainer $container): History {
return new History($container->conf->get('resource.history'));
};
$container['bookmarkService'] = function (ShaarliContainer $container): BookmarkServiceInterface {
return new BookmarkFileService(
$container->conf,
$container->pluginManager,
$container->history,
new FlockMutex(fopen(SHAARLI_MUTEX_FILE, 'r'), 2),
$container->loginManager->isLoggedIn()
);
};
$container['metadataRetriever'] = function (ShaarliContainer $container): MetadataRetriever {
return new MetadataRetriever($container->conf, $container->httpAccess);
};
$container['pageBuilder'] = function (ShaarliContainer $container): PageBuilder {
return new PageBuilder(
$container->conf,
$container->sessionManager->getSession(),
$container->logger,
$container->bookmarkService,
$container->sessionManager->generateToken(),
$container->loginManager->isLoggedIn()
);
};
$container['formatterFactory'] = function (ShaarliContainer $container): FormatterFactory {
return new FormatterFactory(
$container->conf,
$container->loginManager->isLoggedIn()
);
};
$container['pageCacheManager'] = function (ShaarliContainer $container): PageCacheManager {
return new PageCacheManager(
$container->conf->get('resource.page_cache'),
$container->loginManager->isLoggedIn()
);
};
$container['feedBuilder'] = function (ShaarliContainer $container): FeedBuilder {
return new FeedBuilder(
$container->bookmarkService,
$container->formatterFactory->getFormatter(),
$container->environment,
$container->loginManager->isLoggedIn()
);
};
$container['thumbnailer'] = function (ShaarliContainer $container): Thumbnailer {
return new Thumbnailer($container->conf);
};
$container['httpAccess'] = function (): HttpAccess {
return new HttpAccess();
};
$container['netscapeBookmarkUtils'] = function (ShaarliContainer $container): NetscapeBookmarkUtils {
return new NetscapeBookmarkUtils($container->bookmarkService, $container->conf, $container->history);
};
$container['updater'] = function (ShaarliContainer $container): Updater {
return new Updater(
UpdaterUtils::readUpdatesFile($container->conf->get('resource.updates')),
$container->bookmarkService,
$container->conf,
$container->loginManager->isLoggedIn()
);
};
$container['notFoundHandler'] = function (ShaarliContainer $container): ErrorNotFoundController {
return new ErrorNotFoundController($container);
};
$container['errorHandler'] = function (ShaarliContainer $container): ErrorController {
return new ErrorController($container);
};
$container['phpErrorHandler'] = function (ShaarliContainer $container): ErrorController {
return new ErrorController($container);
};
return $container;
}
}

View file

@ -1,54 +0,0 @@
<?php
declare(strict_types=1);
namespace Shaarli\Container;
use Psr\Log\LoggerInterface;
use Shaarli\Bookmark\BookmarkServiceInterface;
use Shaarli\Config\ConfigManager;
use Shaarli\Feed\FeedBuilder;
use Shaarli\Formatter\FormatterFactory;
use Shaarli\History;
use Shaarli\Http\HttpAccess;
use Shaarli\Http\MetadataRetriever;
use Shaarli\Netscape\NetscapeBookmarkUtils;
use Shaarli\Plugin\PluginManager;
use Shaarli\Render\PageBuilder;
use Shaarli\Render\PageCacheManager;
use Shaarli\Security\CookieManager;
use Shaarli\Security\LoginManager;
use Shaarli\Security\SessionManager;
use Shaarli\Thumbnailer;
use Shaarli\Updater\Updater;
use Slim\Container;
/**
* Extension of Slim container to document the injected objects.
*
* @property string $basePath Shaarli's instance base path (e.g. `/shaarli/`)
* @property BookmarkServiceInterface $bookmarkService
* @property CookieManager $cookieManager
* @property ConfigManager $conf
* @property mixed[] $environment $_SERVER automatically injected by Slim
* @property callable $errorHandler Overrides default Slim exception display
* @property FeedBuilder $feedBuilder
* @property FormatterFactory $formatterFactory
* @property History $history
* @property HttpAccess $httpAccess
* @property LoginManager $loginManager
* @property LoggerInterface $logger
* @property MetadataRetriever $metadataRetriever
* @property NetscapeBookmarkUtils $netscapeBookmarkUtils
* @property callable $notFoundHandler Overrides default Slim exception display
* @property PageBuilder $pageBuilder
* @property PageCacheManager $pageCacheManager
* @property callable $phpErrorHandler Overrides default Slim PHP error display
* @property PluginManager $pluginManager
* @property SessionManager $sessionManager
* @property Thumbnailer $thumbnailer
* @property Updater $updater
*/
class ShaarliContainer extends Container
{
}

View file

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

View file

@ -1,286 +0,0 @@
<?php
namespace Shaarli\Feed;
use DateTime;
use Shaarli\Bookmark\Bookmark;
use Shaarli\Bookmark\BookmarkServiceInterface;
use Shaarli\Formatter\BookmarkFormatter;
/**
* FeedBuilder class.
*
* Used to build ATOM and RSS feeds data.
*/
class FeedBuilder
{
/**
* @var string Constant: RSS feed type.
*/
public static $FEED_RSS = 'rss';
/**
* @var string Constant: ATOM feed type.
*/
public static $FEED_ATOM = 'atom';
/**
* @var string Default language if the locale isn't set.
*/
public static $DEFAULT_LANGUAGE = 'en-en';
/**
* @var int Number of bookmarks to display in a feed by default.
*/
public static $DEFAULT_NB_LINKS = 50;
/**
* @var BookmarkServiceInterface instance.
*/
protected $linkDB;
/**
* @var BookmarkFormatter instance.
*/
protected $formatter;
/** @var mixed[] $_SERVER */
protected $serverInfo;
/**
* @var boolean True if the user is currently logged in, false otherwise.
*/
protected $isLoggedIn;
/**
* @var boolean Use permalinks instead of direct bookmarks if true.
*/
protected $usePermalinks;
/**
* @var boolean true to hide dates in feeds.
*/
protected $hideDates;
/**
* @var string server locale.
*/
protected $locale;
/**
* @var DateTime Latest item date.
*/
protected $latestDate;
/**
* Feed constructor.
*
* @param BookmarkServiceInterface $linkDB LinkDB instance.
* @param BookmarkFormatter $formatter instance.
* @param array $serverInfo $_SERVER.
* @param boolean $isLoggedIn True if the user is currently logged in, false otherwise.
*/
public function __construct($linkDB, $formatter, $serverInfo, $isLoggedIn)
{
$this->linkDB = $linkDB;
$this->formatter = $formatter;
$this->serverInfo = $serverInfo;
$this->isLoggedIn = $isLoggedIn;
}
/**
* Build data for feed templates.
*
* @param string $feedType Type of feed (RSS/ATOM).
* @param array $userInput $_GET.
*
* @return array Formatted data for feeds templates.
*/
public function buildData(string $feedType, ?array $userInput)
{
// Search for untagged bookmarks
if (isset($this->userInput['searchtags']) && empty($userInput['searchtags'])) {
$userInput['searchtags'] = false;
}
$limit = $this->getLimit($userInput);
// Optionally filter the results:
$searchResult = $this->linkDB->search($userInput ?? [], null, false, false, true, ['limit' => $limit]);
$pageaddr = escape(index_url($this->serverInfo));
$this->formatter->addContextData('index_url', $pageaddr);
$links = [];
foreach ($searchResult->getBookmarks() as $key => $bookmark) {
$links[$key] = $this->buildItem($feedType, $bookmark, $pageaddr);
}
$data['language'] = $this->getTypeLanguage($feedType);
$data['last_update'] = $this->getLatestDateFormatted($feedType);
$data['show_dates'] = !$this->hideDates || $this->isLoggedIn;
// Remove leading path from REQUEST_URI (already contained in $pageaddr).
$requestUri = preg_replace('#(.*?/)(feed.*)#', '$2', escape($this->serverInfo['REQUEST_URI']));
$data['self_link'] = $pageaddr . $requestUri;
$data['index_url'] = $pageaddr;
$data['usepermalinks'] = $this->usePermalinks === true;
$data['links'] = $links;
return $data;
}
/**
* Set this to true to use permalinks instead of direct bookmarks.
*
* @param boolean $usePermalinks true to force permalinks.
*/
public function setUsePermalinks($usePermalinks)
{
$this->usePermalinks = $usePermalinks;
}
/**
* Set this to true to hide timestamps in feeds.
*
* @param boolean $hideDates true to enable.
*/
public function setHideDates($hideDates)
{
$this->hideDates = $hideDates;
}
/**
* Set the locale. Used to show feed language.
*
* @param string $locale The locale (eg. 'fr_FR.UTF8').
*/
public function setLocale($locale)
{
$this->locale = strtolower($locale);
}
/**
* Build a feed item (one per shaare).
*
* @param string $feedType Type of feed (RSS/ATOM).
* @param Bookmark $link Single link array extracted from LinkDB.
* @param string $pageaddr Index URL.
*
* @return array Link array with feed attributes.
*/
protected function buildItem(string $feedType, $link, $pageaddr)
{
$data = $this->formatter->format($link);
$data['guid'] = rtrim($pageaddr, '/') . '/shaare/' . $data['shorturl'];
if ($this->usePermalinks === true) {
$permalink = '<a href="' . $data['url'] . '" title="' . t('Direct link') . '">' . t('Direct link') . '</a>';
} else {
$permalink = '<a href="' . $data['guid'] . '" title="' . t('Permalink') . '">' . t('Permalink') . '</a>';
}
$data['description'] .= PHP_EOL . PHP_EOL . '<br>&#8212; ' . $permalink;
$data['pub_iso_date'] = $this->getIsoDate($feedType, $data['created']);
// atom:entry elements MUST contain exactly one atom:updated element.
if (!empty($link->getUpdated())) {
$data['up_iso_date'] = $this->getIsoDate($feedType, $data['updated'], DateTime::ATOM);
} else {
$data['up_iso_date'] = $this->getIsoDate($feedType, $data['created'], DateTime::ATOM);
}
// Save the more recent item.
if (empty($this->latestDate) || $this->latestDate < $data['created']) {
$this->latestDate = $data['created'];
}
if (!empty($data['updated']) && $this->latestDate < $data['updated']) {
$this->latestDate = $data['updated'];
}
return $data;
}
/**
* Get the language according to the feed type, based on the locale:
*
* - RSS format: en-us (default: 'en-en').
* - ATOM format: fr (default: 'en').
*
* @param string $feedType Type of feed (RSS/ATOM).
*
* @return string The language.
*/
protected function getTypeLanguage(string $feedType)
{
// Use the locale do define the language, if available.
if (!empty($this->locale) && preg_match('/^\w{2}[_\-]\w{2}/', $this->locale)) {
$length = ($feedType === self::$FEED_RSS) ? 5 : 2;
return str_replace('_', '-', substr($this->locale, 0, $length));
}
return ($feedType === self::$FEED_RSS) ? 'en-en' : 'en';
}
/**
* Format the latest item date found according to the feed type.
*
* Return an empty string if invalid DateTime is passed.
*
* @param string $feedType Type of feed (RSS/ATOM).
*
* @return string Formatted date.
*/
protected function getLatestDateFormatted(string $feedType)
{
if (empty($this->latestDate) || !$this->latestDate instanceof DateTime) {
return '';
}
$type = ($feedType == self::$FEED_RSS) ? DateTime::RSS : DateTime::ATOM;
return $this->latestDate->format($type);
}
/**
* Get ISO date from DateTime according to feed type.
*
* @param string $feedType Type of feed (RSS/ATOM).
* @param DateTime $date Date to format.
* @param string|bool $format Force format.
*
* @return string Formatted date.
*/
protected function getIsoDate(string $feedType, DateTime $date, $format = false)
{
if ($format !== false) {
return $date->format($format);
}
if ($feedType == self::$FEED_RSS) {
return $date->format(DateTime::RSS);
}
return $date->format(DateTime::ATOM);
}
/**
* Returns the number of link to display according to 'nb' user input parameter.
*
* If 'nb' not set or invalid, default value: $DEFAULT_NB_LINKS.
* If 'nb' is set to 'all', display all filtered bookmarks (max parameter).
*
* @param array $userInput $_GET.
*
* @return int number of bookmarks to display.
*/
protected function getLimit(?array $userInput)
{
if (empty($userInput['nb'])) {
return self::$DEFAULT_NB_LINKS;
}
if ($userInput['nb'] == 'all') {
return null;
}
$intNb = intval($userInput['nb']);
if (!is_int($intNb) || $intNb == 0) {
return self::$DEFAULT_NB_LINKS;
}
return $intNb;
}
}

View file

@ -1,229 +0,0 @@
<?php
namespace Shaarli\Formatter;
use Shaarli\Bookmark\Bookmark;
/**
* Class BookmarkDefaultFormatter
*
* Default bookmark formatter.
* Escape values for HTML display and automatically add link to URL and hashtags.
*
* @package Shaarli\Formatter
*/
class BookmarkDefaultFormatter extends BookmarkFormatter
{
public const SEARCH_HIGHLIGHT_OPEN = 'SHAARLI_O_HIGHLIGHT';
public const SEARCH_HIGHLIGHT_CLOSE = 'SHAARLI_C_HIGHLIGHT';
/**
* @inheritdoc
*/
protected function formatTitle($bookmark)
{
return escape($bookmark->getTitle());
}
/**
* @inheritdoc
*/
protected function formatTitleHtml($bookmark)
{
$title = $this->tokenizeSearchHighlightField(
$bookmark->getTitle() ?? '',
$bookmark->getAdditionalContentEntry('search_highlight')['title'] ?? []
);
return $this->replaceTokens(escape($title));
}
/**
* @inheritdoc
*/
protected function formatDescription($bookmark)
{
$indexUrl = ! empty($this->contextData['index_url']) ? $this->contextData['index_url'] : '';
$description = $this->tokenizeSearchHighlightField(
$bookmark->getDescription() ?? '',
$bookmark->getAdditionalContentEntry('search_highlight')['description'] ?? []
);
$description = format_description(
escape($description),
$indexUrl,
$this->conf->get('formatter_settings.autolink', true)
);
return $this->replaceTokens($description);
}
/**
* @inheritdoc
*/
protected function formatTagList($bookmark)
{
return escape(parent::formatTagList($bookmark));
}
/**
* @inheritdoc
*/
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($tagsSeparator),
$bookmark->getAdditionalContentEntry('search_highlight')['tags']
);
$tags = $this->filterTagList(tags_str2array($tags, $tagsSeparator));
$tags = escape($tags);
$tags = $this->replaceTokensArray($tags);
return $tags;
}
/**
* @inheritdoc
*/
protected function formatTagString($bookmark)
{
return implode($this->conf->get('general.tags_separator'), $this->formatTagList($bookmark));
}
/**
* @inheritdoc
*/
protected function formatUrl($bookmark)
{
if ($bookmark->isNote() && isset($this->contextData['index_url'])) {
return rtrim($this->contextData['index_url'], '/') . '/' . escape(ltrim($bookmark->getUrl(), '/'));
}
return escape($bookmark->getUrl());
}
/**
* @inheritdoc
*/
protected function formatRealUrl($bookmark)
{
if ($bookmark->isNote()) {
if (isset($this->contextData['index_url'])) {
$prefix = rtrim($this->contextData['index_url'], '/') . '/';
}
if (isset($this->contextData['base_path'])) {
$prefix = rtrim($this->contextData['base_path'], '/') . '/';
}
return escape($prefix ?? '') . escape(ltrim($bookmark->getUrl() ?? '', '/'));
}
return escape($bookmark->getUrl());
}
/**
* @inheritdoc
*/
protected function formatUrlHtml($bookmark)
{
$url = $this->tokenizeSearchHighlightField(
$bookmark->getUrl() ?? '',
$bookmark->getAdditionalContentEntry('search_highlight')['url'] ?? []
);
return $this->replaceTokens(escape($url));
}
/**
* @inheritdoc
*/
protected function formatThumbnail($bookmark)
{
return escape($bookmark->getThumbnail());
}
/**
* @inheritDoc
*/
protected function formatAdditionalContent(Bookmark $bookmark): array
{
$additionalContent = parent::formatAdditionalContent($bookmark);
unset($additionalContent['search_highlight']);
return $additionalContent;
}
/**
* Insert search highlight token in provided field content based on a list of search result positions
*
* @param string $fieldContent
* @param array|null $positions List of of search results with 'start' and 'end' positions.
*
* @return string Updated $fieldContent.
*/
protected function tokenizeSearchHighlightField(string $fieldContent, ?array $positions): string
{
if (empty($positions)) {
return $fieldContent;
}
$insertedTokens = 0;
$tokenLength = strlen(static::SEARCH_HIGHLIGHT_OPEN);
foreach ($positions as $position) {
$position = [
'start' => $position['start'] + ($insertedTokens * $tokenLength),
'end' => $position['end'] + ($insertedTokens * $tokenLength),
];
$content = mb_substr($fieldContent, 0, $position['start']);
$content .= static::SEARCH_HIGHLIGHT_OPEN;
$content .= mb_substr($fieldContent, $position['start'], $position['end'] - $position['start']);
$content .= static::SEARCH_HIGHLIGHT_CLOSE;
$content .= mb_substr($fieldContent, $position['end']);
$fieldContent = $content;
$insertedTokens += 2;
}
return $fieldContent;
}
/**
* Replace search highlight tokens with HTML highlighted span.
*
* @param string $fieldContent
*
* @return string updated content.
*/
protected function replaceTokens(string $fieldContent): string
{
return str_replace(
[static::SEARCH_HIGHLIGHT_OPEN, static::SEARCH_HIGHLIGHT_CLOSE],
['<span class="search-highlight">', '</span>'],
$fieldContent
);
}
/**
* Apply replaceTokens to an array of content strings.
*
* @param string[] $fieldContents
*
* @return array
*/
protected function replaceTokensArray(array $fieldContents): array
{
foreach ($fieldContents as &$entry) {
$entry = $this->replaceTokens($entry);
}
return $fieldContents;
}
}

View file

@ -1,390 +0,0 @@
<?php
namespace Shaarli\Formatter;
use DateTimeInterface;
use Shaarli\Bookmark\Bookmark;
use Shaarli\Config\ConfigManager;
/**
* Class BookmarkFormatter
*
* Abstract class processing all bookmark attributes through methods designed to be overridden.
*
* List of available formatted fields:
* - id ID
* - shorturl Unique identifier, used in permalinks
* - url URL, can be altered in some way, e.g. passing through an HTTP reverse proxy
* - real_url (legacy) same as `url`
* - url_html URL to be displayed in HTML content (it can contain HTML tags)
* - title Title
* - title_html Title to be displayed in HTML content (it can contain HTML tags)
* - description Description content. It most likely contains HTML tags
* - thumbnail Thumbnail: path to local cache file, false if there is none, null if hasn't been retrieved
* - taglist List of tags (array)
* - taglist_urlencoded List of tags (array) URL encoded: it must be used to create a link to a URL containing a tag
* - taglist_html List of tags (array) to be displayed in HTML content (it can contain HTML tags)
* - tags Tags separated by a single whitespace
* - tags_urlencoded Tags separated by a single whitespace, URL encoded: must be used to create a link
* - sticky Is sticky (bool)
* - private Is private (bool)
* - class Additional CSS class
* - created Creation DateTime
* - updated Last edit DateTime
* - timestamp Creation timestamp
* - updated_timestamp Last edit timestamp
*
* @package Shaarli\Formatter
*/
abstract class BookmarkFormatter
{
/**
* @var ConfigManager
*/
protected $conf;
/** @var bool */
protected $isLoggedIn;
/**
* @var array Additional parameters than can be used for specific formatting
* e.g. index_url for Feed formatting
*/
protected $contextData = [];
/**
* LinkDefaultFormatter constructor.
* @param ConfigManager $conf
*/
public function __construct(ConfigManager $conf, bool $isLoggedIn)
{
$this->conf = $conf;
$this->isLoggedIn = $isLoggedIn;
}
/**
* Convert a Bookmark into an array usable by templates and plugins.
*
* All Bookmark attributes are formatted through a format method
* that can be overridden in a formatter extending this class.
*
* @param Bookmark $bookmark instance
*
* @return array formatted representation of a Bookmark
*/
public function format($bookmark)
{
$out['id'] = $this->formatId($bookmark);
$out['shorturl'] = $this->formatShortUrl($bookmark);
$out['url'] = $this->formatUrl($bookmark);
$out['real_url'] = $this->formatRealUrl($bookmark);
$out['url_html'] = $this->formatUrlHtml($bookmark);
$out['title'] = $this->formatTitle($bookmark);
$out['title_html'] = $this->formatTitleHtml($bookmark);
$out['description'] = $this->formatDescription($bookmark);
$out['thumbnail'] = $this->formatThumbnail($bookmark);
$out['taglist'] = $this->formatTagList($bookmark);
$out['taglist_urlencoded'] = $this->formatTagListUrlEncoded($bookmark);
$out['taglist_html'] = $this->formatTagListHtml($bookmark);
$out['tags'] = $this->formatTagString($bookmark);
$out['tags_urlencoded'] = $this->formatTagStringUrlEncoded($bookmark);
$out['sticky'] = $bookmark->isSticky();
$out['private'] = $bookmark->isPrivate();
$out['class'] = $this->formatClass($bookmark);
$out['created'] = $this->formatCreated($bookmark);
$out['updated'] = $this->formatUpdated($bookmark);
$out['timestamp'] = $this->formatCreatedTimestamp($bookmark);
$out['updated_timestamp'] = $this->formatUpdatedTimestamp($bookmark);
$out['additional_content'] = $this->formatAdditionalContent($bookmark);
return $out;
}
/**
* Add additional data available to formatters.
* This is used for example to add `index_url` in description's links.
*
* @param string $key Context data key
* @param string $value Context data value
*/
public function addContextData($key, $value)
{
$this->contextData[$key] = $value;
return $this;
}
/**
* Format ID
*
* @param Bookmark $bookmark instance
*
* @return int formatted ID
*/
protected function formatId($bookmark)
{
return $bookmark->getId();
}
/**
* Format ShortUrl
*
* @param Bookmark $bookmark instance
*
* @return string formatted ShortUrl
*/
protected function formatShortUrl($bookmark)
{
return $bookmark->getShortUrl();
}
/**
* Format Url
*
* @param Bookmark $bookmark instance
*
* @return string formatted Url
*/
protected function formatUrl($bookmark)
{
return $bookmark->getUrl();
}
/**
* Format RealUrl
* Legacy: identical to Url
*
* @param Bookmark $bookmark instance
*
* @return string formatted RealUrl
*/
protected function formatRealUrl($bookmark)
{
return $this->formatUrl($bookmark);
}
/**
* Format Url Html: to be displayed in HTML content, it can contains HTML tags.
*
* @param Bookmark $bookmark instance
*
* @return string formatted Url HTML
*/
protected function formatUrlHtml($bookmark)
{
return $this->formatUrl($bookmark);
}
/**
* Format Title
*
* @param Bookmark $bookmark instance
*
* @return string formatted Title
*/
protected function formatTitle($bookmark)
{
return $bookmark->getTitle();
}
/**
* Format Title HTML: to be displayed in HTML content, it can contains HTML tags.
*
* @param Bookmark $bookmark instance
*
* @return string formatted Title
*/
protected function formatTitleHtml($bookmark)
{
return $bookmark->getTitle();
}
/**
* Format Description
*
* @param Bookmark $bookmark instance
*
* @return string formatted Description
*/
protected function formatDescription($bookmark)
{
return $bookmark->getDescription();
}
/**
* Format Thumbnail
*
* @param Bookmark $bookmark instance
*
* @return string formatted Thumbnail
*/
protected function formatThumbnail($bookmark)
{
return $bookmark->getThumbnail();
}
/**
* Format Tags
*
* @param Bookmark $bookmark instance
*
* @return array formatted Tags
*/
protected function formatTagList($bookmark)
{
return $this->filterTagList($bookmark->getTags());
}
/**
* Format Url Encoded Tags
*
* @param Bookmark $bookmark instance
*
* @return array formatted Tags
*/
protected function formatTagListUrlEncoded($bookmark)
{
return array_map('urlencode', $this->filterTagList($bookmark->getTags()));
}
/**
* Format Tags HTML: to be displayed in HTML content, it can contains HTML tags.
*
* @param Bookmark $bookmark instance
*
* @return array formatted Tags
*/
protected function formatTagListHtml($bookmark)
{
return $this->formatTagList($bookmark);
}
/**
* Format TagString
*
* @param Bookmark $bookmark instance
*
* @return string formatted TagString
*/
protected function formatTagString($bookmark)
{
return implode($this->conf->get('general.tags_separator', ' '), $this->formatTagList($bookmark));
}
/**
* Format TagString
*
* @param Bookmark $bookmark instance
*
* @return string formatted TagString
*/
protected function formatTagStringUrlEncoded($bookmark)
{
return implode(' ', $this->formatTagListUrlEncoded($bookmark));
}
/**
* Format Class
* Used to add specific CSS class for a link
*
* @param Bookmark $bookmark instance
*
* @return string formatted Class
*/
protected function formatClass($bookmark)
{
return $bookmark->isPrivate() ? 'private' : '';
}
/**
* Format Created
*
* @param Bookmark $bookmark instance
*
* @return DateTimeInterface instance
*/
protected function formatCreated(Bookmark $bookmark)
{
return $bookmark->getCreated();
}
/**
* Format Updated
*
* @param Bookmark $bookmark instance
*
* @return DateTimeInterface instance
*/
protected function formatUpdated(Bookmark $bookmark)
{
return $bookmark->getUpdated();
}
/**
* Format CreatedTimestamp
*
* @param Bookmark $bookmark instance
*
* @return int formatted CreatedTimestamp
*/
protected function formatCreatedTimestamp(Bookmark $bookmark)
{
if (! empty($bookmark->getCreated())) {
return $bookmark->getCreated()->getTimestamp();
}
return 0;
}
/**
* Format UpdatedTimestamp
*
* @param Bookmark $bookmark instance
*
* @return int formatted UpdatedTimestamp
*/
protected function formatUpdatedTimestamp(Bookmark $bookmark)
{
if (! empty($bookmark->getUpdated())) {
return $bookmark->getUpdated()->getTimestamp();
}
return 0;
}
/**
* Format bookmark's additional content
*
* @param Bookmark $bookmark instance
*
* @return mixed[]
*/
protected function formatAdditionalContent(Bookmark $bookmark): array
{
return $bookmark->getAdditionalContent();
}
/**
* 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
*
* @return array
*/
protected function filterTagList(array $tags): array
{
if ($this->isLoggedIn === true) {
return $tags;
}
$out = [];
foreach ($tags as $tag) {
if (strpos($tag, '.') === 0) {
continue;
}
$out[] = $tag;
}
return $out;
}
}

View file

@ -1,24 +0,0 @@
<?php
namespace Shaarli\Formatter;
use Shaarli\Config\ConfigManager;
use Shaarli\Formatter\Parsedown\ShaarliParsedownExtra;
/**
* Class BookmarkMarkdownExtraFormatter
*
* Format bookmark description into MarkdownExtra format.
*
* @see https://michelf.ca/projects/php-markdown/extra/
*
* @package Shaarli\Formatter
*/
class BookmarkMarkdownExtraFormatter extends BookmarkMarkdownFormatter
{
public function __construct(ConfigManager $conf, bool $isLoggedIn)
{
parent::__construct($conf, $isLoggedIn);
$this->parsedown = new ShaarliParsedownExtra();
}
}

View file

@ -1,221 +0,0 @@
<?php
namespace Shaarli\Formatter;
use Shaarli\Config\ConfigManager;
use Shaarli\Formatter\Parsedown\ShaarliParsedown;
/**
* Class BookmarkMarkdownFormatter
*
* Format bookmark description into Markdown format.
*
* @package Shaarli\Formatter
*/
class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter
{
/**
* When this tag is present in a bookmark, its description should not be processed with Markdown
*/
public const NO_MD_TAG = 'nomarkdown';
/** @var \Parsedown instance */
protected $parsedown;
/** @var bool used to escape HTML in Markdown or not.
* It MUST be set to true for shared instance as HTML content can
* introduce XSS vulnerabilities.
*/
protected $escape;
/**
* @var array List of allowed protocols for links inside bookmark's description.
*/
protected $allowedProtocols;
/**
* LinkMarkdownFormatter constructor.
*
* @param ConfigManager $conf instance
* @param bool $isLoggedIn
*/
public function __construct(ConfigManager $conf, bool $isLoggedIn)
{
parent::__construct($conf, $isLoggedIn);
$this->parsedown = new ShaarliParsedown();
$this->escape = $conf->get('security.markdown_escape', true);
$this->allowedProtocols = $conf->get('security.allowed_protocols', []);
}
/**
* @inheritdoc
*/
public function formatDescription($bookmark)
{
if (in_array(self::NO_MD_TAG, $bookmark->getTags())) {
return parent::formatDescription($bookmark);
}
$processedDescription = $this->tokenizeSearchHighlightField(
$bookmark->getDescription() ?? '',
$bookmark->getAdditionalContentEntry('search_highlight')['description'] ?? []
);
$processedDescription = $this->filterProtocols($processedDescription);
$processedDescription = $this->formatHashTags($processedDescription);
$processedDescription = $this->reverseEscapedHtml($processedDescription);
$processedDescription = $this->parsedown
->setMarkupEscaped($this->escape)
->setBreaksEnabled(true)
->text($processedDescription);
$processedDescription = $this->sanitizeHtml($processedDescription);
$processedDescription = $this->replaceTokens($processedDescription);
if (!empty($processedDescription)) {
$processedDescription = '<div class="markdown">' . $processedDescription . '</div>';
}
return $processedDescription;
}
/**
* Remove the NO markdown tag if it is present
*
* @inheritdoc
*/
protected function formatTagList($bookmark)
{
$out = parent::formatTagList($bookmark);
if ($this->isLoggedIn === false && ($pos = array_search(self::NO_MD_TAG, $out)) !== false) {
unset($out[$pos]);
return array_values($out);
}
return $out;
}
/**
* Replace not whitelisted protocols with http:// in given description.
* Also adds `index_url` to relative links if it's specified
*
* @param string $description input description text.
*
* @return string $description without malicious link.
*/
protected function filterProtocols($description)
{
$allowedProtocols = $this->allowedProtocols;
$indexUrl = ! empty($this->contextData['index_url']) ? $this->contextData['index_url'] : '';
return preg_replace_callback(
'#]\((.*?)\)#is',
function ($match) use ($allowedProtocols, $indexUrl) {
$link = startsWith($match[1], '?') || startsWith($match[1], '/') ? $indexUrl : '';
$link .= whitelist_protocols($match[1], $allowedProtocols);
return '](' . $link . ')';
},
$description
);
}
/**
* Replace hashtag in Markdown links format
* E.g. `#hashtag` becomes `[#hashtag](./add-tag/hashtag)`
* It includes the index URL if specified.
*
* @param string $description
*
* @return string
*/
protected function formatHashTags($description)
{
$indexUrl = ! empty($this->contextData['index_url']) ? $this->contextData['index_url'] : '';
$tokens = '(?:' . BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_OPEN . ')' .
'(?:' . BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_CLOSE . ')'
;
/*
* To support unicode: http://stackoverflow.com/a/35498078/1484919
* \p{Pc} - to match underscore
* \p{N} - numeric character in any script
* \p{L} - letter from any language
* \p{Mn} - any non marking space (accents, umlauts, etc)
*/
$regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}' . $tokens . ']+)/mui';
$replacement = function (array $match) use ($indexUrl): string {
$cleanMatch = str_replace(
BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_OPEN,
'',
str_replace(BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_CLOSE, '', $match[2])
);
return $match[1] . '[#' . $match[2] . '](' . $indexUrl . './add-tag/' . $cleanMatch . ')';
};
$descriptionLines = explode(PHP_EOL, $description);
$descriptionOut = '';
$codeBlockOn = false;
$lineCount = 0;
foreach ($descriptionLines as $descriptionLine) {
// Detect line of code: starting with 4 spaces,
// except lists which can start with +/*/- or `2.` after spaces.
$codeLineOn = preg_match('/^ +(?=[^\+\*\-])(?=(?!\d\.).)/', $descriptionLine) > 0;
// Detect and toggle block of code
if (!$codeBlockOn) {
$codeBlockOn = preg_match('/^```/', $descriptionLine) > 0;
} elseif (preg_match('/^```/', $descriptionLine) > 0) {
$codeBlockOn = false;
}
if (!$codeBlockOn && !$codeLineOn) {
$descriptionLine = preg_replace_callback($regex, $replacement, $descriptionLine);
}
$descriptionOut .= $descriptionLine;
if ($lineCount++ < count($descriptionLines) - 1) {
$descriptionOut .= PHP_EOL;
}
}
return $descriptionOut;
}
/**
* Remove dangerous HTML tags (tags, iframe, etc.).
* Doesn't affect <code> content (already escaped by Parsedown).
*
* @param string $description input description text.
*
* @return string given string escaped.
*/
protected function sanitizeHtml($description)
{
$escapeTags = [
'script',
'style',
'link',
'iframe',
'frameset',
'frame',
];
foreach ($escapeTags as $tag) {
$description = preg_replace_callback(
'#<\s*' . $tag . '[^>]*>(.*</\s*' . $tag . '[^>]*>)?#is',
function ($match) {
return escape($match[0]);
},
$description
);
}
$description = preg_replace(
'#(<[^>]+\s)on[a-z]*="?[^ "]*"?#is',
'$1',
$description
);
return $description;
}
protected function reverseEscapedHtml($description)
{
return unescape($description);
}
}

View file

@ -1,15 +0,0 @@
<?php
namespace Shaarli\Formatter;
/**
* Class BookmarkRawFormatter
*
* Used to retrieve bookmarks as array with raw values.
* Warning: Do NOT use this for HTML content as it can introduce XSS vulnerabilities.
*
* @package Shaarli\Formatter
*/
class BookmarkRawFormatter extends BookmarkFormatter
{
}

View file

@ -1,51 +0,0 @@
<?php
namespace Shaarli\Formatter;
use Shaarli\Config\ConfigManager;
/**
* Class FormatterFactory
*
* Helper class used to instantiate the proper BookmarkFormatter.
*
* @package Shaarli\Formatter
*/
class FormatterFactory
{
/** @var ConfigManager instance */
protected $conf;
/** @var bool */
protected $isLoggedIn;
/**
* FormatterFactory constructor.
*
* @param ConfigManager $conf
* @param bool $isLoggedIn
*/
public function __construct(ConfigManager $conf, bool $isLoggedIn)
{
$this->conf = $conf;
$this->isLoggedIn = $isLoggedIn;
}
/**
* Instanciate a BookmarkFormatter depending on the configuration or provided formatter type.
*
* @param string|null $type force a specific type regardless of the configuration
*
* @return BookmarkFormatter instance.
*/
public function getFormatter(string $type = null): BookmarkFormatter
{
$type = $type ? $type : $this->conf->get('formatter', 'default');
$className = '\\Shaarli\\Formatter\\Bookmark' . ucfirst($type) . 'Formatter';
if (!class_exists($className)) {
$className = '\\Shaarli\\Formatter\\BookmarkDefaultFormatter';
}
return new $className($this->conf, $this->isLoggedIn);
}
}

View file

@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
namespace Shaarli\Formatter\Parsedown;
/**
* Parsedown extension for Shaarli.
*
* Extension for both Parsedown and ParsedownExtra centralized in ShaarliParsedownTrait.
*/
class ShaarliParsedown extends \Parsedown
{
use ShaarliParsedownTrait;
}

View file

@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
namespace Shaarli\Formatter\Parsedown;
/**
* ParsedownExtra extension for Shaarli.
*
* Extension for both Parsedown and ParsedownExtra centralized in ShaarliParsedownTrait.
*/
class ShaarliParsedownExtra extends \ParsedownExtra
{
use ShaarliParsedownTrait;
}

View file

@ -1,81 +0,0 @@
<?php
declare(strict_types=1);
namespace Shaarli\Formatter\Parsedown;
use Shaarli\Formatter\BookmarkDefaultFormatter as Formatter;
/**
* Trait used for Parsedown and ParsedownExtra extension.
*
* Extended:
* - Format links properly in search context
*/
trait ShaarliParsedownTrait
{
/**
* @inheritDoc
*/
protected function inlineLink($excerpt)
{
return $this->shaarliFormatLink(parent::inlineLink($excerpt), true);
}
/**
* @inheritDoc
*/
protected function inlineUrl($excerpt)
{
return $this->shaarliFormatLink(parent::inlineUrl($excerpt), false);
}
/**
* Properly format markdown link:
* - remove highlight tags from HREF attribute
* - (optional) add highlight tags to link caption
*
* @param array|null $link Parsedown formatted link array.
* It can be empty.
* @param bool $fullWrap Add highlight tags the whole link caption
*
* @return array|null
*/
protected function shaarliFormatLink(?array $link, bool $fullWrap): ?array
{
// If open and clean search tokens are found in the link, process.
if (
is_array($link)
&& strpos($link['element']['attributes']['href'] ?? '', Formatter::SEARCH_HIGHLIGHT_OPEN) !== false
&& strpos($link['element']['attributes']['href'] ?? '', Formatter::SEARCH_HIGHLIGHT_CLOSE) !== false
) {
$link['element']['attributes']['href'] = $this->shaarliRemoveSearchTokens(
$link['element']['attributes']['href']
);
if ($fullWrap) {
$link['element']['text'] = Formatter::SEARCH_HIGHLIGHT_OPEN .
$link['element']['text'] .
Formatter::SEARCH_HIGHLIGHT_CLOSE
;
}
}
return $link;
}
/**
* Remove open and close tags from provided string.
*
* @param string $entry input
*
* @return string Striped input
*/
protected function shaarliRemoveSearchTokens(string $entry): string
{
$entry = str_replace(Formatter::SEARCH_HIGHLIGHT_OPEN, '', $entry);
$entry = str_replace(Formatter::SEARCH_HIGHLIGHT_CLOSE, '', $entry);
return $entry;
}
}

View file

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

View file

@ -1,116 +0,0 @@
<?php
namespace Shaarli\Front;
use Shaarli\Container\ShaarliContainer;
use Shaarli\Front\Exception\UnauthorizedException;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Class ShaarliMiddleware
*
* This will be called before accessing any Shaarli controller.
*/
class ShaarliMiddleware
{
/** @var ShaarliContainer contains all Shaarli DI */
protected $container;
public function __construct(ShaarliContainer $container)
{
$this->container = $container;
}
/**
* Middleware execution:
* - run updates
* - if not logged in open shaarli, redirect to login
* - execute the controller
* - return the response
*
* In case of error, the error template will be displayed with the exception message.
*
* @param Request $request Slim request
* @param Response $response Slim response
* @param callable $next Next action
*
* @return Response response.
*/
public function __invoke(Request $request, Response $response, callable $next): Response
{
$this->initBasePath($request);
try {
if (
!is_file($this->container->conf->getConfigFileExt())
&& !in_array($next->getName(), ['displayInstall', 'saveInstall'], true)
) {
return $response->withRedirect($this->container->basePath . '/install');
}
$this->runUpdates();
$this->checkOpenShaarli($request, $response, $next);
return $next($request, $response);
} catch (UnauthorizedException $e) {
$returnUrl = urlencode($this->container->environment['REQUEST_URI']);
return $response->withRedirect($this->container->basePath . '/login?returnurl=' . $returnUrl);
}
// Other exceptions are handled by ErrorController
}
/**
* Run the updater for every requests processed while logged in.
*/
protected function runUpdates(): void
{
if ($this->container->loginManager->isLoggedIn() !== true) {
return;
}
$this->container->updater->setBasePath($this->container->basePath);
$newUpdates = $this->container->updater->update();
if (!empty($newUpdates)) {
$this->container->updater->writeUpdates(
$this->container->conf->get('resource.updates'),
$this->container->updater->getDoneUpdates()
);
$this->container->pageCacheManager->invalidateCaches();
}
}
/**
* Access is denied to most pages with `hide_public_links` + `force_login` settings.
*/
protected function checkOpenShaarli(Request $request, Response $response, callable $next): bool
{
if (
// if the user isn't logged in
!$this->container->loginManager->isLoggedIn()
// and Shaarli doesn't have public content...
&& $this->container->conf->get('privacy.hide_public_links')
// and is configured to enforce the login
&& $this->container->conf->get('privacy.force_login')
// and the current page isn't already the login page
// and the user is not requesting a feed (which would lead to a different content-type as expected)
&& !in_array($next->getName(), ['login', 'processLogin', 'atom', 'rss'], true)
) {
throw new UnauthorizedException();
}
return true;
}
/**
* Initialize the URL base path if it hasn't been defined yet.
*/
protected function initBasePath(Request $request): void
{
if (null === $this->container->basePath) {
$this->container->basePath = rtrim($request->getUri()->getBasePath(), '/');
}
}
}

View file

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

View file

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

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