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
619 changed files with 27556 additions and 61516 deletions

30
.gitattributes vendored Normal file
View file

@ -0,0 +1,30 @@
# Set default behavior
* text=auto eol=lf
# Ensure sources are processed
*.conf text
*.css text
*.html text diff=html
*.js text
*.md text
*.php text diff=php
Dockerfile text
# Do not alter images nor minified scripts
*.ico binary
*.jpg binary
*.png binary
*.min.css binary
*.min.js binary
# Exclude from Git archives
.gitattributes export-ignore
.gitignore export-ignore
.travis.yml export-ignore
doc/**/*.json export-ignore
doc/**/*.md export-ignore
docker/ export-ignore
Doxyfile export-ignore
Makefile export-ignore
phpunit.xml export-ignore
tests/ export-ignore

50
.gitignore vendored
View file

@ -13,62 +13,18 @@ pagecache
*.rtpl.php
# 3rd-party dependencies
composer.lock
vendor/
# Release archives
*.tar.gz
*.tar
*.zip
inc/languages/*/LC_MESSAGES/shaarli.mo
# Development and test resources
coverage
doxygen
sandbox
phpmd.html
phpdoc.xml
.phpunit.result.cache
trivy
# User plugin configuration
plugins/*
!addlink_toolbar
!archiveorg
!default_colors
!demo_plugin
!isso
!myShaarli
!piwik
!playvideos
!pubsubhubbub
!qrcode
!wallabag
plugins/*/config.php
plugins/default_colors/default_colors.css
# HTML documentation
doc/html/
doc/phpdoc/
doc/
# 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

119
AUTHORS
View file

@ -1,119 +0,0 @@
1221 ArthurHoaro <arthur@hoa.ro>
518 nodiscc <nodiscc@gmail.com>
405 VirtualTam <virtualtam@flibidi.net>
56 Sébastien Sauvage <sebsauvage@sebsauvage.net>
31 Keith Carangelo <mail@kcaran.com>
28 dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.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>
7 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 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 David Sferruzza <david.sferruzza@gmail.com>
4 yude <yudesleepy@gmail.com>
3 Agurato <mail.vmonot@gmail.com>
3 Andreas Waschinski <25221082+waschinski@users.noreply.github.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 Robert Riebisch <15858666+bttrx@users.noreply.github.com>
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 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 7Ds7 <7Ds7@users.noreply.github.com>
1 Adrien Oliva <adrien.oliva@yapbreak.fr>
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 Henschi <to@h6l.de>
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 Martin Puppe <dev@mpuppe.de>
1 Mickaël Schoentgen <contact@tiger-222.fr>
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 Thibaud CANALE <thican@thican.net>
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 sokai <sokai@users.noreply.github.com>
1 sprak3000 <sprak3000+github@gmail.com>
1 yudejp <i@yude.jp>

File diff suppressed because it is too large Load diff

View file

@ -13,14 +13,18 @@ Check the [milestones](https://github.com/shaarli/Shaarli/milestones) to see wha
* The issues list should preferably contain **only tasks that can be actioned immediately**. Anyone should be able to open the issues list, pick one and start working on it immediately.
* If you have a clear idea of a **feature you expect, or have a specific bug/defect to report**, [search the issues list, both open and closed](https://github.com/shaarli/Shaarli/issues?q=is%3Aissue) to check if it has been discussed, and comment on the appropriate issue. If you can't find one, please open a [new issue](https://github.com/shaarli/Shaarli/issues/new)
* The **[general discussion](https://github.com/shaarli/Shaarli/issues/308)** issue can be used for general announcements or project-related discussion.
* You can also join instant discussion at https://gitter.im/shaarli/Shaarli.
* **General discussions** fit in #44 so that we don't follow a slope where users and contributors have to track 90 "maybe" items in the bug tracker. Separate issues about clear, separate steps can be opened after discussion.
* 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
**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
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`)
* 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`)
* 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.
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: *
License: zlib/libpng
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)
Copyright: (c) 2010, Yahoo! Inc.
Files: assets/vintage/img/calendar.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
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
License: CC-BY (http://creativecommons.org/licenses/by/3.0/)
Copyright: (c) 2014 Yusuke Kamiyamane
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/)
Copyright: (c) 2014 Designmodo
Source: http://designmodo.com/linecons-free/
Files: assets/vintage/img/floral_left.png
assets/vintage/img/floral_right.png
assets/vintage/img/squiggle.png
assets/vintage/img/squiggle_closing.png
Files: images/floral_left.png, images/floral_right.png, images/squiggle.png, images/squiggle2.png, images/squiggle_closing.png
Licence: Public Domain
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
Source: http://bashcorpo.deviantart.com/art/Grungy-paper-texture-v-5-22966998
Files: assets/vintage/img/logo.png
assets/vintage/img/logo.png
Files: images/logo.png
License: zlib/libpng
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)
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
License: LGPL-3+ (https://www.gnu.org/licenses/lgpl-3.0.txt)
Copyright: 2011-2012, Federico Ulfo <rainelemental@gmail.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
License: MIT License (http://opensource.org/licenses/MIT)

2376
Doxyfile Normal file

File diff suppressed because it is too large Load diff

217
Makefile Normal file
View file

@ -0,0 +1,217 @@
# The personal, minimalist, super-fast, database free, bookmarking service.
# 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
PHP_SOURCE = index.php application tests plugins
PHP_COMMA_SOURCE = index.php,application,tests,plugins
all: static_analysis_summary check_permissions test
##
# Concise status of the project
# These targets are non-blocking: || exit 0
##
static_analysis_summary: code_sniffer_source copy_paste mess_detector_summary
@echo
##
# PHP_CodeSniffer
# Detects PHP syntax errors
# Documentation (usage, output formatting):
# - http://pear.php.net/manual/en/package.php.php-codesniffer.usage.php
# - http://pear.php.net/manual/en/package.php.php-codesniffer.reporting.php
##
code_sniffer: code_sniffer_full
### - errors filtered by coding standard: PEAR, PSR1, PSR2, Zend...
PHPCS_%:
@$(BIN)/phpcs $(PHP_SOURCE) --report-full --report-width=200 --standard=$*
### - errors by Git author
code_sniffer_blame:
@$(BIN)/phpcs $(PHP_SOURCE) --report-gitblame
### - all errors/warnings
code_sniffer_full:
@$(BIN)/phpcs $(PHP_SOURCE) --report-full --report-width=200
### - errors grouped by kind
code_sniffer_source:
@$(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
##
check_permissions:
@echo "----------------------"
@echo "Check file permissions"
@echo "----------------------"
@for file in `git ls-files`; do \
if [ -x $$file ]; then \
errors=true; \
echo "$${file} is executable"; \
fi \
done; [ -z $$errors ] || false
##
# PHPUnit
# Runs unitary and functional tests
# Generates an HTML coverage report if Xdebug is enabled
#
# See phpunit.xml for configuration
# https://phpunit.de/manual/current/en/appendixes.configuration.html
##
test:
@echo "-------"
@echo "PHPUNIT"
@echo "-------"
@mkdir -p sandbox
@$(BIN)/phpunit tests
##
# Custom release archive generation
#
# For each tagged revision, GitHub provides tar and zip archives that correspond
# to the output of git-archive
#
# These targets produce similar archives, featuring 3rd-party dependencies
# to ease deployment on shared hosting.
##
ARCHIVE_VERSION := shaarli-$$(git describe)-full
ARCHIVE_PREFIX=Shaarli/
release_archive: release_tar release_zip
### download 3rd-party PHP libraries
composer_dependencies: clean
composer update --no-dev
find vendor/ -name ".git" -type d -exec rm -rf {} +
### generate a release tarball and include 3rd-party dependencies
release_tar: composer_dependencies
git archive --prefix=$(ARCHIVE_PREFIX) -o $(ARCHIVE_VERSION).tar HEAD
tar rvf $(ARCHIVE_VERSION).tar --transform "s|^vendor|$(ARCHIVE_PREFIX)vendor|" vendor/
gzip $(ARCHIVE_VERSION).tar
### generate a release zip and include 3rd-party dependencies
release_zip: composer_dependencies
git archive --prefix=$(ARCHIVE_PREFIX) -o $(ARCHIVE_VERSION).zip -9 HEAD
mkdir $(ARCHIVE_PREFIX)
rsync -a vendor/ $(ARCHIVE_PREFIX)vendor/
zip -r $(ARCHIVE_VERSION).zip $(ARCHIVE_PREFIX)vendor/
rm -rf $(ARCHIVE_PREFIX)
##
# Targets for repository and documentation maintenance
##
### remove all unversioned files
clean:
@git clean -df
@rm -rf sandbox
### generate Doxygen documentation
doxygen: clean
@rm -rf doxygen
@( cat Doxyfile ; echo "PROJECT_NUMBER=`git describe`" ) | doxygen -
### update the local copy of the documentation
doc: clean
@rm -rf doc
@git clone https://github.com/shaarli/Shaarli.wiki.git doc
@rm -rf doc/.git
### Generate a custom sidebar
#
# Sidebar content:
# - convert GitHub-flavoured relative links to standard Markdown
# - trim HTML, only keep the list (<ul>[...]</ul>) part
htmlsidebar:
@echo '<div id="local-sidebar">' > doc/sidebar.html
@awk 'BEGIN { FS = "[\\[\\]]{2}" }'\
'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
### Convert local markdown documentation to HTML
#
# For all pages:
# - infer title from the file name
# - convert GitHub-flavoured relative links to standard Markdown
# - insert the sidebar menu
htmlpages:
@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;
htmldoc: doc htmlsidebar htmlpages

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?_
_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._
[![](https://img.shields.io/badge/release-v0.13.0-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.13.0)
[![](https://img.shields.io/badge/master-v0.13.x-blue.svg)](https://github.com/shaarli/Shaarli)
[![](https://github.com/shaarli/Shaarli/actions/workflows/ci.yml/badge.svg)](https://github.com/shaarli/Shaarli/actions)
[![](https://github.com/shaarli/Shaarli/actions/workflows/trivy-release.yml/badge.svg)](https://github.com/shaarli/Shaarli/actions)
[![](https://img.shields.io/travis/shaarli/Shaarli.svg?label=master)](https://travis-ci.org/shaarli/Shaarli)
[![](https://img.shields.io/travis/shaarli/Shaarli/stable.svg?label=stable)](https://travis-ci.org/shaarli/Shaarli)
[![](https://img.shields.io/github/release/shaarli/shaarli.svg)](https://github.com/shaarli/Shaarli/releases/latest/)
[![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)
[![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
- [Documentation](https://shaarli.readthedocs.io)
- [Wiki/documentation](https://github.com/shaarli/Shaarli/wiki)
- [Change log](CHANGELOG.md)
- [Bugs/Feature requests/Discussion](https://github.com/shaarli/Shaarli/issues/)
### Demo
You can use this [public demo instance of Shaarli](https://demo.shaarli.org).
You can use this [public demo instance of Shaarli](http://shaarlidemo.tuxfamily.org/Shaarli).
It runs the latest development version of Shaarli and is updated/reset daily.
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.

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
namespace Shaarli\Exceptions;
use Exception;
/**
* Exception class thrown when a filesystem access failure happens
*/
@ -20,7 +15,7 @@ class IOException extends Exception
public function __construct($path, $message = '')
{
$this->path = $path;
$this->message = empty($message) ? t('Error accessing') : $message;
$this->message .= ' "' . $this->path . '"';
$this->message = empty($message) ? 'Error accessing' : $message;
$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
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'.
* This class allows to either use PHP gettext extension, or a PHP implementation of gettext,
* with a fixed language, or dynamically using autoLocale().
* Not doing translation for now.
*
* Translation files PO/MO files follow gettext standard and must be placed under:
* <translation path>/<language>/LC_MESSAGES/<domain>.[po|mo]
* @param string $text Text to translate.
* @param string $nText The plural message ID.
* @param int $nb The number of items for plural forms.
*
* Pros/cons:
* - 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
* @return String Text translated.
*/
class Languages
{
/**
* 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)'),
];
function t($text, $nText = '', $nb = 0) {
if (empty($nText)) {
return $text;
}
$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
/**
* Generates a list of available timezone continents and cities.
* Generates the timezone selection form and JavaScript.
*
* Two distinct array based on available timezones
* 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)
* Note: 'UTC/UTC' is mapped to 'UTC' to form a valid option
*
* Example:
* [
* [
* 'America',
* 'Europe',
* 'selected' => 'Europe',
* ],
* [
* ['continent' => 'America', 'city' => 'Toronto'],
* ['continent' => 'Europe', 'city' => 'Paris'],
* 'selected' => 'Paris',
* ],
* ];
* Example: preselect Europe/Paris
* list($htmlform, $js) = generateTimeZoneForm('Europe/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)
*
* @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') {
$pcity = $pcontinent = 'UTC';
} else {
// Try to split the provided timezone
$spos = strpos($preselectedTimezone, '/');
$pcontinent = substr($preselectedTimezone, 0, $spos);
$pcity = substr($preselectedTimezone, $spos + 1);
$pcity = substr($preselectedTimezone, $spos+1);
}
$continents = [];
$cities = [];
foreach ($installedTimeZones as $tz) {
// The list is in the form 'Europe/Paris', 'America/Argentina/Buenos_Aires'
// We split the list in continents/cities.
$continents = array();
$cities = array();
// TODO: use a template to generate the HTML/Javascript form
foreach (timezone_identifiers_list() as $tz) {
if ($tz == 'UTC') {
$tz = 'UTC/UTC';
}
$spos = strpos($tz, '/');
// Ignore invalid timezones
if ($spos === false) {
continue;
}
if ($spos !== false) {
$continent = substr($tz, 0, $spos);
$city = substr($tz, $spos+1);
$continents[$continent] = 1;
$continent = substr($tz, 0, $spos);
$city = substr($tz, $spos + 1);
$cities[] = ['continent' => $continent, 'city' => $city];
$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['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)
{
return in_array(
$continent . '/' . $city,
$continent.'/'.$city,
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
/**
* 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
@ -17,7 +78,7 @@ namespace Shaarli\Http;
*/
class Url
{
private static $annoyingQueryParams = [
private static $annoyingQueryParams = array(
// Facebook
'action_object_map=',
'action_ref_map=',
@ -33,19 +94,16 @@ class Url
'utm_',
// ATInternet
'xtor=',
'xtor='
);
// Other
'campaign_'
];
private static $annoyingFragments = [
private static $annoyingFragments = array(
// ATInternet
'xtor=RSS-',
// Misc.
'tk.rss_all'
];
);
/*
* URL parts represented as an array
@ -61,7 +119,6 @@ class Url
*/
public function __construct($url)
{
$url = $url ?? '';
$url = self::cleanupUnparsedUrl(trim($url));
$this->parts = parse_url($url);
@ -98,7 +155,7 @@ class Url
}
return $input;
}
/**
* Returns a string representation of this URL
*/
@ -112,7 +169,7 @@ class Url
*/
protected function cleanupQuery()
{
if (!isset($this->parts['query'])) {
if (! isset($this->parts['query'])) {
return;
}
@ -121,7 +178,7 @@ class Url
foreach (self::$annoyingQueryParams as $annoying) {
foreach ($queryParams as $param) {
if (startsWith($param, $annoying)) {
$queryParams = array_diff($queryParams, [$param]);
$queryParams = array_diff($queryParams, array($param));
continue;
}
}
@ -133,14 +190,14 @@ class Url
}
$this->parts['query'] = implode('&', $queryParams);
}
}
/**
* Removes undesired fragments
*/
protected function cleanupFragment()
{
if (!isset($this->parts['fragment'])) {
if (! isset($this->parts['fragment'])) {
return;
}
@ -173,10 +230,10 @@ class Url
public function idnToAscii()
{
$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;
}
$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);
}
@ -185,8 +242,7 @@ class Url
*
* @return string the URL scheme or false if none is provided.
*/
public function getScheme()
{
public function getScheme() {
if (!isset($this->parts['scheme'])) {
return false;
}
@ -198,8 +254,7 @@ class Url
*
* @return string the URL host or false if none is provided.
*/
public function getHost()
{
public function getHost() {
if (empty($this->parts['host'])) {
return false;
}
@ -207,12 +262,11 @@ class Url
}
/**
* Test if the UrlUtils is an HTTP one.
* Test if the Url is an HTTP one.
*
* @return true is HTTP, false otherwise.
*/
public function isHttp()
{
public function isHttp() {
return strpos(strtolower($this->parts['scheme']), 'http') !== false;
}
}

View file

@ -1,27 +1,24 @@
<?php
/**
* Shaarli utilities
*/
/**
* Format log using provided data.
* Logs a message to a text file
*
* @param string $message the message to log
* @param string|null $clientIp the client's remote IPv4/IPv6 address
* The log format is compatible with fail2ban.
*
* @return string Formatted message to log
* @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
*/
function format_log(string $message, string $clientIp = null): string
function logm($logFile, $clientIp, $message)
{
$out = $message;
if (!empty($clientIp)) {
// Note: we keep the first dash to avoid breaking fail2ban configs
$out = '- ' . $clientIp . ' - ' . $out;
}
return $out;
file_put_contents(
$logFile,
date('Y/m/d H:i:s').' - '.$clientIp.' - '.strval($message).PHP_EOL,
FILE_APPEND
);
}
/**
@ -34,11 +31,7 @@ function format_log(string $message, string $clientIp = null): string
* - are NOT cryptographically secure (they CAN be forged)
*
* 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' . 142) --> eaWxtQ
*
* @warning before v0.8.1, smallhashes were built only with the date,
* and their value has been preserved.
* e.g. smallHash('20111006_131924') --> yZH23w
*
* @param string $text Create a hash from this text.
*
@ -61,7 +54,6 @@ function smallHash($text)
*/
function startsWith($haystack, $needle, $case = true)
{
$needle = $needle ?? '';
if ($case) {
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.
*
* @return string|array escaped.
* @return string escaped.
*/
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)) {
$out = [];
foreach ($input as $key => $value) {
$out[escape($key)] = escape($value);
$out = array();
foreach($input as $key => $value) {
$out[$key] = escape($value);
}
return $out;
}
@ -165,12 +149,12 @@ function checkDateFormat($format, $string)
*
* @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.
foreach (array_filter($loopTerms) as $value) {
foreach ($loopTerms as $value) {
if (strpos($referer, $value) !== false) {
return $finalReferer;
}
@ -181,7 +165,7 @@ function generateLocation($referer, $host, $loopTerms = [])
$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))) {
$finalReferer = $referer;
}
@ -189,6 +173,36 @@ function generateLocation($referer, $host, $loopTerms = [])
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.
* Note that is may not work on your server if the corresponding locale is not installed.
@ -198,328 +212,28 @@ function generateLocation($referer, $host, $loopTerms = [])
function autoLocale($headerLocale)
{
// Default if browser does not send HTTP_ACCEPT_LANGUAGE
$locales = ['en_US.UTF-8', 'en_US.utf8', 'en_US'];
if (! empty($headerLocale)) {
if (preg_match_all('/([a-z]{2,3})[-_]?([a-z]{2})?,?/i', $headerLocale, $matches, PREG_SET_ORDER)) {
$attempts = [];
foreach ($matches as $match) {
$first = [strtolower($match[1]), strtoupper($match[1])];
$separators = ['_', '-'];
$encodings = ['utf8', 'UTF-8'];
if (!empty($match[2])) {
$second = [strtoupper($match[2]), strtolower($match[2])];
$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);
}
$attempts = array('en_US');
if (isset($headerLocale)) {
// (It's a bit crude, but it works very well. Preferred language is always presented first.)
if (preg_match('/([a-z]{2})-?([a-z]{2})?/i', $headerLocale, $matches)) {
$loc = $matches[1] . (!empty($matches[2]) ? '_' . strtoupper($matches[2]) : '');
$attempts = array(
$loc.'.UTF-8', $loc, str_replace('_', '-', $loc).'.UTF-8', str_replace('_', '-', $loc),
$loc . '_' . strtoupper($loc).'.UTF-8', $loc . '_' . strtoupper($loc),
$loc . '_' . $loc.'.UTF-8', $loc . '_' . $loc, $loc . '-' . strtoupper($loc).'.UTF-8',
$loc . '-' . strtoupper($loc), $loc . '-' . $loc.'.UTF-8', $loc . '-' . $loc
);
}
}
setlocale(LC_ALL, $locales);
setlocale(LC_ALL, $attempts);
}
/**
* 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)
function getAllTheme()
{
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];
}
$allTheme = glob('tpl/*', GLOB_ONLYDIR);
foreach ($allTheme as $value) {
$themes[] = str_replace('tpl/', '', $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(
get_locale(LC_TIME),
IntlDateFormatter::LONG,
$time ? IntlDateFormatter::LONG : IntlDateFormatter::NONE
);
$formatter->setTimeZone($date->getTimezone());
return $formatter->format($date);
}
/**
* Format the date month according to the locale.
*
* @param DateTimeInterface $date to format.
*
* @return bool|string Formatted date, or false if the input is invalid.
*/
function format_month(DateTimeInterface $date)
{
if (! $date instanceof DateTimeInterface) {
return false;
}
return strftime('%B', $date->getTimestamp());
}
/**
* Check if the input is an integer, no matter its real type.
*
* 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();
}
/**
* Get the current locale, overrides 'C' locale which is no longer compatible with PHP-intl
*
* @param int $category Category of the locale (LC_CTYPE, LC_NUMERIC, LC_TIME, LC_COLLATE, LC_MONETARY, LC_ALL)
*
* @return string|false The locale, or false if not found.
*
* @see https://github.com/php/php-src/issues/12561
*/
function get_locale(int $category = LC_CTYPE)
{
$locale = setlocale($category, 0);
if ($locale === 'C' || startsWith($locale, 'C.')) {
$locale = 'en_US.utf8'; // failback
}
return $locale;
return $themes;
}

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,44 +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),
'tags_separator' => $this->conf->get('general.tags_separator', ' '),
],
];
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,546 +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. If the new tag already exists, merge them
*
* @param string $fromTag
* @param string $toTag
*/
public function renameTag(string $fromTag, string $toTag): void
{
if (($pos = array_search($fromTag, $this->tags ?? [])) !== false) {
if (in_array($toTag, $this->tags ?? []) !== false) {
$this->deleteTag($fromTag);
} else {
$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
namespace Shaarli\Config;
/**
* Interface ConfigIO
*
@ -16,7 +14,7 @@ interface ConfigIO
*
* @return array All configuration in an array.
*/
public function read($filepath);
function read($filepath);
/**
* Write configuration.
@ -24,12 +22,12 @@ interface ConfigIO
* @param string $filepath Config file absolute path.
* @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.
*
* @return string Config file extension.
*/
public function getExtension();
function getExtension();
}

View file

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

View file

@ -1,18 +1,17 @@
<?php
namespace Shaarli\Config;
use Shaarli\Config\Exception\MissingFieldConfigException;
use Shaarli\Config\Exception\UnauthorizedConfigException;
use Shaarli\Thumbnailer;
// FIXME! Namespaces...
require_once 'ConfigIO.php';
require_once 'ConfigJson.php';
require_once 'ConfigPhp.php';
/**
* Class ConfigManager
*
* Manages all Shaarli's settings.
* See the documentation for more information on settings:
* - doc/md/Shaarli-configuration.md
* - https://shaarli.readthedocs.io/en/master/Shaarli-configuration/#configuration
* - doc/Shaarli-configuration.html
* - https://github.com/shaarli/Shaarli/wiki/Shaarli-configuration
*/
class ConfigManager
{
@ -21,8 +20,6 @@ class ConfigManager
*/
protected static $NOT_FOUND = 'NOT_FOUND';
public static $DEFAULT_PLUGINS = ['qrcode'];
/**
* @var string Config folder.
*/
@ -83,11 +80,7 @@ class ConfigManager
*/
protected function load()
{
try {
$this->loadedConfig = $this->configIO->read($this->getConfigFileExt());
} catch (\Exception $e) {
die($e->getMessage());
}
$this->loadedConfig = $this->configIO->read($this->getConfigFileExt());
$this->setDefaultValues();
}
@ -125,16 +118,16 @@ class ConfigManager
* Supports nested settings with dot separated keys.
*
* @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 $isLoggedIn User login state, default false.
*
* @throws \Exception Invalid
* @throws Exception Invalid
*/
public function set($setting, $value, $write = false, $isLoggedIn = false)
{
if (empty($setting) || ! is_string($setting)) {
throw new \Exception(t('Invalid setting key parameter. String expected, got: ') . gettype($setting));
throw new Exception('Invalid setting key parameter. String expected, got: '. gettype($setting));
}
// During the ConfigIO transition, map legacy settings to the new ones.
@ -149,33 +142,6 @@ class ConfigManager
}
}
/**
* 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.
*
@ -209,12 +175,12 @@ class ConfigManager
*
* @throws MissingFieldConfigException: a mandatory field has not been provided in $conf.
* @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)
{
// These fields are required in configuration.
$mandatoryFields = [
$mandatoryFields = array(
'credentials.login',
'credentials.hash',
'credentials.salt',
@ -223,7 +189,8 @@ class ConfigManager
'general.title',
'general.header_link',
'privacy.default_private_links',
];
'redirector.url',
);
// Only logged in user can alter config.
if (is_file($this->getConfigFileExt()) && !$isLoggedIn) {
@ -300,7 +267,7 @@ class ConfigManager
*
* @param array $settings Ordered array which contains keys to find.
* @param mixed $value
* @param array $conf Loaded settings, then sub-array.
* @param array $conf Loaded settings, then sub-array.
*
* @return mixed Found setting or NOT_FOUND flag.
*/
@ -317,27 +284,6 @@ class ConfigManager
$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.
*/
@ -350,7 +296,6 @@ class ConfigManager
$this->setEmpty('resource.updates', 'data/updates.txt');
$this->setEmpty('resource.log', 'data/log.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.theme', 'default');
$this->setEmpty('resource.raintpl_tmp', 'tmp/');
@ -361,40 +306,29 @@ class ConfigManager
$this->setEmpty('security.ban_duration', 1800);
$this->setEmpty('security.session_protection_disabled', 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.enabled_plugins', self::$DEFAULT_PLUGINS);
$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('general.enabled_plugins', array('qrcode'));
$this->setEmpty('updates.check_updates', true);
$this->setEmpty('updates.check_updates', false);
$this->setEmpty('updates.check_updates_branch', 'stable');
$this->setEmpty('updates.check_updates_interval', 86400);
$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.hide_public_links', false);
$this->setEmpty('privacy.force_login', 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('thumbnails.width', '125');
$this->setEmpty('thumbnails.height', '90');
$this->setEmpty('thumbnail.enable_thumbnails', true);
$this->setEmpty('thumbnail.enable_localcache', true);
$this->setEmpty('translation.language', 'auto');
$this->setEmpty('translation.mode', 'php');
$this->setEmpty('translation.extensions', []);
$this->setEmpty('redirector.url', '');
$this->setEmpty('redirector.encode_url', true);
$this->setEmpty('plugins', []);
$this->setEmpty('formatter', 'markdown');
$this->setEmpty('plugins', array());
}
/**
@ -426,3 +360,36 @@ class ConfigManager
$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
namespace Shaarli\Config;
/**
* Class ConfigPhp (ConfigIO implementation)
*
@ -13,7 +11,7 @@ class ConfigPhp implements ConfigIO
/**
* @var array List of config key without group.
*/
public static $ROOT_KEYS = [
public static $ROOT_KEYS = array(
'login',
'hash',
'salt',
@ -23,16 +21,16 @@ class ConfigPhp implements ConfigIO
'redirector',
'disablesessionprotection',
'privateLinkByDefault',
];
);
/**
* Map legacy config keys with the new ones.
* 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.
*/
public static $LEGACY_KEYS_MAPPING = [
public static $LEGACY_KEYS_MAPPING = array(
'credentials.login' => 'login',
'credentials.hash' => 'hash',
'credentials.salt' => 'salt',
@ -69,34 +67,34 @@ class ConfigPhp implements ConfigIO
'privacy.hide_public_links' => 'config.HIDE_PUBLIC_LINKS',
'privacy.hide_timestamps' => 'config.HIDE_TIMESTAMPS',
'security.open_shaarli' => 'config.OPEN_SHAARLI',
];
);
/**
* @inheritdoc
*/
public function read($filepath)
function read($filepath)
{
if (! file_exists($filepath) || ! is_readable($filepath)) {
return [];
return array();
}
include $filepath;
$out = [];
$out = array();
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['plugins'] = isset($GLOBALS['plugins']) ? $GLOBALS['plugins'] : [];
$out['config'] = $GLOBALS['config'];
$out['plugins'] = !empty($GLOBALS['plugins']) ? $GLOBALS['plugins'] : array();
return $out;
}
/**
* @inheritdoc
*/
public function write($filepath, $conf)
function write($filepath, $conf)
{
$configStr = '<?php ' . PHP_EOL;
$configStr = '<?php '. PHP_EOL;
foreach (self::$ROOT_KEYS as $key) {
if (isset($conf[$key])) {
$configStr .= '$GLOBALS[\'' . $key . '\'] = ' . var_export($conf[$key], true) . ';' . PHP_EOL;
@ -105,31 +103,22 @@ class ConfigPhp implements ConfigIO
// Store all $conf['config']
foreach ($conf['config'] as $key => $value) {
$configStr .= '$GLOBALS[\'config\'][\''
. $key
. '\'] = '
. var_export($conf['config'][$key], true) . ';'
. PHP_EOL;
$configStr .= '$GLOBALS[\'config\'][\''. $key .'\'] = '.var_export($conf['config'][$key], true).';'. PHP_EOL;
}
if (isset($conf['plugins'])) {
foreach ($conf['plugins'] as $key => $value) {
$configStr .= '$GLOBALS[\'plugins\'][\''
. $key
. '\'] = '
. var_export($conf['plugins'][$key], true) . ';'
. PHP_EOL;
$configStr .= '$GLOBALS[\'plugins\'][\''. $key .'\'] = '.var_export($conf['plugins'][$key], true).';'. PHP_EOL;
}
}
if (
!file_put_contents($filepath, $configStr)
if (!file_put_contents($filepath, $configStr)
|| strcmp(file_get_contents($filepath), $configStr) != 0
) {
throw new \Shaarli\Exceptions\IOException(
throw new IOException(
$filepath,
t('Shaarli could not create the config file. ' .
'Please make sure Shaarli has the right to write in the folder is it installed in.')
'Shaarli could not create the config file.
Please make sure Shaarli has the right to write in the folder is it installed in.'
);
}
}
@ -137,7 +126,7 @@ class ConfigPhp implements ConfigIO
/**
* @inheritdoc
*/
public function getExtension()
function getExtension()
{
return '.php';
}

View file

@ -1,8 +1,4 @@
<?php
use Shaarli\Config\Exception\PluginConfigOrderException;
use Shaarli\Plugin\PluginManager;
/**
* Plugin configuration helper functions.
*
@ -20,27 +16,13 @@ use Shaarli\Plugin\PluginManager;
*/
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.
if (!validate_plugin_order($formData)) {
throw new PluginConfigOrderException();
}
$plugins = [];
$newEnabledPlugins = [];
$plugins = array();
$newEnabledPlugins = array();
foreach ($formData as $key => $data) {
if (startsWith($key, 'order')) {
continue;
@ -49,7 +31,8 @@ function save_plugin_config($formData)
// If there is no order, it means a disabled plugin has been enabled.
if (isset($formData['order_' . $key])) {
$plugins[(int) $formData['order_' . $key]] = $key;
} else {
}
else {
$newEnabledPlugins[] = $key;
}
}
@ -62,7 +45,7 @@ function save_plugin_config($formData)
throw new PluginConfigOrderException();
}
$finalPlugins = [];
$finalPlugins = array();
// Make plugins order continuous.
foreach ($plugins as $plugin) {
$finalPlugins[] = $plugin;
@ -81,10 +64,10 @@ function save_plugin_config($formData)
*/
function validate_plugin_order($formData)
{
$orders = [];
$orders = array();
foreach ($formData as $key => $value) {
// No duplicate order allowed.
if (in_array($value, $orders, true)) {
if (in_array($value, $orders)) {
return false;
}
@ -125,3 +108,17 @@ function load_plugin_parameter_values($plugins, $conf)
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));
}
}

View file

@ -1,82 +0,0 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Admin;
use Psr\Http\Message\UploadedFileInterface;
use Shaarli\Render\TemplatePage;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Class ImportController
*
* Slim controller used to display Shaarli data import page,
* and import bookmarks from Netscape Bookmarks file.
*/
class ImportController extends ShaarliAdminController
{
/**
* GET /admin/import - Display import page
*/
public function index(Request $request, Response $response): Response
{
$this->assignView(
'maxfilesize',
get_max_upload_size(
ini_get('post_max_size'),
ini_get('upload_max_filesize'),
false
)
);
$this->assignView(
'maxfilesizeHuman',
get_max_upload_size(
ini_get('post_max_size'),
ini_get('upload_max_filesize'),
true
)
);
$this->assignView('pagetitle', t('Import') . ' - ' . $this->container->conf->get('general.title', 'Shaarli'));
return $response->write($this->render(TemplatePage::IMPORT));
}
/**
* POST /admin/import - Process import file provided and create bookmarks
*/
public function import(Request $request, Response $response): Response
{
$this->checkToken($request);
$file = ($request->getUploadedFiles() ?? [])['filetoupload'] ?? null;
if (!$file instanceof UploadedFileInterface) {
$this->saveErrorMessage(t('No import file provided.'));
return $this->redirect($response, '/admin/import');
}
// Import bookmarks from an uploaded file
if (0 === $file->getSize()) {
// The file is too big or some form field may be missing.
$msg = sprintf(
t(
'The file you are trying to upload is probably bigger than what this webserver can accept'
. ' (%s). Please upload in smaller chunks.'
),
get_max_upload_size(ini_get('post_max_size'), ini_get('upload_max_filesize'))
);
$this->saveErrorMessage($msg);
return $this->redirect($response, '/admin/import');
}
$status = $this->container->netscapeBookmarkUtils->import($request->getParams(), $file);
$this->saveSuccessMessage($status);
return $this->redirect($response, '/admin/import');
}
}

View file

@ -1,33 +0,0 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Admin;
use Shaarli\Security\CookieManager;
use Shaarli\Security\LoginManager;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Class LogoutController
*
* Slim controller used to logout the user.
* It invalidates page cache and terminate the user session. Then it redirects to the homepage.
*/
class LogoutController extends ShaarliAdminController
{
public function index(Request $request, Response $response): Response
{
$this->container->pageCacheManager->invalidateCaches();
$this->container->sessionManager->logout();
$this->container->cookieManager->setCookieParameter(
CookieManager::STAY_SIGNED_IN,
'false',
0,
$this->container->basePath . '/'
);
return $this->redirect($response, '/');
}
}

View file

@ -1,124 +0,0 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Admin;
use Shaarli\Bookmark\BookmarkFilter;
use Shaarli\Render\TemplatePage;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Class ManageTagController
*
* Slim controller used to handle Shaarli manage tags page (rename and delete tags).
*/
class ManageTagController extends ShaarliAdminController
{
/**
* GET /admin/tags - Displays the manage tags page
*/
public function index(Request $request, Response $response): Response
{
$fromTag = $request->getParam('fromtag') ?? '';
$this->assignView('fromtag', escape($fromTag));
$separator = escape($this->container->conf->get('general.tags_separator', ' '));
if ($separator === ' ') {
$separator = '&nbsp;';
$this->assignView('tags_separator_desc', t('whitespace'));
}
$this->assignView('tags_separator', $separator);
$this->assignView(
'pagetitle',
t('Manage tags') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
);
return $response->write($this->render(TemplatePage::CHANGE_TAG));
}
/**
* POST /admin/tags - Update or delete provided tag
*/
public function save(Request $request, Response $response): Response
{
$this->checkToken($request);
$isDelete = null !== $request->getParam('deletetag') && null === $request->getParam('renametag');
$fromTag = trim($request->getParam('fromtag') ?? '');
$toTag = trim($request->getParam('totag') ?? '');
if (0 === strlen($fromTag) || false === $isDelete && 0 === strlen($toTag)) {
$this->saveWarningMessage(t('Invalid tags provided.'));
return $this->redirect($response, '/admin/tags');
}
// TODO: move this to bookmark service
$searchResult = $this->container->bookmarkService->search(
['searchtags' => $fromTag],
BookmarkFilter::$ALL,
true
);
foreach ($searchResult->getBookmarks() as $bookmark) {
if (false === $isDelete) {
$bookmark->renameTag($fromTag, $toTag);
} else {
$bookmark->deleteTag($fromTag);
}
$this->container->bookmarkService->set($bookmark, false);
$this->container->history->updateLink($bookmark);
}
$this->container->bookmarkService->save();
$count = $searchResult->getResultCount();
if (true === $isDelete) {
$alert = sprintf(
t('The tag was removed from %d bookmark.', 'The tag was removed from %d bookmarks.', $count),
$count
);
} else {
$alert = sprintf(
t('The tag was renamed in %d bookmark.', 'The tag was renamed in %d bookmarks.', $count),
$count
);
}
$this->saveSuccessMessage($alert);
$redirect = true === $isDelete ? '/admin/tags' : '/?searchtags=' . urlencode($toTag);
return $this->redirect($response, $redirect);
}
/**
* POST /admin/tags/change-separator - Change tag separator
*/
public function changeSeparator(Request $request, Response $response): Response
{
$this->checkToken($request);
$reservedCharacters = ['-', '.', '*'];
$newSeparator = $request->getParam('separator');
if ($newSeparator === null || mb_strlen($newSeparator) !== 1) {
$this->saveErrorMessage(t('Tags separator must be a single character.'));
} elseif (in_array($newSeparator, $reservedCharacters, true)) {
$reservedCharacters = implode(' ', array_map(function (string $character) {
return '<code>' . $character . '</code>';
}, $reservedCharacters));
$this->saveErrorMessage(
t('These characters are reserved and can\'t be used as tags separator: ') . $reservedCharacters
);
} else {
$this->container->conf->set('general.tags_separator', $newSeparator, true, true);
$this->saveSuccessMessage('Your tags separator setting has been updated!');
}
return $this->redirect($response, '/admin/tags');
}
}

View file

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

View file

@ -1,101 +0,0 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Admin;
use Shaarli\Container\ShaarliContainer;
use Shaarli\Front\Exception\OpenShaarliPasswordException;
use Shaarli\Front\Exception\ShaarliFrontException;
use Shaarli\Render\TemplatePage;
use Slim\Http\Request;
use Slim\Http\Response;
use Throwable;
/**
* Class PasswordController
*
* Slim controller used to handle passwords update.
*/
class PasswordController extends ShaarliAdminController
{
public function __construct(ShaarliContainer $container)
{
parent::__construct($container);
$this->assignView(
'pagetitle',
t('Change password') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
);
}
/**
* GET /admin/password - Displays the change password template
*/
public function index(Request $request, Response $response): Response
{
return $response->write($this->render(TemplatePage::CHANGE_PASSWORD));
}
/**
* POST /admin/password - Change admin password - existing and new passwords need to be provided.
*/
public function change(Request $request, Response $response): Response
{
$this->checkToken($request);
if ($this->container->conf->get('security.open_shaarli', false)) {
throw new OpenShaarliPasswordException();
}
$oldPassword = $request->getParam('oldpassword');
$newPassword = $request->getParam('setpassword');
if (empty($newPassword) || empty($oldPassword)) {
$this->saveErrorMessage(t('You must provide the current and new password to change it.'));
return $response
->withStatus(400)
->write($this->render(TemplatePage::CHANGE_PASSWORD))
;
}
// Make sure old password is correct.
$oldHash = sha1(
$oldPassword .
$this->container->conf->get('credentials.login') .
$this->container->conf->get('credentials.salt')
);
if ($oldHash !== $this->container->conf->get('credentials.hash')) {
$this->saveErrorMessage(t('The old password is not correct.'));
return $response
->withStatus(400)
->write($this->render(TemplatePage::CHANGE_PASSWORD))
;
}
// Save new password
// Salt renders rainbow-tables attacks useless.
$this->container->conf->set('credentials.salt', sha1(uniqid('', true) . '_' . mt_rand()));
$this->container->conf->set(
'credentials.hash',
sha1(
$newPassword
. $this->container->conf->get('credentials.login')
. $this->container->conf->get('credentials.salt')
)
);
try {
$this->container->conf->write($this->container->loginManager->isLoggedIn());
} catch (Throwable $e) {
throw new ShaarliFrontException($e->getMessage(), 500, $e);
}
$this->saveSuccessMessage(t('Your password has been changed'));
return $response->write($this->render(TemplatePage::CHANGE_PASSWORD));
}
}

View file

@ -1,85 +0,0 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Admin;
use Exception;
use Shaarli\Render\TemplatePage;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Class PluginsController
*
* Slim controller used to handle Shaarli plugins configuration page (display + save new config).
*/
class PluginsController extends ShaarliAdminController
{
/**
* GET /admin/plugins - Displays the configuration page
*/
public function index(Request $request, Response $response): Response
{
$pluginMeta = $this->container->pluginManager->getPluginsMeta();
// Split plugins into 2 arrays: ordered enabled plugins and disabled.
$enabledPlugins = array_filter($pluginMeta, function ($v) {
return ($v['order'] ?? false) !== false;
});
$enabledPlugins = load_plugin_parameter_values($enabledPlugins, $this->container->conf->get('plugins', []));
uasort(
$enabledPlugins,
function ($a, $b) {
return $a['order'] - $b['order'];
}
);
$disabledPlugins = array_filter($pluginMeta, function ($v) {
return ($v['order'] ?? false) === false;
});
$this->assignView('enabledPlugins', $enabledPlugins);
$this->assignView('disabledPlugins', $disabledPlugins);
$this->assignView(
'pagetitle',
t('Plugin Administration') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
);
return $response->write($this->render(TemplatePage::PLUGINS_ADMIN));
}
/**
* POST /admin/plugins - Update Shaarli's configuration
*/
public function save(Request $request, Response $response): Response
{
$this->checkToken($request);
try {
$parameters = $request->getParams() ?? [];
$this->executePageHooks('save_plugin_parameters', $parameters);
if (isset($parameters['parameters_form'])) {
unset($parameters['parameters_form']);
unset($parameters['token']);
foreach ($parameters as $param => $value) {
$this->container->conf->set('plugins.' . $param, escape($value));
}
} else {
$this->container->conf->set('general.enabled_plugins', save_plugin_config($parameters));
}
$this->container->conf->write($this->container->loginManager->isLoggedIn());
$this->container->history->updateSettings();
$this->saveSuccessMessage(t('Setting successfully saved.'));
} catch (Exception $e) {
$this->saveErrorMessage(
t('Error while saving plugin configuration: ') . PHP_EOL . $e->getMessage()
);
}
return $this->redirect($response, '/admin/plugins');
}
}

View file

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

View file

@ -1,48 +0,0 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Admin;
use Shaarli\Bookmark\BookmarkFilter;
use Shaarli\Security\SessionManager;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Class SessionFilterController
*
* Slim controller used to handle filters stored in the user session, such as visibility, etc.
*/
class SessionFilterController extends ShaarliAdminController
{
/**
* GET /admin/visibility: allows to display only public or only private bookmarks in linklist
*/
public function visibility(Request $request, Response $response, array $args): Response
{
if (false === $this->container->loginManager->isLoggedIn()) {
return $this->redirectFromReferer($request, $response, ['visibility']);
}
$newVisibility = $args['visibility'] ?? null;
if (false === in_array($newVisibility, [BookmarkFilter::$PRIVATE, BookmarkFilter::$PUBLIC], true)) {
$newVisibility = null;
}
$currentVisibility = $this->container->sessionManager->getSessionParameter(SessionManager::KEY_VISIBILITY);
// Visibility not set or not already expected value, set expected value, otherwise reset it
if ($newVisibility !== null && (null === $currentVisibility || $currentVisibility !== $newVisibility)) {
// See only public bookmarks
$this->container->sessionManager->setSessionParameter(
SessionManager::KEY_VISIBILITY,
$newVisibility
);
} else {
$this->container->sessionManager->deleteSessionParameter(SessionManager::KEY_VISIBILITY);
}
return $this->redirectFromReferer($request, $response, ['visibility']);
}
}

View file

@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Admin;
use Shaarli\Formatter\BookmarkMarkdownFormatter;
use Shaarli\Render\TemplatePage;
use Slim\Http\Request;
use Slim\Http\Response;
class ShaareAddController extends ShaarliAdminController
{
/**
* GET /admin/add-shaare - Displays the form used to create a new bookmark from an URL
*/
public function addShaare(Request $request, Response $response): Response
{
$tags = $this->container->bookmarkService->bookmarksCountPerTag();
if ($this->container->conf->get('formatter') === 'markdown') {
$tags[BookmarkMarkdownFormatter::NO_MD_TAG] = 1;
}
$this->assignView(
'pagetitle',
t('Shaare a new link') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
);
$this->assignView('tags', $tags);
$this->assignView('default_private_links', $this->container->conf->get('privacy.default_private_links', false));
$this->assignView('async_metadata', $this->container->conf->get('general.enable_async_metadata', true));
return $response->write($this->render(TemplatePage::ADDLINK));
}
}

View file

@ -1,287 +0,0 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Admin;
use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Class PostBookmarkController
*
* Slim controller used to handle Shaarli create or edit bookmarks.
*/
class ShaareManageController extends ShaarliAdminController
{
/**
* GET /admin/shaare/delete - Delete one or multiple bookmarks (depending on `id` query parameter).
*/
public function deleteBookmark(Request $request, Response $response): Response
{
$this->checkToken($request);
$ids = escape(trim($request->getParam('id') ?? ''));
if (empty($ids) || strpos($ids, ' ') !== false) {
// multiple, space-separated ids provided
$ids = array_values(array_filter(preg_split('/\s+/', $ids), 'ctype_digit'));
} else {
$ids = [$ids];
}
// assert at least one id is given
if (0 === count($ids)) {
$this->saveErrorMessage(t('Invalid bookmark ID provided.'));
return $this->redirectFromReferer($request, $response, [], ['delete-shaare']);
}
$formatter = $this->container->formatterFactory->getFormatter('raw');
$count = 0;
foreach ($ids as $id) {
try {
$bookmark = $this->container->bookmarkService->get((int) $id);
} catch (BookmarkNotFoundException $e) {
$this->saveErrorMessage(sprintf(
t('Bookmark with identifier %s could not be found.'),
$id
));
continue;
}
$data = $formatter->format($bookmark);
$this->executePageHooks('delete_link', $data);
$this->container->bookmarkService->remove($bookmark, false);
++$count;
}
if ($count > 0) {
$this->container->bookmarkService->save();
}
// If we are called from the bookmarklet, we must close the popup:
if ($request->getParam('source') === 'bookmarklet') {
return $response->write('<script>self.close();</script>');
}
if ($request->getParam('source') === 'batch') {
return $response->withStatus(204);
}
// Don't redirect to permalink after deletion.
return $this->redirectFromReferer($request, $response, ['shaare/']);
}
/**
* GET /admin/shaare/visibility
*
* Change visibility (public/private) of one or multiple bookmarks (depending on `id` query parameter).
*/
public function changeVisibility(Request $request, Response $response): Response
{
$this->checkToken($request);
$ids = trim(escape($request->getParam('id') ?? ''));
if (empty($ids) || strpos($ids, ' ') !== false) {
// multiple, space-separated ids provided
$ids = array_values(array_filter(preg_split('/\s+/', $ids), 'ctype_digit'));
} else {
// only a single id provided
$ids = [$ids];
}
// assert at least one id is given
if (0 === count($ids)) {
$this->saveErrorMessage(t('Invalid bookmark ID provided.'));
return $this->redirectFromReferer($request, $response, [], ['change_visibility']);
}
// assert that the visibility is valid
$visibility = $request->getParam('newVisibility');
if (null === $visibility || false === in_array($visibility, ['public', 'private'], true)) {
$this->saveErrorMessage(t('Invalid visibility provided.'));
return $this->redirectFromReferer($request, $response, [], ['change_visibility']);
} else {
$isPrivate = $visibility === 'private';
}
$formatter = $this->container->formatterFactory->getFormatter('raw');
$count = 0;
foreach ($ids as $id) {
try {
$bookmark = $this->container->bookmarkService->get((int) $id);
} catch (BookmarkNotFoundException $e) {
$this->saveErrorMessage(sprintf(
t('Bookmark with identifier %s could not be found.'),
$id
));
continue;
}
$bookmark->setPrivate($isPrivate);
// To preserve backward compatibility with 3rd parties, plugins still use arrays
$data = $formatter->format($bookmark);
$this->executePageHooks('save_link', $data);
$bookmark->fromArray($data, $this->container->conf->get('general.tags_separator', ' '));
$this->container->bookmarkService->set($bookmark, false);
++$count;
}
if ($count > 0) {
$this->container->bookmarkService->save();
}
return $this->redirectFromReferer($request, $response, ['/visibility'], ['change_visibility']);
}
/**
* GET /admin/shaare/{id}/pin - Pin or unpin a bookmark.
*/
public function pinBookmark(Request $request, Response $response, array $args): Response
{
$this->checkToken($request);
$id = $args['id'] ?? '';
try {
if (false === ctype_digit($id)) {
throw new BookmarkNotFoundException();
}
$bookmark = $this->container->bookmarkService->get((int) $id); // Read database
} catch (BookmarkNotFoundException $e) {
$this->saveErrorMessage(sprintf(
t('Bookmark with identifier %s could not be found.'),
$id
));
return $this->redirectFromReferer($request, $response, ['/pin'], ['pin']);
}
$formatter = $this->container->formatterFactory->getFormatter('raw');
$bookmark->setSticky(!$bookmark->isSticky());
// To preserve backward compatibility with 3rd parties, plugins still use arrays
$data = $formatter->format($bookmark);
$this->executePageHooks('save_link', $data);
$bookmark->fromArray($data, $this->container->conf->get('general.tags_separator', ' '));
$this->container->bookmarkService->set($bookmark);
return $this->redirectFromReferer($request, $response, ['/pin'], ['pin']);
}
/**
* GET /admin/shaare/private/{hash} - Attach a private key to given bookmark, then redirect to the sharing URL.
*/
public function sharePrivate(Request $request, Response $response, array $args): Response
{
$this->checkToken($request);
$hash = $args['hash'] ?? '';
$bookmark = $this->container->bookmarkService->findByHash($hash);
if ($bookmark->isPrivate() !== true) {
return $this->redirect($response, '/shaare/' . $hash);
}
if (empty($bookmark->getAdditionalContentEntry('private_key'))) {
$privateKey = bin2hex(random_bytes(16));
$bookmark->setAdditionalContentEntry('private_key', $privateKey);
$this->container->bookmarkService->set($bookmark);
}
return $this->redirect(
$response,
'/shaare/' . $hash . '?key=' . $bookmark->getAdditionalContentEntry('private_key')
);
}
/**
* POST /admin/shaare/update-tags
*
* Bulk add or delete a tags on one or multiple bookmarks.
*/
public function addOrDeleteTags(Request $request, Response $response): Response
{
$this->checkToken($request);
$ids = trim(escape($request->getParam('id') ?? ''));
if (empty($ids) || strpos($ids, ' ') !== false) {
// multiple, space-separated ids provided
$ids = array_values(array_filter(preg_split('/\s+/', $ids), 'ctype_digit'));
} else {
// only a single id provided
$ids = [$ids];
}
// assert at least one id is given
if (0 === count($ids)) {
$this->saveErrorMessage(t('Invalid bookmark ID provided.'));
return $this->redirectFromReferer($request, $response, ['/updateTag'], []);
}
// assert that the action is valid
$action = $request->getParam('action');
if (!in_array($action, ['add', 'delete'], true)) {
$this->saveErrorMessage(t('Invalid action provided.'));
return $this->redirectFromReferer($request, $response, ['/updateTag'], []);
}
// assert that the tag name is valid
$tagString = trim($request->getParam('tag'));
if (empty($tagString)) {
$this->saveErrorMessage(t('Invalid tag name provided.'));
return $this->redirectFromReferer($request, $response, ['/updateTag'], []);
}
$tags = tags_str2array($tagString, $this->container->conf->get('general.tags_separator', ' '));
$formatter = $this->container->formatterFactory->getFormatter('raw');
$count = 0;
foreach ($ids as $id) {
try {
$bookmark = $this->container->bookmarkService->get((int) $id);
} catch (BookmarkNotFoundException $e) {
$this->saveErrorMessage(sprintf(
t('Bookmark with identifier %s could not be found.'),
$id
));
continue;
}
foreach ($tags as $tag) {
if ($action === 'add') {
$bookmark->addTag($tag);
} else {
$bookmark->deleteTag($tag);
}
}
// To preserve backward compatibility with 3rd parties, plugins still use arrays
$data = $formatter->format($bookmark);
$this->executePageHooks('save_link', $data);
$bookmark->fromArray($data, $this->container->conf->get('general.tags_separator', ' '));
$this->container->bookmarkService->set($bookmark, false);
++$count;
}
if ($count > 0) {
$this->container->bookmarkService->save();
}
return $this->redirectFromReferer($request, $response, ['/updateTag'], []);
}
}

View file

@ -1,274 +0,0 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Admin;
use Shaarli\Bookmark\Bookmark;
use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
use Shaarli\Formatter\BookmarkFormatter;
use Shaarli\Formatter\BookmarkMarkdownFormatter;
use Shaarli\Render\TemplatePage;
use Shaarli\Thumbnailer;
use Slim\Http\Request;
use Slim\Http\Response;
class ShaarePublishController extends ShaarliAdminController
{
/**
* @var BookmarkFormatter[] Statically cached instances of formatters
*/
protected $formatters = [];
/**
* @var array Statically cached bookmark's tags counts
*/
protected $tags;
/**
* GET /admin/shaare - Displays the bookmark form for creation.
* Note that if the URL is found in existing bookmarks, then it will be in edit mode.
*/
public function displayCreateForm(Request $request, Response $response): Response
{
$url = cleanup_url($request->getParam('post'));
$link = $this->buildLinkDataFromUrl($request, $url);
return $this->displayForm($link, $link['linkIsNew'], $request, $response);
}
/**
* POST /admin/shaare-batch - Displays multiple creation/edit forms from bulk add in add-link page.
*/
public function displayCreateBatchForms(Request $request, Response $response): Response
{
$urls = array_map('cleanup_url', explode(PHP_EOL, $request->getParam('urls')));
$links = [];
foreach ($urls as $url) {
if (empty($url)) {
continue;
}
$link = $this->buildLinkDataFromUrl($request, $url);
$data = $this->buildFormData($link, $link['linkIsNew'], $request);
$data['token'] = $this->container->sessionManager->generateToken();
$data['source'] = 'batch';
$this->executePageHooks('render_editlink', $data, TemplatePage::EDIT_LINK);
$links[] = $data;
}
$this->assignView('links', $links);
$this->assignView('batch_mode', true);
$this->assignView('async_metadata', $this->container->conf->get('general.enable_async_metadata', true));
return $response->write($this->render(TemplatePage::EDIT_LINK_BATCH));
}
/**
* GET /admin/shaare/{id} - Displays the bookmark form in edition mode.
*/
public function displayEditForm(Request $request, Response $response, array $args): Response
{
$id = $args['id'] ?? '';
try {
if (false === ctype_digit($id)) {
throw new BookmarkNotFoundException();
}
$bookmark = $this->container->bookmarkService->get((int) $id); // Read database
} catch (BookmarkNotFoundException $e) {
$this->saveErrorMessage(sprintf(
t('Bookmark with identifier %s could not be found.'),
$id
));
return $this->redirect($response, '/');
}
$formatter = $this->getFormatter('raw');
$link = $formatter->format($bookmark);
return $this->displayForm($link, false, $request, $response);
}
/**
* POST /admin/shaare
*/
public function save(Request $request, Response $response): Response
{
$this->checkToken($request);
// lf_id should only be present if the link exists.
$id = $request->getParam('lf_id') !== null ? intval(escape($request->getParam('lf_id'))) : null;
if (null !== $id && true === $this->container->bookmarkService->exists($id)) {
// Edit
$bookmark = $this->container->bookmarkService->get($id);
} else {
// New link
$bookmark = new Bookmark();
}
$bookmark->setTitle($request->getParam('lf_title'));
$bookmark->setDescription($request->getParam('lf_description'));
$bookmark->setUrl($request->getParam('lf_url'), $this->container->conf->get('security.allowed_protocols', []));
$bookmark->setPrivate(filter_var($request->getParam('lf_private'), FILTER_VALIDATE_BOOLEAN));
$bookmark->setTagsString(
$request->getParam('lf_tags'),
$this->container->conf->get('general.tags_separator', ' ')
);
if (
$this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE
&& true !== $this->container->conf->get('general.enable_async_metadata', true)
&& $bookmark->shouldUpdateThumbnail()
) {
$bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl()));
}
$this->container->bookmarkService->addOrSet($bookmark, false);
// To preserve backward compatibility with 3rd parties, plugins still use arrays
$formatter = $this->getFormatter('raw');
$data = $formatter->format($bookmark);
$this->executePageHooks('save_link', $data);
$bookmark->fromArray($data, $this->container->conf->get('general.tags_separator', ' '));
$this->container->bookmarkService->set($bookmark);
// If we are called from the bookmarklet, we must close the popup:
if ($request->getParam('source') === 'bookmarklet') {
return $response->write('<script>self.close();</script>');
} elseif ($request->getParam('source') === 'batch') {
return $response;
}
if (!empty($request->getParam('returnurl'))) {
$this->container->environment['HTTP_REFERER'] = $request->getParam('returnurl');
}
return $this->redirectFromReferer(
$request,
$response,
['/admin/add-shaare', '/admin/shaare'],
['addlink', 'post', 'edit_link'],
$bookmark->getShortUrl()
);
}
/**
* Helper function used to display the shaare form whether it's a new or existing bookmark.
*
* @param array $link data used in template, either from parameters or from the data store
*/
protected function displayForm(array $link, bool $isNew, Request $request, Response $response): Response
{
$data = $this->buildFormData($link, $isNew, $request);
$this->executePageHooks('render_editlink', $data, TemplatePage::EDIT_LINK);
foreach ($data as $key => $value) {
$this->assignView($key, $value);
}
$editLabel = false === $isNew ? t('Edit') . ' ' : '';
$this->assignView(
'pagetitle',
$editLabel . t('Shaare') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
);
return $response->write($this->render(TemplatePage::EDIT_LINK));
}
protected function buildLinkDataFromUrl(Request $request, string $url): array
{
// Check if URL is not already in database (in this case, we will edit the existing link)
$bookmark = $this->container->bookmarkService->findByUrl($url);
if (null === $bookmark) {
// Get shaare data if it was provided in URL (e.g.: by the bookmarklet).
$title = $request->getParam('title');
$description = $request->getParam('description');
$tags = $request->getParam('tags');
if ($request->getParam('private') !== null) {
$private = filter_var($request->getParam('private'), FILTER_VALIDATE_BOOLEAN);
} else {
$private = $this->container->conf->get('privacy.default_private_links', false);
}
// If this is an HTTP(S) link, we try go get the page to extract
// the title (otherwise we will to straight to the edit form.)
if (
true !== $this->container->conf->get('general.enable_async_metadata', true)
&& empty($title)
&& strpos(get_url_scheme($url) ?: '', 'http') !== false
) {
$metadata = $this->container->metadataRetriever->retrieve($url);
}
if (empty($url)) {
$metadata['title'] = $this->container->conf->get('general.default_note_title', t('Note: '));
}
return [
'title' => $title ?? $metadata['title'] ?? '',
'url' => $url ?? '',
'description' => $description ?? $metadata['description'] ?? '',
'tags' => $tags ?? $metadata['tags'] ?? '',
'private' => $private,
'linkIsNew' => true,
];
}
$formatter = $this->getFormatter('raw');
$link = $formatter->format($bookmark);
$link['linkIsNew'] = false;
return $link;
}
protected function buildFormData(array $link, bool $isNew, Request $request): array
{
$link['tags'] = $link['tags'] !== null && strlen($link['tags']) > 0
? $link['tags'] . $this->container->conf->get('general.tags_separator', ' ')
: $link['tags']
;
return escape([
'link' => $link,
'link_is_new' => $isNew,
'http_referer' => $this->container->environment['HTTP_REFERER'] ?? '',
'source' => $request->getParam('source') ?? '',
'tags' => $this->getTags(),
'default_private_links' => $this->container->conf->get('privacy.default_private_links', false),
'async_metadata' => $this->container->conf->get('general.enable_async_metadata', true),
'retrieve_description' => $this->container->conf->get('general.retrieve_description', false),
]);
}
/**
* Memoize formatterFactory->getFormatter() calls.
*/
protected function getFormatter(string $type): BookmarkFormatter
{
if (!array_key_exists($type, $this->formatters) || $this->formatters[$type] === null) {
$this->formatters[$type] = $this->container->formatterFactory->getFormatter($type);
}
return $this->formatters[$type];
}
/**
* Memoize bookmarkService->bookmarksCountPerTag() calls.
*/
protected function getTags(): array
{
if ($this->tags === null) {
$this->tags = $this->container->bookmarkService->bookmarksCountPerTag();
if ($this->container->conf->get('formatter') === 'markdown') {
$this->tags[BookmarkMarkdownFormatter::NO_MD_TAG] = 1;
}
}
return $this->tags;
}
}

View file

@ -1,71 +0,0 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Admin;
use Shaarli\Front\Controller\Visitor\ShaarliVisitorController;
use Shaarli\Front\Exception\WrongTokenException;
use Shaarli\Security\SessionManager;
use Slim\Http\Request;
/**
* Class ShaarliAdminController
*
* All admin controllers (for logged in users) MUST extend this abstract class.
* It makes sure that the user is properly logged in, and otherwise throw an exception
* which will redirect to the login page.
*
* @package Shaarli\Front\Controller\Admin
*/
abstract class ShaarliAdminController extends ShaarliVisitorController
{
/**
* Any persistent action to the config or data store must check the XSRF token validity.
*/
protected function checkToken(Request $request): bool
{
if (!$this->container->sessionManager->checkToken($request->getParam('token'))) {
throw new WrongTokenException();
}
return true;
}
/**
* Save a SUCCESS message in user session, which will be displayed on any template page.
*/
protected function saveSuccessMessage(string $message): void
{
$this->saveMessage(SessionManager::KEY_SUCCESS_MESSAGES, $message);
}
/**
* Save a WARNING message in user session, which will be displayed on any template page.
*/
protected function saveWarningMessage(string $message): void
{
$this->saveMessage(SessionManager::KEY_WARNING_MESSAGES, $message);
}
/**
* Save an ERROR message in user session, which will be displayed on any template page.
*/
protected function saveErrorMessage(string $message): void
{
$this->saveMessage(SessionManager::KEY_ERROR_MESSAGES, $message);
}
/**
* Use the sessionManager to save the provided message using the proper type.
*
* @param string $type successes/warnings/errors
*/
protected function saveMessage(string $type, string $message): void
{
$messages = $this->container->sessionManager->getSessionParameter($type) ?? [];
$messages[] = $message;
$this->container->sessionManager->setSessionParameter($type, $messages);
}
}

View file

@ -1,65 +0,0 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Admin;
use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
use Shaarli\Render\TemplatePage;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Class ToolsController
*
* Slim controller used to handle thumbnails update.
*/
class ThumbnailsController extends ShaarliAdminController
{
/**
* GET /admin/thumbnails - Display thumbnails update page
*/
public function index(Request $request, Response $response): Response
{
$ids = [];
foreach ($this->container->bookmarkService->search()->getBookmarks() as $bookmark) {
// A note or not HTTP(S)
if ($bookmark->isNote() || !startsWith(strtolower($bookmark->getUrl()), 'http')) {
continue;
}
$ids[] = $bookmark->getId();
}
$this->assignView('ids', $ids);
$this->assignView(
'pagetitle',
t('Thumbnails update') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
);
return $response->write($this->render(TemplatePage::THUMBNAILS));
}
/**
* PATCH /admin/shaare/{id}/thumbnail-update - Route for AJAX calls
*/
public function ajaxUpdate(Request $request, Response $response, array $args): Response
{
$id = $args['id'] ?? '';
if (false === ctype_digit($id)) {
return $response->withStatus(400);
}
try {
$bookmark = $this->container->bookmarkService->get((int) $id);
} catch (BookmarkNotFoundException $e) {
return $response->withStatus(404);
}
$bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl()));
$this->container->bookmarkService->set($bookmark);
return $response->withJson($this->container->formatterFactory->getFormatter('raw')->format($bookmark));
}
}

View file

@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Admin;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Class TokenController
*
* Endpoint used to retrieve a XSRF token. Useful for AJAX requests.
*/
class TokenController extends ShaarliAdminController
{
/**
* GET /admin/token
*/
public function getToken(Request $request, Response $response): Response
{
$response = $response->withHeader('Content-Type', 'text/plain');
return $response->write($this->container->sessionManager->generateToken());
}
}

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