Compare commits
2 commits
984073a980
...
2c1f0981d9
Author | SHA1 | Date | |
---|---|---|---|
2c1f0981d9 | |||
23a5fc1eef |
307 changed files with 36898 additions and 14068 deletions
25
.htaccess
25
.htaccess
|
@ -7,31 +7,20 @@ RewriteEngine On
|
||||||
RewriteRule ^(.git|doxygen|vendor) - [F]
|
RewriteRule ^(.git|doxygen|vendor) - [F]
|
||||||
|
|
||||||
# Forward the "Authorization" HTTP header
|
# Forward the "Authorization" HTTP header
|
||||||
|
# fixes JWT token not correctly forwarded on some Apache/FastCGI setups
|
||||||
RewriteCond %{HTTP:Authorization} ^(.*)
|
RewriteCond %{HTTP:Authorization} ^(.*)
|
||||||
RewriteRule .* - [e=HTTP_AUTHORIZATION:%1]
|
RewriteRule .* - [e=HTTP_AUTHORIZATION:%1]
|
||||||
|
# Alternative (if the 2 lines above don't work)
|
||||||
|
# SetEnvIf Authorization .+ HTTP_AUTHORIZATION=$0
|
||||||
|
|
||||||
# REST API
|
# Slim URL Redirection
|
||||||
|
# Ionos Hosting needs RewriteBase /
|
||||||
|
# RewriteBase /
|
||||||
RewriteCond %{REQUEST_FILENAME} !-f
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
RewriteCond %{REQUEST_FILENAME} !-d
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
RewriteRule ^ index.php [QSA,L]
|
RewriteRule ^ index.php [QSA,L]
|
||||||
|
|
||||||
<Limit GET POST PUT DELETE OPTIONS>
|
<LimitExcept GET POST PUT DELETE PATCH OPTIONS>
|
||||||
<IfModule version_module>
|
|
||||||
<IfVersion >= 2.4>
|
|
||||||
Require all granted
|
|
||||||
</IfVersion>
|
|
||||||
<IfVersion < 2.4>
|
|
||||||
Allow from all
|
|
||||||
Deny from none
|
|
||||||
</IfVersion>
|
|
||||||
</IfModule>
|
|
||||||
|
|
||||||
<IfModule !version_module>
|
|
||||||
Require all granted
|
|
||||||
</IfModule>
|
|
||||||
</Limit>
|
|
||||||
|
|
||||||
<LimitExcept GET POST PUT DELETE OPTIONS>
|
|
||||||
<IfModule version_module>
|
<IfModule version_module>
|
||||||
<IfVersion >= 2.4>
|
<IfVersion >= 2.4>
|
||||||
Require all denied
|
Require all denied
|
||||||
|
|
72
AUTHORS
72
AUTHORS
|
@ -1,47 +1,73 @@
|
||||||
769 ArthurHoaro <arthur@hoa.ro>
|
1206 ArthurHoaro <arthur@hoa.ro>
|
||||||
401 VirtualTam <virtualtam@flibidi.net>
|
405 VirtualTam <virtualtam@flibidi.net>
|
||||||
216 nodiscc <nodiscc@gmail.com>
|
384 nodiscc <nodiscc@gmail.com>
|
||||||
56 Sébastien Sauvage <sebsauvage@sebsauvage.net>
|
56 Sébastien Sauvage <sebsauvage@sebsauvage.net>
|
||||||
|
23 dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
|
||||||
|
19 Keith Carangelo <mail@kcaran.com>
|
||||||
|
16 Luce Carević <lcarevic@access42.net>
|
||||||
15 Florian Eula <eula.florian@gmail.com>
|
15 Florian Eula <eula.florian@gmail.com>
|
||||||
13 Emilien Klein <emilien@klein.st>
|
14 Emilien Klein <emilien@klein.st>
|
||||||
13 Luce Carević <lcarevic@access42.net>
|
|
||||||
12 Nicolas Danelon <hi@nicolasmd.com.ar>
|
12 Nicolas Danelon <hi@nicolasmd.com.ar>
|
||||||
|
9 Lucas Cimon <lucas.cimon@gmail.com>
|
||||||
9 Willi Eggeling <thewilli@gmail.com>
|
9 Willi Eggeling <thewilli@gmail.com>
|
||||||
8 Christophe HENRY <christophe.henry@sbgodin.fr>
|
8 Christophe HENRY <christophe.henry@sbgodin.fr>
|
||||||
|
6 Immánuel Fodor <immanuelfactor+github@gmail.com>
|
||||||
|
6 YFdyh000 <yfdyh000@gmail.com>
|
||||||
|
6 kalvn <kalvnthereal@gmail.com>
|
||||||
6 B. van Berkum <dev@dotmpe.com>
|
6 B. van Berkum <dev@dotmpe.com>
|
||||||
6 llune <llune@users.noreply.github.com>
|
6 llune <llune@users.noreply.github.com>
|
||||||
5 Lucas Cimon <lucas.cimon@gmail.com>
|
|
||||||
5 Mark Schmitz <kramred@gmail.com>
|
5 Mark Schmitz <kramred@gmail.com>
|
||||||
5 kalvn <kalvnthereal@gmail.com>
|
5 Sébastien NOBILI <code@pipoprods.org>
|
||||||
4 Alexandre Alapetite <alexandre@alapetite.fr>
|
4 Alexandre Alapetite <alexandre@alapetite.fr>
|
||||||
|
4 yude <yudesleepy@gmail.com>
|
||||||
4 David Sferruzza <david.sferruzza@gmail.com>
|
4 David Sferruzza <david.sferruzza@gmail.com>
|
||||||
4 Immánuel Fodor <immanuelfactor+github@gmail.com>
|
|
||||||
3 Agurato <mail.vmonot@gmail.com>
|
|
||||||
3 Teromene <teromene@teromene.fr>
|
3 Teromene <teromene@teromene.fr>
|
||||||
2 Alexandre G.-Raymond <alex@ndre.gr>
|
3 yudete <yu@yude.moe>
|
||||||
2 Chris Kuethe <chris.kuethe@gmail.com>
|
3 Agurato <mail.vmonot@gmail.com>
|
||||||
|
3 Olivier <bourreauolivier@gmail.com>
|
||||||
|
3 Christoph Stoettner <christoph.stoettner@stoeps.de>
|
||||||
2 Felix Bartels <felix@host-consultants.de>
|
2 Felix Bartels <felix@host-consultants.de>
|
||||||
2 Knah Tsaeb <Knah-Tsaeb@knah-tsaeb.org>
|
|
||||||
2 Luce Carević <lcarevic@access42.net>
|
|
||||||
2 Mathieu Chabanon <git@matchab.fr>
|
2 Mathieu Chabanon <git@matchab.fr>
|
||||||
2 Miloš Jovanović <mjovanovic@gmail.com>
|
2 Miloš Jovanović <mjovanovic@gmail.com>
|
||||||
|
2 Neros <contact@neros.fr>
|
||||||
|
2 Alexandre G.-Raymond <alex@ndre.gr>
|
||||||
2 Qwerty <champlywood@free.fr>
|
2 Qwerty <champlywood@free.fr>
|
||||||
|
2 Guillaume Virlet <github@virlet.org>
|
||||||
|
2 Sebastien Wains <sebw@users.noreply.github.com>
|
||||||
2 Stephen Muth <smuth4@gmail.com>
|
2 Stephen Muth <smuth4@gmail.com>
|
||||||
2 Timo Van Neerden <fire@lehollandaisvolant.net>
|
2 Timo Van Neerden <fire@lehollandaisvolant.net>
|
||||||
|
2 Alexander Railean <alexandr.railean@arculus.de>
|
||||||
|
2 Doug Breaux <25640850+dougbreaux@users.noreply.github.com>
|
||||||
|
2 flow.gunso <flow.gunso@gmail.com>
|
||||||
|
2 Chris Kuethe <chris.kuethe@gmail.com>
|
||||||
|
2 Ganesh Kandu <kanduganesh@gmail.com>
|
||||||
2 julienCXX <software@chmodplusx.eu>
|
2 julienCXX <software@chmodplusx.eu>
|
||||||
|
2 Knah Tsaeb <Knah-Tsaeb@knah-tsaeb.org>
|
||||||
2 philipp-r <philipp-r@users.noreply.github.com>
|
2 philipp-r <philipp-r@users.noreply.github.com>
|
||||||
2 pips <pips@e5150.fr>
|
2 pips <pips@e5150.fr>
|
||||||
|
2 prog-it <pash.vld@gmail.com>
|
||||||
2 trailjeep <trailjeep@gmail.com>
|
2 trailjeep <trailjeep@gmail.com>
|
||||||
1 Adrien Oliva <adrien.oliva@yapbreak.fr>
|
1 leyrer <gitlab@leyrer.priv.at>
|
||||||
|
1 locness3 <37651007+locness3@users.noreply.github.com>
|
||||||
|
1 owen bell <66233223+xfnw@users.noreply.github.com>
|
||||||
|
1 philipp <philipp@philipp.PC.Ubuntu>
|
||||||
|
1 rfolo9li <50079896+rfolo9li@users.noreply.github.com>
|
||||||
|
1 sprak3000 <sprak3000+github@gmail.com>
|
||||||
|
1 yudejp <i@yude.jp>
|
||||||
|
1 Rajat Hans <rajathans9@gmail.com>
|
||||||
1 Adrien le Maire <adrien@alemaire.be>
|
1 Adrien le Maire <adrien@alemaire.be>
|
||||||
|
1 Ajabep <ajabep@users.noreply.github.com>
|
||||||
1 Alexis J <alexis@effingo.be>
|
1 Alexis J <alexis@effingo.be>
|
||||||
1 Angristan <angristan@users.noreply.github.com>
|
1 Angristan <angristan@users.noreply.github.com>
|
||||||
1 Bish Erbas <42714627+bisherbas@users.noreply.github.com>
|
1 Bish Erbas <42714627+bisherbas@users.noreply.github.com>
|
||||||
1 BoboTiG <bobotig@gmail.com>
|
1 BoboTiG <bobotig@gmail.com>
|
||||||
|
1 Brendan M. Sleight <bms.git@barwap.com>
|
||||||
1 Bronco <bronco@warriordudimanche.net>
|
1 Bronco <bronco@warriordudimanche.net>
|
||||||
1 Buster One <37770318+buster-one@users.noreply.github.com>
|
1 Buster One <37770318+buster-one@users.noreply.github.com>
|
||||||
1 D Low <daniellowtw@gmail.com>
|
1 D Low <daniellowtw@gmail.com>
|
||||||
1 Daniel Jakots <vigdis@chown.me>
|
1 Daniel Jakots <vigdis@chown.me>
|
||||||
|
1 David Foucher <dev@tyjak.net>
|
||||||
|
1 Denis Renning <denis@devtty.de>
|
||||||
1 Dennis Verspuij <dennisverspuij@users.noreply.github.com>
|
1 Dennis Verspuij <dennisverspuij@users.noreply.github.com>
|
||||||
1 Dimtion <zizou.xena@gmail.com>
|
1 Dimtion <zizou.xena@gmail.com>
|
||||||
1 Fanch <fanch-github@qth.fr>
|
1 Fanch <fanch-github@qth.fr>
|
||||||
|
@ -49,19 +75,31 @@
|
||||||
1 Florian Voigt <flvoigt@me.com>
|
1 Florian Voigt <flvoigt@me.com>
|
||||||
1 Franck Kerbiriou <FranckKe@users.noreply.github.com>
|
1 Franck Kerbiriou <FranckKe@users.noreply.github.com>
|
||||||
1 Gary Marigliano <gmarigliano93@gmail.com>
|
1 Gary Marigliano <gmarigliano93@gmail.com>
|
||||||
1 Guillaume Virlet <github@virlet.org>
|
1 Gregory <gregory@nosheep.fr>
|
||||||
|
1 Hazhar Galeh <78073762+hazhargaleh@users.noreply.github.com>
|
||||||
|
1 Hg <dev@indigo.re>
|
||||||
|
1 Jens Kubieziel <github@kubieziel.de>
|
||||||
1 Jonathan Amiez <jonathan.amiez@gmail.com>
|
1 Jonathan Amiez <jonathan.amiez@gmail.com>
|
||||||
1 Jonathan Druart <jonathan.druart@gmail.com>
|
1 Jonathan Druart <jonathan.druart@gmail.com>
|
||||||
1 Julien Pivotto <roidelapluie@inuits.eu>
|
1 Julien Pivotto <roidelapluie@inuits.eu>
|
||||||
1 Kevin Canévet <kevin@streamroot.io>
|
1 Kevin Canévet <kevin@streamroot.io>
|
||||||
|
1 Kevin Masson <kevin.masson@methodinthemadness.eu>
|
||||||
1 Knah Tsaeb <knah-tsaeb@knah-tsaeb.org>
|
1 Knah Tsaeb <knah-tsaeb@knah-tsaeb.org>
|
||||||
1 Lionel Martin <renarddesmers@gmail.com>
|
1 Lionel Martin <renarddesmers@gmail.com>
|
||||||
|
1 Loïc Carr <zizou.xena@gmail.com>
|
||||||
1 Mark Gerarts <mark.gerarts@gmail.com>
|
1 Mark Gerarts <mark.gerarts@gmail.com>
|
||||||
1 Marsup <marsup@gmail.com>
|
1 Marsup <marsup@gmail.com>
|
||||||
1 Neros <contact@neros.fr>
|
1 Nicolas Friedli <nicolas@theologique.ch>
|
||||||
1 Rajat Hans <rajathans9@gmail.com>
|
1 Paul van den Burg <github@paulvandenburg.nl>
|
||||||
|
1 Adrien Oliva <adrien.oliva@yapbreak.fr>
|
||||||
1 Sbgodin <Sbgodin@users.noreply.github.com>
|
1 Sbgodin <Sbgodin@users.noreply.github.com>
|
||||||
|
1 ToM <tom@leloop.org>
|
||||||
1 TsT <tst2005@gmail.com>
|
1 TsT <tst2005@gmail.com>
|
||||||
|
1 agentcobra <agentcobra@free.fr>
|
||||||
|
1 aguy <aguytech@users.noreply.github.com>
|
||||||
|
1 bschwede <gummibando@gmx.net>
|
||||||
1 dimtion <zizou.xena@gmail.com>
|
1 dimtion <zizou.xena@gmail.com>
|
||||||
1 durcheinandr <jochen@durcheinandr.de>
|
1 durcheinandr <jochen@durcheinandr.de>
|
||||||
|
1 heimpogo <hypertexthome@googlemail.com>
|
||||||
|
1 jalr <mail@jalr.de>
|
||||||
1 lapineige <lapineige@users.noreply.github.com>
|
1 lapineige <lapineige@users.noreply.github.com>
|
||||||
|
|
265
CHANGELOG.md
265
CHANGELOG.md
|
@ -4,44 +4,234 @@ All notable changes to this project will be documented in this file.
|
||||||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||||
|
|
||||||
<<<<<<< HEAD
|
## [v0.12.2](https://github.com/shaarli/Shaarli/releases/tag/v0.12.2) - 2023-03-18
|
||||||
## [v0.10.4](https://github.com/shaarli/Shaarli/releases/tag/v0.10.4) - 2019-04-16
|
|
||||||
|
|
||||||
### Fixed
|
> Docker: use `ghcr.io/shaarli/shaarli` as Docker image instead of `shaarli/shaarli`.
|
||||||
- Fix thumbnails disabling if PHP GD is not installed
|
> The `:master` Docker image has been removed, please use `:latest` instead.
|
||||||
- Fix a warning if links sticky status isn't set
|
> The `:stable` Docker image has been removed, please use `:release` instead.
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
- Bulk action: add or delete tag to multiple bookmarks
|
||||||
|
- New Core Plugin: ReadItLater
|
||||||
|
- Plugin system: allow plugins to provide custom routes
|
||||||
|
- Support search highlights when matching URL content
|
||||||
|
- Support for OR (~) and optional AND (+) operators for tag search
|
||||||
|
- Russian translation
|
||||||
|
- Chinese translation
|
||||||
|
- Export:
|
||||||
|
- Export: set a bookmark's LAST_MODIFIED attribute to its update timestamp
|
||||||
|
- Export: set a bookmark's PRIVATE attribute using an integer value
|
||||||
|
- Add an additional free disk space check before saving the datastore
|
||||||
|
- curl: support HTTP/2 response code header
|
||||||
|
- CI:
|
||||||
|
- Build and push Docker images through Github Actions
|
||||||
|
- push container images to github registry in addition to dockerhub
|
||||||
|
- Documentation:
|
||||||
|
- Add '206 not acceptable' to the Troubleshooting section
|
||||||
|
- Add mention to Shaarli Archiver
|
||||||
|
- doc: add note to adjust proxy timeouts or PHP max execution time
|
||||||
|
- doc: shaarli configuration: mention file:/// URIs
|
||||||
|
- add "formatter" key to example config.json.php
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
- docker latest: replace dev in shaarli_version.php with the latest commit
|
||||||
|
- Daily RSS Cache: invalidate cache base on the date
|
||||||
|
- Update Japanese translations
|
||||||
|
- Update German translations
|
||||||
|
- Templates: Inject current template name
|
||||||
|
- format_date: include timezone in IntlDateFormatter object
|
||||||
|
- Handle pagination through BookmarkService
|
||||||
|
- autocapitalize off for username input
|
||||||
|
- More intuitive label for plugin checkboxes
|
||||||
|
- Simple and uniform localized website title
|
||||||
|
- Use rewrited version of Netscape Bookmark Parser
|
||||||
|
- tests/makefile: rewrite translate target to be compatible with busybox
|
||||||
|
- PubSubHub Plugin: make 1 external call per request
|
||||||
|
- Docker:
|
||||||
|
- newer alpine (for newer PHP) and apk upgrade
|
||||||
|
- Dockerfile.armhf: upgrade python2 -> python3
|
||||||
|
- Dockerfile: add php8-gettext package
|
||||||
|
- update s6 service definition to use php-fpm8
|
||||||
|
- install php8-ldap in Docker images
|
||||||
|
- CI:
|
||||||
|
- use Github Action instead of Travis CI
|
||||||
|
- use the yarnpkg command instead of yarn
|
||||||
|
- tools: github actions: fix PHP 8.0 tests
|
||||||
|
- github actions: add tests for PHP 8.2
|
||||||
|
- Documentation:
|
||||||
|
- apache: explicitely ste index.php as DirectoryIndex
|
||||||
|
- bookmarklet is now working on github.com
|
||||||
|
- LDAP login support, update php requirements list
|
||||||
|
- installation/tests: clarify build tools installation procedure
|
||||||
|
- doc: PHP extensions are also required for development
|
||||||
|
- doc: move OCI images hosting to ghcr.io
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- Error handling if the datastore mutex is not working
|
||||||
|
- Synchronous metadata retrieval is failing in strict mode
|
||||||
|
- Improve metadata extraction
|
||||||
|
- Typo: 'Authentication' ->
|
||||||
|
- default_colors plugin: update CSS file on color change
|
||||||
|
- API: POST/PUT Link - properly parse tags string
|
||||||
|
- Error when using bulk shaare with a single URL
|
||||||
|
- Bulk Shaare:
|
||||||
|
- use unique HTML ID
|
||||||
|
- error with a single URL
|
||||||
|
- redirection with ending slash
|
||||||
|
- Bug when trying to access ATOM feed without bookmarks
|
||||||
|
- Documentation build
|
||||||
|
- pubsubhubbub hub link in RSS / Atom.
|
||||||
|
- Monthly views previous/next month links during month
|
||||||
|
- Resolve PHP 8.1 deprecation warnings
|
||||||
|
- Fix PHP 8 incompatibility with debug mode enabled
|
||||||
|
- Fixed Roboto-Regular and Roboto-Bold font declarations
|
||||||
|
- template/vintage: fix typo in visibility selection link
|
||||||
|
- Do not display deprecated warnings by default
|
||||||
|
- Fix a bug when using '/' as a tag separator
|
||||||
|
- Fix Logger exception: gracefully handle permission issue
|
||||||
|
- Documentation:
|
||||||
|
- plugins.md: fix link casing
|
||||||
|
|
||||||
|
## Removed
|
||||||
|
|
||||||
|
- Daily RSS: Remove relative description (today, yesterday)
|
||||||
|
- Documentation:
|
||||||
|
- remove the markdown plugin from the plugins list
|
||||||
|
- remove duplicate "general" key in example config.php.json
|
||||||
|
|
||||||
|
## [v0.12.1](https://github.com/shaarli/Shaarli/releases/tag/v0.12.1) - 2020-11-12
|
||||||
|
|
||||||
|
> nginx ([#1628](https://github.com/shaarli/Shaarli/pull/1628)) and Apache ([#1630](https://github.com/shaarli/Shaarli/pull/1630)) configurations have been reviewed. It is recommended that you
|
||||||
|
> update yours using [the documentation](https://shaarli.readthedocs.io/en/master/Server-configuration/).
|
||||||
|
> Users using official Docker image will receive updated configuration automatically.
|
||||||
|
|
||||||
## [v0.10.3](https://github.com/shaarli/Shaarli/releases/tag/v0.10.3) - 2019-02-23
|
|
||||||
### Added
|
### Added
|
||||||
- Add OpenGraph metadata tags on permalink page
|
- Bulk creation of bookmarks
|
||||||
- Add CORS headers to REST API reponses
|
- Server administration tool page (and install page requirements)
|
||||||
- Add a button to toggle checkboxes of displayed links
|
- Support any tag separator, not just whitespaces
|
||||||
- Add an icon to the link list when the Isso plugin is enabled
|
- Share a private bookmark using a URL with a token
|
||||||
- Add noindex, nofollow to documentation pages
|
- Add a setting to retrieve bookmark metadata asynchronously (enabled by default)
|
||||||
- Document usage of robots.txt
|
- Highlight fulltext search results
|
||||||
- Add a button to set links as sticky
|
- Weekly and monthly view/RSS feed for daily page
|
||||||
|
- MarkdownExtra formatter
|
||||||
|
- Default formatter: add a setting to disable auto-linkification
|
||||||
|
- Add mutex on datastore I/O operations to prevent data loss
|
||||||
|
- PHP 8.0 support
|
||||||
|
- REST API: allow override of creation and update dates
|
||||||
|
- Add strict types for bookmarks management
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Update French translation
|
- Improve regex and performances to extract HTML metadata (title, description, etc.)
|
||||||
- Refactor the documentation homepage
|
- Support using Shaarli without URL rewriting (prefix URL with `/index.php/`)
|
||||||
- Bump netscape-bookmark-parser
|
- Improve the "Manage tags" tools page
|
||||||
- Update session_start condition
|
- Use PSR-3 logger for login attempts
|
||||||
- Improve accessibility
|
- Move utils classes to Shaarli\Helper namespace and folder
|
||||||
- Cleanup and refactor lint tooling
|
- Include php-simplexml in Docker image
|
||||||
|
- Raise 404 error instead of 500 if permalink access is denied
|
||||||
|
- Display error details even with dev.debug set to false
|
||||||
|
- Reviewed nginx configuration
|
||||||
|
- Reviewed Apache configuration
|
||||||
|
- Replace vimeo link in demo bookmarks due to IP ban on the demo instance
|
||||||
|
- Apply PSR-12 on code base, and add CI check using PHPCS
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Fix input size for dropdown search form
|
- Compatiliby issue on login with PHP 7.1
|
||||||
- Fix history for bulk link deletion
|
- Japanese translations update
|
||||||
- Fix thumbnail requests
|
- Redirect to referrer after bookmark deletion
|
||||||
- Fix hashtag rendering when markdown escaping is enabled
|
- Inject ROOT_PATH in plugin instead of regenerating it everywhere
|
||||||
- Fix AJAX tag deletion
|
- Wallabag plugin: minor improvements
|
||||||
- Fix lint errors and improve PSR-1 and PSR-2 compliance
|
- REST API postLink: change relative path to absolute path
|
||||||
|
- Webpack: fix vintage theme images include
|
||||||
|
- Docker-compose: fix SSL certificate + add parameter for Docker tag
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
- Remove Firefox Share documentation
|
- `config.json.php` new lines in prefix/suffix to prevent issues with Windows PHP
|
||||||
|
|
||||||
|
## [v0.12.0](https://github.com/shaarli/Shaarli/releases/tag/v0.12.0) - 2020-10-13
|
||||||
|
|
||||||
|
**Save you `data/` folder before updating!**
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Thumbnailer: add soundcloud.com to list of common media domains
|
||||||
|
- Markdown rendering is now integrated into Shaarli core
|
||||||
|
- Add autofocus on tag cloud filter input
|
||||||
|
- Japanese translations
|
||||||
|
- Japanese translation: add language to admin configuration page
|
||||||
|
- Support for PHP 8.0
|
||||||
|
- Support for local anchor URL (starting with `#`)
|
||||||
|
- LDAP authentication
|
||||||
|
- Encapsulated PageCacheManager
|
||||||
|
- Docs:
|
||||||
|
- add screenshots of all pages
|
||||||
|
- section about mkdocs
|
||||||
|
- Ulauncher extension
|
||||||
|
- CI: run against PHP 7.4
|
||||||
|
- Added $links_per_page variable to template and display on default
|
||||||
|
- Inject BookmarkServiceInterface in plugins data
|
||||||
|
- Add manual configuration for root URL
|
||||||
|
- Added PATCH to the allowed Apache request methods.
|
||||||
|
- REST API: compatibility with ionos Apache's headers
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Introduce Bookmark object and Service layer
|
||||||
|
- Save bookmark as objects in the datastore
|
||||||
|
- Handle bookmark as objects across the whole codebase (except templates and plugins)
|
||||||
|
- Process all Shaarli page through Slim controller, with proper URL rewriting (see #1516)
|
||||||
|
- Docs: the entire documentation has been reviewed, updated and improved, thanks to @nodiscc!
|
||||||
|
- ATOM feed: use instance name as author name instead of URL
|
||||||
|
- Updated French translation
|
||||||
|
- Default colors plugin: generate CSS file during initialization
|
||||||
|
- Improve default bookmarks after install
|
||||||
|
- Upgrade all front end dependencies and webpack build
|
||||||
|
- Default theme: Make tag cloud/list views buttons more obvious
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Undefined index: thumbnail in daily page
|
||||||
|
- Undefined index: thumbnail on OpenGraph headers
|
||||||
|
- Undefined index: updated on linklist
|
||||||
|
- Make sure that bookmark sort is consistent, even with equal timestamps
|
||||||
|
- Code PHP version check as requirement bumped to PHP 7.1
|
||||||
|
- Thumbnail images lazy loading
|
||||||
|
- Markdown plugin: fix RSS feed direct link reverse
|
||||||
|
- Fix RSS permalink included in Markdown bloc
|
||||||
|
- Demo plugin: multiple typos
|
||||||
|
- Makefile target for releases
|
||||||
|
- Makefile target for html documentation
|
||||||
|
- Session cookie setting being set while session is active
|
||||||
|
- Deprecated use of implode
|
||||||
|
- Division by zero in tag cloud
|
||||||
|
- CI: deprecated linux distribution and sudo directive
|
||||||
|
- Docker build: gcc is no longer included in python alpine image
|
||||||
|
- Default template: display pin button in mobile view
|
||||||
|
- Pinned bookmarks are not longer displayed first in ATOM/RSS feeds
|
||||||
|
- Docs:
|
||||||
|
- Outdated Docker documentation for stable branch
|
||||||
|
- Outdated links
|
||||||
|
- Plugin description in meta files
|
||||||
|
- docker-compose.yml: pin traefik image to 1.7-alpine
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- Markdown plugin
|
||||||
|
- Docs:
|
||||||
|
- emojione & twemoji removed
|
||||||
|
- Makefile: remove static_analysis_summary from all: target
|
||||||
|
- doc/Makefile: remove references to composer update
|
||||||
|
|
||||||
|
## [v0.11.1](https://github.com/shaarli/Shaarli/releases/tag/v0.11.1) - 2019-08-03
|
||||||
|
|
||||||
|
Release to fix broken Docker build on the latest version.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed Docker build
|
||||||
|
- Fixed a few documentation broken links
|
||||||
|
- Fixed broken label in configuration page
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- More accessibility improvements
|
||||||
|
|
||||||
||||||| merged common ancestors
|
|
||||||
=======
|
|
||||||
## [v0.11.0](https://github.com/shaarli/Shaarli/releases/tag/v0.11.0) - 2019-07-27
|
## [v0.11.0](https://github.com/shaarli/Shaarli/releases/tag/v0.11.0) - 2019-07-27
|
||||||
|
|
||||||
**Shaarli no longer officially support PHP 5.6 and PHP 7.0 as they've reached end of life.**
|
**Shaarli no longer officially support PHP 5.6 and PHP 7.0 as they've reached end of life.**
|
||||||
|
@ -122,7 +312,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||||
### Removed
|
### Removed
|
||||||
- Remove Firefox Share documentation
|
- Remove Firefox Share documentation
|
||||||
|
|
||||||
>>>>>>> v0.11.0
|
|
||||||
## [v0.10.2](https://github.com/shaarli/Shaarli/releases/tag/v0.10.2) - 2018-08-11
|
## [v0.10.2](https://github.com/shaarli/Shaarli/releases/tag/v0.10.2) - 2018-08-11
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
@ -366,7 +555,7 @@ configuration to enable URL rewriting, see:
|
||||||
- `/api/v1/info`: get general information on the Shaarli instance
|
- `/api/v1/info`: get general information on the Shaarli instance
|
||||||
- `/api/v1/links`: get a list of shaared links
|
- `/api/v1/links`: get a list of shaared links
|
||||||
- `/api/v1/history`: get a list of latest actions
|
- `/api/v1/history`: get a list of latest actions
|
||||||
Theming:
|
- Theming:
|
||||||
- Introduce a new theme
|
- Introduce a new theme
|
||||||
- Allow selecting themes/templates from the configuration page
|
- Allow selecting themes/templates from the configuration page
|
||||||
- New/Edit link form can be submitted using CTRL+Enter in the textarea
|
- New/Edit link form can be submitted using CTRL+Enter in the textarea
|
||||||
|
@ -425,22 +614,6 @@ Theming:
|
||||||
### Security
|
### Security
|
||||||
- Markdown plugin: escape HTML entities by default
|
- Markdown plugin: escape HTML entities by default
|
||||||
|
|
||||||
## [v0.8.4](https://github.com/shaarli/Shaarli/releases/tag/v0.8.4) - 2017-03-04
|
|
||||||
### Security
|
|
||||||
- Markdown plugin: escape HTML entities by default
|
|
||||||
|
|
||||||
|
|
||||||
## [v0.8.3](https://github.com/shaarli/Shaarli/releases/tag/v0.8.3) - 2017-01-20
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- PHP 7.1 compatibility: add ConfigManager parameter to anti-bruteforce function call in login template.
|
|
||||||
|
|
||||||
## [v0.8.2](https://github.com/shaarli/Shaarli/releases/tag/v0.8.2) - 2016-12-15
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Editing a link created before the new ID system would change its permalink.
|
|
||||||
|
|
||||||
## [v0.8.7](https://github.com/shaarli/Shaarli/releases/tag/v0.8.7) - 2018-06-20
|
## [v0.8.7](https://github.com/shaarli/Shaarli/releases/tag/v0.8.7) - 2018-06-20
|
||||||
### Changed
|
### Changed
|
||||||
|
|
15
README.md
15
README.md
|
@ -6,18 +6,13 @@ _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 link sharing service that you can install on your own server._
|
||||||
_It is designed to be personal (single-user), fast and handy._
|
_It is designed to be personal (single-user), fast and handy._
|
||||||
|
|
||||||
[![](https://img.shields.io/badge/stable-v0.9.7-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.9.7)
|
[![](https://img.shields.io/badge/stable-v0.11.1-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.11.1)
|
||||||
[![](https://img.shields.io/travis/shaarli/Shaarli/stable.svg?label=stable)](https://travis-ci.org/shaarli/Shaarli)
|
[![](https://img.shields.io/badge/latest-v0.12.1-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.12.1)
|
||||||
•
|
[![](https://img.shields.io/badge/master-v0.12.x-blue.svg)](https://github.com/shaarli/Shaarli)
|
||||||
[![](https://img.shields.io/badge/latest-v0.10.4-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.10.4)
|
[![](https://github.com/shaarli/Shaarli/actions/workflows/ci.yml/badge.svg)](https://github.com/shaarli/Shaarli/actions)
|
||||||
[![](https://img.shields.io/travis/shaarli/Shaarli/latest.svg?label=latest)](https://travis-ci.org/shaarli/Shaarli)
|
|
||||||
•
|
|
||||||
[![](https://img.shields.io/badge/master-v0.11.x-blue.svg)](https://github.com/shaarli/Shaarli)
|
|
||||||
[![](https://img.shields.io/travis/shaarli/Shaarli.svg?label=master)](https://travis-ci.org/shaarli/Shaarli)
|
|
||||||
|
|
||||||
[![Join the chat at https://gitter.im/shaarli/Shaarli](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/shaarli/Shaarli)
|
[![Join the chat at https://gitter.im/shaarli/Shaarli](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/shaarli/Shaarli)
|
||||||
[![Bountysource](https://www.bountysource.com/badge/team?team_id=19583&style=bounties_received)](https://www.bountysource.com/teams/shaarli/issues)
|
[![Bountysource](https://www.bountysource.com/badge/team?team_id=19583&style=bounties_received)](https://www.bountysource.com/teams/shaarli/issues)
|
||||||
[![Docker repository](https://img.shields.io/docker/pulls/shaarli/shaarli.svg)](https://hub.docker.com/r/shaarli/shaarli/)
|
[![Docker repository](https://img.shields.io/docker/pulls/shaarli/shaarli.svg)](https://github.com/shaarli/Shaarli/pkgs/container/shaarli)
|
||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,11 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Shaarli;
|
namespace Shaarli;
|
||||||
|
|
||||||
use DateTime;
|
use DateTime;
|
||||||
use Exception;
|
use Exception;
|
||||||
|
use Shaarli\Bookmark\Bookmark;
|
||||||
|
use Shaarli\Helper\FileUtils;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class History
|
* Class History
|
||||||
|
@ -20,7 +23,7 @@ use Exception;
|
||||||
* - UPDATED: link updated
|
* - UPDATED: link updated
|
||||||
* - DELETED: link deleted
|
* - DELETED: link deleted
|
||||||
* - SETTINGS: the settings have been updated through the UI.
|
* - SETTINGS: the settings have been updated through the UI.
|
||||||
* - IMPORT: bulk links import
|
* - IMPORT: bulk bookmarks import
|
||||||
*
|
*
|
||||||
* Note: new events are put at the beginning of the file and history array.
|
* Note: new events are put at the beginning of the file and history array.
|
||||||
*/
|
*/
|
||||||
|
@ -29,27 +32,27 @@ class History
|
||||||
/**
|
/**
|
||||||
* @var string Action key: a new link has been created.
|
* @var string Action key: a new link has been created.
|
||||||
*/
|
*/
|
||||||
const CREATED = 'CREATED';
|
public const CREATED = 'CREATED';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var string Action key: a link has been updated.
|
* @var string Action key: a link has been updated.
|
||||||
*/
|
*/
|
||||||
const UPDATED = 'UPDATED';
|
public const UPDATED = 'UPDATED';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var string Action key: a link has been deleted.
|
* @var string Action key: a link has been deleted.
|
||||||
*/
|
*/
|
||||||
const DELETED = 'DELETED';
|
public const DELETED = 'DELETED';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var string Action key: settings have been updated.
|
* @var string Action key: settings have been updated.
|
||||||
*/
|
*/
|
||||||
const SETTINGS = 'SETTINGS';
|
public const SETTINGS = 'SETTINGS';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var string Action key: a bulk import has been processed.
|
* @var string Action key: a bulk import has been processed.
|
||||||
*/
|
*/
|
||||||
const IMPORT = 'IMPORT';
|
public const IMPORT = 'IMPORT';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var string History file path.
|
* @var string History file path.
|
||||||
|
@ -96,31 +99,31 @@ class History
|
||||||
/**
|
/**
|
||||||
* Add Event: new link.
|
* Add Event: new link.
|
||||||
*
|
*
|
||||||
* @param array $link Link data.
|
* @param Bookmark $link Link data.
|
||||||
*/
|
*/
|
||||||
public function addLink($link)
|
public function addLink($link)
|
||||||
{
|
{
|
||||||
$this->addEvent(self::CREATED, $link['id']);
|
$this->addEvent(self::CREATED, $link->getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add Event: update existing link.
|
* Add Event: update existing link.
|
||||||
*
|
*
|
||||||
* @param array $link Link data.
|
* @param Bookmark $link Link data.
|
||||||
*/
|
*/
|
||||||
public function updateLink($link)
|
public function updateLink($link)
|
||||||
{
|
{
|
||||||
$this->addEvent(self::UPDATED, $link['id']);
|
$this->addEvent(self::UPDATED, $link->getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add Event: delete existing link.
|
* Add Event: delete existing link.
|
||||||
*
|
*
|
||||||
* @param array $link Link data.
|
* @param Bookmark $link Link data.
|
||||||
*/
|
*/
|
||||||
public function deleteLink($link)
|
public function deleteLink($link)
|
||||||
{
|
{
|
||||||
$this->addEvent(self::DELETED, $link['id']);
|
$this->addEvent(self::DELETED, $link->getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -134,7 +137,7 @@ class History
|
||||||
/**
|
/**
|
||||||
* Add Event: bulk import.
|
* Add Event: bulk import.
|
||||||
*
|
*
|
||||||
* Note: we don't store links add/update one by one since it can have a huge impact on performances.
|
* Note: we don't store bookmarks add/update one by one since it can have a huge impact on performances.
|
||||||
*/
|
*/
|
||||||
public function importLinks()
|
public function importLinks()
|
||||||
{
|
{
|
||||||
|
|
|
@ -41,7 +41,7 @@ class Languages
|
||||||
/**
|
/**
|
||||||
* Core translations domain
|
* Core translations domain
|
||||||
*/
|
*/
|
||||||
const DEFAULT_DOMAIN = 'shaarli';
|
public const DEFAULT_DOMAIN = 'shaarli';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var TranslatorInterface
|
* @var TranslatorInterface
|
||||||
|
@ -76,7 +76,8 @@ class Languages
|
||||||
$this->language = $confLanguage;
|
$this->language = $confLanguage;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! extension_loaded('gettext')
|
if (
|
||||||
|
! extension_loaded('gettext')
|
||||||
|| in_array($this->conf->get('translation.mode', 'auto'), ['auto', 'php'])
|
|| in_array($this->conf->get('translation.mode', 'auto'), ['auto', 'php'])
|
||||||
) {
|
) {
|
||||||
$this->initPhpTranslator();
|
$this->initPhpTranslator();
|
||||||
|
@ -98,7 +99,7 @@ class Languages
|
||||||
$this->translator->loadDomain(self::DEFAULT_DOMAIN, 'inc/languages');
|
$this->translator->loadDomain(self::DEFAULT_DOMAIN, 'inc/languages');
|
||||||
|
|
||||||
// Default extension translation from the current theme
|
// Default extension translation from the current theme
|
||||||
$themeTransFolder = rtrim($this->conf->get('raintpl_tpl'), '/') .'/'. $this->conf->get('theme') .'/language';
|
$themeTransFolder = rtrim($this->conf->get('raintpl_tpl'), '/') . '/' . $this->conf->get('theme') . '/language';
|
||||||
if (is_dir($themeTransFolder)) {
|
if (is_dir($themeTransFolder)) {
|
||||||
$this->translator->loadDomain($this->conf->get('theme'), $themeTransFolder, false);
|
$this->translator->loadDomain($this->conf->get('theme'), $themeTransFolder, false);
|
||||||
}
|
}
|
||||||
|
@ -121,7 +122,9 @@ class Languages
|
||||||
$translations = new Translations();
|
$translations = new Translations();
|
||||||
// Core translations
|
// Core translations
|
||||||
try {
|
try {
|
||||||
$translations = $translations->addFromPoFile('inc/languages/'. $this->language .'/LC_MESSAGES/shaarli.po');
|
$translations = $translations->addFromPoFile(
|
||||||
|
'inc/languages/' . $this->language . '/LC_MESSAGES/shaarli.po'
|
||||||
|
);
|
||||||
$translations->setDomain('shaarli');
|
$translations->setDomain('shaarli');
|
||||||
$this->translator->loadTranslations($translations);
|
$this->translator->loadTranslations($translations);
|
||||||
} catch (\InvalidArgumentException $e) {
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
@ -129,11 +132,11 @@ class Languages
|
||||||
|
|
||||||
// Default extension translation from the current theme
|
// Default extension translation from the current theme
|
||||||
$theme = $this->conf->get('theme');
|
$theme = $this->conf->get('theme');
|
||||||
$themeTransFolder = rtrim($this->conf->get('raintpl_tpl'), '/') .'/'. $theme .'/language';
|
$themeTransFolder = rtrim($this->conf->get('raintpl_tpl'), '/') . '/' . $theme . '/language';
|
||||||
if (is_dir($themeTransFolder)) {
|
if (is_dir($themeTransFolder)) {
|
||||||
try {
|
try {
|
||||||
$translations = Translations::fromPoFile(
|
$translations = Translations::fromPoFile(
|
||||||
$themeTransFolder .'/'. $this->language .'/LC_MESSAGES/'. $theme .'.po'
|
$themeTransFolder . '/' . $this->language . '/LC_MESSAGES/' . $theme . '.po'
|
||||||
);
|
);
|
||||||
$translations->setDomain($theme);
|
$translations->setDomain($theme);
|
||||||
$this->translator->loadTranslations($translations);
|
$this->translator->loadTranslations($translations);
|
||||||
|
@ -149,7 +152,7 @@ class Languages
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$extension = Translations::fromPoFile(
|
$extension = Translations::fromPoFile(
|
||||||
$translationPath . $this->language .'/LC_MESSAGES/'. $domain .'.po'
|
$translationPath . $this->language . '/LC_MESSAGES/' . $domain . '.po'
|
||||||
);
|
);
|
||||||
$extension->setDomain($domain);
|
$extension->setDomain($domain);
|
||||||
$this->translator->loadTranslations($extension);
|
$this->translator->loadTranslations($extension);
|
||||||
|
@ -179,9 +182,12 @@ class Languages
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'auto' => t('Automatic'),
|
'auto' => t('Automatic'),
|
||||||
|
'de' => t('German'),
|
||||||
'en' => t('English'),
|
'en' => t('English'),
|
||||||
'fr' => t('French'),
|
'fr' => t('French'),
|
||||||
'de' => t('German'),
|
'jp' => t('Japanese'),
|
||||||
|
'ru' => t('Russian'),
|
||||||
|
'zh_CN' => t('Chinese (Simplified)'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,6 @@ namespace Shaarli;
|
||||||
|
|
||||||
use Shaarli\Config\ConfigManager;
|
use Shaarli\Config\ConfigManager;
|
||||||
use WebThumbnailer\Application\ConfigManager as WTConfigManager;
|
use WebThumbnailer\Application\ConfigManager as WTConfigManager;
|
||||||
use WebThumbnailer\Exception\WebThumbnailerException;
|
|
||||||
use WebThumbnailer\WebThumbnailer;
|
use WebThumbnailer\WebThumbnailer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -14,7 +13,7 @@ use WebThumbnailer\WebThumbnailer;
|
||||||
*/
|
*/
|
||||||
class Thumbnailer
|
class Thumbnailer
|
||||||
{
|
{
|
||||||
const COMMON_MEDIA_DOMAINS = [
|
protected const COMMON_MEDIA_DOMAINS = [
|
||||||
'imgur.com',
|
'imgur.com',
|
||||||
'flickr.com',
|
'flickr.com',
|
||||||
'youtube.com',
|
'youtube.com',
|
||||||
|
@ -27,13 +26,14 @@ class Thumbnailer
|
||||||
'instagram.com',
|
'instagram.com',
|
||||||
'pinterest.com',
|
'pinterest.com',
|
||||||
'pinterest.fr',
|
'pinterest.fr',
|
||||||
|
'soundcloud.com',
|
||||||
'tumblr.com',
|
'tumblr.com',
|
||||||
'deviantart.com',
|
'deviantart.com',
|
||||||
];
|
];
|
||||||
|
|
||||||
const MODE_ALL = 'all';
|
public const MODE_ALL = 'all';
|
||||||
const MODE_COMMON = 'common';
|
public const MODE_COMMON = 'common';
|
||||||
const MODE_NONE = 'none';
|
public const MODE_NONE = 'none';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var WebThumbnailer instance.
|
* @var WebThumbnailer instance.
|
||||||
|
@ -60,7 +60,7 @@ class Thumbnailer
|
||||||
// TODO: create a proper error handling system able to catch exceptions...
|
// TODO: create a proper error handling system able to catch exceptions...
|
||||||
die(t(
|
die(t(
|
||||||
'php-gd extension must be loaded to use thumbnails. '
|
'php-gd extension must be loaded to use thumbnails. '
|
||||||
.'Thumbnails are now disabled. Please reload the page.'
|
. 'Thumbnails are now disabled. Please reload the page.'
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -81,7 +81,8 @@ class Thumbnailer
|
||||||
*/
|
*/
|
||||||
public function get($url)
|
public function get($url)
|
||||||
{
|
{
|
||||||
if ($this->conf->get('thumbnails.mode') === self::MODE_COMMON
|
if (
|
||||||
|
$this->conf->get('thumbnails.mode') === self::MODE_COMMON
|
||||||
&& ! $this->isCommonMediaOrImage($url)
|
&& ! $this->isCommonMediaOrImage($url)
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
|
@ -89,7 +90,7 @@ class Thumbnailer
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return $this->wt->thumbnail($url);
|
return $this->wt->thumbnail($url);
|
||||||
} catch (WebThumbnailerException $e) {
|
} catch (\Throwable $e) {
|
||||||
// Exceptions are only thrown in debug mode.
|
// Exceptions are only thrown in debug mode.
|
||||||
error_log(get_class($e) . ': ' . $e->getMessage());
|
error_log(get_class($e) . ': ' . $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a list of available timezone continents and cities.
|
* Generates a list of available timezone continents and cities.
|
||||||
*
|
*
|
||||||
|
@ -43,7 +44,7 @@ function generateTimeZoneData($installedTimeZones, $preselectedTimezone = '')
|
||||||
// Try to split the provided timezone
|
// Try to split the provided timezone
|
||||||
$spos = strpos($preselectedTimezone, '/');
|
$spos = strpos($preselectedTimezone, '/');
|
||||||
$pcontinent = substr($preselectedTimezone, 0, $spos);
|
$pcontinent = substr($preselectedTimezone, 0, $spos);
|
||||||
$pcity = substr($preselectedTimezone, $spos+1);
|
$pcity = substr($preselectedTimezone, $spos + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
$continents = [];
|
$continents = [];
|
||||||
|
@ -60,7 +61,7 @@ function generateTimeZoneData($installedTimeZones, $preselectedTimezone = '')
|
||||||
}
|
}
|
||||||
|
|
||||||
$continent = substr($tz, 0, $spos);
|
$continent = substr($tz, 0, $spos);
|
||||||
$city = substr($tz, $spos+1);
|
$city = substr($tz, $spos + 1);
|
||||||
$cities[] = ['continent' => $continent, 'city' => $city];
|
$cities[] = ['continent' => $continent, 'city' => $city];
|
||||||
$continents[$continent] = true;
|
$continents[$continent] = true;
|
||||||
}
|
}
|
||||||
|
@ -85,7 +86,7 @@ function generateTimeZoneData($installedTimeZones, $preselectedTimezone = '')
|
||||||
function isTimeZoneValid($continent, $city)
|
function isTimeZoneValid($continent, $city)
|
||||||
{
|
{
|
||||||
return in_array(
|
return in_array(
|
||||||
$continent.'/'.$city,
|
$continent . '/' . $city,
|
||||||
timezone_identifiers_list()
|
timezone_identifiers_list()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,24 +1,27 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shaarli utilities
|
* Shaarli utilities
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Logs a message to a text file
|
* Format log using provided data.
|
||||||
*
|
*
|
||||||
* The log format is compatible with fail2ban.
|
* @param string $message the message to log
|
||||||
|
* @param string|null $clientIp the client's remote IPv4/IPv6 address
|
||||||
*
|
*
|
||||||
* @param string $logFile where to write the logs
|
* @return string Formatted message to log
|
||||||
* @param string $clientIp the client's remote IPv4/IPv6 address
|
|
||||||
* @param string $message the message to log
|
|
||||||
*/
|
*/
|
||||||
function logm($logFile, $clientIp, $message)
|
function format_log(string $message, string $clientIp = null): string
|
||||||
{
|
{
|
||||||
file_put_contents(
|
$out = $message;
|
||||||
$logFile,
|
|
||||||
date('Y/m/d H:i:s').' - '.$clientIp.' - '.strval($message).PHP_EOL,
|
if (!empty($clientIp)) {
|
||||||
FILE_APPEND
|
// Note: we keep the first dash to avoid breaking fail2ban configs
|
||||||
);
|
$out = '- ' . $clientIp . ' - ' . $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -58,6 +61,7 @@ function smallHash($text)
|
||||||
*/
|
*/
|
||||||
function startsWith($haystack, $needle, $case = true)
|
function startsWith($haystack, $needle, $case = true)
|
||||||
{
|
{
|
||||||
|
$needle = $needle ?? '';
|
||||||
if ($case) {
|
if ($case) {
|
||||||
return (strcmp(substr($haystack, 0, strlen($needle)), $needle) === 0);
|
return (strcmp(substr($haystack, 0, strlen($needle)), $needle) === 0);
|
||||||
}
|
}
|
||||||
|
@ -87,18 +91,22 @@ function endsWith($haystack, $needle, $case = true)
|
||||||
*
|
*
|
||||||
* @param mixed $input Data to escape: a single string or an array of strings.
|
* @param mixed $input Data to escape: a single string or an array of strings.
|
||||||
*
|
*
|
||||||
* @return string escaped.
|
* @return string|array escaped.
|
||||||
*/
|
*/
|
||||||
function escape($input)
|
function escape($input)
|
||||||
{
|
{
|
||||||
if (is_bool($input)) {
|
if (null === $input) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_bool($input) || is_int($input) || is_float($input) || $input instanceof DateTimeInterface) {
|
||||||
return $input;
|
return $input;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (is_array($input)) {
|
if (is_array($input)) {
|
||||||
$out = array();
|
$out = [];
|
||||||
foreach ($input as $key => $value) {
|
foreach ($input as $key => $value) {
|
||||||
$out[$key] = escape($value);
|
$out[escape($key)] = escape($value);
|
||||||
}
|
}
|
||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
@ -157,12 +165,12 @@ function checkDateFormat($format, $string)
|
||||||
*
|
*
|
||||||
* @return string $referer - final referer.
|
* @return string $referer - final referer.
|
||||||
*/
|
*/
|
||||||
function generateLocation($referer, $host, $loopTerms = array())
|
function generateLocation($referer, $host, $loopTerms = [])
|
||||||
{
|
{
|
||||||
$finalReferer = '?';
|
$finalReferer = './?';
|
||||||
|
|
||||||
// No referer if it contains any value in $loopCriteria.
|
// No referer if it contains any value in $loopCriteria.
|
||||||
foreach ($loopTerms as $value) {
|
foreach (array_filter($loopTerms) as $value) {
|
||||||
if (strpos($referer, $value) !== false) {
|
if (strpos($referer, $value) !== false) {
|
||||||
return $finalReferer;
|
return $finalReferer;
|
||||||
}
|
}
|
||||||
|
@ -173,7 +181,7 @@ function generateLocation($referer, $host, $loopTerms = array())
|
||||||
$host = substr($host, 0, $pos);
|
$host = substr($host, 0, $pos);
|
||||||
}
|
}
|
||||||
|
|
||||||
$refererHost = parse_url($referer, PHP_URL_HOST);
|
$refererHost = parse_url($referer, PHP_URL_HOST) ?? '';
|
||||||
if (!empty($referer) && (strpos($refererHost, $host) !== false || startsWith('?', $refererHost))) {
|
if (!empty($referer) && (strpos($refererHost, $host) !== false || startsWith('?', $refererHost))) {
|
||||||
$finalReferer = $referer;
|
$finalReferer = $referer;
|
||||||
}
|
}
|
||||||
|
@ -190,7 +198,7 @@ function generateLocation($referer, $host, $loopTerms = array())
|
||||||
function autoLocale($headerLocale)
|
function autoLocale($headerLocale)
|
||||||
{
|
{
|
||||||
// Default if browser does not send HTTP_ACCEPT_LANGUAGE
|
// Default if browser does not send HTTP_ACCEPT_LANGUAGE
|
||||||
$locales = array('en_US', 'en_US.utf8', 'en_US.UTF-8');
|
$locales = ['en_US.UTF-8', 'en_US.utf8', 'en_US'];
|
||||||
if (! empty($headerLocale)) {
|
if (! empty($headerLocale)) {
|
||||||
if (preg_match_all('/([a-z]{2,3})[-_]?([a-z]{2})?,?/i', $headerLocale, $matches, PREG_SET_ORDER)) {
|
if (preg_match_all('/([a-z]{2,3})[-_]?([a-z]{2})?,?/i', $headerLocale, $matches, PREG_SET_ORDER)) {
|
||||||
$attempts = [];
|
$attempts = [];
|
||||||
|
@ -285,7 +293,7 @@ function generate_api_secret($username, $salt)
|
||||||
*/
|
*/
|
||||||
function normalize_spaces($string)
|
function normalize_spaces($string)
|
||||||
{
|
{
|
||||||
return preg_replace('/\s{2,}/', ' ', trim($string));
|
return preg_replace('/\s{2,}/', ' ', trim($string ?? ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -294,32 +302,52 @@ function normalize_spaces($string)
|
||||||
* Requires php-intl to display international datetimes,
|
* Requires php-intl to display international datetimes,
|
||||||
* otherwise default format '%c' will be returned.
|
* otherwise default format '%c' will be returned.
|
||||||
*
|
*
|
||||||
* @param DateTime $date to format.
|
* @param DateTimeInterface $date to format.
|
||||||
* @param bool $time Displays time if true.
|
* @param bool $time Displays time if true.
|
||||||
* @param bool $intl Use international format if true.
|
* @param bool $intl Use international format if true.
|
||||||
*
|
*
|
||||||
* @return bool|string Formatted date, or false if the input is invalid.
|
* @return bool|string Formatted date, or false if the input is invalid.
|
||||||
*/
|
*/
|
||||||
function format_date($date, $time = true, $intl = true)
|
function format_date($date, $time = true, $intl = true)
|
||||||
{
|
{
|
||||||
if (! $date instanceof DateTime) {
|
if (! $date instanceof DateTimeInterface) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $intl || ! class_exists('IntlDateFormatter')) {
|
if (! $intl || ! class_exists('IntlDateFormatter')) {
|
||||||
$format = $time ? '%c' : '%x';
|
$format = 'F j, Y';
|
||||||
return strftime($format, $date->getTimestamp());
|
if ($time) {
|
||||||
|
$format .= ' h:i:s A \G\M\TP';
|
||||||
|
}
|
||||||
|
return $date->format($format);
|
||||||
}
|
}
|
||||||
|
|
||||||
$formatter = new IntlDateFormatter(
|
$formatter = new IntlDateFormatter(
|
||||||
setlocale(LC_TIME, 0),
|
setlocale(LC_TIME, 0),
|
||||||
IntlDateFormatter::LONG,
|
IntlDateFormatter::LONG,
|
||||||
$time ? IntlDateFormatter::LONG : IntlDateFormatter::NONE
|
$time ? IntlDateFormatter::LONG : IntlDateFormatter::NONE
|
||||||
);
|
);
|
||||||
|
$formatter->setTimeZone($date->getTimezone());
|
||||||
|
|
||||||
return $formatter->format($date);
|
return $formatter->format($date);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format the date month according to the locale.
|
||||||
|
*
|
||||||
|
* @param DateTimeInterface $date to format.
|
||||||
|
*
|
||||||
|
* @return bool|string Formatted date, or false if the input is invalid.
|
||||||
|
*/
|
||||||
|
function format_month(DateTimeInterface $date)
|
||||||
|
{
|
||||||
|
if (! $date instanceof DateTimeInterface) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return strftime('%B', $date->getTimestamp());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the input is an integer, no matter its real type.
|
* Check if the input is an integer, no matter its real type.
|
||||||
*
|
*
|
||||||
|
@ -353,13 +381,15 @@ function return_bytes($val)
|
||||||
return $val;
|
return $val;
|
||||||
}
|
}
|
||||||
$val = trim($val);
|
$val = trim($val);
|
||||||
$last = strtolower($val[strlen($val)-1]);
|
$last = strtolower($val[strlen($val) - 1]);
|
||||||
$val = intval(substr($val, 0, -1));
|
$val = intval(substr($val, 0, -1));
|
||||||
switch ($last) {
|
switch ($last) {
|
||||||
case 'g':
|
case 'g':
|
||||||
$val *= 1024;
|
$val *= 1024;
|
||||||
|
// do no break in order 1024^2 for each unit
|
||||||
case 'm':
|
case 'm':
|
||||||
$val *= 1024;
|
$val *= 1024;
|
||||||
|
// do no break in order 1024^2 for each unit
|
||||||
case 'k':
|
case 'k':
|
||||||
$val *= 1024;
|
$val *= 1024;
|
||||||
}
|
}
|
||||||
|
@ -448,14 +478,28 @@ function alphabetical_sort(&$data, $reverse = false, $byKeys = false)
|
||||||
* Wrapper function for translation which match the API
|
* Wrapper function for translation which match the API
|
||||||
* of gettext()/_() and ngettext().
|
* of gettext()/_() and ngettext().
|
||||||
*
|
*
|
||||||
* @param string $text Text to translate.
|
* @param string $text Text to translate.
|
||||||
* @param string $nText The plural message ID.
|
* @param string $nText The plural message ID.
|
||||||
* @param int $nb The number of items for plural forms.
|
* @param int $nb The number of items for plural forms.
|
||||||
* @param string $domain The domain where the translation is stored (default: shaarli).
|
* @param string $domain The domain where the translation is stored (default: shaarli).
|
||||||
|
* @param array $variables Associative array of variables to replace in translated text.
|
||||||
|
* @param bool $fixCase Apply `ucfirst` on the translated string, might be useful for strings with variables.
|
||||||
*
|
*
|
||||||
* @return string Text translated.
|
* @return string Text translated.
|
||||||
*/
|
*/
|
||||||
function t($text, $nText = '', $nb = 1, $domain = 'shaarli')
|
function t($text, $nText = '', $nb = 1, $domain = 'shaarli', $variables = [], $fixCase = false)
|
||||||
{
|
{
|
||||||
return dn__($domain, $text, $nText, $nb);
|
$postFunction = $fixCase ? 'ucfirst' : function ($input) {
|
||||||
|
return $input;
|
||||||
|
};
|
||||||
|
|
||||||
|
return $postFunction(dn__($domain, $text, $nText, $nb, $variables));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts an exception into a printable stack trace string.
|
||||||
|
*/
|
||||||
|
function exception2text(Throwable $e): string
|
||||||
|
{
|
||||||
|
return $e->getMessage() . PHP_EOL . $e->getFile() . $e->getLine() . PHP_EOL . $e->getTraceAsString();
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,11 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Shaarli\Api;
|
namespace Shaarli\Api;
|
||||||
|
|
||||||
|
use malkusch\lock\mutex\FlockMutex;
|
||||||
use Shaarli\Api\Exceptions\ApiAuthorizationException;
|
use Shaarli\Api\Exceptions\ApiAuthorizationException;
|
||||||
use Shaarli\Api\Exceptions\ApiException;
|
use Shaarli\Api\Exceptions\ApiException;
|
||||||
|
use Shaarli\Bookmark\BookmarkFileService;
|
||||||
use Shaarli\Config\ConfigManager;
|
use Shaarli\Config\ConfigManager;
|
||||||
use Slim\Container;
|
use Slim\Container;
|
||||||
use Slim\Http\Request;
|
use Slim\Http\Request;
|
||||||
|
@ -70,7 +73,14 @@ class ApiMiddleware
|
||||||
$response = $e->getApiResponse();
|
$response = $e->getApiResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
return $response;
|
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')
|
||||||
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -99,7 +109,10 @@ class ApiMiddleware
|
||||||
*/
|
*/
|
||||||
protected function checkToken($request)
|
protected function checkToken($request)
|
||||||
{
|
{
|
||||||
if (! $request->hasHeader('Authorization')) {
|
if (
|
||||||
|
!$request->hasHeader('Authorization')
|
||||||
|
&& !isset($this->container->environment['REDIRECT_HTTP_AUTHORIZATION'])
|
||||||
|
) {
|
||||||
throw new ApiAuthorizationException('JWT token not provided');
|
throw new ApiAuthorizationException('JWT token not provided');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -107,7 +120,11 @@ class ApiMiddleware
|
||||||
throw new ApiAuthorizationException('Token secret must be set in Shaarli\'s administration');
|
throw new ApiAuthorizationException('Token secret must be set in Shaarli\'s administration');
|
||||||
}
|
}
|
||||||
|
|
||||||
$authorization = $request->getHeaderLine('Authorization');
|
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)) {
|
if (! preg_match('/^Bearer (.*)/i', $authorization, $matches)) {
|
||||||
throw new ApiAuthorizationException('Invalid JWT header');
|
throw new ApiAuthorizationException('Invalid JWT header');
|
||||||
|
@ -117,7 +134,7 @@ class ApiMiddleware
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Instantiate a new LinkDB including private links,
|
* Instantiate a new LinkDB including private bookmarks,
|
||||||
* and load in the Slim container.
|
* and load in the Slim container.
|
||||||
*
|
*
|
||||||
* FIXME! LinkDB could use a refactoring to avoid this trick.
|
* FIXME! LinkDB could use a refactoring to avoid this trick.
|
||||||
|
@ -126,10 +143,12 @@ class ApiMiddleware
|
||||||
*/
|
*/
|
||||||
protected function setLinkDb($conf)
|
protected function setLinkDb($conf)
|
||||||
{
|
{
|
||||||
$linkDb = new \Shaarli\Bookmark\LinkDB(
|
$linkDb = new BookmarkFileService(
|
||||||
$conf->get('resource.datastore'),
|
$conf,
|
||||||
true,
|
$this->container->get('pluginManager'),
|
||||||
$conf->get('privacy.hide_public_links')
|
$this->container->get('history'),
|
||||||
|
new FlockMutex(fopen(SHAARLI_MUTEX_FILE, 'r'), 2),
|
||||||
|
true
|
||||||
);
|
);
|
||||||
$this->container['db'] = $linkDb;
|
$this->container['db'] = $linkDb;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Shaarli\Api;
|
namespace Shaarli\Api;
|
||||||
|
|
||||||
use Shaarli\Api\Exceptions\ApiAuthorizationException;
|
use Shaarli\Api\Exceptions\ApiAuthorizationException;
|
||||||
|
use Shaarli\Bookmark\Bookmark;
|
||||||
use Shaarli\Http\Base64Url;
|
use Shaarli\Http\Base64Url;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -15,6 +17,8 @@ class ApiUtils
|
||||||
* @param string $token JWT token extracted from the headers.
|
* @param string $token JWT token extracted from the headers.
|
||||||
* @param string $secret API secret set in the settings.
|
* @param string $secret API secret set in the settings.
|
||||||
*
|
*
|
||||||
|
* @return bool true on success
|
||||||
|
*
|
||||||
* @throws ApiAuthorizationException the token is not valid.
|
* @throws ApiAuthorizationException the token is not valid.
|
||||||
*/
|
*/
|
||||||
public static function validateJwtToken($token, $secret)
|
public static function validateJwtToken($token, $secret)
|
||||||
|
@ -24,7 +28,7 @@ class ApiUtils
|
||||||
throw new ApiAuthorizationException('Malformed JWT token');
|
throw new ApiAuthorizationException('Malformed JWT token');
|
||||||
}
|
}
|
||||||
|
|
||||||
$genSign = Base64Url::encode(hash_hmac('sha512', $parts[0] .'.'. $parts[1], $secret, true));
|
$genSign = Base64Url::encode(hash_hmac('sha512', $parts[0] . '.' . $parts[1], $secret, true));
|
||||||
if ($parts[2] != $genSign) {
|
if ($parts[2] != $genSign) {
|
||||||
throw new ApiAuthorizationException('Invalid JWT signature');
|
throw new ApiAuthorizationException('Invalid JWT signature');
|
||||||
}
|
}
|
||||||
|
@ -39,39 +43,42 @@ class ApiUtils
|
||||||
throw new ApiAuthorizationException('Invalid JWT payload');
|
throw new ApiAuthorizationException('Invalid JWT payload');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (empty($payload->iat)
|
if (
|
||||||
|
empty($payload->iat)
|
||||||
|| $payload->iat > time()
|
|| $payload->iat > time()
|
||||||
|| time() - $payload->iat > ApiMiddleware::$TOKEN_DURATION
|
|| time() - $payload->iat > ApiMiddleware::$TOKEN_DURATION
|
||||||
) {
|
) {
|
||||||
throw new ApiAuthorizationException('Invalid JWT issued time');
|
throw new ApiAuthorizationException('Invalid JWT issued time');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format a Link for the REST API.
|
* Format a Link for the REST API.
|
||||||
*
|
*
|
||||||
* @param array $link Link data read from the datastore.
|
* @param Bookmark $bookmark Bookmark data read from the datastore.
|
||||||
* @param string $indexUrl Shaarli's index URL (used for relative URL).
|
* @param string $indexUrl Shaarli's index URL (used for relative URL).
|
||||||
*
|
*
|
||||||
* @return array Link data formatted for the REST API.
|
* @return array Link data formatted for the REST API.
|
||||||
*/
|
*/
|
||||||
public static function formatLink($link, $indexUrl)
|
public static function formatLink($bookmark, $indexUrl)
|
||||||
{
|
{
|
||||||
$out['id'] = $link['id'];
|
$out['id'] = $bookmark->getId();
|
||||||
// Not an internal link
|
// Not an internal link
|
||||||
if (! is_note($link['url'])) {
|
if (! $bookmark->isNote()) {
|
||||||
$out['url'] = $link['url'];
|
$out['url'] = $bookmark->getUrl();
|
||||||
} else {
|
} else {
|
||||||
$out['url'] = $indexUrl . $link['url'];
|
$out['url'] = rtrim($indexUrl, '/') . '/' . ltrim($bookmark->getUrl(), '/');
|
||||||
}
|
}
|
||||||
$out['shorturl'] = $link['shorturl'];
|
$out['shorturl'] = $bookmark->getShortUrl();
|
||||||
$out['title'] = $link['title'];
|
$out['title'] = $bookmark->getTitle();
|
||||||
$out['description'] = $link['description'];
|
$out['description'] = $bookmark->getDescription();
|
||||||
$out['tags'] = preg_split('/\s+/', $link['tags'], -1, PREG_SPLIT_NO_EMPTY);
|
$out['tags'] = $bookmark->getTags();
|
||||||
$out['private'] = $link['private'] == true;
|
$out['private'] = $bookmark->isPrivate();
|
||||||
$out['created'] = $link['created']->format(\DateTime::ATOM);
|
$out['created'] = $bookmark->getCreated()->format(\DateTime::ATOM);
|
||||||
if (! empty($link['updated'])) {
|
if (! empty($bookmark->getUpdated())) {
|
||||||
$out['updated'] = $link['updated']->format(\DateTime::ATOM);
|
$out['updated'] = $bookmark->getUpdated()->format(\DateTime::ATOM);
|
||||||
} else {
|
} else {
|
||||||
$out['updated'] = '';
|
$out['updated'] = '';
|
||||||
}
|
}
|
||||||
|
@ -79,58 +86,72 @@ class ApiUtils
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a link given through a request, to a valid link for LinkDB.
|
* 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 URL is provided, it will generate a local note URL.
|
||||||
* If no title is provided, it will use the URL as title.
|
* If no title is provided, it will use the URL as title.
|
||||||
*
|
*
|
||||||
* @param array $input Request Link.
|
* @param array|null $input Request Link.
|
||||||
* @param bool $defaultPrivate 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 array Formatted link.
|
* @return Bookmark instance.
|
||||||
*/
|
*/
|
||||||
public static function buildLinkFromRequest($input, $defaultPrivate)
|
public static function buildBookmarkFromRequest(
|
||||||
{
|
?array $input,
|
||||||
$input['url'] = ! empty($input['url']) ? cleanup_url($input['url']) : '';
|
bool $defaultPrivate,
|
||||||
|
string $tagsSeparator
|
||||||
|
): Bookmark {
|
||||||
|
$bookmark = new Bookmark();
|
||||||
|
$url = ! empty($input['url']) ? cleanup_url($input['url']) : '';
|
||||||
if (isset($input['private'])) {
|
if (isset($input['private'])) {
|
||||||
$private = filter_var($input['private'], FILTER_VALIDATE_BOOLEAN);
|
$private = filter_var($input['private'], FILTER_VALIDATE_BOOLEAN);
|
||||||
} else {
|
} else {
|
||||||
$private = $defaultPrivate;
|
$private = $defaultPrivate;
|
||||||
}
|
}
|
||||||
|
|
||||||
$link = [
|
$bookmark->setTitle(! empty($input['title']) ? $input['title'] : '');
|
||||||
'title' => ! empty($input['title']) ? $input['title'] : $input['url'],
|
$bookmark->setUrl($url);
|
||||||
'url' => $input['url'],
|
$bookmark->setDescription(! empty($input['description']) ? $input['description'] : '');
|
||||||
'description' => ! empty($input['description']) ? $input['description'] : '',
|
|
||||||
'tags' => ! empty($input['tags']) ? implode(' ', $input['tags']) : '',
|
// Be permissive with provided tags format
|
||||||
'private' => $private,
|
if (is_string($input['tags'] ?? null)) {
|
||||||
'created' => new \DateTime(),
|
$input['tags'] = tags_str2array($input['tags'], $tagsSeparator);
|
||||||
];
|
}
|
||||||
return $link;
|
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.
|
* Update link fields using an updated link object.
|
||||||
*
|
*
|
||||||
* @param array $oldLink data
|
* @param Bookmark $oldLink data
|
||||||
* @param array $newLink data
|
* @param Bookmark $newLink data
|
||||||
*
|
*
|
||||||
* @return array $oldLink updated with $newLink values
|
* @return Bookmark $oldLink updated with $newLink values
|
||||||
*/
|
*/
|
||||||
public static function updateLink($oldLink, $newLink)
|
public static function updateLink($oldLink, $newLink)
|
||||||
{
|
{
|
||||||
foreach (['title', 'url', 'description', 'tags', 'private'] as $field) {
|
$oldLink->setTitle($newLink->getTitle());
|
||||||
$oldLink[$field] = $newLink[$field];
|
$oldLink->setUrl($newLink->getUrl());
|
||||||
}
|
$oldLink->setDescription($newLink->getDescription());
|
||||||
$oldLink['updated'] = new \DateTime();
|
$oldLink->setTags($newLink->getTags());
|
||||||
|
$oldLink->setPrivate($newLink->isPrivate());
|
||||||
if (empty($oldLink['url'])) {
|
|
||||||
$oldLink['url'] = '?' . $oldLink['shorturl'];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($oldLink['title'])) {
|
|
||||||
$oldLink['title'] = $oldLink['url'];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $oldLink;
|
return $oldLink;
|
||||||
}
|
}
|
||||||
|
@ -139,7 +160,7 @@ class ApiUtils
|
||||||
* Format a Tag for the REST API.
|
* Format a Tag for the REST API.
|
||||||
*
|
*
|
||||||
* @param string $tag Tag name
|
* @param string $tag Tag name
|
||||||
* @param int $occurrences Number of links using this tag
|
* @param int $occurrences Number of bookmarks using this tag
|
||||||
*
|
*
|
||||||
* @return array Link data formatted for the REST API.
|
* @return array Link data formatted for the REST API.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -2,8 +2,9 @@
|
||||||
|
|
||||||
namespace Shaarli\Api\Controllers;
|
namespace Shaarli\Api\Controllers;
|
||||||
|
|
||||||
use Shaarli\Bookmark\LinkDB;
|
use Shaarli\Bookmark\BookmarkServiceInterface;
|
||||||
use Shaarli\Config\ConfigManager;
|
use Shaarli\Config\ConfigManager;
|
||||||
|
use Shaarli\History;
|
||||||
use Slim\Container;
|
use Slim\Container;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -26,12 +27,12 @@ abstract class ApiController
|
||||||
protected $conf;
|
protected $conf;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var LinkDB
|
* @var BookmarkServiceInterface
|
||||||
*/
|
*/
|
||||||
protected $linkDb;
|
protected $bookmarkService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var HistoryController
|
* @var History
|
||||||
*/
|
*/
|
||||||
protected $history;
|
protected $history;
|
||||||
|
|
||||||
|
@ -51,7 +52,7 @@ abstract class ApiController
|
||||||
{
|
{
|
||||||
$this->ci = $ci;
|
$this->ci = $ci;
|
||||||
$this->conf = $ci->get('conf');
|
$this->conf = $ci->get('conf');
|
||||||
$this->linkDb = $ci->get('db');
|
$this->bookmarkService = $ci->get('db');
|
||||||
$this->history = $ci->get('history');
|
$this->history = $ci->get('history');
|
||||||
if ($this->conf->get('dev.debug', false)) {
|
if ($this->conf->get('dev.debug', false)) {
|
||||||
$this->jsonStyle = JSON_PRETTY_PRINT;
|
$this->jsonStyle = JSON_PRETTY_PRINT;
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
|
||||||
namespace Shaarli\Api\Controllers;
|
namespace Shaarli\Api\Controllers;
|
||||||
|
|
||||||
use Shaarli\Api\Exceptions\ApiBadParametersException;
|
use Shaarli\Api\Exceptions\ApiBadParametersException;
|
||||||
|
@ -31,7 +30,7 @@ class HistoryController extends ApiController
|
||||||
$history = $this->history->getHistory();
|
$history = $this->history->getHistory();
|
||||||
|
|
||||||
// Return history operations from the {offset}th, starting from {since}.
|
// Return history operations from the {offset}th, starting from {since}.
|
||||||
$since = \DateTime::createFromFormat(\DateTime::ATOM, $request->getParam('since'));
|
$since = \DateTime::createFromFormat(\DateTime::ATOM, $request->getParam('since', ''));
|
||||||
$offset = $request->getParam('offset');
|
$offset = $request->getParam('offset');
|
||||||
if (empty($offset)) {
|
if (empty($offset)) {
|
||||||
$offset = 0;
|
$offset = 0;
|
||||||
|
@ -41,7 +40,7 @@ class HistoryController extends ApiController
|
||||||
throw new ApiBadParametersException('Invalid offset');
|
throw new ApiBadParametersException('Invalid offset');
|
||||||
}
|
}
|
||||||
|
|
||||||
// limit parameter is either a number of links or 'all' for everything.
|
// limit parameter is either a number of bookmarks or 'all' for everything.
|
||||||
$limit = $request->getParam('limit');
|
$limit = $request->getParam('limit');
|
||||||
if (empty($limit)) {
|
if (empty($limit)) {
|
||||||
$limit = count($history);
|
$limit = count($history);
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace Shaarli\Api\Controllers;
|
namespace Shaarli\Api\Controllers;
|
||||||
|
|
||||||
|
use Shaarli\Bookmark\BookmarkFilter;
|
||||||
use Slim\Http\Request;
|
use Slim\Http\Request;
|
||||||
use Slim\Http\Response;
|
use Slim\Http\Response;
|
||||||
|
|
||||||
|
@ -26,15 +27,15 @@ class Info extends ApiController
|
||||||
public function getInfo($request, $response)
|
public function getInfo($request, $response)
|
||||||
{
|
{
|
||||||
$info = [
|
$info = [
|
||||||
'global_counter' => count($this->linkDb),
|
'global_counter' => $this->bookmarkService->count(),
|
||||||
'private_counter' => count_private($this->linkDb),
|
'private_counter' => $this->bookmarkService->count(BookmarkFilter::$PRIVATE),
|
||||||
'settings' => array(
|
'settings' => [
|
||||||
'title' => $this->conf->get('general.title', 'Shaarli'),
|
'title' => $this->conf->get('general.title', 'Shaarli'),
|
||||||
'header_link' => $this->conf->get('general.header_link', '?'),
|
'header_link' => $this->conf->get('general.header_link', '?'),
|
||||||
'timezone' => $this->conf->get('general.timezone', 'UTC'),
|
'timezone' => $this->conf->get('general.timezone', 'UTC'),
|
||||||
'enabled_plugins' => $this->conf->get('general.enabled_plugins', []),
|
'enabled_plugins' => $this->conf->get('general.enabled_plugins', []),
|
||||||
'default_private_links' => $this->conf->get('privacy.default_private_links', false),
|
'default_private_links' => $this->conf->get('privacy.default_private_links', false),
|
||||||
),
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
return $response->withJson($info, 200, $this->jsonStyle);
|
return $response->withJson($info, 200, $this->jsonStyle);
|
||||||
|
|
|
@ -11,7 +11,7 @@ use Slim\Http\Response;
|
||||||
/**
|
/**
|
||||||
* Class Links
|
* Class Links
|
||||||
*
|
*
|
||||||
* REST API Controller: all services related to links collection.
|
* REST API Controller: all services related to bookmarks collection.
|
||||||
*
|
*
|
||||||
* @package Api\Controllers
|
* @package Api\Controllers
|
||||||
* @see http://shaarli.github.io/api-documentation/#links-links-collection
|
* @see http://shaarli.github.io/api-documentation/#links-links-collection
|
||||||
|
@ -19,12 +19,12 @@ use Slim\Http\Response;
|
||||||
class Links extends ApiController
|
class Links extends ApiController
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @var int Number of links returned if no limit is provided.
|
* @var int Number of bookmarks returned if no limit is provided.
|
||||||
*/
|
*/
|
||||||
public static $DEFAULT_LIMIT = 20;
|
public static $DEFAULT_LIMIT = 20;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieve a list of links, allowing different filters.
|
* Retrieve a list of bookmarks, allowing different filters.
|
||||||
*
|
*
|
||||||
* @param Request $request Slim request.
|
* @param Request $request Slim request.
|
||||||
* @param Response $response Slim response.
|
* @param Response $response Slim response.
|
||||||
|
@ -36,49 +36,48 @@ class Links extends ApiController
|
||||||
public function getLinks($request, $response)
|
public function getLinks($request, $response)
|
||||||
{
|
{
|
||||||
$private = $request->getParam('visibility');
|
$private = $request->getParam('visibility');
|
||||||
$links = $this->linkDb->filterSearch(
|
|
||||||
[
|
|
||||||
'searchtags' => $request->getParam('searchtags', ''),
|
|
||||||
'searchterm' => $request->getParam('searchterm', ''),
|
|
||||||
],
|
|
||||||
false,
|
|
||||||
$private
|
|
||||||
);
|
|
||||||
|
|
||||||
// Return links from the {offset}th link, starting from 0.
|
// Return bookmarks from the {offset}th link, starting from 0.
|
||||||
$offset = $request->getParam('offset');
|
$offset = $request->getParam('offset');
|
||||||
if (! empty($offset) && ! ctype_digit($offset)) {
|
if (! empty($offset) && ! ctype_digit($offset)) {
|
||||||
throw new ApiBadParametersException('Invalid offset');
|
throw new ApiBadParametersException('Invalid offset');
|
||||||
}
|
}
|
||||||
$offset = ! empty($offset) ? intval($offset) : 0;
|
$offset = ! empty($offset) ? intval($offset) : 0;
|
||||||
if ($offset > count($links)) {
|
|
||||||
return $response->withJson([], 200, $this->jsonStyle);
|
|
||||||
}
|
|
||||||
|
|
||||||
// limit parameter is either a number of links or 'all' for everything.
|
// limit parameter is either a number of bookmarks or 'all' for everything.
|
||||||
$limit = $request->getParam('limit');
|
$limit = $request->getParam('limit');
|
||||||
if (empty($limit)) {
|
if (empty($limit)) {
|
||||||
$limit = self::$DEFAULT_LIMIT;
|
$limit = self::$DEFAULT_LIMIT;
|
||||||
} elseif (ctype_digit($limit)) {
|
} elseif (ctype_digit($limit)) {
|
||||||
$limit = intval($limit);
|
$limit = intval($limit);
|
||||||
} elseif ($limit === 'all') {
|
} elseif ($limit === 'all') {
|
||||||
$limit = count($links);
|
$limit = null;
|
||||||
} else {
|
} else {
|
||||||
throw new ApiBadParametersException('Invalid limit');
|
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.
|
// 'environment' is set by Slim and encapsulate $_SERVER.
|
||||||
$indexUrl = index_url($this->ci['environment']);
|
$indexUrl = index_url($this->ci['environment']);
|
||||||
|
|
||||||
$out = [];
|
$out = [];
|
||||||
$index = 0;
|
foreach ($searchResult->getBookmarks() as $bookmark) {
|
||||||
foreach ($links as $link) {
|
$out[] = ApiUtils::formatLink($bookmark, $indexUrl);
|
||||||
if (count($out) >= $limit) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if ($index++ >= $offset) {
|
|
||||||
$out[] = ApiUtils::formatLink($link, $indexUrl);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $response->withJson($out, 200, $this->jsonStyle);
|
return $response->withJson($out, 200, $this->jsonStyle);
|
||||||
|
@ -97,11 +96,12 @@ class Links extends ApiController
|
||||||
*/
|
*/
|
||||||
public function getLink($request, $response, $args)
|
public function getLink($request, $response, $args)
|
||||||
{
|
{
|
||||||
if (!isset($this->linkDb[$args['id']])) {
|
$id = is_integer_mixed($args['id']) ? (int) $args['id'] : null;
|
||||||
|
if ($id === null || ! $this->bookmarkService->exists($id)) {
|
||||||
throw new ApiLinkNotFoundException();
|
throw new ApiLinkNotFoundException();
|
||||||
}
|
}
|
||||||
$index = index_url($this->ci['environment']);
|
$index = index_url($this->ci['environment']);
|
||||||
$out = ApiUtils::formatLink($this->linkDb[$args['id']], $index);
|
$out = ApiUtils::formatLink($this->bookmarkService->get($id), $index);
|
||||||
|
|
||||||
return $response->withJson($out, 200, $this->jsonStyle);
|
return $response->withJson($out, 200, $this->jsonStyle);
|
||||||
}
|
}
|
||||||
|
@ -116,10 +116,17 @@ class Links extends ApiController
|
||||||
*/
|
*/
|
||||||
public function postLink($request, $response)
|
public function postLink($request, $response)
|
||||||
{
|
{
|
||||||
$data = $request->getParsedBody();
|
$data = (array) ($request->getParsedBody() ?? []);
|
||||||
$link = ApiUtils::buildLinkFromRequest($data, $this->conf->get('privacy.default_private_links'));
|
$bookmark = ApiUtils::buildBookmarkFromRequest(
|
||||||
|
$data,
|
||||||
|
$this->conf->get('privacy.default_private_links'),
|
||||||
|
$this->conf->get('general.tags_separator', ' ')
|
||||||
|
);
|
||||||
// duplicate by URL, return 409 Conflict
|
// duplicate by URL, return 409 Conflict
|
||||||
if (! empty($link['url']) && ! empty($dup = $this->linkDb->getLinkFromUrl($link['url']))) {
|
if (
|
||||||
|
! empty($bookmark->getUrl())
|
||||||
|
&& ! empty($dup = $this->bookmarkService->findByUrl($bookmark->getUrl()))
|
||||||
|
) {
|
||||||
return $response->withJson(
|
return $response->withJson(
|
||||||
ApiUtils::formatLink($dup, index_url($this->ci['environment'])),
|
ApiUtils::formatLink($dup, index_url($this->ci['environment'])),
|
||||||
409,
|
409,
|
||||||
|
@ -127,23 +134,9 @@ class Links extends ApiController
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$link['id'] = $this->linkDb->getNextId();
|
$this->bookmarkService->add($bookmark);
|
||||||
$link['shorturl'] = link_small_hash($link['created'], $link['id']);
|
$out = ApiUtils::formatLink($bookmark, index_url($this->ci['environment']));
|
||||||
|
$redirect = $this->ci->router->pathFor('getLink', ['id' => $bookmark->getId()]);
|
||||||
// note: general relative URL
|
|
||||||
if (empty($link['url'])) {
|
|
||||||
$link['url'] = '?' . $link['shorturl'];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($link['title'])) {
|
|
||||||
$link['title'] = $link['url'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->linkDb[$link['id']] = $link;
|
|
||||||
$this->linkDb->save($this->conf->get('resource.page_cache'));
|
|
||||||
$this->history->addLink($link);
|
|
||||||
$out = ApiUtils::formatLink($link, index_url($this->ci['environment']));
|
|
||||||
$redirect = $this->ci->router->relativePathFor('getLink', ['id' => $link['id']]);
|
|
||||||
return $response->withAddedHeader('Location', $redirect)
|
return $response->withAddedHeader('Location', $redirect)
|
||||||
->withJson($out, 201, $this->jsonStyle);
|
->withJson($out, 201, $this->jsonStyle);
|
||||||
}
|
}
|
||||||
|
@ -161,18 +154,24 @@ class Links extends ApiController
|
||||||
*/
|
*/
|
||||||
public function putLink($request, $response, $args)
|
public function putLink($request, $response, $args)
|
||||||
{
|
{
|
||||||
if (! isset($this->linkDb[$args['id']])) {
|
$id = is_integer_mixed($args['id']) ? (int) $args['id'] : null;
|
||||||
|
if ($id === null || !$this->bookmarkService->exists($id)) {
|
||||||
throw new ApiLinkNotFoundException();
|
throw new ApiLinkNotFoundException();
|
||||||
}
|
}
|
||||||
|
|
||||||
$index = index_url($this->ci['environment']);
|
$index = index_url($this->ci['environment']);
|
||||||
$data = $request->getParsedBody();
|
$data = $request->getParsedBody();
|
||||||
|
|
||||||
$requestLink = ApiUtils::buildLinkFromRequest($data, $this->conf->get('privacy.default_private_links'));
|
$requestBookmark = ApiUtils::buildBookmarkFromRequest(
|
||||||
|
$data,
|
||||||
|
$this->conf->get('privacy.default_private_links'),
|
||||||
|
$this->conf->get('general.tags_separator', ' ')
|
||||||
|
);
|
||||||
// duplicate URL on a different link, return 409 Conflict
|
// duplicate URL on a different link, return 409 Conflict
|
||||||
if (! empty($requestLink['url'])
|
if (
|
||||||
&& ! empty($dup = $this->linkDb->getLinkFromUrl($requestLink['url']))
|
! empty($requestBookmark->getUrl())
|
||||||
&& $dup['id'] != $args['id']
|
&& ! empty($dup = $this->bookmarkService->findByUrl($requestBookmark->getUrl()))
|
||||||
|
&& $dup->getId() != $id
|
||||||
) {
|
) {
|
||||||
return $response->withJson(
|
return $response->withJson(
|
||||||
ApiUtils::formatLink($dup, $index),
|
ApiUtils::formatLink($dup, $index),
|
||||||
|
@ -181,13 +180,11 @@ class Links extends ApiController
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$responseLink = $this->linkDb[$args['id']];
|
$responseBookmark = $this->bookmarkService->get($id);
|
||||||
$responseLink = ApiUtils::updateLink($responseLink, $requestLink);
|
$responseBookmark = ApiUtils::updateLink($responseBookmark, $requestBookmark);
|
||||||
$this->linkDb[$responseLink['id']] = $responseLink;
|
$this->bookmarkService->set($responseBookmark);
|
||||||
$this->linkDb->save($this->conf->get('resource.page_cache'));
|
|
||||||
$this->history->updateLink($responseLink);
|
|
||||||
|
|
||||||
$out = ApiUtils::formatLink($responseLink, $index);
|
$out = ApiUtils::formatLink($responseBookmark, $index);
|
||||||
return $response->withJson($out, 200, $this->jsonStyle);
|
return $response->withJson($out, 200, $this->jsonStyle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -204,13 +201,12 @@ class Links extends ApiController
|
||||||
*/
|
*/
|
||||||
public function deleteLink($request, $response, $args)
|
public function deleteLink($request, $response, $args)
|
||||||
{
|
{
|
||||||
if (! isset($this->linkDb[$args['id']])) {
|
$id = is_integer_mixed($args['id']) ? (int) $args['id'] : null;
|
||||||
|
if ($id === null || !$this->bookmarkService->exists($id)) {
|
||||||
throw new ApiLinkNotFoundException();
|
throw new ApiLinkNotFoundException();
|
||||||
}
|
}
|
||||||
$link = $this->linkDb[$args['id']];
|
$bookmark = $this->bookmarkService->get($id);
|
||||||
unset($this->linkDb[(int) $args['id']]);
|
$this->bookmarkService->remove($bookmark);
|
||||||
$this->linkDb->save($this->conf->get('resource.page_cache'));
|
|
||||||
$this->history->deleteLink($link);
|
|
||||||
|
|
||||||
return $response->withStatus(204);
|
return $response->withStatus(204);
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@ namespace Shaarli\Api\Controllers;
|
||||||
use Shaarli\Api\ApiUtils;
|
use Shaarli\Api\ApiUtils;
|
||||||
use Shaarli\Api\Exceptions\ApiBadParametersException;
|
use Shaarli\Api\Exceptions\ApiBadParametersException;
|
||||||
use Shaarli\Api\Exceptions\ApiTagNotFoundException;
|
use Shaarli\Api\Exceptions\ApiTagNotFoundException;
|
||||||
|
use Shaarli\Bookmark\BookmarkFilter;
|
||||||
use Slim\Http\Request;
|
use Slim\Http\Request;
|
||||||
use Slim\Http\Response;
|
use Slim\Http\Response;
|
||||||
|
|
||||||
|
@ -18,7 +19,7 @@ use Slim\Http\Response;
|
||||||
class Tags extends ApiController
|
class Tags extends ApiController
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @var int Number of links returned if no limit is provided.
|
* @var int Number of bookmarks returned if no limit is provided.
|
||||||
*/
|
*/
|
||||||
public static $DEFAULT_LIMIT = 'all';
|
public static $DEFAULT_LIMIT = 'all';
|
||||||
|
|
||||||
|
@ -35,7 +36,7 @@ class Tags extends ApiController
|
||||||
public function getTags($request, $response)
|
public function getTags($request, $response)
|
||||||
{
|
{
|
||||||
$visibility = $request->getParam('visibility');
|
$visibility = $request->getParam('visibility');
|
||||||
$tags = $this->linkDb->linksCountPerTag([], $visibility);
|
$tags = $this->bookmarkService->bookmarksCountPerTag([], $visibility);
|
||||||
|
|
||||||
// Return tags from the {offset}th tag, starting from 0.
|
// Return tags from the {offset}th tag, starting from 0.
|
||||||
$offset = $request->getParam('offset');
|
$offset = $request->getParam('offset');
|
||||||
|
@ -47,7 +48,7 @@ class Tags extends ApiController
|
||||||
return $response->withJson([], 200, $this->jsonStyle);
|
return $response->withJson([], 200, $this->jsonStyle);
|
||||||
}
|
}
|
||||||
|
|
||||||
// limit parameter is either a number of links or 'all' for everything.
|
// limit parameter is either a number of bookmarks or 'all' for everything.
|
||||||
$limit = $request->getParam('limit');
|
$limit = $request->getParam('limit');
|
||||||
if (empty($limit)) {
|
if (empty($limit)) {
|
||||||
$limit = self::$DEFAULT_LIMIT;
|
$limit = self::$DEFAULT_LIMIT;
|
||||||
|
@ -87,7 +88,7 @@ class Tags extends ApiController
|
||||||
*/
|
*/
|
||||||
public function getTag($request, $response, $args)
|
public function getTag($request, $response, $args)
|
||||||
{
|
{
|
||||||
$tags = $this->linkDb->linksCountPerTag();
|
$tags = $this->bookmarkService->bookmarksCountPerTag();
|
||||||
if (!isset($tags[$args['tagName']])) {
|
if (!isset($tags[$args['tagName']])) {
|
||||||
throw new ApiTagNotFoundException();
|
throw new ApiTagNotFoundException();
|
||||||
}
|
}
|
||||||
|
@ -111,7 +112,7 @@ class Tags extends ApiController
|
||||||
*/
|
*/
|
||||||
public function putTag($request, $response, $args)
|
public function putTag($request, $response, $args)
|
||||||
{
|
{
|
||||||
$tags = $this->linkDb->linksCountPerTag();
|
$tags = $this->bookmarkService->bookmarksCountPerTag();
|
||||||
if (! isset($tags[$args['tagName']])) {
|
if (! isset($tags[$args['tagName']])) {
|
||||||
throw new ApiTagNotFoundException();
|
throw new ApiTagNotFoundException();
|
||||||
}
|
}
|
||||||
|
@ -121,13 +122,19 @@ class Tags extends ApiController
|
||||||
throw new ApiBadParametersException('New tag name is required in the request body');
|
throw new ApiBadParametersException('New tag name is required in the request body');
|
||||||
}
|
}
|
||||||
|
|
||||||
$updated = $this->linkDb->renameTag($args['tagName'], $data['name']);
|
$searchResult = $this->bookmarkService->search(
|
||||||
$this->linkDb->save($this->conf->get('resource.page_cache'));
|
['searchtags' => $args['tagName']],
|
||||||
foreach ($updated as $link) {
|
BookmarkFilter::$ALL,
|
||||||
$this->history->updateLink($link);
|
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->linkDb->linksCountPerTag();
|
$tags = $this->bookmarkService->bookmarksCountPerTag();
|
||||||
$out = ApiUtils::formatTag($data['name'], $tags[$data['name']]);
|
$out = ApiUtils::formatTag($data['name'], $tags[$data['name']]);
|
||||||
return $response->withJson($out, 200, $this->jsonStyle);
|
return $response->withJson($out, 200, $this->jsonStyle);
|
||||||
}
|
}
|
||||||
|
@ -145,15 +152,22 @@ class Tags extends ApiController
|
||||||
*/
|
*/
|
||||||
public function deleteTag($request, $response, $args)
|
public function deleteTag($request, $response, $args)
|
||||||
{
|
{
|
||||||
$tags = $this->linkDb->linksCountPerTag();
|
$tags = $this->bookmarkService->bookmarksCountPerTag();
|
||||||
if (! isset($tags[$args['tagName']])) {
|
if (! isset($tags[$args['tagName']])) {
|
||||||
throw new ApiTagNotFoundException();
|
throw new ApiTagNotFoundException();
|
||||||
}
|
}
|
||||||
$updated = $this->linkDb->renameTag($args['tagName'], null);
|
|
||||||
$this->linkDb->save($this->conf->get('resource.page_cache'));
|
$searchResult = $this->bookmarkService->search(
|
||||||
foreach ($updated as $link) {
|
['searchtags' => $args['tagName']],
|
||||||
$this->history->updateLink($link);
|
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);
|
return $response->withStatus(204);
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,7 +28,7 @@ class ApiAuthorizationException extends ApiException
|
||||||
*/
|
*/
|
||||||
public function setMessage($message)
|
public function setMessage($message)
|
||||||
{
|
{
|
||||||
$original = $this->debug === true ? ': '. $this->getMessage() : '';
|
$original = $this->debug === true ? ': ' . $this->getMessage() : '';
|
||||||
$this->message = $message . $original;
|
$this->message = $message . $original;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,7 +12,6 @@ use Slim\Http\Response;
|
||||||
*/
|
*/
|
||||||
abstract class ApiException extends \Exception
|
abstract class ApiException extends \Exception
|
||||||
{
|
{
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var Response instance from Slim.
|
* @var Response instance from Slim.
|
||||||
*/
|
*/
|
||||||
|
@ -44,7 +43,7 @@ abstract class ApiException extends \Exception
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
'message' => $this->getMessage(),
|
'message' => $this->getMessage(),
|
||||||
'stacktrace' => get_class($this) .': '. $this->getTraceAsString()
|
'stacktrace' => get_class($this) . ': ' . $this->getTraceAsString()
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
542
application/bookmark/Bookmark.php
Normal file
542
application/bookmark/Bookmark.php
Normal file
|
@ -0,0 +1,542 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Bookmark;
|
||||||
|
|
||||||
|
use DateTime;
|
||||||
|
use DateTimeInterface;
|
||||||
|
use Shaarli\Bookmark\Exception\InvalidBookmarkException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class Bookmark
|
||||||
|
*
|
||||||
|
* This class represent a single Bookmark with all its attributes.
|
||||||
|
* Every bookmark should manipulated using this, before being formatted.
|
||||||
|
*
|
||||||
|
* @package Shaarli\Bookmark
|
||||||
|
*/
|
||||||
|
class Bookmark
|
||||||
|
{
|
||||||
|
/** @var string Date format used in string (former ID format) */
|
||||||
|
public const LINK_DATE_FORMAT = 'Ymd_His';
|
||||||
|
|
||||||
|
/** @var int Bookmark ID */
|
||||||
|
protected $id;
|
||||||
|
|
||||||
|
/** @var string Permalink identifier */
|
||||||
|
protected $shortUrl;
|
||||||
|
|
||||||
|
/** @var string Bookmark's URL - $shortUrl prefixed with `?` for notes */
|
||||||
|
protected $url;
|
||||||
|
|
||||||
|
/** @var string Bookmark's title */
|
||||||
|
protected $title;
|
||||||
|
|
||||||
|
/** @var string Raw bookmark's description */
|
||||||
|
protected $description;
|
||||||
|
|
||||||
|
/** @var array List of bookmark's tags */
|
||||||
|
protected $tags;
|
||||||
|
|
||||||
|
/** @var string|bool|null Thumbnail's URL - initialized at null, false if no thumbnail could be found */
|
||||||
|
protected $thumbnail;
|
||||||
|
|
||||||
|
/** @var bool Set to true if the bookmark is set as sticky */
|
||||||
|
protected $sticky;
|
||||||
|
|
||||||
|
/** @var DateTimeInterface Creation datetime */
|
||||||
|
protected $created;
|
||||||
|
|
||||||
|
/** @var DateTimeInterface datetime */
|
||||||
|
protected $updated;
|
||||||
|
|
||||||
|
/** @var bool True if the bookmark can only be seen while logged in */
|
||||||
|
protected $private;
|
||||||
|
|
||||||
|
/** @var mixed[] Available to store any additional content for a bookmark. Currently used for search highlight. */
|
||||||
|
protected $additionalContent = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize a link from array data. Especially useful to create a Bookmark from former link storage format.
|
||||||
|
*
|
||||||
|
* @param array $data
|
||||||
|
* @param string $tagsSeparator Tags separator loaded from the config file.
|
||||||
|
* This is a context data, and it should *never* be stored in the Bookmark object.
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function fromArray(array $data, string $tagsSeparator = ' '): Bookmark
|
||||||
|
{
|
||||||
|
$this->id = $data['id'] ?? null;
|
||||||
|
$this->shortUrl = $data['shorturl'] ?? null;
|
||||||
|
$this->url = $data['url'] ?? null;
|
||||||
|
$this->title = $data['title'] ?? null;
|
||||||
|
$this->description = $data['description'] ?? null;
|
||||||
|
$this->thumbnail = $data['thumbnail'] ?? null;
|
||||||
|
$this->sticky = $data['sticky'] ?? false;
|
||||||
|
$this->created = $data['created'] ?? null;
|
||||||
|
if (is_array($data['tags'])) {
|
||||||
|
$this->tags = $data['tags'];
|
||||||
|
} else {
|
||||||
|
$this->tags = tags_str2array($data['tags'] ?? '', $tagsSeparator);
|
||||||
|
}
|
||||||
|
if (! empty($data['updated'])) {
|
||||||
|
$this->updated = $data['updated'];
|
||||||
|
}
|
||||||
|
$this->private = ($data['private'] ?? false) ? true : false;
|
||||||
|
$this->additionalContent = $data['additional_content'] ?? [];
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make sure that the current instance of Bookmark is valid and can be saved into the data store.
|
||||||
|
* A valid link requires:
|
||||||
|
* - an integer ID
|
||||||
|
* - a short URL (for permalinks)
|
||||||
|
* - a creation date
|
||||||
|
*
|
||||||
|
* This function also initialize optional empty fields:
|
||||||
|
* - the URL with the permalink
|
||||||
|
* - the title with the URL
|
||||||
|
*
|
||||||
|
* Also make sure that we do not save search highlights in the datastore.
|
||||||
|
*
|
||||||
|
* @throws InvalidBookmarkException
|
||||||
|
*/
|
||||||
|
public function validate(): void
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
$this->id === null
|
||||||
|
|| ! is_int($this->id)
|
||||||
|
|| empty($this->shortUrl)
|
||||||
|
|| empty($this->created)
|
||||||
|
) {
|
||||||
|
throw new InvalidBookmarkException($this);
|
||||||
|
}
|
||||||
|
if (empty($this->url)) {
|
||||||
|
$this->url = '/shaare/' . $this->shortUrl;
|
||||||
|
}
|
||||||
|
if (empty($this->title)) {
|
||||||
|
$this->title = $this->url;
|
||||||
|
}
|
||||||
|
if (array_key_exists('search_highlight', $this->additionalContent)) {
|
||||||
|
unset($this->additionalContent['search_highlight']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the Id.
|
||||||
|
* If they're not already initialized, this function also set:
|
||||||
|
* - created: with the current datetime
|
||||||
|
* - shortUrl: with a generated small hash from the date and the given ID
|
||||||
|
*
|
||||||
|
* @param int|null $id
|
||||||
|
*
|
||||||
|
* @return Bookmark
|
||||||
|
*/
|
||||||
|
public function setId(?int $id): Bookmark
|
||||||
|
{
|
||||||
|
$this->id = $id;
|
||||||
|
if (empty($this->created)) {
|
||||||
|
$this->created = new DateTime();
|
||||||
|
}
|
||||||
|
if (empty($this->shortUrl)) {
|
||||||
|
$this->shortUrl = link_small_hash($this->created, $this->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Id.
|
||||||
|
*
|
||||||
|
* @return int|null
|
||||||
|
*/
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the ShortUrl.
|
||||||
|
*
|
||||||
|
* @return string|null
|
||||||
|
*/
|
||||||
|
public function getShortUrl(): ?string
|
||||||
|
{
|
||||||
|
return $this->shortUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Url.
|
||||||
|
*
|
||||||
|
* @return string|null
|
||||||
|
*/
|
||||||
|
public function getUrl(): ?string
|
||||||
|
{
|
||||||
|
return $this->url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Title.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getTitle(): ?string
|
||||||
|
{
|
||||||
|
return $this->title;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Description.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return ! empty($this->description) ? $this->description : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Created.
|
||||||
|
*
|
||||||
|
* @return DateTimeInterface
|
||||||
|
*/
|
||||||
|
public function getCreated(): ?DateTimeInterface
|
||||||
|
{
|
||||||
|
return $this->created;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Updated.
|
||||||
|
*
|
||||||
|
* @return DateTimeInterface
|
||||||
|
*/
|
||||||
|
public function getUpdated(): ?DateTimeInterface
|
||||||
|
{
|
||||||
|
return $this->updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the ShortUrl.
|
||||||
|
*
|
||||||
|
* @param string|null $shortUrl
|
||||||
|
*
|
||||||
|
* @return Bookmark
|
||||||
|
*/
|
||||||
|
public function setShortUrl(?string $shortUrl): Bookmark
|
||||||
|
{
|
||||||
|
$this->shortUrl = $shortUrl;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the Url.
|
||||||
|
*
|
||||||
|
* @param string|null $url
|
||||||
|
* @param string[] $allowedProtocols
|
||||||
|
*
|
||||||
|
* @return Bookmark
|
||||||
|
*/
|
||||||
|
public function setUrl(?string $url, array $allowedProtocols = []): Bookmark
|
||||||
|
{
|
||||||
|
$url = $url !== null ? trim($url) : '';
|
||||||
|
if (! empty($url)) {
|
||||||
|
$url = whitelist_protocols($url, $allowedProtocols);
|
||||||
|
}
|
||||||
|
$this->url = $url;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the Title.
|
||||||
|
*
|
||||||
|
* @param string|null $title
|
||||||
|
*
|
||||||
|
* @return Bookmark
|
||||||
|
*/
|
||||||
|
public function setTitle(?string $title): Bookmark
|
||||||
|
{
|
||||||
|
$this->title = $title !== null ? trim($title) : '';
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the Description.
|
||||||
|
*
|
||||||
|
* @param string|null $description
|
||||||
|
*
|
||||||
|
* @return Bookmark
|
||||||
|
*/
|
||||||
|
public function setDescription(?string $description): Bookmark
|
||||||
|
{
|
||||||
|
$this->description = $description;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the Created.
|
||||||
|
* Note: you shouldn't set this manually except for special cases (like bookmark import)
|
||||||
|
*
|
||||||
|
* @param DateTimeInterface|null $created
|
||||||
|
*
|
||||||
|
* @return Bookmark
|
||||||
|
*/
|
||||||
|
public function setCreated(?DateTimeInterface $created): Bookmark
|
||||||
|
{
|
||||||
|
$this->created = $created;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the Updated.
|
||||||
|
*
|
||||||
|
* @param DateTimeInterface|null $updated
|
||||||
|
*
|
||||||
|
* @return Bookmark
|
||||||
|
*/
|
||||||
|
public function setUpdated(?DateTimeInterface $updated): Bookmark
|
||||||
|
{
|
||||||
|
$this->updated = $updated;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Private.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function isPrivate(): bool
|
||||||
|
{
|
||||||
|
return $this->private ? true : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the Private.
|
||||||
|
*
|
||||||
|
* @param bool|null $private
|
||||||
|
*
|
||||||
|
* @return Bookmark
|
||||||
|
*/
|
||||||
|
public function setPrivate(?bool $private): Bookmark
|
||||||
|
{
|
||||||
|
$this->private = $private ? true : false;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Tags.
|
||||||
|
*
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
public function getTags(): array
|
||||||
|
{
|
||||||
|
return is_array($this->tags) ? $this->tags : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the Tags.
|
||||||
|
*
|
||||||
|
* @param string[]|null $tags
|
||||||
|
*
|
||||||
|
* @return Bookmark
|
||||||
|
*/
|
||||||
|
public function setTags(?array $tags): Bookmark
|
||||||
|
{
|
||||||
|
$this->tags = array_map(
|
||||||
|
function (string $tag): string {
|
||||||
|
return $tag[0] === '-' ? substr($tag, 1) : $tag;
|
||||||
|
},
|
||||||
|
tags_filter($tags, ' ')
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Thumbnail.
|
||||||
|
*
|
||||||
|
* @return string|bool|null Thumbnail's URL - initialized at null, false if no thumbnail could be found
|
||||||
|
*/
|
||||||
|
public function getThumbnail()
|
||||||
|
{
|
||||||
|
return !$this->isNote() ? $this->thumbnail : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the Thumbnail.
|
||||||
|
*
|
||||||
|
* @param string|bool|null $thumbnail Thumbnail's URL - false if no thumbnail could be found
|
||||||
|
*
|
||||||
|
* @return Bookmark
|
||||||
|
*/
|
||||||
|
public function setThumbnail($thumbnail): Bookmark
|
||||||
|
{
|
||||||
|
$this->thumbnail = $thumbnail;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return true if:
|
||||||
|
* - the bookmark's thumbnail is not already set to false (= not found)
|
||||||
|
* - it's not a note
|
||||||
|
* - it's an HTTP(S) link
|
||||||
|
* - the thumbnail has not yet be retrieved (null) or its associated cache file doesn't exist anymore
|
||||||
|
*
|
||||||
|
* @return bool True if the bookmark's thumbnail needs to be retrieved.
|
||||||
|
*/
|
||||||
|
public function shouldUpdateThumbnail(): bool
|
||||||
|
{
|
||||||
|
return $this->thumbnail !== false
|
||||||
|
&& !$this->isNote()
|
||||||
|
&& startsWith(strtolower($this->url), 'http')
|
||||||
|
&& (null === $this->thumbnail || !is_file($this->thumbnail))
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Sticky.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function isSticky(): bool
|
||||||
|
{
|
||||||
|
return $this->sticky ? true : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the Sticky.
|
||||||
|
*
|
||||||
|
* @param bool|null $sticky
|
||||||
|
*
|
||||||
|
* @return Bookmark
|
||||||
|
*/
|
||||||
|
public function setSticky(?bool $sticky): Bookmark
|
||||||
|
{
|
||||||
|
$this->sticky = $sticky ? true : false;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $separator Tags separator loaded from the config file.
|
||||||
|
*
|
||||||
|
* @return string Bookmark's tags as a string, separated by a separator
|
||||||
|
*/
|
||||||
|
public function getTagsString(string $separator = ' '): string
|
||||||
|
{
|
||||||
|
return tags_array2str($this->getTags(), $separator);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function isNote(): bool
|
||||||
|
{
|
||||||
|
// We check empty value to get a valid result if the link has not been saved yet
|
||||||
|
return empty($this->url) || startsWith($this->url, '/shaare/') || $this->url[0] === '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set tags from a string.
|
||||||
|
* Note:
|
||||||
|
* - tags must be separated whether by a space or a comma
|
||||||
|
* - multiple spaces will be removed
|
||||||
|
* - trailing dash in tags will be removed
|
||||||
|
*
|
||||||
|
* @param string|null $tags
|
||||||
|
* @param string $separator Tags separator loaded from the config file.
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function setTagsString(?string $tags, string $separator = ' '): Bookmark
|
||||||
|
{
|
||||||
|
$this->setTags(tags_str2array($tags, $separator));
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get entire additionalContent array.
|
||||||
|
*
|
||||||
|
* @return mixed[]
|
||||||
|
*/
|
||||||
|
public function getAdditionalContent(): array
|
||||||
|
{
|
||||||
|
return $this->additionalContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a single entry in additionalContent, by key.
|
||||||
|
*
|
||||||
|
* @param string $key
|
||||||
|
* @param mixed|null $value Any type of value can be set.
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function setAdditionalContentEntry(string $key, $value): self
|
||||||
|
{
|
||||||
|
$this->additionalContent[$key] = $value;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single entry in additionalContent, by key.
|
||||||
|
*
|
||||||
|
* @param string $key
|
||||||
|
* @param mixed|null $default
|
||||||
|
*
|
||||||
|
* @return mixed|null can be any type or even null.
|
||||||
|
*/
|
||||||
|
public function getAdditionalContentEntry(string $key, $default = null)
|
||||||
|
{
|
||||||
|
return array_key_exists($key, $this->additionalContent) ? $this->additionalContent[$key] : $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rename a tag in tags list.
|
||||||
|
*
|
||||||
|
* @param string $fromTag
|
||||||
|
* @param string $toTag
|
||||||
|
*/
|
||||||
|
public function renameTag(string $fromTag, string $toTag): void
|
||||||
|
{
|
||||||
|
if (($pos = array_search($fromTag, $this->tags ?? [])) !== false) {
|
||||||
|
$this->tags[$pos] = trim($toTag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a tag in tags list.
|
||||||
|
*
|
||||||
|
* @param string $tag
|
||||||
|
*/
|
||||||
|
public function addTag(string $tag): self
|
||||||
|
{
|
||||||
|
return $this->setTags(array_unique(array_merge($this->getTags(), [$tag])));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a tag from tags list.
|
||||||
|
*
|
||||||
|
* @param string $tag
|
||||||
|
*/
|
||||||
|
public function deleteTag(string $tag): void
|
||||||
|
{
|
||||||
|
while (($pos = array_search($tag, $this->tags ?? [])) !== false) {
|
||||||
|
unset($this->tags[$pos]);
|
||||||
|
$this->tags = array_values($this->tags);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
264
application/bookmark/BookmarkArray.php
Normal file
264
application/bookmark/BookmarkArray.php
Normal file
|
@ -0,0 +1,264 @@
|
||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
443
application/bookmark/BookmarkFileService.php
Normal file
443
application/bookmark/BookmarkFileService.php
Normal file
|
@ -0,0 +1,443 @@
|
||||||
|
<?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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
635
application/bookmark/BookmarkFilter.php
Normal file
635
application/bookmark/BookmarkFilter.php
Normal file
|
@ -0,0 +1,635 @@
|
||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
173
application/bookmark/BookmarkIO.php
Normal file
173
application/bookmark/BookmarkIO.php
Normal file
|
@ -0,0 +1,173 @@
|
||||||
|
<?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
|
||||||
|
{
|
||||||
|
return disk_free_space(dirname($this->datastore)) > (strlen($data) + 1024 * 500);
|
||||||
|
}
|
||||||
|
}
|
115
application/bookmark/BookmarkInitializer.php
Normal file
115
application/bookmark/BookmarkInitializer.php
Normal file
|
@ -0,0 +1,115 @@
|
||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
189
application/bookmark/BookmarkServiceInterface.php
Normal file
189
application/bookmark/BookmarkServiceInterface.php
Normal file
|
@ -0,0 +1,189 @@
|
||||||
|
<?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;
|
||||||
|
}
|
|
@ -1,112 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Shaarli\Bookmark\LinkDB;
|
use Shaarli\Bookmark\Bookmark;
|
||||||
|
use Shaarli\Formatter\BookmarkDefaultFormatter;
|
||||||
/**
|
|
||||||
* Get cURL callback function for CURLOPT_WRITEFUNCTION
|
|
||||||
*
|
|
||||||
* @param string $charset to extract from the downloaded page (reference)
|
|
||||||
* @param string $title to extract from the downloaded page (reference)
|
|
||||||
* @param string $description to extract from the downloaded page (reference)
|
|
||||||
* @param string $keywords to extract from the downloaded page (reference)
|
|
||||||
* @param bool $retrieveDescription Automatically tries to retrieve description and keywords from HTML content
|
|
||||||
* @param string $curlGetInfo Optionally overrides curl_getinfo function
|
|
||||||
*
|
|
||||||
* @return Closure
|
|
||||||
*/
|
|
||||||
function get_curl_download_callback(
|
|
||||||
&$charset,
|
|
||||||
&$title,
|
|
||||||
&$description,
|
|
||||||
&$keywords,
|
|
||||||
$retrieveDescription,
|
|
||||||
$curlGetInfo = 'curl_getinfo'
|
|
||||||
) {
|
|
||||||
$isRedirected = false;
|
|
||||||
$currentChunk = 0;
|
|
||||||
$foundChunk = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* cURL callback function for CURLOPT_WRITEFUNCTION (called during the download).
|
|
||||||
*
|
|
||||||
* While downloading the remote page, we check that the HTTP code is 200 and content type is 'html/text'
|
|
||||||
* Then we extract the title and the charset and stop the download when it's done.
|
|
||||||
*
|
|
||||||
* @param resource $ch cURL resource
|
|
||||||
* @param string $data chunk of data being downloaded
|
|
||||||
*
|
|
||||||
* @return int|bool length of $data or false if we need to stop the download
|
|
||||||
*/
|
|
||||||
return function (&$ch, $data) use (
|
|
||||||
$retrieveDescription,
|
|
||||||
$curlGetInfo,
|
|
||||||
&$charset,
|
|
||||||
&$title,
|
|
||||||
&$description,
|
|
||||||
&$keywords,
|
|
||||||
&$isRedirected,
|
|
||||||
&$currentChunk,
|
|
||||||
&$foundChunk
|
|
||||||
) {
|
|
||||||
$currentChunk++;
|
|
||||||
$responseCode = $curlGetInfo($ch, CURLINFO_RESPONSE_CODE);
|
|
||||||
if (!empty($responseCode) && in_array($responseCode, [301, 302])) {
|
|
||||||
$isRedirected = true;
|
|
||||||
return strlen($data);
|
|
||||||
}
|
|
||||||
if (!empty($responseCode) && $responseCode !== 200) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// After a redirection, the content type will keep the previous request value
|
|
||||||
// until it finds the next content-type header.
|
|
||||||
if (! $isRedirected || strpos(strtolower($data), 'content-type') !== false) {
|
|
||||||
$contentType = $curlGetInfo($ch, CURLINFO_CONTENT_TYPE);
|
|
||||||
}
|
|
||||||
if (!empty($contentType) && strpos($contentType, 'text/html') === false) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!empty($contentType) && empty($charset)) {
|
|
||||||
$charset = header_extract_charset($contentType);
|
|
||||||
}
|
|
||||||
if (empty($charset)) {
|
|
||||||
$charset = html_extract_charset($data);
|
|
||||||
}
|
|
||||||
if (empty($title)) {
|
|
||||||
$title = html_extract_title($data);
|
|
||||||
$foundChunk = ! empty($title) ? $currentChunk : $foundChunk;
|
|
||||||
}
|
|
||||||
if ($retrieveDescription && empty($description)) {
|
|
||||||
$description = html_extract_tag('description', $data);
|
|
||||||
$foundChunk = ! empty($description) ? $currentChunk : $foundChunk;
|
|
||||||
}
|
|
||||||
if ($retrieveDescription && empty($keywords)) {
|
|
||||||
$keywords = html_extract_tag('keywords', $data);
|
|
||||||
if (! empty($keywords)) {
|
|
||||||
$foundChunk = $currentChunk;
|
|
||||||
// Keywords use the format tag1, tag2 multiple words, tag
|
|
||||||
// So we format them to match Shaarli's separator and glue multiple words with '-'
|
|
||||||
$keywords = implode(' ', array_map(function($keyword) {
|
|
||||||
return implode('-', preg_split('/\s+/', trim($keyword)));
|
|
||||||
}, explode(',', $keywords)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// We got everything we want, stop the download.
|
|
||||||
// If we already found either the title, description or keywords,
|
|
||||||
// it's highly unlikely that we'll found the other metas further than
|
|
||||||
// in the same chunk of data or the next one. So we also stop the download after that.
|
|
||||||
if ((!empty($responseCode) && !empty($contentType) && !empty($charset)) && $foundChunk !== null
|
|
||||||
&& (! $retrieveDescription
|
|
||||||
|| $foundChunk < $currentChunk
|
|
||||||
|| (!empty($title) && !empty($description) && !empty($keywords))
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return strlen($data);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract title from an HTML document.
|
* Extract title from an HTML document.
|
||||||
|
@ -132,7 +27,7 @@ function html_extract_title($html)
|
||||||
*/
|
*/
|
||||||
function header_extract_charset($header)
|
function header_extract_charset($header)
|
||||||
{
|
{
|
||||||
preg_match('/charset="?([^; ]+)/i', $header, $match);
|
preg_match('/charset=["\']?([^; "\']+)/i', $header, $match);
|
||||||
if (! empty($match[1])) {
|
if (! empty($match[1])) {
|
||||||
return strtolower(trim($match[1]));
|
return strtolower(trim($match[1]));
|
||||||
}
|
}
|
||||||
|
@ -172,53 +67,50 @@ function html_extract_tag($tag, $html)
|
||||||
{
|
{
|
||||||
$propertiesKey = ['property', 'name', 'itemprop'];
|
$propertiesKey = ['property', 'name', 'itemprop'];
|
||||||
$properties = implode('|', $propertiesKey);
|
$properties = implode('|', $propertiesKey);
|
||||||
// Try to retrieve OpenGraph image.
|
// We need a OR here to accept either 'property=og:noquote' or 'property="og:unrelated og:my-tag"'
|
||||||
$ogRegex = '#<meta[^>]+(?:'. $properties .')=["\']?(?:og:)?'. $tag .'["\'\s][^>]*content=["\']?(.*?)["\'/>]#';
|
$orCondition = '["\']?(?:og:)?' . $tag . '["\']?|["\'][^\'"]*?(?:og:)?' . $tag . '[^\'"]*?[\'"]';
|
||||||
|
// Support quotes in double quoted content, and the other way around
|
||||||
|
$content = 'content=(["\'])((?:(?!\1).)*)\1';
|
||||||
|
// Try to retrieve OpenGraph tag.
|
||||||
|
$ogRegex = '#<meta[^>]+(?:' . $properties . ')=(?:' . $orCondition . ')[^>]*' . $content . '.*?>#';
|
||||||
// If the attributes are not in the order property => content (e.g. Github)
|
// If the attributes are not in the order property => content (e.g. Github)
|
||||||
// New regex to keep this readable... more or less.
|
// New regex to keep this readable... more or less.
|
||||||
$ogRegexReverse = '#<meta[^>]+content=["\']([^"\']+)[^>]+(?:'. $properties .')=["\']?(?:og)?:'. $tag .'["\'\s/>]#';
|
$ogRegexReverse = '#<meta[^>]+' . $content . '[^>]+(?:' . $properties . ')=(?:' . $orCondition . ').*?>#';
|
||||||
|
|
||||||
if (preg_match($ogRegex, $html, $matches) > 0
|
if (
|
||||||
|
preg_match($ogRegex, $html, $matches) > 0
|
||||||
|| preg_match($ogRegexReverse, $html, $matches) > 0
|
|| preg_match($ogRegexReverse, $html, $matches) > 0
|
||||||
) {
|
) {
|
||||||
return $matches[1];
|
return $matches[2];
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Count private links in given linklist.
|
* In a string, converts URLs to clickable bookmarks.
|
||||||
*
|
|
||||||
* @param array|Countable $links Linklist.
|
|
||||||
*
|
|
||||||
* @return int Number of private links.
|
|
||||||
*/
|
|
||||||
function count_private($links)
|
|
||||||
{
|
|
||||||
$cpt = 0;
|
|
||||||
foreach ($links as $link) {
|
|
||||||
if ($link['private']) {
|
|
||||||
$cpt += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $cpt;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In a string, converts URLs to clickable links.
|
|
||||||
*
|
*
|
||||||
* @param string $text input string.
|
* @param string $text input string.
|
||||||
*
|
*
|
||||||
* @return string returns $text with all links converted to HTML links.
|
* @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
|
* @see Function inspired from http://www.php.net/manual/en/function.preg-replace.php#85722
|
||||||
*/
|
*/
|
||||||
function text2clickable($text)
|
function text2clickable($text)
|
||||||
{
|
{
|
||||||
$regex = '!(((?:https?|ftp|file)://|apt:|magnet:)\S+[a-z0-9\(\)]/?)!si';
|
$regex = '!(((?:https?|ftp|file)://|apt:|magnet:)\S+[a-z0-9\(\)]/?)!si';
|
||||||
return preg_replace($regex, '<a href="$1">$1</a>', $text);
|
$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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -231,6 +123,9 @@ function text2clickable($text)
|
||||||
*/
|
*/
|
||||||
function hashtag_autolink($description, $indexUrl = '')
|
function hashtag_autolink($description, $indexUrl = '')
|
||||||
{
|
{
|
||||||
|
$tokens = '(?:' . BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_OPEN . ')' .
|
||||||
|
'(?:' . BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_CLOSE . ')'
|
||||||
|
;
|
||||||
/*
|
/*
|
||||||
* To support unicode: http://stackoverflow.com/a/35498078/1484919
|
* To support unicode: http://stackoverflow.com/a/35498078/1484919
|
||||||
* \p{Pc} - to match underscore
|
* \p{Pc} - to match underscore
|
||||||
|
@ -238,9 +133,20 @@ function hashtag_autolink($description, $indexUrl = '')
|
||||||
* \p{L} - letter from any language
|
* \p{L} - letter from any language
|
||||||
* \p{Mn} - any non marking space (accents, umlauts, etc)
|
* \p{Mn} - any non marking space (accents, umlauts, etc)
|
||||||
*/
|
*/
|
||||||
$regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui';
|
$regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}' . $tokens . ']+)/mui';
|
||||||
$replacement = '$1<a href="'. $indexUrl .'?addtag=$2" title="Hashtag $2">#$2</a>';
|
$format = function (array $match) use ($indexUrl): string {
|
||||||
return preg_replace($regex, $replacement, $description);
|
$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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -261,12 +167,17 @@ function space2nbsp($text)
|
||||||
*
|
*
|
||||||
* @param string $description shaare's description.
|
* @param string $description shaare's description.
|
||||||
* @param string $indexUrl URL to Shaarli's index.
|
* @param string $indexUrl URL to Shaarli's index.
|
||||||
|
* @param bool $autolink Turn on/off automatic linkifications of URLs and hashtags
|
||||||
|
*
|
||||||
* @return string formatted description.
|
* @return string formatted description.
|
||||||
*/
|
*/
|
||||||
function format_description($description, $indexUrl = '')
|
function format_description($description, $indexUrl = '', $autolink = true)
|
||||||
{
|
{
|
||||||
return nl2br(space2nbsp(hashtag_autolink(text2clickable($description), $indexUrl)));
|
if ($autolink) {
|
||||||
|
$description = hashtag_autolink(text2clickable($description), $indexUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return nl2br(space2nbsp($description));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -279,7 +190,7 @@ function format_description($description, $indexUrl = '')
|
||||||
*/
|
*/
|
||||||
function link_small_hash($date, $id)
|
function link_small_hash($date, $id)
|
||||||
{
|
{
|
||||||
return smallHash($date->format(LinkDB::LINK_DATE_FORMAT) . $id);
|
return smallHash($date->format(Bookmark::LINK_DATE_FORMAT) . $id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -294,3 +205,49 @@ function is_note($linkUrl)
|
||||||
{
|
{
|
||||||
return isset($linkUrl[0]) && $linkUrl[0] === '?';
|
return isset($linkUrl[0]) && $linkUrl[0] === '?';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract an array of tags from a given tag string, with provided separator.
|
||||||
|
*
|
||||||
|
* @param string|null $tags String containing a list of tags separated by $separator.
|
||||||
|
* @param string $separator Shaarli's default: ' ' (whitespace)
|
||||||
|
*
|
||||||
|
* @return array List of tags
|
||||||
|
*/
|
||||||
|
function tags_str2array(?string $tags, string $separator): array
|
||||||
|
{
|
||||||
|
// For whitespaces, we use the special \s regex character
|
||||||
|
$separator = 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 ?? [])));
|
||||||
|
}
|
||||||
|
|
136
application/bookmark/SearchResult.php
Normal file
136
application/bookmark/SearchResult.php
Normal file
|
@ -0,0 +1,136 @@
|
||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
16
application/bookmark/exception/BookmarkNotFoundException.php
Normal file
16
application/bookmark/exception/BookmarkNotFoundException.php
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
<?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.');
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Bookmark\Exception;
|
||||||
|
|
||||||
|
class DatastoreNotInitializedException extends \Exception
|
||||||
|
{
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Bookmark\Exception;
|
||||||
|
|
||||||
|
class EmptyDataStoreException extends \Exception
|
||||||
|
{
|
||||||
|
}
|
30
application/bookmark/exception/InvalidBookmarkException.php
Normal file
30
application/bookmark/exception/InvalidBookmarkException.php
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
<?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.';
|
||||||
|
}
|
||||||
|
}
|
14
application/bookmark/exception/NotEnoughSpaceException.php
Normal file
14
application/bookmark/exception/NotEnoughSpaceException.php
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
<?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.';
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?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.';
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,4 +1,5 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Shaarli\Config;
|
namespace Shaarli\Config;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -19,7 +19,7 @@ class ConfigJson implements ConfigIO
|
||||||
$data = file_get_contents($filepath);
|
$data = file_get_contents($filepath);
|
||||||
$data = str_replace(self::getPhpHeaders(), '', $data);
|
$data = str_replace(self::getPhpHeaders(), '', $data);
|
||||||
$data = str_replace(self::getPhpSuffix(), '', $data);
|
$data = str_replace(self::getPhpSuffix(), '', $data);
|
||||||
$data = json_decode($data, true);
|
$data = json_decode(trim($data), true);
|
||||||
if ($data === null) {
|
if ($data === null) {
|
||||||
$errorCode = json_last_error();
|
$errorCode = json_last_error();
|
||||||
$error = sprintf(
|
$error = sprintf(
|
||||||
|
@ -46,7 +46,7 @@ class ConfigJson implements ConfigIO
|
||||||
// JSON_PRETTY_PRINT is available from PHP 5.4.
|
// JSON_PRETTY_PRINT is available from PHP 5.4.
|
||||||
$print = defined('JSON_PRETTY_PRINT') ? JSON_PRETTY_PRINT : 0;
|
$print = defined('JSON_PRETTY_PRINT') ? JSON_PRETTY_PRINT : 0;
|
||||||
$data = self::getPhpHeaders() . json_encode($conf, $print) . self::getPhpSuffix();
|
$data = self::getPhpHeaders() . json_encode($conf, $print) . self::getPhpSuffix();
|
||||||
if (!file_put_contents($filepath, $data)) {
|
if (empty($filepath) || !file_put_contents($filepath, $data)) {
|
||||||
throw new \Shaarli\Exceptions\IOException(
|
throw new \Shaarli\Exceptions\IOException(
|
||||||
$filepath,
|
$filepath,
|
||||||
t('Shaarli could not create the config file. '.
|
t('Shaarli could not create the config file. '.
|
||||||
|
@ -73,7 +73,7 @@ class ConfigJson implements ConfigIO
|
||||||
*/
|
*/
|
||||||
public static function getPhpHeaders()
|
public static function getPhpHeaders()
|
||||||
{
|
{
|
||||||
return '<?php /*'. PHP_EOL;
|
return '<?php /*';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -85,6 +85,6 @@ class ConfigJson implements ConfigIO
|
||||||
*/
|
*/
|
||||||
public static function getPhpSuffix()
|
public static function getPhpSuffix()
|
||||||
{
|
{
|
||||||
return PHP_EOL . '*/ ?>';
|
return '*/ ?>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Shaarli\Config;
|
namespace Shaarli\Config;
|
||||||
|
|
||||||
use Shaarli\Config\Exception\MissingFieldConfigException;
|
use Shaarli\Config\Exception\MissingFieldConfigException;
|
||||||
use Shaarli\Config\Exception\UnauthorizedConfigException;
|
use Shaarli\Config\Exception\UnauthorizedConfigException;
|
||||||
|
use Shaarli\Thumbnailer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class ConfigManager
|
* Class ConfigManager
|
||||||
|
@ -19,7 +21,7 @@ class ConfigManager
|
||||||
*/
|
*/
|
||||||
protected static $NOT_FOUND = 'NOT_FOUND';
|
protected static $NOT_FOUND = 'NOT_FOUND';
|
||||||
|
|
||||||
public static $DEFAULT_PLUGINS = array('qrcode');
|
public static $DEFAULT_PLUGINS = ['qrcode'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var string Config folder.
|
* @var string Config folder.
|
||||||
|
@ -132,7 +134,7 @@ class ConfigManager
|
||||||
public function set($setting, $value, $write = false, $isLoggedIn = false)
|
public function set($setting, $value, $write = false, $isLoggedIn = false)
|
||||||
{
|
{
|
||||||
if (empty($setting) || ! is_string($setting)) {
|
if (empty($setting) || ! is_string($setting)) {
|
||||||
throw new \Exception(t('Invalid setting key parameter. String expected, got: '). gettype($setting));
|
throw new \Exception(t('Invalid setting key parameter. String expected, got: ') . gettype($setting));
|
||||||
}
|
}
|
||||||
|
|
||||||
// During the ConfigIO transition, map legacy settings to the new ones.
|
// During the ConfigIO transition, map legacy settings to the new ones.
|
||||||
|
@ -159,7 +161,7 @@ class ConfigManager
|
||||||
public function remove($setting, $write = false, $isLoggedIn = false)
|
public function remove($setting, $write = false, $isLoggedIn = false)
|
||||||
{
|
{
|
||||||
if (empty($setting) || ! is_string($setting)) {
|
if (empty($setting) || ! is_string($setting)) {
|
||||||
throw new \Exception(t('Invalid setting key parameter. String expected, got: '). gettype($setting));
|
throw new \Exception(t('Invalid setting key parameter. String expected, got: ') . gettype($setting));
|
||||||
}
|
}
|
||||||
|
|
||||||
// During the ConfigIO transition, map legacy settings to the new ones.
|
// During the ConfigIO transition, map legacy settings to the new ones.
|
||||||
|
@ -212,7 +214,7 @@ class ConfigManager
|
||||||
public function write($isLoggedIn)
|
public function write($isLoggedIn)
|
||||||
{
|
{
|
||||||
// These fields are required in configuration.
|
// These fields are required in configuration.
|
||||||
$mandatoryFields = array(
|
$mandatoryFields = [
|
||||||
'credentials.login',
|
'credentials.login',
|
||||||
'credentials.hash',
|
'credentials.hash',
|
||||||
'credentials.salt',
|
'credentials.salt',
|
||||||
|
@ -221,7 +223,7 @@ class ConfigManager
|
||||||
'general.title',
|
'general.title',
|
||||||
'general.header_link',
|
'general.header_link',
|
||||||
'privacy.default_private_links',
|
'privacy.default_private_links',
|
||||||
);
|
];
|
||||||
|
|
||||||
// Only logged in user can alter config.
|
// Only logged in user can alter config.
|
||||||
if (is_file($this->getConfigFileExt()) && !$isLoggedIn) {
|
if (is_file($this->getConfigFileExt()) && !$isLoggedIn) {
|
||||||
|
@ -361,14 +363,16 @@ class ConfigManager
|
||||||
$this->setEmpty('security.open_shaarli', false);
|
$this->setEmpty('security.open_shaarli', false);
|
||||||
$this->setEmpty('security.allowed_protocols', ['ftp', 'ftps', 'magnet']);
|
$this->setEmpty('security.allowed_protocols', ['ftp', 'ftps', 'magnet']);
|
||||||
|
|
||||||
$this->setEmpty('general.header_link', '?');
|
$this->setEmpty('general.header_link', '/');
|
||||||
$this->setEmpty('general.links_per_page', 20);
|
$this->setEmpty('general.links_per_page', 20);
|
||||||
$this->setEmpty('general.enabled_plugins', self::$DEFAULT_PLUGINS);
|
$this->setEmpty('general.enabled_plugins', self::$DEFAULT_PLUGINS);
|
||||||
$this->setEmpty('general.default_note_title', 'Note: ');
|
$this->setEmpty('general.default_note_title', 'Note: ');
|
||||||
$this->setEmpty('general.retrieve_description', false);
|
$this->setEmpty('general.retrieve_description', true);
|
||||||
|
$this->setEmpty('general.enable_async_metadata', true);
|
||||||
|
$this->setEmpty('general.tags_separator', ' ');
|
||||||
|
|
||||||
$this->setEmpty('updates.check_updates', false);
|
$this->setEmpty('updates.check_updates', true);
|
||||||
$this->setEmpty('updates.check_updates_branch', 'stable');
|
$this->setEmpty('updates.check_updates_branch', 'latest');
|
||||||
$this->setEmpty('updates.check_updates_interval', 86400);
|
$this->setEmpty('updates.check_updates_interval', 86400);
|
||||||
|
|
||||||
$this->setEmpty('feed.rss_permalinks', true);
|
$this->setEmpty('feed.rss_permalinks', true);
|
||||||
|
@ -381,6 +385,7 @@ class ConfigManager
|
||||||
// default state of the 'remember me' checkbox of the login form
|
// default state of the 'remember me' checkbox of the login form
|
||||||
$this->setEmpty('privacy.remember_user_default', true);
|
$this->setEmpty('privacy.remember_user_default', true);
|
||||||
|
|
||||||
|
$this->setEmpty('thumbnails.mode', Thumbnailer::MODE_ALL);
|
||||||
$this->setEmpty('thumbnails.width', '125');
|
$this->setEmpty('thumbnails.width', '125');
|
||||||
$this->setEmpty('thumbnails.height', '90');
|
$this->setEmpty('thumbnails.height', '90');
|
||||||
|
|
||||||
|
@ -388,7 +393,9 @@ class ConfigManager
|
||||||
$this->setEmpty('translation.mode', 'php');
|
$this->setEmpty('translation.mode', 'php');
|
||||||
$this->setEmpty('translation.extensions', []);
|
$this->setEmpty('translation.extensions', []);
|
||||||
|
|
||||||
$this->setEmpty('plugins', array());
|
$this->setEmpty('plugins', []);
|
||||||
|
|
||||||
|
$this->setEmpty('formatter', 'markdown');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Shaarli\Config;
|
namespace Shaarli\Config;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -12,7 +13,7 @@ class ConfigPhp implements ConfigIO
|
||||||
/**
|
/**
|
||||||
* @var array List of config key without group.
|
* @var array List of config key without group.
|
||||||
*/
|
*/
|
||||||
public static $ROOT_KEYS = array(
|
public static $ROOT_KEYS = [
|
||||||
'login',
|
'login',
|
||||||
'hash',
|
'hash',
|
||||||
'salt',
|
'salt',
|
||||||
|
@ -22,7 +23,7 @@ class ConfigPhp implements ConfigIO
|
||||||
'redirector',
|
'redirector',
|
||||||
'disablesessionprotection',
|
'disablesessionprotection',
|
||||||
'privateLinkByDefault',
|
'privateLinkByDefault',
|
||||||
);
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map legacy config keys with the new ones.
|
* Map legacy config keys with the new ones.
|
||||||
|
@ -31,7 +32,7 @@ class ConfigPhp implements ConfigIO
|
||||||
*
|
*
|
||||||
* @var array current key => legacy key.
|
* @var array current key => legacy key.
|
||||||
*/
|
*/
|
||||||
public static $LEGACY_KEYS_MAPPING = array(
|
public static $LEGACY_KEYS_MAPPING = [
|
||||||
'credentials.login' => 'login',
|
'credentials.login' => 'login',
|
||||||
'credentials.hash' => 'hash',
|
'credentials.hash' => 'hash',
|
||||||
'credentials.salt' => 'salt',
|
'credentials.salt' => 'salt',
|
||||||
|
@ -68,7 +69,7 @@ class ConfigPhp implements ConfigIO
|
||||||
'privacy.hide_public_links' => 'config.HIDE_PUBLIC_LINKS',
|
'privacy.hide_public_links' => 'config.HIDE_PUBLIC_LINKS',
|
||||||
'privacy.hide_timestamps' => 'config.HIDE_TIMESTAMPS',
|
'privacy.hide_timestamps' => 'config.HIDE_TIMESTAMPS',
|
||||||
'security.open_shaarli' => 'config.OPEN_SHAARLI',
|
'security.open_shaarli' => 'config.OPEN_SHAARLI',
|
||||||
);
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @inheritdoc
|
* @inheritdoc
|
||||||
|
@ -76,12 +77,12 @@ class ConfigPhp implements ConfigIO
|
||||||
public function read($filepath)
|
public function read($filepath)
|
||||||
{
|
{
|
||||||
if (! file_exists($filepath) || ! is_readable($filepath)) {
|
if (! file_exists($filepath) || ! is_readable($filepath)) {
|
||||||
return array();
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
include $filepath;
|
include $filepath;
|
||||||
|
|
||||||
$out = array();
|
$out = [];
|
||||||
foreach (self::$ROOT_KEYS as $key) {
|
foreach (self::$ROOT_KEYS as $key) {
|
||||||
$out[$key] = isset($GLOBALS[$key]) ? $GLOBALS[$key] : '';
|
$out[$key] = isset($GLOBALS[$key]) ? $GLOBALS[$key] : '';
|
||||||
}
|
}
|
||||||
|
@ -95,7 +96,7 @@ class ConfigPhp implements ConfigIO
|
||||||
*/
|
*/
|
||||||
public function write($filepath, $conf)
|
public function write($filepath, $conf)
|
||||||
{
|
{
|
||||||
$configStr = '<?php '. PHP_EOL;
|
$configStr = '<?php ' . PHP_EOL;
|
||||||
foreach (self::$ROOT_KEYS as $key) {
|
foreach (self::$ROOT_KEYS as $key) {
|
||||||
if (isset($conf[$key])) {
|
if (isset($conf[$key])) {
|
||||||
$configStr .= '$GLOBALS[\'' . $key . '\'] = ' . var_export($conf[$key], true) . ';' . PHP_EOL;
|
$configStr .= '$GLOBALS[\'' . $key . '\'] = ' . var_export($conf[$key], true) . ';' . PHP_EOL;
|
||||||
|
@ -106,8 +107,8 @@ class ConfigPhp implements ConfigIO
|
||||||
foreach ($conf['config'] as $key => $value) {
|
foreach ($conf['config'] as $key => $value) {
|
||||||
$configStr .= '$GLOBALS[\'config\'][\''
|
$configStr .= '$GLOBALS[\'config\'][\''
|
||||||
. $key
|
. $key
|
||||||
.'\'] = '
|
. '\'] = '
|
||||||
.var_export($conf['config'][$key], true).';'
|
. var_export($conf['config'][$key], true) . ';'
|
||||||
. PHP_EOL;
|
. PHP_EOL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -115,18 +116,19 @@ class ConfigPhp implements ConfigIO
|
||||||
foreach ($conf['plugins'] as $key => $value) {
|
foreach ($conf['plugins'] as $key => $value) {
|
||||||
$configStr .= '$GLOBALS[\'plugins\'][\''
|
$configStr .= '$GLOBALS[\'plugins\'][\''
|
||||||
. $key
|
. $key
|
||||||
.'\'] = '
|
. '\'] = '
|
||||||
.var_export($conf['plugins'][$key], true).';'
|
. var_export($conf['plugins'][$key], true) . ';'
|
||||||
. PHP_EOL;
|
. PHP_EOL;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!file_put_contents($filepath, $configStr)
|
if (
|
||||||
|
!file_put_contents($filepath, $configStr)
|
||||||
|| strcmp(file_get_contents($filepath), $configStr) != 0
|
|| strcmp(file_get_contents($filepath), $configStr) != 0
|
||||||
) {
|
) {
|
||||||
throw new \Shaarli\Exceptions\IOException(
|
throw new \Shaarli\Exceptions\IOException(
|
||||||
$filepath,
|
$filepath,
|
||||||
t('Shaarli could not create the config file. '.
|
t('Shaarli could not create the config file. ' .
|
||||||
'Please make sure Shaarli has the right to write in the folder is it installed in.')
|
'Please make sure Shaarli has the right to write in the folder is it installed in.')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Shaarli\Config\Exception\PluginConfigOrderException;
|
use Shaarli\Config\Exception\PluginConfigOrderException;
|
||||||
|
use Shaarli\Plugin\PluginManager;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Plugin configuration helper functions.
|
* Plugin configuration helper functions.
|
||||||
|
@ -19,13 +20,27 @@ use Shaarli\Config\Exception\PluginConfigOrderException;
|
||||||
*/
|
*/
|
||||||
function save_plugin_config($formData)
|
function save_plugin_config($formData)
|
||||||
{
|
{
|
||||||
|
// We can only save existing plugins
|
||||||
|
$directories = str_replace(
|
||||||
|
PluginManager::$PLUGINS_PATH . '/',
|
||||||
|
'',
|
||||||
|
glob(PluginManager::$PLUGINS_PATH . '/*')
|
||||||
|
);
|
||||||
|
$formData = array_filter(
|
||||||
|
$formData,
|
||||||
|
function ($value, string $key) use ($directories) {
|
||||||
|
return startsWith($key, 'order') || in_array($key, $directories);
|
||||||
|
},
|
||||||
|
ARRAY_FILTER_USE_BOTH
|
||||||
|
);
|
||||||
|
|
||||||
// Make sure there are no duplicates in orders.
|
// Make sure there are no duplicates in orders.
|
||||||
if (!validate_plugin_order($formData)) {
|
if (!validate_plugin_order($formData)) {
|
||||||
throw new PluginConfigOrderException();
|
throw new PluginConfigOrderException();
|
||||||
}
|
}
|
||||||
|
|
||||||
$plugins = array();
|
$plugins = [];
|
||||||
$newEnabledPlugins = array();
|
$newEnabledPlugins = [];
|
||||||
foreach ($formData as $key => $data) {
|
foreach ($formData as $key => $data) {
|
||||||
if (startsWith($key, 'order')) {
|
if (startsWith($key, 'order')) {
|
||||||
continue;
|
continue;
|
||||||
|
@ -47,7 +62,7 @@ function save_plugin_config($formData)
|
||||||
throw new PluginConfigOrderException();
|
throw new PluginConfigOrderException();
|
||||||
}
|
}
|
||||||
|
|
||||||
$finalPlugins = array();
|
$finalPlugins = [];
|
||||||
// Make plugins order continuous.
|
// Make plugins order continuous.
|
||||||
foreach ($plugins as $plugin) {
|
foreach ($plugins as $plugin) {
|
||||||
$finalPlugins[] = $plugin;
|
$finalPlugins[] = $plugin;
|
||||||
|
@ -66,10 +81,10 @@ function save_plugin_config($formData)
|
||||||
*/
|
*/
|
||||||
function validate_plugin_order($formData)
|
function validate_plugin_order($formData)
|
||||||
{
|
{
|
||||||
$orders = array();
|
$orders = [];
|
||||||
foreach ($formData as $key => $value) {
|
foreach ($formData as $key => $value) {
|
||||||
// No duplicate order allowed.
|
// No duplicate order allowed.
|
||||||
if (in_array($value, $orders)) {
|
if (in_array($value, $orders, true)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
|
||||||
namespace Shaarli\Config\Exception;
|
namespace Shaarli\Config\Exception;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
|
||||||
namespace Shaarli\Config\Exception;
|
namespace Shaarli\Config\Exception;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
176
application/container/ContainerBuilder.php
Normal file
176
application/container/ContainerBuilder.php
Normal file
|
@ -0,0 +1,176 @@
|
||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
54
application/container/ShaarliContainer.php
Normal file
54
application/container/ShaarliContainer.php
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
<?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
|
||||||
|
{
|
||||||
|
}
|
|
@ -1,4 +1,5 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Shaarli\Exceptions;
|
namespace Shaarli\Exceptions;
|
||||||
|
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
|
@ -1,34 +1,43 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Shaarli\Feed;
|
namespace Shaarli\Feed;
|
||||||
|
|
||||||
|
use DatePeriod;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simple cache system, mainly for the RSS/ATOM feeds
|
* Simple cache system, mainly for the RSS/ATOM feeds
|
||||||
*/
|
*/
|
||||||
class CachedPage
|
class CachedPage
|
||||||
{
|
{
|
||||||
// Directory containing page caches
|
/** Directory containing page caches */
|
||||||
private $cacheDir;
|
protected $cacheDir;
|
||||||
|
|
||||||
// Should this URL be cached (boolean)?
|
/** Should this URL be cached (boolean)? */
|
||||||
private $shouldBeCached;
|
protected $shouldBeCached;
|
||||||
|
|
||||||
// Name of the cache file for this URL
|
/** Name of the cache file for this URL */
|
||||||
private $filename;
|
protected $filename;
|
||||||
|
|
||||||
|
/** @var DatePeriod|null Optionally specify a period of time for cache validity */
|
||||||
|
protected $validityPeriod;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new CachedPage
|
* Creates a new CachedPage
|
||||||
*
|
*
|
||||||
* @param string $cacheDir page cache directory
|
* @param string $cacheDir page cache directory
|
||||||
* @param string $url page URL
|
* @param string $url page URL
|
||||||
* @param bool $shouldBeCached whether this page needs to be cached
|
* @param bool $shouldBeCached whether this page needs to be cached
|
||||||
|
* @param ?DatePeriod $validityPeriod Optionally specify a time limit on requested cache
|
||||||
*/
|
*/
|
||||||
public function __construct($cacheDir, $url, $shouldBeCached)
|
public function __construct($cacheDir, $url, $shouldBeCached, ?DatePeriod $validityPeriod)
|
||||||
{
|
{
|
||||||
// TODO: check write access to the cache directory
|
// TODO: check write access to the cache directory
|
||||||
$this->cacheDir = $cacheDir;
|
$this->cacheDir = $cacheDir;
|
||||||
$this->filename = $this->cacheDir . '/' . sha1($url) . '.cache';
|
$this->filename = $this->cacheDir . '/' . sha1($url) . '.cache';
|
||||||
$this->shouldBeCached = $shouldBeCached;
|
$this->shouldBeCached = $shouldBeCached;
|
||||||
|
$this->validityPeriod = $validityPeriod;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -41,10 +50,20 @@ class CachedPage
|
||||||
if (!$this->shouldBeCached) {
|
if (!$this->shouldBeCached) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (is_file($this->filename)) {
|
if (!is_file($this->filename)) {
|
||||||
return file_get_contents($this->filename);
|
return null;
|
||||||
}
|
}
|
||||||
return null;
|
if ($this->validityPeriod !== null) {
|
||||||
|
$cacheDate = \DateTime::createFromFormat('U', (string) filemtime($this->filename));
|
||||||
|
if (
|
||||||
|
$cacheDate < $this->validityPeriod->getStartDate()
|
||||||
|
|| $cacheDate > $this->validityPeriod->getEndDate()
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return file_get_contents($this->filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -1,7 +1,11 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Shaarli\Feed;
|
namespace Shaarli\Feed;
|
||||||
|
|
||||||
use DateTime;
|
use DateTime;
|
||||||
|
use Shaarli\Bookmark\Bookmark;
|
||||||
|
use Shaarli\Bookmark\BookmarkServiceInterface;
|
||||||
|
use Shaarli\Formatter\BookmarkFormatter;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FeedBuilder class.
|
* FeedBuilder class.
|
||||||
|
@ -26,37 +30,30 @@ class FeedBuilder
|
||||||
public static $DEFAULT_LANGUAGE = 'en-en';
|
public static $DEFAULT_LANGUAGE = 'en-en';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var int Number of links to display in a feed by default.
|
* @var int Number of bookmarks to display in a feed by default.
|
||||||
*/
|
*/
|
||||||
public static $DEFAULT_NB_LINKS = 50;
|
public static $DEFAULT_NB_LINKS = 50;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var \Shaarli\Bookmark\LinkDB instance.
|
* @var BookmarkServiceInterface instance.
|
||||||
*/
|
*/
|
||||||
protected $linkDB;
|
protected $linkDB;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var string RSS or ATOM feed.
|
* @var BookmarkFormatter instance.
|
||||||
*/
|
*/
|
||||||
protected $feedType;
|
protected $formatter;
|
||||||
|
|
||||||
/**
|
/** @var mixed[] $_SERVER */
|
||||||
* @var array $_SERVER
|
|
||||||
*/
|
|
||||||
protected $serverInfo;
|
protected $serverInfo;
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array $_GET
|
|
||||||
*/
|
|
||||||
protected $userInput;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var boolean True if the user is currently logged in, false otherwise.
|
* @var boolean True if the user is currently logged in, false otherwise.
|
||||||
*/
|
*/
|
||||||
protected $isLoggedIn;
|
protected $isLoggedIn;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var boolean Use permalinks instead of direct links if true.
|
* @var boolean Use permalinks instead of direct bookmarks if true.
|
||||||
*/
|
*/
|
||||||
protected $usePermalinks;
|
protected $usePermalinks;
|
||||||
|
|
||||||
|
@ -69,7 +66,6 @@ class FeedBuilder
|
||||||
* @var string server locale.
|
* @var string server locale.
|
||||||
*/
|
*/
|
||||||
protected $locale;
|
protected $locale;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var DateTime Latest item date.
|
* @var DateTime Latest item date.
|
||||||
*/
|
*/
|
||||||
|
@ -78,115 +74,61 @@ class FeedBuilder
|
||||||
/**
|
/**
|
||||||
* Feed constructor.
|
* Feed constructor.
|
||||||
*
|
*
|
||||||
* @param \Shaarli\Bookmark\LinkDB $linkDB LinkDB instance.
|
* @param BookmarkServiceInterface $linkDB LinkDB instance.
|
||||||
* @param string $feedType Type of feed.
|
* @param BookmarkFormatter $formatter instance.
|
||||||
* @param array $serverInfo $_SERVER.
|
* @param array $serverInfo $_SERVER.
|
||||||
* @param array $userInput $_GET.
|
* @param boolean $isLoggedIn True if the user is currently logged in, false otherwise.
|
||||||
* @param boolean $isLoggedIn True if the user is currently logged in,
|
|
||||||
* false otherwise.
|
|
||||||
*/
|
*/
|
||||||
public function __construct($linkDB, $feedType, $serverInfo, $userInput, $isLoggedIn)
|
public function __construct($linkDB, $formatter, $serverInfo, $isLoggedIn)
|
||||||
{
|
{
|
||||||
$this->linkDB = $linkDB;
|
$this->linkDB = $linkDB;
|
||||||
$this->feedType = $feedType;
|
$this->formatter = $formatter;
|
||||||
$this->serverInfo = $serverInfo;
|
$this->serverInfo = $serverInfo;
|
||||||
$this->userInput = $userInput;
|
|
||||||
$this->isLoggedIn = $isLoggedIn;
|
$this->isLoggedIn = $isLoggedIn;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build data for feed templates.
|
* Build data for feed templates.
|
||||||
*
|
*
|
||||||
|
* @param string $feedType Type of feed (RSS/ATOM).
|
||||||
|
* @param array $userInput $_GET.
|
||||||
|
*
|
||||||
* @return array Formatted data for feeds templates.
|
* @return array Formatted data for feeds templates.
|
||||||
*/
|
*/
|
||||||
public function buildData()
|
public function buildData(string $feedType, ?array $userInput)
|
||||||
{
|
{
|
||||||
// Search for untagged links
|
// Search for untagged bookmarks
|
||||||
if (isset($this->userInput['searchtags']) && empty($this->userInput['searchtags'])) {
|
if (isset($this->userInput['searchtags']) && empty($userInput['searchtags'])) {
|
||||||
$this->userInput['searchtags'] = false;
|
$userInput['searchtags'] = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$limit = $this->getLimit($userInput);
|
||||||
|
|
||||||
// Optionally filter the results:
|
// Optionally filter the results:
|
||||||
$linksToDisplay = $this->linkDB->filterSearch($this->userInput);
|
$searchResult = $this->linkDB->search($userInput ?? [], null, false, false, true, ['limit' => $limit]);
|
||||||
|
|
||||||
$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));
|
$pageaddr = escape(index_url($this->serverInfo));
|
||||||
$linkDisplayed = array();
|
$this->formatter->addContextData('index_url', $pageaddr);
|
||||||
for ($i = 0; $i < $nblinksToDisplay && $i < count($keys); $i++) {
|
$links = [];
|
||||||
$linkDisplayed[$keys[$i]] = $this->buildItem($linksToDisplay[$keys[$i]], $pageaddr);
|
foreach ($searchResult->getBookmarks() as $key => $bookmark) {
|
||||||
|
$links[$key] = $this->buildItem($feedType, $bookmark, $pageaddr);
|
||||||
}
|
}
|
||||||
|
|
||||||
$data['language'] = $this->getTypeLanguage();
|
$data['language'] = $this->getTypeLanguage($feedType);
|
||||||
$data['last_update'] = $this->getLatestDateFormatted();
|
$data['last_update'] = $this->getLatestDateFormatted($feedType);
|
||||||
$data['show_dates'] = !$this->hideDates || $this->isLoggedIn;
|
$data['show_dates'] = !$this->hideDates || $this->isLoggedIn;
|
||||||
// Remove leading slash from REQUEST_URI.
|
// Remove leading path from REQUEST_URI (already contained in $pageaddr).
|
||||||
$data['self_link'] = escape(server_url($this->serverInfo))
|
$requestUri = preg_replace('#(.*?/)(feed.*)#', '$2', escape($this->serverInfo['REQUEST_URI']));
|
||||||
. escape($this->serverInfo['REQUEST_URI']);
|
$data['self_link'] = $pageaddr . $requestUri;
|
||||||
$data['index_url'] = $pageaddr;
|
$data['index_url'] = $pageaddr;
|
||||||
$data['usepermalinks'] = $this->usePermalinks === true;
|
$data['usepermalinks'] = $this->usePermalinks === true;
|
||||||
$data['links'] = $linkDisplayed;
|
$data['links'] = $links;
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a feed item (one per shaare).
|
* Set this to true to use permalinks instead of direct bookmarks.
|
||||||
*
|
|
||||||
* @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 . '?' . $link['shorturl'];
|
|
||||||
// Prepend the root URL for notes
|
|
||||||
if (is_note($link['url'])) {
|
|
||||||
$link['url'] = $pageaddr . $link['url'];
|
|
||||||
}
|
|
||||||
if ($this->usePermalinks === true) {
|
|
||||||
$permalink = '<a href="' . $link['url'] . '" title="' . t('Direct link') . '">' . t('Direct link') . '</a>';
|
|
||||||
} else {
|
|
||||||
$permalink = '<a href="' . $link['guid'] . '" title="' . t('Permalink') . '">' . t('Permalink') . '</a>';
|
|
||||||
}
|
|
||||||
$link['description'] = format_description($link['description'], $pageaddr);
|
|
||||||
$link['description'] .= PHP_EOL . '<br>— ' . $permalink;
|
|
||||||
|
|
||||||
$pubDate = $link['created'];
|
|
||||||
$link['pub_iso_date'] = $this->getIsoDate($pubDate);
|
|
||||||
|
|
||||||
// atom:entry elements MUST contain exactly one atom:updated element.
|
|
||||||
if (!empty($link['updated'])) {
|
|
||||||
$upDate = $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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set this to true to use permalinks instead of direct links.
|
|
||||||
*
|
*
|
||||||
* @param boolean $usePermalinks true to force permalinks.
|
* @param boolean $usePermalinks true to force permalinks.
|
||||||
*/
|
*/
|
||||||
|
@ -215,22 +157,64 @@ class FeedBuilder
|
||||||
$this->locale = strtolower($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>— ' . $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:
|
* Get the language according to the feed type, based on the locale:
|
||||||
*
|
*
|
||||||
* - RSS format: en-us (default: 'en-en').
|
* - RSS format: en-us (default: 'en-en').
|
||||||
* - ATOM format: fr (default: 'en').
|
* - ATOM format: fr (default: 'en').
|
||||||
*
|
*
|
||||||
|
* @param string $feedType Type of feed (RSS/ATOM).
|
||||||
|
*
|
||||||
* @return string The language.
|
* @return string The language.
|
||||||
*/
|
*/
|
||||||
public function getTypeLanguage()
|
protected function getTypeLanguage(string $feedType)
|
||||||
{
|
{
|
||||||
// Use the locale do define the language, if available.
|
// Use the locale do define the language, if available.
|
||||||
if (!empty($this->locale) && preg_match('/^\w{2}[_\-]\w{2}/', $this->locale)) {
|
if (!empty($this->locale) && preg_match('/^\w{2}[_\-]\w{2}/', $this->locale)) {
|
||||||
$length = ($this->feedType === self::$FEED_RSS) ? 5 : 2;
|
$length = ($feedType === self::$FEED_RSS) ? 5 : 2;
|
||||||
return str_replace('_', '-', substr($this->locale, 0, $length));
|
return str_replace('_', '-', substr($this->locale, 0, $length));
|
||||||
}
|
}
|
||||||
return ($this->feedType === self::$FEED_RSS) ? 'en-en' : 'en';
|
return ($feedType === self::$FEED_RSS) ? 'en-en' : 'en';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -238,32 +222,35 @@ class FeedBuilder
|
||||||
*
|
*
|
||||||
* Return an empty string if invalid DateTime is passed.
|
* Return an empty string if invalid DateTime is passed.
|
||||||
*
|
*
|
||||||
|
* @param string $feedType Type of feed (RSS/ATOM).
|
||||||
|
*
|
||||||
* @return string Formatted date.
|
* @return string Formatted date.
|
||||||
*/
|
*/
|
||||||
protected function getLatestDateFormatted()
|
protected function getLatestDateFormatted(string $feedType)
|
||||||
{
|
{
|
||||||
if (empty($this->latestDate) || !$this->latestDate instanceof DateTime) {
|
if (empty($this->latestDate) || !$this->latestDate instanceof DateTime) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
$type = ($this->feedType == self::$FEED_RSS) ? DateTime::RSS : DateTime::ATOM;
|
$type = ($feedType == self::$FEED_RSS) ? DateTime::RSS : DateTime::ATOM;
|
||||||
return $this->latestDate->format($type);
|
return $this->latestDate->format($type);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get ISO date from DateTime according to feed 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 DateTime $date Date to format.
|
||||||
* @param string|bool $format Force format.
|
* @param string|bool $format Force format.
|
||||||
*
|
*
|
||||||
* @return string Formatted date.
|
* @return string Formatted date.
|
||||||
*/
|
*/
|
||||||
protected function getIsoDate(DateTime $date, $format = false)
|
protected function getIsoDate(string $feedType, DateTime $date, $format = false)
|
||||||
{
|
{
|
||||||
if ($format !== false) {
|
if ($format !== false) {
|
||||||
return $date->format($format);
|
return $date->format($format);
|
||||||
}
|
}
|
||||||
if ($this->feedType == self::$FEED_RSS) {
|
if ($feedType == self::$FEED_RSS) {
|
||||||
return $date->format(DateTime::RSS);
|
return $date->format(DateTime::RSS);
|
||||||
}
|
}
|
||||||
return $date->format(DateTime::ATOM);
|
return $date->format(DateTime::ATOM);
|
||||||
|
@ -273,23 +260,23 @@ class FeedBuilder
|
||||||
* Returns the number of link to display according to 'nb' user input parameter.
|
* 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' not set or invalid, default value: $DEFAULT_NB_LINKS.
|
||||||
* If 'nb' is set to 'all', display all filtered links (max parameter).
|
* If 'nb' is set to 'all', display all filtered bookmarks (max parameter).
|
||||||
*
|
*
|
||||||
* @param int $max maximum number of links to display.
|
* @param array $userInput $_GET.
|
||||||
*
|
*
|
||||||
* @return int number of links to display.
|
* @return int number of bookmarks to display.
|
||||||
*/
|
*/
|
||||||
public function getNbLinks($max)
|
protected function getLimit(?array $userInput)
|
||||||
{
|
{
|
||||||
if (empty($this->userInput['nb'])) {
|
if (empty($userInput['nb'])) {
|
||||||
return self::$DEFAULT_NB_LINKS;
|
return self::$DEFAULT_NB_LINKS;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->userInput['nb'] == 'all') {
|
if ($userInput['nb'] == 'all') {
|
||||||
return $max;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$intNb = intval($this->userInput['nb']);
|
$intNb = intval($userInput['nb']);
|
||||||
if (!is_int($intNb) || $intNb == 0) {
|
if (!is_int($intNb) || $intNb == 0) {
|
||||||
return self::$DEFAULT_NB_LINKS;
|
return self::$DEFAULT_NB_LINKS;
|
||||||
}
|
}
|
||||||
|
|
229
application/formatter/BookmarkDefaultFormatter.php
Normal file
229
application/formatter/BookmarkDefaultFormatter.php
Normal file
|
@ -0,0 +1,229 @@
|
||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
390
application/formatter/BookmarkFormatter.php
Normal file
390
application/formatter/BookmarkFormatter.php
Normal file
|
@ -0,0 +1,390 @@
|
||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
24
application/formatter/BookmarkMarkdownExtraFormatter.php
Normal file
24
application/formatter/BookmarkMarkdownExtraFormatter.php
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
<?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();
|
||||||
|
}
|
||||||
|
}
|
221
application/formatter/BookmarkMarkdownFormatter.php
Normal file
221
application/formatter/BookmarkMarkdownFormatter.php
Normal file
|
@ -0,0 +1,221 @@
|
||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
15
application/formatter/BookmarkRawFormatter.php
Normal file
15
application/formatter/BookmarkRawFormatter.php
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
<?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
|
||||||
|
{
|
||||||
|
}
|
51
application/formatter/FormatterFactory.php
Normal file
51
application/formatter/FormatterFactory.php
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
15
application/formatter/Parsedown/ShaarliParsedown.php
Normal file
15
application/formatter/Parsedown/ShaarliParsedown.php
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
<?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;
|
||||||
|
}
|
15
application/formatter/Parsedown/ShaarliParsedownExtra.php
Normal file
15
application/formatter/Parsedown/ShaarliParsedownExtra.php
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
<?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;
|
||||||
|
}
|
81
application/formatter/Parsedown/ShaarliParsedownTrait.php
Normal file
81
application/formatter/Parsedown/ShaarliParsedownTrait.php
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
27
application/front/ShaarliAdminMiddleware.php
Normal file
27
application/front/ShaarliAdminMiddleware.php
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
116
application/front/ShaarliMiddleware.php
Normal file
116
application/front/ShaarliMiddleware.php
Normal file
|
@ -0,0 +1,116 @@
|
||||||
|
<?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(), '/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
132
application/front/controller/admin/ConfigureController.php
Normal file
132
application/front/controller/admin/ConfigureController.php
Normal file
|
@ -0,0 +1,132 @@
|
||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
80
application/front/controller/admin/ExportController.php
Normal file
80
application/front/controller/admin/ExportController.php
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
<?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));
|
||||||
|
}
|
||||||
|
}
|
82
application/front/controller/admin/ImportController.php
Normal file
82
application/front/controller/admin/ImportController.php
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
33
application/front/controller/admin/LogoutController.php
Normal file
33
application/front/controller/admin/LogoutController.php
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
<?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, '/');
|
||||||
|
}
|
||||||
|
}
|
124
application/front/controller/admin/ManageTagController.php
Normal file
124
application/front/controller/admin/ManageTagController.php
Normal file
|
@ -0,0 +1,124 @@
|
||||||
|
<?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 = ' ';
|
||||||
|
$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');
|
||||||
|
}
|
||||||
|
}
|
29
application/front/controller/admin/MetadataController.php
Normal file
29
application/front/controller/admin/MetadataController.php
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Front\Controller\Admin;
|
||||||
|
|
||||||
|
use Slim\Http\Request;
|
||||||
|
use Slim\Http\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controller used to retrieve/update bookmark's metadata.
|
||||||
|
*/
|
||||||
|
class MetadataController extends ShaarliAdminController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* GET /admin/metadata/{url} - Attempt to retrieve the bookmark title from provided URL.
|
||||||
|
*/
|
||||||
|
public function ajaxRetrieveTitle(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$url = $request->getParam('url');
|
||||||
|
|
||||||
|
// Only try to extract metadata from URL with HTTP(s) scheme
|
||||||
|
if (!empty($url) && strpos(get_url_scheme($url) ?: '', 'http') !== false) {
|
||||||
|
return $response->withJson($this->container->metadataRetriever->retrieve($url));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response->withJson([]);
|
||||||
|
}
|
||||||
|
}
|
101
application/front/controller/admin/PasswordController.php
Normal file
101
application/front/controller/admin/PasswordController.php
Normal file
|
@ -0,0 +1,101 @@
|
||||||
|
<?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));
|
||||||
|
}
|
||||||
|
}
|
85
application/front/controller/admin/PluginsController.php
Normal file
85
application/front/controller/admin/PluginsController.php
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
101
application/front/controller/admin/ServerController.php
Normal file
101
application/front/controller/admin/ServerController.php
Normal file
|
@ -0,0 +1,101 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Front\Controller\Admin;
|
||||||
|
|
||||||
|
use Shaarli\Helper\ApplicationUtils;
|
||||||
|
use Shaarli\Helper\FileUtils;
|
||||||
|
use Slim\Http\Request;
|
||||||
|
use Slim\Http\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slim controller used to handle Server administration page, and actions.
|
||||||
|
*/
|
||||||
|
class ServerController extends ShaarliAdminController
|
||||||
|
{
|
||||||
|
/** @var string Cache type - main - by default pagecache/ and tmp/ */
|
||||||
|
protected const CACHE_MAIN = 'main';
|
||||||
|
|
||||||
|
/** @var string Cache type - thumbnails - by default cache/ */
|
||||||
|
protected const CACHE_THUMB = 'thumbnails';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /admin/server - Display page Server administration
|
||||||
|
*/
|
||||||
|
public function index(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$releaseUrl = ApplicationUtils::$GITHUB_URL . '/releases/';
|
||||||
|
if ($this->container->conf->get('updates.check_updates', true)) {
|
||||||
|
$latestVersion = 'v' . ApplicationUtils::getVersion(
|
||||||
|
ApplicationUtils::$GIT_RAW_URL . '/latest/' . ApplicationUtils::$VERSION_FILE
|
||||||
|
);
|
||||||
|
$releaseUrl .= 'tag/' . $latestVersion;
|
||||||
|
} else {
|
||||||
|
$latestVersion = t('Check disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentVersion = ApplicationUtils::getVersion('./shaarli_version.php');
|
||||||
|
$currentVersion = $currentVersion === 'dev' ? $currentVersion : 'v' . $currentVersion;
|
||||||
|
$phpEol = new \DateTimeImmutable(ApplicationUtils::getPhpEol(PHP_VERSION));
|
||||||
|
|
||||||
|
$permissions = array_merge(
|
||||||
|
ApplicationUtils::checkResourcePermissions($this->container->conf),
|
||||||
|
ApplicationUtils::checkDatastoreMutex()
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assignView('php_version', PHP_VERSION);
|
||||||
|
$this->assignView('php_eol', format_date($phpEol, false));
|
||||||
|
$this->assignView('php_has_reached_eol', $phpEol < new \DateTimeImmutable());
|
||||||
|
$this->assignView('php_extensions', ApplicationUtils::getPhpExtensionsRequirement());
|
||||||
|
$this->assignView('permissions', $permissions);
|
||||||
|
$this->assignView('release_url', $releaseUrl);
|
||||||
|
$this->assignView('latest_version', $latestVersion);
|
||||||
|
$this->assignView('current_version', $currentVersion);
|
||||||
|
$this->assignView('thumbnails_mode', $this->container->conf->get('thumbnails.mode'));
|
||||||
|
$this->assignView('index_url', index_url($this->container->environment));
|
||||||
|
$this->assignView('client_ip', client_ip_id($this->container->environment));
|
||||||
|
$this->assignView('trusted_proxies', $this->container->conf->get('security.trusted_proxies', []));
|
||||||
|
|
||||||
|
$this->assignView(
|
||||||
|
'pagetitle',
|
||||||
|
t('Server administration') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
|
||||||
|
);
|
||||||
|
|
||||||
|
return $response->write($this->render('server'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /admin/clear-cache?type={$type} - Action to trigger cache folder clearing (either main or thumbnails).
|
||||||
|
*/
|
||||||
|
public function clearCache(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$exclude = ['.htaccess'];
|
||||||
|
|
||||||
|
if ($request->getQueryParam('type') === static::CACHE_THUMB) {
|
||||||
|
$folders = [$this->container->conf->get('resource.thumbnails_cache')];
|
||||||
|
|
||||||
|
$this->saveWarningMessage(
|
||||||
|
t('Thumbnails cache has been cleared.') . ' ' .
|
||||||
|
'<a href="' . $this->container->basePath . '/admin/thumbnails">' .
|
||||||
|
t('Please synchronize them.') .
|
||||||
|
'</a>'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$folders = [
|
||||||
|
$this->container->conf->get('resource.page_cache'),
|
||||||
|
$this->container->conf->get('resource.raintpl_tmp'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->saveSuccessMessage(t('Shaarli\'s cache folder has been cleared!'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure that we don't delete root cache folder
|
||||||
|
$folders = array_map('realpath', array_values(array_filter(array_map('trim', $folders))));
|
||||||
|
foreach ($folders as $folder) {
|
||||||
|
FileUtils::clearFolder($folder, false, $exclude);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirect($response, '/admin/server');
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,48 @@
|
||||||
|
<?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']);
|
||||||
|
}
|
||||||
|
}
|
34
application/front/controller/admin/ShaareAddController.php
Normal file
34
application/front/controller/admin/ShaareAddController.php
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Front\Controller\Admin;
|
||||||
|
|
||||||
|
use Shaarli\Formatter\BookmarkMarkdownFormatter;
|
||||||
|
use Shaarli\Render\TemplatePage;
|
||||||
|
use Slim\Http\Request;
|
||||||
|
use Slim\Http\Response;
|
||||||
|
|
||||||
|
class ShaareAddController extends ShaarliAdminController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* GET /admin/add-shaare - Displays the form used to create a new bookmark from an URL
|
||||||
|
*/
|
||||||
|
public function addShaare(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$tags = $this->container->bookmarkService->bookmarksCountPerTag();
|
||||||
|
if ($this->container->conf->get('formatter') === 'markdown') {
|
||||||
|
$tags[BookmarkMarkdownFormatter::NO_MD_TAG] = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assignView(
|
||||||
|
'pagetitle',
|
||||||
|
t('Shaare a new link') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
|
||||||
|
);
|
||||||
|
$this->assignView('tags', $tags);
|
||||||
|
$this->assignView('default_private_links', $this->container->conf->get('privacy.default_private_links', false));
|
||||||
|
$this->assignView('async_metadata', $this->container->conf->get('general.enable_async_metadata', true));
|
||||||
|
|
||||||
|
return $response->write($this->render(TemplatePage::ADDLINK));
|
||||||
|
}
|
||||||
|
}
|
287
application/front/controller/admin/ShaareManageController.php
Normal file
287
application/front/controller/admin/ShaareManageController.php
Normal file
|
@ -0,0 +1,287 @@
|
||||||
|
<?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'], []);
|
||||||
|
}
|
||||||
|
}
|
274
application/front/controller/admin/ShaarePublishController.php
Normal file
274
application/front/controller/admin/ShaarePublishController.php
Normal file
|
@ -0,0 +1,274 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Front\Controller\Admin;
|
||||||
|
|
||||||
|
use Shaarli\Bookmark\Bookmark;
|
||||||
|
use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
|
||||||
|
use Shaarli\Formatter\BookmarkFormatter;
|
||||||
|
use Shaarli\Formatter\BookmarkMarkdownFormatter;
|
||||||
|
use Shaarli\Render\TemplatePage;
|
||||||
|
use Shaarli\Thumbnailer;
|
||||||
|
use Slim\Http\Request;
|
||||||
|
use Slim\Http\Response;
|
||||||
|
|
||||||
|
class ShaarePublishController extends ShaarliAdminController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var BookmarkFormatter[] Statically cached instances of formatters
|
||||||
|
*/
|
||||||
|
protected $formatters = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array Statically cached bookmark's tags counts
|
||||||
|
*/
|
||||||
|
protected $tags;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /admin/shaare - Displays the bookmark form for creation.
|
||||||
|
* Note that if the URL is found in existing bookmarks, then it will be in edit mode.
|
||||||
|
*/
|
||||||
|
public function displayCreateForm(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$url = cleanup_url($request->getParam('post'));
|
||||||
|
$link = $this->buildLinkDataFromUrl($request, $url);
|
||||||
|
|
||||||
|
return $this->displayForm($link, $link['linkIsNew'], $request, $response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /admin/shaare-batch - Displays multiple creation/edit forms from bulk add in add-link page.
|
||||||
|
*/
|
||||||
|
public function displayCreateBatchForms(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$urls = array_map('cleanup_url', explode(PHP_EOL, $request->getParam('urls')));
|
||||||
|
|
||||||
|
$links = [];
|
||||||
|
foreach ($urls as $url) {
|
||||||
|
if (empty($url)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$link = $this->buildLinkDataFromUrl($request, $url);
|
||||||
|
$data = $this->buildFormData($link, $link['linkIsNew'], $request);
|
||||||
|
$data['token'] = $this->container->sessionManager->generateToken();
|
||||||
|
$data['source'] = 'batch';
|
||||||
|
|
||||||
|
$this->executePageHooks('render_editlink', $data, TemplatePage::EDIT_LINK);
|
||||||
|
|
||||||
|
$links[] = $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assignView('links', $links);
|
||||||
|
$this->assignView('batch_mode', true);
|
||||||
|
$this->assignView('async_metadata', $this->container->conf->get('general.enable_async_metadata', true));
|
||||||
|
|
||||||
|
return $response->write($this->render(TemplatePage::EDIT_LINK_BATCH));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /admin/shaare/{id} - Displays the bookmark form in edition mode.
|
||||||
|
*/
|
||||||
|
public function displayEditForm(Request $request, Response $response, array $args): Response
|
||||||
|
{
|
||||||
|
$id = $args['id'] ?? '';
|
||||||
|
try {
|
||||||
|
if (false === ctype_digit($id)) {
|
||||||
|
throw new BookmarkNotFoundException();
|
||||||
|
}
|
||||||
|
$bookmark = $this->container->bookmarkService->get((int) $id); // Read database
|
||||||
|
} catch (BookmarkNotFoundException $e) {
|
||||||
|
$this->saveErrorMessage(sprintf(
|
||||||
|
t('Bookmark with identifier %s could not be found.'),
|
||||||
|
$id
|
||||||
|
));
|
||||||
|
|
||||||
|
return $this->redirect($response, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
$formatter = $this->getFormatter('raw');
|
||||||
|
$link = $formatter->format($bookmark);
|
||||||
|
|
||||||
|
return $this->displayForm($link, false, $request, $response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /admin/shaare
|
||||||
|
*/
|
||||||
|
public function save(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$this->checkToken($request);
|
||||||
|
|
||||||
|
// lf_id should only be present if the link exists.
|
||||||
|
$id = $request->getParam('lf_id') !== null ? intval(escape($request->getParam('lf_id'))) : null;
|
||||||
|
if (null !== $id && true === $this->container->bookmarkService->exists($id)) {
|
||||||
|
// Edit
|
||||||
|
$bookmark = $this->container->bookmarkService->get($id);
|
||||||
|
} else {
|
||||||
|
// New link
|
||||||
|
$bookmark = new Bookmark();
|
||||||
|
}
|
||||||
|
|
||||||
|
$bookmark->setTitle($request->getParam('lf_title'));
|
||||||
|
$bookmark->setDescription($request->getParam('lf_description'));
|
||||||
|
$bookmark->setUrl($request->getParam('lf_url'), $this->container->conf->get('security.allowed_protocols', []));
|
||||||
|
$bookmark->setPrivate(filter_var($request->getParam('lf_private'), FILTER_VALIDATE_BOOLEAN));
|
||||||
|
$bookmark->setTagsString(
|
||||||
|
$request->getParam('lf_tags'),
|
||||||
|
$this->container->conf->get('general.tags_separator', ' ')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
$this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE
|
||||||
|
&& true !== $this->container->conf->get('general.enable_async_metadata', true)
|
||||||
|
&& $bookmark->shouldUpdateThumbnail()
|
||||||
|
) {
|
||||||
|
$bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl()));
|
||||||
|
}
|
||||||
|
$this->container->bookmarkService->addOrSet($bookmark, false);
|
||||||
|
|
||||||
|
// To preserve backward compatibility with 3rd parties, plugins still use arrays
|
||||||
|
$formatter = $this->getFormatter('raw');
|
||||||
|
$data = $formatter->format($bookmark);
|
||||||
|
$this->executePageHooks('save_link', $data);
|
||||||
|
|
||||||
|
$bookmark->fromArray($data, $this->container->conf->get('general.tags_separator', ' '));
|
||||||
|
$this->container->bookmarkService->set($bookmark);
|
||||||
|
|
||||||
|
// If we are called from the bookmarklet, we must close the popup:
|
||||||
|
if ($request->getParam('source') === 'bookmarklet') {
|
||||||
|
return $response->write('<script>self.close();</script>');
|
||||||
|
} elseif ($request->getParam('source') === 'batch') {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($request->getParam('returnurl'))) {
|
||||||
|
$this->container->environment['HTTP_REFERER'] = $request->getParam('returnurl');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectFromReferer(
|
||||||
|
$request,
|
||||||
|
$response,
|
||||||
|
['/admin/add-shaare', '/admin/shaare'],
|
||||||
|
['addlink', 'post', 'edit_link'],
|
||||||
|
$bookmark->getShortUrl()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function used to display the shaare form whether it's a new or existing bookmark.
|
||||||
|
*
|
||||||
|
* @param array $link data used in template, either from parameters or from the data store
|
||||||
|
*/
|
||||||
|
protected function displayForm(array $link, bool $isNew, Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$data = $this->buildFormData($link, $isNew, $request);
|
||||||
|
|
||||||
|
$this->executePageHooks('render_editlink', $data, TemplatePage::EDIT_LINK);
|
||||||
|
|
||||||
|
foreach ($data as $key => $value) {
|
||||||
|
$this->assignView($key, $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
$editLabel = false === $isNew ? t('Edit') . ' ' : '';
|
||||||
|
$this->assignView(
|
||||||
|
'pagetitle',
|
||||||
|
$editLabel . t('Shaare') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
|
||||||
|
);
|
||||||
|
|
||||||
|
return $response->write($this->render(TemplatePage::EDIT_LINK));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function buildLinkDataFromUrl(Request $request, string $url): array
|
||||||
|
{
|
||||||
|
// Check if URL is not already in database (in this case, we will edit the existing link)
|
||||||
|
$bookmark = $this->container->bookmarkService->findByUrl($url);
|
||||||
|
if (null === $bookmark) {
|
||||||
|
// Get shaare data if it was provided in URL (e.g.: by the bookmarklet).
|
||||||
|
$title = $request->getParam('title');
|
||||||
|
$description = $request->getParam('description');
|
||||||
|
$tags = $request->getParam('tags');
|
||||||
|
if ($request->getParam('private') !== null) {
|
||||||
|
$private = filter_var($request->getParam('private'), FILTER_VALIDATE_BOOLEAN);
|
||||||
|
} else {
|
||||||
|
$private = $this->container->conf->get('privacy.default_private_links', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this is an HTTP(S) link, we try go get the page to extract
|
||||||
|
// the title (otherwise we will to straight to the edit form.)
|
||||||
|
if (
|
||||||
|
true !== $this->container->conf->get('general.enable_async_metadata', true)
|
||||||
|
&& empty($title)
|
||||||
|
&& strpos(get_url_scheme($url) ?: '', 'http') !== false
|
||||||
|
) {
|
||||||
|
$metadata = $this->container->metadataRetriever->retrieve($url);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($url)) {
|
||||||
|
$metadata['title'] = $this->container->conf->get('general.default_note_title', t('Note: '));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => $title ?? $metadata['title'] ?? '',
|
||||||
|
'url' => $url ?? '',
|
||||||
|
'description' => $description ?? $metadata['description'] ?? '',
|
||||||
|
'tags' => $tags ?? $metadata['tags'] ?? '',
|
||||||
|
'private' => $private,
|
||||||
|
'linkIsNew' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$formatter = $this->getFormatter('raw');
|
||||||
|
$link = $formatter->format($bookmark);
|
||||||
|
$link['linkIsNew'] = false;
|
||||||
|
|
||||||
|
return $link;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function buildFormData(array $link, bool $isNew, Request $request): array
|
||||||
|
{
|
||||||
|
$link['tags'] = $link['tags'] !== null && strlen($link['tags']) > 0
|
||||||
|
? $link['tags'] . $this->container->conf->get('general.tags_separator', ' ')
|
||||||
|
: $link['tags']
|
||||||
|
;
|
||||||
|
|
||||||
|
return escape([
|
||||||
|
'link' => $link,
|
||||||
|
'link_is_new' => $isNew,
|
||||||
|
'http_referer' => $this->container->environment['HTTP_REFERER'] ?? '',
|
||||||
|
'source' => $request->getParam('source') ?? '',
|
||||||
|
'tags' => $this->getTags(),
|
||||||
|
'default_private_links' => $this->container->conf->get('privacy.default_private_links', false),
|
||||||
|
'async_metadata' => $this->container->conf->get('general.enable_async_metadata', true),
|
||||||
|
'retrieve_description' => $this->container->conf->get('general.retrieve_description', false),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Memoize formatterFactory->getFormatter() calls.
|
||||||
|
*/
|
||||||
|
protected function getFormatter(string $type): BookmarkFormatter
|
||||||
|
{
|
||||||
|
if (!array_key_exists($type, $this->formatters) || $this->formatters[$type] === null) {
|
||||||
|
$this->formatters[$type] = $this->container->formatterFactory->getFormatter($type);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->formatters[$type];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Memoize bookmarkService->bookmarksCountPerTag() calls.
|
||||||
|
*/
|
||||||
|
protected function getTags(): array
|
||||||
|
{
|
||||||
|
if ($this->tags === null) {
|
||||||
|
$this->tags = $this->container->bookmarkService->bookmarksCountPerTag();
|
||||||
|
|
||||||
|
if ($this->container->conf->get('formatter') === 'markdown') {
|
||||||
|
$this->tags[BookmarkMarkdownFormatter::NO_MD_TAG] = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->tags;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,71 @@
|
||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
65
application/front/controller/admin/ThumbnailsController.php
Normal file
65
application/front/controller/admin/ThumbnailsController.php
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
<?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));
|
||||||
|
}
|
||||||
|
}
|
26
application/front/controller/admin/TokenController.php
Normal file
26
application/front/controller/admin/TokenController.php
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
<?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());
|
||||||
|
}
|
||||||
|
}
|
35
application/front/controller/admin/ToolsController.php
Normal file
35
application/front/controller/admin/ToolsController.php
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Front\Controller\Admin;
|
||||||
|
|
||||||
|
use Shaarli\Render\TemplatePage;
|
||||||
|
use Slim\Http\Request;
|
||||||
|
use Slim\Http\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class ToolsController
|
||||||
|
*
|
||||||
|
* Slim controller used to display the tools page.
|
||||||
|
*/
|
||||||
|
class ToolsController extends ShaarliAdminController
|
||||||
|
{
|
||||||
|
public function index(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$data = [
|
||||||
|
'pageabsaddr' => index_url($this->container->environment),
|
||||||
|
'sslenabled' => is_https($this->container->environment),
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->executePageHooks('render_tools', $data, TemplatePage::TOOLS);
|
||||||
|
|
||||||
|
foreach ($data as $key => $value) {
|
||||||
|
$this->assignView($key, $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assignView('pagetitle', t('Tools') . ' - ' . $this->container->conf->get('general.title', 'Shaarli'));
|
||||||
|
|
||||||
|
return $response->write($this->render(TemplatePage::TOOLS));
|
||||||
|
}
|
||||||
|
}
|
239
application/front/controller/visitor/BookmarkListController.php
Normal file
239
application/front/controller/visitor/BookmarkListController.php
Normal file
|
@ -0,0 +1,239 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Front\Controller\Visitor;
|
||||||
|
|
||||||
|
use Shaarli\Bookmark\Bookmark;
|
||||||
|
use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
|
||||||
|
use Shaarli\Legacy\LegacyController;
|
||||||
|
use Shaarli\Legacy\UnknowLegacyRouteException;
|
||||||
|
use Shaarli\Render\TemplatePage;
|
||||||
|
use Shaarli\Thumbnailer;
|
||||||
|
use Slim\Http\Request;
|
||||||
|
use Slim\Http\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class BookmarkListController
|
||||||
|
*
|
||||||
|
* Slim controller used to render the bookmark list, the home page of Shaarli.
|
||||||
|
* It also displays permalinks, and process legacy routes based on GET parameters.
|
||||||
|
*/
|
||||||
|
class BookmarkListController extends ShaarliVisitorController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* GET / - Displays the bookmark list, with optional filter parameters.
|
||||||
|
*/
|
||||||
|
public function index(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$legacyResponse = $this->processLegacyController($request, $response);
|
||||||
|
if (null !== $legacyResponse) {
|
||||||
|
return $legacyResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
$formatter = $this->container->formatterFactory->getFormatter();
|
||||||
|
$formatter->addContextData('base_path', $this->container->basePath);
|
||||||
|
$formatter->addContextData('index_url', index_url($this->container->environment));
|
||||||
|
|
||||||
|
$searchTags = normalize_spaces($request->getParam('searchtags') ?? '');
|
||||||
|
$searchTerm = escape(normalize_spaces($request->getParam('searchterm') ?? ''));
|
||||||
|
|
||||||
|
// Filter bookmarks according search parameters.
|
||||||
|
$visibility = $this->container->sessionManager->getSessionParameter('visibility');
|
||||||
|
$search = [
|
||||||
|
'searchtags' => $searchTags,
|
||||||
|
'searchterm' => $searchTerm,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Select articles according to paging.
|
||||||
|
$page = (int) ($request->getParam('page') ?? 1);
|
||||||
|
$page = $page < 1 ? 1 : $page;
|
||||||
|
$linksPerPage = $this->container->sessionManager->getSessionParameter('LINKS_PER_PAGE', 20) ?: 20;
|
||||||
|
|
||||||
|
$searchResult = $this->container->bookmarkService->search(
|
||||||
|
$search,
|
||||||
|
$visibility,
|
||||||
|
false,
|
||||||
|
!!$this->container->sessionManager->getSessionParameter('untaggedonly'),
|
||||||
|
false,
|
||||||
|
['offset' => $linksPerPage * ($page - 1), 'limit' => $linksPerPage]
|
||||||
|
) ?? [];
|
||||||
|
|
||||||
|
$save = false;
|
||||||
|
$links = [];
|
||||||
|
foreach ($searchResult->getBookmarks() as $key => $bookmark) {
|
||||||
|
$save = $this->updateThumbnail($bookmark, false) || $save;
|
||||||
|
$links[$key] = $formatter->format($bookmark);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($save) {
|
||||||
|
$this->container->bookmarkService->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute paging navigation
|
||||||
|
$searchtagsUrl = $searchTags === '' ? '' : '&searchtags=' . urlencode($searchTags);
|
||||||
|
$searchtermUrl = $searchTerm === '' ? '' : '&searchterm=' . urlencode($searchTerm);
|
||||||
|
$page = $searchResult->getPage();
|
||||||
|
|
||||||
|
$previousPageUrl = !$searchResult->isLastPage() ? '?page=' . ($page + 1) . $searchtermUrl . $searchtagsUrl : '';
|
||||||
|
$nextPageUrl = !$searchResult->isFirstPage() ? '?page=' . ($page - 1) . $searchtermUrl . $searchtagsUrl : '';
|
||||||
|
|
||||||
|
$tagsSeparator = $this->container->conf->get('general.tags_separator', ' ');
|
||||||
|
$searchTagsUrlEncoded = array_map('urlencode', tags_str2array($searchTags, $tagsSeparator));
|
||||||
|
$searchTags = !empty($searchTags) ? trim($searchTags, $tagsSeparator) . $tagsSeparator : '';
|
||||||
|
|
||||||
|
// Fill all template fields.
|
||||||
|
$data = array_merge(
|
||||||
|
$this->initializeTemplateVars(),
|
||||||
|
[
|
||||||
|
'previous_page_url' => $previousPageUrl,
|
||||||
|
'next_page_url' => $nextPageUrl,
|
||||||
|
'page_current' => $page,
|
||||||
|
'page_max' => $searchResult->getLastPage(),
|
||||||
|
'result_count' => $searchResult->getTotalCount(),
|
||||||
|
'search_term' => escape($searchTerm),
|
||||||
|
'search_tags' => escape($searchTags),
|
||||||
|
'search_tags_url' => $searchTagsUrlEncoded,
|
||||||
|
'visibility' => $visibility,
|
||||||
|
'links' => $links,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!empty($searchTerm) || !empty($searchTags)) {
|
||||||
|
$data['pagetitle'] = t('Search: ');
|
||||||
|
$data['pagetitle'] .= ! empty($searchTerm) ? $searchTerm . ' ' : '';
|
||||||
|
$bracketWrap = function ($tag) {
|
||||||
|
return '[' . $tag . ']';
|
||||||
|
};
|
||||||
|
$data['pagetitle'] .= ! empty($searchTags)
|
||||||
|
? implode(' ', array_map($bracketWrap, tags_str2array($searchTags, $tagsSeparator))) . ' '
|
||||||
|
: ''
|
||||||
|
;
|
||||||
|
$data['pagetitle'] .= '- ';
|
||||||
|
}
|
||||||
|
|
||||||
|
$data['pagetitle'] = ($data['pagetitle'] ?? '') . $this->container->conf->get('general.title', 'Shaarli');
|
||||||
|
|
||||||
|
$this->executePageHooks('render_linklist', $data, TemplatePage::LINKLIST);
|
||||||
|
$this->assignAllView($data);
|
||||||
|
|
||||||
|
return $response->write($this->render(TemplatePage::LINKLIST));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /shaare/{hash} - Display a single shaare
|
||||||
|
*/
|
||||||
|
public function permalink(Request $request, Response $response, array $args): Response
|
||||||
|
{
|
||||||
|
$privateKey = $request->getParam('key');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$bookmark = $this->container->bookmarkService->findByHash($args['hash'], $privateKey);
|
||||||
|
} catch (BookmarkNotFoundException $e) {
|
||||||
|
$this->assignView('error_message', $e->getMessage());
|
||||||
|
|
||||||
|
return $response->write($this->render(TemplatePage::ERROR_404));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->updateThumbnail($bookmark);
|
||||||
|
|
||||||
|
$formatter = $this->container->formatterFactory->getFormatter();
|
||||||
|
$formatter->addContextData('base_path', $this->container->basePath);
|
||||||
|
$formatter->addContextData('index_url', index_url($this->container->environment));
|
||||||
|
|
||||||
|
$data = array_merge(
|
||||||
|
$this->initializeTemplateVars(),
|
||||||
|
[
|
||||||
|
'pagetitle' => $bookmark->getTitle() . ' - ' . $this->container->conf->get('general.title', 'Shaarli'),
|
||||||
|
'links' => [$formatter->format($bookmark)],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->executePageHooks('render_linklist', $data, TemplatePage::LINKLIST);
|
||||||
|
$this->assignAllView($data);
|
||||||
|
|
||||||
|
return $response->write($this->render(TemplatePage::LINKLIST));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the thumbnail of a single bookmark if necessary.
|
||||||
|
*/
|
||||||
|
protected function updateThumbnail(Bookmark $bookmark, bool $writeDatastore = true): bool
|
||||||
|
{
|
||||||
|
if (false === $this->container->loginManager->isLoggedIn()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If thumbnail should be updated, we reset it to null
|
||||||
|
if ($bookmark->shouldUpdateThumbnail()) {
|
||||||
|
$bookmark->setThumbnail(null);
|
||||||
|
|
||||||
|
// Requires an update, not async retrieval, thumbnails enabled
|
||||||
|
if (
|
||||||
|
$bookmark->shouldUpdateThumbnail()
|
||||||
|
&& true !== $this->container->conf->get('general.enable_async_metadata', true)
|
||||||
|
&& $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE
|
||||||
|
) {
|
||||||
|
$bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl()));
|
||||||
|
$this->container->bookmarkService->set($bookmark, $writeDatastore);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string[] Default template variables without values.
|
||||||
|
*/
|
||||||
|
protected function initializeTemplateVars(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'previous_page_url' => '',
|
||||||
|
'next_page_url' => '',
|
||||||
|
'page_max' => '',
|
||||||
|
'search_tags' => '',
|
||||||
|
'result_count' => '',
|
||||||
|
'async_metadata' => $this->container->conf->get('general.enable_async_metadata', true)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process legacy routes if necessary. They used query parameters.
|
||||||
|
* If no legacy routes is passed, return null.
|
||||||
|
*/
|
||||||
|
protected function processLegacyController(Request $request, Response $response): ?Response
|
||||||
|
{
|
||||||
|
// Legacy smallhash filter
|
||||||
|
$queryString = $this->container->environment['QUERY_STRING'] ?? null;
|
||||||
|
if (null !== $queryString && 1 === preg_match('/^([a-zA-Z0-9-_@]{6})($|&|#)/', $queryString, $match)) {
|
||||||
|
return $this->redirect($response, '/shaare/' . $match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy controllers (mostly used for redirections)
|
||||||
|
if (null !== $request->getQueryParam('do')) {
|
||||||
|
$legacyController = new LegacyController($this->container);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return $legacyController->process($request, $response, $request->getQueryParam('do'));
|
||||||
|
} catch (UnknowLegacyRouteException $e) {
|
||||||
|
// We ignore legacy 404
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy GET admin routes
|
||||||
|
$legacyGetRoutes = array_intersect(
|
||||||
|
LegacyController::LEGACY_GET_ROUTES,
|
||||||
|
array_keys($request->getQueryParams() ?? [])
|
||||||
|
);
|
||||||
|
if (1 === count($legacyGetRoutes)) {
|
||||||
|
$legacyController = new LegacyController($this->container);
|
||||||
|
|
||||||
|
return $legacyController->process($request, $response, $legacyGetRoutes[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
206
application/front/controller/visitor/DailyController.php
Normal file
206
application/front/controller/visitor/DailyController.php
Normal file
|
@ -0,0 +1,206 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Front\Controller\Visitor;
|
||||||
|
|
||||||
|
use DateTime;
|
||||||
|
use Shaarli\Bookmark\Bookmark;
|
||||||
|
use Shaarli\Helper\DailyPageHelper;
|
||||||
|
use Shaarli\Render\TemplatePage;
|
||||||
|
use Slim\Http\Request;
|
||||||
|
use Slim\Http\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class DailyController
|
||||||
|
*
|
||||||
|
* Slim controller used to render the daily page.
|
||||||
|
*/
|
||||||
|
class DailyController extends ShaarliVisitorController
|
||||||
|
{
|
||||||
|
public static $DAILY_RSS_NB_DAYS = 8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controller displaying all bookmarks published in a single day.
|
||||||
|
* It take a `day` date query parameter (format YYYYMMDD).
|
||||||
|
*/
|
||||||
|
public function index(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$type = DailyPageHelper::extractRequestedType($request);
|
||||||
|
$format = DailyPageHelper::getFormatByType($type);
|
||||||
|
$latestBookmark = $this->container->bookmarkService->getLatest();
|
||||||
|
$dateTime = DailyPageHelper::extractRequestedDateTime($type, $request->getQueryParam($type), $latestBookmark);
|
||||||
|
$start = DailyPageHelper::getStartDateTimeByType($type, $dateTime);
|
||||||
|
$end = DailyPageHelper::getEndDateTimeByType($type, $dateTime);
|
||||||
|
$dailyDesc = DailyPageHelper::getDescriptionByType($type, $dateTime);
|
||||||
|
|
||||||
|
$linksToDisplay = $this->container->bookmarkService->findByDate(
|
||||||
|
$start,
|
||||||
|
$end,
|
||||||
|
$previousDay,
|
||||||
|
$nextDay
|
||||||
|
);
|
||||||
|
|
||||||
|
$formatter = $this->container->formatterFactory->getFormatter();
|
||||||
|
$formatter->addContextData('base_path', $this->container->basePath);
|
||||||
|
// We pre-format some fields for proper output.
|
||||||
|
foreach ($linksToDisplay as $key => $bookmark) {
|
||||||
|
$linksToDisplay[$key] = $formatter->format($bookmark);
|
||||||
|
// This page is a bit specific, we need raw description to calculate the length
|
||||||
|
$linksToDisplay[$key]['formatedDescription'] = $linksToDisplay[$key]['description'];
|
||||||
|
$linksToDisplay[$key]['description'] = $bookmark->getDescription();
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'linksToDisplay' => $linksToDisplay,
|
||||||
|
'dayDate' => $start,
|
||||||
|
'day' => $start->getTimestamp(),
|
||||||
|
'previousday' => $previousDay ? $previousDay->format($format) : '',
|
||||||
|
'nextday' => $nextDay ? $nextDay->format($format) : '',
|
||||||
|
'dayDesc' => $dailyDesc,
|
||||||
|
'type' => $type,
|
||||||
|
'localizedType' => $this->translateType($type),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Hooks are called before column construction so that plugins don't have to deal with columns.
|
||||||
|
$this->executePageHooks('render_daily', $data, TemplatePage::DAILY);
|
||||||
|
|
||||||
|
$data['cols'] = $this->calculateColumns($data['linksToDisplay']);
|
||||||
|
|
||||||
|
$this->assignAllView($data);
|
||||||
|
|
||||||
|
$mainTitle = $this->container->conf->get('general.title', 'Shaarli');
|
||||||
|
$this->assignView(
|
||||||
|
'pagetitle',
|
||||||
|
$data['localizedType'] . ' - ' . $data['dayDesc'] . ' - ' . $mainTitle
|
||||||
|
);
|
||||||
|
|
||||||
|
return $response->write($this->render(TemplatePage::DAILY));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Daily RSS feed: 1 RSS entry per day giving all the bookmarks on that day.
|
||||||
|
* Gives the last 7 days (which have bookmarks).
|
||||||
|
* This RSS feed cannot be filtered and does not trigger plugins yet.
|
||||||
|
*/
|
||||||
|
public function rss(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$response = $response->withHeader('Content-Type', 'application/rss+xml; charset=utf-8');
|
||||||
|
$type = DailyPageHelper::extractRequestedType($request);
|
||||||
|
$cacheDuration = DailyPageHelper::getCacheDatePeriodByType($type);
|
||||||
|
|
||||||
|
$pageUrl = page_url($this->container->environment);
|
||||||
|
$cache = $this->container->pageCacheManager->getCachePage($pageUrl, $cacheDuration);
|
||||||
|
|
||||||
|
$cached = $cache->cachedVersion();
|
||||||
|
if (!empty($cached)) {
|
||||||
|
return $response->write($cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
$days = [];
|
||||||
|
$format = DailyPageHelper::getFormatByType($type);
|
||||||
|
$length = DailyPageHelper::getRssLengthByType($type);
|
||||||
|
foreach ($this->container->bookmarkService->search()->getBookmarks() as $bookmark) {
|
||||||
|
$day = $bookmark->getCreated()->format($format);
|
||||||
|
|
||||||
|
// Stop iterating after DAILY_RSS_NB_DAYS entries
|
||||||
|
if (count($days) === $length && !isset($days[$day])) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$days[$day][] = $bookmark;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the RSS feed.
|
||||||
|
$indexUrl = escape(index_url($this->container->environment));
|
||||||
|
|
||||||
|
$formatter = $this->container->formatterFactory->getFormatter();
|
||||||
|
$formatter->addContextData('index_url', $indexUrl);
|
||||||
|
|
||||||
|
$dataPerDay = [];
|
||||||
|
|
||||||
|
/** @var Bookmark[] $bookmarks */
|
||||||
|
foreach ($days as $day => $bookmarks) {
|
||||||
|
$dayDateTime = DailyPageHelper::extractRequestedDateTime($type, (string) $day);
|
||||||
|
$endDateTime = DailyPageHelper::getEndDateTimeByType($type, $dayDateTime);
|
||||||
|
|
||||||
|
// We only want the RSS entry to be published when the period is over.
|
||||||
|
if (new DateTime() < $endDateTime) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dataPerDay[$day] = [
|
||||||
|
'date' => $endDateTime,
|
||||||
|
'date_rss' => $endDateTime->format(DateTime::RSS),
|
||||||
|
'date_human' => DailyPageHelper::getDescriptionByType($type, $dayDateTime, false),
|
||||||
|
'absolute_url' => $indexUrl . 'daily?' . $type . '=' . $day,
|
||||||
|
'links' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($bookmarks as $key => $bookmark) {
|
||||||
|
$dataPerDay[$day]['links'][$key] = $formatter->format($bookmark);
|
||||||
|
|
||||||
|
// Make permalink URL absolute
|
||||||
|
if ($bookmark->isNote()) {
|
||||||
|
$dataPerDay[$day]['links'][$key]['url'] = rtrim($indexUrl, '/') . $bookmark->getUrl();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assignAllView([
|
||||||
|
'title' => $this->container->conf->get('general.title', 'Shaarli'),
|
||||||
|
'index_url' => $indexUrl,
|
||||||
|
'page_url' => $pageUrl,
|
||||||
|
'hide_timestamps' => $this->container->conf->get('privacy.hide_timestamps', false),
|
||||||
|
'days' => $dataPerDay,
|
||||||
|
'type' => $type,
|
||||||
|
'localizedType' => $this->translateType($type),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rssContent = $this->render(TemplatePage::DAILY_RSS);
|
||||||
|
|
||||||
|
$cache->cache($rssContent);
|
||||||
|
|
||||||
|
return $response->write($rssContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We need to spread the articles on 3 columns.
|
||||||
|
* did not want to use a JavaScript lib like http://masonry.desandro.com/
|
||||||
|
* so I manually spread entries with a simple method: I roughly evaluate the
|
||||||
|
* height of a div according to title and description length.
|
||||||
|
*/
|
||||||
|
protected function calculateColumns(array $links): array
|
||||||
|
{
|
||||||
|
// Entries to display, for each column.
|
||||||
|
$columns = [[], [], []];
|
||||||
|
// Rough estimate of columns fill.
|
||||||
|
$fill = [0, 0, 0];
|
||||||
|
foreach ($links as $link) {
|
||||||
|
// Roughly estimate length of entry (by counting characters)
|
||||||
|
// Title: 30 chars = 1 line. 1 line is 30 pixels height.
|
||||||
|
// Description: 836 characters gives roughly 342 pixel height.
|
||||||
|
// This is not perfect, but it's usually OK.
|
||||||
|
$length = strlen($link['title'] ?? '') + (342 * strlen($link['description'] ?? '')) / 836;
|
||||||
|
if (! empty($link['thumbnail'])) {
|
||||||
|
$length += 100; // 1 thumbnails roughly takes 100 pixels height.
|
||||||
|
}
|
||||||
|
// Then put in column which is the less filled:
|
||||||
|
$smallest = min($fill); // find smallest value in array.
|
||||||
|
$index = array_search($smallest, $fill); // find index of this smallest value.
|
||||||
|
array_push($columns[$index], $link); // Put entry in this column.
|
||||||
|
$fill[$index] += $length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $columns;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function translateType($type): string
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
t('day') => t('Daily'),
|
||||||
|
t('week') => t('Weekly'),
|
||||||
|
t('month') => t('Monthly'),
|
||||||
|
][t($type)] ?? t('Daily');
|
||||||
|
}
|
||||||
|
}
|
47
application/front/controller/visitor/ErrorController.php
Normal file
47
application/front/controller/visitor/ErrorController.php
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Front\Controller\Visitor;
|
||||||
|
|
||||||
|
use Shaarli\Front\Exception\ShaarliFrontException;
|
||||||
|
use Slim\Http\Request;
|
||||||
|
use Slim\Http\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controller used to render the error page, with a provided exception.
|
||||||
|
* It is actually used as a Slim error handler.
|
||||||
|
*/
|
||||||
|
class ErrorController extends ShaarliVisitorController
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request, Response $response, \Throwable $throwable): Response
|
||||||
|
{
|
||||||
|
// Unknown error encountered
|
||||||
|
$this->container->pageBuilder->reset();
|
||||||
|
|
||||||
|
if ($throwable instanceof ShaarliFrontException) {
|
||||||
|
// Functional error
|
||||||
|
$this->assignView('message', nl2br($throwable->getMessage()));
|
||||||
|
|
||||||
|
$response = $response->withStatus($throwable->getCode());
|
||||||
|
} else {
|
||||||
|
// Internal error (any other Throwable)
|
||||||
|
if ($this->container->conf->get('dev.debug', false) || $this->container->loginManager->isLoggedIn()) {
|
||||||
|
$this->assignView('message', t('Error: ') . $throwable->getMessage());
|
||||||
|
$this->assignView(
|
||||||
|
'text',
|
||||||
|
'<a href="https://github.com/shaarli/Shaarli/issues/new">'
|
||||||
|
. t('Please report it on Github.')
|
||||||
|
. '</a>'
|
||||||
|
);
|
||||||
|
$this->assignView('stacktrace', exception2text($throwable));
|
||||||
|
} else {
|
||||||
|
$this->assignView('message', t('An unexpected error occurred.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $response->withStatus(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response->write($this->render('error'));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Front\Controller\Visitor;
|
||||||
|
|
||||||
|
use Slim\Http\Request;
|
||||||
|
use Slim\Http\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controller used to render the 404 error page.
|
||||||
|
*/
|
||||||
|
class ErrorNotFoundController extends ShaarliVisitorController
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
// Request from the API
|
||||||
|
if (false !== strpos($request->getRequestTarget(), '/api/v1')) {
|
||||||
|
return $response->withStatus(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is required because the middleware is ignored if the route is not found.
|
||||||
|
$this->container->basePath = rtrim($request->getUri()->getBasePath(), '/');
|
||||||
|
|
||||||
|
$this->assignView('error_message', t('Requested page could not be found.'));
|
||||||
|
|
||||||
|
return $response->withStatus(404)->write($this->render('404'));
|
||||||
|
}
|
||||||
|
}
|
58
application/front/controller/visitor/FeedController.php
Normal file
58
application/front/controller/visitor/FeedController.php
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Front\Controller\Visitor;
|
||||||
|
|
||||||
|
use Shaarli\Feed\FeedBuilder;
|
||||||
|
use Slim\Http\Request;
|
||||||
|
use Slim\Http\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class FeedController
|
||||||
|
*
|
||||||
|
* Slim controller handling ATOM and RSS feed.
|
||||||
|
*/
|
||||||
|
class FeedController extends ShaarliVisitorController
|
||||||
|
{
|
||||||
|
public function atom(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
return $this->processRequest(FeedBuilder::$FEED_ATOM, $request, $response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rss(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
return $this->processRequest(FeedBuilder::$FEED_RSS, $request, $response);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function processRequest(string $feedType, Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$response = $response->withHeader('Content-Type', 'application/' . $feedType . '+xml; charset=utf-8');
|
||||||
|
|
||||||
|
$pageUrl = page_url($this->container->environment);
|
||||||
|
$cache = $this->container->pageCacheManager->getCachePage($pageUrl);
|
||||||
|
|
||||||
|
$cached = $cache->cachedVersion();
|
||||||
|
if (!empty($cached)) {
|
||||||
|
return $response->write($cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate data.
|
||||||
|
$this->container->feedBuilder->setLocale(strtolower(setlocale(LC_COLLATE, 0)));
|
||||||
|
$this->container->feedBuilder->setHideDates($this->container->conf->get('privacy.hide_timestamps', false));
|
||||||
|
$this->container->feedBuilder->setUsePermalinks(
|
||||||
|
null !== $request->getParam('permalinks') || !$this->container->conf->get('feed.rss_permalinks')
|
||||||
|
);
|
||||||
|
|
||||||
|
$data = $this->container->feedBuilder->buildData($feedType, $request->getParams());
|
||||||
|
|
||||||
|
$this->executePageHooks('render_feed', $data, 'feed.' . $feedType);
|
||||||
|
$this->assignAllView($data);
|
||||||
|
|
||||||
|
$content = $this->render('feed.' . $feedType);
|
||||||
|
|
||||||
|
$cache->cache($content);
|
||||||
|
|
||||||
|
return $response->write($content);
|
||||||
|
}
|
||||||
|
}
|
183
application/front/controller/visitor/InstallController.php
Normal file
183
application/front/controller/visitor/InstallController.php
Normal file
|
@ -0,0 +1,183 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Front\Controller\Visitor;
|
||||||
|
|
||||||
|
use Shaarli\Container\ShaarliContainer;
|
||||||
|
use Shaarli\Front\Exception\AlreadyInstalledException;
|
||||||
|
use Shaarli\Front\Exception\ResourcePermissionException;
|
||||||
|
use Shaarli\Helper\ApplicationUtils;
|
||||||
|
use Shaarli\Languages;
|
||||||
|
use Shaarli\Security\SessionManager;
|
||||||
|
use Slim\Http\Request;
|
||||||
|
use Slim\Http\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slim controller used to render install page, and create initial configuration file.
|
||||||
|
*/
|
||||||
|
class InstallController extends ShaarliVisitorController
|
||||||
|
{
|
||||||
|
public const SESSION_TEST_KEY = 'session_tested';
|
||||||
|
public const SESSION_TEST_VALUE = 'Working';
|
||||||
|
|
||||||
|
public function __construct(ShaarliContainer $container)
|
||||||
|
{
|
||||||
|
parent::__construct($container);
|
||||||
|
|
||||||
|
if (is_file($this->container->conf->getConfigFileExt())) {
|
||||||
|
throw new AlreadyInstalledException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display the install template page.
|
||||||
|
* Also test file permissions and sessions beforehand.
|
||||||
|
*/
|
||||||
|
public function index(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
// Before installation, we'll make sure that permissions are set properly, and sessions are working.
|
||||||
|
$this->checkPermissions();
|
||||||
|
|
||||||
|
if (
|
||||||
|
static::SESSION_TEST_VALUE
|
||||||
|
!== $this->container->sessionManager->getSessionParameter(static::SESSION_TEST_KEY)
|
||||||
|
) {
|
||||||
|
$this->container->sessionManager->setSessionParameter(static::SESSION_TEST_KEY, static::SESSION_TEST_VALUE);
|
||||||
|
|
||||||
|
return $this->redirect($response, '/install/session-test');
|
||||||
|
}
|
||||||
|
|
||||||
|
[$continents, $cities] = generateTimeZoneData(timezone_identifiers_list(), date_default_timezone_get());
|
||||||
|
|
||||||
|
$this->assignView('continents', $continents);
|
||||||
|
$this->assignView('cities', $cities);
|
||||||
|
$this->assignView('languages', Languages::getAvailableLanguages());
|
||||||
|
|
||||||
|
$phpEol = new \DateTimeImmutable(ApplicationUtils::getPhpEol(PHP_VERSION));
|
||||||
|
|
||||||
|
$permissions = array_merge(
|
||||||
|
ApplicationUtils::checkResourcePermissions($this->container->conf),
|
||||||
|
ApplicationUtils::checkDatastoreMutex()
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assignView('php_version', PHP_VERSION);
|
||||||
|
$this->assignView('php_eol', format_date($phpEol, false));
|
||||||
|
$this->assignView('php_has_reached_eol', $phpEol < new \DateTimeImmutable());
|
||||||
|
$this->assignView('php_extensions', ApplicationUtils::getPhpExtensionsRequirement());
|
||||||
|
$this->assignView('permissions', $permissions);
|
||||||
|
|
||||||
|
$this->assignView('pagetitle', t('Install Shaarli'));
|
||||||
|
|
||||||
|
return $response->write($this->render('install'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route checking that the session parameter has been properly saved between two distinct requests.
|
||||||
|
* If the session parameter is preserved, redirect to install template page, otherwise displays error.
|
||||||
|
*/
|
||||||
|
public function sessionTest(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
// This part makes sure sessions works correctly.
|
||||||
|
// (Because on some hosts, session.save_path may not be set correctly,
|
||||||
|
// or we may not have write access to it.)
|
||||||
|
if (
|
||||||
|
static::SESSION_TEST_VALUE
|
||||||
|
!== $this->container->sessionManager->getSessionParameter(static::SESSION_TEST_KEY)
|
||||||
|
) {
|
||||||
|
// Step 2: Check if data in session is correct.
|
||||||
|
$msg = t(
|
||||||
|
'<pre>Sessions do not seem to work correctly on your server.<br>' .
|
||||||
|
'Make sure the variable "session.save_path" is set correctly in your PHP config, ' .
|
||||||
|
'and that you have write access to it.<br>' .
|
||||||
|
'It currently points to %s.<br>' .
|
||||||
|
'On some browsers, accessing your server via a hostname like \'localhost\' ' .
|
||||||
|
'or any custom hostname without a dot causes cookie storage to fail. ' .
|
||||||
|
'We recommend accessing your server via it\'s IP address or Fully Qualified Domain Name.<br>'
|
||||||
|
);
|
||||||
|
$msg = sprintf($msg, $this->container->sessionManager->getSavePath());
|
||||||
|
|
||||||
|
$this->assignView('message', $msg);
|
||||||
|
|
||||||
|
return $response->write($this->render('error'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirect($response, '/install');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save installation form and initialize config file and datastore if necessary.
|
||||||
|
*/
|
||||||
|
public function save(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$timezone = 'UTC';
|
||||||
|
if (
|
||||||
|
!empty($request->getParam('continent'))
|
||||||
|
&& !empty($request->getParam('city'))
|
||||||
|
&& isTimeZoneValid($request->getParam('continent'), $request->getParam('city'))
|
||||||
|
) {
|
||||||
|
$timezone = $request->getParam('continent') . '/' . $request->getParam('city');
|
||||||
|
}
|
||||||
|
$this->container->conf->set('general.timezone', $timezone);
|
||||||
|
|
||||||
|
$login = $request->getParam('setlogin');
|
||||||
|
$this->container->conf->set('credentials.login', $login);
|
||||||
|
$salt = sha1(uniqid('', true) . '_' . mt_rand());
|
||||||
|
$this->container->conf->set('credentials.salt', $salt);
|
||||||
|
$this->container->conf->set('credentials.hash', sha1($request->getParam('setpassword') . $login . $salt));
|
||||||
|
|
||||||
|
if (!empty($request->getParam('title'))) {
|
||||||
|
$this->container->conf->set('general.title', escape($request->getParam('title')));
|
||||||
|
} else {
|
||||||
|
$this->container->conf->set(
|
||||||
|
'general.title',
|
||||||
|
t('Shared Bookmarks')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->container->conf->set('translation.language', escape($request->getParam('language')));
|
||||||
|
$this->container->conf->set('updates.check_updates', !empty($request->getParam('updateCheck')));
|
||||||
|
$this->container->conf->set('api.enabled', !empty($request->getParam('enableApi')));
|
||||||
|
$this->container->conf->set(
|
||||||
|
'api.secret',
|
||||||
|
generate_api_secret(
|
||||||
|
$this->container->conf->get('credentials.login'),
|
||||||
|
$this->container->conf->get('credentials.salt')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
$this->container->conf->set('general.header_link', $this->container->basePath . '/');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Everything is ok, let's create config file.
|
||||||
|
$this->container->conf->write($this->container->loginManager->isLoggedIn());
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->assignView('message', t('Error while writing config file after configuration update.'));
|
||||||
|
$this->assignView('stacktrace', $e->getMessage() . PHP_EOL . $e->getTraceAsString());
|
||||||
|
|
||||||
|
return $response->write($this->render('error'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->container->sessionManager->setSessionParameter(
|
||||||
|
SessionManager::KEY_SUCCESS_MESSAGES,
|
||||||
|
[t('Shaarli is now configured. Please login and start shaaring your bookmarks!')]
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->redirect($response, '/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function checkPermissions(): bool
|
||||||
|
{
|
||||||
|
// Ensure Shaarli has proper access to its resources
|
||||||
|
$errors = ApplicationUtils::checkResourcePermissions($this->container->conf, true);
|
||||||
|
if (empty($errors)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = t('Insufficient permissions:') . PHP_EOL;
|
||||||
|
foreach ($errors as $error) {
|
||||||
|
$message .= PHP_EOL . $error;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ResourcePermissionException($message);
|
||||||
|
}
|
||||||
|
}
|
155
application/front/controller/visitor/LoginController.php
Normal file
155
application/front/controller/visitor/LoginController.php
Normal file
|
@ -0,0 +1,155 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Front\Controller\Visitor;
|
||||||
|
|
||||||
|
use Shaarli\Front\Exception\CantLoginException;
|
||||||
|
use Shaarli\Front\Exception\LoginBannedException;
|
||||||
|
use Shaarli\Front\Exception\WrongTokenException;
|
||||||
|
use Shaarli\Render\TemplatePage;
|
||||||
|
use Shaarli\Security\CookieManager;
|
||||||
|
use Shaarli\Security\SessionManager;
|
||||||
|
use Slim\Http\Request;
|
||||||
|
use Slim\Http\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class LoginController
|
||||||
|
*
|
||||||
|
* Slim controller used to render the login page.
|
||||||
|
*
|
||||||
|
* The login page is not available if the user is banned
|
||||||
|
* or if open shaarli setting is enabled.
|
||||||
|
*/
|
||||||
|
class LoginController extends ShaarliVisitorController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* GET /login - Display the login page.
|
||||||
|
*/
|
||||||
|
public function index(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->checkLoginState();
|
||||||
|
} catch (CantLoginException $e) {
|
||||||
|
return $this->redirect($response, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->getParam('login') !== null) {
|
||||||
|
$this->assignView('username', escape($request->getParam('login')));
|
||||||
|
}
|
||||||
|
|
||||||
|
$returnUrl = $request->getParam('returnurl') ?? $this->container->environment['HTTP_REFERER'] ?? null;
|
||||||
|
|
||||||
|
$this
|
||||||
|
->assignView('returnurl', escape($returnUrl))
|
||||||
|
->assignView('remember_user_default', $this->container->conf->get('privacy.remember_user_default', true))
|
||||||
|
->assignView('pagetitle', t('Login') . ' - ' . $this->container->conf->get('general.title', 'Shaarli'))
|
||||||
|
;
|
||||||
|
|
||||||
|
return $response->write($this->render(TemplatePage::LOGIN));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /login - Process login
|
||||||
|
*/
|
||||||
|
public function login(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
if (!$this->container->sessionManager->checkToken($request->getParam('token'))) {
|
||||||
|
throw new WrongTokenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->checkLoginState();
|
||||||
|
} catch (CantLoginException $e) {
|
||||||
|
return $this->redirect($response, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!$this->container->loginManager->checkCredentials(
|
||||||
|
client_ip_id($this->container->environment),
|
||||||
|
$request->getParam('login'),
|
||||||
|
$request->getParam('password')
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
$this->container->loginManager->handleFailedLogin($this->container->environment);
|
||||||
|
|
||||||
|
$this->container->sessionManager->setSessionParameter(
|
||||||
|
SessionManager::KEY_ERROR_MESSAGES,
|
||||||
|
[t('Wrong login/password.')]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Call controller directly instead of unnecessary redirection
|
||||||
|
return $this->index($request, $response);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->container->loginManager->handleSuccessfulLogin($this->container->environment);
|
||||||
|
|
||||||
|
$cookiePath = $this->container->basePath . '/';
|
||||||
|
$expirationTime = $this->saveLongLastingSession($request, $cookiePath);
|
||||||
|
$this->renewUserSession($cookiePath, $expirationTime);
|
||||||
|
|
||||||
|
// Force referer from given return URL
|
||||||
|
$this->container->environment['HTTP_REFERER'] = $request->getParam('returnurl');
|
||||||
|
|
||||||
|
return $this->redirectFromReferer($request, $response, ['login', 'install']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make sure that the user is allowed to login and/or displaying the login page:
|
||||||
|
* - not already logged in
|
||||||
|
* - not open shaarli
|
||||||
|
* - not banned
|
||||||
|
*/
|
||||||
|
protected function checkLoginState(): bool
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
$this->container->loginManager->isLoggedIn()
|
||||||
|
|| $this->container->conf->get('security.open_shaarli', false)
|
||||||
|
) {
|
||||||
|
throw new CantLoginException();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (true !== $this->container->loginManager->canLogin($this->container->environment)) {
|
||||||
|
throw new LoginBannedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return int Session duration in seconds
|
||||||
|
*/
|
||||||
|
protected function saveLongLastingSession(Request $request, string $cookiePath): int
|
||||||
|
{
|
||||||
|
if (empty($request->getParam('longlastingsession'))) {
|
||||||
|
// Standard session expiration (=when browser closes)
|
||||||
|
$expirationTime = 0;
|
||||||
|
} else {
|
||||||
|
// Keep the session cookie even after the browser closes
|
||||||
|
$this->container->sessionManager->setStaySignedIn(true);
|
||||||
|
$expirationTime = $this->container->sessionManager->extendSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->container->cookieManager->setCookieParameter(
|
||||||
|
CookieManager::STAY_SIGNED_IN,
|
||||||
|
$this->container->loginManager->getStaySignedInToken(),
|
||||||
|
$expirationTime,
|
||||||
|
$cookiePath
|
||||||
|
);
|
||||||
|
|
||||||
|
return $expirationTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function renewUserSession(string $cookiePath, int $expirationTime): void
|
||||||
|
{
|
||||||
|
// Send cookie with the new expiration date to the browser
|
||||||
|
$this->container->sessionManager->destroy();
|
||||||
|
$this->container->sessionManager->cookieParameters(
|
||||||
|
$expirationTime,
|
||||||
|
$cookiePath,
|
||||||
|
$this->container->environment['SERVER_NAME']
|
||||||
|
);
|
||||||
|
$this->container->sessionManager->start();
|
||||||
|
$this->container->sessionManager->regenerateId(true);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Front\Controller\Visitor;
|
||||||
|
|
||||||
|
use Shaarli\Render\TemplatePage;
|
||||||
|
use Slim\Http\Request;
|
||||||
|
use Slim\Http\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class OpenSearchController
|
||||||
|
*
|
||||||
|
* Slim controller used to render open search template.
|
||||||
|
* This allows to add Shaarli as a search engine within the browser.
|
||||||
|
*/
|
||||||
|
class OpenSearchController extends ShaarliVisitorController
|
||||||
|
{
|
||||||
|
public function index(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$response = $response->withHeader('Content-Type', 'application/opensearchdescription+xml; charset=utf-8');
|
||||||
|
|
||||||
|
$this->assignView('serverurl', index_url($this->container->environment));
|
||||||
|
|
||||||
|
return $response->write($this->render(TemplatePage::OPEN_SEARCH));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,54 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Front\Controller\Visitor;
|
||||||
|
|
||||||
|
use Shaarli\Front\Exception\ThumbnailsDisabledException;
|
||||||
|
use Shaarli\Render\TemplatePage;
|
||||||
|
use Shaarli\Thumbnailer;
|
||||||
|
use Slim\Http\Request;
|
||||||
|
use Slim\Http\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class PicturesWallController
|
||||||
|
*
|
||||||
|
* Slim controller used to render the pictures wall page.
|
||||||
|
* If thumbnails mode is set to NONE, we just render the template without any image.
|
||||||
|
*/
|
||||||
|
class PictureWallController extends ShaarliVisitorController
|
||||||
|
{
|
||||||
|
public function index(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
if ($this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) === Thumbnailer::MODE_NONE) {
|
||||||
|
throw new ThumbnailsDisabledException();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assignView(
|
||||||
|
'pagetitle',
|
||||||
|
t('Picture wall') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Optionally filter the results:
|
||||||
|
$bookmarks = $this->container->bookmarkService->search($request->getQueryParams())->getBookmarks();
|
||||||
|
$links = [];
|
||||||
|
|
||||||
|
// Get only bookmarks which have a thumbnail.
|
||||||
|
// Note: we do not retrieve thumbnails here, the request is too heavy.
|
||||||
|
$formatter = $this->container->formatterFactory->getFormatter('raw');
|
||||||
|
foreach ($bookmarks as $key => $bookmark) {
|
||||||
|
if (!empty($bookmark->getThumbnail())) {
|
||||||
|
$links[] = $formatter->format($bookmark);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = ['linksToDisplay' => $links];
|
||||||
|
$this->executePageHooks('render_picwall', $data, TemplatePage::PICTURE_WALL);
|
||||||
|
|
||||||
|
foreach ($data as $key => $value) {
|
||||||
|
$this->assignView($key, $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response->write($this->render(TemplatePage::PICTURE_WALL));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,46 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Front\Controller\Visitor;
|
||||||
|
|
||||||
|
use Shaarli\Security\SessionManager;
|
||||||
|
use Slim\Http\Request;
|
||||||
|
use Slim\Http\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slim controller used to handle filters stored in the visitor session, links per page, etc.
|
||||||
|
*/
|
||||||
|
class PublicSessionFilterController extends ShaarliVisitorController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* GET /links-per-page: set the number of bookmarks to display per page in homepage
|
||||||
|
*/
|
||||||
|
public function linksPerPage(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$linksPerPage = $request->getParam('nb') ?? null;
|
||||||
|
if (null === $linksPerPage || false === is_numeric($linksPerPage)) {
|
||||||
|
$linksPerPage = $this->container->conf->get('general.links_per_page', 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->container->sessionManager->setSessionParameter(
|
||||||
|
SessionManager::KEY_LINKS_PER_PAGE,
|
||||||
|
abs(intval($linksPerPage))
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->redirectFromReferer($request, $response, ['linksperpage'], ['nb']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /untagged-only: allows to display only bookmarks without any tag
|
||||||
|
*/
|
||||||
|
public function untaggedOnly(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$this->container->sessionManager->setSessionParameter(
|
||||||
|
SessionManager::KEY_UNTAGGED_ONLY,
|
||||||
|
empty($this->container->sessionManager->getSessionParameter(SessionManager::KEY_UNTAGGED_ONLY))
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->redirectFromReferer($request, $response, ['untaggedonly', 'untagged-only']);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,186 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Front\Controller\Visitor;
|
||||||
|
|
||||||
|
use Shaarli\Bookmark\BookmarkFilter;
|
||||||
|
use Shaarli\Container\ShaarliContainer;
|
||||||
|
use Slim\Http\Request;
|
||||||
|
use Slim\Http\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class ShaarliVisitorController
|
||||||
|
*
|
||||||
|
* All controllers accessible by visitors (non logged in users) should extend this abstract class.
|
||||||
|
* Contains a few helper function for template rendering, plugins, etc.
|
||||||
|
*
|
||||||
|
* @package Shaarli\Front\Controller\Visitor
|
||||||
|
*/
|
||||||
|
abstract class ShaarliVisitorController
|
||||||
|
{
|
||||||
|
/** @var ShaarliContainer */
|
||||||
|
protected $container;
|
||||||
|
|
||||||
|
/** @param ShaarliContainer $container Slim container (extended for attribute completion). */
|
||||||
|
public function __construct(ShaarliContainer $container)
|
||||||
|
{
|
||||||
|
$this->container = $container;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assign variables to RainTPL template through the PageBuilder.
|
||||||
|
*
|
||||||
|
* @param mixed $value Value to assign to the template
|
||||||
|
*/
|
||||||
|
protected function assignView(string $name, $value): self
|
||||||
|
{
|
||||||
|
$this->container->pageBuilder->assign($name, $value);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assign variables to RainTPL template through the PageBuilder.
|
||||||
|
*
|
||||||
|
* @param mixed $data Values to assign to the template and their keys
|
||||||
|
*/
|
||||||
|
protected function assignAllView(array $data): self
|
||||||
|
{
|
||||||
|
foreach ($data as $key => $value) {
|
||||||
|
$this->assignView($key, $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function render(string $template): string
|
||||||
|
{
|
||||||
|
// Legacy key that used to be injected by PluginManager
|
||||||
|
$this->assignView('_PAGE_', $template);
|
||||||
|
$this->assignView('template', $template);
|
||||||
|
|
||||||
|
$this->assignView('linkcount', $this->container->bookmarkService->count(BookmarkFilter::$ALL));
|
||||||
|
$this->assignView('privateLinkcount', $this->container->bookmarkService->count(BookmarkFilter::$PRIVATE));
|
||||||
|
|
||||||
|
$this->executeDefaultHooks($template);
|
||||||
|
|
||||||
|
$this->assignView('plugin_errors', $this->container->pluginManager->getErrors());
|
||||||
|
|
||||||
|
return $this->container->pageBuilder->render($template, $this->container->basePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call plugin hooks for header, footer and includes, specifying which page will be rendered.
|
||||||
|
* Then assign generated data to RainTPL.
|
||||||
|
*/
|
||||||
|
protected function executeDefaultHooks(string $template): void
|
||||||
|
{
|
||||||
|
$common_hooks = [
|
||||||
|
'includes',
|
||||||
|
'header',
|
||||||
|
'footer',
|
||||||
|
];
|
||||||
|
|
||||||
|
$parameters = $this->buildPluginParameters($template);
|
||||||
|
|
||||||
|
foreach ($common_hooks as $name) {
|
||||||
|
$pluginData = [];
|
||||||
|
$this->container->pluginManager->executeHooks(
|
||||||
|
'render_' . $name,
|
||||||
|
$pluginData,
|
||||||
|
$parameters
|
||||||
|
);
|
||||||
|
$this->assignView('plugins_' . $name, $pluginData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function executePageHooks(string $hook, array &$data, string $template = null): void
|
||||||
|
{
|
||||||
|
$this->container->pluginManager->executeHooks(
|
||||||
|
$hook,
|
||||||
|
$data,
|
||||||
|
$this->buildPluginParameters($template)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function buildPluginParameters(?string $template): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'target' => $template,
|
||||||
|
'loggedin' => $this->container->loginManager->isLoggedIn(),
|
||||||
|
'basePath' => $this->container->basePath,
|
||||||
|
'rootPath' => preg_replace('#/index\.php$#', '', $this->container->basePath),
|
||||||
|
'bookmarkService' => $this->container->bookmarkService
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple helper which prepend the base path to redirect path.
|
||||||
|
*
|
||||||
|
* @param Response $response
|
||||||
|
* @param string $path Absolute path, e.g.: `/`, or `/admin/shaare/123` regardless of install directory
|
||||||
|
*
|
||||||
|
* @return Response updated
|
||||||
|
*/
|
||||||
|
protected function redirect(Response $response, string $path): Response
|
||||||
|
{
|
||||||
|
return $response->withRedirect($this->container->basePath . $path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a redirection to the previous page, based on the HTTP_REFERER.
|
||||||
|
* It fails back to the home page.
|
||||||
|
*
|
||||||
|
* @param array $loopTerms Terms to remove from path and query string to prevent direction loop.
|
||||||
|
* @param array $clearParams List of parameter to remove from the query string of the referrer.
|
||||||
|
*/
|
||||||
|
protected function redirectFromReferer(
|
||||||
|
Request $request,
|
||||||
|
Response $response,
|
||||||
|
array $loopTerms = [],
|
||||||
|
array $clearParams = [],
|
||||||
|
string $anchor = null
|
||||||
|
): Response {
|
||||||
|
$defaultPath = $this->container->basePath . '/';
|
||||||
|
$referer = $this->container->environment['HTTP_REFERER'] ?? null;
|
||||||
|
|
||||||
|
if (null !== $referer) {
|
||||||
|
$currentUrl = parse_url($referer);
|
||||||
|
// If the referer is not related to Shaarli instance, redirect to default
|
||||||
|
if (
|
||||||
|
isset($currentUrl['host'])
|
||||||
|
&& strpos(index_url($this->container->environment), $currentUrl['host']) === false
|
||||||
|
) {
|
||||||
|
return $response->withRedirect($defaultPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_str($currentUrl['query'] ?? '', $params);
|
||||||
|
$path = $currentUrl['path'] ?? $defaultPath;
|
||||||
|
} else {
|
||||||
|
$params = [];
|
||||||
|
$path = $defaultPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent redirection loop
|
||||||
|
if (isset($currentUrl)) {
|
||||||
|
foreach ($clearParams as $value) {
|
||||||
|
unset($params[$value]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$checkQuery = implode('', array_keys($params));
|
||||||
|
foreach ($loopTerms as $value) {
|
||||||
|
if (strpos($path . $checkQuery, $value) !== false) {
|
||||||
|
$params = [];
|
||||||
|
$path = $defaultPath;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$queryString = count($params) > 0 ? '?' . http_build_query($params) : '';
|
||||||
|
$anchor = $anchor ? '#' . $anchor : '';
|
||||||
|
|
||||||
|
return $response->withRedirect($path . $queryString . $anchor);
|
||||||
|
}
|
||||||
|
}
|
123
application/front/controller/visitor/TagCloudController.php
Normal file
123
application/front/controller/visitor/TagCloudController.php
Normal file
|
@ -0,0 +1,123 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Front\Controller\Visitor;
|
||||||
|
|
||||||
|
use Slim\Http\Request;
|
||||||
|
use Slim\Http\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class TagCloud
|
||||||
|
*
|
||||||
|
* Slim controller used to render the tag cloud and tag list pages.
|
||||||
|
*/
|
||||||
|
class TagCloudController extends ShaarliVisitorController
|
||||||
|
{
|
||||||
|
protected const TYPE_CLOUD = 'cloud';
|
||||||
|
protected const TYPE_LIST = 'list';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display the tag cloud through the template engine.
|
||||||
|
* This controller a few filters:
|
||||||
|
* - Visibility stored in the session for logged in users
|
||||||
|
* - `searchtags` query parameter: will return tags associated with filter in at least one bookmark
|
||||||
|
*/
|
||||||
|
public function cloud(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
return $this->processRequest(static::TYPE_CLOUD, $request, $response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display the tag list through the template engine.
|
||||||
|
* This controller a few filters:
|
||||||
|
* - Visibility stored in the session for logged in users
|
||||||
|
* - `searchtags` query parameter: will return tags associated with filter in at least one bookmark
|
||||||
|
* - `sort` query parameters:
|
||||||
|
* + `usage` (default): most used tags first
|
||||||
|
* + `alpha`: alphabetical order
|
||||||
|
*/
|
||||||
|
public function list(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
return $this->processRequest(static::TYPE_LIST, $request, $response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process the request for both tag cloud and tag list endpoints.
|
||||||
|
*/
|
||||||
|
protected function processRequest(string $type, Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$tagsSeparator = $this->container->conf->get('general.tags_separator', ' ');
|
||||||
|
if ($this->container->loginManager->isLoggedIn() === true) {
|
||||||
|
$visibility = $this->container->sessionManager->getSessionParameter('visibility');
|
||||||
|
}
|
||||||
|
|
||||||
|
$sort = $request->getQueryParam('sort');
|
||||||
|
$searchTags = $request->getQueryParam('searchtags');
|
||||||
|
$filteringTags = $searchTags !== null ? explode($tagsSeparator, $searchTags) : [];
|
||||||
|
|
||||||
|
$tags = $this->container->bookmarkService->bookmarksCountPerTag($filteringTags, $visibility ?? null);
|
||||||
|
|
||||||
|
if (static::TYPE_CLOUD === $type || 'alpha' === $sort) {
|
||||||
|
// TODO: the sorting should be handled by bookmarkService instead of the controller
|
||||||
|
alphabetical_sort($tags, false, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (static::TYPE_CLOUD === $type) {
|
||||||
|
$tags = $this->formatTagsForCloud($tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tagsUrl = [];
|
||||||
|
foreach ($tags as $tag => $value) {
|
||||||
|
$tagsUrl[escape($tag)] = urlencode((string) $tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
$searchTags = tags_array2str($filteringTags, $tagsSeparator);
|
||||||
|
$searchTags = !empty($searchTags) ? trim($searchTags, $tagsSeparator) . $tagsSeparator : '';
|
||||||
|
$searchTagsUrl = urlencode($searchTags);
|
||||||
|
$data = [
|
||||||
|
'search_tags' => escape($searchTags),
|
||||||
|
'search_tags_url' => $searchTagsUrl,
|
||||||
|
'tags' => escape($tags),
|
||||||
|
'tags_url' => $tagsUrl,
|
||||||
|
];
|
||||||
|
$this->executePageHooks('render_tag' . $type, $data, 'tag.' . $type);
|
||||||
|
$this->assignAllView($data);
|
||||||
|
|
||||||
|
$searchTags = !empty($searchTags) ? trim(str_replace($tagsSeparator, ' ', $searchTags)) . ' - ' : '';
|
||||||
|
$this->assignView(
|
||||||
|
'pagetitle',
|
||||||
|
$searchTags . t('Tag ' . $type) . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
|
||||||
|
);
|
||||||
|
|
||||||
|
return $response->write($this->render('tag.' . $type));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format the tags array for the tag cloud template.
|
||||||
|
*
|
||||||
|
* @param array<string, int> $tags List of tags as key with count as value
|
||||||
|
*
|
||||||
|
* @return mixed[] List of tags as key, with count and expected font size in a subarray
|
||||||
|
*/
|
||||||
|
protected function formatTagsForCloud(array $tags): array
|
||||||
|
{
|
||||||
|
// We sort tags alphabetically, then choose a font size according to count.
|
||||||
|
// First, find max value.
|
||||||
|
$maxCount = count($tags) > 0 ? max($tags) : 0;
|
||||||
|
$logMaxCount = $maxCount > 1 ? log($maxCount, 30) : 1;
|
||||||
|
$tagList = [];
|
||||||
|
foreach ($tags as $key => $value) {
|
||||||
|
// Tag font size scaling:
|
||||||
|
// default 15 and 30 logarithm bases affect scaling,
|
||||||
|
// 2.2 and 0.8 are arbitrary font sizes in em.
|
||||||
|
$size = log($value, 15) / $logMaxCount * 2.2 + 0.8;
|
||||||
|
$tagList[$key] = [
|
||||||
|
'count' => $value,
|
||||||
|
'size' => number_format($size, 2, '.', ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tagList;
|
||||||
|
}
|
||||||
|
}
|
120
application/front/controller/visitor/TagController.php
Normal file
120
application/front/controller/visitor/TagController.php
Normal file
|
@ -0,0 +1,120 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Front\Controller\Visitor;
|
||||||
|
|
||||||
|
use Slim\Http\Request;
|
||||||
|
use Slim\Http\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class TagController
|
||||||
|
*
|
||||||
|
* Slim controller handle tags.
|
||||||
|
*/
|
||||||
|
class TagController extends ShaarliVisitorController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Add another tag in the current search through an HTTP redirection.
|
||||||
|
*
|
||||||
|
* @param array $args Should contain `newTag` key as tag to add to current search
|
||||||
|
*/
|
||||||
|
public function addTag(Request $request, Response $response, array $args): Response
|
||||||
|
{
|
||||||
|
$newTag = $args['newTag'] ?? null;
|
||||||
|
$referer = $this->container->environment['HTTP_REFERER'] ?? null;
|
||||||
|
|
||||||
|
// In case browser does not send HTTP_REFERER, we search a single tag
|
||||||
|
if (null === $referer) {
|
||||||
|
if (null !== $newTag) {
|
||||||
|
return $this->redirect($response, '/?searchtags=' . urlencode($newTag));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirect($response, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentUrl = parse_url($referer);
|
||||||
|
parse_str($currentUrl['query'] ?? '', $params);
|
||||||
|
|
||||||
|
if (null === $newTag) {
|
||||||
|
return $response->withRedirect(($currentUrl['path'] ?? './') . '?' . http_build_query($params));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent redirection loop
|
||||||
|
if (isset($params['addtag'])) {
|
||||||
|
unset($params['addtag']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tagsSeparator = $this->container->conf->get('general.tags_separator', ' ');
|
||||||
|
// Check if this tag is already in the search query and ignore it if it is.
|
||||||
|
// Each tag is always separated by a space
|
||||||
|
$currentTags = tags_str2array($params['searchtags'] ?? '', $tagsSeparator);
|
||||||
|
|
||||||
|
$addtag = true;
|
||||||
|
foreach ($currentTags as $value) {
|
||||||
|
if ($value === $newTag) {
|
||||||
|
$addtag = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append the tag if necessary
|
||||||
|
if (true === $addtag) {
|
||||||
|
$currentTags[] = trim($newTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
$params['searchtags'] = tags_array2str($currentTags, $tagsSeparator);
|
||||||
|
|
||||||
|
// We also remove page (keeping the same page has no sense, since the results are different)
|
||||||
|
unset($params['page']);
|
||||||
|
|
||||||
|
return $response->withRedirect(($currentUrl['path'] ?? './') . '?' . http_build_query($params));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a tag from the current search through an HTTP redirection.
|
||||||
|
*
|
||||||
|
* @param array $args Should contain `tag` key as tag to remove from current search
|
||||||
|
*/
|
||||||
|
public function removeTag(Request $request, Response $response, array $args): Response
|
||||||
|
{
|
||||||
|
$referer = $this->container->environment['HTTP_REFERER'] ?? null;
|
||||||
|
|
||||||
|
// If the referrer is not provided, we can update the search, so we failback on the bookmark list
|
||||||
|
if (empty($referer)) {
|
||||||
|
return $this->redirect($response, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
$tagToRemove = $args['tag'] ?? null;
|
||||||
|
$currentUrl = parse_url($referer);
|
||||||
|
parse_str($currentUrl['query'] ?? '', $params);
|
||||||
|
|
||||||
|
if (null === $tagToRemove) {
|
||||||
|
return $response->withRedirect(($currentUrl['path'] ?? './') . '?' . http_build_query($params));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent redirection loop
|
||||||
|
if (isset($params['removetag'])) {
|
||||||
|
unset($params['removetag']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($params['searchtags'])) {
|
||||||
|
$tagsSeparator = $this->container->conf->get('general.tags_separator', ' ');
|
||||||
|
$tags = tags_str2array($params['searchtags'] ?? '', $tagsSeparator);
|
||||||
|
// Remove value from array $tags.
|
||||||
|
$tags = array_diff($tags, [$tagToRemove]);
|
||||||
|
$params['searchtags'] = tags_array2str($tags, $tagsSeparator);
|
||||||
|
|
||||||
|
if (empty($params['searchtags'])) {
|
||||||
|
unset($params['searchtags']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// We also remove page (keeping the same page has no sense, since the results are different)
|
||||||
|
unset($params['page']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$queryParams = count($params) > 0 ? '?' . http_build_query($params) : '';
|
||||||
|
|
||||||
|
return $response->withRedirect(($currentUrl['path'] ?? './') . $queryParams);
|
||||||
|
}
|
||||||
|
}
|
15
application/front/exceptions/AlreadyInstalledException.php
Normal file
15
application/front/exceptions/AlreadyInstalledException.php
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Front\Exception;
|
||||||
|
|
||||||
|
class AlreadyInstalledException extends ShaarliFrontException
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$message = t('Shaarli has already been installed. Login to edit the configuration.');
|
||||||
|
|
||||||
|
parent::__construct($message, 401);
|
||||||
|
}
|
||||||
|
}
|
9
application/front/exceptions/CantLoginException.php
Normal file
9
application/front/exceptions/CantLoginException.php
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Front\Exception;
|
||||||
|
|
||||||
|
class CantLoginException extends \Exception
|
||||||
|
{
|
||||||
|
}
|
15
application/front/exceptions/LoginBannedException.php
Normal file
15
application/front/exceptions/LoginBannedException.php
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Front\Exception;
|
||||||
|
|
||||||
|
class LoginBannedException extends ShaarliFrontException
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$message = t('You have been banned after too many failed login attempts. Try again later.');
|
||||||
|
|
||||||
|
parent::__construct($message, 401);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Front\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class OpenShaarliPasswordException
|
||||||
|
*
|
||||||
|
* Raised if the user tries to change the admin password on an open shaarli instance.
|
||||||
|
*/
|
||||||
|
class OpenShaarliPasswordException extends ShaarliFrontException
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct(t('You are not supposed to change a password on an Open Shaarli.'), 403);
|
||||||
|
}
|
||||||
|
}
|
13
application/front/exceptions/ResourcePermissionException.php
Normal file
13
application/front/exceptions/ResourcePermissionException.php
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Front\Exception;
|
||||||
|
|
||||||
|
class ResourcePermissionException extends ShaarliFrontException
|
||||||
|
{
|
||||||
|
public function __construct(string $message)
|
||||||
|
{
|
||||||
|
parent::__construct($message, 500);
|
||||||
|
}
|
||||||
|
}
|
23
application/front/exceptions/ShaarliFrontException.php
Normal file
23
application/front/exceptions/ShaarliFrontException.php
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Front\Exception;
|
||||||
|
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class ShaarliException
|
||||||
|
*
|
||||||
|
* Exception class used to defined any custom exception thrown during front rendering.
|
||||||
|
*
|
||||||
|
* @package Front\Exception
|
||||||
|
*/
|
||||||
|
class ShaarliFrontException extends \Exception
|
||||||
|
{
|
||||||
|
/** Override parent constructor to force $message and $httpCode parameters to be set. */
|
||||||
|
public function __construct(string $message, int $httpCode, Throwable $previous = null)
|
||||||
|
{
|
||||||
|
parent::__construct($message, $httpCode, $previous);
|
||||||
|
}
|
||||||
|
}
|
15
application/front/exceptions/ThumbnailsDisabledException.php
Normal file
15
application/front/exceptions/ThumbnailsDisabledException.php
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Front\Exception;
|
||||||
|
|
||||||
|
class ThumbnailsDisabledException extends ShaarliFrontException
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$message = t('Picture wall unavailable (thumbnails are disabled).');
|
||||||
|
|
||||||
|
parent::__construct($message, 400);
|
||||||
|
}
|
||||||
|
}
|
14
application/front/exceptions/UnauthorizedException.php
Normal file
14
application/front/exceptions/UnauthorizedException.php
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Front\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class UnauthorizedException
|
||||||
|
*
|
||||||
|
* Exception raised if the user tries to access a ShaarliAdminController while logged out.
|
||||||
|
*/
|
||||||
|
class UnauthorizedException extends \Exception
|
||||||
|
{
|
||||||
|
}
|
18
application/front/exceptions/WrongTokenException.php
Normal file
18
application/front/exceptions/WrongTokenException.php
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Front\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class OpenShaarliPasswordException
|
||||||
|
*
|
||||||
|
* Raised if the user tries to perform an action with an invalid XSRF token.
|
||||||
|
*/
|
||||||
|
class WrongTokenException extends ShaarliFrontException
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct(t('Wrong token.'), 403);
|
||||||
|
}
|
||||||
|
}
|
335
application/helper/ApplicationUtils.php
Normal file
335
application/helper/ApplicationUtils.php
Normal file
|
@ -0,0 +1,335 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Helper;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
use malkusch\lock\exception\LockAcquireException;
|
||||||
|
use malkusch\lock\mutex\FlockMutex;
|
||||||
|
use Shaarli\Config\ConfigManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shaarli (application) utilities
|
||||||
|
*/
|
||||||
|
class ApplicationUtils
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var string File containing the current version
|
||||||
|
*/
|
||||||
|
public static $VERSION_FILE = 'shaarli_version.php';
|
||||||
|
|
||||||
|
public static $GITHUB_URL = 'https://github.com/shaarli/Shaarli';
|
||||||
|
public static $GIT_RAW_URL = 'https://raw.githubusercontent.com/shaarli/Shaarli';
|
||||||
|
public static $GIT_BRANCHES = ['latest', 'stable'];
|
||||||
|
private static $VERSION_START_TAG = '<?php /* ';
|
||||||
|
private static $VERSION_END_TAG = ' */ ?>';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (preg_match('#HTTP/[\d\.]+ 200(?: OK)?#', $headers[0]) !== 1) {
|
||||||
|
error_log('Failed to retrieve ' . $url);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the version from a remote URL or a file.
|
||||||
|
*
|
||||||
|
* @param string $remote URL or file to fetch.
|
||||||
|
* @param int $timeout For URLs fetching.
|
||||||
|
*
|
||||||
|
* @return bool|string The version or false if it couldn't be retrieved.
|
||||||
|
*/
|
||||||
|
public static function getVersion($remote, $timeout = 2)
|
||||||
|
{
|
||||||
|
if (startsWith($remote, 'http')) {
|
||||||
|
if (($data = static::getLatestGitVersionCode($remote, $timeout)) === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!is_file($remote)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$data = file_get_contents($remote);
|
||||||
|
}
|
||||||
|
|
||||||
|
return str_replace(
|
||||||
|
[self::$VERSION_START_TAG, self::$VERSION_END_TAG, PHP_EOL],
|
||||||
|
['', '', ''],
|
||||||
|
$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'
|
||||||
|
) {
|
||||||
|
// Do not check versions for visitors
|
||||||
|
// Do not check if the user doesn't want to
|
||||||
|
// Do not check with dev version
|
||||||
|
if (!$isLoggedIn || empty($enableCheck) || $currentVersion === 'dev') {
|
||||||
|
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::getVersion(
|
||||||
|
self::$GIT_RAW_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)
|
||||||
|
*
|
||||||
|
* @return bool true on success
|
||||||
|
*
|
||||||
|
* @throws Exception the PHP version is not supported
|
||||||
|
*/
|
||||||
|
public static function checkPHPVersion($minVersion, $curVersion)
|
||||||
|
{
|
||||||
|
if (version_compare($curVersion, $minVersion) < 0) {
|
||||||
|
$msg = t(
|
||||||
|
'Your PHP version is obsolete!'
|
||||||
|
. ' Shaarli requires at least PHP %s, and thus cannot run.'
|
||||||
|
. ' Your PHP version has known security vulnerabilities and should be'
|
||||||
|
. ' updated as soon as possible.'
|
||||||
|
);
|
||||||
|
throw new Exception(sprintf($msg, $minVersion));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks Shaarli has the proper access permissions to its resources
|
||||||
|
*
|
||||||
|
* @param ConfigManager $conf Configuration Manager instance.
|
||||||
|
* @param bool $minimalMode In minimal mode we only check permissions to be able to display a template.
|
||||||
|
* Currently we only need to be able to read the theme and write in raintpl cache.
|
||||||
|
*
|
||||||
|
* @return array A list of the detected configuration issues
|
||||||
|
*/
|
||||||
|
public static function checkResourcePermissions(ConfigManager $conf, bool $minimalMode = false): array
|
||||||
|
{
|
||||||
|
$errors = [];
|
||||||
|
$rainTplDir = rtrim($conf->get('resource.raintpl_tpl'), '/');
|
||||||
|
|
||||||
|
// Check script and template directories are readable
|
||||||
|
foreach (
|
||||||
|
[
|
||||||
|
'application',
|
||||||
|
'inc',
|
||||||
|
'plugins',
|
||||||
|
$rainTplDir,
|
||||||
|
$rainTplDir . '/' . $conf->get('resource.theme'),
|
||||||
|
] as $path
|
||||||
|
) {
|
||||||
|
if (!is_readable(realpath($path))) {
|
||||||
|
$errors[] = '"' . $path . '" ' . t('directory is not readable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cache and data directories are readable and writable
|
||||||
|
if ($minimalMode) {
|
||||||
|
$folders = [
|
||||||
|
$conf->get('resource.raintpl_tmp'),
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$folders = [
|
||||||
|
$conf->get('resource.thumbnails_cache'),
|
||||||
|
$conf->get('resource.data_dir'),
|
||||||
|
$conf->get('resource.page_cache'),
|
||||||
|
$conf->get('resource.raintpl_tmp'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($folders as $path) {
|
||||||
|
if (!is_readable(realpath($path))) {
|
||||||
|
$errors[] = '"' . $path . '" ' . t('directory is not readable');
|
||||||
|
}
|
||||||
|
if (!is_writable(realpath($path))) {
|
||||||
|
$errors[] = '"' . $path . '" ' . t('directory is not writable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($minimalMode) {
|
||||||
|
return $errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check configuration files are readable and writable
|
||||||
|
foreach (
|
||||||
|
[
|
||||||
|
$conf->getConfigFileExt(),
|
||||||
|
$conf->get('resource.datastore'),
|
||||||
|
$conf->get('resource.ban_file'),
|
||||||
|
$conf->get('resource.log'),
|
||||||
|
$conf->get('resource.update_check'),
|
||||||
|
] as $path
|
||||||
|
) {
|
||||||
|
if (!is_string($path) || !is_file(realpath($path))) {
|
||||||
|
# the file may not exist yet
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_readable(realpath($path))) {
|
||||||
|
$errors[] = '"' . $path . '" ' . t('file is not readable');
|
||||||
|
}
|
||||||
|
if (!is_writable(realpath($path))) {
|
||||||
|
$errors[] = '"' . $path . '" ' . t('file is not writable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function checkDatastoreMutex(): array
|
||||||
|
{
|
||||||
|
$mutex = new FlockMutex(fopen(SHAARLI_MUTEX_FILE, 'r'), 2);
|
||||||
|
try {
|
||||||
|
$mutex->synchronized(function () {
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
} catch (LockAcquireException $e) {
|
||||||
|
$errors[] = t('Lock can not be acquired on the datastore. You might encounter concurrent access issues.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $errors ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a salted hash representing the current Shaarli version.
|
||||||
|
*
|
||||||
|
* Useful for assets browser cache.
|
||||||
|
*
|
||||||
|
* @param string $currentVersion of Shaarli
|
||||||
|
* @param string $salt User personal salt, also used for the authentication
|
||||||
|
*
|
||||||
|
* @return string version hash
|
||||||
|
*/
|
||||||
|
public static function getVersionHash($currentVersion, $salt)
|
||||||
|
{
|
||||||
|
return hash_hmac('sha256', $currentVersion, $salt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a list of PHP extensions used by Shaarli.
|
||||||
|
*
|
||||||
|
* @return array[] List of extension with following keys:
|
||||||
|
* - name: extension name
|
||||||
|
* - required: whether the extension is required to use Shaarli
|
||||||
|
* - desc: short description of extension usage in Shaarli
|
||||||
|
* - loaded: whether the extension is properly loaded or not
|
||||||
|
*/
|
||||||
|
public static function getPhpExtensionsRequirement(): array
|
||||||
|
{
|
||||||
|
$extensions = [
|
||||||
|
['name' => 'json', 'required' => true, 'desc' => t('Configuration parsing')],
|
||||||
|
['name' => 'simplexml', 'required' => true, 'desc' => t('Slim Framework (routing, etc.)')],
|
||||||
|
['name' => 'mbstring', 'required' => true, 'desc' => t('Multibyte (Unicode) string support')],
|
||||||
|
['name' => 'gd', 'required' => false, 'desc' => t('Required to use thumbnails')],
|
||||||
|
['name' => 'intl', 'required' => false, 'desc' => t('Localized text sorting (e.g. e->è->f)')],
|
||||||
|
['name' => 'curl', 'required' => false, 'desc' => t('Better retrieval of bookmark metadata and thumbnail')],
|
||||||
|
['name' => 'gettext', 'required' => false, 'desc' => t('Use the translation system in gettext mode')],
|
||||||
|
['name' => 'ldap', 'required' => false, 'desc' => t('Login using LDAP server')],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($extensions as &$extension) {
|
||||||
|
$extension['loaded'] = extension_loaded($extension['name']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $extensions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the EOL date of given PHP version. If the version is unknown,
|
||||||
|
* we return today + 2 years.
|
||||||
|
*
|
||||||
|
* @param string $fullVersion PHP version, e.g. 7.4.7
|
||||||
|
*
|
||||||
|
* @return string Date format: YYYY-MM-DD
|
||||||
|
*/
|
||||||
|
public static function getPhpEol(string $fullVersion): string
|
||||||
|
{
|
||||||
|
preg_match('/(\d+\.\d+)\.\d+/', $fullVersion, $matches);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'7.1' => '2019-12-01',
|
||||||
|
'7.2' => '2020-11-30',
|
||||||
|
'7.3' => '2021-12-06',
|
||||||
|
'7.4' => '2022-11-28',
|
||||||
|
'8.0' => '2023-12-01',
|
||||||
|
][$matches[1]] ?? (new \DateTime('+2 year'))->format('Y-m-d');
|
||||||
|
}
|
||||||
|
}
|
241
application/helper/DailyPageHelper.php
Normal file
241
application/helper/DailyPageHelper.php
Normal file
|
@ -0,0 +1,241 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Helper;
|
||||||
|
|
||||||
|
use DatePeriod;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Exception;
|
||||||
|
use Shaarli\Bookmark\Bookmark;
|
||||||
|
use Slim\Http\Request;
|
||||||
|
|
||||||
|
class DailyPageHelper
|
||||||
|
{
|
||||||
|
public const MONTH = 'month';
|
||||||
|
public const WEEK = 'week';
|
||||||
|
public const DAY = 'day';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the type of the daily to display from the HTTP request parameters
|
||||||
|
*
|
||||||
|
* @param Request $request HTTP request
|
||||||
|
*
|
||||||
|
* @return string month/week/day
|
||||||
|
*/
|
||||||
|
public static function extractRequestedType(Request $request): string
|
||||||
|
{
|
||||||
|
if ($request->getQueryParam(static::MONTH) !== null) {
|
||||||
|
return static::MONTH;
|
||||||
|
} elseif ($request->getQueryParam(static::WEEK) !== null) {
|
||||||
|
return static::WEEK;
|
||||||
|
}
|
||||||
|
|
||||||
|
return static::DAY;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts a DateTimeImmutable from provided HTTP request.
|
||||||
|
* If no parameter is provided, we rely on the creation date of the latest provided created bookmark.
|
||||||
|
* If the datastore is empty or no bookmark is provided, we use the current date.
|
||||||
|
*
|
||||||
|
* @param string $type month/week/day
|
||||||
|
* @param string|null $requestedDate Input string extracted from the request
|
||||||
|
* @param Bookmark|null $latestBookmark Latest bookmark found in the datastore (by date)
|
||||||
|
*
|
||||||
|
* @return DateTimeImmutable from input or latest bookmark.
|
||||||
|
*
|
||||||
|
* @throws Exception Type not supported.
|
||||||
|
*/
|
||||||
|
public static function extractRequestedDateTime(
|
||||||
|
string $type,
|
||||||
|
?string $requestedDate,
|
||||||
|
Bookmark $latestBookmark = null
|
||||||
|
): DateTimeImmutable {
|
||||||
|
$format = static::getFormatByType($type);
|
||||||
|
if (empty($requestedDate)) {
|
||||||
|
return $latestBookmark instanceof Bookmark
|
||||||
|
? new DateTimeImmutable($latestBookmark->getCreated()->format(\DateTime::ATOM))
|
||||||
|
: new DateTimeImmutable()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't use today's day of month (github issue #1844)
|
||||||
|
if ($type === static::MONTH) {
|
||||||
|
$format = '!' . $format;
|
||||||
|
}
|
||||||
|
|
||||||
|
// W is not supported by createFromFormat...
|
||||||
|
if ($type === static::WEEK) {
|
||||||
|
return (new DateTimeImmutable())
|
||||||
|
->setISODate((int) substr($requestedDate, 0, 4), (int) substr($requestedDate, 4, 2))
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DateTimeImmutable::createFromFormat($format, $requestedDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the DateTime format used by provided type
|
||||||
|
* Examples:
|
||||||
|
* - day: 20201016 (<year><month><day>)
|
||||||
|
* - week: 202041 (<year><week number>)
|
||||||
|
* - month: 202010 (<year><month>)
|
||||||
|
*
|
||||||
|
* @param string $type month/week/day
|
||||||
|
*
|
||||||
|
* @return string DateTime compatible format
|
||||||
|
*
|
||||||
|
* @see https://www.php.net/manual/en/datetime.format.php
|
||||||
|
*
|
||||||
|
* @throws Exception Type not supported.
|
||||||
|
*/
|
||||||
|
public static function getFormatByType(string $type): string
|
||||||
|
{
|
||||||
|
switch ($type) {
|
||||||
|
case static::MONTH:
|
||||||
|
return 'Ym';
|
||||||
|
case static::WEEK:
|
||||||
|
return 'YW';
|
||||||
|
case static::DAY:
|
||||||
|
return 'Ymd';
|
||||||
|
default:
|
||||||
|
throw new Exception('Unsupported daily format type');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the first DateTime of the time period depending on given datetime and type.
|
||||||
|
* Note: DateTimeImmutable is required because we rely heavily on DateTime->modify() syntax
|
||||||
|
* and we don't want to alter original datetime.
|
||||||
|
*
|
||||||
|
* @param string $type month/week/day
|
||||||
|
* @param DateTimeImmutable $requested DateTime extracted from request input
|
||||||
|
* (should come from extractRequestedDateTime)
|
||||||
|
*
|
||||||
|
* @return \DateTimeInterface First DateTime of the time period
|
||||||
|
*
|
||||||
|
* @throws Exception Type not supported.
|
||||||
|
*/
|
||||||
|
public static function getStartDateTimeByType(string $type, DateTimeImmutable $requested): \DateTimeInterface
|
||||||
|
{
|
||||||
|
switch ($type) {
|
||||||
|
case static::MONTH:
|
||||||
|
return $requested->modify('first day of this month midnight');
|
||||||
|
case static::WEEK:
|
||||||
|
return $requested->modify('Monday this week midnight');
|
||||||
|
case static::DAY:
|
||||||
|
return $requested->modify('Today midnight');
|
||||||
|
default:
|
||||||
|
throw new Exception('Unsupported daily format type');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the last DateTime of the time period depending on given datetime and type.
|
||||||
|
* Note: DateTimeImmutable is required because we rely heavily on DateTime->modify() syntax
|
||||||
|
* and we don't want to alter original datetime.
|
||||||
|
*
|
||||||
|
* @param string $type month/week/day
|
||||||
|
* @param DateTimeImmutable $requested DateTime extracted from request input
|
||||||
|
* (should come from extractRequestedDateTime)
|
||||||
|
*
|
||||||
|
* @return \DateTimeInterface Last DateTime of the time period
|
||||||
|
*
|
||||||
|
* @throws Exception Type not supported.
|
||||||
|
*/
|
||||||
|
public static function getEndDateTimeByType(string $type, DateTimeImmutable $requested): \DateTimeInterface
|
||||||
|
{
|
||||||
|
switch ($type) {
|
||||||
|
case static::MONTH:
|
||||||
|
return $requested->modify('last day of this month 23:59:59');
|
||||||
|
case static::WEEK:
|
||||||
|
return $requested->modify('Sunday this week 23:59:59');
|
||||||
|
case static::DAY:
|
||||||
|
return $requested->modify('Today 23:59:59');
|
||||||
|
default:
|
||||||
|
throw new Exception('Unsupported daily format type');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get localized description of the time period depending on given datetime and type.
|
||||||
|
* Example: for a month period, it returns `October, 2020`.
|
||||||
|
*
|
||||||
|
* @param string $type month/week/day
|
||||||
|
* @param \DateTimeImmutable $requested DateTime extracted from request input
|
||||||
|
* (should come from extractRequestedDateTime)
|
||||||
|
* @param bool $includeRelative Include relative date description (today, yesterday, etc.)
|
||||||
|
*
|
||||||
|
* @return string Localized time period description
|
||||||
|
*
|
||||||
|
* @throws Exception Type not supported.
|
||||||
|
*/
|
||||||
|
public static function getDescriptionByType(
|
||||||
|
string $type,
|
||||||
|
\DateTimeImmutable $requested,
|
||||||
|
bool $includeRelative = true
|
||||||
|
): string {
|
||||||
|
switch ($type) {
|
||||||
|
case static::MONTH:
|
||||||
|
return $requested->format('F') . ', ' . $requested->format('Y');
|
||||||
|
case static::WEEK:
|
||||||
|
$requested = $requested->modify('Monday this week');
|
||||||
|
return t('Week') . ' ' . $requested->format('W') . ' (' . format_date($requested, false) . ')';
|
||||||
|
case static::DAY:
|
||||||
|
$out = '';
|
||||||
|
if ($includeRelative && $requested->format('Ymd') === date('Ymd')) {
|
||||||
|
$out = t('Today') . ' - ';
|
||||||
|
} elseif ($includeRelative && $requested->format('Ymd') === date('Ymd', strtotime('-1 days'))) {
|
||||||
|
$out = t('Yesterday') . ' - ';
|
||||||
|
}
|
||||||
|
return $out . format_date($requested, false);
|
||||||
|
default:
|
||||||
|
throw new Exception('Unsupported daily format type');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the number of items to display in the RSS feed depending on the given type.
|
||||||
|
*
|
||||||
|
* @param string $type month/week/day
|
||||||
|
*
|
||||||
|
* @return int number of elements
|
||||||
|
*
|
||||||
|
* @throws Exception Type not supported.
|
||||||
|
*/
|
||||||
|
public static function getRssLengthByType(string $type): int
|
||||||
|
{
|
||||||
|
switch ($type) {
|
||||||
|
case static::MONTH:
|
||||||
|
return 12; // 1 year
|
||||||
|
case static::WEEK:
|
||||||
|
return 26; // ~6 months
|
||||||
|
case static::DAY:
|
||||||
|
return 30; // ~1 month
|
||||||
|
default:
|
||||||
|
throw new Exception('Unsupported daily format type');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the number of items to display in the RSS feed depending on the given type.
|
||||||
|
*
|
||||||
|
* @param string $type month/week/day
|
||||||
|
* @param ?DateTimeImmutable $requested Currently only used for UT
|
||||||
|
*
|
||||||
|
* @return DatePeriod number of elements
|
||||||
|
*
|
||||||
|
* @throws Exception Type not supported.
|
||||||
|
*/
|
||||||
|
public static function getCacheDatePeriodByType(string $type, DateTimeImmutable $requested = null): DatePeriod
|
||||||
|
{
|
||||||
|
$requested = $requested ?? new DateTimeImmutable();
|
||||||
|
|
||||||
|
return new DatePeriod(
|
||||||
|
static::getStartDateTimeByType($type, $requested),
|
||||||
|
new \DateInterval('P1D'),
|
||||||
|
static::getEndDateTimeByType($type, $requested)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
140
application/helper/FileUtils.php
Normal file
140
application/helper/FileUtils.php
Normal file
|
@ -0,0 +1,140 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Helper;
|
||||||
|
|
||||||
|
use Shaarli\Exceptions\IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class FileUtils
|
||||||
|
*
|
||||||
|
* Utility class for file manipulation.
|
||||||
|
*/
|
||||||
|
class FileUtils
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected static $phpPrefix = '<?php /* ';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected static $phpSuffix = ' */ ?>';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write data into a file (Shaarli database format).
|
||||||
|
* The data is stored in a PHP file, as a comment, in compressed base64 format.
|
||||||
|
*
|
||||||
|
* The file will be created if it doesn't exist.
|
||||||
|
*
|
||||||
|
* @param string $file File path.
|
||||||
|
* @param mixed $content Content to write.
|
||||||
|
*
|
||||||
|
* @return int|bool Number of bytes written or false if it fails.
|
||||||
|
*
|
||||||
|
* @throws IOException The destination file can't be written.
|
||||||
|
*/
|
||||||
|
public static function writeFlatDB($file, $content)
|
||||||
|
{
|
||||||
|
if (is_file($file) && !is_writeable($file)) {
|
||||||
|
// The datastore exists but is not writeable
|
||||||
|
throw new IOException($file);
|
||||||
|
} elseif (!is_file($file) && !is_writeable(dirname($file))) {
|
||||||
|
// The datastore does not exist and its parent directory is not writeable
|
||||||
|
throw new IOException(dirname($file));
|
||||||
|
}
|
||||||
|
|
||||||
|
return file_put_contents(
|
||||||
|
$file,
|
||||||
|
self::$phpPrefix . base64_encode(gzdeflate(serialize($content))) . self::$phpSuffix
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read data from a file containing Shaarli database format content.
|
||||||
|
*
|
||||||
|
* If the file isn't readable or doesn't exist, default data will be returned.
|
||||||
|
*
|
||||||
|
* @param string $file File path.
|
||||||
|
* @param mixed $default The default value to return if the file isn't readable.
|
||||||
|
*
|
||||||
|
* @return mixed The content unserialized, or default if the file isn't readable, or false if it fails.
|
||||||
|
*/
|
||||||
|
public static function readFlatDB($file, $default = null)
|
||||||
|
{
|
||||||
|
// Note that gzinflate is faster than gzuncompress.
|
||||||
|
// See: http://www.php.net/manual/en/function.gzdeflate.php#96439
|
||||||
|
if (!is_readable($file)) {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = file_get_contents($file);
|
||||||
|
if ($data == '') {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
return unserialize(
|
||||||
|
gzinflate(
|
||||||
|
base64_decode(
|
||||||
|
substr($data, strlen(self::$phpPrefix), -strlen(self::$phpSuffix))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively deletes a folder content, and deletes itself optionally.
|
||||||
|
* If an excluded file is found, folders won't be deleted.
|
||||||
|
*
|
||||||
|
* Additional security: raise an exception if it tries to delete a folder outside of Shaarli directory.
|
||||||
|
*
|
||||||
|
* @param string $path
|
||||||
|
* @param bool $selfDelete Delete the provided folder if true, only its content if false.
|
||||||
|
* @param array $exclude
|
||||||
|
*/
|
||||||
|
public static function clearFolder(string $path, bool $selfDelete, array $exclude = []): bool
|
||||||
|
{
|
||||||
|
$skipped = false;
|
||||||
|
|
||||||
|
if (!is_dir($path)) {
|
||||||
|
throw new IOException(t('Provided path is not a directory.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!static::isPathInShaarliFolder($path)) {
|
||||||
|
throw new IOException(t('Trying to delete a folder outside of Shaarli path.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (new \DirectoryIterator($path) as $file) {
|
||||||
|
if ($file->isDot()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($file->getBasename(), $exclude, true)) {
|
||||||
|
$skipped = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($file->isFile()) {
|
||||||
|
unlink($file->getPathname());
|
||||||
|
} elseif ($file->isDir()) {
|
||||||
|
$skipped = static::clearFolder($file->getRealPath(), true, $exclude) || $skipped;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($selfDelete && !$skipped) {
|
||||||
|
rmdir($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $skipped;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks that the given path is inside Shaarli directory.
|
||||||
|
*/
|
||||||
|
public static function isPathInShaarliFolder(string $path): bool
|
||||||
|
{
|
||||||
|
$rootDirectory = dirname(dirname(dirname(__FILE__)));
|
||||||
|
|
||||||
|
return strpos(realpath($path), $rootDirectory) !== false;
|
||||||
|
}
|
||||||
|
}
|
49
application/http/HttpAccess.php
Normal file
49
application/http/HttpAccess.php
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Http;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class HttpAccess
|
||||||
|
*
|
||||||
|
* This is mostly an OOP wrapper for HTTP functions defined in `HttpUtils`.
|
||||||
|
* It is used as dependency injection in Shaarli's container.
|
||||||
|
*
|
||||||
|
* @package Shaarli\Http
|
||||||
|
*/
|
||||||
|
class HttpAccess
|
||||||
|
{
|
||||||
|
public function getHttpResponse(
|
||||||
|
$url,
|
||||||
|
$timeout = 30,
|
||||||
|
$maxBytes = 4194304,
|
||||||
|
$curlHeaderFunction = null,
|
||||||
|
$curlWriteFunction = null
|
||||||
|
) {
|
||||||
|
return get_http_response($url, $timeout, $maxBytes, $curlHeaderFunction, $curlWriteFunction);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCurlDownloadCallback(
|
||||||
|
&$charset,
|
||||||
|
&$title,
|
||||||
|
&$description,
|
||||||
|
&$keywords,
|
||||||
|
$retrieveDescription,
|
||||||
|
$tagsSeparator
|
||||||
|
) {
|
||||||
|
return get_curl_download_callback(
|
||||||
|
$charset,
|
||||||
|
$title,
|
||||||
|
$description,
|
||||||
|
$keywords,
|
||||||
|
$retrieveDescription,
|
||||||
|
$tagsSeparator
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCurlHeaderCallback(&$charset, $curlGetInfo = 'curl_getinfo')
|
||||||
|
{
|
||||||
|
return get_curl_header_callback($charset, $curlGetInfo);
|
||||||
|
}
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue